@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +17 -2
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +125 -14
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/pipeline.js +34 -1
package/src/engine.js CHANGED
@@ -208,6 +208,8 @@ import { annotateNarration } from './posture/flow-narration.js';
208
208
  import { applyPathConstraints } from './posture/path-predicates.js';
209
209
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
210
210
  import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
211
+ import _v8 from 'node:v8';
212
+ import _vm from 'node:vm';
211
213
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
212
214
  import { runDeepAnalysis } from './dataflow/index.js';
213
215
  import { buildLineageGraph } from './lineage/index.js';
@@ -301,6 +303,15 @@ import {
301
303
  computeGlobalKey, globalKeyMeta, invalidatedFiles, bundleShaForRunKey,
302
304
  } from './posture/scan-checkpoint.js';
303
305
  import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
306
+ import { isLanguageSource, isLanguageExcludedPath, withLanguageClosure } from './language/discovery.js';
307
+ import { annotateHaskellFindings } from './language/haskell-findings.js';
308
+ import { annotateHaskellLlm, analyzeHaskellLlmRules } from './language/haskell-llm.js';
309
+ import { languagePragmaOnLine as _languagePragmaOnLine, languagePragmaSuppresses as _languagePragmaSuppresses } from './language/pragma.js';
310
+ import { applyHaskellGuards, consolidateFlowSinks } from './language/haskell-guards.js';
311
+ import { annotateUnresolvedTargets, attachEvidenceChains } from './language/haskell-disclosure.js';
312
+ import { analyzeHaskellRules } from './language/haskell-security-rules.js';
313
+ import { scanLanguageSecretConcat, scanDependencyUrlCredentials, providerInfo as _secretProvider } from './language/secrets.js';
314
+ import { analyzeHaskellWeb, toEngineRoutes } from './language/haskell-web.js';
304
315
  import { effectiveVersion as _effectiveRulesetVersion } from './posture/ruleset-version.js';
305
316
 
306
317
  // Disk-backed cache replacing browser sessionStorage. One JSON blob per key under ~/.claude/agentic-security/osv-cache/.
@@ -818,7 +829,9 @@ function shouldScan(p){if(/\.(test|spec|mock)\./i.test(p))return false;if(/_test
818
829
  if (/(?:^|[\\/])Info\.plist$/i.test(p)) return true;
819
830
  if (/(?:^|[\\/])module\.json5$/i.test(p)) return true;
820
831
  if (/^\.env(?:\.[\w-]+)?$/.test(base)) return true;
821
- return CODE_EXTS.has(getExt(p)) || _isIaCFile(p);}
832
+ // Haskell/Nix sources come from the shared registry; build output and store
833
+ // paths stay out. Scoped to those sources so no other language's semantics move.
834
+ return CODE_EXTS.has(getExt(p)) || _isIaCFile(p) || (isLanguageSource(p) && !isLanguageExcludedPath(p));}
822
835
  function lineAt(c,i){return c.substring(0,i).split("\n").length;}
823
836
 
824
837
 
@@ -2776,8 +2789,19 @@ function _applyIgnorePragmas(arr, fc){
2776
2789
  const file = f && (f.file || f.sink?.file);
2777
2790
  const line = f && Number(f.line ?? f.sink?.line);
2778
2791
  if (!file || !Number.isInteger(line)) continue;
2779
- const pragma = _pragmaOnLine(fc[file], line);
2780
- if (!_pragmaSuppresses(pragma, f)) continue;
2792
+ // Haskell and Nix pragmas are recognised only inside real comments (never in a string or an operator) and match a
2793
+ // rule id EXACTLY; every other language keeps the line-regex pragma below.
2794
+ let pragma; let suppresses;
2795
+ if (/\.(?:l?hs|hs-boot|hsc|nix)$/i.test(file)) {
2796
+ const lp = _languagePragmaOnLine(file, fc[file], line);
2797
+ if (lp && lp.malformed && !lp.bare && !lp.rules.length) { f.ignorePragmaIgnored = `malformed agentic-security-ignore: ${lp.malformed}`; continue; }
2798
+ suppresses = _languagePragmaSuppresses(lp, f);
2799
+ pragma = lp ? { rule: lp.bare ? '' : lp.rules.join(', ') } : null;
2800
+ } else {
2801
+ pragma = _pragmaOnLine(fc[file], line);
2802
+ suppresses = _pragmaSuppresses(pragma, f);
2803
+ }
2804
+ if (!suppresses) continue;
2781
2805
  _suppressionLog.push({
2782
2806
  vuln: f.vuln, file, line, snippet: f.snippet || '',
2783
2807
  reason: `inline pragma: agentic-security-ignore${pragma.rule ? ': ' + pragma.rule : ''}`,
@@ -6534,7 +6558,9 @@ function dedupeFindingsWithEvidence(findings){
6534
6558
  // Multiple sources reaching the same sink collapse — the merged finding
6535
6559
  // accumulates them in `evidence` and `dedupedSources` instead.
6536
6560
  const sinkLine = f.sink?.line || f.line || 0;
6537
- const key=`${file}:${sinkLine}:${fam}`;
6561
+ // Haskell taint findings carry a site key: two distinct sinks of one family on one line stay two findings,
6562
+ // so a discharged (sanitized) sink can never absorb its unprotected sibling.
6563
+ const key=`${file}:${sinkLine}:${fam}${f.siteKey?':'+f.siteKey:''}`;
6538
6564
  if(!buckets.has(key)){buckets.set(key,f);continue;}
6539
6565
  const kept=buckets.get(key);
6540
6566
  // Winner selection: an IR-TAINT finding (the deep engine's real
@@ -7237,7 +7263,7 @@ function scanCredentials(fp,raw){
7237
7263
  // unredacted-snippet leak as scanEntropySecrets — `snippet` carried
7238
7264
  // the raw source line (full credential value) straight through to
7239
7265
  // every report format. Redact the exact matched value here too.
7240
- results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
7266
+ results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,snippet:snippet.split(val).join(masked),masked,..._secretProvider(val),fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
7241
7267
  }
7242
7268
  }
7243
7269
  // One secret, one finding. A `postgres://user:pass@host/db` matches both the
@@ -8692,6 +8718,7 @@ async function queryRegistries(components){
8692
8718
  _aF.push(...(runDetector(_detectorErrors,p,'scanPythonStructural',()=>scanPythonStructural(p,cc))||[]));
8693
8719
  _aF.push(...(runDetector(_detectorErrors,p,'scanGoStructural',()=>scanGoStructural(p,cc))||[]));
8694
8720
  _aF.push(...(runDetector(_detectorErrors,p,'scanSecretConcat',()=>scanSecretConcat(p,cc))||[]));
8721
+ _aF.push(...(runDetector(_detectorErrors,p,'scanLanguageSecretConcat',()=>scanLanguageSecretConcat(p,c))||[]));
8695
8722
  _aF.push(...(runDetector(_detectorErrors,p,'scanXssReflectedMultilang',()=>scanXssReflectedMultilang(p,cc))||[]));
8696
8723
  _aF.push(...(runDetector(_detectorErrors,p,'scanCodeInjectionMultilang',()=>scanCodeInjectionMultilang(p,cc))||[]));
8697
8724
  _aF.push(...(runDetector(_detectorErrors,p,'scanResponseSplitting',()=>scanResponseSplitting(p,cc))||[]));
@@ -8742,7 +8769,14 @@ async function queryRegistries(components){
8742
8769
  // because a direct runFullScan caller supplying no file-subsetting options is
8743
8770
  // scanning everything it was given; runScan.js narrows it for --changed-since
8744
8771
  // and for caller-supplied fileContents.
8745
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8772
+ let _gcFn = null;
8773
+ function _gcHint(minHeapMiB) {
8774
+ if (process.memoryUsage().heapUsed < minHeapMiB * 1048576) return;
8775
+ if (!_gcFn) { _v8.setFlagsFromString('--expose_gc'); _gcFn = _vm.runInNewContext('gc'); }
8776
+ _gcFn();
8777
+ }
8778
+
8779
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8746
8780
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8747
8781
  // analysis can recognize calls to user-input-returning helper methods
8748
8782
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -8794,7 +8828,9 @@ function _deterministicFileTimings(timings) {
8794
8828
  _ckpt = openCheckpoint(scanRoot, {
8795
8829
  globalKey: computeGlobalKey(_ckptIdentity),
8796
8830
  meta: globalKeyMeta(_ckptIdentity),
8797
- fileContents,
8831
+ // Haskell/Nix sources hash with their import closure, so an imported
8832
+ // module's change invalidates its importers' checkpointed work too.
8833
+ fileContents: withLanguageClosure(fileContents, depFileContents),
8798
8834
  });
8799
8835
  for (const r of resumeFindings(_ckpt)) { if (r && r.findings) _ckptPayloads.set(r.file, r.findings); }
8800
8836
  _ckptDone = completedFiles(_ckpt);
@@ -9190,6 +9226,9 @@ function _deterministicFileTimings(timings) {
9190
9226
  }
9191
9227
  }
9192
9228
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
9229
+ // X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
9230
+ // component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
9231
+ let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
9193
9232
  aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9194
9233
  aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
9195
9234
  // SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
@@ -9291,10 +9330,17 @@ function _deterministicFileTimings(timings) {
9291
9330
  // believing it enables deep mode — it never did, so those tests were
9292
9331
  // exercising whatever coincidentally fires without the deep engine, not
9293
9332
  // the interprocedural machinery they're named for.
9294
- const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
9333
+ // Haskell has NO analysis outside the deep (IR) layer: its taint findings, sink checks and guard refutations all
9334
+ // live there. So, unlike the other languages, a project with Haskell sources runs deep mode by default, in CI too,
9335
+ // unless it is explicitly switched off (AGENTIC_SECURITY_DEEP=0 / --no-deep / deep:false). An explicit off is not
9336
+ // silent: the missing analyzer is a scan-health condition (see language/assurance.js) and strict assurance fails.
9337
+ const _hasHaskellSource = Object.keys(fc).some((f) => /\.l?hs$/i.test(f));
9338
+ const _deepExplicitlyOff = deep === false || process.env.AGENTIC_SECURITY_DEEP === '0';
9339
+ const _deepForHaskell = _hasHaskellSource && !_deepExplicitlyOff;
9340
+ const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1' || _deepForHaskell;
9295
9341
  const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
9296
9342
  process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
9297
- const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
9343
+ const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1' || _deepForHaskell;
9298
9344
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
9299
9345
  let _deepCallGraph = null;
9300
9346
  let _deepFailure = null;
@@ -9343,6 +9389,7 @@ function _deterministicFileTimings(timings) {
9343
9389
  f.unvalidated = true;
9344
9390
  f.validator_verdict = 'unvalidated';
9345
9391
  }
9392
+ try { applyHaskellGuards(irFindings, perFile); consolidateFlowSinks(irFindings, perFile); annotateUnresolvedTargets(irFindings, perFile); attachEvidenceChains(irFindings, perFile); annotateHaskellFindings(irFindings, fc); annotateHaskellLlm(irFindings, fc); } catch (_) { /* metadata only; the finding stands without it */ }
9346
9393
  aF.push(...irFindings);
9347
9394
  } catch (e) {
9348
9395
  // Deep mode is best-effort. A parser blowup in one file shouldn't kill
@@ -9506,6 +9553,13 @@ function _deterministicFileTimings(timings) {
9506
9553
  }
9507
9554
  } catch { /* Java SCA enrichment is best-effort */ }
9508
9555
  }
9556
+ // Haskell crypto / randomness / resource / XML / sensitive-logging rules (project-level: imports resolve across modules).
9557
+ try{aF.push(...analyzeHaskellRules(fc).findings);}catch(_){}
9558
+ try{aF.push(...analyzeHaskellLlmRules(fc));}catch(_){}
9559
+ try{const{analyzeLanguageFindings}=await import('./language/engine-pass.js');aF.push(...analyzeLanguageFindings(allFileContents).findings);}catch(_){}
9560
+ let _languageBridges=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$/i.test(f))){const{analyzePolyglotBridges}=await import('./language/bridges.js');_languageBridges=analyzePolyglotBridges(allFileContents);}}catch(_){}
9561
+ try{for(const[_p,_t]of Object.entries(allFileContents))if(typeof _t==='string')aF.push(...scanDependencyUrlCredentials(_p,_t));}catch(_){}
9562
+ try{const _hw=analyzeHaskellWeb(fc);aF.push(..._hw.findings);aR.push(...toEngineRoutes(_hw));}catch(_){}
9509
9563
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
9510
9564
  // Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
9511
9565
  // finding that exists BY THIS POINT — the pattern detectors, the cross-file
@@ -9708,6 +9762,10 @@ function _deterministicFileTimings(timings) {
9708
9762
  // reports fewer steps than the total and the progress bar does not
9709
9763
  // necessarily reach it — that's the same "stale total, real motion" shape
9710
9764
  // the pre-existing "Linking" phase already accepts.
9765
+ // A large scan has just finished its heaviest allocation phase (IR, taint). Ask V8 for a collection before the annotators start so
9766
+ // the transient garbage of that phase is not still resident when the annotation phase peaks (the V8 heuristics would otherwise let
9767
+ // the process grow first). Only when the heap is already large: a small scan never pays for it.
9768
+ try { _gcHint(400); } catch (_) { /* a hint, never a requirement */ }
9711
9769
  const _ANNOTATOR_TOTAL_ESTIMATE = 52;
9712
9770
  let _annotatorIdx = 0;
9713
9771
  const _runAnnotator = (phase, fn) => {
@@ -10207,6 +10265,8 @@ function _deterministicFileTimings(timings) {
10207
10265
  // aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
10208
10266
  // above, so pushing into aF here silently discards every result.
10209
10267
  try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
10268
+ // Nix fetch/build/cache trust (static): supply-chain entries, so they live in this bucket.
10269
+ try{const{analyzeLanguageSupplyChain}=await import('./language/engine-pass.js');supplyChain.push(...analyzeLanguageSupplyChain(allFileContents,{scanRoot}).supplyChain);}catch(_){}
10210
10270
  // Deployment-platform security checklist
10211
10271
  try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
10212
10272
  // Stack-specific security playbook
@@ -10422,7 +10482,7 @@ function _deterministicFileTimings(timings) {
10422
10482
  // SBOM diff — drift detection across releases.
10423
10483
  if (process.env.AGENTIC_SECURITY_NO_SBOM_DIFF !== '1') {
10424
10484
  try {
10425
- _sbomDiff = runSbomDiff(scanRoot, annotatedComponents || []);
10485
+ _sbomDiff = runSbomDiff(scanRoot, [...(annotatedComponents || []), ...((_languageBom && _languageBom.components) || [])]);
10426
10486
  if (_sbomDiff && Array.isArray(_sbomDiff.findings)) finalFindings.push(..._sbomDiff.findings);
10427
10487
  } catch (_) {}
10428
10488
  }
@@ -10459,14 +10519,18 @@ function _deterministicFileTimings(timings) {
10459
10519
  if (process.env.AGENTIC_SECURITY_NO_LICENSE_GRAPH !== '1') {
10460
10520
  try {
10461
10521
  const lgPolicy = loadLicenseGraphPolicy(scanRoot);
10462
- _licenseGraph = analyzeLicenseGraph(annotatedComponents || [], lgPolicy);
10522
+ // A Haskell or Nix component carries no license data of its own, and "no license" is not evidence of a licensing problem:
10523
+ // judging it would add a review finding per dependency for every project. They are left out and the gap is disclosed
10524
+ // (language/assurance.js `licenseUnavailable`) rather than turned into noise.
10525
+ const _langLic = ((_languageBom && _languageBom.components) || []).filter((c) => c && (c.license || (Array.isArray(c.licenses) && c.licenses.length)));
10526
+ _licenseGraph = analyzeLicenseGraph([...(annotatedComponents || []), ..._langLic], lgPolicy);
10463
10527
  if (_licenseGraph && Array.isArray(_licenseGraph.findings)) finalFindings.push(..._licenseGraph.findings);
10464
10528
  } catch (_) {}
10465
10529
  }
10466
10530
  // Attributions: emit ATTRIBUTIONS.md (and NOTICE if Apache deps present).
10467
10531
  if (process.env.AGENTIC_SECURITY_NO_ATTRIBUTIONS !== '1') {
10468
10532
  try {
10469
- _attributions = generateAttributions(annotatedComponents || []);
10533
+ _attributions = generateAttributions([...(annotatedComponents || []), ...((_languageBom && _languageBom.components) || [])]);
10470
10534
  if (_attributions && _attributions.componentCount) persistAttributions(scanRoot, _attributions);
10471
10535
  } catch (_) {}
10472
10536
  }
@@ -10477,7 +10541,7 @@ function _deterministicFileTimings(timings) {
10477
10541
  // .json 03.04.10, nist-ai-600-1.json MG-4.1-001) could never clear.
10478
10542
  if (process.env.AGENTIC_SECURITY_NO_AIBOM !== '1') {
10479
10543
  try {
10480
- const _aibom = buildAIBOM({ components: annotatedComponents || [] }, fc, {});
10544
+ const _aibom = buildAIBOM({ components: annotatedComponents || [] }, fc, { manifests: depFileContents });
10481
10545
  if (_aibom && (_aibom.models.length || _aibom.promptTemplates.length || _aibom.frameworks.length)) {
10482
10546
  persistAIBOM(scanRoot, _aibom);
10483
10547
  }
@@ -10728,6 +10792,14 @@ function _deterministicFileTimings(timings) {
10728
10792
  // exists on dependency COMPONENTS but was never carried onto the
10729
10793
  // vulnerable_dep entries, so the negation was true for every entry and the
10730
10794
  // filter excluded nothing. `isDirect` is now propagated at materialization.
10795
+ // Hackage entries come from a declared dependency; the VERSION may be decided by a freeze/lock/extra-deps file, and
10796
+ // that file is where the version transition (the origin) is recorded, so it is the manifest provenance reads.
10797
+ for (const s of (supplyChain || [])) {
10798
+ if (!s || s.type !== 'vulnerable_dep' || s.ecosystem !== 'hackage') continue;
10799
+ if (s.isDirect === undefined) s.isDirect = true;
10800
+ if (!s.fixedVersions && Array.isArray(s.fixedIn)) s.fixedVersions = s.fixedIn;
10801
+ if (!s.filePath) s.filePath = (s.versionSource && s.versionSource.file) || s.file;
10802
+ }
10731
10803
  const directDeps = (supplyChain || []).filter((s) => s && s.type === 'vulnerable_dep' && s.isDirect);
10732
10804
  // `resolveDirectSCAOrigin`/`scaStableId` key on `filePath`; the vulnerable_dep
10733
10805
  // entries built above carry the manifest path as `file` (report/index.js
@@ -10923,6 +10995,18 @@ function _deterministicFileTimings(timings) {
10923
10995
  // (_rootCauseSweep/_proofCoverage/_coverageLedger/_scanHealth are hoisted
10924
10996
  // above the `skipAnnotators` guard — FR-PROV-029.)
10925
10997
  try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
10998
+ // X-008: Haskell siblings are found on the IR by resolved callee (the generic sweep keys on a call-text shape that
10999
+ // Haskell never produces). Merged into the same structure so found === candidates + mitigated still holds overall.
11000
+ try {
11001
+ if (finalFindings.some(f => f && f.parser === 'IR-TAINT' && /\.l?hs$/i.test(f.file || ''))) {
11002
+ const { buildProjectIR } = await import('./ir/index.js');
11003
+ const { sweepHaskellSiblings } = await import('./language/haskell-sweep.js');
11004
+ const hsFiles = Object.fromEntries(Object.entries(fc).filter(([k]) => /\.l?hs$/i.test(k)));
11005
+ const hs = sweepHaskellSiblings(finalFindings, buildProjectIR(hsFiles).callGraph);
11006
+ const base = _rootCauseSweep || { sweeps: [], totals: { found: 0, candidates: 0, mitigated: 0 } };
11007
+ _rootCauseSweep = { ...base, sweeps: [...base.sweeps, ...hs.sweeps.map(x => ({ ...x, language: 'haskell' }))], totals: { found: base.totals.found + hs.totals.found, candidates: base.totals.candidates + hs.totals.candidates, mitigated: base.totals.mitigated + hs.totals.mitigated } };
11008
+ }
11009
+ } catch { /* the Haskell sweep is additive; the generic sweep result stands */ }
10926
11010
  // PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
10927
11011
  // computed over the provable subset makes a narrow subset look like strength;
10928
11012
  // the three-bucket split (provable / declined-on-purpose / not-yet-classified)
@@ -10938,6 +11022,31 @@ function _deterministicFileTimings(timings) {
10938
11022
  // summary, computed from signals the engine already collects.
10939
11023
  // `analyzers` was `null` (see pipeline/scan-health.js's prior comment)
10940
11024
  // until FR-203's coverage ledger existed to compute it for real.
11025
+ // X-007: Haskell/Nix assurance (parse outcomes, manifests, advisory currency, required capabilities, optional
11026
+ // modes) feeds the SAME scan-health input every other analyzer uses, so advisory/standard/strict and the
11027
+ // deploy verdict apply unchanged. A failure here is recorded as a condition; findings are never dropped.
11028
+ let _languageCoverage = null;
11029
+ try {
11030
+ if (Object.keys(allFileContents).some(f => /\.(?:l?hs|hs-boot|hsc|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project|package\.yaml|stack\.yaml)/i.test(f))) {
11031
+ const { assessLanguageAssurance } = await import('./language/assurance.js');
11032
+ const { configuredAdvisoryDb } = await import('./language/haskell-supply.js');
11033
+ const env = process.env;
11034
+ const { resolvedHackageComponents, analyzeNixClosure, runSelectedNixEval } = await import('./language/resolved-pass.js');
11035
+ let _languageSupplyGaps = [];
11036
+ try { _languageSupplyGaps = [...resolvedHackageComponents(allFileContents).gaps, ...((analyzeNixClosure(allFileContents, { scanRoot }) || {}).gaps || [])]; } catch (e) { _languageSupplyGaps = [{ kind: 'resolved-analysis-failed', detail: String((e && e.message) || e).slice(0, 160) }]; }
11037
+ const _nixEvalResult = await runSelectedNixEval(scanRoot, env);
11038
+ _languageCoverage = (await assessLanguageAssurance({
11039
+ files: allFileContents, findings: finalFindings, timeoutMs: Number(env.AGENTIC_SECURITY_LANG_TIMEOUT_MS) || 0,
11040
+ disabled: [...String(env.AGENTIC_SECURITY_LANG_DISABLE || '').split(',').map(x => x.trim()).filter(Boolean), ...((_hasHaskellSource && !_deepEnabled) ? ['haskell:taint'] : [])],
11041
+ advisoryDb: configuredAdvisoryDb(scanRoot).db,
11042
+ policyExcluded,
11043
+ sizeSkipped,
11044
+ supplyGaps: _languageSupplyGaps,
11045
+ licenseUnavailable: ((_languageBom && _languageBom.components) || []).filter((c) => c && !c.license && !(Array.isArray(c.licenses) && c.licenses.length)).length,
11046
+ optional: { 'nix-eval': { selected: env.AGENTIC_SECURITY_NIX_EVAL === '1', result: _nixEvalResult }, 'cabal-plan': { selected: false }, 'hackage-live': { selected: false } },
11047
+ })).languageCoverage;
11048
+ }
11049
+ } catch (e) { _languageCoverage = { totals: null, byKind: null, conditions: [`language assurance could not be computed: ${String((e && e.message) || e)}`] }; }
10941
11050
  _scanHealth = computeScanHealth({
10942
11051
  scanMeta: _scanMeta,
10943
11052
  annotatorErrors: _annotatorErrors,
@@ -10945,6 +11054,8 @@ function _deterministicFileTimings(timings) {
10945
11054
  deepStatus: _deepStatus,
10946
11055
  analyzerCoverage: summarizeCoverageForScanHealth(_coverageLedger),
10947
11056
  lineageStatus: _lineageStatus,
11057
+ languageCoverage: _languageCoverage,
11058
+ supplyChain,
10948
11059
  });
10949
11060
  // FR-207: stale vulnerability feeds, calibration data, and compliance
10950
11061
  // evidence are real assurance gaps, not just findings the feed omits --
@@ -10960,7 +11071,7 @@ function _deterministicFileTimings(timings) {
10960
11071
  compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
10961
11072
  });
10962
11073
  } // end if (!skipAnnotators) — FR-PROV-029
10963
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,aiAssistance:_aiAssistance};}
11074
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
10964
11075
 
10965
11076
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
10966
11077
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
package/src/ir/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // Build per-file IR for every JS/TS/Python/Java file in a project, then
4
4
  // build the cross-file call graph on top.
5
5
 
6
+ import { buildHaskellIR } from '../language/haskell-ir.js';
6
7
  import { parseJsFile } from './parser-js.js';
7
8
  import { parseCSharpFile } from './parser-cs.js';
8
9
  import { parseKotlinFile } from './parser-kt.js';
@@ -137,6 +138,7 @@ function _parsePythonFiles(pyEntries) {
137
138
  export function buildProjectIR(fileContents) {
138
139
  const perFile = {};
139
140
  const pyBatch = [];
141
+ const hsFiles = {};
140
142
  for (const [file, code] of Object.entries(fileContents || {})) {
141
143
  // Each branch is wrapped so one pathological file (e.g. a deeply nested
142
144
  // expression tree that blows the regex-parser's recursive descent, seen
@@ -148,6 +150,8 @@ export function buildProjectIR(fileContents) {
148
150
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
149
151
  const ir = parseJsFile(file, code);
150
152
  if (ir) perFile[file] = ir;
153
+ } else if (/\.hs$/i.test(file)) {
154
+ hsFiles[file] = code; // lowered together: Haskell resolution is whole-program (imports, re-exports)
151
155
  } else if (/\.py$/i.test(file)) {
152
156
  // Defer Python files to a single batched subprocess call.
153
157
  pyBatch.push({ file, content: code });
@@ -185,6 +189,7 @@ export function buildProjectIR(fileContents) {
185
189
  // the CFGs (SSA numbering included), since PHP's `include` genuinely
186
190
  // executes in the caller's variable namespace. See php-include-merge.js's
187
191
  // module header for the full rationale and scope.
192
+ const hsCg = mergeHaskellIR(perFile, hsFiles);
188
193
  try { mergePhpIncludes(perFile); } catch {}
189
194
  if (isSSAEnabled()) {
190
195
  for (const ir of Object.values(perFile)) {
@@ -199,15 +204,44 @@ export function buildProjectIR(fileContents) {
199
204
  // passed — without it, a call to an aliased re-export can never resolve
200
205
  // (no function is literally named the alias anywhere), a dropped edge,
201
206
  // not just an imprecise one.
202
- const cg = buildCallGraph(perFile, fileContents);
207
+ const cg = withHaskellCallGraph(buildCallGraph(perFile, fileContents), hsCg);
203
208
  const cha = buildClassHierarchy(perFile);
204
209
  return { perFile, callGraph: cg, cha };
205
210
  }
206
211
 
212
+ // Haskell is lowered as one project (module resolution crosses files), then its
213
+ // per-file IR joins the same perFile map every consumer already reads. Its call graph
214
+ // keeps its richer edge statuses and is merged into the shared one below.
215
+ function mergeHaskellIR(perFile, hsFiles) {
216
+ if (!Object.keys(hsFiles).length) return null;
217
+ try {
218
+ const hs = buildHaskellIR(hsFiles);
219
+ for (const [file, ir] of Object.entries(hs.perFile)) perFile[file] = ir;
220
+ for (const d of hs.diagnostics) _noteParseFailure(d.file, new Error(`haskell ${d.kind}: ${d.detail}`));
221
+ return hs.callGraph;
222
+ } catch (err) {
223
+ for (const f of Object.keys(hsFiles)) _noteParseFailure(f, err);
224
+ return null;
225
+ }
226
+ }
227
+
228
+ function withHaskellCallGraph(cg, hsCg) {
229
+ if (!hsCg) return cg;
230
+ const functions = new Map([...cg.functions, ...hsCg.functions]);
231
+ const edges = [...(cg.edges || []), ...hsCg.edges];
232
+ const callersOf = new Map(cg.callersOf || []);
233
+ for (const [k, v] of hsCg.callersOf || []) callersOf.set(k, [...(callersOf.get(k) || []), ...v]);
234
+ const isHs = (f) => typeof f === 'string' && /\.hs$/i.test(f);
235
+ const resolve = (name, callerFile) => (isHs(callerFile) ? hsCg.resolve(name) : cg.resolve(name, callerFile));
236
+ const resolveKnownCallee = (name, callerFile) => (isHs(callerFile) ? hsCg.resolveKnownCallee(name) : cg.resolveKnownCallee(name, callerFile));
237
+ return { ...cg, functions, edges, callersOf, resolve, resolveKnownCallee, haskell: hsCg };
238
+ }
239
+
207
240
  // Async variant — includes Java IR via java-parser.
208
241
  export async function buildProjectIRAsync(fileContents) {
209
242
  const perFile = {};
210
243
  const pyBatch = [];
244
+ const hsFiles = {};
211
245
  for (const [file, code] of Object.entries(fileContents || {})) {
212
246
  // See buildProjectIR's comment: per-file try/catch so one pathological
213
247
  // file can't abort IR construction for the whole project.
@@ -215,6 +249,8 @@ export async function buildProjectIRAsync(fileContents) {
215
249
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
216
250
  const ir = parseJsFile(file, code);
217
251
  if (ir) perFile[file] = ir;
252
+ } else if (/\.hs$/i.test(file)) {
253
+ hsFiles[file] = code;
218
254
  } else if (/\.py$/i.test(file)) {
219
255
  pyBatch.push({ file, content: code });
220
256
  } else if (/\.cs$/i.test(file)) {
@@ -256,6 +292,7 @@ export async function buildProjectIRAsync(fileContents) {
256
292
  // the CFGs (SSA numbering included), since PHP's `include` genuinely
257
293
  // executes in the caller's variable namespace. See php-include-merge.js's
258
294
  // module header for the full rationale and scope.
295
+ const hsCg = mergeHaskellIR(perFile, hsFiles);
259
296
  try { mergePhpIncludes(perFile); } catch {}
260
297
  if (isSSAEnabled()) {
261
298
  for (const ir of Object.values(perFile)) {
@@ -270,7 +307,7 @@ export async function buildProjectIRAsync(fileContents) {
270
307
  // passed — without it, a call to an aliased re-export can never resolve
271
308
  // (no function is literally named the alias anywhere), a dropped edge,
272
309
  // not just an imprecise one.
273
- const cg = buildCallGraph(perFile, fileContents);
310
+ const cg = withHaskellCallGraph(buildCallGraph(perFile, fileContents), hsCg);
274
311
  const cha = buildClassHierarchy(perFile);
275
312
  return { perFile, callGraph: cg, cha };
276
313
  }
@@ -0,0 +1,74 @@
1
+ # scanner/src/language/
2
+
3
+ Haskell and Nix/NixOS support: parsers, IRs, analyzers, fixers, support registry. **Read this before adding a rule, a model entry
4
+ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides/nix-nixos.md`. Measured status:
5
+ `docs/language-support.md` (generated).
6
+
7
+ ## Invariants (the ones you will get wrong without reading)
8
+
9
+ - **Static by default.** A default scan never runs GHC, Cabal, Stack, `nix`, `Setup.hs`, Template Haskell or a build. Compile
10
+ checks (`haskell-fix.js` `compileGate`) and evaluation (`nix-eval-isolation.js`) are opt-in, and a tool that is absent is a
11
+ stated `not-run` / `unsupported`, never a pass. A required criterion that needs a tool **fails** (and the support registry
12
+ marks the capability `blocked`); it never skips.
13
+ - **Never derive an answer from ground truth.** The engine must not read the corpus under `test/language/corpora/`, fixture
14
+ names, comments or label metadata (a test enforces the corpus half). Detection keys on code semantics; `bench/mutation/`
15
+ and the metamorphic pairs in `bench/language-support/` are the anti-overfitting check.
16
+ - **Every limit is disclosed.** An unresolved construct, a conditional Nix option, a stale plan, a missing advisory feed, a
17
+ malformed export, a size-capped file, a disabled analyzer: each becomes a `scanHealth` condition or a limitation
18
+ (`assurance.js`). Findings are never dropped because a condition appeared, and an absent feed is "not assessed", never clean.
19
+ - **Findings schema.** Optional contract fields (`language`, `capability`, `evidenceKind`, `scope`, `uncertainty`,
20
+ `originalLocation`) in `contracts.js`; the legacy required fields are unchanged. A producer must be registered
21
+ (`producer-registry.js`) or its results are discarded and reported.
22
+ - **Locations index the ORIGINAL file** (1-based lines, 0-based UTF-16 columns, UTF-8 bytes). Preprocessing (CPP, literate,
23
+ hsc) overwrites with same-length spaces.
24
+ - **State safety.** Persisted language artifacts go through `posture/state-dir.js` (`statePath`, `safeWriteState`); a symlinked
25
+ state dir or file is refused; fix backups are encrypted when encryption is configured and are `confidential`. A fix target
26
+ outside the project root is refused (`fix-lifecycle.js` `isRootRelativePath`).
27
+ - **Secrets never leave redacted.** `secrets.js` `redactLanguageSecrets` joins split literals before masking; egress and model
28
+ prompts use it. Credential values are never written to any output or state file.
29
+ - **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
30
+
31
+ ## Module map
32
+
33
+ | Group | Files |
34
+ |---|---|
35
+ | Shared | `discovery.js` (sources, manifests, explicit exports, exclusions, import graph, invalidation digests), `contracts.js`, `assurance.js` (scan-health inputs, conditions, limitations), `engine-pass.js` (language supply chain wired into the engine), `resolved-pass.js` (plan / Stack export / Nix closure / opt-in evaluation reachable from the CLI), `context.js` (project context for partial scans, fix previews and dependency upgrades, model prompt extras), `state-artifacts.js`, `pragma.js`, `secrets.js`, `bom.js`, `bom-validate.js`, `aibom.js`, `compliance-map.js`, `bridges.js`, `witness.js`, `fix-lifecycle.js` (the one fix lifecycle: path, syntax, rescan, compile, backup, history, undo) |
36
+ | Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-supply.js`, `haskell-fix.js` |
37
+ | Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
38
+ | Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
39
+
40
+ Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
41
+ `ir/` Haskell/Nix adapters, `report/` format handling, `egress/redact.js`, `llm-validator/`, `discovery/` (hunt partitions Nix).
42
+
43
+ ## Tests and benches
44
+
45
+ | Scope | Command | Notes |
46
+ |---|---|---|
47
+ | Haskell | `npm run test:haskell` | parser, IR, taint, web/auth, SCA, resolved graph, remediation |
48
+ | Nix | `npm run test:nix` | parser/IR, module resolution, hardening, build trust, secrets, closure, SCA, eval isolation, fixes |
49
+ | Language-wide | `npm run test:language` | corpus integrity, accuracy regression, end-to-end CLI matrix, package/CI, BOM/AI-BOM, privacy, secrets, governance, assurance, scan modes |
50
+ | Stress | `npm run test:language-stress` | offline scale and a peak-memory ceiling; excluded from `npm test` (it must own the machine) |
51
+ | Needs a compiler | `npm run test:language-tools` | HS-006.AC01 and the support gate; excluded from `npm test`; **fails** without `ghc` |
52
+ | Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
53
+
54
+ The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
55
+ against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
56
+ frozen hashes and demotes rows until a deliberate re-measure. `docs/language-support.*` are generated; do not edit by hand.
57
+
58
+ ## Docs generated from here
59
+
60
+ `node scripts/render-language-docs.mjs` renders tables and counts from the registries (models, hardening, build-trust, secrets,
61
+ support, metrics) and from `docs/examples/language/captured.json`, which `node scripts/capture-language-examples.mjs` captures
62
+ from the BUILT bundle over `examples/`. Both have `--check` modes (`npm run docs:check-language`). Rebuild the bundle first:
63
+ the capture reads `dist/`.
64
+
65
+ ## Adding a rule or a model entry
66
+
67
+ 1. Haskell API: add it to `haskell-models.js` (module-qualified). The catalog and lineage registries follow, and **pinned counts in
68
+ `test/lineage/{source,sink,transform}-registry.test.js` change deliberately** (update them and say why).
69
+ 2. Nix rule: add it to the rule table of its analyzer (`nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`); the guide's
70
+ table is generated, so re-render.
71
+ 3. Add a minimal vulnerable / safe pair to the QA-001 templates (`test/language/corpora/templates.mjs`), regenerate the corpus, and
72
+ record the revision in `provenance.json`. Do not hand-edit labels.
73
+ 4. Run `test:haskell` / `test:nix` / `test:language`, `bench:mutation:check`, `bench:layer-recall:check` and
74
+ `bench:language-support:check`; re-promote deliberately if a measured number legitimately changes.
@@ -0,0 +1,90 @@
1
+ // Accuracy scoring for the Haskell/Nix capability gates (PRD section 9.2).
2
+ //
3
+ // One-to-one matching: every expected label is matched by AT MOST ONE finding (same file, same family or
4
+ // CWE, and the finding's line within `lineTolerance` of the labeled line when one is given), and every
5
+ // finding matches at most one label. An unmatched finding is a false positive, an unmatched label a false
6
+ // negative, so the same vulnerability reported twice is not inflated into two true positives, and a
7
+ // missing parse or discovery is a miss, never an exclusion.
8
+ //
9
+ // A zero or absent denominator is `notMeasured`, never 100%. Layers are scored independently (a finding
10
+ // belongs to exactly one layer) and are never combined into one headline number, so a strong layer cannot
11
+ // hide a weak one.
12
+
13
+ export const LAYERS = Object.freeze({
14
+ 'security-taint': (f) => f.parser === 'IR-TAINT',
15
+ 'structural-sast': (f) => f.parser === 'HS-RULES' || f.parser === 'NIX-RULES',
16
+ 'privacy-lineage': (f) => f.parser === 'LINEAGE' || f.kind === 'privacy',
17
+ 'config-sast': (f) => f.parser === 'NIXOS-HARDENING',
18
+ });
19
+
20
+ const ratio = (n, d) => (d > 0 ? n / d : null);
21
+
22
+ export function prf(tp, fp, fn) {
23
+ const precision = ratio(tp, tp + fp);
24
+ const recall = ratio(tp, tp + fn);
25
+ const f1 = precision !== null && recall !== null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : (precision !== null && recall !== null ? 0 : null);
26
+ return { tp, fp, fn, precision, recall, f1, measured: precision !== null && recall !== null };
27
+ }
28
+
29
+ const matches = (label, f, tol) => {
30
+ if (label.file && f.file !== label.file) return false;
31
+ // `families`: the finding's own family must be one of these (a detector's family vocabulary differs from a corpus's; the
32
+ // alias table lives with the harness that declares it, never in the engine). Otherwise a single family, otherwise the CWE.
33
+ if (Array.isArray(label.families)) { if (!label.families.includes(f.family)) return false; }
34
+ else if (label.family ? f.family !== label.family : (label.cwe && f.cwe !== label.cwe)) return false;
35
+ if (!Array.isArray(label.families) && label.cwe && label.family && f.cwe !== label.cwe) return false;
36
+ if (Number.isInteger(label.line) && Number.isInteger(f.line) && Math.abs(label.line - f.line) > tol) return false;
37
+ return true;
38
+ };
39
+
40
+ // A finding the proof gate discharged (sanitized / guarded) is reported but is not a live detection.
41
+ export const isLive = (f) => !(f.proof && /^proven-/.test(f.proof.verdict));
42
+
43
+ /**
44
+ * @param {Array<{id:string, file:string, label?:'vuln'|'safe', expect:Array<{file?:string,cwe?:string,family?:string,line?:number}>}>} cases
45
+ * @param {object[]} findings all findings of the scan
46
+ * @param {{layer:string, lineTolerance?:number, live?:(f:object)=>boolean}} opts
47
+ */
48
+ export function scoreLayer(cases, findings, opts) {
49
+ const inLayer = typeof opts.inLayer === 'function' ? opts.inLayer : LAYERS[opts.layer];
50
+ if (!inLayer) throw new Error(`unknown layer ${opts.layer}`);
51
+ const tol = opts.lineTolerance ?? 0;
52
+ const live = opts.live || isLive;
53
+ const pool = findings.filter((f) => inLayer(f) && live(f)).map((f) => ({ f, used: false }));
54
+ const perFamily = {};
55
+ let tp = 0, fp = 0, fn = 0, tn = 0;
56
+ const misses = [];
57
+ const strays = [];
58
+ const bump = (fam, key) => { (perFamily[fam] ||= { tp: 0, fp: 0, fn: 0 })[key]++; };
59
+ for (const c of cases) {
60
+ const here = pool.filter((p) => p.f.file === c.file);
61
+ const expected = c.expect || [];
62
+ for (const label of expected) {
63
+ const hit = here.find((p) => !p.used && matches({ file: c.file, ...label }, p.f, tol));
64
+ const fam = c.family || label.family || label.cwe || 'unknown';
65
+ if (hit) { hit.used = true; tp++; bump(fam, 'tp'); } else { fn++; bump(fam, 'fn'); misses.push({ case: c.id, label }); }
66
+ }
67
+ if (!expected.length && !here.length) tn++;
68
+ }
69
+ let crossFamily = 0;
70
+ for (const p of pool) {
71
+ if (p.used) continue;
72
+ // Family-scoped scoring (the convention of per-category benchmarks): a finding of a DIFFERENT CWE than the case it sits in
73
+ // answers a different question and is counted separately, never as a false positive of this case's family. Strict mode
74
+ // (the default) counts it as a stray, so both numbers can always be reported side by side.
75
+ if (opts.familyScoped) {
76
+ const own = cases.find((c) => c.file === p.f.file);
77
+ if (own && Array.isArray(own.families) ? !own.families.includes(p.f.family) : (own && own.cwe && p.f.cwe !== own.cwe)) { crossFamily++; continue; }
78
+ }
79
+ // only findings located in labeled files count against precision: an unrelated file is not scored
80
+ const owner = cases.find((c) => c.file === p.f.file);
81
+ if (owner) { fp++; bump(owner.family || p.f.family || p.f.cwe || 'unknown', 'fp'); strays.push({ file: p.f.file, line: p.f.line, cwe: p.f.cwe }); }
82
+ }
83
+ const families = Object.fromEntries(Object.entries(perFamily).map(([k, v]) => [k, prf(v.tp, v.fp, v.fn)]));
84
+ return { layer: opts.layer, cases: cases.length, tn, ...prf(tp, fp, fn), families, misses, strays, ...(opts.familyScoped ? { crossFamilyFindingsNotScored: crossFamily } : {}) };
85
+ }
86
+
87
+ /** Per-layer results side by side. Deliberately NO combined headline number. */
88
+ export function scoreAllLayers(cases, findings, layerList, opts = {}) {
89
+ return Object.fromEntries(layerList.map((layer) => [layer, scoreLayer(cases, findings, { ...opts, layer })]));
90
+ }