UNPKG

@cloud-copilot/iam-lens

Version:

Visibility in IAM in and across AWS accounts

552 lines 24.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.whoCan = whoCan; exports.uniqueAccountsToCheck = uniqueAccountsToCheck; exports.accountsToCheckBasedOnResourcePolicy = accountsToCheckBasedOnResourcePolicy; exports.actionsForWhoCan = actionsForWhoCan; exports.lookupActionsForResourceArn = lookupActionsForResourceArn; exports.findResourceTypeForArn = findResourceTypeForArn; exports.convertResourcePatternToRegex = convertResourcePatternToRegex; exports.sortWhoCanResults = sortWhoCanResults; const iam_data_1 = require("@cloud-copilot/iam-data"); const iam_policy_1 = require("@cloud-copilot/iam-policy"); const iam_utils_1 = require("@cloud-copilot/iam-utils"); const arn_js_1 = require("../utils/arn.js"); const sts_js_1 = require("../utils/sts.js"); const WhoCanProcessor_js_1 = require("./WhoCanProcessor.js"); /** * Processes a single whoCan request by creating a temporary WhoCanProcessor, * enqueuing the request, waiting for it to settle, and shutting down. This * preserves the original one-shot behavior where workers and cache are created * and destroyed per call. * * For better performance when running multiple requests, use WhoCanProcessor * directly to keep workers and cache alive across calls. * * @param collectConfigs the collect configurations for loading IAM data * @param partition the AWS partition (e.g. 'aws', 'aws-cn') * @param request the whoCan request parameters * @returns the whoCan response with allowed principals and optional deny details */ async function whoCan(collectConfigs, partition, request) { let settledEvent; const processor = await WhoCanProcessor_js_1.WhoCanProcessor.create({ collectConfigs, partition, tuning: { workerThreads: request.workerThreads }, ignorePrincipalIndex: request.ignorePrincipalIndex, clientFactoryPlugin: request.clientFactoryPlugin, workerBootstrapPlugin: request.workerBootstrapPlugin, s3AbacOverride: request.s3AbacOverride, collectGrantDetails: !!request.collectGrantDetails, onRequestSettled: async (event) => { settledEvent = event; } }); try { processor.enqueueWhoCan({ resource: request.resource, resourceAccount: request.resourceAccount, actions: request.actions, sort: request.sort, denyDetailsCallback: request.denyDetailsCallback, principalScope: request.principalScope, strictContextKeys: request.strictContextKeys }); await processor.waitForIdle(); if (!settledEvent) { throw new Error('whoCan request did not settle'); } if (settledEvent.status === 'rejected') { throw settledEvent.error; } return settledEvent.result; } finally { await processor.shutdown(); } } async function uniqueAccountsToCheck(collectClient, accountsToCheck) { const returnValue = { accountsNotFound: [], organizationsNotFound: [], organizationalUnitsNotFound: [], accounts: [] }; if (accountsToCheck.allAccounts) { returnValue.accounts = await collectClient.allAccounts(); return returnValue; } const uniqueAccounts = new Set(); for (const account of accountsToCheck.specificAccounts || []) { const accountExists = await collectClient.accountExists(account); if (accountExists) { uniqueAccounts.add(account); } else { returnValue.accountsNotFound.push(account); } } for (const ouPath of accountsToCheck.specificOrganizationalUnits || []) { const parts = ouPath.split('/'); const orgId = parts[0]; const pathParts = parts.slice(1); const [found, accounts] = await collectClient.getAccountsForOrgPath(orgId, pathParts); for (const account of accounts) { uniqueAccounts.add(account); } if (!found) { returnValue.organizationalUnitsNotFound.push(ouPath); } } for (const orgId of accountsToCheck.specificOrganizations || []) { const [found, accounts] = await collectClient.getAccountsForOrganization(orgId); for (const account of accounts) { uniqueAccounts.add(account); } if (!found) { returnValue.organizationsNotFound.push(orgId); } } returnValue.accounts = Array.from(uniqueAccounts); return returnValue; } /** * Splits an ARN-like string on `:` while treating `${...}` blocks as opaque. * Colons inside `${...}` dynamic variable references are not used as split points. * * For example, `arn:${aws:Partition}:iam::999999999999:role/*` splits into * `['arn', '${aws:Partition}', 'iam', '', '999999999999', 'role/*']`. * * @param value - The raw ARN string, possibly containing `${...}` references. * @returns An array of colon-delimited segments. */ function splitArnIgnoringDynamicVars(value) { const segments = []; let current = ''; let depth = 0; for (let i = 0; i < value.length; i++) { const ch = value[i]; if (ch === '$' && i + 1 < value.length && value[i + 1] === '{') { depth++; current += '${'; i++; // skip the '{' } else if (ch === '}' && depth > 0) { depth--; current += '}'; } else if (ch === ':' && depth === 0) { segments.push(current); current = ''; } else { current += ch; } } segments.push(current); return segments; } const PRINCIPAL_ARN_PATTERN_OPERATORS = new Set(['stringlike', 'arnequals', 'arnlike']); /** * Checks whether a string contains any wildcard or dynamic variable characters * (`*`, `?`, or `$`). * * @param value - The string to check. * @returns `true` if the string contains `*`, `?`, or `$`. */ function hasWildcardOrDynamic(value) { return value.includes('*') || value.includes('?') || value.includes('$'); } /** The 3 scalar condition keys that are only populated for service principal requests. */ const UNNAMED_SERVICE_SCALAR_KEYS = new Set([ 'aws:sourceaccount', 'aws:sourceowner', 'aws:sourceorgid' ]); /** * Checks whether a positive operator is used on a scalar service-principal-only key. * Accepts `StringEquals` family and `StringLike`. * * @param op - The condition operation to check. * @returns `true` if the operator is a positive match for a scalar key. */ function isPositiveScalarOperator(op) { return (op.value().toLowerCase().startsWith('stringequals') || op.baseOperator().toLowerCase() === 'stringlike'); } /** * Checks whether a positive operator is used on the `aws:SourceOrgPaths` array key. * Only `ForAnyValue:StringEquals*` and `ForAnyValue:StringLike` qualify. * `ForAllValues` and plain operators without a set operator do not. * * @param op - The condition operation to check. * @returns `true` if the operator is a valid positive match for the array key. */ function isPositiveOrgPathsOperator(op) { if (op.setOperator() !== 'ForAnyValue') return false; const base = op.baseOperator().toLowerCase(); return base.startsWith('stringequals') || base === 'stringlike'; } /** * Inspects a statement's conditions to determine if the statement effectively * requires an AWS service principal. Used for wildcard-principal Allow statements * to avoid unnecessarily widening the whoCan search scope. * * @param conditions - The conditions from the statement to inspect. * @returns A classification indicating whether the statement is not service-only, * requires an unnamed service principal (skip entirely), or names specific * service principals (extract for simulation). */ function checkForServicePrincipalConditions(conditions) { let hasUnnamedServiceKey = false; const namedServicePrincipals = []; for (const cond of conditions) { const key = cond.conditionKey().toLowerCase(); const op = cond.operation(); if (op.isIfExists()) continue; // Category 1a: Scalar unnamed keys (aws:SourceAccount, aws:SourceOwner, aws:SourceOrgID) if (UNNAMED_SERVICE_SCALAR_KEYS.has(key) && isPositiveScalarOperator(op)) { hasUnnamedServiceKey = true; } // Category 1b: Array unnamed key (aws:SourceOrgPaths) — requires ForAnyValue if (key === 'aws:sourceorgpaths' && isPositiveOrgPathsOperator(op)) { hasUnnamedServiceKey = true; } // Category 1c: aws:PrincipalIsAWSService with Bool or StringEquals and value 'true' // Multiple condition values are ORed, so mixed ['true', 'false'] is NOT service-only. // All values must be 'true' for the condition to exclusively require a service principal. if (key === 'aws:principalisawsservice') { const baseOp = op.baseOperator().toLowerCase(); const opVal = op.value().toLowerCase(); const isBoolOrStringEquals = baseOp === 'bool' || opVal.startsWith('stringequals'); const values = cond.conditionValues(); if (isBoolOrStringEquals && values.length > 0 && values.every((v) => v.toLowerCase() === 'true')) { hasUnnamedServiceKey = true; } } // Category 2: aws:PrincipalServiceName — extract named service principals if (key === 'aws:principalservicename' && op.value().toLowerCase().startsWith('stringequals') && !cond.conditionValues().some((v) => v.includes('$'))) { namedServicePrincipals.push(...cond.conditionValues()); } } // Named takes priority — the simulator fills aws:SourceAccount etc. for service principals if (namedServicePrincipals.length > 0) { return { type: 'named-service-only', principals: namedServicePrincipals }; } if (hasUnnamedServiceKey) { return { type: 'unnamed-service-only' }; } return { type: 'not-service-only' }; } async function accountsToCheckBasedOnResourcePolicy(resourcePolicy, resourceAccount) { const accountsToCheck = { allAccounts: false, specificAccounts: [], specificPrincipals: [], specificOrganizations: [], specificOrganizationalUnits: [], checkAnonymous: false, checkAllFromResourceAccount: false, resourceAccountTrustedByPolicy: false }; if (!resourcePolicy) { return accountsToCheck; } const policy = (0, iam_policy_1.loadPolicy)(resourcePolicy); for (const statement of policy.statements()) { if (statement.isAllow() && statement.isNotPrincipalStatement()) { accountsToCheck.allAccounts = true; accountsToCheck.checkAllFromResourceAccount = true; accountsToCheck.resourceAccountTrustedByPolicy = true; } if (statement.isAllow() && statement.isPrincipalStatement()) { const principals = statement.principals(); let hasWildcardPrincipal = false; for (const principal of principals) { if (principal.isWildcardPrincipal()) { hasWildcardPrincipal = true; } else if (principal.isAccountPrincipal()) { accountsToCheck.specificAccounts.push(principal.accountId()); if (principal.accountId() === resourceAccount) { accountsToCheck.resourceAccountTrustedByPolicy = true; } } else { accountsToCheck.specificPrincipals.push(convertSessionArnToRoleArn(principal.value())); } } if (hasWildcardPrincipal) { const serviceCheck = checkForServicePrincipalConditions(statement.conditions()); if (serviceCheck.type === 'unnamed-service-only') { continue; } if (serviceCheck.type === 'named-service-only') { accountsToCheck.specificPrincipals.push(...serviceCheck.principals); continue; } const specificOrgs = []; const specificOus = []; const specificAccounts = []; const specificPrincipals = []; const conditions = statement.conditions(); for (const cond of conditions) { const condKey = cond.conditionKey().toLowerCase(); if (condKey === 'aws:principalorgid' && cond.operation().value().toLowerCase().startsWith('stringequals') && !cond.conditionValues().some((v) => v.includes('$'))) { specificOrgs.push(...cond.conditionValues()); } if (condKey === 'aws:principalorgpaths' && cond.operation().baseOperator().toLowerCase().startsWith('stringequals') && !cond.conditionValues().some((v) => v.includes('$'))) { specificOus.push(...cond.conditionValues()); } if (condKey === 'aws:principalaccount' || condKey === 'kms:calleraccount') { const opVal = cond.operation().value().toLowerCase(); const baseOp = cond.operation().baseOperator().toLowerCase(); const values = cond.conditionValues(); const hasDynamic = values.some((v) => v.includes('$')); if (opVal.startsWith('stringequals') && !hasDynamic) { // StringEquals family — all values are literal account IDs specificAccounts.push(...values); } else if (baseOp === 'stringlike' && !hasDynamic && values.every((v) => !v.includes('*') && !v.includes('?'))) { // StringLike where ALL values are literal (no wildcards or dynamic vars) specificAccounts.push(...values); } } if (condKey === 'aws:principalarn') { const opValue = cond.operation().value().toLowerCase(); const baseOp = cond.operation().baseOperator().toLowerCase(); const isExactOperator = opValue.startsWith('stringequals'); const isPatternOperator = PRINCIPAL_ARN_PATTERN_OPERATORS.has(baseOp); if (!isExactOperator && !isPatternOperator) { continue; } if (cond.operation().isIfExists()) { accountsToCheck.checkAnonymous = true; } for (const value of cond.conditionValues()) { if (!hasWildcardOrDynamic(value)) { // Exact literal — push as a specific principal specificPrincipals.push(value); } else if (isExactOperator && !value.includes('*') && !value.includes('?')) { // Exact operator with a dynamic variable but no wildcards — try account extraction const segments = splitArnIgnoringDynamicVars(value); if (segments.length >= 6 && segments[0].toLowerCase() === 'arn') { const account = segments[4]; if (account && !hasWildcardOrDynamic(account)) { specificAccounts.push(account); } } } else { // Pattern operator or value with wildcards — try account extraction const segments = splitArnIgnoringDynamicVars(value); if (segments.length >= 6 && segments[0].toLowerCase() === 'arn') { const account = segments[4]; if (account && !hasWildcardOrDynamic(account)) { specificAccounts.push(account); } } } } } } if (specificPrincipals.length > 0) { accountsToCheck.specificPrincipals.push(...specificPrincipals); } if (specificAccounts.length > 0) { accountsToCheck.specificAccounts.push(...specificAccounts); if (resourceAccount && specificAccounts.includes(resourceAccount)) { accountsToCheck.resourceAccountTrustedByPolicy = true; accountsToCheck.checkAllFromResourceAccount = true; } } else if (specificOus.length > 0) { accountsToCheck.specificOrganizationalUnits.push(...specificOus); // The resource account may be in these OUs; conservatively assume trusted accountsToCheck.resourceAccountTrustedByPolicy = true; accountsToCheck.checkAllFromResourceAccount = true; } else if (specificOrgs.length > 0) { accountsToCheck.specificOrganizations.push(...specificOrgs); // The resource account may be in these orgs; conservatively assume trusted accountsToCheck.resourceAccountTrustedByPolicy = true; accountsToCheck.checkAllFromResourceAccount = true; } else if (specificPrincipals.length === 0) { accountsToCheck.allAccounts = true; accountsToCheck.resourceAccountTrustedByPolicy = true; accountsToCheck.checkAllFromResourceAccount = true; } } } } return accountsToCheck; } /** * If the princpal arn is a session, converts it to the ARN of the assumed role. * Otherwise returns the principal ARN as is. * * @param principalArn The principal ARN to convert. * @returns The ARN of the assumed role if the principal ARN is a session, otherwise the original principal ARN. */ function convertSessionArnToRoleArn(principalArn) { if (!(0, iam_utils_1.isAssumedRoleArn)(principalArn)) { return principalArn; } return (0, iam_utils_1.convertAssumedRoleArnToRoleArn)(principalArn); } async function actionsForWhoCan(request) { const { actions } = request; if (actions && actions.length > 0) { const validActions = []; for (const action of actions) { const parts = action.split(':'); if (parts.length !== 2) { continue; } const [service, actionName] = parts; const serviceExists = await (0, iam_data_1.iamServiceExists)(service); if (!serviceExists) { continue; } const actionExists = await (0, iam_data_1.iamActionExists)(service, actionName); if (!actionExists) { continue; } validActions.push(action); } return validActions; } if (!request.resource) { return []; } return lookupActionsForResourceArn(request.resource); } /** * Get the the possible resource types for an action and resource * * @param service the service the action belongs to * @param action the action to get the resource type for * @param resourceArn the resource type matching the action, if any * @throws an error if the service or action does not exist, or if the action is a wildcard only action */ async function lookupActionsForResourceArn(resourceArn) { const [service, resourceType] = await findResourceTypeForArn(resourceArn); const resourceTypeKey = resourceType.key; const selectedActions = []; const serviceActions = await (0, iam_data_1.iamActionsForService)(service); for (const action of serviceActions) { const actionDetails = await (0, iam_data_1.iamActionDetails)(service, action); for (const rt of actionDetails.resourceTypes) { if (rt.name == resourceTypeKey) { selectedActions.push(`${service}:${action}`); break; // No need to check other resource types for this action } } } const isRole = new arn_js_1.Arn(resourceArn).matches({ service: 'iam', resourceType: 'role' }); if (isRole) { selectedActions.push(...sts_js_1.AssumeRoleActions.values()); } return selectedActions; } async function findResourceTypeForArn(resourceArn) { const arnParts = (0, iam_utils_1.splitArnParts)(resourceArn); const service = arnParts.service.toLowerCase(); const serviceExists = await (0, iam_data_1.iamServiceExists)(service); if (!serviceExists) { throw new Error(`Unable to find service ${service} for resource ${resourceArn}`); } const sortedResourceTypes = await allResourceTypesByArnLength(service); for (const rt of sortedResourceTypes) { const pattern = convertResourcePatternToRegex(rt.arn); const match = resourceArn.match(new RegExp(pattern)); if (match) { return [service, rt]; } } throw new Error(`Unable to find resource type for service ${service} and resource ${resourceArn}.`); } /** * Convert a resource pattern from iam-data to a regex pattern * * @param pattern the pattern to convert to a regex * @returns the regex pattern */ function convertResourcePatternToRegex(pattern) { const regex = pattern.replace(/\$\{.*?\}/g, (match, position) => { const name = match.substring(2, match.length - 1); const camelName = name.at(0)?.toLowerCase() + name.substring(1); return `(?<${camelName}>(.+?))`; }); return `^${regex}$`; } async function allResourceTypesByArnLength(service) { const resourceTypeKeys = await (0, iam_data_1.iamResourceTypesForService)(service); const sortedResourceTypes = []; for (const key of resourceTypeKeys) { const details = await (0, iam_data_1.iamResourceTypeDetails)(service, key); sortedResourceTypes.push(details); } return sortedResourceTypes.sort((a, b) => { return b.arn.length - a.arn.length; }); } /** * Sort the results in a WhoCanResponse in place for consistent output * * @param whoCanResponse the WhoCanResponse to sort */ function sortWhoCanResults(whoCanResponse) { whoCanResponse.allowed.sort((a, b) => { if (a.principal < b.principal) return -1; if (a.principal > b.principal) return 1; if (a.service < b.service) return -1; if (a.service > b.service) return 1; if (a.action < b.action) return -1; if (a.action > b.action) return 1; return 0; }); whoCanResponse.denyDetails?.sort((a, b) => { if (a.principal < b.principal) return -1; if (a.principal > b.principal) return 1; if (a.service < b.service) return -1; if (a.service > b.service) return 1; if (a.action < b.action) return -1; if (a.action > b.action) return 1; return 0; }); whoCanResponse.accountsNotFound.sort(); whoCanResponse.organizationsNotFound.sort(); whoCanResponse.organizationalUnitsNotFound.sort(); whoCanResponse.principalsNotFound.sort(); } //# sourceMappingURL=whoCan.js.map