@cloud-copilot/iam-lens 0.1.108 → 0.1.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/cjs/index.d.ts +2 -0
  2. package/dist/cjs/index.d.ts.map +1 -1
  3. package/dist/cjs/index.js +3 -1
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/utils/bitset.js +3 -3
  6. package/dist/cjs/utils/bitset.js.map +1 -1
  7. package/dist/cjs/whoCan/WhoCanMainThreadWorker.d.ts +65 -3
  8. package/dist/cjs/whoCan/WhoCanMainThreadWorker.d.ts.map +1 -1
  9. package/dist/cjs/whoCan/WhoCanMainThreadWorker.js +52 -31
  10. package/dist/cjs/whoCan/WhoCanMainThreadWorker.js.map +1 -1
  11. package/dist/cjs/whoCan/WhoCanProcessor.d.ts +371 -0
  12. package/dist/cjs/whoCan/WhoCanProcessor.d.ts.map +1 -0
  13. package/dist/cjs/whoCan/WhoCanProcessor.js +980 -0
  14. package/dist/cjs/whoCan/WhoCanProcessor.js.map +1 -0
  15. package/dist/cjs/whoCan/WhoCanWorker.d.ts +2 -0
  16. package/dist/cjs/whoCan/WhoCanWorker.d.ts.map +1 -1
  17. package/dist/cjs/whoCan/WhoCanWorker.js.map +1 -1
  18. package/dist/cjs/whoCan/WhoCanWorkerThreadWorker.js +99 -80
  19. package/dist/cjs/whoCan/WhoCanWorkerThreadWorker.js.map +1 -1
  20. package/dist/cjs/whoCan/principalArnFilter.d.ts +84 -0
  21. package/dist/cjs/whoCan/principalArnFilter.d.ts.map +1 -0
  22. package/dist/cjs/whoCan/principalArnFilter.js +256 -0
  23. package/dist/cjs/whoCan/principalArnFilter.js.map +1 -0
  24. package/dist/cjs/whoCan/untrustingActions.d.ts +7 -0
  25. package/dist/cjs/whoCan/untrustingActions.d.ts.map +1 -0
  26. package/dist/cjs/whoCan/untrustingActions.js +30 -0
  27. package/dist/cjs/whoCan/untrustingActions.js.map +1 -0
  28. package/dist/cjs/whoCan/whoCan.d.ts +35 -2
  29. package/dist/cjs/whoCan/whoCan.d.ts.map +1 -1
  30. package/dist/cjs/whoCan/whoCan.js +277 -233
  31. package/dist/cjs/whoCan/whoCan.js.map +1 -1
  32. package/dist/esm/index.d.ts +2 -0
  33. package/dist/esm/index.d.ts.map +1 -1
  34. package/dist/esm/index.js +2 -0
  35. package/dist/esm/index.js.map +1 -1
  36. package/dist/esm/utils/bitset.js +3 -3
  37. package/dist/esm/utils/bitset.js.map +1 -1
  38. package/dist/esm/whoCan/WhoCanMainThreadWorker.d.ts +65 -3
  39. package/dist/esm/whoCan/WhoCanMainThreadWorker.d.ts.map +1 -1
  40. package/dist/esm/whoCan/WhoCanMainThreadWorker.js +53 -34
  41. package/dist/esm/whoCan/WhoCanMainThreadWorker.js.map +1 -1
  42. package/dist/esm/whoCan/WhoCanProcessor.d.ts +371 -0
  43. package/dist/esm/whoCan/WhoCanProcessor.d.ts.map +1 -0
  44. package/dist/esm/whoCan/WhoCanProcessor.js +970 -0
  45. package/dist/esm/whoCan/WhoCanProcessor.js.map +1 -0
  46. package/dist/esm/whoCan/WhoCanWorker.d.ts +2 -0
  47. package/dist/esm/whoCan/WhoCanWorker.d.ts.map +1 -1
  48. package/dist/esm/whoCan/WhoCanWorker.js.map +1 -1
  49. package/dist/esm/whoCan/WhoCanWorkerThreadWorker.js +102 -81
  50. package/dist/esm/whoCan/WhoCanWorkerThreadWorker.js.map +1 -1
  51. package/dist/esm/whoCan/principalArnFilter.d.ts +84 -0
  52. package/dist/esm/whoCan/principalArnFilter.d.ts.map +1 -0
  53. package/dist/esm/whoCan/principalArnFilter.js +251 -0
  54. package/dist/esm/whoCan/principalArnFilter.js.map +1 -0
  55. package/dist/esm/whoCan/untrustingActions.d.ts +7 -0
  56. package/dist/esm/whoCan/untrustingActions.d.ts.map +1 -0
  57. package/dist/esm/whoCan/untrustingActions.js +27 -0
  58. package/dist/esm/whoCan/untrustingActions.js.map +1 -0
  59. package/dist/esm/whoCan/whoCan.d.ts +35 -2
  60. package/dist/esm/whoCan/whoCan.d.ts.map +1 -1
  61. package/dist/esm/whoCan/whoCan.js +278 -237
  62. package/dist/esm/whoCan/whoCan.js.map +1 -1
  63. package/package.json +3 -3
@@ -1,245 +1,66 @@
1
1
  import {} from '@cloud-copilot/iam-collect';
2
- import { log } from '@cloud-copilot/log';
2
+ import { IamCollectClient } from '../collect/client.js';
3
3
  import {} from '../collect/collect.js';
4
4
  import { iamActionDetails, iamActionExists, iamActionsForService, iamResourceTypeDetails, iamResourceTypesForService, iamServiceExists } from '@cloud-copilot/iam-data';
5
5
  import { loadPolicy } from '@cloud-copilot/iam-policy';
6
6
  import {} from '@cloud-copilot/iam-simulate';
7
- import { isAssumedRoleArn, isIamRoleArn, isIamUserArn, isServicePrincipal, splitArnParts } from '@cloud-copilot/iam-utils';
8
- import { numberOfCpus, StreamingJobQueue } from '@cloud-copilot/job';
9
- import { Worker } from 'worker_threads';
10
- import { IamCollectClient } from '../collect/client.js';
11
- import { getCollectClient } from '../collect/collect.js';
12
- import { getAccountIdForResource, getResourcePolicyForResource } from '../resources.js';
7
+ import { splitArnParts } from '@cloud-copilot/iam-utils';
13
8
  import { Arn } from '../utils/arn.js';
14
9
  import {} from '../utils/s3Abac.js';
15
10
  import { AssumeRoleActions } from '../utils/sts.js';
16
- import { getWorkerScriptPath } from '../utils/workerScript.js';
17
- import { ArrayStreamingWorkQueue } from '../workers/ArrayStreamingWorkQueue.js';
18
- import { SharedArrayBufferMainCache } from '../workers/SharedArrayBufferMainCache.js';
19
- import { StreamingWorkQueue } from '../workers/StreamingWorkQueue.js';
20
- import { createMainThreadStreamingWorkQueue } from './WhoCanMainThreadWorker.js';
21
- import {} from './WhoCanWorker.js';
22
- import { intersectWithPrincipalScope, resolvePrincipalScope } from './principalScope.js';
23
11
  import {} from './requestAnalysis.js';
12
+ import { WhoCanProcessor } from './WhoCanProcessor.js';
24
13
  /**
25
- * Get the number of worker threads to use, defaulting to number of CPUs - 1
14
+ * Processes a single whoCan request by creating a temporary WhoCanProcessor,
15
+ * enqueuing the request, waiting for it to settle, and shutting down. This
16
+ * preserves the original one-shot behavior where workers and cache are created
17
+ * and destroyed per call.
18
+ *
19
+ * For better performance when running multiple requests, use WhoCanProcessor
20
+ * directly to keep workers and cache alive across calls.
26
21
  *
27
- * @param overrideValue the override value, if any
28
- * @returns the override value if provided, otherwise number of CPUs - 1
22
+ * @param collectConfigs the collect configurations for loading IAM data
23
+ * @param partition the AWS partition (e.g. 'aws', 'aws-cn')
24
+ * @param request the whoCan request parameters
25
+ * @returns the whoCan response with allowed principals and optional deny details
29
26
  */
30
- function getNumberOfWorkers(overrideValue) {
31
- if (typeof overrideValue === 'number' && overrideValue >= 0) {
32
- return Math.floor(overrideValue);
33
- }
34
- return Math.max(0, numberOfCpus() - 1);
35
- }
36
27
  export async function whoCan(collectConfigs, partition, request) {
37
- const { resource } = request;
38
- // Get the number of workers and the worker script path.
39
- // It's possible in bundled environments that the worker script path may not be found, so handle that gracefully.
40
- const numWorkers = getNumberOfWorkers(request.workerThreads);
41
- const workerPath = getWorkerScriptPath('whoCan/WhoCanWorkerThreadWorker.js');
42
- const collectDenyDetails = !!request.denyDetailsCallback;
43
- const collectGrantDetails = !!request.collectGrantDetails;
44
- const workers = !workerPath
45
- ? []
46
- : new Array(numWorkers).fill(undefined).map((val) => {
47
- return new Worker(workerPath, {
48
- workerData: {
49
- collectConfigs: collectConfigs,
50
- partition,
51
- concurrency: 50,
52
- s3AbacOverride: request.s3AbacOverride,
53
- collectDenyDetails,
54
- collectGrantDetails,
55
- strictContextKeys: request.strictContextKeys,
56
- clientFactoryPlugin: request.clientFactoryPlugin
57
- }
58
- });
59
- });
60
- const collectClient = await getCollectClient(collectConfigs, partition, {
61
- cacheProvider: new SharedArrayBufferMainCache(workers),
62
- clientFactoryPlugin: request.clientFactoryPlugin
63
- });
64
- if (!request.resourceAccount && !request.resource) {
65
- throw new Error('Either resourceAccount or resource must be provided in the request.');
66
- }
67
- const resourceAccount = request.resourceAccount || (await getAccountIdForResource(collectClient, resource));
68
- if (!resourceAccount) {
69
- throw new Error(`Could not determine account ID for resource ${resource}. Please use a different ARN or specify resourceAccount.`);
70
- }
71
- const actions = await actionsForWhoCan(request);
72
- if (!actions || actions.length === 0) {
73
- throw new Error('No valid actions provided or found for the resource.');
74
- }
75
- let resourcePolicy = undefined;
76
- if (resource) {
77
- resourcePolicy = await getResourcePolicyForResource(collectClient, resource, resourceAccount);
78
- const resourceArn = new Arn(resource);
79
- if ((resourceArn.matches({ service: 'iam', resourceType: 'role' }) ||
80
- resourceArn.matches({ service: 'kms', resourceType: 'key' })) &&
81
- !resourcePolicy) {
82
- throw new Error(`Unable to find resource policy for ${resource}. Cannot determine who can access the resource.`);
83
- }
84
- }
85
- const accountsToCheck = await accountsToCheckBasedOnResourcePolicy(resourcePolicy, resourceAccount);
86
- const uniqueAccounts = await uniqueAccountsToCheck(collectClient, accountsToCheck);
87
- let accountsForSearch = uniqueAccounts.accounts;
88
- let principalsForSearch = accountsToCheck.specificPrincipals;
89
- let scopeIncludesResourceAccount = true;
90
- if (request.principalScope) {
91
- const resolved = await resolvePrincipalScope(collectClient, request.principalScope);
92
- const intersection = intersectWithPrincipalScope(uniqueAccounts.accounts, accountsToCheck.specificPrincipals, accountsToCheck.allAccounts, resolved.accounts, resolved.principals);
93
- accountsForSearch = intersection.accounts;
94
- principalsForSearch = intersection.principals;
95
- scopeIncludesResourceAccount = resolved.accounts.has(resourceAccount);
96
- }
97
- const whoCanResults = [];
98
- const concurrency = Math.min(50, Math.max(1, numberOfCpus() * 2));
99
- let simulationCount = 0;
100
- const simulateQueue = new StreamingWorkQueue();
101
- const simulationErrors = [];
102
- const denyDetails = [];
103
- const onComplete = (result) => {
104
- simulationCount++;
105
- if (result.status === 'fulfilled' && result.value) {
106
- whoCanResults.push(result.value);
107
- }
108
- else if (result.status === 'rejected') {
109
- log.error('Error running simulation', { error: result.reason });
110
- simulationErrors.push(result);
28
+ let settledEvent;
29
+ const processor = await WhoCanProcessor.create({
30
+ collectConfigs,
31
+ partition,
32
+ tuning: {
33
+ workerThreads: request.workerThreads
34
+ },
35
+ ignorePrincipalIndex: request.ignorePrincipalIndex,
36
+ clientFactoryPlugin: request.clientFactoryPlugin,
37
+ s3AbacOverride: request.s3AbacOverride,
38
+ collectGrantDetails: !!request.collectGrantDetails,
39
+ onRequestSettled: async (event) => {
40
+ settledEvent = event;
111
41
  }
112
- };
113
- const mainThreadWorker = createMainThreadStreamingWorkQueue(simulateQueue, collectClient, request.s3AbacOverride, onComplete, request.denyDetailsCallback, (detail) => denyDetails.push(detail), collectGrantDetails, request.strictContextKeys);
114
- workers.forEach((worker) => {
115
- worker.on('message', (msg) => {
116
- if (msg.type === 'requestTask') {
117
- const task = simulateQueue.dequeue();
118
- worker.postMessage({ type: 'task', workerId: msg.workerId, task });
119
- }
120
- if (msg.type === 'result') {
121
- onComplete(msg.result);
122
- }
123
- if (msg.type === 'checkDenyDetails') {
124
- // Run the callback on main thread to check if we should include deny details
125
- const shouldInclude = request.denyDetailsCallback?.(msg.lightAnalysis) ?? false;
126
- worker.postMessage({
127
- type: 'denyDetailsCheckResult',
128
- checkId: msg.checkId,
129
- shouldInclude
130
- });
131
- }
132
- if (msg.type === 'denyDetailsResult') {
133
- denyDetails.push(msg.denyDetail);
134
- }
135
- });
136
42
  });
137
- simulateQueue.setWorkAvailableCallback(() => {
138
- mainThreadWorker.notifyWorkAvailable();
139
- workers.forEach((w) => w.postMessage({ type: 'workAvailable' }));
140
- });
141
- const accountQueue = new StreamingJobQueue(concurrency, console, async (response) => { });
142
- const principalIndexExists = await collectClient.principalIndexExists();
143
- if (principalIndexExists) {
144
- const allFromAccount = scopeIncludesResourceAccount ? resourceAccount : undefined;
145
- for (const action of actions) {
146
- const indexedPrincipals = await collectClient.getPrincipalsWithActionAllowed(allFromAccount, accountsForSearch, action);
147
- for (const principal of indexedPrincipals || []) {
148
- simulateQueue.enqueue({
149
- resource,
150
- action,
151
- principal,
152
- resourceAccount
153
- });
154
- }
43
+ try {
44
+ processor.enqueueWhoCan({
45
+ resource: request.resource,
46
+ resourceAccount: request.resourceAccount,
47
+ actions: request.actions,
48
+ sort: request.sort,
49
+ denyDetailsCallback: request.denyDetailsCallback,
50
+ principalScope: request.principalScope,
51
+ strictContextKeys: request.strictContextKeys
52
+ });
53
+ await processor.waitForIdle();
54
+ if (!settledEvent) {
55
+ throw new Error('whoCan request did not settle');
155
56
  }
156
- }
157
- else {
158
- for (const account of accountsForSearch) {
159
- accountQueue.enqueue({
160
- properties: {},
161
- execute: async () => {
162
- const principals = await collectClient.getAllPrincipalsInAccount(account);
163
- for (const principal of principals) {
164
- await runPrincipalForActions(collectClient, simulateQueue, principal, resource, resourceAccount, actions);
165
- }
166
- }
167
- });
57
+ if (settledEvent.status === 'rejected') {
58
+ throw settledEvent.error;
168
59
  }
60
+ return settledEvent.result;
169
61
  }
170
- const principalsNotFound = [];
171
- for (const principal of principalsForSearch) {
172
- accountQueue.enqueue({
173
- properties: {},
174
- execute: async () => {
175
- if (isServicePrincipal(principal)) {
176
- await runPrincipalForActions(collectClient, simulateQueue, principal, resource, resourceAccount, actions);
177
- }
178
- else if (isIamUserArn(principal) ||
179
- isIamRoleArn(principal) ||
180
- isAssumedRoleArn(principal)) {
181
- const principalExists = await collectClient.principalExists(principal);
182
- if (!principalExists) {
183
- principalsNotFound.push(principal);
184
- }
185
- else {
186
- await runPrincipalForActions(collectClient, simulateQueue, principal, resource, resourceAccount, actions);
187
- }
188
- }
189
- else {
190
- // TODO: Add a check for OIDC and SAML providers here
191
- principalsNotFound.push(principal);
192
- }
193
- }
194
- });
195
- }
196
- await accountQueue.finishAllWork();
197
- const workerPromises = workers.map((worker) => {
198
- return new Promise((resolve, reject) => {
199
- worker.on('message', (msg) => {
200
- if (msg.type === 'finished') {
201
- worker.terminate().then(() => resolve());
202
- }
203
- });
204
- worker.on('error', (err) => {
205
- log.error('Worker error', { error: err });
206
- reject(err);
207
- });
208
- worker.postMessage({ type: 'finishWork' });
209
- });
210
- });
211
- await Promise.all([
212
- //
213
- mainThreadWorker.finishAllWork(),
214
- ...workerPromises
215
- ]);
216
- if (simulationErrors.length > 0) {
217
- log.error(`Completed with ${simulationErrors.length} simulation errors.`);
218
- throw new Error(`Completed with ${simulationErrors.length} simulation errors. See previous logs.`);
219
- }
220
- const results = {
221
- simulationCount,
222
- allowed: whoCanResults,
223
- allAccountsChecked: request.principalScope ? false : accountsToCheck.allAccounts,
224
- accountsNotFound: uniqueAccounts.accountsNotFound,
225
- organizationsNotFound: uniqueAccounts.organizationsNotFound,
226
- organizationalUnitsNotFound: uniqueAccounts.organizationalUnitsNotFound,
227
- principalsNotFound: principalsNotFound,
228
- denyDetails: request.denyDetailsCallback ? denyDetails : undefined
229
- };
230
- if (request.sort) {
231
- sortWhoCanResults(results);
232
- }
233
- return results;
234
- }
235
- async function runPrincipalForActions(collectClient, simulationQueue, principal, resource, resourceAccount, actions) {
236
- for (const action of actions) {
237
- simulationQueue.enqueue({
238
- resource,
239
- action,
240
- principal,
241
- resourceAccount
242
- });
62
+ finally {
63
+ await processor.shutdown();
243
64
  }
244
65
  }
245
66
  export async function uniqueAccountsToCheck(collectClient, accountsToCheck) {
@@ -287,13 +108,171 @@ export async function uniqueAccountsToCheck(collectClient, accountsToCheck) {
287
108
  returnValue.accounts = Array.from(uniqueAccounts);
288
109
  return returnValue;
289
110
  }
111
+ /**
112
+ * Splits an ARN-like string on `:` while treating `${...}` blocks as opaque.
113
+ * Colons inside `${...}` dynamic variable references are not used as split points.
114
+ *
115
+ * For example, `arn:${aws:Partition}:iam::999999999999:role/*` splits into
116
+ * `['arn', '${aws:Partition}', 'iam', '', '999999999999', 'role/*']`.
117
+ *
118
+ * @param value - The raw ARN string, possibly containing `${...}` references.
119
+ * @returns An array of colon-delimited segments.
120
+ */
121
+ function splitArnIgnoringDynamicVars(value) {
122
+ const segments = [];
123
+ let current = '';
124
+ let depth = 0;
125
+ for (let i = 0; i < value.length; i++) {
126
+ const ch = value[i];
127
+ if (ch === '$' && i + 1 < value.length && value[i + 1] === '{') {
128
+ depth++;
129
+ current += '${';
130
+ i++; // skip the '{'
131
+ }
132
+ else if (ch === '}' && depth > 0) {
133
+ depth--;
134
+ current += '}';
135
+ }
136
+ else if (ch === ':' && depth === 0) {
137
+ segments.push(current);
138
+ current = '';
139
+ }
140
+ else {
141
+ current += ch;
142
+ }
143
+ }
144
+ segments.push(current);
145
+ return segments;
146
+ }
147
+ const PRINCIPAL_ARN_PATTERN_OPERATORS = new Set(['stringlike', 'arnequals', 'arnlike']);
148
+ /**
149
+ * Checks whether a string contains any wildcard or dynamic variable characters
150
+ * (`*`, `?`, or `$`).
151
+ *
152
+ * @param value - The string to check.
153
+ * @returns `true` if the string contains `*`, `?`, or `$`.
154
+ */
155
+ function hasWildcardOrDynamic(value) {
156
+ return value.includes('*') || value.includes('?') || value.includes('$');
157
+ }
158
+ /** The 3 scalar condition keys that are only populated for service principal requests. */
159
+ const UNNAMED_SERVICE_SCALAR_KEYS = new Set([
160
+ 'aws:sourceaccount',
161
+ 'aws:sourceowner',
162
+ 'aws:sourceorgid'
163
+ ]);
164
+ /**
165
+ * Checks whether a positive operator is used on a scalar service-principal-only key.
166
+ * Accepts `StringEquals` family and `StringLike`.
167
+ *
168
+ * @param op - The condition operation to check.
169
+ * @returns `true` if the operator is a positive match for a scalar key.
170
+ */
171
+ function isPositiveScalarOperator(op) {
172
+ return (op.value().toLowerCase().startsWith('stringequals') ||
173
+ op.baseOperator().toLowerCase() === 'stringlike');
174
+ }
175
+ /**
176
+ * Checks whether a positive operator is used on the `aws:SourceOrgPaths` array key.
177
+ * Only `ForAnyValue:StringEquals*` and `ForAnyValue:StringLike` qualify.
178
+ * `ForAllValues` and plain operators without a set operator do not.
179
+ *
180
+ * @param op - The condition operation to check.
181
+ * @returns `true` if the operator is a valid positive match for the array key.
182
+ */
183
+ function isPositiveOrgPathsOperator(op) {
184
+ if (op.setOperator() !== 'ForAnyValue')
185
+ return false;
186
+ const base = op.baseOperator().toLowerCase();
187
+ return base.startsWith('stringequals') || base === 'stringlike';
188
+ }
189
+ /**
190
+ * Inspects a statement's conditions to determine if the statement effectively
191
+ * requires an AWS service principal. Used for wildcard-principal Allow statements
192
+ * to avoid unnecessarily widening the whoCan search scope.
193
+ *
194
+ * @param conditions - The conditions from the statement to inspect.
195
+ * @returns A classification indicating whether the statement is not service-only,
196
+ * requires an unnamed service principal (skip entirely), or names specific
197
+ * service principals (extract for simulation).
198
+ */
199
+ function checkForServicePrincipalConditions(conditions) {
200
+ let hasUnnamedServiceKey = false;
201
+ const namedServicePrincipals = [];
202
+ for (const cond of conditions) {
203
+ const key = cond.conditionKey().toLowerCase();
204
+ const op = cond.operation();
205
+ if (op.isIfExists())
206
+ continue;
207
+ // Category 1a: Scalar unnamed keys (aws:SourceAccount, aws:SourceOwner, aws:SourceOrgID)
208
+ if (UNNAMED_SERVICE_SCALAR_KEYS.has(key) && isPositiveScalarOperator(op)) {
209
+ hasUnnamedServiceKey = true;
210
+ }
211
+ // Category 1b: Array unnamed key (aws:SourceOrgPaths) — requires ForAnyValue
212
+ if (key === 'aws:sourceorgpaths' && isPositiveOrgPathsOperator(op)) {
213
+ hasUnnamedServiceKey = true;
214
+ }
215
+ // Category 1c: aws:PrincipalIsAWSService with Bool or StringEquals and value 'true'
216
+ // Multiple condition values are ORed, so mixed ['true', 'false'] is NOT service-only.
217
+ // All values must be 'true' for the condition to exclusively require a service principal.
218
+ if (key === 'aws:principalisawsservice') {
219
+ const baseOp = op.baseOperator().toLowerCase();
220
+ const opVal = op.value().toLowerCase();
221
+ const isBoolOrStringEquals = baseOp === 'bool' || opVal.startsWith('stringequals');
222
+ const values = cond.conditionValues();
223
+ if (isBoolOrStringEquals &&
224
+ values.length > 0 &&
225
+ values.every((v) => v.toLowerCase() === 'true')) {
226
+ hasUnnamedServiceKey = true;
227
+ }
228
+ }
229
+ // Category 2: aws:PrincipalServiceName — extract named service principals
230
+ if (key === 'aws:principalservicename' &&
231
+ op.value().toLowerCase().startsWith('stringequals') &&
232
+ !cond.conditionValues().some((v) => v.includes('$'))) {
233
+ namedServicePrincipals.push(...cond.conditionValues());
234
+ }
235
+ }
236
+ // Named takes priority — the simulator fills aws:SourceAccount etc. for service principals
237
+ if (namedServicePrincipals.length > 0) {
238
+ return { type: 'named-service-only', principals: namedServicePrincipals };
239
+ }
240
+ if (hasUnnamedServiceKey) {
241
+ return { type: 'unnamed-service-only' };
242
+ }
243
+ return { type: 'not-service-only' };
244
+ }
245
+ /**
246
+ * Determines whether a policy statement requires checking all principals from
247
+ * the resource account. This is true when the statement is an Allow with a
248
+ * wildcard principal (`*` or `{ AWS: "*" }`) or a `NotPrincipal` element,
249
+ * since either form could grant access to any principal in the resource account.
250
+ *
251
+ * @param statement - The policy statement to check.
252
+ * @returns `true` if the statement could allow any principal from the resource account.
253
+ */
254
+ export function statementRequiresAllFromResourceAccount(statement) {
255
+ if (!statement.isAllow())
256
+ return false;
257
+ if (statement.isNotPrincipalStatement())
258
+ return true;
259
+ if (statement.isPrincipalStatement()) {
260
+ for (const principal of statement.principals()) {
261
+ if (principal.isWildcardPrincipal())
262
+ return true;
263
+ }
264
+ }
265
+ return false;
266
+ }
290
267
  export async function accountsToCheckBasedOnResourcePolicy(resourcePolicy, resourceAccount) {
291
268
  const accountsToCheck = {
292
269
  allAccounts: false,
293
270
  specificAccounts: [],
294
271
  specificPrincipals: [],
295
272
  specificOrganizations: [],
296
- specificOrganizationalUnits: []
273
+ specificOrganizationalUnits: [],
274
+ checkAnonymous: false,
275
+ checkAllForCurrentAccount: false
297
276
  };
298
277
  if (resourceAccount) {
299
278
  accountsToCheck.specificAccounts.push(resourceAccount);
@@ -303,6 +282,9 @@ export async function accountsToCheckBasedOnResourcePolicy(resourcePolicy, resou
303
282
  }
304
283
  const policy = loadPolicy(resourcePolicy);
305
284
  for (const statement of policy.statements()) {
285
+ if (statementRequiresAllFromResourceAccount(statement)) {
286
+ accountsToCheck.checkAllForCurrentAccount = true;
287
+ }
306
288
  if (statement.isAllow() && statement.isNotPrincipalStatement()) {
307
289
  accountsToCheck.allAccounts = true;
308
290
  }
@@ -321,30 +303,89 @@ export async function accountsToCheckBasedOnResourcePolicy(resourcePolicy, resou
321
303
  }
322
304
  }
323
305
  if (hasWildcardPrincipal) {
306
+ const serviceCheck = checkForServicePrincipalConditions(statement.conditions());
307
+ if (serviceCheck.type === 'unnamed-service-only') {
308
+ continue;
309
+ }
310
+ if (serviceCheck.type === 'named-service-only') {
311
+ accountsToCheck.specificPrincipals.push(...serviceCheck.principals);
312
+ continue;
313
+ }
324
314
  const specificOrgs = [];
325
315
  const specificOus = [];
326
316
  const specificAccounts = [];
317
+ const specificPrincipals = [];
327
318
  const conditions = statement.conditions();
328
319
  for (const cond of conditions) {
329
- if (cond.conditionKey().toLowerCase() === 'aws:principalorgid' &&
320
+ const condKey = cond.conditionKey().toLowerCase();
321
+ if (condKey === 'aws:principalorgid' &&
330
322
  cond.operation().value().toLowerCase().startsWith('stringequals') &&
331
- !cond.conditionValues().some((v) => v.includes('$')) // Ignore dynamic values for now
332
- ) {
323
+ !cond.conditionValues().some((v) => v.includes('$'))) {
333
324
  specificOrgs.push(...cond.conditionValues());
334
325
  }
335
- if (cond.conditionKey().toLowerCase() === 'aws:principalorgpaths' &&
326
+ if (condKey === 'aws:principalorgpaths' &&
336
327
  cond.operation().baseOperator().toLowerCase().startsWith('stringequals') &&
337
- !cond.conditionValues().some((v) => v.includes('$')) // Ignore dynamic values for now
338
- ) {
328
+ !cond.conditionValues().some((v) => v.includes('$'))) {
339
329
  specificOus.push(...cond.conditionValues());
340
330
  }
341
- if (cond.conditionKey().toLowerCase() === 'aws:principalaccount' &&
342
- cond.operation().value().toLowerCase().startsWith('stringequals') &&
343
- !cond.conditionValues().some((v) => v.includes('$')) // Ignore dynamic values for now
344
- ) {
345
- specificAccounts.push(...cond.conditionValues());
331
+ if (condKey === 'aws:principalaccount' || condKey === 'kms:calleraccount') {
332
+ const opVal = cond.operation().value().toLowerCase();
333
+ const baseOp = cond.operation().baseOperator().toLowerCase();
334
+ const values = cond.conditionValues();
335
+ const hasDynamic = values.some((v) => v.includes('$'));
336
+ if (opVal.startsWith('stringequals') && !hasDynamic) {
337
+ // StringEquals family — all values are literal account IDs
338
+ specificAccounts.push(...values);
339
+ }
340
+ else if (baseOp === 'stringlike' &&
341
+ !hasDynamic &&
342
+ values.every((v) => !v.includes('*') && !v.includes('?'))) {
343
+ // StringLike where ALL values are literal (no wildcards or dynamic vars)
344
+ specificAccounts.push(...values);
345
+ }
346
+ }
347
+ if (condKey === 'aws:principalarn') {
348
+ const opValue = cond.operation().value().toLowerCase();
349
+ const baseOp = cond.operation().baseOperator().toLowerCase();
350
+ const isExactOperator = opValue.startsWith('stringequals');
351
+ const isPatternOperator = PRINCIPAL_ARN_PATTERN_OPERATORS.has(baseOp);
352
+ if (!isExactOperator && !isPatternOperator) {
353
+ continue;
354
+ }
355
+ if (cond.operation().isIfExists()) {
356
+ accountsToCheck.checkAnonymous = true;
357
+ }
358
+ for (const value of cond.conditionValues()) {
359
+ if (!hasWildcardOrDynamic(value)) {
360
+ // Exact literal — push as a specific principal
361
+ specificPrincipals.push(value);
362
+ }
363
+ else if (isExactOperator && !value.includes('*') && !value.includes('?')) {
364
+ // Exact operator with a dynamic variable but no wildcards — try account extraction
365
+ const segments = splitArnIgnoringDynamicVars(value);
366
+ if (segments.length >= 6 && segments[0].toLowerCase() === 'arn') {
367
+ const account = segments[4];
368
+ if (account && !hasWildcardOrDynamic(account)) {
369
+ specificAccounts.push(account);
370
+ }
371
+ }
372
+ }
373
+ else {
374
+ // Pattern operator or value with wildcards — try account extraction
375
+ const segments = splitArnIgnoringDynamicVars(value);
376
+ if (segments.length >= 6 && segments[0].toLowerCase() === 'arn') {
377
+ const account = segments[4];
378
+ if (account && !hasWildcardOrDynamic(account)) {
379
+ specificAccounts.push(account);
380
+ }
381
+ }
382
+ }
383
+ }
346
384
  }
347
385
  }
386
+ if (specificPrincipals.length > 0) {
387
+ accountsToCheck.specificPrincipals.push(...specificPrincipals);
388
+ }
348
389
  if (specificAccounts.length > 0) {
349
390
  accountsToCheck.specificAccounts.push(...specificAccounts);
350
391
  }
@@ -354,7 +395,7 @@ export async function accountsToCheckBasedOnResourcePolicy(resourcePolicy, resou
354
395
  else if (specificOrgs.length > 0) {
355
396
  accountsToCheck.specificOrganizations.push(...specificOrgs);
356
397
  }
357
- else {
398
+ else if (specificPrincipals.length === 0) {
358
399
  accountsToCheck.allAccounts = true;
359
400
  }
360
401
  }