@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.
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/utils/bitset.js +3 -3
- package/dist/cjs/utils/bitset.js.map +1 -1
- package/dist/cjs/whoCan/WhoCanMainThreadWorker.d.ts +65 -3
- package/dist/cjs/whoCan/WhoCanMainThreadWorker.d.ts.map +1 -1
- package/dist/cjs/whoCan/WhoCanMainThreadWorker.js +52 -31
- package/dist/cjs/whoCan/WhoCanMainThreadWorker.js.map +1 -1
- package/dist/cjs/whoCan/WhoCanProcessor.d.ts +371 -0
- package/dist/cjs/whoCan/WhoCanProcessor.d.ts.map +1 -0
- package/dist/cjs/whoCan/WhoCanProcessor.js +980 -0
- package/dist/cjs/whoCan/WhoCanProcessor.js.map +1 -0
- package/dist/cjs/whoCan/WhoCanWorker.d.ts +2 -0
- package/dist/cjs/whoCan/WhoCanWorker.d.ts.map +1 -1
- package/dist/cjs/whoCan/WhoCanWorker.js.map +1 -1
- package/dist/cjs/whoCan/WhoCanWorkerThreadWorker.js +99 -80
- package/dist/cjs/whoCan/WhoCanWorkerThreadWorker.js.map +1 -1
- package/dist/cjs/whoCan/principalArnFilter.d.ts +84 -0
- package/dist/cjs/whoCan/principalArnFilter.d.ts.map +1 -0
- package/dist/cjs/whoCan/principalArnFilter.js +256 -0
- package/dist/cjs/whoCan/principalArnFilter.js.map +1 -0
- package/dist/cjs/whoCan/untrustingActions.d.ts +7 -0
- package/dist/cjs/whoCan/untrustingActions.d.ts.map +1 -0
- package/dist/cjs/whoCan/untrustingActions.js +30 -0
- package/dist/cjs/whoCan/untrustingActions.js.map +1 -0
- package/dist/cjs/whoCan/whoCan.d.ts +35 -2
- package/dist/cjs/whoCan/whoCan.d.ts.map +1 -1
- package/dist/cjs/whoCan/whoCan.js +277 -233
- package/dist/cjs/whoCan/whoCan.js.map +1 -1
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/utils/bitset.js +3 -3
- package/dist/esm/utils/bitset.js.map +1 -1
- package/dist/esm/whoCan/WhoCanMainThreadWorker.d.ts +65 -3
- package/dist/esm/whoCan/WhoCanMainThreadWorker.d.ts.map +1 -1
- package/dist/esm/whoCan/WhoCanMainThreadWorker.js +53 -34
- package/dist/esm/whoCan/WhoCanMainThreadWorker.js.map +1 -1
- package/dist/esm/whoCan/WhoCanProcessor.d.ts +371 -0
- package/dist/esm/whoCan/WhoCanProcessor.d.ts.map +1 -0
- package/dist/esm/whoCan/WhoCanProcessor.js +970 -0
- package/dist/esm/whoCan/WhoCanProcessor.js.map +1 -0
- package/dist/esm/whoCan/WhoCanWorker.d.ts +2 -0
- package/dist/esm/whoCan/WhoCanWorker.d.ts.map +1 -1
- package/dist/esm/whoCan/WhoCanWorker.js.map +1 -1
- package/dist/esm/whoCan/WhoCanWorkerThreadWorker.js +102 -81
- package/dist/esm/whoCan/WhoCanWorkerThreadWorker.js.map +1 -1
- package/dist/esm/whoCan/principalArnFilter.d.ts +84 -0
- package/dist/esm/whoCan/principalArnFilter.d.ts.map +1 -0
- package/dist/esm/whoCan/principalArnFilter.js +251 -0
- package/dist/esm/whoCan/principalArnFilter.js.map +1 -0
- package/dist/esm/whoCan/untrustingActions.d.ts +7 -0
- package/dist/esm/whoCan/untrustingActions.d.ts.map +1 -0
- package/dist/esm/whoCan/untrustingActions.js +27 -0
- package/dist/esm/whoCan/untrustingActions.js.map +1 -0
- package/dist/esm/whoCan/whoCan.d.ts +35 -2
- package/dist/esm/whoCan/whoCan.d.ts.map +1 -1
- package/dist/esm/whoCan/whoCan.js +278 -237
- package/dist/esm/whoCan/whoCan.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,245 +1,66 @@
|
|
|
1
1
|
import {} from '@cloud-copilot/iam-collect';
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
28
|
-
* @
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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('$'))
|
|
332
|
-
) {
|
|
323
|
+
!cond.conditionValues().some((v) => v.includes('$'))) {
|
|
333
324
|
specificOrgs.push(...cond.conditionValues());
|
|
334
325
|
}
|
|
335
|
-
if (
|
|
326
|
+
if (condKey === 'aws:principalorgpaths' &&
|
|
336
327
|
cond.operation().baseOperator().toLowerCase().startsWith('stringequals') &&
|
|
337
|
-
!cond.conditionValues().some((v) => v.includes('$'))
|
|
338
|
-
) {
|
|
328
|
+
!cond.conditionValues().some((v) => v.includes('$'))) {
|
|
339
329
|
specificOus.push(...cond.conditionValues());
|
|
340
330
|
}
|
|
341
|
-
if (
|
|
342
|
-
cond.operation().value().toLowerCase()
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
}
|