Skip to content

Commit

Permalink
Version 0.2.0
Browse files Browse the repository at this point in the history
This commit represents a major overhaul of the P0 CLI.

Major changes:
- Login supports login via either P0-brokered SSO or Okta OIDC
- Adds support for assuming AWS roles via Okta SAML federation
  - Also adds a command that will list available assumable roles
- Adds automaticate re-login on token expiration

Minor changes:
- Moves the CLI config path to `~/.p0` from `~/.p0cli`
- Stores OIDC credentials directly, rather than Firestore credentials
- Adds a `guard` utility, which prevents Firestore from blocking
  proper command return
- Removes unused `snowflake` command

Required changes to p0 app:
- The org document requires additional data:
  - `clientId` - The SSO provider's OIDC client ID
  - `providerDomain` - Required for Okta login only; this must be the
    Okta domain of the org's Okta authorization server
- OIDC login will need to support secret-free PKCE login (this is
  not supported by our current built-in Firebase auth flow)
  • Loading branch information
nbrahms committed Feb 3, 2024
1 parent bbc3f70 commit bbbe918
Show file tree
Hide file tree
Showing 26 changed files with 1,615 additions and 789 deletions.
3 changes: 3 additions & 0 deletions .prettierrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
plugins:
- '@trivago/prettier-plugin-sort-imports'
trailingComma: es5
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Contributing to the P0 CLI

Thank you for your interest in contributing to the P0 CLI!

## CLA

All contributors must first sign P0's contributor license agreement. This ensures
that your contribution is properly licensed under the CLI's open-source license
agreement.

Send an email to support@p0.dev, or submit a PR via a GitHub user with a valid email
address, and we'll hook you up.

## Guidelines

Since this project is a user-consumable command-line interface, we ask that contributions
follow certain guidelines, in order to ensure usability of the CLI:

- All console text not meant to be consumed by a machine should be emmitted via `console.error`
- Text that will be consumed by a machine, or either a machine or a human, should be emmitted
via `console.log`
- Client-usage errors (that is, errors that provide feedback to the user of expected CLI misuse)
should be emmitted using `throw <message>`; this prevents a stack trace from being shown
2 changes: 1 addition & 1 deletion p0
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env node --no-deprecation

const originalEmit = process.emit;
process.emit = function (name, data, ...args) {
Expand Down
15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
{
"name": "p0cli",
"version": "0.1.0",
"version": "0.2.0",
"description": "Execute infra CLI commands with P0 grants",
"main": "index.ts",
"repository": "https://github.com/p0-security/p0cli",
"author": "P0 Security",
"license": "Proprietary",
"private": true,
"dependencies": {
"firebase": "^9.13.0",
"@rgrove/parse-xml": "^4.1.0",
"firebase": "^10.7.2",
"jsdom": "^24.0.0",
"lodash": "^4.17.21",
"open": "^8.4.0",
"typescript": "^4.8.4",
"yargs": "^17.6.0"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/jsdom": "^21.1.6",
"@types/lodash": "^4.14.202",
"@types/node": "^18.11.7",
"@types/yargs": "^17.0.13"
"@types/yargs": "^17.0.13",
"prettier": "^3.2.4"
},
"scripts": {
"build": "tsc",
"p0cli": "node ./build/index.js"
"p0": "node --no-deprecation ./p0"
}
}
17 changes: 17 additions & 0 deletions src/commands/aws/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { sts } from "./sts";
import yargs from "yargs";

const awsCommands = [sts];

const awsArgs = (yargs: yargs.Argv) => {
const base = yargs
.option("account", {
type: "string",
describe: "AWS account ID or alias (or set P0_AWS_ACCOUNT)",
})
.env("P0_AWS");
return awsCommands.reduce((m, c) => c(m), base).demandCommand(1);
};

export const awsCommand = (yargs: yargs.Argv) =>
yargs.command("aws", "Execute AWS commands", awsArgs);
130 changes: 130 additions & 0 deletions src/commands/aws/sts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { parseXml } from "../../../common/xml";
import { guard } from "../../../drivers/firestore";
import { assumeRoleWithSaml } from "../../../plugins/aws/assumeRole";
import { getAwsConfig } from "../../../plugins/aws/config";
import {
AwsItemConfig,
AwsOktaSamlUidLocation,
} from "../../../plugins/aws/types";
import { getSamlResponse } from "../../../plugins/okta/login";
import { identity, uniq } from "lodash";
import { sys } from "typescript";
import yargs from "yargs";

export const sts = (yargs: yargs.Argv<{ account: string | undefined }>) =>
yargs.command("role", "Interact with AWS roles", (yargs) =>
yargs
.command(
"ls",
"List available AWS roles",
identity,
guard(oktaAwsListRoles)
)
.command(
"assume <role>",
"Assume an AWS role",
(y: yargs.Argv<{ account: string | undefined }>) =>
y.positional("role", {
type: "string",
demandOption: true,
describe: "An AWS role name",
}),
guard(oktaAwsAssumeRole)
)
.demandCommand(1)
);

const isOktaSamlConfig = (
config: AwsItemConfig
): config is AwsItemConfig & { uidLocation: AwsOktaSamlUidLocation } =>
config.uidLocation?.id === "okta_saml_sso";

/** Retrieves the configured Okta SAML response for the specified account
*
* If no account is passed, and the organization only has one account configured,
* assumes that account.
*/
const initOktaSaml = async (account: string | undefined) => {
const { identity, config } = await getAwsConfig(account);
if (!isOktaSamlConfig(config))
throw `Account ${account} is not configured for Okta SAML login.`;
const samlResponse = await getSamlResponse(identity, config.uidLocation);
return {
samlResponse,
config,
account: config.account.id,
};
};

/** Assumes a role in AWS via Okta SAML federation.
*
* Prerequisites:
* - AWS is configured with a SAML identity provider
* - This identity provider is integrated with a
* "AWS SAML Account Federation" app in Okta
* - The AWS SAML identity provider name, Okta domain,
* and Okta SAML app identifier are all contained in
* the user's identity blob
* - The requested role is assigned to the user in Okta
*/
const oktaAwsAssumeRole = async (args: { account?: string; role: string }) => {
const { account, config, samlResponse } = await initOktaSaml(args.account);
const stsXml = await assumeRoleWithSaml({
account,
role: args.role,
saml: {
providerName: config.uidLocation.samlProviderName,
response: samlResponse,
},
});
const stsObject = parseXml(stsXml);
const stsCredentials =
stsObject.AssumeRoleWithSAMLResponse.AssumeRoleWithSAMLResult.Credentials;
const isTty = sys.writeOutputIsTTY?.();
if (isTty) console.error("Execute the following commands:\n");
const indent = isTty ? " " : "";
console.log(`${indent}export AWS_ACCESS_KEY_ID=${stsCredentials.AccessKeyId}
${indent}export AWS_SECRET_ACCESS_KEY=${stsCredentials.SecretAccessKey}
${indent}export AWS_SESSION_TOKEN=${stsCredentials.SessionToken}`);
if (isTty)
console.error(`
Or, populate these environment variables using BASH command substitution:
$(p0 aws${args.account ? ` --account ${args.account}` : ""} role assume ${
args.role
})
`);
};

/** Lists assigned AWS roles for this user on this account */
const oktaAwsListRoles = async (args: { account?: string }) => {
const { account, samlResponse } = await initOktaSaml(args.account);
const samlText = Buffer.from(samlResponse, "base64").toString("ascii");
const samlObject = parseXml(samlText);
const samlAttributes =
samlObject["saml2p:Response"]["saml2:Assertion"][
"saml2:AttributeStatement"
]["saml2:Attribute"];
const roleAttribute = samlAttributes.find(
(a: any) =>
a._attributes.Name === "https://aws.amazon.com/SAML/Attributes/Role"
);
// Format:
// 'arn:aws:iam::391052057035:saml-provider/p0dev-ext_okta_sso,arn:aws:iam::391052057035:role/SSOAmazonS3FullAccess'
const arns = (roleAttribute?.["saml2:AttributeValue"] as string[])?.map(
(r) => r.split(",")[1]!
);
const roles = arns
.filter((r) => r.startsWith(`arn:aws:iam::${account}:role/`))
.map((r) => r.split("/")[1]!);
const isTty = sys.writeOutputIsTTY?.();
if (isTty) console.error(`Your available roles for account ${account}:`);
if (!roles?.length) {
const accounts = uniq(arns.map((a) => a.split(":")[4])).sort();
throw `No roles found. You have roles on these accounts:\n${accounts.join(
"\n"
)}`;
}
const indent = isTty ? " " : "";
console.log(roles.map((r) => `${indent}${r}`).join("\n"));
};
Loading

0 comments on commit bbbe918

Please sign in to comment.