@shieldaai/agent-map 0.1.0-alpha.2 → 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.
Files changed (96) hide show
  1. package/README.md +653 -268
  2. package/dist/assets.js +16 -26
  3. package/dist/boundary-flags.d.ts +73 -0
  4. package/dist/boundary-flags.js +352 -0
  5. package/dist/boundary.d.ts +19 -2
  6. package/dist/boundary.js +75 -19
  7. package/dist/cli-entry.d.ts +13 -0
  8. package/dist/cli-entry.js +66 -0
  9. package/dist/cli.js +41 -44
  10. package/dist/common.d.ts +34 -0
  11. package/dist/common.js +94 -0
  12. package/dist/http.js +78 -51
  13. package/dist/identity-cli.js +94 -47
  14. package/dist/identity-http.js +226 -94
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/mcp.js +40 -5
  18. package/dist/oidc.d.ts +5 -0
  19. package/dist/oidc.js +121 -38
  20. package/dist/policy-reader.d.ts +56 -3
  21. package/dist/policy-reader.js +823 -110
  22. package/dist/projection-fields.d.ts +4 -0
  23. package/dist/projection-fields.js +144 -0
  24. package/dist/service/changes.d.ts +5 -0
  25. package/dist/service/changes.js +255 -0
  26. package/dist/service/context.d.ts +33 -0
  27. package/dist/service/context.js +2 -0
  28. package/dist/service/discovery.d.ts +6 -0
  29. package/dist/service/discovery.js +31 -0
  30. package/dist/service/explain.d.ts +12 -0
  31. package/dist/service/explain.js +126 -0
  32. package/dist/service/flags.d.ts +20 -0
  33. package/dist/service/flags.js +70 -0
  34. package/dist/service/inventory.d.ts +4 -0
  35. package/dist/service/inventory.js +62 -0
  36. package/dist/service/json.d.ts +8 -0
  37. package/dist/service/json.js +7 -0
  38. package/dist/service/operations/discovery.d.ts +5 -0
  39. package/dist/service/operations/discovery.js +90 -0
  40. package/dist/service/operations/explain-access.d.ts +3 -0
  41. package/dist/service/operations/explain-access.js +25 -0
  42. package/dist/service/operations/find-agents-with-access.d.ts +3 -0
  43. package/dist/service/operations/find-agents-with-access.js +120 -0
  44. package/dist/service/operations/get-agent-access.d.ts +3 -0
  45. package/dist/service/operations/get-agent-access.js +75 -0
  46. package/dist/service/operations/get-agent-inventory.d.ts +3 -0
  47. package/dist/service/operations/get-agent-inventory.js +42 -0
  48. package/dist/service/operations/get-changes.d.ts +3 -0
  49. package/dist/service/operations/get-changes.js +11 -0
  50. package/dist/service/operations/get-inventory-health.d.ts +3 -0
  51. package/dist/service/operations/get-inventory-health.js +81 -0
  52. package/dist/service/operations/get-resource-access.d.ts +3 -0
  53. package/dist/service/operations/get-resource-access.js +48 -0
  54. package/dist/service/operations/list-agents.d.ts +3 -0
  55. package/dist/service/operations/list-agents.js +92 -0
  56. package/dist/service/operations/list-owners-resources.d.ts +5 -0
  57. package/dist/service/operations/list-owners-resources.js +69 -0
  58. package/dist/service/ordering.d.ts +9 -0
  59. package/dist/service/ordering.js +47 -0
  60. package/dist/service/outside-guard.d.ts +68 -0
  61. package/dist/service/outside-guard.js +313 -0
  62. package/dist/service/policy.d.ts +26 -0
  63. package/dist/service/policy.js +299 -0
  64. package/dist/service/schemas.d.ts +16 -0
  65. package/dist/service/schemas.js +192 -0
  66. package/dist/service/summaries.d.ts +32 -0
  67. package/dist/service/summaries.js +247 -0
  68. package/dist/service/work.d.ts +33 -0
  69. package/dist/service/work.js +116 -0
  70. package/dist/service.d.ts +5 -5
  71. package/dist/service.js +110 -645
  72. package/dist/store/ancestors.d.ts +9 -0
  73. package/dist/store/ancestors.js +86 -0
  74. package/dist/store/discovery.d.ts +8 -0
  75. package/dist/store/discovery.js +288 -0
  76. package/dist/store/evidence.d.ts +12 -0
  77. package/dist/store/evidence.js +90 -0
  78. package/dist/store/identity-scope.d.ts +9 -0
  79. package/dist/store/identity-scope.js +71 -0
  80. package/dist/store/nodes.d.ts +24 -0
  81. package/dist/store/nodes.js +237 -0
  82. package/dist/store/projection.d.ts +19 -0
  83. package/dist/store/projection.js +242 -0
  84. package/dist/store/queries.d.ts +48 -0
  85. package/dist/store/queries.js +302 -0
  86. package/dist/store/read.d.ts +81 -0
  87. package/dist/store/read.js +135 -0
  88. package/dist/store/selection.d.ts +3 -0
  89. package/dist/store/selection.js +102 -0
  90. package/dist/store.d.ts +15 -0
  91. package/dist/store.js +102 -395
  92. package/dist/transport.d.ts +42 -2
  93. package/dist/transport.js +144 -16
  94. package/dist/types.d.ts +184 -4
  95. package/dist/types.js +52 -11
  96. package/package.json +32 -5
@@ -1,51 +1,66 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { immutableJson } from '@shieldaai/agent-guard';
3
- import { MapQueryError } from './types.js';
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', 's3:PutObject': 'CAN_WRITE', 's3:ListBucket': 'CAN_LIST',
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) { return typeof node.properties[name] === 'string' ? node.properties[name] : null; }
11
- function fail(code = 'MAP_UNAVAILABLE') { throw new MapQueryError(code); }
12
- /** Pair only the selected task's grants with its deployment's declared execution identity. */
13
- export function policyPlan(companyId, tenantId, nodes, edges, maximum) {
14
- const byId = new Map(nodes.map(node => [node.id, node]));
15
- const outgoing = new Map(), incoming = new Map();
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) => { work += n; if (work > 100_000)
29
- fail('MAP_LIMIT_EXCEEDED'); };
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 => { const item = byId.get(id); return item?.label === label ? [item] : []; });
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 || actions.some(action => typeof action !== 'string' || !action || action.length > 4096))
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 && executionDeployments.length === 1 &&
72
- agents.length === 1 && exact(deployment, 'G_INSTANCE_OF', agents[0]) &&
73
- executionDeployments[0].id === deployment.id && selectors.length === 1 && selectors[0].id === selector.id &&
74
- grantTools.length === 1 && executionTools.length === 1 && grantTools[0].id === executionTools[0].id &&
75
- value(grantTools[0], 'name') === tool && exact(task, 'G_ASSIGNED_TO', deployment) &&
76
- exact(execution, 'G_EXECUTES_FOR', deployment) && exact(binding, 'G_BINDS_SELECTOR', selector) &&
77
- exact(grant, 'G_FOR_TOOL', grantTools[0]) && exact(execution, 'G_EXECUTES_TOOL', executionTools[0]);
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 = { taskId: task.entityId, deploymentId: deployment.entityId,
89
- executionBindingId: execution.entityId, resourceBindingId: binding.entityId,
90
- identityId: identity?.entityId ?? null, resourceId: resource?.entityId ?? null,
91
- action, status: 'not_collected', reason: 'collection_missing', observedAt: null, assessment: 'unknown', candidates: [] };
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 || !identity || !resource || !exact(execution, 'G_USES_IDENTITY', identity) || !exact(binding, 'G_MAPS_TO', resource)) {
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
- const bucket = /^arn:aws:s3:::([a-z0-9][a-z0-9.-]{1,61}[a-z0-9])(?:\/(.+))?$/.exec(resourceArn ?? '');
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' || value(resource, 'provider') !== 'aws' || partition !== 'aws' ||
112
- value(resource, 'partition') !== partition || !accountId || !/^[0-9]{12}$/.test(accountId) ||
113
- value(resource, 'account_id') !== accountId || !principal || principal[1] !== accountId || !bucket ||
114
- !['bucket', 'object', 'prefix'].includes(kind ?? '') || (kind === 'bucket') !== (bucket[2] === undefined)) {
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 || identity.properties.cloud_status === undefined ||
119
- resource.properties.cloud_status === null || resource.properties.cloud_status === undefined) {
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' || resource.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({ key: recordIndex, identity, resource, accountId, partition, principalArn: principalArn,
131
- bucketArn: `arn:aws:s3:::${bucket[1]}`, bucketName: bucket[1], principalLabel: principal[2] === 'role' ? 'AWSRole' : 'AWSUser',
132
- relationship, collectionId: createHash('sha256').update(encoded).digest('hex'), proof: {
133
- agentNodeId: agents[0].id, agentEntityId: agents[0].entityId, agentTeamId: value(agents[0], 'team_id'),
134
- taskNodeId: task.id, taskEntityId: task.entityId, deploymentNodeId: deployment.id, deploymentEntityId: deployment.entityId,
135
- executionNodeId: execution.id, executionEntityId: execution.entityId, grantNodeId: grant.id, grantEntityId: grant.entityId,
136
- selectorNodeId: selector.id, selectorEntityId: selector.entityId, bindingNodeId: binding.id, bindingEntityId: binding.entityId,
137
- toolNodeId: grantTools[0].id, toolEntityId: grantTools[0].entityId, tool, resourceRef: selectorValue, action,
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 = { id: 4096, entity_id: 4096, provider: 32, partition: 32, account_id: 12, region: 64, arn: 2048,
150
- kind: 32, cloud_status: 32, cloud_assessment: 32, cloud_provider_label: 32, cloud_provider_id: 2048, cloud_match_kind: 32 };
151
- const referenceNumbers = ['cloud_observed_at', 'cloud_node_updated', 'cloud_ownership_updated', 'cloud_account_updated'];
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 = ['key', 'bindingProof', 'identity', 'resource', 'identityLink', 'resourceLink', 'principal', 'bucket',
231
- 'principalAccount', 'bucketAccount', 'principalOwnership', 'bucketOwnership', 'collection', 'principalMember', 'bucketMember', 'candidate'];
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 => ({ ...probe.proof, key: probe.key, identityNodeId: probe.identity.id, resourceNodeId: probe.resource.id,
234
- accountId: probe.accountId, partition: probe.partition }));
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) { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null; }
245
- function stamp(value, now) { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= now; }
246
- function hash(value) { return typeof value === 'string' && HASH.test(value); }
247
- function unicodeOrder(a, b) {
248
- const left = [...a], right = [...b];
249
- for (let index = 0; index < Math.min(left.length, right.length); index++) {
250
- const difference = left[index].codePointAt(0) - right[index].codePointAt(0);
251
- if (difference)
252
- return difference;
253
- }
254
- return left.length - right.length;
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.filter(field => field !== 'id' && field !== 'entity_id')
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) => { result[probe.key] = reason(base, status, code, time); };
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 || p.role !== (probe.principalLabel === 'AWSRole') || p.user !== (probe.principalLabel === 'AWSUser') ||
305
- p.id !== probe.principalArn || p.arn !== probe.principalArn || b.bucket !== true || b.id !== probe.bucketName || b.arn !== probe.bucketArn ||
306
- pa.id !== probe.accountId || ba.id !== probe.accountId || pa.eid !== ba.eid ||
307
- i.cloud_provider_id !== probe.principalArn || i.cloud_provider_label !== probe.principalLabel || i.cloud_match_kind !== 'exact' ||
308
- x.cloud_provider_id !== probe.bucketName || x.cloud_provider_label !== 'AWSS3Bucket' || x.cloud_match_kind !== (containment ? 'containment' : 'exact') ||
309
- ic.kind !== 'G_CLOUD_MATCH' || bc.kind !== (containment ? 'G_CLOUD_CONTAINER' : 'G_CLOUD_MATCH') ||
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' || bc.evidence_kind !== 'provider_inventory' ||
312
- ic.observed_at !== i.cloud_observed_at || bc.observed_at !== x.cloud_observed_at ||
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 || c.schema_version !== 1 || c.status !== 'complete' || !hash(c.content_hash) || !stamp(c.captured_at, now)) {
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 = [p.lastupdated, b.lastupdated, pa.lastupdated, ba.lastupdated, po.lastupdated, bo.lastupdated,
327
- i.cloud_observed_at, x.cloud_observed_at, i.cloud_node_updated, x.cloud_node_updated,
328
- i.cloud_ownership_updated, x.cloud_ownership_updated, i.cloud_account_updated, x.cloud_account_updated];
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 || !bm || [pm, bm].some(member => member.capture_hash !== c.content_hash || member.captured_at !== c.captured_at) ||
338
- p.lastupdated !== c.captured_at || b.lastupdated !== c.captured_at || po.lastupdated !== c.captured_at || bo.lastupdated !== c.captured_at ||
339
- i.cloud_node_updated !== p.lastupdated || x.cloud_node_updated !== b.lastupdated ||
340
- i.cloud_ownership_updated !== po.lastupdated || x.cloud_ownership_updated !== bo.lastupdated ||
341
- i.cloud_account_updated !== pa.lastupdated || x.cloud_account_updated !== ba.lastupdated || bm.region !== b.region) {
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' || !hash(c.policy_hash) || c.policy_mapping_version !== VERSION ||
350
- pm.policy_hash !== c.policy_hash || pm.policy_mapping_version !== VERSION || pm.policy_assessment !== 'unknown' ||
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' || pm.policy_boundary_present !== (pm.policy_status === 'boundary_unresolved')) {
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, lengths = candidate.condition_key_lengths;
558
+ const keys = candidate.condition_keys;
370
559
  const relation = candidate.relationship;
371
- if (candidate.collection_id !== c.id || candidate.capture_hash !== c.content_hash || candidate.captured_at !== c.captured_at ||
372
- candidate.policy_hash !== c.policy_hash || candidate.mapping_version !== VERSION || candidate.assessment !== 'unknown' ||
373
- candidate.resource_scope !== 'bucket_summary' || candidate.evidence_kind !== 'iam_identity_policy_candidate' ||
374
- typeof relation !== 'string' || !Object.values(MAPPING).includes(relation) || seen.has(relation) ||
375
- typeof candidate.has_condition !== 'boolean' || !Array.isArray(keys) || keys.length > 128 ||
376
- candidate.condition_key_count !== keys.length || !Array.isArray(lengths) || lengths.length !== keys.length ||
377
- keys.some((key, index) => typeof key !== 'string' || !key || /[\u0000-\u001f\ud800-\udfff]/u.test(key) || Buffer.byteLength(key) > 256 || lengths[index] !== [...key].length) ||
378
- new Set(keys).size !== keys.length || JSON.stringify(keys) !== JSON.stringify([...keys].sort(unicodeOrder)) || candidate.has_condition !== (keys.length > 0)) {
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({ relationship: relation, hasCondition: candidate.has_condition,
385
- conditionKeys: keys, resourceScope: 'bucket_summary' });
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] = { ...base, status: 'complete', reason: candidates.length ? null : 'no_candidate', observedAt, candidates };
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
  }