-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
26 changed files
with
1,615 additions
and
789 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
plugins: | ||
- '@trivago/prettier-plugin-sort-imports' | ||
trailingComma: es5 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")); | ||
}; |
Oops, something went wrong.