@cloud-copilot/iam-lens
Version:
Visibility in IAM in and across AWS accounts
552 lines • 24.2 kB
JavaScript
"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