@p0security/cli 0.5.2 → 0.6.1

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,125 @@
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
+ })
62
+ .option("debug", {
63
+ type: "boolean",
64
+ describe: "Print debug information, dangerous for sensitive data",
65
+ }), (0, firestore_1.guard)(scpAction));
66
+ exports.scpCommand = scpCommand;
67
+ /** Transfers files between a local and remote hosts using SSH.
68
+ *
69
+ * Implicitly gains access to the SSH resource if required.
70
+ */
71
+ const scpAction = (args) => __awaiter(void 0, void 0, void 0, function* () {
72
+ const authn = yield (0, auth_1.authenticate)();
73
+ const host = getHostIdentifier(args.source, args.destination);
74
+ if (!host) {
75
+ throw "Could not determine host identifier from source or destination";
76
+ }
77
+ const requestId = yield (0, shared_1.provisionRequest)(authn, args, host);
78
+ if (!requestId) {
79
+ throw "Server did not return a request id. Please contact support@p0.dev for assistance.";
80
+ }
81
+ const { publicKey, privateKey } = createKeyPair();
82
+ const result = yield (0, api_1.fetchExerciseGrant)(authn, {
83
+ requestId,
84
+ destination: host,
85
+ publicKey,
86
+ });
87
+ // replace the host with the linuxUserName@instanceId
88
+ const { source, destination } = replaceHostWithInstance(result, args);
89
+ yield (0, ssm_1.scp)(authn, result, Object.assign(Object.assign({}, args), { source,
90
+ destination }), privateKey);
91
+ });
92
+ /** If a path is not explicitly local, use this pattern to determine if it's remote */
93
+ const REMOTE_PATTERN_COLON = /^([^:]+:)(.*)$/; // Matches host:[path]
94
+ // TODO (ENG-1931): Improve remote host and local host checking for SCP requests
95
+ const isExplicitlyRemote = (path) => {
96
+ return REMOTE_PATTERN_COLON.test(path);
97
+ };
98
+ const getHostIdentifier = (source, destination) => {
99
+ // the instance is contained in the source or destination and is always delimited by a colon.
100
+ const isSourceRemote = isExplicitlyRemote(source);
101
+ const isDestinationRemote = isExplicitlyRemote(destination);
102
+ const remote = isSourceRemote ? source : destination;
103
+ if (isSourceRemote != isDestinationRemote) {
104
+ return remote.split(":")[0];
105
+ }
106
+ // TODO (ENG-1930): support scp across multiple remote hosts.
107
+ throw "Exactly one host (source or destination) must be remote.";
108
+ };
109
+ const replaceHostWithInstance = (result, args) => {
110
+ let source = args.source;
111
+ let destination = args.destination;
112
+ if (isExplicitlyRemote(source)) {
113
+ source = `${result.linuxUserName}@${result.instance.id}:${source.split(":")[1]}`;
114
+ }
115
+ if (isExplicitlyRemote(destination)) {
116
+ destination = `${result.linuxUserName}@${result.instance.id}:${destination.split(":")[1]}`;
117
+ }
118
+ return { source, destination };
119
+ };
120
+ const createKeyPair = () => {
121
+ const rsaKeyPair = node_forge_1.default.pki.rsa.generateKeyPair({ bits: 2048 });
122
+ const privateKey = node_forge_1.default.pki.privateKeyToPem(rsaKeyPair.privateKey);
123
+ const publicKey = node_forge_1.default.ssh.publicKeyToOpenSSH(rsaKeyPair.publicKey);
124
+ return { publicKey, privateKey };
125
+ };
@@ -0,0 +1,35 @@
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
+ debug?: boolean;
26
+ };
27
+ export declare type SshCommandArgs = BaseSshCommandArgs & {
28
+ sudo?: boolean;
29
+ destination: string;
30
+ L?: string;
31
+ N?: boolean;
32
+ arguments: string[];
33
+ command?: string;
34
+ };
35
+ 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<number | null>;
@@ -12,10 +12,11 @@ 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");
19
+ const lodash_1 = require("lodash");
19
20
  const node_child_process_1 = require("node:child_process");
20
21
  const node_stream_1 = require("node:stream");
21
22
  const ps_tree_1 = __importDefault(require("ps-tree"));
@@ -23,6 +24,14 @@ const STARTING_SESSION_MESSAGE = /Starting session with SessionId: (.*)/;
23
24
  /** Matches the error message that AWS SSM print1 when access is not propagated */
24
25
  // Note that the resource will randomly be either the SSM document or the EC2 instance
25
26
  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/;
27
+ /**
28
+ * Matches the following error messages that AWS SSM print1 when ssh authorized
29
+ * key access hasn't propagated to the instance yet.
30
+ * - Connection closed by UNKNOWN port 65535
31
+ * - scp: Connection closed
32
+ * - kex_exchange_identification: Connection closed by remote host
33
+ */
34
+ const UNPROVISIONED_SCP_ACCESS_MESSAGE = /\bConnection closed\b.*\b(?:by UNKNOWN port \d+|by remote host)?/;
26
35
  /** Maximum amount of time after AWS SSM process starts to check for {@link UNPROVISIONED_ACCESS_MESSAGE}
27
36
  * in the process's stderr
28
37
  */
@@ -32,9 +41,9 @@ const UNPROVISIONED_ACCESS_VALIDATION_WINDOW_MS = 5e3;
32
41
  * Note that each attempt consumes ~ 1 s.
33
42
  */
34
43
  const MAX_SSM_RETRIES = 30;
35
- const INSTANCE_ARN_PATTERN = /^arn:aws:ssm:([^:]+):([^:]+):managed-instance\/([^:]+)$/;
36
44
  /** The name of the SessionManager port forwarding document. This document is managed by AWS. */
37
45
  const LOCAL_PORT_FORWARDING_DOCUMENT_NAME = "AWS-StartPortForwardingSession";
46
+ const START_SSH_SESSION_DOCUMENT_NAME = "AWS-StartSSHSession";
38
47
  /** Checks if access has propagated through AWS to the SSM agent
39
48
  *
40
49
  * AWS takes about 8 minutes to fully resolve access after it is granted. During
@@ -55,7 +64,8 @@ const accessPropagationGuard = (child) => {
55
64
  const beforeStart = Date.now();
56
65
  child.stderr.on("data", (chunk) => {
57
66
  const chunkString = chunk.toString("utf-8");
58
- const match = chunkString.match(UNPROVISIONED_ACCESS_MESSAGE);
67
+ const match = chunkString.match(UNPROVISIONED_ACCESS_MESSAGE) ||
68
+ chunkString.match(UNPROVISIONED_SCP_ACCESS_MESSAGE);
59
69
  if (match &&
60
70
  Date.now() <= beforeStart + UNPROVISIONED_ACCESS_VALIDATION_WINDOW_MS) {
61
71
  isEphemeralAccessDeniedException = true;
@@ -119,8 +129,8 @@ const createSsmCommands = (args) => {
119
129
  subCommand: portForwardingCommand,
120
130
  };
121
131
  };
122
- function spawnChildProcess(credential, command, stdio) {
123
- return (0, node_child_process_1.spawn)("/usr/bin/env", command, {
132
+ function spawnChildProcess(credential, command, args, stdio) {
133
+ return (0, node_child_process_1.spawn)(command, args, {
124
134
  env: Object.assign(Object.assign({}, process.env), credential),
125
135
  stdio,
126
136
  });
@@ -129,36 +139,39 @@ function spawnChildProcess(credential, command, stdio) {
129
139
  *
130
140
  * Requires `aws ssm` to be installed on the client machine.
131
141
  */
132
- const spawnSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* () {
133
- return new Promise((resolve, reject) => {
134
- const child = spawnChildProcess(options.credential, options.command, [
135
- "inherit",
136
- "inherit",
137
- "pipe",
138
- ]);
139
- const { isAccessPropagated } = accessPropagationGuard(child);
140
- const exitListener = child.on("exit", (code) => {
141
- var _a;
142
- exitListener.unref();
143
- // In the case of ephemeral AccessDenied exceptions due to unpropagated
144
- // permissions, continually retry access until success
145
- if (!isAccessPropagated()) {
146
- const attemptsRemaining = (_a = options === null || options === void 0 ? void 0 : options.attemptsRemaining) !== null && _a !== void 0 ? _a : MAX_SSM_RETRIES;
147
- if (attemptsRemaining <= 0) {
148
- reject("Access did not propagate through AWS before max retry attempts were exceeded. Please contact support@p0.dev for assistance.");
142
+ function spawnSsmNode(options) {
143
+ return __awaiter(this, void 0, void 0, function* () {
144
+ return new Promise((resolve, reject) => {
145
+ var _a, _b;
146
+ const child = spawnChildProcess(options.credential, options.command, options.args, options.stdio);
147
+ // optionally buffer content into stdin for the child process
148
+ for (const command of (_a = options.writeStdin) !== null && _a !== void 0 ? _a : []) {
149
+ (_b = child.stdin) === null || _b === void 0 ? void 0 : _b.write(`${command}\n`);
150
+ }
151
+ const { isAccessPropagated } = accessPropagationGuard(child);
152
+ const exitListener = child.on("exit", (code) => {
153
+ var _a, _b;
154
+ exitListener.unref();
155
+ // In the case of ephemeral AccessDenied exceptions due to unpropagated
156
+ // permissions, continually retry access until success
157
+ if (!isAccessPropagated()) {
158
+ const attemptsRemaining = (_a = options === null || options === void 0 ? void 0 : options.attemptsRemaining) !== null && _a !== void 0 ? _a : MAX_SSM_RETRIES;
159
+ if (attemptsRemaining <= 0) {
160
+ reject("Access did not propagate through AWS before max retry attempts were exceeded. Please contact support@p0.dev for assistance.");
161
+ return;
162
+ }
163
+ spawnSsmNode(Object.assign(Object.assign({}, options), { attemptsRemaining: attemptsRemaining - 1 }))
164
+ .then((code) => resolve(code))
165
+ .catch(reject);
149
166
  return;
150
167
  }
151
- spawnSsmNode(Object.assign(Object.assign({}, options), { attemptsRemaining: attemptsRemaining - 1 }))
152
- .then((code) => resolve(code))
153
- .catch(reject);
154
- return;
155
- }
156
- options.abortController.abort(code);
157
- (0, stdio_1.print2)(`SSH session terminated`);
158
- resolve(code);
168
+ (_b = options.abortController) === null || _b === void 0 ? void 0 : _b.abort(code);
169
+ (0, stdio_1.print2)(`SSH session terminated`);
170
+ resolve(code);
171
+ });
159
172
  });
160
173
  });
161
- });
174
+ }
162
175
  /**
163
176
  * A subprocess SSM session redirects its output through a proxy that filters certain messages reducing the verbosity of the output.
164
177
  * The subprocess also makes sure to terminate any grandchild processes that might spawn during the session.
@@ -167,11 +180,7 @@ const spawnSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* ()
167
180
  */
168
181
  const spawnSubprocessSsmNode = (options) => __awaiter(void 0, void 0, void 0, function* () {
169
182
  return new Promise((resolve, reject) => {
170
- const child = spawnChildProcess(options.credential, options.command, [
171
- "ignore",
172
- "pipe",
173
- "pipe",
174
- ]);
183
+ const child = spawnChildProcess(options.credential, "/usr/bin/env", options.command, ["ignore", "pipe", "pipe"]);
175
184
  // Captures the starting session message and filters it from the output
176
185
  const proxyStream = new node_stream_1.Transform({
177
186
  transform(chunk, _, end) {
@@ -236,19 +245,14 @@ const ssm = (authn, request, args) => __awaiter(void 0, void 0, void 0, function
236
245
  const isInstalled = yield (0, install_1.ensureSsmInstall)();
237
246
  if (!isInstalled)
238
247
  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
248
  const credential = yield (0, aws_1.assumeRoleWithOktaSaml)(authn, {
244
- account,
245
- role: request.generatedRoles[0].role,
249
+ account: request.instance.accountId,
250
+ role: request.role,
246
251
  });
247
252
  const ssmArgs = {
248
- instance: instance,
249
- region: region,
250
- documentName: request.generated.documentName,
251
- requestId: request.id,
253
+ instance: request.instance.id,
254
+ region: request.instance.region,
255
+ documentName: request.documentName,
252
256
  forwardPortAddress: args.L,
253
257
  noRemoteCommands: args.N,
254
258
  command: commandParameter(args),
@@ -265,10 +269,110 @@ const startSsmProcesses = (credential, commands) => __awaiter(void 0, void 0, vo
265
269
  const abortController = new AbortController();
266
270
  const args = { credential, abortController };
267
271
  const processes = [
268
- spawnSsmNode(Object.assign(Object.assign({}, args), { command: commands.shellCommand })),
272
+ spawnSsmNode(Object.assign(Object.assign({}, args), { command: "/usr/bin/env", args: commands.shellCommand, stdio: ["inherit", "inherit", "pipe"] })),
269
273
  ];
270
274
  if (commands.subCommand) {
271
275
  processes.push(spawnSubprocessSsmNode(Object.assign(Object.assign({}, args), { command: commands.subCommand })));
272
276
  }
273
277
  yield Promise.all(processes);
274
278
  });
279
+ const createProxyCommands = (data, args, debug) => {
280
+ const ssmCommand = [
281
+ ...createBaseSsmCommand({
282
+ region: data.instance.region,
283
+ instance: "%h",
284
+ }),
285
+ "--document-name",
286
+ START_SSH_SESSION_DOCUMENT_NAME,
287
+ "--parameters",
288
+ '"portNumber=%p"',
289
+ ];
290
+ return {
291
+ scp: [
292
+ "scp",
293
+ ...(debug ? ["-v"] : []),
294
+ "-o",
295
+ `ProxyCommand='${ssmCommand.join(" ")}'`,
296
+ // if a response is not received after three 5 minute attempts,
297
+ // the connection will be closed.
298
+ "-o",
299
+ "ServerAliveCountMax=3",
300
+ `-o`,
301
+ "ServerAliveInterval=300",
302
+ ...(args.recursive ? ["-r"] : []),
303
+ args.source,
304
+ args.destination,
305
+ ],
306
+ ssh: [
307
+ "ssh",
308
+ ...(debug ? ["-v"] : []),
309
+ "-o",
310
+ `ProxyCommand='${ssmCommand.join(" ")}'`,
311
+ `${data.linuxUserName}@${data.instance.id}`,
312
+ ],
313
+ ssm: ssmCommand,
314
+ };
315
+ };
316
+ const scp = (authn, data, args, privateKey) => __awaiter(void 0, void 0, void 0, function* () {
317
+ if (!(yield (0, install_1.ensureSsmInstall)())) {
318
+ throw "Please try again after installing the required AWS utilities";
319
+ }
320
+ if (!privateKey) {
321
+ throw "Failed to load a private key for this request. Please contact support@p0.dev for assistance.";
322
+ }
323
+ const credential = yield (0, aws_1.assumeRoleWithOktaSaml)(authn, {
324
+ account: data.instance.accountId,
325
+ role: data.role,
326
+ });
327
+ const commands = createProxyCommands(data, args, args.debug);
328
+ const scpCommand = commands.scp.join(" ");
329
+ const sshCommand = commands.ssh.join(" ");
330
+ const debug = [
331
+ `echo "SSH_AUTH_SOCK: $SSH_AUTH_SOCK"`,
332
+ `echo "SSH_AGENT_PID: $SSH_AGENT_PID"`,
333
+ `echo '$(p0 aws role assume ${data.role})'`,
334
+ `echo "${sshCommand}"`,
335
+ `echo "${scpCommand}"`,
336
+ `echo "SSH Agent Keys:"`,
337
+ `ssh-add -l`,
338
+ ];
339
+ /**
340
+ * Spawns a child process to add a private key to the ssh-agent. The SSH agent is included in the OpenSSH suite
341
+ * of tools and is used to hold private keys during a session. The SSH agent typically does not persist keys
342
+ * across system reboots or logout/login cycles. Once you log out or restart your system, any keys added to
343
+ * the SSH agent during that session will need to be added again in subsequent sessions.
344
+ */
345
+ const writeStdin = [
346
+ // This might be overkill because we are already spawning a subprocess that will run the commands for us
347
+ // but just in case someone enters that subprocess we're also disabling the history of commands run.
348
+ `unset HISTFILE`,
349
+ // in debug mode, we want to see the pid of the ssh-agent and compare it to the environment variable
350
+ `eval $(ssh-agent)${args.debug ? "" : " >/dev/null 2>&1"}`,
351
+ `trap 'kill $SSH_AGENT_PID' EXIT`,
352
+ `ssh-add -q - <<< '${privateKey}'`,
353
+ // in debug mode, we'll see the keys that were added to the agent and more information about the agent
354
+ ...(args.debug ? debug : []),
355
+ scpCommand,
356
+ `SCP_EXIT_CODE=$?`,
357
+ `exit $SCP_EXIT_CODE`,
358
+ ];
359
+ if (args.debug) {
360
+ // Print commands that can be individually executed to reproduce behavior
361
+ // Remove the debug information - can be executed manually between steps
362
+ const reproCommands = (0, lodash_1.without)([
363
+ "bash",
364
+ ...Object.entries(process.env).map(([key, value]) => `export ${key}='${value}'`),
365
+ ...Object.entries(credential).map(([key, value]) => `export ${key}='${value}'`),
366
+ ...writeStdin,
367
+ ], ...debug);
368
+ (0, stdio_1.print2)(`Execute the following commands to create a similar SCP session:\n *** COMMANDS BEGIN ***\n${reproCommands.join("\n")}\n *** COMMANDS END ***`);
369
+ }
370
+ return spawnSsmNode({
371
+ credential,
372
+ writeStdin,
373
+ stdio: ["pipe", "inherit", "pipe"],
374
+ command: "bash",
375
+ args: [],
376
+ });
377
+ });
378
+ 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.1",
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",