@p0security/cli 0.8.0 → 0.8.2

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.
Files changed (49) hide show
  1. package/dist/commands/__tests__/ssh.test.js +11 -10
  2. package/dist/commands/scp.d.ts +1 -1
  3. package/dist/commands/scp.js +11 -5
  4. package/dist/commands/shared/index.d.ts +4 -0
  5. package/dist/commands/shared/index.js +67 -0
  6. package/dist/commands/{shared.d.ts → shared/ssh.d.ts} +7 -14
  7. package/dist/commands/{shared.js → shared/ssh.js} +30 -64
  8. package/dist/commands/ssh.d.ts +1 -1
  9. package/dist/commands/ssh.js +10 -4
  10. package/dist/common/retry.d.ts +9 -0
  11. package/dist/common/retry.js +50 -0
  12. package/dist/common/subprocess.d.ts +10 -0
  13. package/dist/common/subprocess.js +72 -0
  14. package/dist/drivers/auth.d.ts +1 -1
  15. package/dist/drivers/auth.js +7 -3
  16. package/dist/plugins/aws/config.d.ts +1 -1
  17. package/dist/plugins/aws/idc/index.d.ts +16 -0
  18. package/dist/plugins/aws/idc/index.js +150 -0
  19. package/dist/plugins/aws/ssh.d.ts +13 -0
  20. package/dist/plugins/aws/ssh.js +24 -0
  21. package/dist/plugins/aws/types.d.ts +42 -16
  22. package/dist/plugins/google/ssh-key.d.ts +14 -0
  23. package/dist/plugins/google/ssh-key.js +80 -0
  24. package/dist/plugins/google/ssh.d.ts +15 -0
  25. package/dist/plugins/google/ssh.js +29 -0
  26. package/dist/plugins/google/types.d.ts +57 -0
  27. package/dist/plugins/google/types.js +2 -0
  28. package/dist/plugins/login.d.ts +3 -0
  29. package/dist/plugins/login.js +10 -0
  30. package/dist/plugins/oidc/login.d.ts +33 -2
  31. package/dist/plugins/oidc/login.js +100 -60
  32. package/dist/plugins/okta/aws.d.ts +1 -1
  33. package/dist/plugins/okta/aws.js +2 -2
  34. package/dist/plugins/okta/login.js +11 -1
  35. package/dist/plugins/ping/login.d.ts +2 -1
  36. package/dist/plugins/ping/login.js +11 -1
  37. package/dist/plugins/{aws/ssm → ssh}/index.d.ts +3 -2
  38. package/dist/plugins/ssh/index.js +311 -0
  39. package/dist/plugins/ssh/types.d.ts +4 -0
  40. package/dist/plugins/ssh-agent/index.js +5 -39
  41. package/dist/types/aws/oidc.d.ts +36 -0
  42. package/dist/types/aws/oidc.js +12 -0
  43. package/dist/types/oidc.d.ts +21 -0
  44. package/dist/types/request.d.ts +9 -11
  45. package/dist/types/request.js +0 -10
  46. package/dist/types/ssh.d.ts +27 -0
  47. package/dist/types/ssh.js +5 -0
  48. package/package.json +1 -1
  49. package/dist/plugins/aws/ssm/index.js +0 -213
@@ -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.oidcLogin = exports.validateProviderDomain = void 0;
15
+ exports.oidcLogin = exports.oidcLoginSteps = exports.waitForActivation = exports.fetchOidcToken = exports.authorize = exports.validateProviderDomain = exports.DEVICE_GRANT_TYPE = void 0;
16
16
  /** Copyright © 2024-present P0 Security
17
17
 
18
18
  This file is part of @p0security/cli
@@ -27,107 +27,147 @@ const oidc_1 = require("../../common/auth/oidc");
27
27
  const fetch_1 = require("../../common/fetch");
28
28
  const stdio_1 = require("../../drivers/stdio");
29
29
  const util_1 = require("../../util");
30
- const lodash_1 = require("lodash");
31
30
  const open_1 = __importDefault(require("open"));
32
- const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
31
+ exports.DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
33
32
  const validateProviderDomain = (org) => {
34
33
  if (!org.providerDomain)
35
34
  throw "Login requires a configured provider domain.";
36
35
  };
37
36
  exports.validateProviderDomain = validateProviderDomain;
37
+ const oidcProviderLabels = (providerType) => {
38
+ switch (providerType) {
39
+ case "okta":
40
+ return "Okta";
41
+ case "ping":
42
+ return "PingOne";
43
+ case "google":
44
+ case "google-oidc":
45
+ return "Google";
46
+ case "oidc-pkce":
47
+ return "OIDC";
48
+ case "aws-oidc":
49
+ return "AWS";
50
+ case "azure-oidc":
51
+ case "microsoft":
52
+ return "Entra ID";
53
+ default:
54
+ (0, util_1.throwAssertNever)(providerType);
55
+ }
56
+ throw "Invalid provider type";
57
+ };
38
58
  /** Executes the first step of a device-authorization grant flow */
39
59
  // cf. https://developer.okta.com/docs/guides/device-authorization-grant/main/
40
- const authorize = (org, scope) => __awaiter(void 0, void 0, void 0, function* () {
41
- if (org.providerType === undefined) {
42
- throw "Login requires a configured provider type.";
43
- }
44
- const init = {
45
- method: "POST",
46
- headers: oidc_1.OIDC_HEADERS,
47
- body: (0, fetch_1.urlEncode)({
48
- client_id: org.clientId,
49
- scope,
50
- }),
51
- };
52
- (0, exports.validateProviderDomain)(org);
53
- // This is the "org" authorization server; the okta.apps.* scopes are not
54
- // available with custom authorization servers
55
- const url = org.providerType === "okta"
56
- ? `https:${org.providerDomain}/oauth2/v1/device/authorize`
57
- : org.providerType === "ping"
58
- ? `https://${org.providerDomain}/${org.environmentId}/as/device_authorization`
59
- : (0, util_1.throwAssertNever)(org.providerType);
60
+ const authorize = (request, validateResponse) => __awaiter(void 0, void 0, void 0, function* () {
61
+ const { url, init } = request;
60
62
  const response = yield fetch(url, init);
61
- yield (0, fetch_1.validateResponse)(response);
63
+ yield validateResponse(response);
62
64
  return (yield response.json());
63
65
  });
66
+ exports.authorize = authorize;
64
67
  /** Attempts to fetch this device's OIDC token
65
68
  *
66
69
  * The authorization may or may not be granted at this stage. If it is not, the
67
70
  * authorization server will return "authorization_pending", in which case this
68
71
  * function will return undefined.
69
72
  */
70
- const fetchOidcToken = (org, authorize) => __awaiter(void 0, void 0, void 0, function* () {
71
- if (org.providerType === undefined) {
72
- throw "Login requires a configured provider type.";
73
- }
74
- const init = {
75
- method: "POST",
76
- headers: oidc_1.OIDC_HEADERS,
77
- body: (0, fetch_1.urlEncode)({
78
- client_id: org.clientId,
79
- device_code: authorize.device_code,
80
- grant_type: DEVICE_GRANT_TYPE,
81
- }),
82
- };
83
- (0, exports.validateProviderDomain)(org);
84
- const url = org.providerType === "okta"
85
- ? `https:${org.providerDomain}/oauth2/v1/token`
86
- : org.providerType === "ping"
87
- ? `https://${org.providerDomain}/${org.environmentId}/as/token`
88
- : (0, util_1.throwAssertNever)(org.providerType);
73
+ const fetchOidcToken = (request) => __awaiter(void 0, void 0, void 0, function* () {
74
+ const { url, init } = request;
89
75
  const response = yield fetch(url, init);
90
76
  if (!response.ok) {
91
77
  if (response.status === 400) {
92
78
  const data = yield response.json();
93
79
  if (data.error === "authorization_pending")
94
80
  return undefined;
81
+ if (data.error === "access_denied")
82
+ throw "Access denied, try again";
95
83
  }
96
84
  yield (0, fetch_1.validateResponse)(response);
97
85
  }
98
86
  return (yield response.json());
99
87
  });
88
+ exports.fetchOidcToken = fetchOidcToken;
100
89
  /** Waits until user device authorization is complete
101
90
  *
102
91
  * Returns the OIDC token after completion.
103
92
  */
104
- const waitForActivation = (org, authorize) => __awaiter(void 0, void 0, void 0, function* () {
93
+ const waitForActivation = (authorize, extractExpiryInterval, // Aws implementation differs from standard OIDC response, need function to extract expiry
94
+ tokenRequest) => __awaiter(void 0, void 0, void 0, function* () {
105
95
  const start = Date.now();
106
- while (Date.now() - start <= authorize.expires_in * 1e3) {
107
- const response = yield fetchOidcToken(org, authorize);
96
+ const { expires_in, interval } = extractExpiryInterval(authorize);
97
+ while (Date.now() - start <= expires_in * 1e3) {
98
+ const response = yield (0, exports.fetchOidcToken)(tokenRequest);
108
99
  if (!response)
109
- yield (0, util_1.sleep)(authorize.interval * 1e3);
100
+ yield (0, util_1.sleep)(interval * 1e3);
110
101
  else
111
102
  return response;
112
103
  }
113
104
  throw "Expired awaiting in-browser authorization.";
114
105
  });
115
- /** Logs in to an Identity Provider via OIDC */
116
- const oidcLogin = (org, scope) => __awaiter(void 0, void 0, void 0, function* () {
106
+ exports.waitForActivation = waitForActivation;
107
+ const oidcLoginSteps = (org, scope, urls) => {
108
+ const { deviceAuthorizationUrl, tokenUrl } = urls();
117
109
  if (org.providerType === undefined) {
118
- throw "Login requires a configured provider type.";
110
+ throw "Your organization's login configuration does not support this access. Your P0 admin will need to install a supported OIDC provider in order for you to use this command.";
119
111
  }
120
- const authorizeResponse = yield authorize(org, scope);
112
+ const buildOidcAuthorizeRequest = () => {
113
+ (0, exports.validateProviderDomain)(org);
114
+ return {
115
+ init: {
116
+ method: "POST",
117
+ headers: oidc_1.OIDC_HEADERS,
118
+ body: (0, fetch_1.urlEncode)({
119
+ client_id: org.clientId,
120
+ scope,
121
+ }),
122
+ },
123
+ url: deviceAuthorizationUrl,
124
+ };
125
+ };
126
+ const buildOidcTokenRequest = (authorize) => {
127
+ (0, exports.validateProviderDomain)(org);
128
+ return {
129
+ url: tokenUrl,
130
+ init: {
131
+ method: "POST",
132
+ headers: oidc_1.OIDC_HEADERS,
133
+ body: (0, fetch_1.urlEncode)({
134
+ client_id: org.clientId,
135
+ device_code: authorize.device_code,
136
+ grant_type: exports.DEVICE_GRANT_TYPE,
137
+ }),
138
+ },
139
+ };
140
+ };
141
+ return {
142
+ providerType: org.providerType,
143
+ validateResponse: fetch_1.validateResponse,
144
+ buildAuthorizeRequest: buildOidcAuthorizeRequest,
145
+ buildTokenRequest: buildOidcTokenRequest,
146
+ processAuthzExpiry: (authorize) => ({
147
+ expires_in: authorize.expires_in,
148
+ interval: authorize.interval,
149
+ }),
150
+ processAuthzResponse: (authorize) => ({
151
+ user_code: authorize.user_code,
152
+ verification_uri_complete: authorize.verification_uri_complete,
153
+ }),
154
+ };
155
+ };
156
+ exports.oidcLoginSteps = oidcLoginSteps;
157
+ /** Logs in to an Identity Provider via OIDC */
158
+ const oidcLogin = (steps) => __awaiter(void 0, void 0, void 0, function* () {
159
+ const { providerType, buildAuthorizeRequest, buildTokenRequest, processAuthzExpiry, processAuthzResponse, validateResponse, } = steps;
160
+ const deviceAuthorizationResponse = yield (0, exports.authorize)(buildAuthorizeRequest(), validateResponse);
161
+ const { user_code, verification_uri_complete } = processAuthzResponse(deviceAuthorizationResponse);
121
162
  (0, stdio_1.print2)(`Please use the opened browser window to continue your P0 login.
122
-
123
- When prompted, confirm that ${(0, lodash_1.capitalize)(org.providerType)} displays this code:
124
-
125
- ${authorizeResponse.user_code}
126
163
 
127
- Waiting for authorization...
128
- `);
129
- void (0, open_1.default)(authorizeResponse.verification_uri_complete);
130
- const oidcResponse = yield waitForActivation(org, authorizeResponse);
131
- return oidcResponse;
164
+ When prompted, confirm that ${oidcProviderLabels(providerType)} displays this code:
165
+
166
+ ${user_code}
167
+
168
+ Waiting for authorization...
169
+ `);
170
+ void (0, open_1.default)(verification_uri_complete);
171
+ return yield (0, exports.waitForActivation)(deviceAuthorizationResponse, processAuthzExpiry, buildTokenRequest(deviceAuthorizationResponse));
132
172
  });
133
173
  exports.oidcLogin = oidcLogin;
@@ -1,5 +1,5 @@
1
1
  import { Authn } from "../../types/identity";
2
2
  export declare const assumeRoleWithOktaSaml: (authn: Authn, args: {
3
- account?: string;
3
+ accountId?: string;
4
4
  role: string;
5
5
  }) => Promise<import("../aws/types").AwsCredentials>;
@@ -24,8 +24,8 @@ const role_1 = require("../../commands/aws/role");
24
24
  const auth_1 = require("../../drivers/auth");
25
25
  const assumeRole_1 = require("../aws/assumeRole");
26
26
  const assumeRoleWithOktaSaml = (authn, args) => __awaiter(void 0, void 0, void 0, function* () {
27
- return yield (0, auth_1.cached)(`aws-okta-${args.account}-${args.role}`, () => __awaiter(void 0, void 0, void 0, function* () {
28
- const { account, config, samlResponse } = yield (0, role_1.initOktaSaml)(authn, args.account);
27
+ return yield (0, auth_1.cached)(`aws-okta-${args.accountId}-${args.role}`, () => __awaiter(void 0, void 0, void 0, function* () {
28
+ const { account, config, samlResponse } = yield (0, role_1.initOktaSaml)(authn, args.accountId);
29
29
  const { roles } = (0, role_1.rolesFromSaml)(account, samlResponse);
30
30
  if (!roles.includes(args.role))
31
31
  throw `Role not available. Available roles:\n${roles.map((r) => ` ${r}`).join("\n")}`;
@@ -66,7 +66,17 @@ const fetchSamlResponse = (org, { access_token }) => __awaiter(void 0, void 0, v
66
66
  return samlInput === null || samlInput === void 0 ? void 0 : samlInput.value;
67
67
  });
68
68
  /** Logs in to Okta via OIDC */
69
- const oktaLogin = (org) => __awaiter(void 0, void 0, void 0, function* () { return (0, login_1.oidcLogin)(org, "openid email profile okta.apps.sso"); });
69
+ const oktaLogin = (org) => __awaiter(void 0, void 0, void 0, function* () {
70
+ return (0, login_1.oidcLogin)((0, login_1.oidcLoginSteps)(org, "openid email profile okta.apps.sso", () => {
71
+ if (org.providerType !== "okta") {
72
+ throw `Invalid provider type ${org.providerType} (expected "okta")`;
73
+ }
74
+ return {
75
+ deviceAuthorizationUrl: `https://${org.providerDomain}/oauth2/v1/device/authorize`,
76
+ tokenUrl: `https://${org.providerDomain}/oauth2/v1/token`,
77
+ };
78
+ }));
79
+ });
70
80
  exports.oktaLogin = oktaLogin;
71
81
  /** Retrieves a SAML response for an okta app */
72
82
  // TODO: Inject Okta app
@@ -8,6 +8,7 @@ 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 { TokenResponse } from "../../types/oidc";
11
12
  import { OrgData } from "../../types/org";
12
13
  /** Logs in to PingOne via OIDC */
13
- export declare const pingLogin: (org: OrgData) => Promise<import("../../types/oidc").TokenResponse>;
14
+ export declare const pingLogin: (org: OrgData) => Promise<TokenResponse>;
@@ -12,5 +12,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.pingLogin = void 0;
13
13
  const login_1 = require("../oidc/login");
14
14
  /** Logs in to PingOne via OIDC */
15
- const pingLogin = (org) => __awaiter(void 0, void 0, void 0, function* () { return (0, login_1.oidcLogin)(org, "openid email profile"); });
15
+ const pingLogin = (org) => __awaiter(void 0, void 0, void 0, function* () {
16
+ return (0, login_1.oidcLogin)((0, login_1.oidcLoginSteps)(org, "openid email profile", () => {
17
+ if (org.providerType !== "ping" || org.providerType === undefined) {
18
+ throw `Invalid provider type ${org.providerType} (expected "ping")`;
19
+ }
20
+ return {
21
+ deviceAuthorizationUrl: `https://${org.providerDomain}/${org.environmentId}/as/device_authorization`,
22
+ tokenUrl: `https://${org.providerDomain}/${org.environmentId}/as/token`,
23
+ };
24
+ }));
25
+ });
16
26
  exports.pingLogin = pingLogin;
@@ -8,6 +8,7 @@ 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 { ScpCommandArgs, SshCommandArgs, SshRequest } from "../../../commands/shared";
12
- import { Authn } from "../../../types/identity";
11
+ import { ScpCommandArgs, SshCommandArgs } from "../../commands/shared/ssh";
12
+ import { Authn } from "../../types/identity";
13
+ import { SshRequest } from "../../types/ssh";
13
14
  export declare const sshOrScp: (authn: Authn, data: SshRequest, cmdArgs: ScpCommandArgs | SshCommandArgs, privateKey: string) => Promise<number | null>;
@@ -0,0 +1,311 @@
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.sshOrScp = void 0;
13
+ const keys_1 = require("../../common/keys");
14
+ const stdio_1 = require("../../drivers/stdio");
15
+ const util_1 = require("../../util");
16
+ const config_1 = require("../aws/config");
17
+ const idc_1 = require("../aws/idc");
18
+ const install_1 = require("../aws/ssm/install");
19
+ const aws_1 = require("../okta/aws");
20
+ const ssh_agent_1 = require("../ssh-agent");
21
+ const node_child_process_1 = require("node:child_process");
22
+ /** Matches the error message that AWS SSM print1 when access is not propagated */
23
+ // Note that the resource will randomly be either the SSM document or the EC2 instance
24
+ const UNAUTHORIZED_START_SESSION_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/;
25
+ /**
26
+ * Matches the following error messages that AWS SSM print1 when ssh authorized
27
+ * key access hasn't propagated to the instance yet.
28
+ * - Connection closed by UNKNOWN port 65535
29
+ * - scp: Connection closed
30
+ * - kex_exchange_identification: Connection closed by remote host
31
+ */
32
+ const CONNECTION_CLOSED_MESSAGE = /\bConnection closed\b.*\b(?:by UNKNOWN port \d+|by remote host)?/;
33
+ const PUBLIC_KEY_DENIED_MESSAGE = /Permission denied \(publickey\)/;
34
+ const UNAUTHORIZED_TUNNEL_USER_MESSAGE = /Error while connecting \[4033: 'not authorized'\]/;
35
+ const UNAUTHORIZED_INSTANCES_GET_MESSAGE = /Required 'compute\.instances\.get' permission/;
36
+ const DESTINATION_READ_ERROR = /Error while connecting \[4010: 'destination read failed'\]/;
37
+ const GOOGLE_LOGIN_MESSAGE = /You do not currently have an active account selected/;
38
+ /** Maximum amount of time after SSH subprocess starts to check for {@link UNPROVISIONED_ACCESS_MESSAGES}
39
+ * in the process's stderr
40
+ */
41
+ const DEFAULT_VALIDATION_WINDOW_MS = 5e3;
42
+ /** Maximum number of attempts to start an SSH session
43
+ *
44
+ * Note that each attempt consumes ~ 1 s.
45
+ */
46
+ const DEFAULT_MAX_SSH_RETRIES = 30;
47
+ const GCP_MAX_SSH_RETRIES = 120; // GCP requires more time to propagate access
48
+ /** The name of the SessionManager port forwarding document. This document is managed by AWS. */
49
+ const START_SSH_SESSION_DOCUMENT_NAME = "AWS-StartSSHSession";
50
+ /**
51
+ * AWS
52
+ * There are 2 cases of unprovisioned access in AWS
53
+ * 1. SSM:StartSession action is missing either on the SSM document (AWS-StartSSHSession) or the EC2 instance
54
+ * 2. Temporary error when issuing an SCP command
55
+ *
56
+ * 1: results in UNAUTHORIZED_START_SESSION_MESSAGE
57
+ * 2: results in CONNECTION_CLOSED_MESSAGE
58
+ *
59
+ * Google Cloud
60
+ * There are 5 cases of unprovisioned access in Google Cloud.
61
+ * These are all potentially subject to propagation delays.
62
+ * 1. The linux user name is not present in the user's Google Workspace profile `posixAccounts` attribute
63
+ * 2. The public key is not present in the user's Google Workspace profile `sshPublicKeys` attribute
64
+ * 3. The user cannot act as the service account of the compute instance
65
+ * 4. The user cannot tunnel through the IAP tunnel to the instance
66
+ * 5. The user doesn't have osLogin or osAdminLogin role to the instance
67
+ * 5.a. compute.instances.get permission is missing
68
+ * 5.b. compute.instances.osLogin permission is missing
69
+ * 6: Rare occurrence, the exact conditions so far undetermined (together with CONNECTION_CLOSED_MESSAGE)
70
+ *
71
+ * 1, 2, 3 (yes!), 5b: result in PUBLIC_KEY_DENIED_MESSAGE
72
+ * 4: results in UNAUTHORIZED_TUNNEL_USER_MESSAGE and also CONNECTION_CLOSED_MESSAGE
73
+ * 5a: results in UNAUTHORIZED_INSTANCES_GET_MESSAGE
74
+ * 6: results in DESTINATION_READ_ERROR and also CONNECTION_CLOSED_MESSAGE
75
+ */
76
+ const UNPROVISIONED_ACCESS_MESSAGES = [
77
+ { pattern: UNAUTHORIZED_START_SESSION_MESSAGE },
78
+ { pattern: CONNECTION_CLOSED_MESSAGE },
79
+ { pattern: PUBLIC_KEY_DENIED_MESSAGE },
80
+ { pattern: UNAUTHORIZED_TUNNEL_USER_MESSAGE },
81
+ { pattern: UNAUTHORIZED_INSTANCES_GET_MESSAGE, validationWindowMs: 30e3 },
82
+ { pattern: DESTINATION_READ_ERROR },
83
+ ];
84
+ /** Checks if access has propagated through AWS to the SSM agent
85
+ *
86
+ * AWS takes about 8 minutes, GCP takes under 1 minute
87
+ * to fully resolve access after it is granted.
88
+ * During this time, calls to `aws ssm start-session` / `gcloud compute start-iap-tunnel`
89
+ * will fail randomly with an various error messages.
90
+ *
91
+ * This function checks the subprocess output to see if any of the error messages
92
+ * are printed to the error output within the first 5 seconds of startup.
93
+ * If they are, the returned `isAccessPropagated()` function will return false.
94
+ * When this occurs, the consumer of this function should retry the `aws` / `gcloud` command.
95
+ *
96
+ * Note that this function requires interception of the subprocess stderr stream.
97
+ * This works because AWS SSM wraps the session in a single-stream pty, so we
98
+ * do not capture stderr emitted from the wrapped shell session.
99
+ */
100
+ const accessPropagationGuard = (child, debug) => {
101
+ let isEphemeralAccessDeniedException = false;
102
+ let isGoogleLoginException = false;
103
+ const beforeStart = Date.now();
104
+ child.stderr.on("data", (chunk) => {
105
+ const chunkString = chunk.toString("utf-8");
106
+ if (debug)
107
+ (0, stdio_1.print2)(chunkString);
108
+ const match = UNPROVISIONED_ACCESS_MESSAGES.find((message) => chunkString.match(message.pattern));
109
+ if (match &&
110
+ Date.now() <=
111
+ beforeStart + (match.validationWindowMs || DEFAULT_VALIDATION_WINDOW_MS)) {
112
+ isEphemeralAccessDeniedException = true;
113
+ }
114
+ const googleLoginMatch = chunkString.match(GOOGLE_LOGIN_MESSAGE);
115
+ isGoogleLoginException = isGoogleLoginException || !!googleLoginMatch; // once true, always true
116
+ if (isGoogleLoginException) {
117
+ isEphemeralAccessDeniedException = false; // always overwrite to false so we don't retry the access
118
+ }
119
+ });
120
+ return {
121
+ isAccessPropagated: () => !isEphemeralAccessDeniedException,
122
+ isGoogleLoginException: () => isGoogleLoginException,
123
+ };
124
+ };
125
+ const spawnChildProcess = (credential, command, args, stdio) => (0, node_child_process_1.spawn)(command, args, {
126
+ env: Object.assign(Object.assign({}, process.env), credential),
127
+ stdio,
128
+ shell: false,
129
+ });
130
+ const friendlyProvider = (provider) => provider === "aws"
131
+ ? "AWS"
132
+ : provider === "gcloud"
133
+ ? "Google Cloud"
134
+ : (0, util_1.throwAssertNever)(provider);
135
+ /** Starts an SSM session in the terminal by spawning `aws ssm` as a subprocess
136
+ *
137
+ * Requires `aws ssm` to be installed on the client machine.
138
+ */
139
+ function spawnSshNode(options) {
140
+ return __awaiter(this, void 0, void 0, function* () {
141
+ return new Promise((resolve, reject) => {
142
+ const child = spawnChildProcess(options.credential, options.command, options.args, options.stdio);
143
+ // TODO ENG-2284 support login with Google Cloud: currently return a boolean to indicate if the exception was a Google login error.
144
+ const { isAccessPropagated, isGoogleLoginException } = accessPropagationGuard(child, options.debug);
145
+ const exitListener = child.on("exit", (code) => {
146
+ var _a;
147
+ exitListener.unref();
148
+ // In the case of ephemeral AccessDenied exceptions due to unpropagated
149
+ // permissions, continually retry access until success
150
+ if (!isAccessPropagated()) {
151
+ const attemptsRemaining = options.attemptsRemaining;
152
+ if (options.debug) {
153
+ (0, stdio_1.print2)(`Waiting for access to propagate. Retrying SSH session... (remaining attempts: ${attemptsRemaining})`);
154
+ }
155
+ if (attemptsRemaining <= 0) {
156
+ reject(`Access did not propagate through ${friendlyProvider(options.provider)} before max retry attempts were exceeded. Please contact support@p0.dev for assistance.`);
157
+ return;
158
+ }
159
+ spawnSshNode(Object.assign(Object.assign({}, options), { attemptsRemaining: attemptsRemaining - 1 }))
160
+ .then((code) => resolve(code))
161
+ .catch(reject);
162
+ return;
163
+ }
164
+ else if (isGoogleLoginException()) {
165
+ reject(`Please login to Google Cloud CLI with 'gcloud auth login'`);
166
+ return;
167
+ }
168
+ (_a = options.abortController) === null || _a === void 0 ? void 0 : _a.abort(code);
169
+ (0, stdio_1.print2)(`SSH session terminated`);
170
+ resolve(code);
171
+ });
172
+ });
173
+ });
174
+ }
175
+ const createProxyCommands = (data, args, debug) => {
176
+ let proxyCommand;
177
+ if (data.type === "aws") {
178
+ proxyCommand = [
179
+ "aws",
180
+ "ssm",
181
+ "start-session",
182
+ "--region",
183
+ data.region,
184
+ "--target",
185
+ "%h",
186
+ "--document-name",
187
+ START_SSH_SESSION_DOCUMENT_NAME,
188
+ "--parameters",
189
+ '"portNumber=%p"',
190
+ ];
191
+ }
192
+ else if (data.type === "gcloud") {
193
+ proxyCommand = [
194
+ "gcloud",
195
+ "compute",
196
+ "start-iap-tunnel",
197
+ data.id,
198
+ "%p",
199
+ // --listen-on-stdin flag is required for interactive SSH session.
200
+ // It is undocumented on page https://cloud.google.com/sdk/gcloud/reference/compute/start-iap-tunnel
201
+ // but mention on page https://cloud.google.com/iap/docs/tcp-by-host
202
+ // and also found in `gcloud ssh --dry-run` output
203
+ "--listen-on-stdin",
204
+ `--zone=${data.zone}`,
205
+ `--project=${data.projectId}`,
206
+ ];
207
+ }
208
+ else {
209
+ throw (0, util_1.assertNever)(data);
210
+ }
211
+ const commonArgs = [
212
+ ...(debug ? ["-v"] : []),
213
+ "-o",
214
+ `ProxyCommand=${proxyCommand.join(" ")}`,
215
+ ];
216
+ if ("source" in args) {
217
+ return {
218
+ command: "scp",
219
+ args: [
220
+ ...commonArgs,
221
+ // if a response is not received after three 5 minute attempts,
222
+ // the connection will be closed.
223
+ "-o",
224
+ "ServerAliveCountMax=3",
225
+ `-o`,
226
+ "ServerAliveInterval=300",
227
+ ...(args.recursive ? ["-r"] : []),
228
+ args.source,
229
+ args.destination,
230
+ ],
231
+ };
232
+ }
233
+ return {
234
+ command: "ssh",
235
+ args: [
236
+ ...commonArgs,
237
+ ...(args.A ? ["-A"] : []),
238
+ ...(args.L ? ["-L", args.L] : []),
239
+ ...(args.N ? ["-N"] : []),
240
+ `${data.linuxUserName}@${data.id}`,
241
+ ...(args.command ? [args.command] : []),
242
+ ...args.arguments.map((argument) =>
243
+ // escape all double quotes (") in commands such as `p0 ssh <instance>> echo 'hello; "world"'` because we
244
+ // need to encapsulate command arguments in double quotes as we pass them along to the remote shell
245
+ `"${String(argument).replace(/"/g, '\\"')}"`),
246
+ ],
247
+ };
248
+ };
249
+ /** Converts arguments for manual execution - arguments may have to be quoted or certain characters escaped when executing the commands from a shell */
250
+ const transformForShell = (args) => {
251
+ return args.map((arg) => {
252
+ // The ProxyCommand option must be surrounded by single quotes
253
+ if (arg.startsWith("ProxyCommand=")) {
254
+ const [name, ...value] = arg.split("="); // contains the '=' character in the parameters option: ProxyCommand=aws ssm start-session ... --parameters "portNumber=%p"
255
+ return `${name}='${value.join("=")}'`;
256
+ }
257
+ return arg;
258
+ });
259
+ };
260
+ const awsLogin = (authn, data) => __awaiter(void 0, void 0, void 0, function* () {
261
+ var _a, _b, _c, _d;
262
+ if (!(yield (0, install_1.ensureSsmInstall)())) {
263
+ throw "Please try again after installing the required AWS utilities";
264
+ }
265
+ const { config } = yield (0, config_1.getAwsConfig)(authn, data.accountId);
266
+ if (!((_a = config.login) === null || _a === void 0 ? void 0 : _a.type) || ((_b = config.login) === null || _b === void 0 ? void 0 : _b.type) === "iam") {
267
+ throw "This account is not configured for SSH access via the P0 CLI";
268
+ }
269
+ return ((_c = config.login) === null || _c === void 0 ? void 0 : _c.type) === "idc"
270
+ ? yield (0, idc_1.assumeRoleWithIdc)(data)
271
+ : ((_d = config.login) === null || _d === void 0 ? void 0 : _d.type) === "federated"
272
+ ? yield (0, aws_1.assumeRoleWithOktaSaml)(authn, data)
273
+ : (0, util_1.throwAssertNever)(config.login);
274
+ });
275
+ const sshOrScp = (authn, data, cmdArgs, privateKey) => __awaiter(void 0, void 0, void 0, function* () {
276
+ if (!privateKey) {
277
+ throw "Failed to load a private key for this request. Please contact support@p0.dev for assistance.";
278
+ }
279
+ // TODO ENG-2284 support login with Google Cloud
280
+ const credential = data.type === "aws" ? yield awsLogin(authn, data) : undefined;
281
+ return (0, ssh_agent_1.withSshAgent)(cmdArgs, () => __awaiter(void 0, void 0, void 0, function* () {
282
+ const { command, args } = createProxyCommands(data, cmdArgs, cmdArgs.debug);
283
+ if (cmdArgs.debug) {
284
+ const reproCommands = [
285
+ `eval $(ssh-agent)`,
286
+ `ssh-add "${keys_1.PRIVATE_KEY_PATH}"`,
287
+ // TODO ENG-2284 support login with Google Cloud
288
+ // TODO: Modify commands to add the ability to get permission set commands
289
+ ...(data.type === "aws" && data.access !== "idc"
290
+ ? [
291
+ `eval $(p0 aws role assume ${data.role} --account ${data.accountId})`,
292
+ ]
293
+ : []),
294
+ `${command} ${transformForShell(args).join(" ")}`,
295
+ ];
296
+ (0, stdio_1.print2)(`Execute the following commands to create a similar SSH/SCP session:\n*** COMMANDS BEGIN ***\n${reproCommands.join("\n")}\n*** COMMANDS END ***"\n`);
297
+ }
298
+ const maxRetries = data.type === "gcloud" ? GCP_MAX_SSH_RETRIES : DEFAULT_MAX_SSH_RETRIES;
299
+ return spawnSshNode({
300
+ credential,
301
+ abortController: new AbortController(),
302
+ command,
303
+ args,
304
+ stdio: ["inherit", "inherit", "pipe"],
305
+ debug: cmdArgs.debug,
306
+ provider: data.type,
307
+ attemptsRemaining: maxRetries,
308
+ });
309
+ }));
310
+ });
311
+ exports.sshOrScp = sshOrScp;
@@ -15,4 +15,8 @@ declare type SshItemConfig = {
15
15
  export declare type SshConfig = {
16
16
  "iam-write": Record<string, SshItemConfig>;
17
17
  };
18
+ export declare type CommonSshPermissionSpec = {
19
+ publicKey: string;
20
+ sudo?: boolean;
21
+ };
18
22
  export {};