@p0security/cli 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ const aws_1 = require("./aws");
20
20
  const login_1 = require("./login");
21
21
  const ls_1 = require("./ls");
22
22
  const request_1 = require("./request");
23
+ const scp_1 = require("./scp");
23
24
  const ssh_1 = require("./ssh");
24
25
  const typescript_1 = require("typescript");
25
26
  const yargs_1 = __importDefault(require("yargs"));
@@ -30,6 +31,7 @@ const commands = [
30
31
  ls_1.lsCommand,
31
32
  request_1.requestCommand,
32
33
  ssh_1.sshCommand,
34
+ scp_1.scpCommand,
33
35
  ];
34
36
  exports.cli = commands
35
37
  .reduce((m, c) => c(m), (0, yargs_1.default)((0, helpers_1.hideBin)(process.argv)))
@@ -0,0 +1,3 @@
1
+ import { ScpCommandArgs } from "./shared";
2
+ import yargs from "yargs";
3
+ export declare const scpCommand: (yargs: yargs.Argv<{}>) => yargs.Argv<ScpCommandArgs>;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.scpCommand = void 0;
16
+ /** Copyright © 2024-present P0 Security
17
+
18
+ This file is part of @p0security/cli
19
+
20
+ @p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.
21
+
22
+ @p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
23
+
24
+ You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
25
+ **/
26
+ const api_1 = require("../drivers/api");
27
+ const auth_1 = require("../drivers/auth");
28
+ const firestore_1 = require("../drivers/firestore");
29
+ const ssm_1 = require("../plugins/aws/ssm");
30
+ const shared_1 = require("./shared");
31
+ const node_forge_1 = __importDefault(require("node-forge"));
32
+ const scpCommand = (yargs) => yargs.command("scp <source> <destination>",
33
+ // TODO (ENG-1930): support scp across multiple remote hosts.
34
+ "SCP copies files between a local and remote host.", (yargs) => yargs
35
+ .positional("source", {
36
+ type: "string",
37
+ demandOption: true,
38
+ description: "Format [hostname:]file",
39
+ })
40
+ .positional("destination", {
41
+ type: "string",
42
+ demandOption: true,
43
+ description: "Format [hostname:]file",
44
+ })
45
+ .option("r", {
46
+ alias: "recursive",
47
+ type: "boolean",
48
+ describe: "Recursively copy entire directories",
49
+ })
50
+ .option("reason", {
51
+ describe: "Reason access is needed",
52
+ type: "string",
53
+ })
54
+ .option("account", {
55
+ type: "string",
56
+ describe: "The account on which the instance is located",
57
+ })
58
+ .option("sudo", {
59
+ type: "boolean",
60
+ describe: "Add user to sudoers file",
61
+ }), (0, firestore_1.guard)(scpAction));
62
+ exports.scpCommand = scpCommand;
63
+ /** Transfers files between a local and remote hosts using SSH.
64
+ *
65
+ * Implicitly gains access to the SSH resource if required.
66
+ */
67
+ const scpAction = (args) => __awaiter(void 0, void 0, void 0, function* () {
68
+ const authn = yield (0, auth_1.authenticate)();
69
+ const host = getHostIdentifier(args.source, args.destination);
70
+ if (!host) {
71
+ throw "Could not determine host identifier from source or destination";
72
+ }
73
+ const requestId = yield (0, shared_1.provisionRequest)(authn, args, host);
74
+ if (!requestId) {
75
+ throw "Server did not return a request id. Please contact support@p0.dev for assistance.";
76
+ }
77
+ const { publicKey, privateKey } = createKeyPair();
78
+ const result = yield (0, api_1.fetchExerciseGrant)(authn, {
79
+ requestId,
80
+ destination: host,
81
+ publicKey,
82
+ });
83
+ // replace the host with the linuxUserName@instanceId
84
+ const { source, destination } = replaceHostWithInstance(result, args);
85
+ yield (0, ssm_1.scp)(authn, result, Object.assign(Object.assign({}, args), { source,
86
+ destination }), privateKey);
87
+ });
88
+ /** If a path is not explicitly local, use this pattern to determine if it's remote */
89
+ const REMOTE_PATTERN_COLON = /^([^:]+:)(.*)$/; // Matches host:[path]
90
+ // TODO (ENG-1931): Improve remote host and local host checking for SCP requests
91
+ const isExplicitlyRemote = (path) => {
92
+ return REMOTE_PATTERN_COLON.test(path);
93
+ };
94
+ const getHostIdentifier = (source, destination) => {
95
+ // the instance is contained in the source or destination and is always delimited by a colon.
96
+ const isSourceRemote = isExplicitlyRemote(source);
97
+ const isDestinationRemote = isExplicitlyRemote(destination);
98
+ const remote = isSourceRemote ? source : destination;
99
+ if (isSourceRemote != isDestinationRemote) {
100
+ return remote.split(":")[0];
101
+ }
102
+ // TODO (ENG-1930): support scp across multiple remote hosts.
103
+ throw "Exactly one host (source or destination) must be remote.";
104
+ };
105
+ const replaceHostWithInstance = (result, args) => {
106
+ let source = args.source;
107
+ let destination = args.destination;
108
+ if (isExplicitlyRemote(source)) {
109
+ source = `${result.linuxUserName}@${result.instance.id}:${source.split(":")[1]}`;
110
+ }
111
+ if (isExplicitlyRemote(destination)) {
112
+ destination = `${result.linuxUserName}@${result.instance.id}:${destination.split(":")[1]}`;
113
+ }
114
+ return { source, destination };
115
+ };
116
+ const createKeyPair = () => {
117
+ const rsaKeyPair = node_forge_1.default.pki.rsa.generateKeyPair({ bits: 2048 });
118
+ const privateKey = node_forge_1.default.pki.privateKeyToPem(rsaKeyPair.privateKey);
119
+ const publicKey = node_forge_1.default.ssh.publicKeyToOpenSSH(rsaKeyPair.publicKey);
120
+ return { publicKey, privateKey };
121
+ };
@@ -0,0 +1,34 @@
1
+ import { Authn } from "../types/identity";
2
+ import yargs from "yargs";
3
+ export declare type ExerciseGrantResponse = {
4
+ documentName: string;
5
+ linuxUserName: string;
6
+ ok: true;
7
+ role: string;
8
+ instance: {
9
+ arn: string;
10
+ accountId: string;
11
+ region: string;
12
+ id: string;
13
+ name?: string;
14
+ };
15
+ };
16
+ export declare type BaseSshCommandArgs = {
17
+ sudo?: boolean;
18
+ reason?: string;
19
+ account?: string;
20
+ };
21
+ export declare type ScpCommandArgs = BaseSshCommandArgs & {
22
+ source: string;
23
+ destination: string;
24
+ recursive?: boolean;
25
+ };
26
+ export declare type SshCommandArgs = BaseSshCommandArgs & {
27
+ sudo?: boolean;
28
+ destination: string;
29
+ L?: string;
30
+ N?: boolean;
31
+ arguments: string[];
32
+ command?: string;
33
+ };
34
+ export declare const provisionRequest: (authn: Authn, args: yargs.ArgumentsCamelCase<BaseSshCommandArgs>, destination: string) => Promise<string | undefined>;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.provisionRequest = void 0;
13
+ /** Copyright © 2024-present P0 Security
14
+
15
+ This file is part of @p0security/cli
16
+
17
+ @p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.
18
+
19
+ @p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
20
+
21
+ You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
22
+ **/
23
+ const firestore_1 = require("../drivers/firestore");
24
+ const stdio_1 = require("../drivers/stdio");
25
+ const request_1 = require("../types/request");
26
+ const request_2 = require("./request");
27
+ const firestore_2 = require("firebase/firestore");
28
+ const lodash_1 = require("lodash");
29
+ /** Maximum amount of time to wait after access is approved to wait for access
30
+ * to be configured
31
+ */
32
+ const GRANT_TIMEOUT_MILLIS = 60e3;
33
+ const validateSshInstall = (authn) => __awaiter(void 0, void 0, void 0, function* () {
34
+ var _a;
35
+ const configDoc = yield (0, firestore_2.getDoc)((0, firestore_1.doc)(`o/${authn.identity.org.tenantId}/integrations/ssh`));
36
+ const configItems = (_a = configDoc.data()) === null || _a === void 0 ? void 0 : _a["iam-write"];
37
+ const items = Object.entries(configItems !== null && configItems !== void 0 ? configItems : {}).filter(([key, value]) => value.state == "installed" && key.startsWith("aws"));
38
+ if (items.length === 0) {
39
+ throw "This organization is not configured for SSH access via the P0 CLI";
40
+ }
41
+ });
42
+ /** Waits until P0 grants access for a request */
43
+ const waitForProvisioning = (authn, requestId) => __awaiter(void 0, void 0, void 0, function* () {
44
+ let cancel = undefined;
45
+ const result = yield new Promise((resolve, reject) => {
46
+ let isResolved = false;
47
+ const unsubscribe = (0, firestore_2.onSnapshot)((0, firestore_1.doc)(`o/${authn.identity.org.tenantId}/permission-requests/${requestId}`), (snap) => {
48
+ const data = snap.data();
49
+ if (!data)
50
+ return;
51
+ if (request_1.DONE_STATUSES.includes(data.status)) {
52
+ resolve(data);
53
+ }
54
+ else if (request_1.DENIED_STATUSES.includes(data.status)) {
55
+ reject("Your access request was denied");
56
+ }
57
+ else if (request_1.ERROR_STATUSES.includes(data.status)) {
58
+ reject("Your access request encountered an error (see Slack for details)");
59
+ }
60
+ else {
61
+ return;
62
+ }
63
+ isResolved = true;
64
+ unsubscribe();
65
+ });
66
+ // Skip timeout in test; it holds a ref longer than the test lasts
67
+ if (process.env.NODE_ENV === "test")
68
+ return;
69
+ cancel = setTimeout(() => {
70
+ if (!isResolved) {
71
+ unsubscribe();
72
+ reject("Timeout awaiting SSH access grant");
73
+ }
74
+ }, GRANT_TIMEOUT_MILLIS);
75
+ });
76
+ clearTimeout(cancel);
77
+ return result;
78
+ });
79
+ const provisionRequest = (authn, args, destination) => __awaiter(void 0, void 0, void 0, function* () {
80
+ yield validateSshInstall(authn);
81
+ const response = yield (0, request_2.request)(Object.assign(Object.assign({}, (0, lodash_1.pick)(args, "$0", "_")), { arguments: [
82
+ "ssh",
83
+ "session",
84
+ destination,
85
+ // Prefix is required because the backend uses it to determine that this is an AWS request
86
+ "--provider",
87
+ "aws",
88
+ ...(args.sudo || args.command === "sudo" ? ["--sudo"] : []),
89
+ ...(args.reason ? ["--reason", args.reason] : []),
90
+ ...(args.account ? ["--account", args.account] : []),
91
+ ], wait: true }), authn, { message: "approval-required" });
92
+ if (!response) {
93
+ (0, stdio_1.print2)("Did not receive access ID from server");
94
+ return;
95
+ }
96
+ const { id, isPreexisting } = response;
97
+ if (!isPreexisting)
98
+ (0, stdio_1.print2)("Waiting for access to be provisioned");
99
+ yield waitForProvisioning(authn, id);
100
+ return id;
101
+ });
102
+ exports.provisionRequest = provisionRequest;
@@ -1,12 +1,3 @@
1
+ import { SshCommandArgs } from "./shared";
1
2
  import yargs from "yargs";
2
- export declare type SshCommandArgs = {
3
- destination: string;
4
- command?: string;
5
- L?: string;
6
- N?: boolean;
7
- arguments: string[];
8
- sudo?: boolean;
9
- reason?: string;
10
- account?: string;
11
- };
12
3
  export declare const sshCommand: (yargs: yargs.Argv<{}>) => yargs.Argv<SshCommandArgs>;
@@ -20,20 +20,13 @@ This file is part of @p0security/cli
20
20
 
21
21
  You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
22
22
  **/
23
+ const api_1 = require("../drivers/api");
23
24
  const auth_1 = require("../drivers/auth");
24
25
  const firestore_1 = require("../drivers/firestore");
25
- const stdio_1 = require("../drivers/stdio");
26
26
  const ssm_1 = require("../plugins/aws/ssm");
27
- const request_1 = require("../types/request");
28
- const request_2 = require("./request");
29
- const firestore_2 = require("firebase/firestore");
30
- const lodash_1 = require("lodash");
27
+ const shared_1 = require("./shared");
31
28
  // Matches strings with the pattern "digits:digits" (e.g. 1234:5678)
32
29
  const LOCAL_PORT_FORWARD_PATTERN = /^\d+:\d+$/;
33
- /** Maximum amount of time to wait after access is approved to wait for access
34
- * to be configured
35
- */
36
- const GRANT_TIMEOUT_MILLIS = 60e3;
37
30
  const sshCommand = (yargs) => yargs.command("ssh <destination> [command [arguments..]]", "SSH into a virtual machine", (yargs) => yargs
38
31
  .positional("destination", {
39
32
  type: "string",
@@ -79,53 +72,6 @@ const sshCommand = (yargs) => yargs.command("ssh <destination> [command [argumen
79
72
  describe: "The account on which the instance is located",
80
73
  }), (0, firestore_1.guard)(ssh));
81
74
  exports.sshCommand = sshCommand;
82
- const validateSshInstall = (authn) => __awaiter(void 0, void 0, void 0, function* () {
83
- var _a;
84
- const configDoc = yield (0, firestore_2.getDoc)((0, firestore_1.doc)(`o/${authn.identity.org.tenantId}/integrations/ssh`));
85
- const configItems = (_a = configDoc.data()) === null || _a === void 0 ? void 0 : _a["iam-write"];
86
- const items = Object.entries(configItems !== null && configItems !== void 0 ? configItems : {}).filter(([key, value]) => value.state == "installed" && key.startsWith("aws"));
87
- if (items.length === 0) {
88
- throw "This organization is not configured for SSH access via the P0 CLI";
89
- }
90
- });
91
- // TODO: Move this to a shared utility
92
- /** Waits until P0 grants access for a request */
93
- const waitForProvisioning = (authn, requestId) => __awaiter(void 0, void 0, void 0, function* () {
94
- let cancel = undefined;
95
- const result = yield new Promise((resolve, reject) => {
96
- let isResolved = false;
97
- const unsubscribe = (0, firestore_2.onSnapshot)((0, firestore_1.doc)(`o/${authn.identity.org.tenantId}/permission-requests/${requestId}`), (snap) => {
98
- const data = snap.data();
99
- if (!data)
100
- return;
101
- if (request_1.DONE_STATUSES.includes(data.status)) {
102
- resolve(data);
103
- }
104
- else if (request_1.DENIED_STATUSES.includes(data.status)) {
105
- reject("Your access request was denied");
106
- }
107
- else if (request_1.ERROR_STATUSES.includes(data.status)) {
108
- reject("Your access request encountered an error (see Slack for details)");
109
- }
110
- else {
111
- return;
112
- }
113
- isResolved = true;
114
- unsubscribe();
115
- });
116
- // Skip timeout in test; it holds a ref longer than the test lasts
117
- if (process.env.NODE_ENV === "test")
118
- return;
119
- cancel = setTimeout(() => {
120
- if (!isResolved) {
121
- unsubscribe();
122
- reject("Timeout awaiting SSH access grant");
123
- }
124
- }, GRANT_TIMEOUT_MILLIS);
125
- });
126
- clearTimeout(cancel);
127
- return result;
128
- });
129
75
  /** Connect to an SSH backend
130
76
  *
131
77
  * Implicitly gains access to the SSH resource if required.
@@ -136,37 +82,14 @@ const waitForProvisioning = (authn, requestId) => __awaiter(void 0, void 0, void
136
82
  const ssh = (args) => __awaiter(void 0, void 0, void 0, function* () {
137
83
  // Prefix is required because the backend uses it to determine that this is an AWS request
138
84
  const authn = yield (0, auth_1.authenticate)();
139
- yield validateSshInstall(authn);
140
- const response = yield (0, request_2.request)(Object.assign(Object.assign({}, (0, lodash_1.pick)(args, "$0", "_")), { arguments: [
141
- "ssh",
142
- "session",
143
- args.destination,
144
- "--provider",
145
- "aws",
146
- ...(args.sudo || args.command === "sudo" ? ["--sudo"] : []),
147
- ...(args.reason ? ["--reason", args.reason] : []),
148
- ...(args.account ? ["--account", args.account] : []),
149
- ], wait: true }), authn, { message: "approval-required" });
150
- if (!response) {
151
- (0, stdio_1.print2)("Did not receive access ID from server");
152
- return;
85
+ const destination = args.destination;
86
+ const requestId = yield (0, shared_1.provisionRequest)(authn, args, destination);
87
+ if (!requestId) {
88
+ throw "Server did not return a request id. Please contact support@p0.dev for assistance.";
153
89
  }
154
- const { id, isPreexisting, event } = response;
155
- if (!isPreexisting)
156
- (0, stdio_1.print2)("Waiting for access to be provisioned");
157
- /**
158
- * TODO TECH-DEBT ENG-1813:
159
- * We use the id and waitForProvisioning to find the permission request document which has
160
- * critical data, such as the document name and generated role, that we need to build up a
161
- * viable SSM request.
162
- *
163
- * Replacing the permission with event.permission is necessary when trying to connect to an
164
- * instance which has been granted approval through it's group. The event.permission object
165
- * will contain details about the specific instance we are trying to connect to such as the
166
- * instance id. Without an instance id, which an SSH group permission request document does
167
- * not contain we cannot construct a valid SSM command.
168
- */
169
- const requestData = yield waitForProvisioning(authn, id);
170
- const requestWithId = Object.assign(Object.assign({}, requestData), { id, permission: event.permission });
171
- yield (0, ssm_1.ssm)(authn, requestWithId, args);
90
+ const result = yield (0, api_1.fetchExerciseGrant)(authn, {
91
+ requestId,
92
+ destination,
93
+ });
94
+ yield (0, ssm_1.ssm)(authn, result, args);
172
95
  });
@@ -1,3 +1,20 @@
1
+ /** Copyright © 2024-present P0 Security
2
+
3
+ This file is part of @p0security/cli
4
+
5
+ @p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.
6
+
7
+ @p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
8
+
9
+ You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
10
+ **/
11
+ import { ExerciseGrantResponse } from "../commands/shared";
1
12
  import { Authn } from "../types/identity";
2
13
  import yargs from "yargs";
3
14
  export declare const fetchCommand: <T>(authn: Authn, args: yargs.ArgumentsCamelCase, argv: string[]) => Promise<T>;
15
+ export declare const fetchExerciseGrant: (authn: Authn, args: {
16
+ requestId: string;
17
+ destination: string;
18
+ publicKey?: string;
19
+ }) => Promise<ExerciseGrantResponse>;
20
+ export declare const baseFetch: <T>(authn: Authn, url: string, method: string, body: string) => Promise<T>;
@@ -32,32 +32,32 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
32
32
  });
33
33
  };
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
- exports.fetchCommand = void 0;
36
- /** Copyright © 2024-present P0 Security
37
-
38
- This file is part of @p0security/cli
39
-
40
- @p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.
41
-
42
- @p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
43
-
44
- You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
45
- **/
35
+ exports.baseFetch = exports.fetchExerciseGrant = exports.fetchCommand = void 0;
46
36
  const env_1 = require("../drivers/env");
47
37
  const path = __importStar(require("node:path"));
48
- const commandUrl = (tenant) => `${env_1.config.appUrl}/o/${tenant}/command/`;
38
+ const tenantUrl = (tenant) => `${env_1.config.appUrl}/o/${tenant}`;
39
+ const commandUrl = (tenant) => `${tenantUrl(tenant)}/command/`;
40
+ const exerciseGrantUrl = (tenant) => `${tenantUrl(tenant)}/exercise-grant/`;
49
41
  const fetchCommand = (authn, args, argv) => __awaiter(void 0, void 0, void 0, function* () {
42
+ return (0, exports.baseFetch)(authn, commandUrl(authn.identity.org.slug), "POST", JSON.stringify({
43
+ argv,
44
+ scriptName: path.basename(args.$0),
45
+ }));
46
+ });
47
+ exports.fetchCommand = fetchCommand;
48
+ const fetchExerciseGrant = (authn, args) => __awaiter(void 0, void 0, void 0, function* () {
49
+ return (0, exports.baseFetch)(authn, exerciseGrantUrl(authn.identity.org.slug), "POST", JSON.stringify(args));
50
+ });
51
+ exports.fetchExerciseGrant = fetchExerciseGrant;
52
+ const baseFetch = (authn, url, method, body) => __awaiter(void 0, void 0, void 0, function* () {
50
53
  const token = yield authn.userCredential.user.getIdToken();
51
- const response = yield fetch(commandUrl(authn.identity.org.slug), {
52
- method: "POST",
54
+ const response = yield fetch(url, {
55
+ method,
53
56
  headers: {
54
57
  authorization: `Bearer ${token}`,
55
58
  "Content-Type": "application/json",
56
59
  },
57
- body: JSON.stringify({
58
- argv,
59
- scriptName: path.basename(args.$0),
60
- }),
60
+ body,
61
61
  });
62
62
  const text = yield response.text();
63
63
  const data = JSON.parse(text);
@@ -66,4 +66,4 @@ const fetchCommand = (authn, args, argv) => __awaiter(void 0, void 0, void 0, fu
66
66
  }
67
67
  return data;
68
68
  });
69
- exports.fetchCommand = fetchCommand;
69
+ exports.baseFetch = baseFetch;
@@ -8,11 +8,8 @@ This file is part of @p0security/cli
8
8
 
9
9
  You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
10
10
  **/
11
- import { SshCommandArgs } from "../../../commands/ssh";
11
+ import { ExerciseGrantResponse, ScpCommandArgs, SshCommandArgs } from "../../../commands/shared";
12
12
  import { Authn } from "../../../types/identity";
13
- import { Request } from "../../../types/request";
14
- import { AwsSsh } from "../types";
15
13
  /** Connect to an SSH backend using AWS Systems Manager (SSM) */
16
- export declare const ssm: (authn: Authn, request: Request<AwsSsh> & {
17
- id: string;
18
- }, args: SshCommandArgs) => Promise<void>;
14
+ export declare const ssm: (authn: Authn, request: ExerciseGrantResponse, args: SshCommandArgs) => Promise<void>;
15
+ export declare const scp: (authn: Authn, data: ExerciseGrantResponse, args: ScpCommandArgs, privateKey: string) => Promise<void>;
@@ -12,7 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.ssm = void 0;
15
+ exports.scp = exports.ssm = void 0;
16
16
  const stdio_1 = require("../../../drivers/stdio");
17
17
  const aws_1 = require("../../okta/aws");
18
18
  const install_1 = require("./install");
@@ -23,6 +23,14 @@ const STARTING_SESSION_MESSAGE = /Starting session with SessionId: (.*)/;
23
23
  /** Matches the error message that AWS SSM print1 when access is not propagated */
24
24
  // Note that the resource will randomly be either the SSM document or the EC2 instance
25
25
  const UNPROVISIONED_ACCESS_MESSAGE = /An error occurred \(AccessDeniedException\) when calling the StartSession operation: User: arn:aws:sts::.*:assumed-role\/P0GrantsRole.* is not authorized to perform: ssm:StartSession on resource: arn:aws:.*:.*:.* because no identity-based policy allows the ssm:StartSession action/;
26
+ /**
27
+ * Matches the following error messages that AWS SSM print1 when ssh authorized
28
+ * key access hasn't propagated to the instance yet.
29
+ * - Connection closed by UNKNOWN port 65535
30
+ * - scp: Connection closed
31
+ * - kex_exchange_identification: Connection closed by remote host
32
+ */
33
+ const UNPROVISIONED_SCP_ACCESS_MESSAGE = /\bConnection closed\b.*\b(?:by UNKNOWN port \d+|by remote host)?/;
26
34
  /** Maximum amount of time after AWS SSM process starts to check for {@link UNPROVISIONED_ACCESS_MESSAGE}
27
35
  * in the process's stderr
28
36
  */
@@ -32,9 +40,9 @@ const UNPROVISIONED_ACCESS_VALIDATION_WINDOW_MS = 5e3;
32
40
  * Note that each attempt consumes ~ 1 s.
33
41
  */
34
42
  const MAX_SSM_RETRIES = 30;
35
- const INSTANCE_ARN_PATTERN = /^arn:aws:ssm:([^:]+):([^:]+):managed-instance\/([^:]+)$/;
36
43
  /** The name of the SessionManager port forwarding document. This document is managed by AWS. */
37
44
  const LOCAL_PORT_FORWARDING_DOCUMENT_NAME = "AWS-StartPortForwardingSession";
45
+ const START_SSH_SESSION_DOCUMENT_NAME = "AWS-StartSSHSession";
38
46
  /** Checks if access has propagated through AWS to the SSM agent
39
47
  *
40
48
  * AWS takes about 8 minutes to fully resolve access after it is granted. During
@@ -55,7 +63,8 @@ const accessPropagationGuard = (child) => {
55
63
  const beforeStart = Date.now();
56
64
  child.stderr.on("data", (chunk) => {
57
65
  const chunkString = chunk.toString("utf-8");
58
- const match = chunkString.match(UNPROVISIONED_ACCESS_MESSAGE);
66
+ const match = chunkString.match(UNPROVISIONED_ACCESS_MESSAGE) ||
67
+ chunkString.match(UNPROVISIONED_SCP_ACCESS_MESSAGE);
59
68
  if (match &&
60
69
  Date.now() <= beforeStart + UNPROVISIONED_ACCESS_VALIDATION_WINDOW_MS) {
61
70
  isEphemeralAccessDeniedException = true;
@@ -119,8 +128,8 @@ const createSsmCommands = (args) => {
119
128
  subCommand: portForwardingCommand,
120
129
  };
121
130
  };
122
- function spawnChildProcess(credential, command, stdio) {
123
- return (0, node_child_process_1.spawn)("/usr/bin/env", command, {
131
+ function spawnChildProcess(credential, command, args, stdio) {
132
+ return (0, node_child_process_1.spawn)(command, args, {
124
133
  env: Object.assign(Object.assign({}, process.env), credential),
125
134
  stdio,
126
135
  });
@@ -131,14 +140,10 @@ function spawnChildProcess(credential, command, stdio) {
131
140
  */
132
141
  const spawnSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* () {
133
142
  return new Promise((resolve, reject) => {
134
- const child = spawnChildProcess(options.credential, options.command, [
135
- "inherit",
136
- "inherit",
137
- "pipe",
138
- ]);
143
+ const child = spawnChildProcess(options.credential, options.command, options.args, ["inherit", "inherit", "pipe"]);
139
144
  const { isAccessPropagated } = accessPropagationGuard(child);
140
145
  const exitListener = child.on("exit", (code) => {
141
- var _a;
146
+ var _a, _b;
142
147
  exitListener.unref();
143
148
  // In the case of ephemeral AccessDenied exceptions due to unpropagated
144
149
  // permissions, continually retry access until success
@@ -153,12 +158,34 @@ const spawnSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* ()
153
158
  .catch(reject);
154
159
  return;
155
160
  }
156
- options.abortController.abort(code);
161
+ (_b = options.abortController) === null || _b === void 0 ? void 0 : _b.abort(code);
157
162
  (0, stdio_1.print2)(`SSH session terminated`);
158
163
  resolve(code);
159
164
  });
160
165
  });
161
166
  });
167
+ /**
168
+ * Spawns a child process to add a private key to the ssh-agent. The SSH agent is included in the OpenSSH suite of tools
169
+ * and is used to hold private keys during a session. The SSH agent typically does not persist keys across system reboots
170
+ * or logout/login cycles. Once you log out or restart your system, any keys added to the SSH agent during that session
171
+ * will need to be added again in subsequent sessions.
172
+ */
173
+ const executeScpCommand = (credential, command, privateKey) => __awaiter(void 0, void 0, void 0, function* () {
174
+ // Execute should not leave any ssh-agent processes running after it's done performing an SCP transaction.
175
+ const execute = `
176
+ eval $(ssh-agent) >/dev/null 2>&1
177
+ trap 'kill $SSH_AGENT_PID' EXIT
178
+ ssh-add -q - <<< '${privateKey}'
179
+ ${command}
180
+ SCP_EXIT_CODE=$?
181
+ exit $SCP_EXIT_CODE
182
+ `;
183
+ return spawnSsmNode({
184
+ credential,
185
+ command: "bash",
186
+ args: ["-c", execute],
187
+ });
188
+ });
162
189
  /**
163
190
  * A subprocess SSM session redirects its output through a proxy that filters certain messages reducing the verbosity of the output.
164
191
  * The subprocess also makes sure to terminate any grandchild processes that might spawn during the session.
@@ -167,11 +194,7 @@ const spawnSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* ()
167
194
  */
168
195
  const spawnSubprocessSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* () {
169
196
  return new Promise((resolve, reject) => {
170
- const child = spawnChildProcess(options.credential, options.command, [
171
- "ignore",
172
- "pipe",
173
- "pipe",
174
- ]);
197
+ const child = spawnChildProcess(options.credential, "/usr/bin/env", options.command, ["ignore", "pipe", "pipe"]);
175
198
  // Captures the starting session message and filters it from the output
176
199
  const proxyStream = new node_stream_1.Transform({
177
200
  transform(chunk, _, end) {
@@ -236,19 +259,14 @@ const ssm = (authn, request, args) => __awaiter(void 0, void 0, void 0, function
236
259
  const isInstalled = yield (0, install_1.ensureSsmInstall)();
237
260
  if (!isInstalled)
238
261
  throw "Please try again after installing the required AWS utilities";
239
- const match = request.permission.spec.awsResourcePermission.resource.arn.match(INSTANCE_ARN_PATTERN);
240
- if (!match)
241
- throw "Did not receive a properly formatted instance identifier";
242
- const [, region, account, instance] = match;
243
262
  const credential = yield (0, aws_1.assumeRoleWithOktaSaml)(authn, {
244
- account,
245
- role: request.generatedRoles[0].role,
263
+ account: request.instance.accountId,
264
+ role: request.role,
246
265
  });
247
266
  const ssmArgs = {
248
- instance: instance,
249
- region: region,
250
- documentName: request.generated.documentName,
251
- requestId: request.id,
267
+ instance: request.instance.id,
268
+ region: request.instance.region,
269
+ documentName: request.documentName,
252
270
  forwardPortAddress: args.L,
253
271
  noRemoteCommands: args.N,
254
272
  command: commandParameter(args),
@@ -265,10 +283,52 @@ const startSsmProcesses = (credential, commands) => __awaiter(void 0, void 0, vo
265
283
  const abortController = new AbortController();
266
284
  const args = { credential, abortController };
267
285
  const processes = [
268
- spawnSsmNode(Object.assign(Object.assign({}, args), { command: commands.shellCommand })),
286
+ spawnSsmNode(Object.assign(Object.assign({}, args), { command: "/usr/bin/env", args: commands.shellCommand })),
269
287
  ];
270
288
  if (commands.subCommand) {
271
289
  processes.push(spawnSubprocessSsmNode(Object.assign(Object.assign({}, args), { command: commands.subCommand })));
272
290
  }
273
291
  yield Promise.all(processes);
274
292
  });
293
+ const createScpCommand = (data, args) => {
294
+ const ssmCommand = [
295
+ ...createBaseSsmCommand({
296
+ region: data.instance.region,
297
+ instance: "%h",
298
+ }),
299
+ "--document-name",
300
+ START_SSH_SESSION_DOCUMENT_NAME,
301
+ "--parameters",
302
+ '"portNumber=%p"',
303
+ ];
304
+ // TODO: add support for original SSH too.
305
+ return [
306
+ "scp",
307
+ "-o",
308
+ `ProxyCommand='${ssmCommand.join(" ")}'`,
309
+ // if a response is not received after three 5 minute attempts,
310
+ // the connection will be closed.
311
+ "-o",
312
+ "ServerAliveCountMax=3",
313
+ `-o`,
314
+ "ServerAliveInterval=300",
315
+ ...(args.recursive ? ["-r"] : []),
316
+ args.source,
317
+ args.destination,
318
+ ];
319
+ };
320
+ const scp = (authn, data, args, privateKey) => __awaiter(void 0, void 0, void 0, function* () {
321
+ if (!(yield (0, install_1.ensureSsmInstall)())) {
322
+ throw "Please try again after installing the required AWS utilities";
323
+ }
324
+ const credential = yield (0, aws_1.assumeRoleWithOktaSaml)(authn, {
325
+ account: data.instance.accountId,
326
+ role: data.role,
327
+ });
328
+ const command = createScpCommand(data, args).join(" ");
329
+ if (!privateKey) {
330
+ throw "Failed to load a private key for this request. Please contact support@p0.dev for assistance.";
331
+ }
332
+ yield executeScpCommand(credential, command, privateKey);
333
+ });
334
+ exports.scp = scp;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p0security/cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Execute infra CLI commands with P0 grants",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -27,6 +27,7 @@
27
27
  "inquirer": "^9.2.15",
28
28
  "jsdom": "^24.0.0",
29
29
  "lodash": "^4.17.21",
30
+ "node-forge": "^1.3.1",
30
31
  "open": "^8.4.0",
31
32
  "pkce-challenge": "^4.1.0",
32
33
  "pluralize": "^8.0.0",
@@ -44,6 +45,7 @@
44
45
  "@types/jsdom": "^21.1.6",
45
46
  "@types/lodash": "^4.14.202",
46
47
  "@types/node": "^18.11.7",
48
+ "@types/node-forge": "^1.3.11",
47
49
  "@types/pluralize": "^0.0.33",
48
50
  "@types/ps-tree": "^1.1.6",
49
51
  "@types/which": "^3.0.3",