@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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 (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -0,0 +1,313 @@
1
+ //
2
+ // control-credit.js: which controls protect which witness, and which do not (DAG-04, R04.5 / R04.6).
3
+ //
4
+ // A control earns credit on a witness only when ALL of these hold, each recorded as evidence:
5
+ // * the sanitizer's catalog family matches the SINK family (an HTML encoder does not protect SQL);
6
+ // * the value that reaches the sink is the value the control produced: the control's call sits ON the
7
+ // witness (an edge at its node), its result is what the witness carries, and every later read of the
8
+ // controlled variable sees exactly that definition (reassignment or a merge with another definition
9
+ // voids the credit: stale credit is refused);
10
+ // * nothing later on the same witness reverses it (decoders keyed by family, from sanitizer-gate.js).
11
+ //
12
+ // What this does NOT establish: that the sanitizer is correct. The family comes from the sanitizer
13
+ // catalog, and dominance (a control on every path) is evidence about coverage, never proof of sanitizer
14
+ // semantics. Every credit says so (`semanticsProof: false`). An unrecognized call that widened the value
15
+ // (an opaque call) makes the witness `unknown`, not `unprotected` and never `protected`.
16
+ //
17
+ // Control state per witness: 'protected' | 'unprotected' | 'unknown'. `assessAllPathsProtectedWithControls`
18
+ // feeds the real states into witness-selection.js `assessAllPathsProtected`, whose rules are unchanged:
19
+ // it looks at every enumerated path, refuses on a truncated or resumed query, and one unprotected path
20
+ // refutes the claim. An optional independent abstract proof (`abstractControlProof`) can license the claim
21
+ // when enumeration was truncated, because it does not depend on which paths were enumerated.
22
+
23
+ import { CATALOG } from '../dataflow/catalog.js';
24
+ import { accessPathOf } from '../dataflow/access-paths.js';
25
+ import { familyOfFinding, _reversedOnPath } from '../dataflow/sanitizer-gate.js';
26
+ import { computeDominators } from '../ir/ssa.js';
27
+ import { computeReachingDefinitions } from './value-versions.js';
28
+ import { exprRoots, walkExpr } from './source-seeding.js';
29
+ import { assessAllPathsProtected } from './witness-selection.js';
30
+ import { recognizeTransformation } from './transform-catalog.js';
31
+
32
+ export const CONTROL_CREDIT_VERSION = 'control-credit/1';
33
+
34
+ const CREDIT_LIMITATIONS = Object.freeze([
35
+ 'Sanitizer semantics come from the catalog family; correctness of the sanitizer itself is not proven.',
36
+ 'Dominance or presence on the path is coverage evidence, not proof that the control neutralizes the sink.',
37
+ 'Controls are matched by callee name; a project-local wrapper is an opaque call.',
38
+ ]);
39
+
40
+ let _index = null;
41
+ /** callee -> list of family sets, one per catalog entry (kept apart so name ambiguity is detectable). */
42
+ function sanitizerIndex() {
43
+ if (_index) return _index;
44
+ _index = new Map();
45
+ for (const e of CATALOG) {
46
+ if (!e || e.kind !== 'sanitizer' || e.match?.type !== 'call' || !e.match.callee) continue;
47
+ const fams = Array.isArray(e.appliesTo) ? e.appliesTo : [];
48
+ if (!_index.has(e.match.callee)) _index.set(e.match.callee, []);
49
+ _index.get(e.match.callee).push(new Set(fams));
50
+ }
51
+ return _index;
52
+ }
53
+
54
+ function dotted(callee) {
55
+ if (typeof callee === 'string') return callee;
56
+ if (!callee) return null;
57
+ if (callee.kind === 'ident') return callee.name;
58
+ if (callee.kind === 'member') { const o = dotted(callee.object); return o ? `${o}.${callee.prop}` : callee.prop; }
59
+ return null;
60
+ }
61
+
62
+ /** Names a call may be known by, most specific first. */
63
+ export function calleeNames(callee) {
64
+ const full = dotted(callee);
65
+ if (!full) return [];
66
+ const bare = full.split('.').pop();
67
+ return bare === full ? [full] : [full, bare];
68
+ }
69
+
70
+ /**
71
+ * The families a callee neutralizes: { known, families:Set, ambiguous }. `ambiguous` means the callee name
72
+ * is shared by catalog entries that disagree on families (a bare `escape`), so it cannot be credited.
73
+ */
74
+ export function controlFamiliesOf(callee) {
75
+ const idx = sanitizerIndex();
76
+ for (const nm of calleeNames(callee)) {
77
+ const sets = idx.get(nm);
78
+ if (!sets) continue;
79
+ const key = (s) => [...s].sort().join(',');
80
+ const distinct = new Set(sets.map(key));
81
+ if (distinct.size > 1) return { known: true, name: nm, families: new Set(sets.flatMap((s) => [...s])), ambiguous: true };
82
+ return { known: true, name: nm, families: new Set(sets[0]), ambiguous: false };
83
+ }
84
+ return { known: false, name: null, families: new Set(), ambiguous: false };
85
+ }
86
+
87
+ /** The sink family for a catalog sink entry (`{ vuln: { cwe, name } }`) or a finding-shaped object. */
88
+ export function sinkFamilyOf(x) {
89
+ if (!x) return null;
90
+ const v = x.vuln && typeof x.vuln === 'object' ? x.vuln : x;
91
+ return familyOfFinding({ cwe: v.cwe ?? x.cwe, vuln: typeof v.name === 'string' ? v.name : (typeof x.vuln === 'string' ? x.vuln : ''), family: x.family });
92
+ }
93
+
94
+ function callsAt(node) {
95
+ const calls = [];
96
+ if (!node) return calls;
97
+ for (const root of exprRoots(node)) walkExpr(root, (e) => { if (e.kind === 'call') calls.push(e); });
98
+ if (node.kind === 'call' && node.callee) calls.unshift({ kind: 'call', callee: node.callee, args: node.args ?? [] });
99
+ return calls;
100
+ }
101
+
102
+ const covers = (a, b) => typeof a === 'string' && typeof b === 'string' && (a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`));
103
+
104
+ function argReadsPath(call, fromPath) {
105
+ if (!fromPath) return false;
106
+ let hit = false;
107
+ for (const a of call.args ?? []) {
108
+ walkExpr(a, (e) => { if (!hit && (e.kind === 'ident' || e.kind === 'member')) { const p = e.kind === 'ident' ? e.name : accessPathOf(e); if (covers(p, fromPath)) hit = true; } });
109
+ }
110
+ return hit;
111
+ }
112
+
113
+ const _rdCache = new WeakMap();
114
+ function rdOf(fn) {
115
+ const cfg = fn?.cfg;
116
+ if (!cfg) return null;
117
+ let rd = _rdCache.get(cfg);
118
+ if (!rd) { rd = computeReachingDefinitions({ cfg: { entry: cfg.entry, nodes: cfg.nodes }, params: fn.params ?? [] }); _rdCache.set(cfg, rd); }
119
+ return rd;
120
+ }
121
+
122
+ function isWidened(h) {
123
+ return (h.widenReasons ?? []).includes('unresolved-call') || (h.annotations ?? []).some((a) => a.widenReason === 'unresolved-call');
124
+ }
125
+
126
+ /**
127
+ * Judge one witness.
128
+ *
129
+ * @param {object} o
130
+ * @param {object[]} o.hops `path.hops`, source first
131
+ * @param {(id:string)=>object|null} o.nodeOf store.getNode (to read each hop's from/to access paths)
132
+ * @param {(qid:string)=>object|null} o.fnOf IR function lookup
133
+ * @param {string|null} o.sinkFamily the family of the sink (see sinkFamilyOf), null when unknown
134
+ * @param {boolean} [o.truncated] the path is incomplete or analysis was cut short
135
+ */
136
+ export function creditPath({ hops, nodeOf, fnOf, sinkFamily = null, truncated = false } = {}) {
137
+ const list = Array.isArray(hops) ? hops : [];
138
+ const credits = []; const rejected = []; const opaque = [];
139
+ const pathOf = (id) => nodeOf?.(id)?.path ?? null;
140
+
141
+ for (let i = 0; i < list.length; i++) {
142
+ const h = list[i];
143
+ const fn = fnOf?.(h.scope);
144
+ const node = fn?.cfg?.nodes?.[h.siteNodeId];
145
+ if (!node) continue;
146
+ const fromPath = pathOf(h.fromNodeId);
147
+ const calls = callsAt(node);
148
+ let recognizedHere = false;
149
+
150
+ for (const c of calls) {
151
+ const fam = controlFamiliesOf(c.callee);
152
+ if (!fam.known) continue;
153
+ recognizedHere = true;
154
+ const where = { edgeId: h.edgeId ?? null, scope: h.scope, nodeId: h.siteNodeId, callee: fam.name, families: [...fam.families].sort() };
155
+ if (!argReadsPath(c, fromPath)) { rejected.push({ ...where, reason: 'value-not-through-control' }); continue; }
156
+ // The control's result must be what the witness carries.
157
+ const viaAssign = node.kind === 'assign' && node.source === c && typeof node.target === 'string';
158
+ const viaSinkArg = node.kind === 'call' && (node.args ?? []).includes(c);
159
+ if (!viaAssign && !viaSinkArg) { rejected.push({ ...where, reason: 'result-not-carried' }); continue; }
160
+ if (fam.ambiguous) { rejected.push({ ...where, reason: 'ambiguous-callee-family' }); opaque.push({ ...where, reason: 'ambiguous-callee-family' }); continue; }
161
+ if (!sinkFamily) { rejected.push({ ...where, reason: 'sink-family-unknown' }); opaque.push({ ...where, reason: 'sink-family-unknown' }); continue; }
162
+ if (!fam.families.has(sinkFamily) && !fam.families.has('*')) { rejected.push({ ...where, reason: 'wrong-family', sinkFamily }); continue; }
163
+
164
+ // Value version: later reads of the controlled variable must see exactly this definition.
165
+ let versionOk = true; let versionReason = null;
166
+ if (viaAssign) {
167
+ const T = node.target;
168
+ for (let j = i + 1; j < list.length; j++) {
169
+ const hj = list[j];
170
+ if (hj.scope !== h.scope) continue;
171
+ const fj = pathOf(hj.fromNodeId);
172
+ if (!covers(fj, T)) continue;
173
+ const rd = rdOf(fnOf(hj.scope));
174
+ const vers = rd ? rd.versionsAt(hj.siteNodeId, T) : [];
175
+ if (!vers.includes(h.siteNodeId)) { versionOk = false; versionReason = 'stale-control-version'; break; }
176
+ if (vers.length > 1) { versionOk = false; versionReason = 'merged-with-other-definition'; break; }
177
+ }
178
+ }
179
+ if (!versionOk) { rejected.push({ ...where, reason: versionReason }); if (versionReason === 'merged-with-other-definition') opaque.push({ ...where, reason: versionReason }); continue; }
180
+
181
+ // Reversal later on the same witness voids the credit.
182
+ let reversedBy = null;
183
+ for (let j = i + 1; j < list.length && !reversedBy; j++) {
184
+ const nj = fnOf?.(list[j].scope)?.cfg?.nodes?.[list[j].siteNodeId];
185
+ for (const cj of callsAt(nj)) {
186
+ const hit = _reversedOnPath(calleeNames(cj.callee), sinkFamily);
187
+ if (hit) { reversedBy = hit; break; }
188
+ }
189
+ }
190
+ if (reversedBy) { rejected.push({ ...where, reason: `reversed:${reversedBy}` }); continue; }
191
+
192
+ credits.push({
193
+ ...where, sinkFamily, valueVersion: viaAssign ? h.siteNodeId : 'at-sink-argument',
194
+ basis: ['catalog-family', 'on-witness', 'value-version'], semanticsProof: false,
195
+ });
196
+ }
197
+
198
+ // A parameterized query is the SQL-specific control that is not a call to a sanitizer: the value enters as a
199
+ // bound parameter and never as query text. Credited only for the sql sink family.
200
+ if (sinkFamily === 'sql' && isParameterizedSqlCall(node, fromPath)) {
201
+ credits.push({
202
+ edgeId: h.edgeId ?? null, scope: h.scope, nodeId: h.siteNodeId, callee: 'parameterized-query', families: ['sql'], sinkFamily,
203
+ valueVersion: 'bound-parameter', basis: ['placeholder-binding', 'on-witness'], semanticsProof: false,
204
+ });
205
+ }
206
+
207
+ // An unrecognized call that widened the value could be a control; the witness cannot be called unprotected.
208
+ if (!recognizedHere && isWidened(h)) {
209
+ const names = calls.map((c) => dotted(c.callee)).filter(Boolean);
210
+ // A known decoder is not opaque: it is a recognized reversal (and is judged as such above).
211
+ const known = calls.length > 0 && calls.every((c) => controlFamiliesOf(c.callee).known
212
+ || recognizeTransformation(callDescriptor(c.callee))
213
+ || (sinkFamily && _reversedOnPath(calleeNames(c.callee), sinkFamily)));
214
+ if (!known) opaque.push({ edgeId: h.edgeId ?? null, scope: h.scope, nodeId: h.siteNodeId, callees: names, reason: 'opaque-call' });
215
+ }
216
+ }
217
+
218
+ let controlState;
219
+ const reasons = [];
220
+ if (credits.length) { controlState = 'protected'; reasons.push('matching-control-on-witness'); }
221
+ else if (opaque.length) { controlState = 'unknown'; reasons.push(...new Set(opaque.map((o) => o.reason))); }
222
+ else if (truncated) { controlState = 'unknown'; reasons.push('witness-incomplete'); }
223
+ else { controlState = 'unprotected'; reasons.push(rejected.length ? [...new Set(rejected.map((r) => r.reason))].sort().join(',') : 'no-control-on-witness'); }
224
+ return {
225
+ version: CONTROL_CREDIT_VERSION, controlState, reasons,
226
+ credits, rejected, opaque, sinkFamily, limitations: [...CREDIT_LIMITATIONS],
227
+ };
228
+ }
229
+
230
+ const PLACEHOLDER_RE = /(\?|\$\d+|:[A-Za-z_]\w*|%s|@[A-Za-z_]\w*)/;
231
+
232
+ /** `db.query('... WHERE id = ?', [value])`: a literal query text with placeholders, the witness value only in later arguments. */
233
+ export function isParameterizedSqlCall(node, fromPath) {
234
+ if (!node || node.kind !== 'call') return false;
235
+ const args = node.args ?? [];
236
+ const text = args[0];
237
+ if (args.length < 2 || !text || text.kind !== 'literal' || typeof text.value !== 'string' || !PLACEHOLDER_RE.test(text.value)) return false;
238
+ return argReadsPath({ args: args.slice(1) }, fromPath);
239
+ }
240
+
241
+ function callDescriptor(callee) {
242
+ if (typeof callee === 'string') return { type: 'call', callee };
243
+ if (!callee) return null;
244
+ if (callee.kind === 'ident') return { type: 'call', callee: callee.name };
245
+ if (callee.kind === 'member' && callee.object?.kind === 'ident') return { type: 'member-call', object: callee.object.name, method: callee.prop };
246
+ if (callee.kind === 'member') return { type: 'call', callee: callee.prop };
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * An independent, intraprocedural abstract proof that a matching control covers EVERY path to a sink
252
+ * argument: a control assignment dominates the sink node and is the only definition of the variable that
253
+ * reaches it. It does not depend on which witnesses were enumerated. It is still evidence about coverage:
254
+ * the control's correctness comes from the catalog family.
255
+ */
256
+ export function abstractControlProof(fn, { sinkNodeId, variable, sinkFamily }) {
257
+ const cfg = fn?.cfg;
258
+ if (!cfg || !sinkFamily || !variable) return { proved: false, reason: 'insufficient-input', semanticsProof: false };
259
+ const nodes = cfg.nodes instanceof Map ? Object.fromEntries(cfg.nodes) : cfg.nodes;
260
+ if (!nodes[sinkNodeId]) return { proved: false, reason: 'unknown-sink-node', semanticsProof: false };
261
+ const dom = computeDominators({ entry: cfg.entry, nodes });
262
+ const rd = rdOf(fn);
263
+ const vers = rd.versionsAt(sinkNodeId, variable);
264
+ if (vers.length !== 1) return { proved: false, reason: 'multiple-reaching-definitions', semanticsProof: false };
265
+ const defId = vers[0];
266
+ const def = nodes[defId];
267
+ if (!def || def.kind !== 'assign' || !dom.get(sinkNodeId)?.has(defId)) return { proved: false, reason: 'definition-does-not-dominate-sink', semanticsProof: false };
268
+ const call = def.source?.kind === 'call' ? def.source : null;
269
+ const fam = call ? controlFamiliesOf(call.callee) : null;
270
+ if (!fam?.known || fam.ambiguous) return { proved: false, reason: 'definition-is-not-a-recognized-control', semanticsProof: false };
271
+ if (!fam.families.has(sinkFamily) && !fam.families.has('*')) return { proved: false, reason: 'wrong-family', semanticsProof: false };
272
+ return {
273
+ proved: true, controlNodeId: defId, callee: fam.name, sinkFamily, basis: ['dominance', 'single-reaching-definition', 'catalog-family'],
274
+ semanticsProof: false, limitations: [...CREDIT_LIMITATIONS, 'Intraprocedural: other functions that reach the same sink are not covered by this proof.'],
275
+ };
276
+ }
277
+
278
+ /**
279
+ * "Every path is protected", answered from real control states (R04.6).
280
+ *
281
+ * @param {object} o
282
+ * @param {object} o.result the reconstructPaths result
283
+ * @param {object[]} o.enumerated every enumerated path (not just the displayed ones), with `.hops`
284
+ * @param {Map<string,object>|((p:object)=>object)} o.evidenceOf per-path { controlState, feasibility? }
285
+ * @param {object} [o.proof] abstractControlProof result, an independent licence for the claim
286
+ *
287
+ * Paths whose feasibility is `infeasible-under-model` are excluded from the relevant set and LISTED, so the
288
+ * claim is explicitly scoped to the modeled feasible paths. unknown, unsupported and timeout paths stay
289
+ * relevant: they cannot be dismissed. The claim never rests on the displayed sample.
290
+ */
291
+ export function assessAllPathsProtectedWithControls({ result, enumerated, evidenceOf, proof = null }) {
292
+ const get = (p) => (typeof evidenceOf === 'function' ? evidenceOf(p) : evidenceOf?.get?.(p.id)) ?? null;
293
+ const all = Array.isArray(enumerated) ? enumerated : [];
294
+ const excludedInfeasible = []; const relevant = [];
295
+ for (const p of all) {
296
+ const ev = get(p);
297
+ if (ev?.feasibility?.status === 'infeasible-under-model') excludedInfeasible.push({ pathId: p.id, contradiction: ev.feasibility.contradiction ?? null });
298
+ else relevant.push(p);
299
+ }
300
+ const attrsOf = (p) => ({ controlState: get(p)?.controlState ?? 'unknown' });
301
+ const base = assessAllPathsProtected({ result, enumerated: relevant, attrsOf });
302
+ const out = {
303
+ ...base, semanticsVersion: CONTROL_CREDIT_VERSION, claimScope: 'modeled-feasible-paths',
304
+ relevantPathCount: relevant.length, excludedInfeasible, basis: 'enumeration', abstractProof: proof ?? null,
305
+ };
306
+ if (base.holds === false) return out;
307
+ // Enumeration could not establish the claim (truncated, resumed, or unknown states). An independent abstract
308
+ // proof can, because it does not depend on which paths were enumerated. A refutation above always wins.
309
+ if (!base.allowed && proof?.proved) {
310
+ return { ...out, allowed: true, holds: true, basis: 'abstract-proof', reasons: base.reasons.filter((r) => r !== 'refuted-by-unprotected-path'), proofCovers: 'every path to the sink argument within the function' };
311
+ }
312
+ return out;
313
+ }
@@ -0,0 +1,199 @@
1
+ //
2
+ // correlation.js: provenance correlation version 2 (DAG-03).
3
+ //
4
+ // Version 1 pairs the production and consumption halves of a hop at a join
5
+ // key (scope, nodeId, dataElementId, context) and marks the pairings it
6
+ // cannot tell apart as `ambiguousCorrelation`. Version 2 adds evidence the
7
+ // engine had at the moment it emitted each half, so that halves pair only
8
+ // when that evidence proves them compatible:
9
+ //
10
+ // derivationId where inside the CFG node's expression tree an in-half
11
+ // was produced (a segment path such as `/src/p:left/a0`)
12
+ // derivationScope the part of that tree an out-half consumes
13
+ // relPath the field path of an in-half relative to the value it
14
+ // is selected from, and of an out-half relative to the
15
+ // value it writes
16
+ // fromVersions the definitions of the read path that reach the read
17
+ // toVersion the definition a write creates
18
+ // invocationId one id per call expression instance
19
+ // callRole 'arg' | 'bind' | 'return'
20
+ // argPosition / formalPosition actual and formal argument positions
21
+ //
22
+ // Version 2 is OFF by default. It is enabled per call site by the
23
+ // `correlationVersion: 2` option or process-wide by
24
+ // AGENTIC_SECURITY_LINEAGE_CORRELATION=2. With it off no module in this
25
+ // package emits, reads or hashes any of the fields above, so version 1
26
+ // output, ids and digests are byte-identical to the pre-DAG-03 behavior.
27
+ //
28
+ // A hop that lacks the evidence (a hand-built stream, an older recorder, an
29
+ // expression kind that carries no derivation) is never pruned: it pairs the
30
+ // way version 1 paired it and the edge says so (`correlation:
31
+ // 'legacy-unknown'`). Evidence only ever removes pairings that are proven
32
+ // incompatible; it never removes a marked edge to make a metric look better.
33
+ //
34
+ import * as crypto from 'node:crypto';
35
+ import { provenanceNodeId, provenanceEdgeId } from './ids.js';
36
+
37
+ export const CORRELATION_VERSION_1 = 1;
38
+ export const CORRELATION_VERSION_2 = 2;
39
+
40
+ /** Additive hop fields version 2 adds to the 14 version 1 fields. */
41
+ export const V2_HOP_FIELDS = [
42
+ 'derivationId', 'derivationScope', 'relPath', 'fromVersions', 'toVersion',
43
+ 'invocationId', 'callRole', 'argPosition', 'formalPosition', 'allocKind', 'receiver',
44
+ ];
45
+
46
+ /** Default bound on the call-context stack a balanced traversal tracks. */
47
+ export const DEFAULT_MAX_CALL_DEPTH = 8;
48
+
49
+ /**
50
+ * Resolves the effective correlation version. An explicit option wins over
51
+ * the environment; anything other than 2 (or '2') is version 1.
52
+ */
53
+ export function resolveCorrelationVersion(option) {
54
+ if (option !== undefined && option !== null) {
55
+ return Number(option) === CORRELATION_VERSION_2 ? CORRELATION_VERSION_2 : CORRELATION_VERSION_1;
56
+ }
57
+ return process.env.AGENTIC_SECURITY_LINEAGE_CORRELATION === '2' ? CORRELATION_VERSION_2 : CORRELATION_VERSION_1;
58
+ }
59
+
60
+ // --- derivation ids ---------------------------------------------------
61
+
62
+ /** Appends one segment to a derivation path. */
63
+ export function deriveChild(parent, seg) {
64
+ return `${parent ?? ''}/${seg}`;
65
+ }
66
+
67
+ /**
68
+ * Is `scope` a segment-aligned prefix of (or equal to) `id`? An out-half
69
+ * whose scope is `/src/p:left` consumes `/src/p:left` and everything under
70
+ * it, but not `/src/p:leftover`.
71
+ */
72
+ export function derivationCovers(scope, id) {
73
+ if (scope === '' || scope === null || scope === undefined) return true;
74
+ return id === scope || id.startsWith(`${scope}/`);
75
+ }
76
+
77
+ /**
78
+ * For an out-half that writes the byPath entry `subPath` of a value built by
79
+ * `expr`, returns the derivation scope that entry was produced under and the
80
+ * part of `subPath` left over below that scope. Only an object literal names
81
+ * its properties; every other expression kind keeps the root scope, which
82
+ * is the version 1 behavior (all in-halves of the node are candidates).
83
+ */
84
+ export function scopeForSubPath(expr, subPath, rootDerivation) {
85
+ let scope = rootDerivation ?? '';
86
+ let rest = subPath ?? '';
87
+ let cur = expr;
88
+ while (cur && cur.kind === 'object' && rest !== '') {
89
+ const dot = rest.indexOf('.');
90
+ const head = dot === -1 ? rest : rest.slice(0, dot);
91
+ const tail = dot === -1 ? '' : rest.slice(dot + 1);
92
+ const props = (cur.props ?? []).filter((p) => !p.spread && p.key === head);
93
+ if (props.length !== 1) break; // absent, duplicated or computed: keep the wider scope
94
+ scope = deriveChild(scope, `p:${head}`);
95
+ rest = tail;
96
+ cur = props[0].value;
97
+ }
98
+ return { scope, rest };
99
+ }
100
+
101
+ /** The field path of `key` relative to the queried path (empty when the key is the path or an ancestor). */
102
+ export function relPathOf(key, path) {
103
+ if (typeof key !== 'string' || typeof path !== 'string') return null;
104
+ return key.length > path.length && key.startsWith(`${path}.`) ? key.slice(path.length + 1) : '';
105
+ }
106
+
107
+ /** One id per call expression instance in a function context. */
108
+ export function invocationIdOf({ scope, nodeId, context, derivation }) {
109
+ const h = crypto.createHash('sha256')
110
+ .update([scope, nodeId, context, derivation].map((p) => (p === null || p === undefined ? '' : String(p))).join('|'))
111
+ .digest('hex').slice(0, 12);
112
+ return `inv:${h}`;
113
+ }
114
+
115
+ // Two relative field paths disagree only when BOTH name a field and the names
116
+ // differ. An empty path is a container-level fact, which applies to every
117
+ // field of the container, so it never disagrees: pruning on it would drop a
118
+ // real edge.
119
+ function fieldsDisagree(a, b) {
120
+ return typeof a === 'string' && typeof b === 'string' && a !== '' && b !== '' && a !== b;
121
+ }
122
+
123
+ // --- pairing -----------------------------------------------------------
124
+
125
+ /**
126
+ * Are an in-half `s` and an out-half `o` of one group compatible under
127
+ * version 2? Both arguments are `{hop}` wrappers. `true` means "pair them";
128
+ * `evidence` says whether the pairing is proven by ids on both sides.
129
+ */
130
+ export function pairCompatibility(sHop, oHop) {
131
+ // A call argument feeds exactly the parameter binding of the same
132
+ // invocation and position. It never reaches the assignment that receives
133
+ // the call's result, because the value goes through the callee.
134
+ if (sHop.callRole === 'arg') {
135
+ const ok = oHop.callRole === 'bind'
136
+ && oHop.invocationId === sHop.invocationId
137
+ && oHop.formalPosition === sHop.argPosition
138
+ && !fieldsDisagree(sHop.relPath, oHop.relPath);
139
+ return { compatible: ok, proven: true };
140
+ }
141
+ if (oHop.callRole === 'bind') return { compatible: false, proven: true };
142
+ if (sHop.derivationId != null && oHop.derivationScope != null) {
143
+ if (!derivationCovers(oHop.derivationScope, sHop.derivationId)) return { compatible: false, proven: true };
144
+ if (fieldsDisagree(sHop.relPath, oHop.relPath)) return { compatible: false, proven: true };
145
+ return { compatible: true, proven: true };
146
+ }
147
+ return { compatible: true, proven: false };
148
+ }
149
+
150
+ // --- crosswalk ---------------------------------------------------------
151
+
152
+ /**
153
+ * Version 1 to version 2 crosswalk for a built version 2 store. A version 1
154
+ * id names a (scope, context, path, data element) location; the version 2
155
+ * ids under it are the value versions of that location. An edge crosswalks
156
+ * the same way through its endpoints.
157
+ *
158
+ * { version: 2, nodes: [{ v1, v2: [ids] }], edges: [{ v1, v2: [ids] }],
159
+ * unmapped: { nodes: [...], edges: [...] } }
160
+ *
161
+ * Every version 2 id appears under exactly one version 1 id, so the
162
+ * crosswalk is total over the store; `unmapped` is always empty for a
163
+ * correct store and exists so a test can assert that.
164
+ */
165
+ export function buildCrosswalk(store) {
166
+ const nodes = store.nodes();
167
+ const edges = store.edges();
168
+ const v1OfNode = new Map();
169
+ const nodeGroups = new Map();
170
+ for (const n of nodes) {
171
+ const v1 = provenanceNodeId({
172
+ kind: n.kind, scope: n.scope, context: n.context, path: n.path,
173
+ siteNodeId: n.siteNodeId, dataElementId: n.dataElementId,
174
+ });
175
+ v1OfNode.set(n.id, v1);
176
+ if (!nodeGroups.has(v1)) nodeGroups.set(v1, []);
177
+ nodeGroups.get(v1).push(n.id);
178
+ }
179
+ const edgeGroups = new Map();
180
+ for (const e of edges) {
181
+ const v1 = provenanceEdgeId({
182
+ fromNodeId: v1OfNode.get(e.fromNodeId), toNodeId: v1OfNode.get(e.toNodeId),
183
+ dataElementId: e.dataElementId, scope: e.scope, context: e.context, siteNodeId: e.siteNodeId,
184
+ inKind: e.inKind, inSubKind: e.inSubKind, outKind: e.outKind, outSubKind: e.outSubKind,
185
+ widenReasons: e.widenReasons, lossReasons: e.lossReasons,
186
+ });
187
+ if (!edgeGroups.has(v1)) edgeGroups.set(v1, []);
188
+ edgeGroups.get(v1).push(e.id);
189
+ }
190
+ const toRows = (m) => [...m.entries()]
191
+ .map(([v1, v2]) => ({ v1, v2: [...new Set(v2)].sort() }))
192
+ .sort((a, b) => (a.v1 < b.v1 ? -1 : a.v1 > b.v1 ? 1 : 0));
193
+ return {
194
+ version: CORRELATION_VERSION_2,
195
+ nodes: toRows(nodeGroups),
196
+ edges: toRows(edgeGroups),
197
+ unmapped: { nodes: [], edges: [] },
198
+ };
199
+ }
@@ -442,7 +442,14 @@ export function buildCoverageLedger(built, opts = {}) {
442
442
  paths: { enumerated: built.stats.pathsEnumerated, projected: built.stats.pathsProjected, truncatedQueries: built.stats.truncatedQueries },
443
443
  budgets: { ...PATH_QUERY_DEFAULTS, ...(opts.budget ?? {}) },
444
444
 
445
- provenance: { hops: built.hops.length, pnodes: storeStats.nodes, pedges: storeStats.edges },
445
+ provenance: {
446
+ hops: built.hops.length, pnodes: storeStats.nodes, pedges: storeStats.edges,
447
+ ...(storeStats.correlationVersion ? { correlationVersion: storeStats.correlationVersion } : null),
448
+ },
449
+ // DAG-07: present only when some function stopped early, so a complete
450
+ // run's ledger (and its digest) is unchanged.
451
+ ...(built.analysisCompletion && built.analysisCompletion.status !== 'complete'
452
+ ? { analysisCompletion: built.analysisCompletion } : {}),
446
453
  };
447
454
  }
448
455
 
@@ -127,3 +127,25 @@ export function validateCrossRepoLink(record) {
127
127
 
128
128
  return { valid: errors.length === 0, errors };
129
129
  }
130
+
131
+ // DAG-08: attributable link evidence. A CrossRepoLink is an operator assertion, so as evidence it is a `manual-assertion`: it is
132
+ // not foreign-graph validation, a digest check, a code mapping or an observed connection, and nothing here can turn it into one.
133
+ // The Haskell/Nix analysis scope declared on either endpoint is carried through unchanged, so a link to static Nix evidence stays
134
+ // static-configuration evidence.
135
+ export function linkEvidence(link) {
136
+ const scope = {};
137
+ for (const s of ['local', 'remote']) {
138
+ const sc = link && link.scope && link.scope[s];
139
+ if (sc) scope[s] = { target: sc.target ?? null, config: sc.config ?? null, analysis: sc.analysis ?? null, limitations: sc.limitations || [] };
140
+ }
141
+ return {
142
+ kind: 'manual-assertion', declared: true, observed: false, linkId: link && link.id, declaredBy: link && link.declaredBy,
143
+ declaredAt: link && link.declaredAt, provenance: link && link.provenance, scope,
144
+ staticConfigurationOnly: Object.values(scope).some((sc) => sc.analysis && sc.analysis.runtime === false),
145
+ };
146
+ }
147
+
148
+ /** True only for a structurally valid declared link that does not claim to be observed. */
149
+ export function isDeclaredNotObserved(link) {
150
+ return !!link && validateCrossRepoLink(link).valid && !(link.evidence && link.evidence.observed === true);
151
+ }