diff --git a/README.md b/README.md index 24fd5e8..ae5c337 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Supports creating access requests for cloud resources, assuming AWS roles, and c - [Installation](#installation) - [Configuration](#configuration) - [Command Reference](#command-reference) +- [Example Usage](#example-usage) - [Support](#support) - [Contributing](#contributing) - [Copyright](#copyright) @@ -79,9 +80,112 @@ To view help, use the `--help` option with any command. p0 login Log in to p0 using a web browser p0 ls [arguments..] List request-command arguments p0 request [arguments..] Manually request permissions on a resource - p0 ssh SSH into a virtual machine + p0 ssh SSH into a virtual machine ``` +## Example Usage + +### Create an access request + +To view the resources available for access requests, run: + +``` +p0 request --help +``` + +Sample output: + +``` +Request access to a resource using P0 + +Commands: + p0 request aws Amazon Web Services + p0 request azure-ad Entra ID + p0 request gcloud Google Cloud + p0 request okta Okta + p0 request ssh Secure Shell (SSH) session + p0 request workspace Google Workspace + +Options: + --help Show help [boolean] + --reason Reason access is needed [string] + -w, --wait Block until the request is completed [boolean] +``` + +Run `--help` on any of these commands for information on requesting that resource. For example, to request a Google Cloud role, run + +``` +p0 request gcloud --help +``` + +``` +Google Cloud + +Commands: + p0 request gcloud resource GCP resource + p0 request gcloud role Custom or predefined role + p0 request gcloud permission GCP permissions + +Options: + --help Show help [boolean] + --reason Reason access is needed [string] + -w, --wait Block until the request is completed [boolean] +``` + +If you don't know the name of the role you need, you can use the `p0 ls` command. `p0 ls` accepts the same arguments that you provide to `p0 request` and lists the available options for access within your selected resource. For example, to view the available Google Cloud roles, run + +``` + p0 ls gcloud role names --like bigquery +``` + +Now, to request `bigquery.admin`, run: + +``` +p0 request gcloud role bigquery.admin +``` + +This will create an access request on Slack. Once your access request is approved, you will automatically get access to the Bigquery Admin role. + +### Assume an AWS IAM Role + +You can use the P0 CLI to assume a role in AWS. + +To use this feature, you will need to have installed and configured the AWS CLI. If you have not done so already, you can follow the [installation steps](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html). + +List the roles that you have permissions to assume via: + +``` +p0 aws role ls +``` + +If you don't see your desired role, you will first need to request access to it. You can do that with `p0 request aws role `. + +Once you have permissions, you can run + +``` +$(p0 aws role assume ) +``` + +### SSH into an AWS Instance + +You can request access to an AWS instance and open a SSH session once access is provisioned with a single command in the P0 CLI. + +To use this feature, you will need to have installed and configured the AWS CLI and the Session Manager plugin. If you have not done so already, you can follow the [AWS CLI installation steps](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and [Session Manager plugin installation step](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html). + +To see the available AWS instances, run: + +``` +p0 ls ssh destination +``` + +You can start a SSH session with: + +``` +p0 ssh +``` + +If you already have access, this will directly open the SSH session. Otherwise, it will request access, wait for approval, and open a SSH session once the access is provisioned. + ## Support If you encounter any issues with the P0 CLI, you can open a GitHub issue on this repo, email `support@p0.dev`, or reach out to us on our [community slack](https://join.slack.com/t/p0securitycommunity/shared_invite/zt-1zouzlovp-1kuym9RfuzkJ17ZlvAf6mQ). @@ -94,4 +198,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) Copyright © 2024-present P0 Security. -The P0 Security CLI is licensed under the terms of the GNU General Public License version 3. See [COPYING.md](COPYING.md) for details. +The P0 Security CLI is licensed under the terms of the GNU General Public License version 3. See [LICENSE.md](LICENSE.md) for details. diff --git a/package.json b/package.json index f56cbc7..bc6b643 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "p0", "README.md", "CONTRIBUTING.md", - "COPYING.md" + "LICENSE.md" ], "dependencies": { "@rgrove/parse-xml": "^4.1.0", diff --git a/src/commands/__tests__/__snapshots__/ls.test.ts.snap b/src/commands/__tests__/__snapshots__/ls.test.ts.snap index 0f96f9a..b9932b8 100644 --- a/src/commands/__tests__/__snapshots__/ls.test.ts.snap +++ b/src/commands/__tests__/__snapshots__/ls.test.ts.snap @@ -16,21 +16,31 @@ Unknown argument: foo", } `; -exports[`ls when valid ls command should print list response 1`] = ` +exports[`ls when valid ls command should print friendly message if no items: stderr 1`] = ` [ [ - "instance-1 - Instance 1", + "No destinations", ], +] +`; + +exports[`ls when valid ls command should print friendly message if no items: stdout 1`] = `[]`; + +exports[`ls when valid ls command should print list response: stderr 1`] = ` +[ [ - "instance-2 - Instance 2", + "Showing destinations:", ], ] `; -exports[`ls when valid ls command should print list response 2`] = ` +exports[`ls when valid ls command should print list response: stdout 1`] = ` [ [ - "Showing destinations:", + "instance-1 - Group / Resource 1", + ], + [ + "instance-2 - Resource 2", ], ] `; diff --git a/src/commands/__tests__/ls.test.ts b/src/commands/__tests__/ls.test.ts index 241aea3..107293e 100644 --- a/src/commands/__tests__/ls.test.ts +++ b/src/commands/__tests__/ls.test.ts @@ -23,25 +23,34 @@ const mockPrint1 = print1 as jest.Mock; const mockPrint2 = print2 as jest.Mock; describe("ls", () => { + beforeEach(() => jest.clearAllMocks()); + describe("when valid ls command", () => { const command = "ls ssh destination"; - beforeAll(() => { + const mockItems = (items: object[]) => mockFetchCommand.mockResolvedValue({ ok: true, term: "", arg: "destination", - items: [ - { key: "instance-1", value: "Instance 1" }, - { key: "instance-2", value: "Instance 2" }, - ], + items, }); - }); it("should print list response", async () => { + mockItems([ + { key: "instance-1", group: "Group", value: "Resource 1" }, + { key: "instance-2", value: "Resource 2" }, + ]); + await lsCommand(yargs()).parse(command); + expect(mockPrint1.mock.calls).toMatchSnapshot("stdout"); + expect(mockPrint2.mock.calls).toMatchSnapshot("stderr"); + }); + + it("should print friendly message if no items", async () => { + mockItems([]); await lsCommand(yargs()).parse(command); - expect(mockPrint1.mock.calls).toMatchSnapshot(); - expect(mockPrint2.mock.calls).toMatchSnapshot(); + expect(mockPrint1.mock.calls).toMatchSnapshot("stdout"); + expect(mockPrint2.mock.calls).toMatchSnapshot("stderr"); }); }); diff --git a/src/commands/ls.ts b/src/commands/ls.ts index 177facb..469dbce 100644 --- a/src/commands/ls.ts +++ b/src/commands/ls.ts @@ -54,10 +54,14 @@ const ls = async ( const allArguments = [...args._, ...args.arguments]; if (data && "ok" in data && data.ok) { + const label = pluralize(data.arg); + if (data.items.length === 0) { + print2(`No ${label}`); + return; + } const truncationPart = data.isTruncated ? ` the first ${data.items.length}` : ""; - const argPart = pluralize(data.arg); const postfixPart = data.term ? ` matching '${data.term}'` : data.isTruncated @@ -65,7 +69,7 @@ const ls = async ( ${allArguments.join(" ")} \` to narrow results)` : ""; - print2(`Showing${truncationPart} ${argPart}${postfixPart}:`); + print2(`Showing${truncationPart} ${label}${postfixPart}:`); const isSameValue = data.items.every((i) => !i.group && i.key === i.value); const maxLength = max(data.items.map((i) => i.key.length)) || 0; for (const item of data.items) { diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index d1237dc..2033a0a 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -28,11 +28,15 @@ import { pick } from "lodash"; import yargs from "yargs"; export type SshCommandArgs = { - instance: string; + destination: string; command?: string; + L?: string; // port forwarding option arguments: string[]; }; +// Matches strings with the pattern "digits:digits" (e.g. 1234:5678) +const LOCAL_PORT_FORWARD_PATTERN = /^\d+:\d+$/; + /** Maximum amount of time to wait after access is approved to wait for access * to be configured */ @@ -40,11 +44,11 @@ const GRANT_TIMEOUT_MILLIS = 60e3; export const sshCommand = (yargs: yargs.Argv) => yargs.command( - "ssh [command [arguments..]]", + "ssh [command [arguments..]]", "SSH into a virtual machine", (yargs) => yargs - .positional("instance", { + .positional("destination", { type: "string", demandOption: true, }) @@ -57,6 +61,20 @@ export const sshCommand = (yargs: yargs.Argv) => array: true, string: true, default: [] as string[], + }) + .check((argv: yargs.ArgumentsCamelCase) => { + if (argv.L == null) return true; + + return ( + argv.L.match(LOCAL_PORT_FORWARD_PATTERN) || + "Local port forward should be in the format `local_port:remote_port`" + ); + }) + .option("L", { + type: "string", + describe: + // the order of the sockets in the address matches the ssh man page + "Forward a local port to the remote host; `local_socket:remote_socket`", }), guard(ssh) ); @@ -125,12 +143,13 @@ const waitForProvisioning = async

( * - AWS EC2 via SSM with Okta SAML */ const ssh = async (args: yargs.ArgumentsCamelCase) => { + // 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( { ...pick(args, "$0", "_"), - arguments: ["ssh", args.instance, "--provider", "aws"], + arguments: ["ssh", args.destination, "--provider", "aws"], wait: true, }, authn, diff --git a/src/plugins/aws/ssm/index.ts b/src/plugins/aws/ssm/index.ts index 896cbd8..fdb677a 100644 --- a/src/plugins/aws/ssm/index.ts +++ b/src/plugins/aws/ssm/index.ts @@ -37,6 +37,9 @@ const MAX_SSM_RETRIES = 30; const INSTANCE_ARN_PATTERN = /^arn:aws:ssm:([^:]+):([^:]+):managed-instance\/([^:]+)$/; +/** The name of the SessionManager port forwarding document. This document is managed by AWS. */ +const LOCAL_PORT_FORWARDING_DOCUMENT_NAME = "AWS-StartPortForwardingSession"; + type SsmArgs = { instance: string; region: string; @@ -44,6 +47,7 @@ type SsmArgs = { documentName: string; credential: AwsCredentials; command?: string; + forwardPortAddress?: string; }; /** Checks if access has propagated through AWS to the SSM agent @@ -88,6 +92,12 @@ const accessPropagationGuard = ( }; const createSsmCommand = (args: Omit) => { + const hasCommand = args.command && args.command.trim(); + + if (hasCommand && args.forwardPortAddress) { + throw "Invalid arguments. Specify either a command or port forwarding, not both."; + } + const ssmCommand = [ "aws", "ssm", @@ -97,11 +107,22 @@ const createSsmCommand = (args: Omit) => { "--target", args.instance, "--document-name", - args.documentName, + // Port forwarding is a special case that uses an AWS-managed document and + // not the user-generated document we use for an SSH session + args.forwardPortAddress + ? LOCAL_PORT_FORWARDING_DOCUMENT_NAME + : args.documentName, ]; - if (args.command && args.command.trim()) { + if (hasCommand) { ssmCommand.push("--parameters", `command='${args.command}'`); + } else if (args.forwardPortAddress) { + const [localPort, remotePort] = args.forwardPortAddress.split(":"); + + ssmCommand.push( + "--parameters", + `localPortNumber=${localPort},portNumber=${remotePort}` + ); } return ssmCommand; @@ -193,6 +214,7 @@ export const ssm = async ( region: region!, documentName: request.generated.documentName, requestId: request.id, + forwardPortAddress: args.L, credential, command: commandParameter(args), };