@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -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
+ }