@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,89 @@
1
+ //
2
+ // adapters/semantic.js: read-only view over a SemanticGraph v1
3
+ // (ir/semantic-graph.js). Imports only the schema constants and validator; it
4
+ // never builds or mutates a graph.
5
+ //
6
+ // A graph that fails `validateGraph`, or whose major version is not the
7
+ // supported one, is REJECTED (INCOMPATIBLE_VERSION / INVALID_GRAPH) rather than
8
+ // partially adapted.
9
+ //
10
+ // Entities: symbol, callSite, allocation, fieldAccess, valueOrigin,
11
+ // serviceBoundary, storageEdge, sanitizer, guard (singular names of the
12
+ // graph's own lists). The semantic graph stores references as id fields, not
13
+ // an edge list, so the relation edges here are DERIVED (origin 'derived') and
14
+ // get deterministic ids; the underlying confidence tier becomes the evidence
15
+ // grade, and an unresolved call is carried as an `unknowns` entry, never as a
16
+ // fabricated callee edge.
17
+ //
18
+ // symbol --declaration--> callSite (the caller declares/contains it)
19
+ // symbol --declaration--> valueOrigin / fieldAccess / allocation
20
+ // callSite --calls--> symbol (resolved calls only)
21
+ // valueOrigin --data_flow--> callSite (an argument origin feeds the call)
22
+
23
+ import {
24
+ SEMANTIC_GRAPH_SCHEMA, SUPPORTED_MAJOR, validateGraph, ENTITY_LISTS,
25
+ } from '../../ir/semantic-graph.js';
26
+ import { GraphViewError, digestOf } from '../refs.js';
27
+ import { IndexedGraphView, local } from '../view.js';
28
+
29
+ export const SEMANTIC_ADAPTER_VERSION = '1.0.0';
30
+
31
+ const SINGULAR = {
32
+ symbols: 'symbol', callSites: 'callSite', allocations: 'allocation', fieldAccesses: 'fieldAccess',
33
+ valueOrigins: 'valueOrigin', serviceBoundaries: 'serviceBoundary', storageEdges: 'storageEdge',
34
+ sanitizers: 'sanitizer', guards: 'guard',
35
+ };
36
+
37
+ /**
38
+ * @param {object} graph SemanticGraph v1 (buildSemanticGraph output)
39
+ * @param {{repository:string}} opts
40
+ */
41
+ export function semanticView(graph, opts) {
42
+ const m = typeof graph?.schemaVersion === 'string' ? /^(\d+)\./.exec(graph.schemaVersion) : null;
43
+ if (graph?.schema === SEMANTIC_GRAPH_SCHEMA && m && Number(m[1]) !== SUPPORTED_MAJOR) {
44
+ throw new GraphViewError('INCOMPATIBLE_VERSION', `semantic graph major ${m[1]} is not supported (supported: ${SUPPORTED_MAJOR})`, { schemaVersion: graph.schemaVersion });
45
+ }
46
+ const v = validateGraph(graph);
47
+ if (!v.ok) throw new GraphViewError('INVALID_GRAPH', `semantic graph failed validation: ${v.errors[0].code} at ${v.errors[0].path}`, { errors: v.errors.slice(0, 5) });
48
+
49
+ const graphDigest = digestOf(graph);
50
+ const nodes = [];
51
+ for (const list of ENTITY_LISTS) {
52
+ for (const e of graph[list]) {
53
+ nodes.push({ entityKind: SINGULAR[list], localId: e.id, native: e, summary: { file: e.file, line: e.line, tier: e.confidence?.tier ?? null } });
54
+ }
55
+ }
56
+ const edges = [];
57
+ const edge = (relation, from, to, grade, extra = {}) => {
58
+ edges.push({
59
+ id: `sgedge:${relation}:${from.localId}:${to.localId}`, relation, origin: 'derived',
60
+ from, to, evidence: { grade, source: 'semantic-graph' }, unknowns: extra.unknowns ?? [], attrs: extra.attrs ?? {}, native: null,
61
+ });
62
+ };
63
+ for (const c of graph.callSites) {
64
+ edge('declaration', local('symbol', c.caller), local('callSite', c.id), c.confidence.tier);
65
+ if (c.callee) edge('calls', local('callSite', c.id), local('symbol', c.callee), c.confidence.tier, { attrs: { resolution: c.resolution } });
66
+ for (const a of c.args || []) {
67
+ if (a.origin) edge('data_flow', local('valueOrigin', a.origin), local('callSite', c.id), c.confidence.tier, { attrs: { argIndex: a.index } });
68
+ }
69
+ }
70
+ for (const o of graph.valueOrigins) if (o.symbol) edge('declaration', local('symbol', o.symbol), local('valueOrigin', o.id), o.confidence.tier);
71
+ for (const f of graph.fieldAccesses) if (f.symbol) edge('declaration', local('symbol', f.symbol), local('fieldAccess', f.id), f.confidence.tier);
72
+ for (const a of graph.allocations) if (a.symbol) edge('declaration', local('symbol', a.symbol), local('allocation', a.id), a.confidence.tier);
73
+
74
+ // Unresolved calls are unknowns on the view, not edges.
75
+ const unresolvedCalls = graph.callSites.filter((c) => !c.callee).length;
76
+ const reasons = [];
77
+ const schemaOnly = ENTITY_LISTS.filter((k) => graph.coverage[k].state === 'schema-only');
78
+ if (schemaOnly.length) reasons.push(`schema-only:${schemaOnly.join(',')}`);
79
+ if (unresolvedCalls) reasons.push(`unresolved-calls:${unresolvedCalls}`);
80
+ if ((graph.dynamicFeatures || []).length) reasons.push(`dynamic-features:${graph.dynamicFeatures.length}`);
81
+
82
+ return new IndexedGraphView({
83
+ graphKind: 'semantic', adapterVersion: SEMANTIC_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
84
+ metadata: { schema: graph.schema, schemaVersion: graph.schemaVersion, producer: graph.producer ?? null, limits: graph.limits ?? [], unresolvedCalls },
85
+ completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: graph.coverage },
86
+ nodes, edges,
87
+ reconstruct: () => graph,
88
+ });
89
+ }
@@ -0,0 +1,152 @@
1
+ //
2
+ // crosswalk-builders.js: build the standard crosswalk mappings from registered
3
+ // views. Every builder joins on facts the two graphs both carry and records
4
+ // the join as evidence; none invents an identity.
5
+ //
6
+ // finding -> semantic call site file + line (location-exact)
7
+ // call site -> provenance witnesses enclosing function (qid) + line
8
+ // witness -> projected flow data element id
9
+ // projected node -> underlying sites decision kind/category/coverage/externality
10
+ //
11
+ // Where a join cannot pin one target, the mapping lists ALL candidates
12
+ // (one-to-many) and carries an `unknowns` entry. It never picks one silently.
13
+
14
+ import { canonicalJson } from './refs.js';
15
+
16
+ const stripLine = (qid) => String(qid).replace(/@\d+$/, '');
17
+ const normFile = (f) => String(f ?? '').replace(/^\.\//, '').replace(/\\/g, '/');
18
+
19
+ /**
20
+ * Join findings to semantic call sites by exact file and line.
21
+ * @returns {object[]} the mappings added
22
+ */
23
+ export function mapFindingsToCallSites(crosswalk, findings, semantic) {
24
+ const byLoc = new Map();
25
+ for (const cs of semantic.entities('callSite')) {
26
+ const k = `${normFile(cs.native.file)}:${cs.native.line}`;
27
+ if (!byLoc.has(k)) byLoc.set(k, []);
28
+ byLoc.get(k).push(cs);
29
+ }
30
+ const added = [];
31
+ for (const f of findings.entities('finding')) {
32
+ const { file, line } = f.native;
33
+ if (!Number.isInteger(line) || !file) {
34
+ added.push(crosswalk.add({
35
+ type: 'finding-to-callsite', from: f.ref, to: [], unmapped: 'finding-has-no-file-and-line',
36
+ evidence: { method: 'file-line', grade: 'none', basis: 'the finding carries no integer line, so it cannot be located' },
37
+ }));
38
+ continue;
39
+ }
40
+ const hits = byLoc.get(`${normFile(file)}:${line}`) ?? [];
41
+ if (hits.length === 0) continue; // not a call-site finding; absence is visible as a trace gap
42
+ added.push(crosswalk.add({
43
+ type: 'finding-to-callsite', from: f.ref, to: hits.map((h) => h.ref), coarsens: hits.length > 1,
44
+ evidence: { method: 'file-line', grade: hits.length === 1 ? 'location-exact' : 'location-ambiguous', basis: `${normFile(file)}:${line}` },
45
+ unknowns: hits.length > 1 ? [{ kind: 'ambiguous-location', count: hits.length, reason: 'several call sites share this line; the finding names a line, not a call' }] : [],
46
+ }));
47
+ }
48
+ return added;
49
+ }
50
+
51
+ /**
52
+ * Join semantic call sites to provenance witnesses (pedges): same enclosing
53
+ * function and same line. The provenance scope is `<qid>@<line>`; the semantic
54
+ * symbol carries the qid without the line suffix.
55
+ */
56
+ export function mapCallSitesToWitnesses(crosswalk, semantic, provenance) {
57
+ const witnessByKey = new Map();
58
+ for (const w of provenance.entities('pedge')) {
59
+ const k = `${stripLine(w.native.scope)}\u0000${w.native.line}`;
60
+ if (!witnessByKey.has(k)) witnessByKey.set(k, []);
61
+ witnessByKey.get(k).push(w);
62
+ }
63
+ const symQid = new Map(semantic.entities('symbol').map((s) => [s.localId, s.native.qid]));
64
+ const sitesPerKey = new Map();
65
+ const keyOf = (cs) => `${symQid.get(cs.native.caller)}\u0000${cs.native.line}`;
66
+ const sites = semantic.entities('callSite');
67
+ for (const cs of sites) sitesPerKey.set(keyOf(cs), (sitesPerKey.get(keyOf(cs)) ?? 0) + 1);
68
+ const added = [];
69
+ for (const cs of sites) {
70
+ const hits = witnessByKey.get(keyOf(cs)) ?? [];
71
+ if (hits.length === 0) continue;
72
+ const shared = sitesPerKey.get(keyOf(cs));
73
+ added.push(crosswalk.add({
74
+ type: 'callsite-to-witness', from: cs.ref, to: hits.map((h) => h.ref), coarsens: hits.length > 1 || shared > 1,
75
+ evidence: { method: 'scope-line', grade: shared > 1 ? 'location-ambiguous' : 'location-exact', basis: `${symQid.get(cs.native.caller)} line ${cs.native.line}` },
76
+ unknowns: shared > 1 ? [{ kind: 'shared-line', callSites: shared, reason: 'provenance records a line, not a call; these witnesses cannot be pinned to one of the call sites on that line' }] : [],
77
+ }));
78
+ }
79
+ return added;
80
+ }
81
+
82
+ /**
83
+ * Join provenance witnesses to the projected flows that carry their data
84
+ * element. A flow aggregates many witnesses (and many sites), so every mapping
85
+ * is flagged `coarsens`.
86
+ */
87
+ export function mapWitnessesToFlows(crosswalk, provenance, dataflow) {
88
+ const flowsByDe = new Map();
89
+ for (const fl of dataflow.entities('flow')) {
90
+ for (const de of fl.native.dataElementIds || []) {
91
+ if (!flowsByDe.has(de)) flowsByDe.set(de, []);
92
+ flowsByDe.get(de).push(fl);
93
+ }
94
+ }
95
+ const added = [];
96
+ for (const w of provenance.entities('pedge')) {
97
+ const hits = flowsByDe.get(w.native.dataElementId) ?? [];
98
+ if (hits.length === 0) continue;
99
+ added.push(crosswalk.add({
100
+ type: 'witness-to-flow', from: w.ref, to: hits.map((h) => h.ref), coarsens: true,
101
+ evidence: { method: 'data-element', grade: 'identity-shared', basis: w.native.dataElementId },
102
+ unknowns: [{ kind: 'flow-granularity', reason: 'a projected flow aggregates every witness of its data element; the flow does not say which site this witness belongs to' }],
103
+ }));
104
+ }
105
+ return added;
106
+ }
107
+
108
+ const calleeText = (e) => {
109
+ if (!e || typeof e !== 'object') return null;
110
+ if (e.kind === 'ident') return e.name;
111
+ if (e.kind === 'member') { const o = calleeText(e.object); return o ? `${o}.${e.prop ?? '[]'}` : null; }
112
+ return null;
113
+ };
114
+
115
+ /**
116
+ * Map each projected (category-granular) dataflow node to the underlying call
117
+ * sites it stands for. `sites` is the sink-site list `buildDataFlowGraph`
118
+ * returns. A node with several sites maps one-to-many and is flagged `coarsens`;
119
+ * a site that no semantic call site matches is listed under `unknowns`.
120
+ */
121
+ export function mapProjectedNodesToSites(crosswalk, dataflow, semantic, sites) {
122
+ const callSites = semantic.entities('callSite');
123
+ const added = [];
124
+ for (const n of dataflow.entities('node')) {
125
+ const node = n.native;
126
+ const matching = (sites || []).filter((s) => s.decision
127
+ && s.decision.kind === node.kind
128
+ && (s.decision.category ?? null) === (node.subtype ?? null)
129
+ && s.decision.coverageStatus === node.coverageStatus
130
+ && s.decision.externality === node.externality?.value
131
+ && canonicalJson(s.destination ?? null) === canonicalJson(node.destination ?? null));
132
+ if (matching.length === 0) continue;
133
+ const targets = new Map();
134
+ const unmapped = [];
135
+ for (const s of matching) {
136
+ const atLine = callSites.filter((c) => normFile(c.native.file) === normFile(s.file) && c.native.line === s.line);
137
+ const want = calleeText(s.calleeExpr);
138
+ const exact = want ? atLine.filter((c) => c.native.calleeText === want) : [];
139
+ const picked = exact.length ? exact : atLine;
140
+ if (picked.length === 0) unmapped.push({ file: s.file, line: s.line, reason: 'no semantic call site at this location' });
141
+ for (const c of picked) targets.set(c.localId, c);
142
+ }
143
+ const to = [...targets.values()].map((c) => c.ref);
144
+ const unknowns = unmapped.map((u) => ({ kind: 'unmapped-site', ...u }));
145
+ if (to.length === 0) continue;
146
+ added.push(crosswalk.add({
147
+ type: 'node-to-sites', from: n.ref, to, coarsens: to.length > 1, unknowns,
148
+ evidence: { method: 'decision-match', grade: to.length === 1 ? 'projection-exact' : 'projection-coarse', basis: `${node.kind}/${node.subtype ?? 'none'}: ${matching.length} sink site(s)` },
149
+ }));
150
+ }
151
+ return added;
152
+ }
@@ -0,0 +1,146 @@
1
+ //
2
+ // crosswalk.js: evidence-bearing identity mappings between graph layers
3
+ // (DAG-06 R06.2, R06.5).
4
+ //
5
+ // A crosswalk never merges entities. It records "this entity in graph A
6
+ // corresponds to these entities in graph B, by this method, with this
7
+ // evidence". Every mapping is:
8
+ // - versioned (CROSSWALK_VERSION);
9
+ // - explicit about cardinality (`one-to-one`, `one-to-many`, or `none` with a
10
+ // stated reason), so a projection that coarsens many underlying sites into
11
+ // one projected node says so instead of pretending to be one-to-one;
12
+ // - evidence-bearing: method, grade and basis are required;
13
+ // - checked against the registry when one is supplied: a reference to a graph
14
+ // or entity that is not registered is rejected as DANGLING_REF.
15
+ //
16
+ // Mapping types and the graph layers each connects:
17
+ // finding-to-callsite findings -> semantic (location join)
18
+ // callsite-to-witness semantic -> provenance (scope + line join)
19
+ // witness-to-flow provenance -> dataflow (data element join)
20
+ // node-to-sites dataflow node -> semantic (a projected node's underlying call sites)
21
+ // identity same layer -> same layer (the same entity under another reference)
22
+ //
23
+ // `identity` mappings are only legal inside ONE repository. Declaring two
24
+ // entities in different repositories "the same" because their local ids match
25
+ // is exactly the collision this module exists to prevent, and is rejected as
26
+ // CROSS_REPO_ID_COLLISION.
27
+
28
+ import { createHash } from 'node:crypto';
29
+ import { GraphViewError, validateRef, refKey, canonicalJson } from './refs.js';
30
+
31
+ export const CROSSWALK_VERSION = '1.0.0';
32
+
33
+ export const MAPPING_TYPES = Object.freeze({
34
+ 'finding-to-callsite': { from: { graphKind: 'findings', entityKind: 'finding' }, to: { graphKind: 'semantic', entityKind: 'callSite' } },
35
+ 'callsite-to-witness': { from: { graphKind: 'semantic', entityKind: 'callSite' }, to: { graphKind: 'provenance', entityKind: 'pedge' } },
36
+ 'witness-to-flow': { from: { graphKind: 'provenance', entityKind: 'pedge' }, to: { graphKind: 'dataflow', entityKind: 'flow' } },
37
+ 'node-to-sites': { from: { graphKind: 'dataflow', entityKind: 'node' }, to: { graphKind: 'semantic', entityKind: 'callSite' } },
38
+ identity: { from: null, to: null },
39
+ });
40
+
41
+ const CARDINALITY = (n) => (n === 0 ? 'none' : n === 1 ? 'one-to-one' : 'one-to-many');
42
+
43
+ function checkEnd(ref, want, label, type) {
44
+ const bad = validateRef(ref);
45
+ if (bad) throw new GraphViewError('BAD_REF', `${type} ${label}: ${bad}`, { ref });
46
+ if (want && (ref.graphKind !== want.graphKind || ref.entityKind !== want.entityKind)) {
47
+ throw new GraphViewError('BAD_MAPPING', `${type} ${label} must be a ${want.graphKind} ${want.entityKind}, got ${ref.graphKind} ${ref.entityKind}`, { ref });
48
+ }
49
+ }
50
+
51
+ export function createCrosswalk({ registry = null } = {}) {
52
+ const mappings = new Map(); // id -> record
53
+ const forward = new Map(); // refKey -> record[]
54
+ const reverse = new Map(); // refKey -> record[]
55
+ const push = (m, k, r) => { const a = m.get(k); if (a) a.push(r); else m.set(k, [r]); };
56
+
57
+ function add(spec) {
58
+ const type = spec?.type;
59
+ const desc = MAPPING_TYPES[type];
60
+ if (!desc) throw new GraphViewError('BAD_MAPPING', `unknown mapping type ${JSON.stringify(type)}`);
61
+ checkEnd(spec.from, desc.from, 'from', type);
62
+ if (!Array.isArray(spec.to)) throw new GraphViewError('BAD_MAPPING', `${type}: "to" is a list (empty with an unmapped reason when nothing matched)`);
63
+ for (const t of spec.to) checkEnd(t, desc.to, 'to', type);
64
+
65
+ const ev = spec.evidence;
66
+ if (!ev || typeof ev.method !== 'string' || !ev.method || typeof ev.grade !== 'string' || !ev.grade || typeof ev.basis !== 'string' || !ev.basis) {
67
+ throw new GraphViewError('BAD_MAPPING', `${type}: evidence needs { method, grade, basis }`);
68
+ }
69
+ const keys = spec.to.map(refKey);
70
+ if (new Set(keys).size !== keys.length) throw new GraphViewError('BAD_MAPPING', `${type}: duplicate targets in one mapping`);
71
+ if (spec.to.length === 0 && !spec.unmapped) throw new GraphViewError('BAD_MAPPING', `${type}: an empty mapping must say why in "unmapped"`);
72
+
73
+ if (type === 'identity') {
74
+ for (const t of spec.to) {
75
+ if (t.repository !== spec.from.repository) {
76
+ throw new GraphViewError('CROSS_REPO_ID_COLLISION', `identity mapping between repositories ${spec.from.repository} and ${t.repository} refused: matching local ids do not make two entities the same`, { from: spec.from, to: t });
77
+ }
78
+ if (t.entityKind !== spec.from.entityKind || t.graphKind !== spec.from.graphKind) {
79
+ throw new GraphViewError('BAD_MAPPING', 'identity maps an entity to the same kind of entity');
80
+ }
81
+ }
82
+ }
83
+ if (registry) {
84
+ for (const r of [spec.from, ...spec.to]) {
85
+ if (!registry.has(r)) throw new GraphViewError('DANGLING_REF', `${type}: ${r.graphKind} ${r.entityKind} ${r.localId} (repository ${r.repository}) is not in a registered graph`, { ref: r });
86
+ }
87
+ }
88
+
89
+ const id = `xwalk:${createHash('sha256').update(canonicalJson([type, refKey(spec.from), [...keys].sort(), ev.method])).digest('hex').slice(0, 16)}`;
90
+ if (mappings.has(id)) return mappings.get(id);
91
+ const record = Object.freeze({
92
+ id, version: CROSSWALK_VERSION, type, from: spec.from, to: Object.freeze([...spec.to]),
93
+ cardinality: CARDINALITY(spec.to.length),
94
+ coarsens: !!spec.coarsens,
95
+ evidence: Object.freeze({ ...ev }),
96
+ unknowns: Object.freeze([...(spec.unknowns ?? [])]),
97
+ unmapped: spec.unmapped ?? null,
98
+ });
99
+ mappings.set(id, record);
100
+ push(forward, refKey(record.from), record);
101
+ for (const k of keys) push(reverse, k, record);
102
+ return record;
103
+ }
104
+
105
+ const ofType = (list, type) => (type ? list.filter((m) => m.type === type) : list);
106
+
107
+ return {
108
+ version: CROSSWALK_VERSION,
109
+ add,
110
+ all: () => [...mappings.values()],
111
+ /** Mappings that start at `ref`. */
112
+ from: (ref, type) => ofType(forward.get(refKey(ref)) ?? [], type),
113
+ /** Mappings that end at `ref` (the reverse direction; many sources may share a target). */
114
+ to: (ref, type) => ofType(reverse.get(refKey(ref)) ?? [], type),
115
+
116
+ /**
117
+ * Follow a chain of mapping types from `start`. Returns every chain found
118
+ * and, for each step, which references had NO mapping (a gap is reported,
119
+ * never skipped). `evidence` lists every mapping used, so the join is
120
+ * traceable end to end.
121
+ */
122
+ trace(start, types, { maxChains = 1000 } = {}) {
123
+ let chains = [{ refs: [start], mappings: [] }];
124
+ const gaps = [];
125
+ let truncated = false;
126
+ for (const type of types) {
127
+ const next = [];
128
+ for (const c of chains) {
129
+ const last = c.refs[c.refs.length - 1];
130
+ const ms = this.from(last, type);
131
+ if (ms.length === 0) { gaps.push({ type, ref: last }); continue; }
132
+ for (const m of ms) {
133
+ if (m.to.length === 0) { gaps.push({ type, ref: last, unmapped: m.unmapped, mapping: m.id }); continue; }
134
+ for (const t of m.to) {
135
+ if (next.length >= maxChains) { truncated = true; break; }
136
+ next.push({ refs: [...c.refs, t], mappings: [...c.mappings, m.id] });
137
+ }
138
+ }
139
+ }
140
+ chains = next;
141
+ }
142
+ const used = new Set(chains.flatMap((c) => c.mappings));
143
+ return { start, types: [...types], chains, gaps, truncated, evidence: [...used].map((id) => mappings.get(id)) };
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,15 @@
1
+ // Typed graph views and identity crosswalks (DAG-06). See CLAUDE.md in this directory.
2
+ export * from './refs.js';
3
+ export * from './relations.js';
4
+ export { IndexedGraphView, local, foreign, adjacencyCacheStats, clearAdjacencyCache } from './view.js';
5
+ export { provenanceView, PROVENANCE_ADAPTER_VERSION } from './adapters/provenance.js';
6
+ export { dataflowView, DATAFLOW_ADAPTER_VERSION } from './adapters/dataflow.js';
7
+ export { semanticView, SEMANTIC_ADAPTER_VERSION } from './adapters/semantic.js';
8
+ export { artifactView, ARTIFACT_ADAPTER_VERSION } from './adapters/artifact.js';
9
+ export { crossRepoLinkView, findingsView, CROSSREPO_ADAPTER_VERSION, FINDINGS_ADAPTER_VERSION } from './adapters/crossrepo.js';
10
+ export { createGraphRegistry, SUPPORTED_ADAPTER_MAJORS } from './registry.js';
11
+ export { createCrosswalk, CROSSWALK_VERSION, MAPPING_TYPES } from './crosswalk.js';
12
+ export {
13
+ mapFindingsToCallSites, mapCallSitesToWitnesses, mapWitnessesToFlows, mapProjectedNodesToSites,
14
+ } from './crosswalk-builders.js';
15
+ export { attributeObservation } from './observation.js';
@@ -0,0 +1,57 @@
1
+ //
2
+ // observation.js: attributing a runtime observation to the underlying sites of
3
+ // a projected node (DAG-06 acceptance: "a projected node with several
4
+ // underlying sites cannot falsely assign one runtime observation to every
5
+ // site").
6
+ //
7
+ // A runtime observation is matched to a PROJECTED node (a destination, store or
8
+ // queue). When that node stands for several call sites (a `node-to-sites`
9
+ // mapping with more than one target), the observation says nothing about which
10
+ // site produced it unless it carries its own discriminator. The result is:
11
+ //
12
+ // attributed exactly one site is justified: the node has a single site, or
13
+ // the observation names a site (`file` + `line`, or a call-site
14
+ // id) that matches exactly one candidate.
15
+ // ambiguous several candidates remain. `attributedTo` is EMPTY, every
16
+ // candidate is listed, and no site receives the observation.
17
+ // unmatched the observation names a site that is not one of the candidates.
18
+ //
19
+ // "Observed" is therefore a property of the projected node unless a site is
20
+ // positively identified. An observation is never copied onto each site.
21
+
22
+ import { refKey } from './refs.js';
23
+
24
+ /**
25
+ * @param {object} crosswalk
26
+ * @param {object} registry
27
+ * @param {object} nodeRef ref to the projected dataflow node the observation matched
28
+ * @param {{file?:string, line?:number, callSiteId?:string}} [observation]
29
+ */
30
+ export function attributeObservation(crosswalk, registry, nodeRef, observation = {}) {
31
+ const mappings = crosswalk.from(nodeRef, 'node-to-sites');
32
+ const candidates = [];
33
+ const seen = new Set();
34
+ for (const m of mappings) for (const t of m.to) if (!seen.has(refKey(t))) { seen.add(refKey(t)); candidates.push(t); }
35
+ const base = { node: nodeRef, candidates, mappings: mappings.map((m) => m.id) };
36
+
37
+ if (candidates.length === 0) {
38
+ return { ...base, status: 'unmatched', attributedTo: [], reason: 'the projected node has no recorded underlying sites' };
39
+ }
40
+ const normFile = (f) => String(f ?? '').replace(/^\.\//, '');
41
+ const hasDiscriminator = observation.callSiteId || (observation.file && Number.isInteger(observation.line));
42
+ if (hasDiscriminator) {
43
+ const hit = candidates.filter((c) => {
44
+ if (observation.callSiteId) return c.localId === observation.callSiteId;
45
+ const n = registry.node(c).native;
46
+ return normFile(n.file) === normFile(observation.file) && n.line === observation.line;
47
+ });
48
+ if (hit.length === 1) return { ...base, status: 'attributed', attributedTo: hit, basis: 'observation-names-site' };
49
+ if (hit.length === 0) return { ...base, status: 'unmatched', attributedTo: [], reason: 'the observation names a site that is not one of the node\'s underlying sites' };
50
+ return { ...base, status: 'ambiguous', attributedTo: [], candidates: hit, reason: 'several call sites share the named location' };
51
+ }
52
+ if (candidates.length === 1) return { ...base, status: 'attributed', attributedTo: candidates, basis: 'sole-underlying-site' };
53
+ return {
54
+ ...base, status: 'ambiguous', attributedTo: [],
55
+ reason: `the projected node stands for ${candidates.length} sites and the observation does not say which one produced it`,
56
+ };
57
+ }
@@ -0,0 +1,105 @@
1
+ //
2
+ // refs.js: the graph reference format (DAG-06 R06.2).
3
+ //
4
+ // A reference names ONE entity in ONE immutable graph:
5
+ // { v, graphKind, repository, graphDigest, entityKind, localId }
6
+ //
7
+ // The local id is only unique inside its graph, so it is never used alone as
8
+ // a key. The repository identity and the graph digest are part of the
9
+ // reference, which is what keeps two repositories with identical local ids
10
+ // distinct and stops a stale reference from silently resolving against a
11
+ // different version of the graph.
12
+ //
13
+ // Pure functions, no I/O.
14
+
15
+ import { createHash } from 'node:crypto';
16
+
17
+ export const REF_VERSION = 1;
18
+
19
+ /** The graph layers that have an adapter. Every one keeps its own native schema. */
20
+ export const GRAPH_KINDS = Object.freeze([
21
+ 'provenance', // PathStore pnode/pedge DAG
22
+ 'dataflow', // DataFlowGraph v1
23
+ 'semantic', // SemanticGraph v1
24
+ 'artifact', // artifact provenance graph
25
+ 'cross-repo-links', // declared CrossRepoLink records
26
+ 'findings', // scanner findings, as referenceable entities
27
+ ]);
28
+
29
+ export class GraphViewError extends Error {
30
+ constructor(code, message, detail = {}) {
31
+ super(`${code}: ${message}`);
32
+ this.name = 'GraphViewError';
33
+ this.code = code;
34
+ this.detail = detail;
35
+ }
36
+ }
37
+
38
+ const isNonEmpty = (v) => typeof v === 'string' && v.length > 0;
39
+
40
+ /** Deterministic key-sorted JSON, the canonical form every digest here hashes. */
41
+ export function canonicalJson(value) {
42
+ const canon = (v) => {
43
+ if (Array.isArray(v)) return v.map(canon);
44
+ if (v && typeof v === 'object') {
45
+ const o = {};
46
+ for (const k of Object.keys(v).sort()) if (v[k] !== undefined) o[k] = canon(v[k]);
47
+ return o;
48
+ }
49
+ return v;
50
+ };
51
+ return JSON.stringify(canon(value));
52
+ }
53
+
54
+ export function digestOf(value) {
55
+ return createHash('sha256').update(canonicalJson(value)).digest('hex');
56
+ }
57
+
58
+ /**
59
+ * Build a reference object. Throws GraphViewError('BAD_REF') on a missing
60
+ * component: a reference with a blank part is never constructed.
61
+ */
62
+ export function makeRef({ graphKind, repository, graphDigest, entityKind, localId }) {
63
+ const ref = { v: REF_VERSION, graphKind, repository, graphDigest, entityKind, localId };
64
+ const bad = validateRef(ref);
65
+ if (bad) throw new GraphViewError('BAD_REF', bad, { ref });
66
+ return Object.freeze(ref);
67
+ }
68
+
69
+ /** @returns {string|null} a problem description, or null when the reference is well formed */
70
+ export function validateRef(ref) {
71
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return 'a reference is an object';
72
+ if (ref.v !== REF_VERSION) return `unsupported reference version ${JSON.stringify(ref.v)} (supported: ${REF_VERSION})`;
73
+ if (!GRAPH_KINDS.includes(ref.graphKind)) return `unknown graph kind ${JSON.stringify(ref.graphKind)}`;
74
+ for (const f of ['repository', 'graphDigest', 'entityKind', 'localId']) {
75
+ if (!isNonEmpty(ref[f])) return `${f} is required`;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** The identity of the graph a reference points into (no entity part). */
81
+ export function graphIdentityKey(ref) {
82
+ return [ref.graphKind, ref.repository, ref.graphDigest].map(encodeURIComponent).join('|');
83
+ }
84
+
85
+ /** Stable string form; also the map key used by every index. */
86
+ export function refKey(ref) {
87
+ const bad = validateRef(ref);
88
+ if (bad) throw new GraphViewError('BAD_REF', bad, { ref });
89
+ return `gref${ref.v}|${[ref.graphKind, ref.repository, ref.graphDigest, ref.entityKind, ref.localId].map(encodeURIComponent).join('|')}`;
90
+ }
91
+
92
+ export function parseRef(text) {
93
+ if (typeof text !== 'string') throw new GraphViewError('BAD_REF', 'a reference string is required');
94
+ const parts = text.split('|');
95
+ const m = /^gref(\d+)$/.exec(parts[0] || '');
96
+ if (!m || parts.length !== 6) throw new GraphViewError('BAD_REF', 'malformed reference string');
97
+ let decoded;
98
+ try { decoded = parts.slice(1).map(decodeURIComponent); } catch { throw new GraphViewError('BAD_REF', 'malformed escape in reference string'); }
99
+ const [graphKind, repository, graphDigest, entityKind, localId] = decoded;
100
+ return makeRef({ graphKind, repository, graphDigest, entityKind, localId, v: Number(m[1]) });
101
+ }
102
+
103
+ export function sameRef(a, b) {
104
+ return refKey(a) === refKey(b);
105
+ }