Skip to content

Commit

Permalink
ssm: Guide install of aws binaries
Browse files Browse the repository at this point in the history
Make a user successful if they have not installed `aws ssm`.

Detects required aws binaries at ssh connection time. If these are missing,
prompt for installation.

Currently supports only MacOS ("darwin").

Also moves some SSM-specific code from the ssh command to the ssm plugin
file.
  • Loading branch information
nbrahms committed Feb 25, 2024
1 parent b23143e commit 834f72d
Show file tree
Hide file tree
Showing 6 changed files with 474 additions and 33 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,25 @@
"dotenv": "^16.4.1",
"express": "^4.18.2",
"firebase": "^10.7.2",
"inquirer": "^9.2.15",
"jsdom": "^24.0.0",
"lodash": "^4.17.21",
"open": "^8.4.0",
"pkce-challenge": "^4.1.0",
"pluralize": "^8.0.0",
"typescript": "^4.8.4",
"which": "^4.0.0",
"yargs": "^17.6.0"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/express": "^4.17.21",
"@types/inquirer": "^9.0.7",
"@types/jest": "^29.5.12",
"@types/jsdom": "^21.1.6",
"@types/lodash": "^4.14.202",
"@types/node": "^18.11.7",
"@types/which": "^3.0.3",
"@types/yargs": "^17.0.13",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.0.0",
Expand Down
21 changes: 5 additions & 16 deletions src/commands/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { getDoc, onSnapshot } from "firebase/firestore";
import { pick } from "lodash";
import yargs from "yargs";

type SshCommandArgs = {
export type SshCommandArgs = {
instance: string;
command?: string;
arguments: string[];
Expand Down Expand Up @@ -125,7 +125,6 @@ const waitForProvisioning = async <P extends PluginRequest>(
* - AWS EC2 via SSM with Okta SAML
*/
const ssh = async (args: yargs.ArgumentsCamelCase<SshCommandArgs>) => {
// Prefix is required because the backend uses it to determine that this is an AWS request
const authn = await authenticate();
await validateSshInstall(authn);
const response = await request(
Expand All @@ -143,19 +142,9 @@ const ssh = async (args: yargs.ArgumentsCamelCase<SshCommandArgs>) => {
}
const { id, isPreexisting } = response;
if (!isPreexisting) print2("Waiting for access to be provisioned");

const requestData = await waitForProvisioning<AwsSsh>(authn, id);
await ssm(authn, {
...requestData,
id,
command: args.command
? `${args.command} ${args.arguments
.map(
(argument) =>
// escape all double quotes (") in commands such as `p0 ssh <instance>> echo 'hello; "world"'` because we
// need to encapsulate command arguments in double quotes as we pass them along to the remote shell
`"${argument.replace(/"/g, '\\"')}"`
)
.join(" ")}`.trim()
: undefined,
});
const requestWithId = { ...requestData, id };

await ssm(authn, requestWithId, args);
};
40 changes: 31 additions & 9 deletions src/plugins/aws/ssm.ts → src/plugins/aws/ssm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ This file is part of @p0security/p0cli
You should have received a copy of the GNU General Public License along with @p0security/p0cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { print2 } from "../../drivers/stdio";
import { Authn } from "../../types/identity";
import { Request } from "../../types/request";
import { assumeRoleWithOktaSaml } from "../okta/aws";
import { AwsCredentials, AwsSsh } from "./types";
import { SshCommandArgs } from "../../../commands/ssh";
import { print2 } from "../../../drivers/stdio";
import { Authn } from "../../../types/identity";
import { Request } from "../../../types/request";
import { assumeRoleWithOktaSaml } from "../../okta/aws";
import { AwsCredentials, AwsSsh } from "../types";
import { ensureSsmInstall } from "./install";
import { ChildProcessByStdio, spawn } from "node:child_process";
import { Readable } from "node:stream";

Expand Down Expand Up @@ -153,11 +155,31 @@ const spawnSsmNode = async (
});
});

/** Convert an SshCommandArgs into an SSM document "command" parameter */
const commandParameter = (args: SshCommandArgs) =>
args.command
? `${args.command} ${args.arguments
.map(
(argument) =>
// escape all double quotes (") in commands such as `p0 ssh <instance>> echo 'hello; "world"'` because we
// need to encapsulate command arguments in double quotes as we pass them along to the remote shell
`"${argument.replace(/"/g, '\\"')}"`
)
.join(" ")}`.trim()
: undefined;

/** Connect to an SSH backend using AWS Systems Manager (SSM) */
export const ssm = async (
authn: Authn,
request: Request<AwsSsh> & { id: string; command?: string }
request: Request<AwsSsh> & {
id: string;
},
args: SshCommandArgs
) => {
const isInstalled = await ensureSsmInstall();
if (!isInstalled)
throw "Please try again after installing the required AWS utilities";

const match = request.permission.spec.arn.match(INSTANCE_ARN_PATTERN);
if (!match) throw "Did not receive a properly formatted instance identifier";
const [, region, account, instance] = match;
Expand All @@ -166,13 +188,13 @@ export const ssm = async (
account,
role: request.generatedRoles[0]!.role,
});
const args = {
const ssmArgs = {
instance: instance!,
region: region!,
documentName: request.generated.documentName,
requestId: request.id,
credential,
command: request.command,
command: commandParameter(args),
};
await spawnSsmNode(args);
await spawnSsmNode(ssmArgs);
};
143 changes: 143 additions & 0 deletions src/plugins/aws/ssm/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/** Copyright © 2024-present P0 Security
This file is part of @p0security/p0cli
@p0security/p0cli 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.
@p0security/p0cli 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.
You should have received a copy of the GNU General Public License along with @p0security/p0cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { print1, print2 } from "../../../drivers/stdio";
import { isa } from "../../../types";
import { compact } from "lodash";
import { spawn } from "node:child_process";
import os from "node:os";
import { sys } from "typescript";
import which from "which";

const SupportedPlatforms = ["darwin"] as const;
type SupportedPlatform = (typeof SupportedPlatforms)[number];

const AwsItems = ["aws", "session-manager-plugin"] as const;
type AwsItem = (typeof AwsItems)[number];

const AwsInstall: Readonly<
Record<
AwsItem,
{ label: string; commands: Record<SupportedPlatform, Readonly<string[]>> }
>
> = {
aws: {
label: "AWS CLI v2",
commands: {
darwin: [
'curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"',
"sudo installer -pkg AWSCLIV2.pkg -target /",
'rm "AWSCLIV2.pkg"',
],
},
},
"session-manager-plugin": {
label: "the AWS CLI Session Manager plugin",
commands: {
darwin: [
'curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac/session-manager-plugin.pkg" -o "session-manager-plugin.pkg"',
"sudo installer -pkg session-manager-plugin.pkg -target /",
"sudo ln -s /usr/local/sessionmanagerplugin/bin/session-manager-plugin /usr/local/bin/session-manager-plugin",
'rm "session-manager-plugin.pkg"',
],
},
},
};

const printToInstall = (toInstall: AwsItem[]) => {
print2("The following items must be installed on your system to continue:");
for (const item of toInstall) {
print2(` - ${AwsInstall[item].label}`);
}
print2("");
};

const queryInteractive = async () => {
const inquirer = (await import("inquirer")).default;
const { isGuided } = await inquirer.prompt([
{
type: "confirm",
name: "isGuided",
message: "Do you want P0 to install these for you?",
},
]);
print2("");
return isGuided;
};

const requiredInstalls = async () =>
compact(
await Promise.all(
AwsItems.map(async (item) =>
(await which(item, { nothrow: true })) === null ? item : undefined
)
)
);

const printInstallCommands = (platform: SupportedPlatform, item: AwsItem) => {
const { label, commands } = AwsInstall[item];
print2(`To install ${label}, run the following commands:\n`);
for (const command of commands[platform]) {
print1(` ${command}`);
}
print1(""); // Newline is useful for reading command output in a script, so send to /fd/1
};

const guidedInstall = async (platform: SupportedPlatform, item: AwsItem) => {
const commands = AwsInstall[item].commands[platform];

const combined = commands.join(" && \\\n");

print2(`Executing:\n${combined}`);
print2("");

await new Promise<void>((resolve, reject) => {
const child = spawn("bash", ["-c", combined], { stdio: "inherit" });
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(`Shell exited with code ${code}`);
});
});

print2("");
};

/** Ensures that AWS CLI and SSM plugin are installed on the user environment
*
* If they are not, and the session is a TTY, prompt the user to auto-install. If
* the user declines, or if not a TTY, the installation commands are printed to
* stdout.
*/
export const ensureSsmInstall = async () => {
const platform = os.platform();

if (!isa(SupportedPlatforms)(platform))
throw "SSH to AWS managed instances is only available on MacOS";

const toInstall = await requiredInstalls();
if (toInstall.length === 0) return true;

printToInstall(toInstall);

const interactive = !!sys.writeOutputIsTTY?.() && (await queryInteractive());

for (const item of toInstall) {
if (interactive) await guidedInstall(platform, item);
else printInstallCommands(platform, item);
}

const remaining = await requiredInstalls();

if (remaining.length === 0) {
print2("All packages successfully installed");
return true;
}
return false;
};
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** Copyright © 2024-present P0 Security
This file is part of @p0security/p0cli
@p0security/p0cli 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.
@p0security/p0cli 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.
You should have received a copy of the GNU General Public License along with @p0security/p0cli. If not, see <https://www.gnu.org/licenses/>.
**/

export const isa =
<T>(values: readonly T[]) =>
(item: any): item is T =>
values.includes(item);
Loading

0 comments on commit 834f72d

Please sign in to comment.