@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,253 @@
1
+ // cross-repo.js: resolve deployment boundaries across repositories (X-304).
2
+ //
3
+ // Extends the idea behind cross-repo-link.js (an operator declares that a node
4
+ // in one repository depends on a node in another) from the data-flow graph to
5
+ // the deployment boundary graph, and adds the two things that file's
6
+ // declared-link flavour did not need: an exact revision on both ends, and an
7
+ // access scope.
8
+ //
9
+ // Inputs are all local and all explicit: boundary graph exports the operator
10
+ // supplies (one per repository), a list of declared dependencies, and the set
11
+ // of repositories the operator is authorized to read. This module has no
12
+ // network access and no way to fetch a missing repository: an unavailable one
13
+ // is a gap, not a lookup. The source file contains no import of any network or
14
+ // process module, and a test pins that.
15
+ //
16
+ // A link resolves only when every one of these holds, each tested in both
17
+ // directions:
18
+ // - both repositories are in the access scope and a graph was supplied for
19
+ // each (otherwise: access-denied / repository-unavailable);
20
+ // - each supplied graph passes validation and says it is the repository it
21
+ // was supplied as (otherwise: graph-integrity / identity-collision);
22
+ // - the graph's revision equals the revision the declaration binds, exactly
23
+ // (otherwise: version-mismatch, or revision-unknown when the graph has none);
24
+ // - the declared service exists, resolved, in the graph of the repository it
25
+ // is declared in (otherwise: unresolved-identity), and no second supplied
26
+ // repository also resolves that identity (otherwise: identity-collision);
27
+ // - a declared entry route exists in the callee graph and reaches the callee
28
+ // service (otherwise the link stays unresolved; no route is guessed).
29
+ //
30
+ // An unresolved link is not dropped. It is kept in `links` with every gap code
31
+ // that blocked it, and, when both revisions are bound by the declaration, as
32
+ // an unresolved edge to an unresolved callee node in the federated graph, so
33
+ // the dependency stays visible to path queries as a path nobody could verify.
34
+
35
+ import { semanticId, digestOf } from '../../posture/assurance/identity.js';
36
+ import {
37
+ buildBoundaryGraph, importBoundaryGraph, validateBoundaryGraph, nodeIdOf, indexGraph, findPath, boundaryCrossings,
38
+ } from './boundary-graph.js';
39
+
40
+ export const CROSS_REPO_PARSER = 'cross-repo-declaration';
41
+ export const CROSS_REPO_PARSER_VERSION = '1';
42
+ export const DEFAULT_DECLARATION_FILE = 'cross-repo-declarations.json';
43
+
44
+ const REV = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
45
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
46
+ const SVC = /^[^\u0000-\u001f\u007f]{1,256}$/;
47
+
48
+ function isObj(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
49
+
50
+ /** Check one declaration's shape. Returns an error message or null. Pure. */
51
+ export function checkDeclaration(d) {
52
+ if (!isObj(d)) return 'declaration must be an object';
53
+ for (const side of ['caller', 'callee']) {
54
+ const s = d[side];
55
+ if (!isObj(s)) return `${side} is required`;
56
+ if (typeof s.repository !== 'string' || !NAME.test(s.repository)) return `${side}.repository must be a repository name`;
57
+ if (typeof s.revision !== 'string' || !REV.test(s.revision)) return `${side}.revision must be an exact 40 or 64 hex commit`;
58
+ if (typeof s.service !== 'string' || !SVC.test(s.service)) return `${side}.service must be a service name`;
59
+ if (typeof s.environment !== 'string' || !NAME.test(s.environment)) return `${side}.environment must be an environment name`;
60
+ if (s.tenant !== undefined && s.tenant !== null && (typeof s.tenant !== 'string' || !NAME.test(s.tenant))) return `${side}.tenant must be null or a name`;
61
+ }
62
+ if (d.caller.environment !== d.callee.environment) return 'a cross-repository link cannot join two environments';
63
+ if ((d.caller.tenant ?? null) !== (d.callee.tenant ?? null)) return 'a cross-repository link declares one tenant; a tenant transition needs its own declaration of both sides';
64
+ if (d.callee.route !== undefined && d.callee.route !== null) {
65
+ const r = d.callee.route;
66
+ if (!isObj(r) || (r.host !== undefined && typeof r.host !== 'string') || (r.path !== undefined && typeof r.path !== 'string')) return 'callee.route must be {host?, path?}';
67
+ if (r.host === undefined && r.path === undefined) return 'callee.route needs a host or a path';
68
+ }
69
+ return null;
70
+ }
71
+
72
+ export function declarationId(d) {
73
+ return semanticId('blink', {
74
+ callerRepository: d?.caller?.repository, callerRevision: d?.caller?.revision, callerService: d?.caller?.service,
75
+ calleeRepository: d?.callee?.repository, calleeRevision: d?.callee?.revision, calleeService: d?.callee?.service,
76
+ environment: d?.caller?.environment, tenant: d?.caller?.tenant ?? null, route: d?.callee?.route ?? null,
77
+ }, ['callerRepository', 'callerRevision', 'callerService', 'calleeRepository', 'calleeRevision', 'calleeService', 'environment', 'tenant', 'route']);
78
+ }
79
+
80
+ function pathUnder(prefix, p) {
81
+ if (typeof prefix !== 'string' || typeof p !== 'string') return false;
82
+ if (prefix === '/') return true;
83
+ return p === prefix || p.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
84
+ }
85
+
86
+ /**
87
+ * @param {object} p
88
+ * @param {Array<{repository: string, text?: string, graph?: object}>} p.graphs supplied exports, one per repository
89
+ * @param {object[]} p.declarations
90
+ * @param {{authorizedRepositories: string[]}} p.accessScope
91
+ * @param {{file?: string}} [p.declarationSource]
92
+ */
93
+ export function resolveCrossRepoBoundaries({ graphs = [], declarations = [], accessScope, declarationSource = {} } = {}) {
94
+ const gaps = [];
95
+ const gap = (code, subject, message) => gaps.push({ code, subject, file: null, message });
96
+ if (!isObj(accessScope) || !Array.isArray(accessScope.authorizedRepositories)) {
97
+ return { status: 'invalid-input', reason: 'accessScope.authorizedRepositories is required; with no scope nothing is authorized', graph: null, links: [], gaps: [] };
98
+ }
99
+ const authorized = new Set(accessScope.authorizedRepositories.filter(r => typeof r === 'string'));
100
+ const declFile = declarationSource.file ?? DEFAULT_DECLARATION_FILE;
101
+
102
+ // ---- load the supplied graphs
103
+ const supplied = new Map(); // repository -> { graph, index } for usable ones
104
+ const claimed = new Map(); // repository -> count of supplied entries claiming it
105
+ for (const g of graphs) if (isObj(g) && typeof g.repository === 'string') claimed.set(g.repository, (claimed.get(g.repository) ?? 0) + 1);
106
+ const usable = [];
107
+ for (const entry of graphs) {
108
+ if (!isObj(entry) || typeof entry.repository !== 'string') { gap('malformed-input', 'graphs', 'a supplied graph without a repository name is ignored'); continue; }
109
+ const repo = entry.repository;
110
+ if (!authorized.has(repo)) { gap('access-denied', repo, `repository '${repo}' is not in the access scope; its graph was supplied but is not read`); continue; }
111
+ if (claimed.get(repo) > 1) { gap('identity-collision', repo, `${claimed.get(repo)} supplied graphs claim to be repository '${repo}'; none is used`); continue; }
112
+ let graph;
113
+ if (typeof entry.text === 'string') {
114
+ const imp = importBoundaryGraph(entry.text);
115
+ if (imp.status !== 'ok') { gap('graph-integrity', repo, `the graph supplied for '${repo}' is ${imp.status}: ${imp.reason ?? imp.errors.map(e => e.message).slice(0, 3).join('; ')}`); continue; }
116
+ graph = imp.graph;
117
+ } else if (isObj(entry.graph)) {
118
+ const v = validateBoundaryGraph(entry.graph);
119
+ if (!v.ok) { gap('graph-integrity', repo, `the graph supplied for '${repo}' failed validation: ${v.errors.slice(0, 3).map(e => e.message).join('; ')}`); continue; }
120
+ graph = entry.graph;
121
+ } else { gap('malformed-input', repo, 'a supplied graph needs text or graph'); continue; }
122
+ if (graph.repository !== repo) { gap('identity-collision', repo, `the graph supplied as '${repo}' says it is repository '${graph.repository}'; it is not used`); continue; }
123
+ supplied.set(repo, { graph, index: indexGraph(graph) });
124
+ usable.push(graph);
125
+ }
126
+
127
+ // ---- resolve declarations
128
+ const links = [];
129
+ const crossEdges = [];
130
+ const extraNodes = [];
131
+ const sortedDecls = declarations.map((d, i) => ({ d, i })).sort((a, b) => (declarationId(a.d) < declarationId(b.d) ? -1 : declarationId(a.d) > declarationId(b.d) ? 1 : a.i - b.i));
132
+ const declDigest = digestOf(sortedDecls.map(x => x.d)); // order independent: the same declarations give the same evidence digest
133
+ for (const { d } of sortedDecls) {
134
+ const bad = checkDeclaration(d);
135
+ if (bad) { gap('malformed-input', 'declaration', bad); links.push({ declarationId: null, status: 'invalid', gapCodes: ['malformed-input'], evidence: null, transitions: null, edgeId: null }); continue; }
136
+ const id = declarationId(d);
137
+ const blocked = [];
138
+ const block = (code, message) => { blocked.push(code); gap(code, id, message); };
139
+ const env = d.caller.environment, tenant = d.caller.tenant ?? null;
140
+
141
+ const callerOk = authorized.has(d.caller.repository), calleeOk = authorized.has(d.callee.repository);
142
+ if (!callerOk) block('access-denied', `caller repository '${d.caller.repository}' is not in the access scope`);
143
+ if (!calleeOk) block('access-denied', `callee repository '${d.callee.repository}' is not in the access scope`);
144
+ const callerG = callerOk ? supplied.get(d.caller.repository) : null;
145
+ const calleeG = calleeOk ? supplied.get(d.callee.repository) : null;
146
+ if (callerOk && !callerG) block('repository-unavailable', `no usable graph was supplied for caller repository '${d.caller.repository}'`);
147
+ if (calleeOk && !calleeG) block('repository-unavailable', `no usable graph was supplied for callee repository '${d.callee.repository}'`);
148
+
149
+ for (const [side, g, decl] of [['caller', callerG, d.caller], ['callee', calleeG, d.callee]]) {
150
+ if (!g) continue;
151
+ if (g.graph.revision === null) block('revision-unknown', `the ${side} graph for '${decl.repository}' records no revision, so the declared revision cannot be verified`);
152
+ else if (g.graph.revision !== decl.revision) block('version-mismatch', `the ${side} graph for '${decl.repository}' is at ${g.graph.revision.slice(0, 12)}, the declaration binds ${decl.revision.slice(0, 12)}`);
153
+ }
154
+
155
+ const callerNodeId = nodeIdOf({ kind: 'service', name: d.caller.service, environment: env, tenant });
156
+ const calleeNodeId = nodeIdOf({ kind: 'service', name: d.callee.service, environment: env, tenant });
157
+ let callerNode = null, calleeNode = null;
158
+ if (callerG && !blocked.length) {
159
+ callerNode = callerG.index.nodesById.get(callerNodeId);
160
+ if (!callerNode || !callerNode.resolved) block('unresolved-identity', `service '${d.caller.service}' (${env}) is not a resolved service in '${d.caller.repository}' at the bound revision`);
161
+ else if (callerNode.repository !== null && callerNode.repository !== d.caller.repository) block('identity-collision', `service '${d.caller.service}' is owned by '${callerNode.repository}', not '${d.caller.repository}'`);
162
+ }
163
+ if (calleeG && !blocked.length) {
164
+ calleeNode = calleeG.index.nodesById.get(calleeNodeId);
165
+ if (!calleeNode || !calleeNode.resolved) block('unresolved-identity', `service '${d.callee.service}' (${env}) is not a resolved service in '${d.callee.repository}' at the bound revision`);
166
+ else if (calleeNode.repository !== null && calleeNode.repository !== d.callee.repository) block('identity-collision', `service '${d.callee.service}' is owned by '${calleeNode.repository}', not '${d.callee.repository}'`);
167
+ else {
168
+ const others = [...supplied.entries()].filter(([repo, s]) => repo !== d.callee.repository && s.index.nodesById.get(calleeNodeId)?.resolved);
169
+ if (others.length) block('identity-collision', `service '${d.callee.service}' (${env}) is also defined by ${others.map(([r]) => `'${r}'`).sort().join(', ')}; the callee identity is ambiguous`);
170
+ }
171
+ }
172
+
173
+ // route and privilege transitions
174
+ let routeNode = null, routeChain = null;
175
+ if (!blocked.length && d.callee.route) {
176
+ const want = d.callee.route;
177
+ const candidates = calleeG.graph.nodes.filter(n => n.kind === 'route' && n.environment === env && (n.tenant ?? null) === tenant
178
+ && (want.host === undefined || n.attrs.host === want.host || n.attrs.host === '*') && (want.path === undefined || pathUnder(n.attrs.path, want.path)));
179
+ for (const r of candidates.sort((a, b) => (a.id < b.id ? -1 : 1))) {
180
+ const chain = findPath(calleeG.index.out, r.id, calleeNodeId, { accept: (e) => e.relation === 'routes-to' && e.pathState !== 'blocked', maxHops: 4 });
181
+ if (chain) { routeNode = r; routeChain = chain; break; }
182
+ }
183
+ if (!routeNode) block('unresolved-identity', `no route matching ${JSON.stringify(want)} reaches '${d.callee.service}' in '${d.callee.repository}'`);
184
+ }
185
+
186
+ const crossRepo = { callerRepository: d.caller.repository, callerRevision: d.caller.revision, calleeRepository: d.callee.repository, calleeRevision: d.callee.revision };
187
+ const source = { file: declFile, digest: declDigest, parser: CROSS_REPO_PARSER, parserVersion: CROSS_REPO_PARSER_VERSION };
188
+ const baseEdge = { relation: 'calls', from: callerNodeId, environment: env, tenant, effect: 'none', provenance: 'static-config', confidence: 'high', observationInterval: null, completeness: null, sourceRevision: d.caller.revision, source, discriminator: `xrepo:${d.callee.repository}`, crossRepo, attrs: {} };
189
+
190
+ if (blocked.length) {
191
+ links.push({ declarationId: id, status: 'unresolved', caller: { ...d.caller }, callee: { ...d.callee }, gapCodes: [...new Set(blocked)].sort(), evidence: null, transitions: null, edgeId: null });
192
+ // Keep the dependency visible as an unresolved path, bound to the revisions the operator declared.
193
+ extraNodes.push(
194
+ { kind: 'service', name: d.caller.service, environment: env, tenant, repository: null, trustZone: 'unknown', resolved: false, unresolvedReason: 'cross-repository caller could not be verified', attrs: {}, declaredBy: [] },
195
+ { kind: 'service', name: d.callee.service, environment: env, tenant, repository: null, trustZone: 'unknown', resolved: false, unresolvedReason: `cross-repository callee could not be resolved (${[...new Set(blocked)].sort().join(', ')})`, attrs: {}, declaredBy: [] },
196
+ );
197
+ crossEdges.push({ ...baseEdge, to: calleeNodeId, pathState: 'unresolved', confidence: 'low' });
198
+ continue;
199
+ }
200
+
201
+ const targetId = routeNode ? routeNode.id : calleeNodeId;
202
+ const edge = { ...baseEdge, to: targetId, pathState: 'possible' };
203
+ crossEdges.push(edge);
204
+
205
+ const calleeAssumes = calleeG.graph.edges.filter(e => e.relation === 'assumes' && e.from === calleeNodeId);
206
+ const callerAssumes = callerG.graph.edges.filter(e => e.relation === 'assumes' && e.from === callerNodeId);
207
+ const privileges = calleeAssumes.map(a => ({
208
+ identityNodeId: a.to,
209
+ identity: calleeG.index.nodesById.get(a.to)?.name ?? null,
210
+ identityResolved: calleeG.index.nodesById.get(a.to)?.resolved ?? false,
211
+ assumesEdgeId: a.id,
212
+ grants: calleeG.graph.edges.filter(e => e.relation === 'grants' && e.from === a.to)
213
+ .map(e => ({ edgeId: e.id, resource: calleeG.index.nodesById.get(e.to)?.name ?? null, effect: e.effect, pathState: e.pathState, discriminator: e.discriminator }))
214
+ .sort((x, y) => (x.edgeId < y.edgeId ? -1 : 1)),
215
+ })).sort((x, y) => (x.identityNodeId < y.identityNodeId ? -1 : 1));
216
+ const nodeRefs = (g, nodeId) => (g.index.nodesById.get(nodeId)?.declaredBy ?? []).map(r => ({ ...r }));
217
+ links.push({
218
+ declarationId: id,
219
+ status: 'resolved',
220
+ caller: { ...d.caller }, callee: { ...d.callee },
221
+ gapCodes: [],
222
+ edgeId: null, // filled once the federated graph assigns ids
223
+ evidence: {
224
+ declaration: { file: declFile, digest: declDigest },
225
+ caller: { repository: d.caller.repository, revision: d.caller.revision, graphDigest: callerG.graph.digest, nodeId: callerNodeId, declaredBy: nodeRefs(callerG, callerNodeId) },
226
+ callee: { repository: d.callee.repository, revision: d.callee.revision, graphDigest: calleeG.graph.digest, nodeId: calleeNodeId, declaredBy: nodeRefs(calleeG, calleeNodeId) },
227
+ route: routeNode ? { nodeId: routeNode.id, name: routeNode.name, declaredBy: nodeRefs(calleeG, routeNode.id), chainEdgeIds: routeChain.map(e => e.id) } : null,
228
+ },
229
+ transitions: {
230
+ entry: routeNode ? { routeNodeId: routeNode.id, trustZone: routeNode.trustZone } : null,
231
+ callerIdentities: callerAssumes.map(a => ({ identityNodeId: a.to, identity: callerG.index.nodesById.get(a.to)?.name ?? null })).sort((x, y) => (x.identityNodeId < y.identityNodeId ? -1 : 1)),
232
+ calleePrivileges: privileges,
233
+ },
234
+ _edge: edge,
235
+ });
236
+ }
237
+
238
+ // ---- federated graph
239
+ const nodes = [...usable.flatMap(g => g.nodes), ...extraNodes];
240
+ const edges = [...usable.flatMap(g => g.edges), ...crossEdges];
241
+ const sources = [...usable.flatMap(g => g.sources), { file: declFile, digest: declDigest, parser: CROSS_REPO_PARSER, parserVersion: CROSS_REPO_PARSER_VERSION, bytes: JSON.stringify(declarations).length }];
242
+ const built = buildBoundaryGraph({ repository: null, revision: null, nodes, edges, gaps: [...usable.flatMap(g => g.gaps), ...gaps], sources });
243
+ if (!built.ok) return { status: 'invalid-graph', reason: 'the federated graph failed validation', graph: null, links: links.map(({ _edge, ...l }) => l), gaps, errors: built.errors };
244
+
245
+ // attach edge ids and boundary crossings to resolved links
246
+ const fIndex = indexGraph(built.graph);
247
+ const finalLinks = links.map(({ _edge, ...l }) => {
248
+ if (l.status !== 'resolved') return l;
249
+ const match = built.graph.edges.find(e => e.crossRepo && e.relation === 'calls' && e.from === _edge.from && e.to === _edge.to && e.crossRepo.calleeRepository === _edge.crossRepo.calleeRepository && e.crossRepo.calleeRevision === _edge.crossRepo.calleeRevision && e.crossRepo.callerRevision === _edge.crossRepo.callerRevision);
250
+ return { ...l, edgeId: match?.id ?? null, transitions: { ...l.transitions, crossings: match ? boundaryCrossings(match, fIndex.nodesById) : [] } };
251
+ });
252
+ return { status: 'ok', reason: null, graph: built.graph, links: finalLinks, gaps: built.graph.gaps };
253
+ }
@@ -0,0 +1,383 @@
1
+ // drift.js: boundary drift between two revisions, with dependency-aware invalidation and impact-driven rescans (X-306).
2
+ //
3
+ // Four jobs, all pure (graphs in, records out; no clock, no file or network access):
4
+ //
5
+ // 1. `diffBoundaryGraphs` compares two validated graphs at stable node and edge ids and names what changed in terms a
6
+ // reviewer cares about: new exposure, changed privilege, tenant-boundary weakening, removed controls. Those four are
7
+ // `material`. Every other change to a non-structural relation is reported too (it still changes how a finding is
8
+ // conditioned) but is not material. It also diffs the SOURCES the two graphs were built from and names the edges each
9
+ // changed file supported, so a drift can be explained from the file that moved.
10
+ // 2. `planRescan` maps each change to the hypotheses (findings) it can affect and lists the focused rescans. A hypothesis is
11
+ // affected only when its bound service is the service a change touches, or can reach it, or is reachable from it, in
12
+ // either revision. Unchanged components are not rescanned. A hypothesis that cannot be tied to a service cannot be proven
13
+ // independent of a change, so it makes the plan INCOMPLETE; so does a graph gap that stands for a missing dependency.
14
+ // 3. `incrementalAnnotate` recomputes only the affected hypotheses and carries the rest forward. `compareScoped` then lets a
15
+ // test (or a caller) check the incremental result against a complete recomputation. Equivalence is the property that
16
+ // justifies not rescanning; an incomplete plan never claims it.
17
+ // 4. `invalidatePathsForSources` drops exactly the paths a changed source file supported (through the edges and node
18
+ // declarations that cite it), reusing `invalidateChangedSources` for the graph itself.
19
+
20
+ import { indexGraph, validateBoundaryGraph } from './boundary-graph.js';
21
+ import { invalidateChangedSources } from './ingest.js';
22
+ import { bindFindingToService, exposureReach, TRAVERSED_RELATIONS } from './attack-paths.js';
23
+ import { semanticId, digestOf, hypothesisIdFromFinding } from '../../posture/assurance/identity.js';
24
+
25
+ export const DRIFT_SCHEMA = 'agentic-security/boundary-drift';
26
+ export const DRIFT_VERSION = '1.0.0';
27
+
28
+ export const DRIFT_CATEGORIES = Object.freeze([
29
+ 'new-exposure', 'changed-privilege', 'tenant-weakening', 'removed-control',
30
+ 'control-added', 'privilege-reduced', 'exposure-reduced', 'connectivity-added', 'connectivity-removed', 'edge-changed',
31
+ 'zone-changed', 'node-resolution-changed', 'service-added', 'service-removed',
32
+ ]);
33
+ export const MATERIAL_CATEGORIES = Object.freeze(['new-exposure', 'changed-privilege', 'tenant-weakening', 'removed-control']);
34
+ const MATERIAL = new Set(MATERIAL_CATEGORIES);
35
+
36
+ // A gap of these kinds means a dependency of the graph is missing, so a conclusion drawn from it is incomplete.
37
+ export const DEPENDENCY_GAP_CODES = Object.freeze([
38
+ 'source-missing', 'source-changed', 'repository-unavailable', 'access-denied', 'version-mismatch', 'revision-unknown',
39
+ 'graph-integrity', 'limit-exceeded', 'unresolved-identity',
40
+ ]);
41
+ const DEPENDENCY_GAPS = new Set(DEPENDENCY_GAP_CODES);
42
+
43
+ const TRAVERSED = new Set(TRAVERSED_RELATIONS);
44
+ const STRUCTURAL = new Set(['defined-in', 'deployed-in', 'scoped-to']);
45
+ const MAX_CLOSURE_HOPS = 8;
46
+
47
+ const sortedUnique = (a) => [...new Set(a)].sort();
48
+ const keyOf = (n) => `${n.kind}|${n.name}|${n.environment ?? ''}`;
49
+ const isDeny = (e) => e.effect === 'deny' || e.relation === 'network-denies';
50
+
51
+ // ------------------------------------------------------------ diff
52
+
53
+ function stable(v) { return JSON.stringify(v, Object.keys(v ?? {}).sort()); }
54
+
55
+ function sameEdgeBody(a, b) {
56
+ return a.pathState === b.pathState && a.confidence === b.confidence && stable(a.attrs ?? {}) === stable(b.attrs ?? {});
57
+ }
58
+
59
+ /**
60
+ * Compare two boundary graphs.
61
+ *
62
+ * @returns {{ ok: boolean, diff: object|null, errors: object[] }} never throws
63
+ */
64
+ export function diffBoundaryGraphs(before, after) {
65
+ const vb = validateBoundaryGraph(before), va = validateBoundaryGraph(after);
66
+ if (!vb.ok || !va.ok) return { ok: false, diff: null, errors: [...vb.errors.map((e) => ({ ...e, side: 'before' })), ...va.errors.map((e) => ({ ...e, side: 'after' }))] };
67
+ if (before.repository !== null && after.repository !== null && before.repository !== after.repository) {
68
+ return { ok: false, diff: null, errors: [{ code: 'RULE_VIOLATION', path: 'repository', message: `graphs are for different repositories ('${before.repository}' and '${after.repository}'); a diff across repositories would hide every id as added or removed` }] };
69
+ }
70
+ const bi = indexGraph(before), ai = indexGraph(after);
71
+ const bEdges = new Map(before.edges.map((e) => [e.id, e]));
72
+ const aEdges = new Map(after.edges.map((e) => [e.id, e]));
73
+ const nodeOf = (id) => ai.nodesById.get(id) ?? bi.nodesById.get(id);
74
+ const changes = [];
75
+ const push = (category, o) => {
76
+ const nodeIds = sortedUnique(o.nodeIds ?? []);
77
+ const edgeIds = sortedUnique(o.edgeIds ?? []);
78
+ const env = o.environment ?? null;
79
+ changes.push({
80
+ id: semanticId('bdrift', { category, nodeIds, edgeIds, detail: o.detail ?? null }, ['category', 'nodeIds', 'edgeIds', 'detail']),
81
+ category, material: MATERIAL.has(category), nodeIds, edgeIds, environment: env, tenant: o.tenant ?? null,
82
+ description: o.description, before: o.before ?? null, after: o.after ?? null,
83
+ });
84
+ };
85
+ const endpointsOf = (e) => [e.from, e.to];
86
+
87
+ // 1. exposure
88
+ const rb = exposureReach(before), ra = exposureReach(after);
89
+ const rank = { definite: 2, unresolved: 1 };
90
+ for (const [id, a] of [...ra].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
91
+ const n = ai.nodesById.get(id);
92
+ if (!n || !['service', 'resource', 'route'].includes(n.kind)) continue;
93
+ const b = rb.get(id);
94
+ if (!b || rank[a.state] > rank[b.state]) {
95
+ push('new-exposure', {
96
+ nodeIds: [id], edgeIds: a.via, environment: n.environment, tenant: n.tenant, detail: a.state,
97
+ description: `${n.kind} '${n.name}' is ${a.state === 'definite' ? 'reachable' : 'possibly reachable'} from an exposed entry point${b ? ' (it was only possibly reachable before)' : ' and was not before'}`,
98
+ before: b ? b.state : null, after: a.state,
99
+ });
100
+ }
101
+ }
102
+ for (const [id, b] of [...rb].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
103
+ const n = bi.nodesById.get(id);
104
+ if (!n || !['service', 'resource', 'route'].includes(n.kind)) continue;
105
+ const a = ra.get(id);
106
+ if (!a || rank[a.state] < rank[b.state]) {
107
+ push('exposure-reduced', { nodeIds: [id], edgeIds: b.via, environment: n.environment, tenant: n.tenant, detail: a ? a.state : 'gone', description: `${n.kind} '${n.name}' is ${a ? 'less certainly' : 'no longer'} reachable from an exposed entry point`, before: b.state, after: a ? a.state : null });
108
+ }
109
+ }
110
+
111
+ // 2. edges added
112
+ for (const e of after.edges) {
113
+ if (bEdges.has(e.id) || STRUCTURAL.has(e.relation)) continue;
114
+ const f = nodeOf(e.from), t = nodeOf(e.to);
115
+ const label = `${e.relation} ${f?.name} -> ${t?.name}`;
116
+ const base = { nodeIds: endpointsOf(e), edgeIds: [e.id], environment: e.environment, tenant: e.tenant };
117
+ if (f?.tenant != null && t?.tenant != null && f.tenant !== t.tenant) {
118
+ push('tenant-weakening', { ...base, detail: 'cross-tenant-edge', description: `new ${label} crosses from tenant ${f.tenant} to tenant ${t.tenant}`, after: e.relation });
119
+ }
120
+ if (isDeny(e)) push('control-added', { ...base, description: `new explicit deny: ${label}`, after: e.relation });
121
+ else if (e.relation === 'grants' && e.effect === 'allow') push('changed-privilege', { ...base, detail: 'grant-added', description: `new grant: ${label}`, after: e.attrs ?? {} });
122
+ else if (e.relation === 'assumes') push('changed-privilege', { ...base, detail: 'identity-assumed', description: `workload ${f?.name} now assumes identity ${t?.name}`, after: e.relation });
123
+ else if (TRAVERSED.has(e.relation)) push('connectivity-added', { ...base, description: `new ${label}`, after: e.relation });
124
+ }
125
+
126
+ // 3. edges removed
127
+ for (const e of before.edges) {
128
+ if (aEdges.has(e.id) || STRUCTURAL.has(e.relation)) continue;
129
+ const f = nodeOf(e.from), t = nodeOf(e.to);
130
+ const label = `${e.relation} ${f?.name} -> ${t?.name}`;
131
+ const base = { nodeIds: endpointsOf(e), edgeIds: [e.id], environment: e.environment, tenant: e.tenant };
132
+ if (isDeny(e)) push('removed-control', { ...base, description: `explicit deny removed: ${label}`, before: e.relation });
133
+ else if (e.relation === 'grants' && e.effect === 'allow') push('privilege-reduced', { ...base, description: `grant removed: ${label}`, before: e.attrs ?? {} });
134
+ else if (TRAVERSED.has(e.relation)) push('connectivity-removed', { ...base, description: `removed ${label}`, before: e.relation });
135
+ }
136
+
137
+ // 4. edges present in both whose body changed
138
+ for (const e of after.edges) {
139
+ const old = bEdges.get(e.id);
140
+ if (!old || STRUCTURAL.has(e.relation) || sameEdgeBody(old, e)) continue;
141
+ const f = nodeOf(e.from), t = nodeOf(e.to);
142
+ const label = `${e.relation} ${f?.name} -> ${t?.name}`;
143
+ const base = { nodeIds: endpointsOf(e), edgeIds: [e.id], environment: e.environment, tenant: e.tenant };
144
+ if (e.relation === 'grants' && stable(old.attrs ?? {}) !== stable(e.attrs ?? {})) {
145
+ push('changed-privilege', { ...base, detail: 'grant-changed', description: `grant changed: ${label}`, before: old.attrs ?? {}, after: e.attrs ?? {} });
146
+ } else {
147
+ push('edge-changed', { ...base, detail: `${old.pathState}>${e.pathState}`, description: `${label} changed (state ${old.pathState} -> ${e.pathState}, confidence ${old.confidence} -> ${e.confidence})`, before: { pathState: old.pathState, confidence: old.confidence }, after: { pathState: e.pathState, confidence: e.confidence } });
148
+ }
149
+ }
150
+
151
+ // 5. nodes
152
+ const afterByKey = new Map();
153
+ for (const n of after.nodes) { if (!afterByKey.has(keyOf(n))) afterByKey.set(keyOf(n), []); afterByKey.get(keyOf(n)).push(n); }
154
+ for (const n of before.nodes) {
155
+ const same = ai.nodesById.get(n.id);
156
+ if (same) {
157
+ if (same.trustZone !== n.trustZone) push('zone-changed', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, detail: `${n.trustZone}>${same.trustZone}`, description: `${n.kind} '${n.name}' moved from trust zone ${n.trustZone} to ${same.trustZone}`, before: n.trustZone, after: same.trustZone });
158
+ if (same.resolved !== n.resolved) push('node-resolution-changed', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, detail: `${n.resolved}>${same.resolved}`, description: `${n.kind} '${n.name}' is now ${same.resolved ? 'resolved' : 'unresolved'}`, before: n.resolved, after: same.resolved });
159
+ continue;
160
+ }
161
+ // The id is gone. A tenant binding that was dropped keeps (kind, name, environment) but loses its tenant.
162
+ const twin = (afterByKey.get(keyOf(n)) ?? []).find((m) => m.tenant === null);
163
+ if (n.tenant !== null && twin) {
164
+ push('tenant-weakening', { nodeIds: [n.id, twin.id], environment: n.environment, tenant: n.tenant, detail: 'tenant-binding-lost', description: `${n.kind} '${n.name}' is no longer bound to tenant ${n.tenant}`, before: n.tenant, after: null });
165
+ } else if (n.kind === 'service' && !(afterByKey.get(keyOf(n)) ?? []).length) {
166
+ push('service-removed', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, description: `service '${n.name}' is no longer in the graph`, before: n.name });
167
+ }
168
+ }
169
+ for (const n of after.nodes) {
170
+ if (!bi.nodesById.has(n.id) && n.kind === 'service' && !before.nodes.some((m) => keyOf(m) === keyOf(n))) {
171
+ push('service-added', { nodeIds: [n.id], environment: n.environment, tenant: n.tenant, description: `service '${n.name}' is new`, after: n.name });
172
+ }
173
+ }
174
+
175
+ // Deterministic order, and one record per id.
176
+ const byId = new Map(changes.map((c) => [c.id, c]));
177
+ const list = [...byId.values()].sort((a, b) => (Number(b.material) - Number(a.material)) || (a.category < b.category ? -1 : a.category > b.category ? 1 : a.id < b.id ? -1 : 1));
178
+
179
+ // Sources: what moved on disk, and which edges each moved file supported.
180
+ const bSrc = new Map(before.sources.map((s) => [s.file, s]));
181
+ const aSrc = new Map(after.sources.map((s) => [s.file, s]));
182
+ const supportedBy = (graph, file) => graph.edges.filter((e) => e.source?.file === file).map((e) => e.id).sort();
183
+ const sources = { added: [], removed: [], changed: [] };
184
+ for (const [file, s] of [...aSrc].sort()) {
185
+ const o = bSrc.get(file);
186
+ if (!o) sources.added.push({ file, digest: s.digest, edgeIds: supportedBy(after, file) });
187
+ else if (o.digest !== s.digest) sources.changed.push({ file, beforeDigest: o.digest, afterDigest: s.digest, edgeIdsBefore: supportedBy(before, file), edgeIdsAfter: supportedBy(after, file) });
188
+ }
189
+ for (const [file, s] of [...bSrc].sort()) if (!aSrc.has(file)) sources.removed.push({ file, digest: s.digest, edgeIds: supportedBy(before, file) });
190
+
191
+ const incomplete = after.gaps.filter((g) => DEPENDENCY_GAPS.has(g.code)).map((g) => ({ code: g.code, subject: g.subject, message: g.message }));
192
+ const byCategory = {};
193
+ for (const c of list) byCategory[c.category] = (byCategory[c.category] ?? 0) + 1;
194
+ const diff = {
195
+ schema: DRIFT_SCHEMA, schemaVersion: DRIFT_VERSION,
196
+ repository: after.repository ?? before.repository, beforeDigest: before.digest, afterDigest: after.digest,
197
+ beforeRevision: before.revision, afterRevision: after.revision,
198
+ changes: list, sources, incomplete,
199
+ summary: { total: list.length, material: list.filter((c) => c.material).length, byCategory },
200
+ };
201
+ return { ok: true, diff, errors: [] };
202
+ }
203
+
204
+ /** Human-readable lines for a diff. States what changed and what it does not show; never says a change is safe. */
205
+ export function explainDrift(diff) {
206
+ const lines = [];
207
+ if (!diff || !Array.isArray(diff.changes)) return ['no drift record'];
208
+ lines.push(`Boundary drift: ${diff.summary.total} change(s), ${diff.summary.material} material`);
209
+ for (const c of diff.changes) lines.push(` ${c.material ? '[material] ' : ''}${c.category}: ${c.description}`);
210
+ if (!diff.changes.length) lines.push(' no change to a non-structural relation was found between these two graphs');
211
+ for (const s of diff.sources.changed) lines.push(` source changed: ${s.file} (supported ${s.edgeIdsBefore.length} edge(s) before, ${s.edgeIdsAfter.length} now)`);
212
+ for (const s of diff.sources.removed) lines.push(` source removed: ${s.file} (supported ${s.edgeIds.length} edge(s))`);
213
+ for (const s of diff.sources.added) lines.push(` source added: ${s.file}`);
214
+ if (diff.incomplete.length) lines.push(` incomplete inputs: ${sortedUnique(diff.incomplete.map((g) => g.code)).join(', ')}; a change behind them may be missing from this diff`);
215
+ lines.push(' This compares two configured graphs; it does not show how the deployed system behaves.');
216
+ return lines;
217
+ }
218
+
219
+ // ------------------------------------------------------------ impact: which hypotheses a change can affect
220
+
221
+ function adjacency(graphs) {
222
+ const fwd = new Map(), rev = new Map();
223
+ for (const g of graphs) {
224
+ for (const e of g.edges) {
225
+ if (!TRAVERSED.has(e.relation) && !isDeny(e)) continue;
226
+ if (!fwd.has(e.from)) fwd.set(e.from, new Set());
227
+ fwd.get(e.from).add(e.to);
228
+ if (!rev.has(e.to)) rev.set(e.to, new Set());
229
+ rev.get(e.to).add(e.from);
230
+ }
231
+ }
232
+ return { fwd, rev };
233
+ }
234
+
235
+ function closure(start, map) {
236
+ const seen = new Set(start);
237
+ let frontier = [...start];
238
+ for (let hop = 0; hop < MAX_CLOSURE_HOPS && frontier.length; hop++) {
239
+ const next = [];
240
+ for (const id of frontier) for (const m of map.get(id) ?? []) if (!seen.has(m)) { seen.add(m); next.push(m); }
241
+ frontier = next;
242
+ }
243
+ return seen;
244
+ }
245
+
246
+ /** The (kind|name|environment) keys of every service a change can affect: its own, those that reach it, those it reaches. */
247
+ function affectedServiceKeys(change, before, after) {
248
+ const nodes = new Map();
249
+ for (const g of [before, after]) for (const n of g.nodes) nodes.set(n.id, n);
250
+ const { fwd, rev } = adjacency([before, after]);
251
+ const seeds = change.nodeIds.filter((id) => nodes.has(id));
252
+ const reach = new Set([...closure(seeds, fwd), ...closure(seeds, rev)]);
253
+ const keys = new Set();
254
+ for (const id of reach) { const n = nodes.get(id); if (n && n.kind === 'service') keys.add(keyOf(n)); }
255
+ return keys;
256
+ }
257
+
258
+ function serviceKeyOfBinding(finding, bindings, before, after) {
259
+ for (const g of [after, before]) {
260
+ const b = bindFindingToService(finding, bindings, g);
261
+ if (b.status === 'bound') return { status: 'bound', key: keyOf(b.service), reason: b.reason };
262
+ if (b.status === 'unbound' || b.status === 'ambiguous') return { status: b.status, key: null, reason: b.reason };
263
+ }
264
+ // The service exists in neither graph (renamed or removed). Use the binding's own name so a removal still maps.
265
+ const b = bindFindingToService(finding, bindings, after);
266
+ if (b.binding && b.binding.environment) return { status: 'bound', key: `service|${b.binding.service}|${b.binding.environment}`, reason: 'service is absent from both graphs; matched by the binding name' };
267
+ return { status: b.status, key: null, reason: b.reason };
268
+ }
269
+
270
+ /**
271
+ * The focused rescan plan for a diff.
272
+ *
273
+ * @param {object} args
274
+ * @param {object} args.diff from `diffBoundaryGraphs`
275
+ * @param {object} args.before
276
+ * @param {object} args.after
277
+ * @param {Array<{hypothesisId?:string, finding:object}>} args.hypotheses
278
+ * @param {Array} args.bindings see `bindFindingToService`
279
+ */
280
+ export function planRescan({ diff, before, after, hypotheses = [], bindings = [] } = {}) {
281
+ const affecting = diff.changes;
282
+ const incomplete = diff.incomplete.map((g) => ({ hypothesisId: null, code: g.code, reason: `${g.subject}: ${g.message}` }));
283
+ const serviceKeysByChange = new Map(affecting.map((c) => [c.id, affectedServiceKeys(c, before, after)]));
284
+ const rescan = [], skipped = [];
285
+ for (const h of hypotheses) {
286
+ const finding = h.finding ?? h;
287
+ const hypothesisId = h.hypothesisId ?? hypothesisIdFromFinding(finding);
288
+ const file = finding?.file ?? null;
289
+ const bound = serviceKeyOfBinding(finding, bindings, before, after);
290
+ if (bound.status !== 'bound') {
291
+ if (affecting.length) incomplete.push({ hypothesisId, code: 'unbound-hypothesis', reason: `${bound.reason}; it cannot be shown independent of the ${affecting.length} change(s), so it is neither rescanned nor cleared` });
292
+ else skipped.push({ hypothesisId, file, reason: 'no change between the two graphs' });
293
+ continue;
294
+ }
295
+ const reasons = affecting.filter((c) => serviceKeysByChange.get(c.id).has(bound.key)).map((c) => c.id);
296
+ if (reasons.length) rescan.push({ hypothesisId, file, serviceKey: bound.key, reasons });
297
+ else skipped.push({ hypothesisId, file, reason: `no changed relation touches, reaches or is reached by ${bound.key.split('|')[1]}` });
298
+ }
299
+ const sortById = (a, b) => (String(a.hypothesisId) < String(b.hypothesisId) ? -1 : 1);
300
+ rescan.sort(sortById); skipped.sort(sortById);
301
+ incomplete.sort((a, b) => `${a.code}|${a.hypothesisId}`.localeCompare(`${b.code}|${b.hypothesisId}`));
302
+ return {
303
+ status: incomplete.length ? 'incomplete' : 'complete',
304
+ changeIds: affecting.map((c) => c.id),
305
+ rescan, skipped, incomplete,
306
+ rescanFiles: sortedUnique(rescan.map((r) => r.file).filter(Boolean)),
307
+ statement: incomplete.length
308
+ ? `incomplete: ${incomplete.length} dependency problem(s) mean the plan cannot show that every unaffected hypothesis is independent of the change`
309
+ : `${rescan.length} hypothesis(es) to rescan, ${skipped.length} unaffected and not rescanned`,
310
+ };
311
+ }
312
+
313
+ // ------------------------------------------------------------ incremental vs complete
314
+
315
+ /** The deployment-conditioned result for one hypothesis, without graph-wide metadata that moves between revisions. */
316
+ export function scopedSignature(context) {
317
+ if (!context) return null;
318
+ return digestOf({
319
+ hypothesisId: context.hypothesisId, binding: context.binding.status, service: context.binding.service?.id ?? null,
320
+ exposure: context.exposure.state,
321
+ paths: context.paths.map((p) => ({ id: p.id, kind: p.kind, state: p.state, conditions: p.conditions.map((c) => c.kind), blockers: p.blockers.map((b) => b.edgeId), exploitability: p.exploitability.label })),
322
+ verification: context.verification.map((v) => [v.recordId, v.applicable]),
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Recompute only the hypotheses the plan names and carry the rest forward.
328
+ *
329
+ * @param {object} args
330
+ * @param {object} args.plan
331
+ * @param {Array<{hypothesisId?:string, finding:object}>} args.hypotheses
332
+ * @param {Object<string, object>} args.previous hypothesisId -> context from the earlier revision
333
+ * @param {(h: object) => object} args.annotate recompute one hypothesis against the new revision
334
+ */
335
+ export function incrementalAnnotate({ plan, hypotheses, previous = {}, annotate }) {
336
+ const idOf = (h) => h.hypothesisId ?? hypothesisIdFromFinding(h.finding ?? h);
337
+ const redo = new Set(plan.rescan.map((r) => r.hypothesisId));
338
+ const unproven = new Set(plan.incomplete.map((i) => i.hypothesisId).filter(Boolean));
339
+ const results = {}, recomputed = [], carried = [], stale = [];
340
+ for (const h of hypotheses) {
341
+ const id = idOf(h);
342
+ if (redo.has(id) || !(id in previous)) { results[id] = annotate(h); recomputed.push(id); }
343
+ else { results[id] = previous[id]; (unproven.has(id) ? stale : carried).push(id); }
344
+ }
345
+ return {
346
+ status: plan.status, results, recomputed: recomputed.sort(), carried: carried.sort(), stale: stale.sort(),
347
+ incomplete: plan.incomplete,
348
+ };
349
+ }
350
+
351
+ /** Compare an incremental result with a complete one on the scoped signature. An incomplete incremental result never matches. */
352
+ export function compareScoped(incremental, complete) {
353
+ const ids = sortedUnique([...Object.keys(incremental.results), ...Object.keys(complete)]);
354
+ const differing = ids.filter((id) => scopedSignature(incremental.results[id]) !== scopedSignature(complete[id]));
355
+ const equivalent = incremental.status === 'complete' && differing.length === 0;
356
+ return { equivalent, differing, status: incremental.status };
357
+ }
358
+
359
+ // ------------------------------------------------------------ invalidation of paths by changed sources
360
+
361
+ /**
362
+ * Which paths a set of changed or removed source files invalidates, and the graph with those relations dropped.
363
+ * A path is invalidated exactly when one of its supporting edges, or the declaration of one of its nodes, cites a changed file.
364
+ *
365
+ * @param {object[]} paths path records (`computeAttackPaths`)
366
+ * @param {object} graph
367
+ * @param {Object<string,string>|Map} currentDigests file -> sha256 now (absent means removed)
368
+ */
369
+ export function invalidatePathsForSources(paths, graph, currentDigests) {
370
+ const inv = invalidateChangedSources(graph, currentDigests);
371
+ if (!inv.ok) return { ok: false, valid: [], invalidated: [], graph: null, staleFiles: inv.staleFiles, errors: inv.errors };
372
+ const staleEdges = new Set(inv.invalidatedEdgeIds);
373
+ const stale = new Set(inv.staleFiles);
374
+ const declaredOnlyByStale = new Set(graph.nodes.filter((n) => n.declaredBy.length && n.declaredBy.every((d) => stale.has(d.file))).map((n) => n.id));
375
+ const valid = [], invalidated = [];
376
+ for (const p of paths) {
377
+ const edgeIds = p.supportingEdgeIds.filter((id) => staleEdges.has(id));
378
+ const nodeIds = sortedUnique(p.hops.flatMap((h) => [h.from.id, h.to.id]).filter((id) => declaredOnlyByStale.has(id)));
379
+ if (edgeIds.length || nodeIds.length) invalidated.push({ pathId: p.id, edgeIds: sortedUnique(edgeIds), nodeIds });
380
+ else valid.push(p);
381
+ }
382
+ return { ok: true, valid, invalidated, graph: inv.graph, staleFiles: inv.staleFiles, errors: [] };
383
+ }