@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.
- package/dist/commands/index.js +2 -0
- package/dist/commands/scp.d.ts +3 -0
- package/dist/commands/scp.js +121 -0
- package/dist/commands/shared.d.ts +34 -0
- package/dist/commands/shared.js +102 -0
- package/dist/commands/ssh.d.ts +1 -10
- package/dist/commands/ssh.js +11 -88
- package/dist/drivers/api.d.ts +17 -0
- package/dist/drivers/api.js +19 -19
- package/dist/plugins/aws/ssm/index.d.ts +3 -6
- package/dist/plugins/aws/ssm/index.js +88 -28
- package/package.json +3 -1
package/dist/commands/index.js
CHANGED
|
@@ -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,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;
|
package/dist/commands/ssh.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/ssh.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
});
|
package/dist/drivers/api.d.ts
CHANGED
|
@@ -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>;
|
package/dist/drivers/api.js
CHANGED
|
@@ -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
|
|
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(
|
|
52
|
-
method
|
|
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
|
|
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.
|
|
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/
|
|
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:
|
|
17
|
-
|
|
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)(
|
|
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.
|
|
263
|
+
account: request.instance.accountId,
|
|
264
|
+
role: request.role,
|
|
246
265
|
});
|
|
247
266
|
const ssmArgs = {
|
|
248
|
-
instance: instance,
|
|
249
|
-
region: region,
|
|
250
|
-
documentName: request.
|
|
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.
|
|
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",
|