@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
package/src/engine.js CHANGED
@@ -183,6 +183,10 @@ import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secre
183
183
  // Phase 1 — precision-engineering posture modules.
184
184
  import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
185
185
  import { backfillFindingDefaults } from './posture/finding-defaults.js';
186
+ import { redirectTargetPinsOriginAt, redirectArgumentPinsOrigin } from './sast/redirect-target.js';
187
+ import { responseReflectsRequestInputAt } from './sast/reflected-input.js';
188
+ import { sendFileConfinedToRootAt } from './sast/send-file-root.js';
189
+ import { insideFunctionBody } from './sast/init-scope.js';
186
190
  import { annotatePocs } from './posture/poc-generator.js';
187
191
  import { annotateExecutionProofs } from './posture/prove-findings.js';
188
192
  import { mineVulnHistory, annotateHistoricalRisk } from './posture/vuln-archaeology.js';
@@ -211,12 +215,15 @@ import { annotateNarration } from './posture/flow-narration.js';
211
215
  import { applyPathConstraints } from './posture/path-predicates.js';
212
216
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
213
217
  import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
218
+ import { openIrCache } from './dataflow/ir-cache.js';
214
219
  import _v8 from 'node:v8';
215
220
  import _vm from 'node:vm';
216
221
  import { createStringMemo } from './util/string-memo.js';
217
222
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
218
223
  import { runDeepAnalysis } from './dataflow/index.js';
219
224
  import { buildLineageGraph } from './lineage/index.js';
225
+ import { buildLineageGraphOwned, resolveLineageDeadlineMs } from './lineage/owned-build.js';
226
+ import { completionOfGraph, summarizeForHealth as summarizeCompletionForHealth } from './lineage/completion.js';
220
227
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
221
228
  import { annotateCloneClusters, findCloneOutliers } from './posture/semantic-clone.js';
222
229
  import { annotateAiProvenance } from './posture/ai-code-fingerprint.js';
@@ -1719,12 +1726,43 @@ export function dropGuardedFindings(findings, fileContents, onDrop = null) {
1719
1726
  const dropped = (kind) => { if (onDrop) onDrop(f, { kind, line, evidence: ctx._guardEvidence || null }); return false; };
1720
1727
  if (cwe === 'CWE-918' && _hasSsrfHostGuard(ctx)) return dropped('ssrf-host-guard');
1721
1728
  if (cwe === 'CWE-22' && _hasPathGuard(ctx)) return dropped('path-containment-guard');
1729
+ if (cwe === 'CWE-22' && sendFileConfinedToRootAt(lines, line) === true) return dropped('send-file-root-confined');
1722
1730
  if (cwe === 'CWE-601' && _hasRedirectGuard(ctx)) return dropped('redirect-target-guard');
1731
+ if (cwe === 'CWE-601' && redirectTargetPinsOriginAt(lines, line) === true) return dropped('redirect-origin-pinned');
1723
1732
  if (isXss && _hasXssOutputEncoding(ctx)) return dropped('xss-output-encoding');
1724
1733
  return true;
1725
1734
  });
1726
1735
  }
1727
1736
 
1737
+ // Identifiers a sink call's argument text actually READS as variables. Two things in the raw text are not
1738
+ // variable reads and used to be counted as such, which let an unrelated source variable of the same name
1739
+ // "reach" the sink: (1) words inside string literals ('Viewing user ' + ...), and (2) property names after
1740
+ // a dot (req.user.name reads `req`, not a local called `user` or `name`). Template substitutions
1741
+ // (`${x}`) stay, since they are code. Spread (`...x`) is a read of x and stays.
1742
+ function _usedIdentifiers(args){
1743
+ const t=String(args);let code='';
1744
+ for(let i=0;i<t.length;i++){
1745
+ const c=t[i];
1746
+ if(c==='"'||c==="'"){const q=c;i++;while(i<t.length&&t[i]!==q){if(t[i]==='\\')i++;i++;}code+=' ';}
1747
+ else if(c==='`'){
1748
+ i++;
1749
+ while(i<t.length&&t[i]!=='`'){
1750
+ if(t[i]==='\\'){i+=2;continue;}
1751
+ if(t[i]==='$'&&t[i+1]==='{'){let d=1;i+=2;code+=' ';while(i<t.length&&d>0){if(t[i]==='{')d++;else if(t[i]==='}'){d--;if(d===0)break;}code+=t[i];i++;}i++;continue;}
1752
+ i++;
1753
+ }
1754
+ code+=' ';
1755
+ } else code+=c;
1756
+ }
1757
+ const out=new Set();
1758
+ const re=/\b[a-zA-Z_]\w*\b/g;let m;
1759
+ while((m=re.exec(code))){
1760
+ let j=m.index-1;while(j>=0&&/\s/.test(code[j]))j--;
1761
+ if(j>=0&&code[j]==='.'&&!(j>0&&code[j-1]==='.'))continue; // property name, not a variable read
1762
+ out.add(m[0]);
1763
+ }
1764
+ return [...out];
1765
+ }
1728
1766
  function _detectSafeSinkShape(vuln, args, ctx){
1729
1767
  // 1.1: Reflected XSS / SSRF / Path Traversal / Open Redirect / Code Injection
1730
1768
  // sinks with empty or whitespace-only args cannot reflect user input — skip
@@ -1764,7 +1802,10 @@ function _detectSafeSinkShape(vuln, args, ctx){
1764
1802
  if(/Open Redirect/.test(vuln)) {
1765
1803
  // Static redirect targets (env var or string literal) are not user-controlled
1766
1804
  if (/^\s*process\.env\b/.test(args)) return 'static-redirect-target';
1767
- if (/^\s*['"`]/.test(args)) return 'static-redirect-target';
1805
+ // A literal that fixes the origin ('/user/' + id) cannot be steered off-origin by the
1806
+ // appended part; a literal that does not ('/' + x, '//' + x, '' + x) can, so the mere
1807
+ // presence of a leading quote is not evidence of a static target.
1808
+ if (/^\s*['"`]/.test(args) && redirectArgumentPinsOrigin(args)) return 'static-redirect-target';
1768
1809
  }
1769
1810
  if(/IDOR/.test(vuln)) {
1770
1811
  if (_IDOR_OWNERSHIP_RE.test(args)) return 'ownership-clause';
@@ -1917,11 +1958,11 @@ function performRegexAnalysis(fp,raw){if(_INTENTIONAL_VULN_PATH_RE.test(fp.repla
1917
1958
  const line=lineAt(cleaned,m.index);const lt=lines[line-1]||"";
1918
1959
  // FP-7: per-pattern surrounding-context gate — required call must appear in nearby lines
1919
1960
  if(sp.contextRe){const surround=lines.slice(Math.max(0,line-5),Math.min(lines.length,line+2)).join("\n");if(!sp.contextRe.test(surround))continue;}
1920
- const af=raw.substring(m.index,Math.min(raw.length,m.index+500));const am=af.match(/\(((?:[^()]|\([^()]*\)){0,400})\)/);const args=am?am[1]:"";const uv=[...new Set((args.match(/\b[a-zA-Z_]\w*\b/g)||[]).filter(v=>!["true","false","null","undefined","const","let","var","function","return","if","else","new","this","async","await","typeof","instanceof","void"].includes(v)&&v.length>1))];const safeShape=_detectSafeSinkShape(sp.vuln,args,{lines,line});sinks.push({type:sp.type,severity:sp.severity,vuln:sp.vuln,cwe:sp.cwe,stride:sp.stride,line,file:fp,snippet:lt.trim(),usedVars:uv,args:args.trim(),safeShape});}}
1961
+ const af=raw.substring(m.index,Math.min(raw.length,m.index+500));const am=af.match(/\(((?:[^()]|\([^()]*\)){0,400})\)/);const args=am?am[1]:"";const uv=_usedIdentifiers(args).filter(v=>!["true","false","null","undefined","const","let","var","function","return","if","else","new","this","async","await","typeof","instanceof","void"].includes(v)&&v.length>1);const safeShape=_detectSafeSinkShape(sp.vuln,args,{lines,line});sinks.push({type:sp.type,severity:sp.severity,vuln:sp.vuln,cwe:sp.cwe,stride:sp.stride,line,file:fp,snippet:lt.trim(),usedVars:uv,args:args.trim(),safeShape});}}
1921
1962
  for(const sp of SANITIZER_PATTERNS){const re=new RegExp(sp.regex.source,sp.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const lt=lines[line-1]||"";const am=lt.match(/(?:const|let|var|)\s*(\w+)\s*=/)||lt.match(/(\w+)\s*=/);sanitizers.push({type:sp.type,line,file:fp,snippet:lt.trim(),outputVar:am?am[1]:null});}}
1922
1963
  const tv=new Map();for(const src of sources)if(src.variable)tv.set(src.variable,{source:src,path:[{type:"source",label:"Input: "+src.label,line:src.line,snippet:src.snippet}],sanitized:false,sanitizerType:null});
1923
1964
  for(let i=0;i<lines.length;i++){const lt=lines[i];const am=lt.match(/\b(?:const|let|var)\s+(\w+)(?:\s*:[^=]+?)?\s*=\s*(.+)/)||lt.match(/(?:const|let|var|)\s*(\w+)\s*=(?!=)\s*(.+)/);if(!am)continue;const[,dv,rhs]=am;if(tv.has(dv))continue;for(const[tn,ti]of tv){if(!new RegExp(`\\b${tn}\\b`).test(rhs))continue;let san=false,st=null;for(const s of sanitizers)if(s.line===i+1){san=true;st=s.type;break;}if(!san)for(const sp of SANITIZER_PATTERNS)if(new RegExp(sp.regex.source,sp.regex.flags).test(rhs)){san=true;st=sp.type;break;}tv.set(dv,{source:ti.source,path:[...ti.path,{type:san?"sanitizer":"propagation",label:san?`${st} on ${dv}`:`Assigned to "${dv}"`,line:i+1,snippet:lt.trim(),sanitized:san,sanitizerType:st}],sanitized:san,sanitizerType:st});break;}}
1924
- for(const sink of sinks){const safeShapeDowngrade=sink.safeShape?{isSan:true,sanType:sink.safeShape}:null;for(const src of sources){const sv=src.variable;let reached=false,pp=[],isSan=false,st=null;if(sv&&sink.usedVars.includes(sv)){const ti=tv.get(sv);if(ti){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;}}if(!reached)for(const uv of sink.usedVars)if(tv.has(uv)){const ti=tv.get(uv);if(ti.source===src||ti.source.label.includes(src.label)){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;break;}}if(!reached&&sv&&sink.line>=src.line&&sink.line-src.line<200&&((sink.args&&new RegExp(`\\b${sv}\\b`).test(sink.args))||lines.slice(Math.max(0,sink.line-10),sink.line+5).some(l=>{const re=new RegExp(`\\b${sv}\\b`);if(!re.test(l))return false;/* skip re-declarations: different binding, not a use */if(new RegExp(`\\b(?:const|let|var|function|def)\\s+${sv}\\b`).test(l))return false;return true;}))){reached=true;pp=[{type:"source",label:"Input: "+src.label,line:src.line,snippet:src.snippet}];
1965
+ for(const sink of sinks){const safeShapeDowngrade=sink.safeShape?{isSan:true,sanType:sink.safeShape}:null;for(const src of sources){const sv=src.variable;let reached=false,pp=[],isSan=false,st=null;if(sv&&sink.usedVars.includes(sv)){const ti=tv.get(sv);if(ti){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;}}if(!reached)for(const uv of sink.usedVars)if(tv.has(uv)){const ti=tv.get(uv);if(ti.source===src||ti.source.label.includes(src.label)){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;break;}}if(!reached&&sv&&sink.line>=src.line&&sink.line-src.line<200&&((sink.args&&sink.usedVars.includes(sv))||lines.slice(Math.max(0,sink.line-10),sink.line+5).some(l=>{if(!_usedIdentifiers(l).includes(sv))return false;/* skip re-declarations: different binding, not a use */if(new RegExp(`\\b(?:const|let|var|function|def)\\s+${sv}\\b`).test(l))return false;return true;}))){reached=true;pp=[{type:"source",label:"Input: "+src.label,line:src.line,snippet:src.snippet}];
1925
1966
  // FP-3: only credit a sanitizer here if it has a captured outputVar AND that var
1926
1967
  // is what reaches the sink. A bare `escape(s);` (return discarded) does NOT count.
1927
1968
  for(const san of sanitizers){
@@ -2053,6 +2094,9 @@ const STRUCTURAL_VULN_PATTERNS=[
2053
2094
  // ── XSS / Output Encoding ─────────────────────────────────────────────────
2054
2095
  {regex:/res\s*\.\s*(?:send|write|end)\s*\([^;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^;)]{0,200}\)/g,
2055
2096
  type:"HTTP Response",vuln:"Reflected XSS (User Input in Response)",severity:"high",cwe:"CWE-79",stride:"Tampering",
2097
+ // The call text mentions `req.`, which also carries session, user and app state. Fire only when the
2098
+ // arguments reflect raw request INPUT (body/query/params/headers/...) that is not inside an HTML escaper.
2099
+ predicate:(_m,ctx)=>{const r=responseReflectsRequestInputAt(ctx.lines,ctx.line);return r===false?{fire:false,reason:'no-raw-request-input'}:{fire:true};},
2056
2100
  fix:"Encode all user-supplied data with escapeHtml() before sending in responses"},
2057
2101
  {regex:/bypassSecurityTrust(?:Html|Script|Style|Url|ResourceUrl)\s*\(/g,
2058
2102
  type:"Angular Trust Bypass",vuln:"Angular DomSanitizer Bypass (XSS)",severity:"critical",cwe:"CWE-79",stride:"Tampering",
@@ -2290,6 +2334,9 @@ const STRUCTURAL_VULN_PATTERNS=[
2290
2334
  // ── #25: Synchronous blocking I/O in server context (DoS) ───────────────────
2291
2335
  {regex:/\b(?:fs\.readFileSync|fs\.writeFileSync|fs\.appendFileSync|fs\.readdirSync|fs\.statSync|fs\.existsSync|crypto\.pbkdf2Sync)\s*\(/g,
2292
2336
  type:"Blocking I/O",vuln:"Synchronous Blocking I/O (DoS Risk in Server Context)",severity:"medium",cwe:"CWE-400",stride:"Denial of Service",appliesTo:["server"],
2337
+ // Only a blocking call made while serving stalls other work. At module/initialisation level (reading a TLS
2338
+ // key or config once at startup) it delays startup once; nothing is waiting on the event loop yet.
2339
+ predicate:(_m,ctx)=>ctx.haystack&&Number.isInteger(ctx.index)&&!insideFunctionBody(ctx.haystack,ctx.index)?{fire:false,reason:'module-init-scope'}:{fire:true},
2293
2340
  fix:"Replace with async equivalents: fs.promises.readFile(), fs.promises.stat(), util.promisify(crypto.pbkdf2)()"},
2294
2341
  // ── #26: Body parser without size limit (DoS — large payload / Billion Laughs) ─
2295
2342
  {regex:/(?:app|router)\s*\.\s*use\s*\(\s*express\s*\.\s*(?:json|urlencoded)\s*\(\s*\)\s*\)/g,
@@ -2522,7 +2569,7 @@ function scanStructuralVulns(fp, raw, _suppOut) {
2522
2569
  // FP-8: per-pattern predicate gate. If `predicate` returns {fire:false},
2523
2570
  // the finding is suppressed (and logged for --include-suppressed).
2524
2571
  if (typeof pat.predicate === 'function') {
2525
- const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise });
2572
+ const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise, haystack, index: m.index });
2526
2573
  if (verdict && !verdict.fire) {
2527
2574
  _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
2528
2575
  continue;
@@ -2844,6 +2891,10 @@ function _pragmaSuppresses(pragma, f){
2844
2891
  // `line` property at all (the line survives only inside the id string, e.g.
2845
2892
  // `struct:app.js:22:Mass_Assignment`), so a false positive from one of those
2846
2893
  // cannot be silenced by a pragma and has to be fixed at the source instead.
2894
+ // UPDATE: `backfillFindingDefaults` now sets `line` from `sink.line` (then `source.line`) when a
2895
+ // detector left it off, so the line-less population is dependency and file-level findings. This
2896
+ // function already read `f.sink?.line`, so the backfill mostly makes the schema's `line` true rather
2897
+ // than newly enabling suppression.
2847
2898
  //
2848
2899
  // A file-scoped fallback was considered and REJECTED. Widening a line pragma to
2849
2900
  // a whole file would silently suppress findings the author never looked at, and
@@ -9464,7 +9515,15 @@ function _deterministicFileTimings(timings) {
9464
9515
  // import) for projects that contain no Java. `runFullScan` is already async,
9465
9516
  // so the await costs nothing structurally.
9466
9517
  const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
9467
- const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
9518
+ // DAG-05: with the cross-scan cache on, an unchanged JavaScript/TypeScript file is read back from the IR cache
9519
+ // instead of parsed again (dataflow/ir-cache.js; the content is the same, only the cost differs).
9520
+ const _buildIR = async () => {
9521
+ let irCache = null;
9522
+ if (process.env.AGENTIC_SECURITY_INCREMENTAL === '1' && scanRoot) {
9523
+ try { irCache = openIrCache(scanRoot); } catch { irCache = null; }
9524
+ }
9525
+ return _hasJava ? await buildProjectIRAsync(fc, { irCache }) : buildProjectIR(fc, { irCache });
9526
+ };
9468
9527
  const _irStatsTarget = irStatsTarget();
9469
9528
  if (_irStatsTarget) {
9470
9529
  try {
@@ -9503,6 +9562,7 @@ function _deterministicFileTimings(timings) {
9503
9562
  let _deepCallGraph = null;
9504
9563
  let _deepFailure = null;
9505
9564
  let _deepIncomplete = null;
9565
+ let _deepIncremental = null; // DAG-05 R05.7: cache provenance of the deep pass, when AGENTIC_SECURITY_INCREMENTAL=1
9506
9566
  if (_deepEnabled) {
9507
9567
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
9508
9568
  const t0 = Date.now();
@@ -9547,6 +9607,10 @@ function _deterministicFileTimings(timings) {
9547
9607
  });
9548
9608
  }
9549
9609
  if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
9610
+ if (irFindings._incrementalStats) {
9611
+ const st = irFindings._incrementalStats;
9612
+ _deepIncremental = { mode: st.mode, reason: st.reason, priorGeneration: st.priorGeneration, provenance: st.provenance, invalidation: st.invalidation, files: st.files, persisted: st.persisted && { committed: st.persisted.committed, error: st.persisted.error }, completion: st.completion };
9613
+ }
9550
9614
  for (const f of irFindings) {
9551
9615
  f.unvalidated = true;
9552
9616
  f.validator_verdict = 'unvalidated';
@@ -9577,6 +9641,21 @@ function _deterministicFileTimings(timings) {
9577
9641
  // the finding-based notice above does not: deep mode never explicitly
9578
9642
  // requested at all (the common default-unset-in-CI case), which previously
9579
9643
  // produced no signal of any kind that assurance had quietly narrowed.
9644
+ // DAG-05: when the cache was asked for, say what it did for this scan. Languages whose analyzers keep
9645
+ // no cross-scan state (Nix: there is no taint IR to key a cache on) are analysed in full every time and named.
9646
+ if (process.env.AGENTIC_SECURITY_INCREMENTAL === '1') {
9647
+ const noHook = Object.keys(fc).some((f) => /\.nix$/i.test(f)) ? ['nix'] : [];
9648
+ if (!_deepIncremental) {
9649
+ _deepIncremental = { mode: 'cold', reason: _deepEnabled ? 'the deep pass recorded no incremental state' : 'deep analysis did not run, so the cross-scan cache is not used' };
9650
+ }
9651
+ if (noHook.length) {
9652
+ _deepIncremental = {
9653
+ ..._deepIncremental, languagesWithoutIncrementalHook: noHook,
9654
+ languages: Object.fromEntries(noHook.map((l) => [l, { incremental: false, mode: 'cold', reason: 'its analysers keep no cross-scan state and run in full on every scan' }])),
9655
+ note: `${noHook.join(', ')} analysers keep no cross-scan state and run in full on every scan`,
9656
+ };
9657
+ }
9658
+ }
9580
9659
  const _deepStatus = {
9581
9660
  requested: _deepRequested,
9582
9661
  enabled: _deepEnabled,
@@ -9590,6 +9669,8 @@ function _deterministicFileTimings(timings) {
9590
9669
  failure: _deepFailure,
9591
9670
  // E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
9592
9671
  ...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
9672
+ // DAG-05 R05.7: which facts were reused from the cache and which were analysed afresh, and why a run was cold.
9673
+ ...(_deepIncremental ? { incremental: _deepIncremental } : {}),
9593
9674
  };
9594
9675
  // Sub-project E, increment 5 — Data Flow Explorer lineage graph.
9595
9676
  // DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
@@ -9621,10 +9702,11 @@ function _deterministicFileTimings(timings) {
9621
9702
  // Reuses the SAME _sharedIR memo _deepEnabled's own block above uses —
9622
9703
  // if deep mode already built it, this is free; if not, this is what
9623
9704
  // first triggers the build (the whole reason this gate is independent).
9624
- const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
9705
+ const _lineageDeadlineMs = resolveLineageDeadlineMs();
9706
+ const { perFile, callGraph } = _lineageDeadlineMs ? {} : (_sharedIR || (_sharedIR = await _buildIR()));
9625
9707
  const _lineageT0 = Date.now();
9626
9708
  setProgress({ phase: 'Lineage graph', current: 0, total: 0, file: `starting (budget ${Math.round(_lineageBudgetMs / 1000)}s)` });
9627
- const _lr = buildLineageGraph(callGraph, {
9709
+ const _lineageOpts = {
9628
9710
  // Live per-function progress from the field-identity driver's own
9629
9711
  // single pass over every function (src/lineage/driver.js).
9630
9712
  onProgress: (p) => setProgress({
@@ -9650,9 +9732,32 @@ function _deterministicFileTimings(timings) {
9650
9732
  deterministic: isDeterministic(),
9651
9733
  perFile,
9652
9734
  fileContents,
9653
- });
9735
+ };
9736
+ // DAG-07 R07.7: with AGENTIC_SECURITY_LINEAGE_DEADLINE_MS set, the build runs in an
9737
+ // owned worker with a watchdog. Unset: the in-process call, exactly as before.
9738
+ let _lr = _lineageDeadlineMs
9739
+ ? await buildLineageGraphOwned(fileContents, { ..._lineageOpts, onProgress: undefined, perFile: undefined, deadlineMs: _lineageDeadlineMs }).done
9740
+ : buildLineageGraph(callGraph, _lineageOpts);
9741
+ // The worker entry is a separate file that the single-file bundle does not ship.
9742
+ // When the worker cannot start, fall back to the in-process build with the
9743
+ // cooperative deadline (no watchdog for a non-polling worker, but the analysis
9744
+ // still stops and reports `cancelled`); never fail the build over it.
9745
+ if (_lineageDeadlineMs && _lr.status === 'failed' && /Cannot find module|ERR_MODULE_NOT_FOUND|owned-worker/i.test(String(_lr.failure || ''))) {
9746
+ // The deadline branch skipped the shared IR (the worker rebuilds its own); build it now.
9747
+ const _ir = _sharedIR || (_sharedIR = await _buildIR());
9748
+ _lr = buildLineageGraph(_ir.callGraph, { ..._lineageOpts, perFile: _ir.perFile, deadline: Date.now() + _lineageDeadlineMs });
9749
+ }
9750
+ if (_lr.terminatedByWatchdog) _lineageStatus.failure = _lr.failure;
9654
9751
  if (_lr.status === 'complete') {
9655
9752
  _lineageGraph = _lr.graph;
9753
+ // DAG-07 R07.2: a graph built from a field analysis that stopped
9754
+ // early must reach scan health, not only the graph body.
9755
+ const _ac = _lr.graph?.coverage?.analysisCompletion;
9756
+ if (_ac && _ac.status !== 'complete') { _lineageStatus.incomplete = _ac.incomplete; _lineageStatus.degraded = _ac.degraded; }
9757
+ // R07.2: the whole envelope (cancelled, construction limits, seeding
9758
+ // limits and zero-flow results included), not only the iteration
9759
+ // and degraded-context lists above.
9760
+ { const _env = completionOfGraph(_lr.graph); if (_env.status !== 'complete') _lineageStatus.completion = summarizeCompletionForHealth(_env); }
9656
9761
  if (_lr.elapsedMs > _lineageBudgetMs) {
9657
9762
  // Same "measured, not truly interrupted" disclosure _deepEnabled's
9658
9763
  // own elapsed > budgetMs branch already makes for IR-taint — see
@@ -0,0 +1,31 @@
1
+ # scanner/src/graph/
2
+
3
+ Typed, read-only graph views and identity crosswalks (DAG-06). A lens over the
4
+ graph layers that already exist; it replaces none of them and changes none of
5
+ their schemas, exports or validators.
6
+
7
+ | Module | Responsibility |
8
+ |---|---|
9
+ | `refs.js` | Reference format `{v, graphKind, repository, graphDigest, entityKind, localId}`, `refKey`/`parseRef`, `GraphViewError` codes, canonical digest |
10
+ | `relations.js` | Relation kinds (data_flow, control_dependence, calls, returns, dependency, declaration, observation, artifact_derivation), edge origins (code, derived, schema, declared, observed), the traversal filter |
11
+ | `view.js` | `IndexedGraphView` (the view contract) and the adjacency index cache keyed by graph kind + digest + adapter version |
12
+ | `adapters/` | `provenance` (PathStore pnode/pedge), `dataflow` (DataFlowGraph v1), `semantic` (SemanticGraph v1), `artifact` (artifact provenance graph), `crossrepo` (CrossRepoLink records, findings) |
13
+ | `registry.js` | Registered views; rejects dangling refs, incompatible adapter versions, same-identity-different-content, ambiguous bare local ids |
14
+ | `crosswalk.js` | Versioned, evidence-bearing mappings; cardinality is explicit; cross-repository `identity` mappings are refused |
15
+ | `crosswalk-builders.js` | finding to call site to witnesses to flow; projected node to underlying sites |
16
+ | `observation.js` | Attributes a runtime observation to a projected node's sites without copying it onto every site |
17
+
18
+ ## Rules
19
+
20
+ - **Read-only, pure, no network.** Adapters never mutate their source graph;
21
+ `reconstruct()` returns the original object. `path-store.js` is duck-typed and
22
+ never imported; no analysis engine is imported anywhere here.
23
+ - **A local id is never a key.** Every index and mapping is keyed by the full
24
+ reference (kind, repository, digest, entity kind, local id).
25
+ - **Two axes.** A query filter chooses relations AND origins. The default
26
+ traverses `code`, `derived` and `schema` edges only; a `declared` cross-repo
27
+ link or an `observed` edge is walked only when the caller asks for that origin.
28
+ - **Coarsening is stated.** A mapping with several targets is `one-to-many`
29
+ with `coarsens` and `unknowns`; nothing is attributed to "every" target.
30
+ - **Versions.** Adapter versions and `CROSSWALK_VERSION` are semver; the registry
31
+ accepts only supported majors.
@@ -0,0 +1,81 @@
1
+ //
2
+ // adapters/artifact.js: read-only view over the artifact provenance graph
3
+ // (artifact/provenance-graph.js: commit -> build -> OCI digest -> SBOM
4
+ // component -> finding, plus an operator-declared deployment).
5
+ //
6
+ // The artifact graph has no repository identity of its own, so the caller names
7
+ // it (`repository`: the image repository or the source repository the artifact
8
+ // was built from). The graph digest is a canonical hash of the graph body.
9
+ //
10
+ // Relations: commit->build, build->digest and digest->deployment are
11
+ // `artifact_derivation`; digest->component and component->finding are
12
+ // `dependency`. An operator-declared deployment edge has origin `declared`; an
13
+ // edge with evidence 'none' (a commit that is not known) is kept, carries an
14
+ // `unknowns` entry, and has grade 'none'.
15
+ //
16
+ // The generator can list the same SBOM component or finding twice (an OS and an
17
+ // application package with one purl). Byte-identical duplicates are collapsed
18
+ // in the view and counted in metadata; duplicates that DIFFER are rejected,
19
+ // because choosing one would be a silent merge. `reconstruct()` returns the
20
+ // original graph, duplicates included.
21
+
22
+ import { digestOf, GraphViewError, canonicalJson } from '../refs.js';
23
+ import { IndexedGraphView, local } from '../view.js';
24
+
25
+ export const ARTIFACT_ADAPTER_VERSION = '1.0.0';
26
+
27
+ function relationFor(fromKind, toKind) {
28
+ if (toKind === 'sbomComponent' || toKind === 'finding') return 'dependency';
29
+ return 'artifact_derivation';
30
+ }
31
+
32
+ export function artifactView(graph, opts) {
33
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
34
+ throw new TypeError('artifactView requires a graph with nodes[] and edges[]');
35
+ }
36
+ const graphDigest = digestOf(graph);
37
+ const byId = new Map();
38
+ let duplicateNodes = 0;
39
+ for (const n of graph.nodes) {
40
+ const prev = byId.get(n.id);
41
+ if (prev) {
42
+ if (canonicalJson(prev) !== canonicalJson(n)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different nodes with id ${n.id}`, { id: n.id });
43
+ duplicateNodes += 1;
44
+ } else byId.set(n.id, n);
45
+ }
46
+ const nodes = [...byId.values()].map((n) => ({ entityKind: n.kind, localId: n.id, native: n, summary: { kind: n.kind } }));
47
+ const seenEdges = new Map();
48
+ let duplicateEdges = 0;
49
+ const edges = [];
50
+ for (const e of graph.edges) {
51
+ const id = `agedge:${e.from}->${e.to}`;
52
+ const prev = seenEdges.get(id);
53
+ if (prev) {
54
+ if (canonicalJson(prev) !== canonicalJson(e)) throw new GraphViewError('DUPLICATE_ID', `artifact graph has two different edges ${e.from} -> ${e.to}`, { id });
55
+ duplicateEdges += 1;
56
+ continue;
57
+ }
58
+ seenEdges.set(id, e);
59
+ const from = byId.get(e.from);
60
+ const to = byId.get(e.to);
61
+ if (!from || !to) throw new GraphViewError('DANGLING_REF', `artifact edge ${e.from} -> ${e.to} points at a node that is not in the graph`, { id });
62
+ const declared = e.evidence === 'operator-declared' || to.kind === 'deployment';
63
+ const none = e.evidence === 'none';
64
+ edges.push({
65
+ id, relation: relationFor(from.kind, to.kind), origin: declared ? 'declared' : 'derived',
66
+ from: local(from.kind, from.id), to: local(to.kind, to.id),
67
+ evidence: { grade: e.evidence ?? 'none', source: 'artifact-provenance-graph' },
68
+ unknowns: none ? [{ kind: 'unlinked', reason: 'no evidence links these two nodes' }] : (e.digestMatches === false ? [{ kind: 'digest-mismatch', reason: 'declared digest differs from the scanned artifact' }] : []),
69
+ attrs: e.digestMatches === undefined ? {} : { digestMatches: e.digestMatches }, native: e,
70
+ });
71
+ }
72
+ const unlinked = edges.filter((e) => e.unknowns.some((u) => u.kind === 'unlinked')).length;
73
+ const reasons = unlinked ? [`unlinked-edges:${unlinked}`] : [];
74
+ return new IndexedGraphView({
75
+ graphKind: 'artifact', adapterVersion: ARTIFACT_ADAPTER_VERSION, repository: opts?.repository, graphDigest,
76
+ metadata: { nodes: graph.nodes.length, edges: graph.edges.length, duplicateNodes, duplicateEdges },
77
+ completion: { status: reasons.length ? 'partial' : 'complete', reasons, coverage: null },
78
+ nodes, edges,
79
+ reconstruct: () => graph,
80
+ });
81
+ }
@@ -0,0 +1,75 @@
1
+ //
2
+ // adapters/crossrepo.js: read-only view over CrossRepoLink records
3
+ // (lineage/cross-repo-link.js), plus a small findings view.
4
+ //
5
+ // A cross-repository link is an operator DECLARATION. In this view it is an
6
+ // edge with origin `declared` and evidence grade `declared`, so the default
7
+ // query filter (code-derived and derived edges only) never walks it as if it
8
+ // were a code-derived data flow, and it can never be mistaken for observed
9
+ // telemetry (R06.4). Its endpoints live in OTHER graphs, so they are foreign
10
+ // references, each carrying that graph's repository and digest. An endpoint
11
+ // that no registered graph can resolve is reported by the registry as
12
+ // unresolved, not dropped.
13
+ //
14
+ // Both endpoints are `dataflow` node references: that is the only graph kind a
15
+ // CrossRepoLink record binds today.
16
+
17
+ import { validateCrossRepoLink } from '../../lineage/cross-repo-link.js';
18
+ import { digestOf, makeRef, GraphViewError } from '../refs.js';
19
+ import { IndexedGraphView, foreign } from '../view.js';
20
+
21
+ export const CROSSREPO_ADAPTER_VERSION = '1.0.0';
22
+ export const FINDINGS_ADAPTER_VERSION = '1.0.0';
23
+
24
+ /**
25
+ * @param {object[]} links CrossRepoLink records
26
+ * @param {{localRepository:string}} opts the repository the `local` side belongs to
27
+ */
28
+ export function crossRepoLinkView(links, opts) {
29
+ if (!Array.isArray(links)) throw new TypeError('crossRepoLinkView requires an array of links');
30
+ const localRepository = opts?.localRepository;
31
+ const nodes = [];
32
+ const edges = [];
33
+ for (const l of links) {
34
+ const v = validateCrossRepoLink(l);
35
+ if (!v.valid) throw new GraphViewError('INVALID_LINK', `cross-repo link ${l?.id ?? '?'} is invalid: ${v.errors[0].path} ${v.errors[0].message}`, { errors: v.errors });
36
+ nodes.push({ entityKind: 'link', localId: l.id, native: l, summary: { declaredBy: l.declaredBy, declaredAt: l.declaredAt } });
37
+ const from = makeRef({ graphKind: 'dataflow', repository: localRepository, graphDigest: l.local.graphDigest, entityKind: 'node', localId: l.local.nodeId });
38
+ const to = makeRef({ graphKind: 'dataflow', repository: l.remote.repository, graphDigest: l.remote.graphDigest, entityKind: 'node', localId: l.remote.nodeId });
39
+ edges.push({
40
+ id: l.id, relation: 'data_flow', origin: 'declared',
41
+ from: foreign(from), to: foreign(to),
42
+ evidence: { grade: 'declared', source: 'operator', declaredBy: l.declaredBy },
43
+ unknowns: [{ kind: 'not-observed', reason: 'a declared link is an assertion, not an observed data flow' }],
44
+ attrs: { rationale: l.rationale ?? null, provenance: l.provenance }, native: l,
45
+ });
46
+ }
47
+ return new IndexedGraphView({
48
+ graphKind: 'cross-repo-links', adapterVersion: CROSSREPO_ADAPTER_VERSION, repository: localRepository, graphDigest: digestOf(links),
49
+ metadata: { links: links.length },
50
+ completion: { status: 'complete', reasons: [], coverage: null },
51
+ nodes, edges, allowForeign: true,
52
+ reconstruct: () => links,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Findings as referenceable entities (no edges): the starting point of a
58
+ * finding -> call site -> witnesses -> flow join.
59
+ * @param {object[]} findings scanner findings; localId is `stableId` when present, else `id`
60
+ */
61
+ export function findingsView(findings, opts) {
62
+ if (!Array.isArray(findings)) throw new TypeError('findingsView requires an array of findings');
63
+ const nodes = findings.map((f) => {
64
+ const id = f.stableId ?? f.id;
65
+ if (typeof id !== 'string' || !id) throw new GraphViewError('BAD_REF', 'every finding needs a stableId or id');
66
+ return { entityKind: 'finding', localId: id, native: f, summary: { file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, severity: f.severity ?? null } };
67
+ });
68
+ return new IndexedGraphView({
69
+ graphKind: 'findings', adapterVersion: FINDINGS_ADAPTER_VERSION, repository: opts?.repository, graphDigest: digestOf(findings),
70
+ metadata: { findings: findings.length },
71
+ completion: { status: 'unknown', reasons: ['a finding list says nothing about what was not scanned'], coverage: null },
72
+ nodes, edges: [],
73
+ reconstruct: () => findings,
74
+ });
75
+ }
@@ -0,0 +1,76 @@
1
+ //
2
+ // adapters/dataflow.js: read-only view over a DataFlowGraph v1 document (the
3
+ // graph-builder output / signed export body).
4
+ //
5
+ // The graph keeps its own schema, validator and digest. This view:
6
+ // - uses the existing `computeGraphDigest` as the graph digest, so the
7
+ // digest in every reference equals the digest the export already signs;
8
+ // - exposes node, edge, flow, dataElement, evidence and transformation
9
+ // entities under their original ids;
10
+ // - maps `edge.provenance` to the origin axis (code -> code, schema ->
11
+ // schema, manual -> declared, runtime -> observed), so a runtime-observed
12
+ // or manually declared edge is never traversed as a code-derived one by a
13
+ // default query;
14
+ // - reports completion from `scanHealth`, `coverage` and `limitations`.
15
+ //
16
+ // Projected nodes are category-granular: one node can stand for many source
17
+ // sites. That coarsening is not hidden here; it is recorded by crosswalks
18
+ // (one-to-many) built in crosswalk-builders.js.
19
+
20
+ import { computeGraphDigest } from '../../lineage/export-json.js';
21
+ import { IndexedGraphView, local } from '../view.js';
22
+
23
+ export const DATAFLOW_ADAPTER_VERSION = '1.0.0';
24
+
25
+ const ORIGIN_OF_PROVENANCE = { code: 'code', schema: 'schema', manual: 'declared', runtime: 'observed' };
26
+
27
+ const ENTITY_SECTIONS = [
28
+ ['node', 'nodes'], ['edge', 'edges'], ['flow', 'flows'],
29
+ ['dataElement', 'dataElements'], ['evidence', 'evidence'], ['transformation', 'transformations'],
30
+ ];
31
+
32
+ /**
33
+ * @param {object} graph DataFlowGraph v1 (e.g. buildDataFlowGraph(...).graph)
34
+ * @param {{repository?:string}} [opts]
35
+ */
36
+ export function dataflowView(graph, opts = {}) {
37
+ if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
38
+ throw new TypeError('dataflowView requires a DataFlowGraph v1 object with nodes[] and edges[]');
39
+ }
40
+ const repository = opts.repository ?? graph.scope?.repository;
41
+ const graphDigest = computeGraphDigest(graph);
42
+
43
+ const nodes = [];
44
+ for (const [kind, section] of ENTITY_SECTIONS) {
45
+ for (const e of graph[section] || []) {
46
+ nodes.push({ entityKind: kind, localId: e.id, native: e, summary: kind === 'node' ? { kind: e.kind, subtype: e.subtype ?? null, label: e.label ?? null, coverageStatus: e.coverageStatus ?? null } : null });
47
+ }
48
+ }
49
+ const edges = graph.edges.map((e) => {
50
+ const unknowns = [];
51
+ if (e.protocol?.destinationResolution && e.protocol.destinationResolution !== 'literal') unknowns.push({ kind: 'destination-resolution', reason: e.protocol.destinationResolution });
52
+ if (e.coverageStatus && e.coverageStatus !== 'modeled') unknowns.push({ kind: 'coverage', reason: e.coverageStatus });
53
+ return {
54
+ id: e.id, relation: 'data_flow', origin: ORIGIN_OF_PROVENANCE[e.provenance] ?? 'schema',
55
+ from: local('node', e.from), to: local('node', e.to),
56
+ evidence: { grade: e.evidenceGrade ?? e.coverageStatus ?? 'none', source: 'dataflow-graph', evidenceRefs: e.evidenceRefs ?? [] },
57
+ unknowns, attrs: { provenance: e.provenance, relationship: e.relationship }, native: e,
58
+ };
59
+ });
60
+
61
+ const health = graph.scanHealth || {};
62
+ const reasons = [];
63
+ const status = health.status === 'complete' ? 'complete' : health.status ? 'partial' : 'unknown';
64
+ if (health.status && health.status !== 'complete') reasons.push(`scan-health:${health.status}${health.reason ? `:${health.reason}` : ''}`);
65
+ if (graph.coverage?.pathBudgetTruncation) reasons.push('path-budget-truncation');
66
+ if (health.analysisCompletion && health.analysisCompletion.status && health.analysisCompletion.status !== 'complete') reasons.push(`analysis-${health.analysisCompletion.status}`);
67
+ if ((graph.limitations || []).length) reasons.push(`limitations:${graph.limitations.length}`);
68
+
69
+ return new IndexedGraphView({
70
+ graphKind: 'dataflow', adapterVersion: DATAFLOW_ADAPTER_VERSION, repository, graphDigest,
71
+ metadata: { schemaVersion: graph.schemaVersion ?? null, graphId: graph.graphId ?? null, scope: graph.scope ?? null, counts: Object.fromEntries(ENTITY_SECTIONS.map(([k, s]) => [k, (graph[s] || []).length])) },
72
+ completion: { status: reasons.length && status === 'complete' ? 'partial' : status, reasons, coverage: graph.coverage ?? null },
73
+ nodes, edges,
74
+ reconstruct: () => graph,
75
+ });
76
+ }
@@ -0,0 +1,92 @@
1
+ //
2
+ // adapters/provenance.js: read-only view over a PathStore provenance DAG
3
+ // (pnode / pedge). Duck-typed on the store's read API (`nodes()`, `edges()`,
4
+ // `limitStatus()`, `diagnostics()`); it does not import path-store.js or any
5
+ // analysis engine.
6
+ //
7
+ // Entities: `pnode` (a point in the DAG) and `pedge` (one witness: a join of an
8
+ // in-half and an out-half at one program site). A pedge is BOTH an entity, so a
9
+ // crosswalk can name it as a witness, and a relation edge between two pnodes.
10
+ //
11
+ // Relation choice, from the store's own fields (nothing is guessed):
12
+ // widenReason 'control-dependence' -> control_dependence
13
+ // target path '<return>' -> returns
14
+ // crossScope (argument into a callee) -> calls
15
+ // otherwise -> data_flow
16
+ // Evidence grade reuses the flow-grade vocabulary: severed (loss reason),
17
+ // ambiguous (correlation ambiguity), widened, implicit, else explicit.
18
+
19
+ import { digestOf } from '../refs.js';
20
+ import { IndexedGraphView, local } from '../view.js';
21
+
22
+ export const PROVENANCE_ADAPTER_VERSION = '1.0.0';
23
+
24
+ function relationOf(e, toNode) {
25
+ if ((e.widenReasons || []).includes('control-dependence')) return 'control_dependence';
26
+ if (toNode && toNode.path === '<return>') return 'returns';
27
+ if (e.crossScope) return 'calls';
28
+ return 'data_flow';
29
+ }
30
+
31
+ function gradeOf(e) {
32
+ if ((e.lossReasons || []).length) return 'severed';
33
+ if (e.ambiguousCorrelation) return 'ambiguous';
34
+ if ((e.widenReasons || []).includes('control-dependence')) return 'implicit';
35
+ if ((e.widenReasons || []).length) return 'widened';
36
+ return 'explicit';
37
+ }
38
+
39
+ /**
40
+ * @param {{nodes:()=>object[], edges:()=>object[], limitStatus?:()=>object, diagnostics?:()=>object}} store
41
+ * @param {{repository:string, completion?:object}} opts
42
+ * `completion` may carry the driver's analysisCompletion record; it is
43
+ * folded into the view's completion() so an unfinished analysis never
44
+ * reads as complete.
45
+ */
46
+ export function provenanceView(store, opts) {
47
+ if (!store || typeof store.nodes !== 'function' || typeof store.edges !== 'function') {
48
+ throw new TypeError('provenanceView requires a store with nodes() and edges()');
49
+ }
50
+ const repository = opts?.repository;
51
+ const pnodes = store.nodes();
52
+ const pedges = store.edges();
53
+ const byId = new Map(pnodes.map((n) => [n.id, n]));
54
+ const graphDigest = digestOf({ nodes: pnodes, edges: pedges });
55
+
56
+ const nodes = [
57
+ ...pnodes.map((n) => ({ entityKind: 'pnode', localId: n.id, native: n, summary: { kind: n.kind, scope: n.scope, path: n.path, truncated: !!n.truncated } })),
58
+ ...pedges.map((e) => ({ entityKind: 'pedge', localId: e.id, native: e, summary: { scope: e.scope, line: e.line ?? null, siteNodeId: e.siteNodeId ?? null, dataElementId: e.dataElementId } })),
59
+ ];
60
+ const edges = pedges.map((e) => {
61
+ const unknowns = [
62
+ ...(e.widenReasons || []).map((r) => ({ kind: 'widen', reason: r })),
63
+ ...(e.lossReasons || []).map((r) => ({ kind: 'loss', reason: r })),
64
+ ];
65
+ if (e.truncated) unknowns.push({ kind: 'truncated', reason: 'analysis-incomplete' });
66
+ return {
67
+ id: e.id, relation: relationOf(e, byId.get(e.toNodeId)), origin: 'code',
68
+ from: local('pnode', e.fromNodeId), to: local('pnode', e.toNodeId),
69
+ evidence: { grade: gradeOf(e), source: 'path-store' },
70
+ unknowns, attrs: { line: e.line ?? null, scope: e.scope, dataElementId: e.dataElementId }, native: e,
71
+ };
72
+ });
73
+
74
+ const reasons = [];
75
+ const lim = typeof store.limitStatus === 'function' ? store.limitStatus() : null;
76
+ if (lim?.hit) reasons.push('store-limit-hit');
77
+ const diag = typeof store.diagnostics === 'function' ? store.diagnostics() : null;
78
+ if (diag?.truncations?.length) reasons.push(`truncated-units:${diag.truncations.length}`);
79
+ if (diag?.orphanedPeerSources?.length) reasons.push(`orphaned-peer-sources:${diag.orphanedPeerSources.length}`);
80
+ if (diag?.malformed?.length) reasons.push(`malformed-hops:${diag.malformed.length}`);
81
+ const ac = opts?.completion ?? null;
82
+ if (ac && ac.status && ac.status !== 'complete') reasons.push(`analysis-${ac.status}`);
83
+ const status = reasons.length ? 'partial' : (lim || ac) ? 'complete' : 'unknown';
84
+
85
+ return new IndexedGraphView({
86
+ graphKind: 'provenance', adapterVersion: PROVENANCE_ADAPTER_VERSION, repository, graphDigest,
87
+ metadata: { source: 'PathStore', pnodes: pnodes.length, pedges: pedges.length, relationEdges: edges.length },
88
+ completion: { status, reasons, coverage: { limits: lim, analysis: ac } },
89
+ nodes, edges,
90
+ reconstruct: () => ({ nodes: pnodes, edges: pedges }),
91
+ });
92
+ }