@shieldaai/agent-map 0.1.0-alpha.3 → 1.0.0
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/README.md +652 -272
- package/dist/assets.js +16 -26
- package/dist/boundary-flags.d.ts +73 -0
- package/dist/boundary-flags.js +352 -0
- package/dist/boundary.d.ts +18 -3
- package/dist/boundary.js +71 -21
- package/dist/cli-entry.d.ts +11 -0
- package/dist/cli-entry.js +48 -0
- package/dist/cli.js +40 -43
- package/dist/common.d.ts +34 -0
- package/dist/common.js +94 -0
- package/dist/http.js +78 -51
- package/dist/identity-cli.js +93 -46
- package/dist/identity-http.js +226 -94
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp.js +40 -5
- package/dist/oidc.d.ts +5 -0
- package/dist/oidc.js +121 -38
- package/dist/policy-reader.d.ts +56 -3
- package/dist/policy-reader.js +823 -110
- package/dist/projection-fields.d.ts +4 -0
- package/dist/projection-fields.js +144 -0
- package/dist/service/changes.d.ts +5 -0
- package/dist/service/changes.js +255 -0
- package/dist/service/context.d.ts +33 -0
- package/dist/service/context.js +2 -0
- package/dist/service/discovery.d.ts +6 -0
- package/dist/service/discovery.js +31 -0
- package/dist/service/explain.d.ts +12 -0
- package/dist/service/explain.js +126 -0
- package/dist/service/flags.d.ts +20 -0
- package/dist/service/flags.js +70 -0
- package/dist/service/inventory.d.ts +4 -0
- package/dist/service/inventory.js +62 -0
- package/dist/service/json.d.ts +8 -0
- package/dist/service/json.js +7 -0
- package/dist/service/operations/discovery.d.ts +5 -0
- package/dist/service/operations/discovery.js +90 -0
- package/dist/service/operations/explain-access.d.ts +3 -0
- package/dist/service/operations/explain-access.js +25 -0
- package/dist/service/operations/find-agents-with-access.d.ts +3 -0
- package/dist/service/operations/find-agents-with-access.js +120 -0
- package/dist/service/operations/get-agent-access.d.ts +3 -0
- package/dist/service/operations/get-agent-access.js +75 -0
- package/dist/service/operations/get-agent-inventory.d.ts +3 -0
- package/dist/service/operations/get-agent-inventory.js +42 -0
- package/dist/service/operations/get-changes.d.ts +3 -0
- package/dist/service/operations/get-changes.js +11 -0
- package/dist/service/operations/get-inventory-health.d.ts +3 -0
- package/dist/service/operations/get-inventory-health.js +81 -0
- package/dist/service/operations/get-resource-access.d.ts +3 -0
- package/dist/service/operations/get-resource-access.js +48 -0
- package/dist/service/operations/list-agents.d.ts +3 -0
- package/dist/service/operations/list-agents.js +92 -0
- package/dist/service/operations/list-owners-resources.d.ts +5 -0
- package/dist/service/operations/list-owners-resources.js +69 -0
- package/dist/service/ordering.d.ts +9 -0
- package/dist/service/ordering.js +47 -0
- package/dist/service/outside-guard.d.ts +68 -0
- package/dist/service/outside-guard.js +313 -0
- package/dist/service/policy.d.ts +26 -0
- package/dist/service/policy.js +299 -0
- package/dist/service/schemas.d.ts +16 -0
- package/dist/service/schemas.js +192 -0
- package/dist/service/summaries.d.ts +32 -0
- package/dist/service/summaries.js +247 -0
- package/dist/service/work.d.ts +33 -0
- package/dist/service/work.js +116 -0
- package/dist/service.d.ts +5 -5
- package/dist/service.js +110 -645
- package/dist/store/ancestors.d.ts +9 -0
- package/dist/store/ancestors.js +86 -0
- package/dist/store/discovery.d.ts +8 -0
- package/dist/store/discovery.js +288 -0
- package/dist/store/evidence.d.ts +12 -0
- package/dist/store/evidence.js +90 -0
- package/dist/store/identity-scope.d.ts +9 -0
- package/dist/store/identity-scope.js +71 -0
- package/dist/store/nodes.d.ts +24 -0
- package/dist/store/nodes.js +237 -0
- package/dist/store/projection.d.ts +19 -0
- package/dist/store/projection.js +242 -0
- package/dist/store/queries.d.ts +48 -0
- package/dist/store/queries.js +302 -0
- package/dist/store/read.d.ts +81 -0
- package/dist/store/read.js +135 -0
- package/dist/store/selection.d.ts +3 -0
- package/dist/store/selection.js +102 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.js +102 -395
- package/dist/transport.d.ts +42 -2
- package/dist/transport.js +144 -16
- package/dist/types.d.ts +184 -4
- package/dist/types.js +52 -11
- package/package.json +16 -3
package/dist/policy-reader.js
CHANGED
|
@@ -1,51 +1,66 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { taskLiveness } from './boundary-flags.js';
|
|
3
|
+
import { immutableJson } from '@shieldaai/agent-guard/internal';
|
|
4
|
+
import { LIST_SHORTENED, buildGraphIndex, compareCodePoints, fail, parseS3Arn } from './common.js';
|
|
5
|
+
import { MapQueryError, mapIdentifier, } from './types.js';
|
|
4
6
|
const MAPPING = Object.freeze({
|
|
5
|
-
's3:GetObject': 'CAN_READ',
|
|
7
|
+
's3:GetObject': 'CAN_READ',
|
|
8
|
+
's3:PutObject': 'CAN_WRITE',
|
|
9
|
+
's3:ListBucket': 'CAN_LIST',
|
|
6
10
|
});
|
|
7
11
|
const VERSION = 's3-identity-policy-v1';
|
|
8
12
|
const HASH = /^[a-f0-9]{64}$/;
|
|
9
13
|
const reason = (record, status, code, observedAt = null) => ({ ...record, status, reason: code, observedAt, candidates: [] });
|
|
10
|
-
function value(node, name) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
for (const edge of edges) {
|
|
17
|
-
for (const [index, from, to] of [[outgoing, edge.source, edge.target], [incoming, edge.target, edge.source]]) {
|
|
18
|
-
let kinds = index.get(from);
|
|
19
|
-
if (!kinds)
|
|
20
|
-
index.set(from, kinds = new Map());
|
|
21
|
-
let ids = kinds.get(edge.kind);
|
|
22
|
-
if (!ids)
|
|
23
|
-
kinds.set(edge.kind, ids = []);
|
|
24
|
-
ids.push(to);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
14
|
+
function value(node, name) {
|
|
15
|
+
return typeof node.properties[name] === 'string' ? node.properties[name] : null;
|
|
16
|
+
}
|
|
17
|
+
/** Bounded adjacency over one projection's authorized nodes and edges. */
|
|
18
|
+
function graphIndex(nodes, edges) {
|
|
19
|
+
const { nodes: byId, outgoing, incoming } = buildGraphIndex(nodes, edges);
|
|
27
20
|
let work = 0;
|
|
28
|
-
const step = (n = 1) => {
|
|
29
|
-
|
|
21
|
+
const step = (n = 1) => {
|
|
22
|
+
work += n;
|
|
23
|
+
if (work > 100_000)
|
|
24
|
+
fail('MAP_LIMIT_EXCEEDED');
|
|
25
|
+
};
|
|
30
26
|
const linked = (node, kind, label, reverse = false) => {
|
|
31
27
|
const ids = (reverse ? incoming : outgoing).get(node.id)?.get(kind) ?? [];
|
|
32
28
|
step(ids.length);
|
|
33
|
-
return ids.flatMap(id => {
|
|
29
|
+
return ids.flatMap(id => {
|
|
30
|
+
const item = byId.get(id);
|
|
31
|
+
return item?.label === label ? [item] : [];
|
|
32
|
+
});
|
|
34
33
|
};
|
|
35
34
|
const exact = (node, kind, target) => {
|
|
36
35
|
const ids = outgoing.get(node.id)?.get(kind) ?? [];
|
|
37
36
|
step();
|
|
38
37
|
return ids.length === 1 && ids[0] === target.id;
|
|
39
38
|
};
|
|
39
|
+
return { linked, exact, step };
|
|
40
|
+
}
|
|
41
|
+
/** Python's scope ID uses compact ensure_ascii JSON; preserve that byte contract. */
|
|
42
|
+
export function collectionKey(companyId, tenantId, partition, accountId) {
|
|
43
|
+
const encoded = JSON.stringify([companyId, tenantId, partition, accountId]).replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
44
|
+
return createHash('sha256').update(encoded).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Pair only the selected task's grants with its deployment's declared execution identity.
|
|
48
|
+
* `select` narrows the pairs to one task, tool, resource reference (or mapped ARN) or action,
|
|
49
|
+
* so a read that shows a few pairs is not bound by the size of the whole fleet.
|
|
50
|
+
*/
|
|
51
|
+
export function policyPlan(companyId, tenantId, nodes, edges, maximum, select = {}) {
|
|
52
|
+
const { linked, exact, step } = graphIndex(nodes, edges);
|
|
40
53
|
const records = [], probes = [], seen = new Set();
|
|
41
54
|
for (const task of nodes.filter(node => node.label === 'GuardTask')) {
|
|
55
|
+
if (select.taskId !== undefined && task.entityId !== select.taskId && value(task, 'task_id') !== select.taskId)
|
|
56
|
+
continue;
|
|
42
57
|
const deployments = linked(task, 'G_ASSIGNED_TO', 'GuardDeployment');
|
|
43
58
|
for (const deployment of deployments) {
|
|
44
59
|
const agents = linked(deployment, 'G_INSTANCE_OF', 'GuardAgent');
|
|
45
60
|
const executions = linked(deployment, 'G_EXECUTES_FOR', 'GuardExecutionBinding', true);
|
|
46
61
|
for (const grant of linked(task, 'G_HAS_GRANT', 'GuardGrant')) {
|
|
47
62
|
const tool = value(grant, 'tool');
|
|
48
|
-
if (!tool)
|
|
63
|
+
if (!tool || (select.tool !== undefined && tool !== select.tool))
|
|
49
64
|
continue;
|
|
50
65
|
for (const selector of linked(grant, 'G_TARGETS', 'GuardResource')) {
|
|
51
66
|
for (const binding of linked(selector, 'G_BINDS_SELECTOR', 'GuardResourceBinding', true)) {
|
|
@@ -58,9 +73,15 @@ export function policyPlan(companyId, tenantId, nodes, edges, maximum) {
|
|
|
58
73
|
const grantResources = grant.properties.resources;
|
|
59
74
|
if (!Array.isArray(grantResources) || !grantResources.includes(selectorValue))
|
|
60
75
|
continue;
|
|
76
|
+
if (select.resourceRef !== undefined &&
|
|
77
|
+
selectorValue !== select.resourceRef &&
|
|
78
|
+
!linked(binding, 'G_MAPS_TO', 'GuardExternalResource').some(resource => value(resource, 'arn') === select.resourceRef))
|
|
79
|
+
continue;
|
|
61
80
|
const actions = binding.properties.actions;
|
|
62
|
-
if (!Array.isArray(actions) || actions.length > 128
|
|
81
|
+
if (!Array.isArray(actions) || actions.length > 128)
|
|
63
82
|
fail('MAP_UNAVAILABLE');
|
|
83
|
+
for (const action of actions)
|
|
84
|
+
mapIdentifier(action, 'MAP_UNAVAILABLE');
|
|
64
85
|
for (const execution of executions.filter(item => value(item, 'tool') === tool)) {
|
|
65
86
|
const identities = linked(execution, 'G_USES_IDENTITY', 'GuardExternalIdentity');
|
|
66
87
|
const resources = linked(binding, 'G_MAPS_TO', 'GuardExternalResource');
|
|
@@ -68,15 +89,26 @@ export function policyPlan(companyId, tenantId, nodes, edges, maximum) {
|
|
|
68
89
|
const selectors = linked(binding, 'G_BINDS_SELECTOR', 'GuardResource');
|
|
69
90
|
const grantTools = linked(grant, 'G_FOR_TOOL', 'GuardTool');
|
|
70
91
|
const executionTools = linked(execution, 'G_EXECUTES_TOOL', 'GuardTool');
|
|
71
|
-
const unambiguous = deployments.length === 1 &&
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
const unambiguous = deployments.length === 1 &&
|
|
93
|
+
executionDeployments.length === 1 &&
|
|
94
|
+
agents.length === 1 &&
|
|
95
|
+
exact(deployment, 'G_INSTANCE_OF', agents[0]) &&
|
|
96
|
+
executionDeployments[0].id === deployment.id &&
|
|
97
|
+
selectors.length === 1 &&
|
|
98
|
+
selectors[0].id === selector.id &&
|
|
99
|
+
grantTools.length === 1 &&
|
|
100
|
+
executionTools.length === 1 &&
|
|
101
|
+
grantTools[0].id === executionTools[0].id &&
|
|
102
|
+
value(grantTools[0], 'name') === tool &&
|
|
103
|
+
exact(task, 'G_ASSIGNED_TO', deployment) &&
|
|
104
|
+
exact(execution, 'G_EXECUTES_FOR', deployment) &&
|
|
105
|
+
exact(binding, 'G_BINDS_SELECTOR', selector) &&
|
|
106
|
+
exact(grant, 'G_FOR_TOOL', grantTools[0]) &&
|
|
107
|
+
exact(execution, 'G_EXECUTES_TOOL', executionTools[0]);
|
|
78
108
|
for (const action of actions) {
|
|
79
109
|
step();
|
|
110
|
+
if (select.action !== undefined && action !== select.action)
|
|
111
|
+
continue;
|
|
80
112
|
const key = JSON.stringify([task.id, deployment.id, execution.id, binding.id, action]);
|
|
81
113
|
if (seen.has(key))
|
|
82
114
|
continue;
|
|
@@ -85,12 +117,22 @@ export function policyPlan(companyId, tenantId, nodes, edges, maximum) {
|
|
|
85
117
|
fail('MAP_LIMIT_EXCEEDED');
|
|
86
118
|
const identity = identities.length === 1 ? identities[0] : null;
|
|
87
119
|
const resource = resources.length === 1 ? resources[0] : null;
|
|
88
|
-
const base = {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
const base = {
|
|
121
|
+
taskId: task.entityId,
|
|
122
|
+
deploymentId: deployment.entityId,
|
|
123
|
+
executionBindingId: execution.entityId,
|
|
124
|
+
resourceBindingId: binding.entityId,
|
|
125
|
+
identityId: identity?.entityId ?? null,
|
|
126
|
+
resourceId: resource?.entityId ?? null,
|
|
127
|
+
action,
|
|
128
|
+
status: 'not_collected',
|
|
129
|
+
reason: 'collection_missing',
|
|
130
|
+
observedAt: null,
|
|
131
|
+
assessment: 'unknown',
|
|
132
|
+
candidates: [],
|
|
133
|
+
};
|
|
92
134
|
const recordIndex = records.length;
|
|
93
|
-
const relationship = MAPPING[action];
|
|
135
|
+
const relationship = Object.hasOwn(MAPPING, action) ? MAPPING[action] : undefined;
|
|
94
136
|
if (!relationship) {
|
|
95
137
|
records.push(reason(base, 'not_collected', 'unsupported_action'));
|
|
96
138
|
continue;
|
|
@@ -99,43 +141,84 @@ export function policyPlan(companyId, tenantId, nodes, edges, maximum) {
|
|
|
99
141
|
records.push(reason(base, 'not_collected', 'identity_not_static'));
|
|
100
142
|
continue;
|
|
101
143
|
}
|
|
102
|
-
if (!unambiguous ||
|
|
144
|
+
if (!unambiguous ||
|
|
145
|
+
!identity ||
|
|
146
|
+
!resource ||
|
|
147
|
+
!exact(execution, 'G_USES_IDENTITY', identity) ||
|
|
148
|
+
!exact(binding, 'G_MAPS_TO', resource)) {
|
|
103
149
|
records.push(reason(base, 'unavailable', 'binding_ambiguous'));
|
|
104
150
|
continue;
|
|
105
151
|
}
|
|
106
152
|
const accountId = value(identity, 'account_id'), partition = value(identity, 'partition');
|
|
107
153
|
const principalArn = value(identity, 'arn'), resourceArn = value(resource, 'arn');
|
|
108
154
|
const principal = /^arn:aws:iam::([0-9]{12}):(role|user)\/.+$/.exec(principalArn ?? '');
|
|
109
|
-
|
|
155
|
+
// A bucket ARN, or an object or prefix ARN with a non-empty key.
|
|
156
|
+
const parsed = parseS3Arn(resourceArn);
|
|
157
|
+
const bucket = parsed && parsed.key !== '' ? parsed : null;
|
|
110
158
|
const kind = value(resource, 'kind');
|
|
111
|
-
if (value(identity, 'provider') !== 'aws' ||
|
|
112
|
-
value(resource, '
|
|
113
|
-
|
|
114
|
-
|
|
159
|
+
if (value(identity, 'provider') !== 'aws' ||
|
|
160
|
+
value(resource, 'provider') !== 'aws' ||
|
|
161
|
+
partition !== 'aws' ||
|
|
162
|
+
value(resource, 'partition') !== partition ||
|
|
163
|
+
!accountId ||
|
|
164
|
+
!/^[0-9]{12}$/.test(accountId) ||
|
|
165
|
+
value(resource, 'account_id') !== accountId ||
|
|
166
|
+
!principal ||
|
|
167
|
+
principal[1] !== accountId ||
|
|
168
|
+
!bucket ||
|
|
169
|
+
!['bucket', 'object', 'prefix'].includes(kind ?? '') ||
|
|
170
|
+
(kind === 'bucket') !== (bucket.key === undefined)) {
|
|
115
171
|
records.push(reason(base, 'not_collected', 'unsupported_binding'));
|
|
116
172
|
continue;
|
|
117
173
|
}
|
|
118
|
-
if (identity.properties.cloud_status === null ||
|
|
119
|
-
|
|
174
|
+
if (identity.properties.cloud_status === null ||
|
|
175
|
+
identity.properties.cloud_status === undefined ||
|
|
176
|
+
resource.properties.cloud_status === null ||
|
|
177
|
+
resource.properties.cloud_status === undefined) {
|
|
120
178
|
records.push(reason(base, 'not_collected', 'cloud_not_collected'));
|
|
121
179
|
continue;
|
|
122
180
|
}
|
|
123
|
-
if (identity.properties.cloud_status !== 'resolved' ||
|
|
181
|
+
if (identity.properties.cloud_status !== 'resolved' ||
|
|
182
|
+
resource.properties.cloud_status !== 'resolved') {
|
|
124
183
|
records.push(reason(base, 'unavailable', 'cloud_unresolved'));
|
|
125
184
|
continue;
|
|
126
185
|
}
|
|
127
|
-
// Python's scope ID uses compact ensure_ascii JSON; preserve that byte contract.
|
|
128
|
-
const encoded = JSON.stringify([companyId, tenantId, partition, accountId]).replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
129
186
|
records.push(base);
|
|
130
|
-
probes.push({
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
187
|
+
probes.push({
|
|
188
|
+
key: recordIndex,
|
|
189
|
+
identity,
|
|
190
|
+
resource,
|
|
191
|
+
accountId,
|
|
192
|
+
partition,
|
|
193
|
+
principalArn: principalArn,
|
|
194
|
+
bucketArn: `arn:aws:s3:::${bucket.bucket}`,
|
|
195
|
+
bucketName: bucket.bucket,
|
|
196
|
+
principalLabel: principal[2] === 'role' ? 'AWSRole' : 'AWSUser',
|
|
197
|
+
relationship,
|
|
198
|
+
collectionId: collectionKey(companyId, tenantId, partition, accountId),
|
|
199
|
+
proof: {
|
|
200
|
+
agentNodeId: agents[0].id,
|
|
201
|
+
agentEntityId: agents[0].entityId,
|
|
202
|
+
agentTeamId: value(agents[0], 'team_id'),
|
|
203
|
+
taskNodeId: task.id,
|
|
204
|
+
taskEntityId: task.entityId,
|
|
205
|
+
deploymentNodeId: deployment.id,
|
|
206
|
+
deploymentEntityId: deployment.entityId,
|
|
207
|
+
executionNodeId: execution.id,
|
|
208
|
+
executionEntityId: execution.entityId,
|
|
209
|
+
grantNodeId: grant.id,
|
|
210
|
+
grantEntityId: grant.entityId,
|
|
211
|
+
selectorNodeId: selector.id,
|
|
212
|
+
selectorEntityId: selector.entityId,
|
|
213
|
+
bindingNodeId: binding.id,
|
|
214
|
+
bindingEntityId: binding.entityId,
|
|
215
|
+
toolNodeId: grantTools[0].id,
|
|
216
|
+
toolEntityId: grantTools[0].entityId,
|
|
217
|
+
tool,
|
|
218
|
+
resourceRef: selectorValue,
|
|
219
|
+
action,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
139
222
|
}
|
|
140
223
|
}
|
|
141
224
|
}
|
|
@@ -146,9 +229,27 @@ export function policyPlan(companyId, tenantId, nodes, edges, maximum) {
|
|
|
146
229
|
return { records, probes };
|
|
147
230
|
}
|
|
148
231
|
// These are fixed property projections; neither query text nor property names come from requests.
|
|
149
|
-
const referenceText = {
|
|
150
|
-
|
|
151
|
-
|
|
232
|
+
const referenceText = {
|
|
233
|
+
id: 4096,
|
|
234
|
+
entity_id: 4096,
|
|
235
|
+
provider: 32,
|
|
236
|
+
partition: 32,
|
|
237
|
+
account_id: 12,
|
|
238
|
+
region: 64,
|
|
239
|
+
arn: 2048,
|
|
240
|
+
kind: 32,
|
|
241
|
+
cloud_status: 32,
|
|
242
|
+
cloud_assessment: 32,
|
|
243
|
+
cloud_provider_label: 32,
|
|
244
|
+
cloud_provider_id: 2048,
|
|
245
|
+
cloud_match_kind: 32,
|
|
246
|
+
};
|
|
247
|
+
const referenceNumbers = [
|
|
248
|
+
'cloud_observed_at',
|
|
249
|
+
'cloud_node_updated',
|
|
250
|
+
'cloud_ownership_updated',
|
|
251
|
+
'cloud_account_updated',
|
|
252
|
+
];
|
|
152
253
|
const referenceFields = [...Object.keys(referenceText), ...referenceNumbers];
|
|
153
254
|
const referenceProjection = (variable) => [
|
|
154
255
|
...Object.entries(referenceText).map(([field, maximum]) => `${field}:substring(${variable}.${field},0,${maximum + 1})`),
|
|
@@ -227,11 +328,33 @@ RETURN pair.key AS key,${bindingProof} AS bindingProof,
|
|
|
227
328
|
CASE WHEN bm IS NULL THEN null ELSE {${memberFields.replaceAll('m.', 'bm.').replaceAll('elementId(m)', 'elementId(bm)')}} END AS bucketMember,
|
|
228
329
|
CASE WHEN candidate IS NULL THEN null ELSE {eid:elementId(candidate),collection_id:substring(candidate.collection_id,0,65),capture_hash:substring(candidate.capture_hash,0,65),captured_at:candidate.captured_at,policy_hash:substring(candidate.policy_hash,0,65),mapping_version:substring(candidate.mapping_version,0,65),relationship:substring(candidate.relationship,0,33),has_condition:candidate.has_condition,condition_keys:[key IN candidate.condition_keys[..129] | substring(key,0,257)],condition_key_count:size(candidate.condition_keys),condition_key_lengths:[key IN candidate.condition_keys[..129] | size(key)],resource_scope:substring(candidate.resource_scope,0,33),assessment:substring(candidate.assessment,0,33),evidence_kind:substring(candidate.evidence_kind,0,65)} END AS candidate
|
|
229
330
|
LIMIT $policyRowLimit`;
|
|
230
|
-
export const POLICY_COLUMNS = [
|
|
231
|
-
'
|
|
331
|
+
export const POLICY_COLUMNS = [
|
|
332
|
+
'key',
|
|
333
|
+
'bindingProof',
|
|
334
|
+
'identity',
|
|
335
|
+
'resource',
|
|
336
|
+
'identityLink',
|
|
337
|
+
'resourceLink',
|
|
338
|
+
'principal',
|
|
339
|
+
'bucket',
|
|
340
|
+
'principalAccount',
|
|
341
|
+
'bucketAccount',
|
|
342
|
+
'principalOwnership',
|
|
343
|
+
'bucketOwnership',
|
|
344
|
+
'collection',
|
|
345
|
+
'principalMember',
|
|
346
|
+
'bucketMember',
|
|
347
|
+
'candidate',
|
|
348
|
+
];
|
|
232
349
|
export function policyParameters(plan) {
|
|
233
|
-
return plan.probes.map(probe => ({
|
|
234
|
-
|
|
350
|
+
return plan.probes.map(probe => ({
|
|
351
|
+
...probe.proof,
|
|
352
|
+
key: probe.key,
|
|
353
|
+
identityNodeId: probe.identity.id,
|
|
354
|
+
resourceNodeId: probe.resource.id,
|
|
355
|
+
accountId: probe.accountId,
|
|
356
|
+
partition: probe.partition,
|
|
357
|
+
}));
|
|
235
358
|
}
|
|
236
359
|
export function safePolicyRow(raw) {
|
|
237
360
|
try {
|
|
@@ -241,22 +364,40 @@ export function safePolicyRow(raw) {
|
|
|
241
364
|
return fail('MAP_LIMIT_EXCEEDED');
|
|
242
365
|
}
|
|
243
366
|
}
|
|
244
|
-
function object(value) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
367
|
+
function object(value) {
|
|
368
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
369
|
+
? value
|
|
370
|
+
: null;
|
|
371
|
+
}
|
|
372
|
+
function stamp(value, now) {
|
|
373
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= now;
|
|
374
|
+
}
|
|
375
|
+
function hash(value) {
|
|
376
|
+
return typeof value === 'string' && HASH.test(value);
|
|
377
|
+
}
|
|
378
|
+
/** Condition keys are bounded, unique, sorted text, and present exactly when has_condition is. */
|
|
379
|
+
function conditionKeysValid(candidate) {
|
|
380
|
+
const keys = candidate.condition_keys, lengths = candidate.condition_key_lengths;
|
|
381
|
+
return (typeof candidate.has_condition === 'boolean' &&
|
|
382
|
+
Array.isArray(keys) &&
|
|
383
|
+
keys.length <= 128 &&
|
|
384
|
+
candidate.condition_key_count === keys.length &&
|
|
385
|
+
Array.isArray(lengths) &&
|
|
386
|
+
lengths.length === keys.length &&
|
|
387
|
+
!keys.some((key, index) => typeof key !== 'string' ||
|
|
388
|
+
!key ||
|
|
389
|
+
/[\u0000-\u001f\ud800-\udfff]/u.test(key) ||
|
|
390
|
+
Buffer.byteLength(key) > 256 ||
|
|
391
|
+
lengths[index] !== [...key].length) &&
|
|
392
|
+
new Set(keys).size === keys.length &&
|
|
393
|
+
JSON.stringify(keys) === JSON.stringify([...keys].sort(compareCodePoints)) &&
|
|
394
|
+
candidate.has_condition === keys.length > 0);
|
|
255
395
|
}
|
|
256
396
|
function referenceMatches(actual, expected) {
|
|
257
397
|
if (actual.id !== expected.id || actual.entity_id !== expected.entityId)
|
|
258
398
|
return false;
|
|
259
|
-
return referenceFields
|
|
399
|
+
return referenceFields
|
|
400
|
+
.filter(field => field !== 'id' && field !== 'entity_id')
|
|
260
401
|
.every(field => (actual[field] ?? null) === (expected.properties[field] ?? null));
|
|
261
402
|
}
|
|
262
403
|
/** Pure classification: no positive candidate survives a malformed endpoint or stale receipt. */
|
|
@@ -266,7 +407,7 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
266
407
|
for (const row of rows) {
|
|
267
408
|
const key = row.key;
|
|
268
409
|
if (typeof key !== 'number' || !Number.isSafeInteger(key) || !requested.has(key))
|
|
269
|
-
fail();
|
|
410
|
+
fail('MAP_UNAVAILABLE');
|
|
270
411
|
const items = groups.get(key) ?? [];
|
|
271
412
|
items.push(row);
|
|
272
413
|
groups.set(key, items);
|
|
@@ -274,7 +415,9 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
274
415
|
const result = [...plan.records];
|
|
275
416
|
for (const probe of plan.probes) {
|
|
276
417
|
const base = result[probe.key], matches = groups.get(probe.key) ?? [];
|
|
277
|
-
const set = (status, code, time = null) => {
|
|
418
|
+
const set = (status, code, time = null) => {
|
|
419
|
+
result[probe.key] = reason(base, status, code, time);
|
|
420
|
+
};
|
|
278
421
|
if (!matches.length) {
|
|
279
422
|
set('unavailable', 'changed');
|
|
280
423
|
continue;
|
|
@@ -301,15 +444,30 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
301
444
|
continue;
|
|
302
445
|
}
|
|
303
446
|
const containment = x.kind !== 'bucket';
|
|
304
|
-
if (p.principal !== true ||
|
|
305
|
-
p.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
447
|
+
if (p.principal !== true ||
|
|
448
|
+
p.role !== (probe.principalLabel === 'AWSRole') ||
|
|
449
|
+
p.user !== (probe.principalLabel === 'AWSUser') ||
|
|
450
|
+
p.id !== probe.principalArn ||
|
|
451
|
+
p.arn !== probe.principalArn ||
|
|
452
|
+
b.bucket !== true ||
|
|
453
|
+
b.id !== probe.bucketName ||
|
|
454
|
+
b.arn !== probe.bucketArn ||
|
|
455
|
+
pa.id !== probe.accountId ||
|
|
456
|
+
ba.id !== probe.accountId ||
|
|
457
|
+
pa.eid !== ba.eid ||
|
|
458
|
+
i.cloud_provider_id !== probe.principalArn ||
|
|
459
|
+
i.cloud_provider_label !== probe.principalLabel ||
|
|
460
|
+
i.cloud_match_kind !== 'exact' ||
|
|
461
|
+
x.cloud_provider_id !== probe.bucketName ||
|
|
462
|
+
x.cloud_provider_label !== 'AWSS3Bucket' ||
|
|
463
|
+
x.cloud_match_kind !== (containment ? 'containment' : 'exact') ||
|
|
464
|
+
ic.kind !== 'G_CLOUD_MATCH' ||
|
|
465
|
+
bc.kind !== (containment ? 'G_CLOUD_CONTAINER' : 'G_CLOUD_MATCH') ||
|
|
310
466
|
[i, x, ic, bc].some(item => (item.cloud_assessment ?? item.assessment) !== 'unknown') ||
|
|
311
|
-
ic.evidence_kind !== 'provider_inventory' ||
|
|
312
|
-
|
|
467
|
+
ic.evidence_kind !== 'provider_inventory' ||
|
|
468
|
+
bc.evidence_kind !== 'provider_inventory' ||
|
|
469
|
+
ic.observed_at !== i.cloud_observed_at ||
|
|
470
|
+
bc.observed_at !== x.cloud_observed_at ||
|
|
313
471
|
(x.region && x.region !== b.region)) {
|
|
314
472
|
set('unavailable', 'evidence_invalid');
|
|
315
473
|
continue;
|
|
@@ -318,14 +476,31 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
318
476
|
set('not_collected', 'collection_missing');
|
|
319
477
|
continue;
|
|
320
478
|
}
|
|
321
|
-
if (c.id !== probe.collectionId ||
|
|
479
|
+
if (c.id !== probe.collectionId ||
|
|
480
|
+
c.schema_version !== 1 ||
|
|
481
|
+
c.status !== 'complete' ||
|
|
482
|
+
!hash(c.content_hash) ||
|
|
483
|
+
!stamp(c.captured_at, now)) {
|
|
322
484
|
set('unavailable', 'collection_invalid');
|
|
323
485
|
continue;
|
|
324
486
|
}
|
|
325
487
|
const observedAt = new Date(c.captured_at * 1000).toISOString();
|
|
326
|
-
const times = [
|
|
327
|
-
|
|
328
|
-
|
|
488
|
+
const times = [
|
|
489
|
+
p.lastupdated,
|
|
490
|
+
b.lastupdated,
|
|
491
|
+
pa.lastupdated,
|
|
492
|
+
ba.lastupdated,
|
|
493
|
+
po.lastupdated,
|
|
494
|
+
bo.lastupdated,
|
|
495
|
+
i.cloud_observed_at,
|
|
496
|
+
x.cloud_observed_at,
|
|
497
|
+
i.cloud_node_updated,
|
|
498
|
+
x.cloud_node_updated,
|
|
499
|
+
i.cloud_ownership_updated,
|
|
500
|
+
x.cloud_ownership_updated,
|
|
501
|
+
i.cloud_account_updated,
|
|
502
|
+
x.cloud_account_updated,
|
|
503
|
+
];
|
|
329
504
|
if (times.some(time => !stamp(time, now))) {
|
|
330
505
|
set('unavailable', 'changed', observedAt);
|
|
331
506
|
continue;
|
|
@@ -334,11 +509,20 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
334
509
|
set('unavailable', 'stale', observedAt);
|
|
335
510
|
continue;
|
|
336
511
|
}
|
|
337
|
-
if (!pm ||
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
512
|
+
if (!pm ||
|
|
513
|
+
!bm ||
|
|
514
|
+
[pm, bm].some(member => member.capture_hash !== c.content_hash || member.captured_at !== c.captured_at) ||
|
|
515
|
+
p.lastupdated !== c.captured_at ||
|
|
516
|
+
b.lastupdated !== c.captured_at ||
|
|
517
|
+
po.lastupdated !== c.captured_at ||
|
|
518
|
+
bo.lastupdated !== c.captured_at ||
|
|
519
|
+
i.cloud_node_updated !== p.lastupdated ||
|
|
520
|
+
x.cloud_node_updated !== b.lastupdated ||
|
|
521
|
+
i.cloud_ownership_updated !== po.lastupdated ||
|
|
522
|
+
x.cloud_ownership_updated !== bo.lastupdated ||
|
|
523
|
+
i.cloud_account_updated !== pa.lastupdated ||
|
|
524
|
+
x.cloud_account_updated !== ba.lastupdated ||
|
|
525
|
+
bm.region !== b.region) {
|
|
342
526
|
set('unavailable', 'changed', observedAt);
|
|
343
527
|
continue;
|
|
344
528
|
}
|
|
@@ -346,10 +530,15 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
346
530
|
set('not_collected', 'disabled', observedAt);
|
|
347
531
|
continue;
|
|
348
532
|
}
|
|
349
|
-
if (c.policy_status !== 'complete' ||
|
|
350
|
-
|
|
533
|
+
if (c.policy_status !== 'complete' ||
|
|
534
|
+
!hash(c.policy_hash) ||
|
|
535
|
+
c.policy_mapping_version !== VERSION ||
|
|
536
|
+
pm.policy_hash !== c.policy_hash ||
|
|
537
|
+
pm.policy_mapping_version !== VERSION ||
|
|
538
|
+
pm.policy_assessment !== 'unknown' ||
|
|
351
539
|
!['mapped', 'unsupported', 'boundary_unresolved'].includes(String(pm.policy_status)) ||
|
|
352
|
-
typeof pm.policy_boundary_present !== 'boolean' ||
|
|
540
|
+
typeof pm.policy_boundary_present !== 'boolean' ||
|
|
541
|
+
pm.policy_boundary_present !== (pm.policy_status === 'boundary_unresolved')) {
|
|
353
542
|
set('unavailable', 'collection_invalid', observedAt);
|
|
354
543
|
continue;
|
|
355
544
|
}
|
|
@@ -366,29 +555,553 @@ export function policyEvidence(plan, rows, now, maximumAge) {
|
|
|
366
555
|
invalid = true;
|
|
367
556
|
continue;
|
|
368
557
|
}
|
|
369
|
-
const keys = candidate.condition_keys
|
|
558
|
+
const keys = candidate.condition_keys;
|
|
370
559
|
const relation = candidate.relationship;
|
|
371
|
-
if (candidate.collection_id !== c.id ||
|
|
372
|
-
candidate.
|
|
373
|
-
candidate.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
candidate.
|
|
377
|
-
|
|
378
|
-
|
|
560
|
+
if (candidate.collection_id !== c.id ||
|
|
561
|
+
candidate.capture_hash !== c.content_hash ||
|
|
562
|
+
candidate.captured_at !== c.captured_at ||
|
|
563
|
+
candidate.policy_hash !== c.policy_hash ||
|
|
564
|
+
candidate.mapping_version !== VERSION ||
|
|
565
|
+
candidate.assessment !== 'unknown' ||
|
|
566
|
+
candidate.resource_scope !== 'bucket_summary' ||
|
|
567
|
+
candidate.evidence_kind !== 'iam_identity_policy_candidate' ||
|
|
568
|
+
typeof relation !== 'string' ||
|
|
569
|
+
!Object.values(MAPPING).includes(relation) ||
|
|
570
|
+
seen.has(relation) ||
|
|
571
|
+
!conditionKeysValid(candidate)) {
|
|
379
572
|
invalid = true;
|
|
380
573
|
break;
|
|
381
574
|
}
|
|
382
575
|
seen.add(relation);
|
|
383
576
|
if (relation === probe.relationship)
|
|
384
|
-
candidates.push({
|
|
385
|
-
|
|
577
|
+
candidates.push({
|
|
578
|
+
relationship: relation,
|
|
579
|
+
hasCondition: candidate.has_condition,
|
|
580
|
+
conditionKeys: keys,
|
|
581
|
+
resourceScope: 'bucket_summary',
|
|
582
|
+
});
|
|
386
583
|
}
|
|
387
584
|
if (invalid) {
|
|
388
585
|
set('unavailable', 'evidence_invalid', observedAt);
|
|
389
586
|
continue;
|
|
390
587
|
}
|
|
391
|
-
result[probe.key] = {
|
|
588
|
+
result[probe.key] = {
|
|
589
|
+
...base,
|
|
590
|
+
status: 'complete',
|
|
591
|
+
reason: candidates.length ? null : 'no_candidate',
|
|
592
|
+
observedAt,
|
|
593
|
+
candidates,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// C2: identity scope. The forward reader above probes only bound buckets. This
|
|
600
|
+
// reverse reader asks, per static execution identity, which collected buckets in
|
|
601
|
+
// the same account the identity has an identity-policy candidate on, and compares
|
|
602
|
+
// them with the buckets that tool's own bindings map to. Bucket policies, SCPs and
|
|
603
|
+
// permission boundaries are not evaluated, so extra buckets are candidates only.
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
/** Buckets listed per identity before the comparison reports `truncated`. */
|
|
606
|
+
export const IDENTITY_SCOPE_BUCKETS = 500;
|
|
607
|
+
const RELATIONSHIPS = Object.values(MAPPING);
|
|
608
|
+
const BUCKET = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
|
|
609
|
+
const EMPTY_TOOL_GRANTS = Object.freeze({
|
|
610
|
+
bound: new Set(),
|
|
611
|
+
partial: new Set(),
|
|
612
|
+
wildcard: false,
|
|
613
|
+
ambiguous: false,
|
|
614
|
+
lineageUnknown: false,
|
|
615
|
+
});
|
|
616
|
+
/**
|
|
617
|
+
* Extra buckets listed per execution binding. Tools that share a role share its probe, so
|
|
618
|
+
* each record keeps its own list of at most this many buckets; a longer one is `truncated`.
|
|
619
|
+
*/
|
|
620
|
+
const IDENTITY_SCOPE_RECORD_BUCKETS = 100;
|
|
621
|
+
/** The reach read behind list_resources keeps fewer buckets per identity, bounding its rows. */
|
|
622
|
+
const IDENTITY_REACH_BUCKETS = 100;
|
|
623
|
+
/**
|
|
624
|
+
* One record per execution binding; a probe per distinct, unambiguous, resolved static AWS
|
|
625
|
+
* identity. Past `maximum` identities the rest report `not_evaluated` instead of failing the
|
|
626
|
+
* read. `buckets` limits the read to those bucket names (the reach check behind
|
|
627
|
+
* `list_resources`). Grants count only on live tasks at `observedAt` (the snapshot time).
|
|
628
|
+
*/
|
|
629
|
+
export function identityScopePlan(companyId, tenantId, nodes, edges, maximum, buckets = null, observedAt = Number.POSITIVE_INFINITY) {
|
|
630
|
+
const { linked, exact, step } = graphIndex(nodes, edges);
|
|
631
|
+
const records = [], probes = [], links = [];
|
|
632
|
+
const probeByIdentity = new Map();
|
|
633
|
+
const liveness = new Map();
|
|
634
|
+
const indexes = new Map();
|
|
635
|
+
/**
|
|
636
|
+
* Per deployment, per tool: what live grants bind. task -> grant -> selector <- binding (same
|
|
637
|
+
* tool and selector) -> external S3 resource. Only a binding of the whole bucket covers a
|
|
638
|
+
* bucket-level candidate; an object or prefix is partial. A grant whose task's chain this read
|
|
639
|
+
* cannot finish (the same liveness as the flags) leaves the tool's lineage unknown.
|
|
640
|
+
*/
|
|
641
|
+
const deploymentIndex = (deployment) => {
|
|
642
|
+
const existing = indexes.get(deployment.id);
|
|
643
|
+
if (existing)
|
|
644
|
+
return existing;
|
|
645
|
+
const index = new Map();
|
|
646
|
+
const facts = (tool) => {
|
|
647
|
+
let item = index.get(tool);
|
|
648
|
+
if (!item) {
|
|
649
|
+
item = { bound: new Set(), partial: new Set(), wildcard: false, ambiguous: false, lineageUnknown: false };
|
|
650
|
+
index.set(tool, item);
|
|
651
|
+
}
|
|
652
|
+
return item;
|
|
653
|
+
};
|
|
654
|
+
for (const task of linked(deployment, 'G_ASSIGNED_TO', 'GuardTask', true)) {
|
|
655
|
+
let life = liveness.get(task.id);
|
|
656
|
+
if (!life) {
|
|
657
|
+
life = taskLiveness((node, kind) => linked(node, kind, 'GuardTask'), task, observedAt, () => step());
|
|
658
|
+
liveness.set(task.id, life);
|
|
659
|
+
}
|
|
660
|
+
if (!life.live)
|
|
661
|
+
continue;
|
|
662
|
+
// A task counts as live only on known facts: a missing `revoked` or `deadline` anywhere
|
|
663
|
+
// on its chain, or a snapshot time that does not parse, leaves its grants unknown.
|
|
664
|
+
const incomplete = life.hidden ||
|
|
665
|
+
life.unrecorded ||
|
|
666
|
+
life.depthExceeded ||
|
|
667
|
+
life.ambiguous ||
|
|
668
|
+
life.unverified ||
|
|
669
|
+
Number.isNaN(observedAt);
|
|
670
|
+
for (const grant of linked(task, 'G_HAS_GRANT', 'GuardGrant')) {
|
|
671
|
+
const tool = value(grant, 'tool');
|
|
672
|
+
if (tool === null)
|
|
673
|
+
continue;
|
|
674
|
+
const item = facts(tool);
|
|
675
|
+
if (incomplete) {
|
|
676
|
+
item.lineageUnknown = true;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const resources = grant.properties.resources;
|
|
680
|
+
if (Array.isArray(resources) && resources.includes('*'))
|
|
681
|
+
item.wildcard = true;
|
|
682
|
+
for (const selector of linked(grant, 'G_TARGETS', 'GuardResource')) {
|
|
683
|
+
const selectorValue = value(selector, 'resource_id');
|
|
684
|
+
if (!Array.isArray(resources) || !resources.includes(selectorValue))
|
|
685
|
+
continue;
|
|
686
|
+
for (const binding of linked(selector, 'G_BINDS_SELECTOR', 'GuardResourceBinding', true)) {
|
|
687
|
+
step();
|
|
688
|
+
if (value(binding, 'tool') !== tool || value(binding, 'resource_ref') !== selectorValue)
|
|
689
|
+
continue;
|
|
690
|
+
const targets = linked(binding, 'G_MAPS_TO', 'GuardExternalResource');
|
|
691
|
+
// The collector writes one target per binding; more is malformed, so nothing is bound.
|
|
692
|
+
if (targets.length !== 1)
|
|
693
|
+
item.ambiguous = true;
|
|
694
|
+
for (const target of targets) {
|
|
695
|
+
const bucket = parseS3Arn(value(target, 'arn'));
|
|
696
|
+
if (value(target, 'provider') !== 'aws' || !bucket)
|
|
697
|
+
continue;
|
|
698
|
+
if (value(target, 'kind') === 'bucket' && bucket.key === undefined)
|
|
699
|
+
item.bound.add(bucket.bucket);
|
|
700
|
+
else
|
|
701
|
+
item.partial.add(bucket.bucket);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const item of index.values())
|
|
708
|
+
for (const name of item.bound)
|
|
709
|
+
item.partial.delete(name);
|
|
710
|
+
indexes.set(deployment.id, index);
|
|
711
|
+
return index;
|
|
712
|
+
};
|
|
713
|
+
for (const execution of nodes.filter(node => node.label === 'GuardExecutionBinding')) {
|
|
714
|
+
if (records.length >= 10_000)
|
|
715
|
+
fail('MAP_LIMIT_EXCEEDED');
|
|
716
|
+
const tool = value(execution, 'tool');
|
|
717
|
+
const deployments = linked(execution, 'G_EXECUTES_FOR', 'GuardDeployment');
|
|
718
|
+
const tools = linked(execution, 'G_EXECUTES_TOOL', 'GuardTool');
|
|
719
|
+
const identities = linked(execution, 'G_USES_IDENTITY', 'GuardExternalIdentity');
|
|
720
|
+
const deployment = deployments.length === 1 ? deployments[0] : null;
|
|
721
|
+
const identity = identities.length === 1 ? identities[0] : null;
|
|
722
|
+
const base = {
|
|
723
|
+
executionBindingId: execution.entityId,
|
|
724
|
+
deploymentId: deployment?.entityId ?? null,
|
|
725
|
+
tool,
|
|
726
|
+
identityId: identity?.entityId ?? null,
|
|
727
|
+
accountId: identity ? value(identity, 'account_id') : null,
|
|
728
|
+
status: 'unknown',
|
|
729
|
+
reason: 'collection_missing',
|
|
730
|
+
observedAt: null,
|
|
731
|
+
boundBuckets: [],
|
|
732
|
+
extraResources: [],
|
|
733
|
+
truncated: false,
|
|
734
|
+
evidenceKind: 'iam_identity_policy_candidate',
|
|
735
|
+
assessment: 'unknown',
|
|
736
|
+
};
|
|
737
|
+
const record = (code) => {
|
|
738
|
+
records.push({ ...base, reason: code });
|
|
739
|
+
};
|
|
740
|
+
if (value(execution, 'identity_selection') !== 'static') {
|
|
741
|
+
record('identity_not_static');
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const agents = deployment ? linked(deployment, 'G_INSTANCE_OF', 'GuardAgent') : [];
|
|
745
|
+
if (!tool ||
|
|
746
|
+
!deployment ||
|
|
747
|
+
!identity ||
|
|
748
|
+
tools.length !== 1 ||
|
|
749
|
+
value(tools[0], 'name') !== tool ||
|
|
750
|
+
agents.length !== 1 ||
|
|
751
|
+
!exact(execution, 'G_EXECUTES_FOR', deployment) ||
|
|
752
|
+
!exact(execution, 'G_EXECUTES_TOOL', tools[0]) ||
|
|
753
|
+
!exact(execution, 'G_USES_IDENTITY', identity) ||
|
|
754
|
+
!exact(deployment, 'G_INSTANCE_OF', agents[0])) {
|
|
755
|
+
record('binding_ambiguous');
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const accountId = value(identity, 'account_id'), partition = value(identity, 'partition'), principalArn = value(identity, 'arn');
|
|
759
|
+
const principal = /^arn:aws:iam::([0-9]{12}):(role|user)\/.+$/.exec(principalArn ?? '');
|
|
760
|
+
if (value(identity, 'provider') !== 'aws' ||
|
|
761
|
+
partition !== 'aws' ||
|
|
762
|
+
!accountId ||
|
|
763
|
+
!/^[0-9]{12}$/.test(accountId) ||
|
|
764
|
+
!principal ||
|
|
765
|
+
principal[1] !== accountId) {
|
|
766
|
+
record('unsupported_identity');
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (identity.properties.cloud_status === null || identity.properties.cloud_status === undefined) {
|
|
770
|
+
record('cloud_not_collected');
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (identity.properties.cloud_status !== 'resolved') {
|
|
774
|
+
record('cloud_unresolved');
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
// What this tool's live grants bind for this deployment, from an index built once per
|
|
778
|
+
// deployment (every tool at once), so the work follows tasks and grants, not tools.
|
|
779
|
+
const index = deploymentIndex(deployment);
|
|
780
|
+
const facts = index.get(tool) ?? EMPTY_TOOL_GRANTS;
|
|
781
|
+
const { bound, partial, wildcard, ambiguous, lineageUnknown } = facts;
|
|
782
|
+
const boundBuckets = [...new Set([...bound, ...partial])].sort();
|
|
783
|
+
if (ambiguous) {
|
|
784
|
+
records.push({ ...base, boundBuckets, reason: 'binding_ambiguous' });
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (lineageUnknown) {
|
|
788
|
+
records.push({ ...base, boundBuckets, reason: 'grant_lineage_unknown' });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
// A `*` grant lets the tool name any resource, so there is no narrower grant to compare with.
|
|
792
|
+
if (wildcard) {
|
|
793
|
+
records.push({ ...base, boundBuckets, reason: 'wildcard_grant' });
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
let probe = probeByIdentity.get(identity.id);
|
|
797
|
+
if (probe === undefined) {
|
|
798
|
+
if (probes.length >= maximum) {
|
|
799
|
+
records.push({ ...base, boundBuckets, reason: 'not_evaluated' });
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
probe = probes.length;
|
|
803
|
+
probeByIdentity.set(identity.id, probe);
|
|
804
|
+
probes.push({
|
|
805
|
+
key: probe,
|
|
806
|
+
identity,
|
|
807
|
+
accountId,
|
|
808
|
+
partition,
|
|
809
|
+
principalArn: principalArn,
|
|
810
|
+
principalLabel: principal[2] === 'role' ? 'AWSRole' : 'AWSUser',
|
|
811
|
+
collectionId: collectionKey(companyId, tenantId, partition, accountId),
|
|
812
|
+
proof: {
|
|
813
|
+
executionNodeId: execution.id,
|
|
814
|
+
executionEntityId: execution.entityId,
|
|
815
|
+
deploymentNodeId: deployment.id,
|
|
816
|
+
deploymentEntityId: deployment.entityId,
|
|
817
|
+
agentNodeId: agents[0].id,
|
|
818
|
+
agentEntityId: agents[0].entityId,
|
|
819
|
+
agentTeamId: value(agents[0], 'team_id'),
|
|
820
|
+
tool,
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
links.push({ record: records.length, probe, bound, partial });
|
|
825
|
+
records.push({ ...base, boundBuckets });
|
|
826
|
+
}
|
|
827
|
+
const filter = buckets === null ? null : [...new Set(buckets)].sort();
|
|
828
|
+
const bucketLimit = filter === null ? IDENTITY_SCOPE_BUCKETS : Math.min(IDENTITY_REACH_BUCKETS, filter.length);
|
|
829
|
+
return {
|
|
830
|
+
records,
|
|
831
|
+
probes,
|
|
832
|
+
links,
|
|
833
|
+
buckets: filter,
|
|
834
|
+
bucketLimit,
|
|
835
|
+
// At most three relationships per bucket, so one row past the limit always shows one more
|
|
836
|
+
// bucket; unique (bucket, relationship) pairs make that the only way to reach it.
|
|
837
|
+
candidateLimit: bucketLimit * RELATIONSHIPS.length + 1,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
// The declared chain behind one probe (execution binding -> deployment -> agent), re-checked in
|
|
841
|
+
// the database for the reader's scope.
|
|
842
|
+
const executionProof = `EXISTS {
|
|
843
|
+
MATCH (execution:GuardExecutionBinding {id:pair.executionNodeId,entity_id:pair.executionEntityId})
|
|
844
|
+
-[:G_EXECUTES_FOR]->(deployment:GuardDeployment {id:pair.deploymentNodeId,entity_id:pair.deploymentEntityId})
|
|
845
|
+
-[:G_INSTANCE_OF]->(agent:GuardAgent {id:pair.agentNodeId,entity_id:pair.agentEntityId})
|
|
846
|
+
WHERE ${proofScope('execution')} AND ${proofScope('deployment')} AND ${proofScope('agent')}
|
|
847
|
+
AND execution.identity_selection='static' AND execution.tool=pair.tool
|
|
848
|
+
AND ($allTeams OR agent.team_id IN $teamIds)
|
|
849
|
+
AND (agent.team_id=pair.agentTeamId OR (agent.team_id IS NULL AND pair.agentTeamId IS NULL))
|
|
850
|
+
AND COUNT { (execution)-[:G_EXECUTES_FOR]->() } = 1
|
|
851
|
+
AND COUNT { (deployment)-[:G_INSTANCE_OF]->() } = 1
|
|
852
|
+
AND COUNT { (execution)-[:G_USES_IDENTITY]->() } = 1
|
|
853
|
+
AND EXISTS { (execution)-[:G_USES_IDENTITY]->(i) }
|
|
854
|
+
}`;
|
|
855
|
+
// The candidate's fixed fields are compared with its collection in Cypher, so each row
|
|
856
|
+
// carries a compact boolean instead of repeating hashes for up to 1,500 candidates.
|
|
857
|
+
export const IDENTITY_SCOPE_QUERY = `UNWIND $scopePairs AS pair
|
|
858
|
+
OPTIONAL MATCH (i:GuardExternalIdentity {id:pair.identityNodeId,source_id:$sourceId,projection_id:$projectionId,tenant_id:$tenantId})
|
|
859
|
+
OPTIONAL MATCH (i)-[ic:G_CLOUD_MATCH]->(p)
|
|
860
|
+
OPTIONAL MATCH (pa:AWSAccount)-[po:RESOURCE]->(p)
|
|
861
|
+
OPTIONAL MATCH (c:GuardAwsCollection {company_id:$companyId,tenant_id:$tenantId,partition:pair.partition,account_id:pair.accountId})
|
|
862
|
+
OPTIONAL MATCH (c)-[pm:G_AWS_OBSERVED]->(p)
|
|
863
|
+
CALL (p, c, pair) {
|
|
864
|
+
OPTIONAL MATCH (p)-[candidate:G_AWS_POLICY_CANDIDATE {collection_id:c.id}]->(b)
|
|
865
|
+
WHERE $buckets IS NULL OR b.id IN $buckets
|
|
866
|
+
// Cut to the limit first, so membership lookups follow the limit, not the account size.
|
|
867
|
+
WITH c, pair, candidate, b ORDER BY b.id, candidate.relationship LIMIT $candidateLimit
|
|
868
|
+
OPTIONAL MATCH (c)-[bm:G_AWS_OBSERVED]->(b)
|
|
869
|
+
OPTIONAL MATCH (ba:AWSAccount)-[bo:RESOURCE]->(b)
|
|
870
|
+
RETURN collect(CASE WHEN candidate IS NULL THEN null ELSE {
|
|
871
|
+
bucket:substring(b.id,0,64),
|
|
872
|
+
relationship:substring(candidate.relationship,0,33),
|
|
873
|
+
has_condition:candidate.has_condition,
|
|
874
|
+
condition_keys:[key IN candidate.condition_keys[..129] | substring(key,0,257)],
|
|
875
|
+
condition_key_count:size(candidate.condition_keys),
|
|
876
|
+
condition_key_lengths:[key IN candidate.condition_keys[..129] | size(key)],
|
|
877
|
+
consistent:coalesce(b:AWSS3Bucket AND b.arn='arn:aws:s3:::'+b.id
|
|
878
|
+
AND candidate.capture_hash=c.content_hash AND candidate.captured_at=c.captured_at
|
|
879
|
+
AND candidate.policy_hash=c.policy_hash AND candidate.mapping_version='${VERSION}'
|
|
880
|
+
AND candidate.resource_scope='bucket_summary' AND candidate.assessment='unknown'
|
|
881
|
+
AND candidate.evidence_kind='iam_identity_policy_candidate'
|
|
882
|
+
AND bm.capture_hash=c.content_hash AND bm.captured_at=c.captured_at
|
|
883
|
+
AND b.lastupdated=c.captured_at AND ba.id=pair.accountId AND bo.lastupdated=c.captured_at, false)
|
|
884
|
+
} END) AS candidates
|
|
885
|
+
}
|
|
886
|
+
RETURN pair.key AS key,${executionProof} AS executionProof,
|
|
887
|
+
i {${referenceProjection('i')}} AS identity,
|
|
888
|
+
CASE WHEN ic IS NULL THEN null ELSE {eid:elementId(ic),kind:type(ic),evidence_kind:substring(ic.evidence_kind,0,65),assessment:substring(ic.assessment,0,33),observed_at:ic.observed_at} END AS identityLink,
|
|
889
|
+
CASE WHEN p IS NULL THEN null ELSE {eid:elementId(p),id:substring(p.id,0,2049),arn:substring(p.arn,0,2049),lastupdated:p.lastupdated,principal:p:AWSPrincipal,role:p:AWSRole,user:p:AWSUser} END AS principal,
|
|
890
|
+
CASE WHEN pa IS NULL THEN null ELSE {eid:elementId(pa),id:substring(pa.id,0,13),lastupdated:pa.lastupdated} END AS principalAccount,
|
|
891
|
+
CASE WHEN po IS NULL THEN null ELSE {eid:elementId(po),lastupdated:po.lastupdated} END AS principalOwnership,
|
|
892
|
+
CASE WHEN c IS NULL THEN null ELSE {eid:elementId(c),id:substring(c.id,0,65),schema_version:c.schema_version,status:substring(c.status,0,33),captured_at:c.captured_at,content_hash:substring(c.content_hash,0,65),policy_status:substring(c.policy_status,0,33),policy_hash:substring(c.policy_hash,0,65),policy_mapping_version:substring(c.policy_mapping_version,0,65)} END AS collection,
|
|
893
|
+
CASE WHEN pm IS NULL THEN null ELSE {${memberFields.replaceAll('m.', 'pm.').replaceAll('elementId(m)', 'elementId(pm)')}} END AS principalMember,
|
|
894
|
+
candidates
|
|
895
|
+
ORDER BY key
|
|
896
|
+
LIMIT $scopeRowLimit`;
|
|
897
|
+
export const IDENTITY_SCOPE_COLUMNS = [
|
|
898
|
+
'key',
|
|
899
|
+
'executionProof',
|
|
900
|
+
'identity',
|
|
901
|
+
'identityLink',
|
|
902
|
+
'principal',
|
|
903
|
+
'principalAccount',
|
|
904
|
+
'principalOwnership',
|
|
905
|
+
'collection',
|
|
906
|
+
'principalMember',
|
|
907
|
+
'candidates',
|
|
908
|
+
];
|
|
909
|
+
export function identityScopeParameters(plan) {
|
|
910
|
+
return plan.probes.map(probe => ({
|
|
911
|
+
...probe.proof,
|
|
912
|
+
key: probe.key,
|
|
913
|
+
identityNodeId: probe.identity.id,
|
|
914
|
+
accountId: probe.accountId,
|
|
915
|
+
partition: probe.partition,
|
|
916
|
+
}));
|
|
917
|
+
}
|
|
918
|
+
export function safeIdentityScopeRow(raw) {
|
|
919
|
+
try {
|
|
920
|
+
return immutableJson(raw, 8, 40_000, 1024 * 1024);
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return fail('MAP_LIMIT_EXCEEDED');
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function probeOutcome(plan, probe, matches, now, maximumAge) {
|
|
927
|
+
const failed = (reason, observedAt = null) => ({
|
|
928
|
+
ok: false,
|
|
929
|
+
reason,
|
|
930
|
+
observedAt,
|
|
931
|
+
});
|
|
932
|
+
// The store could not keep this identity's row (too large): its list is cut short.
|
|
933
|
+
if (matches.some(row => row.oversized === true))
|
|
934
|
+
return failed('truncated');
|
|
935
|
+
if (!matches.length || matches[0].executionProof !== true)
|
|
936
|
+
return failed('changed');
|
|
937
|
+
// One identity, principal, account and member each: any fan-out is malformed evidence.
|
|
938
|
+
if (matches.length !== 1)
|
|
939
|
+
return failed('evidence_invalid');
|
|
940
|
+
const row = matches[0];
|
|
941
|
+
const i = object(row.identity), p = object(row.principal), pa = object(row.principalAccount), po = object(row.principalOwnership), ic = object(row.identityLink), c = object(row.collection), pm = object(row.principalMember);
|
|
942
|
+
if (!i || !referenceMatches(i, probe.identity))
|
|
943
|
+
return failed('changed');
|
|
944
|
+
if (!p || !pa || !po || !ic)
|
|
945
|
+
return failed('provider_missing');
|
|
946
|
+
if (p.principal !== true ||
|
|
947
|
+
p.role !== (probe.principalLabel === 'AWSRole') ||
|
|
948
|
+
p.user !== (probe.principalLabel === 'AWSUser') ||
|
|
949
|
+
p.id !== probe.principalArn ||
|
|
950
|
+
p.arn !== probe.principalArn ||
|
|
951
|
+
pa.id !== probe.accountId ||
|
|
952
|
+
i.cloud_provider_id !== probe.principalArn ||
|
|
953
|
+
i.cloud_provider_label !== probe.principalLabel ||
|
|
954
|
+
i.cloud_match_kind !== 'exact' ||
|
|
955
|
+
ic.kind !== 'G_CLOUD_MATCH' ||
|
|
956
|
+
i.cloud_assessment !== 'unknown' ||
|
|
957
|
+
ic.assessment !== 'unknown' ||
|
|
958
|
+
ic.evidence_kind !== 'provider_inventory' ||
|
|
959
|
+
ic.observed_at !== i.cloud_observed_at)
|
|
960
|
+
return failed('evidence_invalid');
|
|
961
|
+
if (!c)
|
|
962
|
+
return failed('collection_missing');
|
|
963
|
+
if (c.id !== probe.collectionId ||
|
|
964
|
+
c.schema_version !== 1 ||
|
|
965
|
+
c.status !== 'complete' ||
|
|
966
|
+
!hash(c.content_hash) ||
|
|
967
|
+
!stamp(c.captured_at, now))
|
|
968
|
+
return failed('collection_invalid');
|
|
969
|
+
const observedAt = new Date(c.captured_at * 1000).toISOString();
|
|
970
|
+
const times = [
|
|
971
|
+
p.lastupdated,
|
|
972
|
+
pa.lastupdated,
|
|
973
|
+
po.lastupdated,
|
|
974
|
+
i.cloud_observed_at,
|
|
975
|
+
i.cloud_node_updated,
|
|
976
|
+
i.cloud_ownership_updated,
|
|
977
|
+
i.cloud_account_updated,
|
|
978
|
+
];
|
|
979
|
+
if (times.some(time => !stamp(time, now)))
|
|
980
|
+
return failed('changed', observedAt);
|
|
981
|
+
if ([c.captured_at, ...times].some(time => now - time > maximumAge))
|
|
982
|
+
return failed('stale', observedAt);
|
|
983
|
+
if (!pm ||
|
|
984
|
+
pm.capture_hash !== c.content_hash ||
|
|
985
|
+
pm.captured_at !== c.captured_at ||
|
|
986
|
+
p.lastupdated !== c.captured_at ||
|
|
987
|
+
po.lastupdated !== c.captured_at ||
|
|
988
|
+
i.cloud_node_updated !== p.lastupdated ||
|
|
989
|
+
i.cloud_ownership_updated !== po.lastupdated ||
|
|
990
|
+
i.cloud_account_updated !== pa.lastupdated)
|
|
991
|
+
return failed('changed', observedAt);
|
|
992
|
+
if (c.policy_status === 'disabled' || c.policy_status === null || c.policy_status === undefined)
|
|
993
|
+
return failed('disabled', observedAt);
|
|
994
|
+
if (c.policy_status !== 'complete' ||
|
|
995
|
+
!hash(c.policy_hash) ||
|
|
996
|
+
c.policy_mapping_version !== VERSION ||
|
|
997
|
+
pm.policy_hash !== c.policy_hash ||
|
|
998
|
+
pm.policy_mapping_version !== VERSION ||
|
|
999
|
+
pm.policy_assessment !== 'unknown' ||
|
|
1000
|
+
!['mapped', 'unsupported', 'boundary_unresolved'].includes(String(pm.policy_status)) ||
|
|
1001
|
+
typeof pm.policy_boundary_present !== 'boolean' ||
|
|
1002
|
+
pm.policy_boundary_present !== (pm.policy_status === 'boundary_unresolved'))
|
|
1003
|
+
return failed('collection_invalid', observedAt);
|
|
1004
|
+
if (pm.policy_status !== 'mapped')
|
|
1005
|
+
return failed(pm.policy_status === 'boundary_unresolved' ? 'boundary_unresolved' : 'unsupported_policy', observedAt);
|
|
1006
|
+
const raw = row.candidates;
|
|
1007
|
+
if (!Array.isArray(raw) || raw.length > plan.candidateLimit)
|
|
1008
|
+
return failed('evidence_invalid', observedAt);
|
|
1009
|
+
const allowed = plan.buckets === null ? null : new Set(plan.buckets);
|
|
1010
|
+
const seen = new Set(), buckets = new Set(), candidates = [];
|
|
1011
|
+
for (const item of raw) {
|
|
1012
|
+
const candidate = object(item);
|
|
1013
|
+
const bucket = candidate?.bucket, relation = candidate?.relationship;
|
|
1014
|
+
if (!candidate ||
|
|
1015
|
+
candidate.consistent !== true ||
|
|
1016
|
+
typeof bucket !== 'string' ||
|
|
1017
|
+
!BUCKET.test(bucket) ||
|
|
1018
|
+
(allowed !== null && !allowed.has(bucket)) ||
|
|
1019
|
+
typeof relation !== 'string' ||
|
|
1020
|
+
!RELATIONSHIPS.includes(relation) ||
|
|
1021
|
+
seen.has(`${bucket}\0${relation}`) ||
|
|
1022
|
+
!conditionKeysValid(candidate))
|
|
1023
|
+
return failed('evidence_invalid', observedAt);
|
|
1024
|
+
seen.add(`${bucket}\0${relation}`);
|
|
1025
|
+
buckets.add(bucket);
|
|
1026
|
+
// The query orders by bucket, so the bucket past the limit marks a longer list.
|
|
1027
|
+
if (buckets.size > plan.bucketLimit)
|
|
1028
|
+
continue;
|
|
1029
|
+
candidates.push({
|
|
1030
|
+
bucket,
|
|
1031
|
+
extra: {
|
|
1032
|
+
resourceId: `arn:aws:s3:::${bucket}`,
|
|
1033
|
+
bucketName: bucket,
|
|
1034
|
+
relationship: relation,
|
|
1035
|
+
hasCondition: candidate.has_condition,
|
|
1036
|
+
conditionKeys: candidate.condition_keys,
|
|
1037
|
+
},
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
return { ok: true, observedAt, candidates, truncated: buckets.size > plan.bucketLimit };
|
|
1041
|
+
}
|
|
1042
|
+
export function identityScopeEvidence(plan, rows, now, maximumAge,
|
|
1043
|
+
/** For a team-scoped reader: the only bucket names it may see; null for an all-teams reader. */
|
|
1044
|
+
readable = null) {
|
|
1045
|
+
const groups = new Map();
|
|
1046
|
+
const requested = new Set(plan.probes.map(probe => probe.key));
|
|
1047
|
+
for (const row of rows) {
|
|
1048
|
+
const key = row.key;
|
|
1049
|
+
if (typeof key !== 'number' || !Number.isSafeInteger(key) || !requested.has(key))
|
|
1050
|
+
fail('MAP_UNAVAILABLE');
|
|
1051
|
+
const items = groups.get(key) ?? [];
|
|
1052
|
+
items.push(row);
|
|
1053
|
+
groups.set(key, items);
|
|
1054
|
+
}
|
|
1055
|
+
const outcomes = plan.probes.map(probe => probeOutcome(plan, probe, groups.get(probe.key) ?? [], now, maximumAge));
|
|
1056
|
+
const result = [...plan.records];
|
|
1057
|
+
for (const link of plan.links) {
|
|
1058
|
+
const base = result[link.record], outcome = outcomes[link.probe];
|
|
1059
|
+
if (!outcome.ok) {
|
|
1060
|
+
result[link.record] = {
|
|
1061
|
+
...base,
|
|
1062
|
+
status: 'unknown',
|
|
1063
|
+
reason: outcome.reason,
|
|
1064
|
+
observedAt: outcome.observedAt,
|
|
1065
|
+
// An identity row too large to keep is a cut list, and says so.
|
|
1066
|
+
truncated: outcome.reason === 'truncated',
|
|
1067
|
+
};
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
let hidden = false, narrower = false;
|
|
1071
|
+
const extras = [];
|
|
1072
|
+
for (const { bucket, extra } of outcome.candidates) {
|
|
1073
|
+
if (link.bound.has(bucket))
|
|
1074
|
+
continue;
|
|
1075
|
+
// A bucket-level candidate on a bucket the tool binds only in part may or may not
|
|
1076
|
+
// reach past that object or prefix.
|
|
1077
|
+
if (link.partial.has(bucket)) {
|
|
1078
|
+
narrower = true;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (readable !== null && !readable.has(bucket)) {
|
|
1082
|
+
hidden = true;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
extras.push(extra);
|
|
1086
|
+
}
|
|
1087
|
+
extras.sort((a, b) => a.bucketName === b.bucketName ? (a.relationship < b.relationship ? -1 : 1) : a.bucketName < b.bucketName ? -1 : 1);
|
|
1088
|
+
// Withheld buckets are still beyond this tool's grants, so they keep the status broad.
|
|
1089
|
+
const broader = extras.length > 0 || hidden;
|
|
1090
|
+
// The list names at most IDENTITY_SCOPE_RECORD_BUCKETS buckets (with all their
|
|
1091
|
+
// relationships). The comparison itself is complete, so a cut list keeps its status.
|
|
1092
|
+
const names = [...new Set(extras.map(extra => extra.bucketName))];
|
|
1093
|
+
const listed = names.length > IDENTITY_SCOPE_RECORD_BUCKETS ? new Set(names.slice(0, IDENTITY_SCOPE_RECORD_BUCKETS)) : null;
|
|
1094
|
+
const notes = [...(hidden ? ['evidence_outside_visible_scope'] : []), ...(listed ? [LIST_SHORTENED] : [])];
|
|
1095
|
+
result[link.record] = {
|
|
1096
|
+
...base,
|
|
1097
|
+
// More than the identity's bucket limit: the comparison is incomplete (spec: unknown).
|
|
1098
|
+
status: outcome.truncated ? 'unknown' : broader ? 'broader_than_grants' : narrower ? 'unknown' : 'matches_grants',
|
|
1099
|
+
reason: outcome.truncated ? 'truncated' : broader ? null : narrower ? 'binding_narrower_than_candidate' : null,
|
|
1100
|
+
observedAt: outcome.observedAt,
|
|
1101
|
+
extraResources: listed ? extras.filter(extra => listed.has(extra.bucketName)) : extras,
|
|
1102
|
+
truncated: outcome.truncated || listed !== null,
|
|
1103
|
+
...(notes.length ? { notes } : {}),
|
|
1104
|
+
};
|
|
392
1105
|
}
|
|
393
1106
|
return result;
|
|
394
1107
|
}
|