@clear-capabilities/agentic-security-scanner 0.153.1 → 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 (136) hide show
  1. package/CHANGELOG.md +67 -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 +18 -3
  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 +140 -19
  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/code-injection-multilang.js +15 -0
  135. package/src/sast/go-extended.js +11 -0
  136. package/src/sast/pipeline.js +34 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,73 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
13
+
14
+ Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
15
+ network. This entry records what is **measured** and what is **not**; nothing here announces completion beyond the evidence.
16
+
17
+ - **Analysis.** Dependency-free Haskell and Nix parsers over SHA-256-pinned grammar tables; Haskell taint (SQL, command, path,
18
+ SSRF, HTML), weak crypto, logging, resource and web-route authentication rules; the NixOS effective-configuration resolver with
19
+ hardening, build-trust and store-secret rules; Haskell and Nix personal-data flows in the Data Flow Explorer; a Haskell
20
+ catalog generated from one model registry. Unresolved constructs, conditional options, stale inputs and missing feeds become
21
+ scan-health conditions, so a scan that could not check something is `partial`, never complete.
22
+ - **Supply chain and BOM.** Hackage components with PVP-aware advisory matching (a snapshot you provide), a resolved graph from a
23
+ fresh Cabal plan or Stack export, Nix flake inputs, an exported closure matched by upstream identity with patch evidence, and
24
+ AI-BOM entries for model calls and inference services. License data is absent for these records and is disclosed, not guessed.
25
+ - **Fixes.** Verified, tiered, reversible fixes for five Haskell shapes and for Nix effective settings, plus dependency-bound
26
+ and flake-input edits labelled as needing a re-resolve or re-lock. The planner now follows a flow into the callee that holds
27
+ the dangerous call. A fix a gate refuses is reported, not applied.
28
+ - **CLI reach.** `fix` and `undo` now work for these languages, `hunt` includes `.hs`/`.nix`, `--since-baseline` filters every
29
+ bucket (a baselined dependency finding no longer reappears as new), the edit hook catches credentials split across literals, and
30
+ a Cabal or Nix project is recognised as a project root for state.
31
+ - **Support, measured.** `docs/language-support.md` records per-capability status from a frozen holdout. On the synthetic,
32
+ template-generated QA-001 corpus every measured layer scores P/R/F1 100%, which says the engine is robust over those shapes and
33
+ **not** that it is accurate on arbitrary real code. `auth` (Haskell) and `nix-eval` / `nixos-host` (Nix) are **blocked**: no
34
+ `ghc`, `nix` or NixOS host was available where this was built.
35
+ - **Tooling.** The `npm test` gate now runs the Haskell, Nix and language scopes; `language-stress` and `language-tools` have
36
+ their own scripts. New CI jobs (`language-suites`, `language-tools-ghc`, `nixos-runtime`) carry explicit deadlines; a
37
+ `language-support-gate` joins pre-push and the release check. A bounded background-loop controller and runbook
38
+ (`docs/guides/loop-engineering.md`) are a development tool, not part of the package.
39
+ - **Docs.** New Haskell, Nix/NixOS, NixOS-install and loop guides; tables and example outputs are generated from the registries and
40
+ from the built bundle, and stale ones fail `npm run docs:check-language`.
41
+ - **Verified and not verified.** The Haskell route fixtures compile under GHC 9.4.8 and the Haskell support table is 9 of 9. NIX-011 (a
42
+ real controlled evaluation) and NIX-012 (the scanner on a NixOS host, VM checks, and the new `flake.nix`) need Nix and NixOS, which
43
+ were never available where this was built: they stay blocked rather than faked, and the flake has never been built. The CI jobs
44
+ that would exercise them are written but have not run.
45
+
46
+ ## 0.153.2 - Scanner F1 benchmark and dependency-currency CI repaired
47
+
48
+ The daily `Scanner F1 benchmark` workflow had failed every run since about 2026-08-10. Root causes, each
49
+ reproduced locally before it was changed:
50
+
51
+ - **Code injection: sink names inside string literals** (`code-injection-multilang.js`). `eval(` quoted in a
52
+ rule library's `fix:`/`example:` text fired as a critical finding. Matches inside a `'...'` or `"..."`
53
+ literal are now skipped (backtick templates are not, since `${...}` holds real code).
54
+ - **ReDoS: overlapping alternation under a quantifier was rejected.** The mechanism gate added in 0.14x
55
+ admitted only nested quantifiers and leading unbounded ones, so `/^(a|aa)*$/` never reached the overlap
56
+ check. It is now a third named mechanism, and like a nested quantifier it is not cured by anchoring.
57
+ - **Go: package-level `http.Get/Post/Head/PostForm` have no timeout.** The old finding only appeared
58
+ because a JavaScript regex happened to match Go; it was correctly scoped to JS later and Go lost the
59
+ finding. A real Go rule (`go-http-no-timeout`) now covers it.
60
+ - **Benchmark scorers** no longer count info-level `stack-playbook:` checklist advice as detections,
61
+ treat an npm lockfile as the manifest for dependency findings (Nodegoat F1 had fallen 80.7% to 15.7%
62
+ on that alone), and carry ground truth for the ownership-authz and rate-limit detectors. The `clean-js`
63
+ fixture now actually checks ownership.
64
+ - **Real-world gate:** Juice Shop, Goof and NodeGoat have ground truth flagged `requiresReAudit`, which the
65
+ bench itself calls informational, so they are gated on recall (floor 75%, measured 81-83%) with F1 still
66
+ printed. OWASP Benchmark and Juliet keep their F1 floors.
67
+ - **SARD blind smoke:** the macro-F1 check now uses the support-floored macro. Zero-support rows that exist
68
+ only because a detector fired outside the scanned CWE subset were dragging down the mean, so a micro-F1
69
+ gain from 65.8% to 88.7% read as a macro regression. Precision remains gated separately.
70
+ - **Real-world bench truncation:** eight unfloored corpora (pygoat, django-clean, nextjs-clean, ...) contain
71
+ files over the engine's size caps, which it skips by design, and the bench read any skipped file as a failed
72
+ measurement, so the job exited 1 on every run. The workflow now passes `--allow-truncation` and instead fails
73
+ any app that HAS a floor if it was truncated, so the gating apps still fail closed.
74
+ - **Flaky provenance test:** `a budget_exhausted result is NEVER cached` raced a 5ms budget against git's
75
+ latency and failed on a fast hosted runner. It now uses a 1ms budget and retries until a run lands on the
76
+ path under test, still failing loudly if none does.
77
+ - `@types/node` 26.6.3 to 26.6.4 in `scanner/` and `ide/vscode/` for the dependency-currency gate.
78
+
12
79
  ## 0.153.1 - CI hygiene: off the deprecated Node 20 Actions runtime
13
80
 
14
81
  A maintenance-only release, no engine or scanner behavior changes.
@@ -50,9 +50,11 @@ import { toOSCAL } from '../src/report/oscal.js';
50
50
  // format had to be added to all three or it silently got human chatter
51
51
  // interleaved into its stdout/stderr. One list, one place to update.
52
52
  const MACHINE_FORMATS = new Set([
53
- 'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom',
53
+ 'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom', 'mlbom',
54
54
  ]);
55
55
  function isMachineFormat(fmt) { return MACHINE_FORMATS.has(String(fmt)); }
56
+ // Every output label `scan` produces (the body of cmdScan has one branch per entry).
57
+ const SCAN_FORMATS = new Set(['cli', 'json', 'md', 'markdown', 'sarif', 'oscal', 'stix', 'junit', 'csv', 'html', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom', 'aibom-md', 'mlbom', 'ship', 'pro', 'summary']);
56
58
 
57
59
  // Shared stderr progress reporter for any command that runs a fresh scan —
58
60
  // the same `\r[phase] current/total file` status line the default `scan`
@@ -75,7 +77,7 @@ function clearScanProgressLine() {
75
77
  }
76
78
  import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
77
79
  import { toPBOM } from '../src/sast/pipeline.js';
78
- import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
80
+ import { buildAIBOM, aibomToMarkdown, toCycloneDXMLBOM } from '../src/posture/aibom.js';
79
81
  import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
80
82
  import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
81
83
  import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
@@ -93,6 +95,16 @@ import * as triage from '../src/posture/triage.js';
93
95
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
94
96
 
95
97
  import { stateDir, statePath, withStateWritesDisabled } from '../src/posture/state-dir.js';
98
+
99
+ // A state file is never written THROUGH a symlink: a hostile tree could place a link at `.agentic-security/last-scan.json` and have a
100
+ // scan overwrite whatever it points to. O_NOFOLLOW makes the open itself refuse a link (no check-then-write race); where the flag does
101
+ // not exist an lstat check stands in for it.
102
+ const _NOFOLLOW_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
103
+ async function _writeStateNoFollow(file, body) {
104
+ if (!fs.constants.O_NOFOLLOW) { try { if ((await fsp.lstat(file)).isSymbolicLink()) return false; } catch { /* absent */ } }
105
+ try { await fsp.writeFile(file, body, { flag: _NOFOLLOW_FLAGS, mode: 0o644 }); return true; }
106
+ catch (e) { if (e && (e.code === 'ELOOP' || e.code === 'EMLINK')) return false; throw e; }
107
+ }
96
108
  import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
97
109
  // last-scan.json integrity helpers — implementation in posture/integrity.js
98
110
  // so the MCP server tools can share verification.
@@ -194,6 +206,8 @@ Commands:
194
206
  --remote-node <node-id> [--repository <label>]
195
207
  [--relationship data_flow] [--rationale <text>]
196
208
  [--output <file>] [--yes] [--base-digest <hex>]
209
+ [--local-target <t>] [--local-config <c>]
210
+ [--remote-target <t>] [--remote-config <c>]
197
211
  Declare a CrossRepoLink between a node in the
198
212
  current locally-scanned graph and a node in a
199
213
  remote graph export (dataflow export --format
@@ -234,7 +248,7 @@ Commands:
234
248
  Options:
235
249
  --profile vibecoder|pro Override profile for this run
236
250
  --only sast|sca|secrets Limit scan to one pillar
237
- --format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
251
+ --format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md | mlbom
238
252
  --pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
239
253
  --baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
240
254
  --fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
@@ -470,12 +484,12 @@ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
470
484
  }
471
485
  await fsp.mkdir(stateDirPath, { recursive: true });
472
486
  // Always JSON (used by /security-fix and /security-report).
473
- await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
487
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
474
488
  JSON.stringify(toJSON(scan, meta), null, 2));
475
489
  if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
476
- await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
490
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
477
491
  JSON.stringify(toSARIF(scan, meta), null, 2));
478
- await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
492
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
479
493
  }
480
494
  }
481
495
 
@@ -613,6 +627,12 @@ async function cmdScan(args) {
613
627
  // Load persona profile (R1). Persona-aware defaults flow from here.
614
628
  const profile = loadPersonaProfile(targetAbs, args);
615
629
  const format = args.flags.format || (profile.profile === 'pro' ? 'cli' : 'ship');
630
+ // An output label this command does not produce is an error, not a quiet fallback to the summary: a CI job that
631
+ // asked for `sarif ` or `junit-xml` and got human text would upload garbage and report success.
632
+ if (!SCAN_FORMATS.has(String(format))) {
633
+ process.stderr.write(`agentic-security scan: unsupported --format ${JSON.stringify(String(format))}. Supported: ${[...SCAN_FORMATS].join(' | ')}.\n`);
634
+ return 2;
635
+ }
616
636
  const verbose = !!args.flags.verbose;
617
637
  const output = args.flags.output;
618
638
  const noNet = !!args.flags['no-network'];
@@ -782,6 +802,9 @@ async function cmdScan(args) {
782
802
  if (args.flags['set-baseline']) {
783
803
  const { normalizeFindings } = await import('../src/report/index.js');
784
804
  const baselineIds = new Set(normalizeFindings(scan).map(f => f.stableId || f.id));
805
+ // A supply-chain, secret or logic finding's normalised id includes its line, so an unrelated edit above it would make it
806
+ // look new. Its own producer id (when it has one) does not, so both are recorded.
807
+ for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) for (const f of scan[bucket] || []) if (f && typeof f.id === 'string' && f.id.includes(':')) baselineIds.add(f.id);
785
808
  fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
786
809
  fs.writeFileSync(baselinePath, JSON.stringify({ ids: [...baselineIds], createdAt: new Date().toISOString(), count: baselineIds.size }, null, 2));
787
810
  process.stderr.write(`[baseline] saved ${baselineIds.size} findings as baseline\n`);
@@ -793,6 +816,20 @@ async function cmdScan(args) {
793
816
  const baselineSet = new Set(baseline.ids || []);
794
817
  const before = scan.findings.length;
795
818
  scan.findings = (scan.findings || []).filter(f => !baselineSet.has(f.stableId || f.id));
819
+ // The baseline is built from every bucket (findings, secrets, supply chain, logic), so it must be applied to every bucket:
820
+ // filtering only `findings` left a baselined dependency or secret finding reappearing as "new" on every run.
821
+ // Identity is the one the baseline was written with: the NORMALISED id (raw supply-chain entries carry a different one).
822
+ const { normalizeFindings: _norm } = await import('../src/report/index.js');
823
+ const _empty = { findings: [], secrets: [], supplyChain: [], logicVulns: [], suppressions: [], components: [], routes: [], filesScanned: 0 };
824
+ for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) {
825
+ if (!Array.isArray(scan[bucket])) continue;
826
+ scan[bucket] = scan[bucket].filter((f) => {
827
+ if (!f) return true;
828
+ let id = f.stableId || f.id;
829
+ try { const n = _norm({ ..._empty, [bucket]: [f] })[0]; if (n) id = n.stableId || n.id; } catch { /* keep the raw identity */ }
830
+ return !baselineSet.has(id) && !(typeof f.id === 'string' && f.id.includes(':') && baselineSet.has(f.id));
831
+ });
832
+ }
796
833
  process.stderr.write(`[baseline] filtered ${before - scan.findings.length} baseline findings, ${scan.findings.length} new\n`);
797
834
  } catch { /* baseline file unreadable, skip */ }
798
835
  }
@@ -997,9 +1034,10 @@ async function cmdScan(args) {
997
1034
  else if (format === 'cyclonedx' || format === 'sbom') body = JSON.stringify(toCycloneDX(scan, meta), null, 2);
998
1035
  else if (format === 'spdx') body = JSON.stringify(toSPDX(scan, meta), null, 2);
999
1036
  else if (format === 'vex' || format === 'openvex') body = JSON.stringify(toVex(scan, meta), null, 2);
1000
- else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta), null, 2);
1001
- else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, meta), null, 2);
1002
- else if (format === 'aibom-md') body = aibomToMarkdown(buildAIBOM(scan, scan.fc || {}, meta));
1037
+ else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta, { languageBuild: (await import('../src/language/bom.js')).languagePbom({ ...(scan.fc || {}), ...(scan.depFileContents || {}) }) }), null, 2);
1038
+ else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), null, 2);
1039
+ else if (format === 'mlbom') body = JSON.stringify(toCycloneDXMLBOM(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), meta), null, 2);
1040
+ else if (format === 'aibom-md') body = aibomToMarkdown(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }));
1003
1041
  else if (format === 'ship') body = toShipVerdict(scan, { profile: effProfile });
1004
1042
  else if (format === 'pro') body = toProTable(scan, { profile: effProfile, columns: args.flags.columns });
1005
1043
  else if (format === 'cli') body = toCLIByProfile(scan, { profile: effProfile, columns: args.flags.columns, verbose });
@@ -1143,10 +1181,16 @@ async function cmdScan(args) {
1143
1181
  } catch { /* best-effort, offline-degrading — never block a scan */ }
1144
1182
  }
1145
1183
  const lastScanBody = JSON.stringify(persistedScan, null, 2);
1146
- await fsp.writeFile(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
1184
+ await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
1147
1185
  try {
1148
- await fsp.writeFile(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
1186
+ await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
1149
1187
  } catch { /* non-fatal — sig file is best-effort */ }
1188
+ // X-016: Haskell/Nix analysis metadata and BOM as registered, signed artifacts. Written only when the scan has
1189
+ // Haskell or Nix content, so every other project's state directory is unchanged.
1190
+ try {
1191
+ const { persistLanguageArtifacts } = await import('../src/language/state-artifacts.js');
1192
+ persistLanguageArtifacts(path.resolve(target), { ...scan, scanHealth: persistedScan.scanHealth || scan.scanHealth });
1193
+ } catch { /* non-fatal: metadata artifacts never block a scan */ }
1150
1194
  // Sub-project E, increment 5: persist the lineage graph as its own
1151
1195
  // artifact, mirroring last-scan.json's own write+sign pattern exactly
1152
1196
  // — signLastScan/verifyLastScan are fully generic (an arbitrary string
@@ -1158,9 +1202,9 @@ async function cmdScan(args) {
1158
1202
  if (scan.lineageGraph) {
1159
1203
  try {
1160
1204
  const lineageBody = JSON.stringify(scan.lineageGraph, null, 2);
1161
- await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
1205
+ await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
1162
1206
  try {
1163
- await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
1207
+ await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
1164
1208
  } catch { /* non-fatal — sig file is best-effort, same precedent as last-scan.json.sig above */ }
1165
1209
  } catch { /* non-fatal — the lineage artifact write is best-effort and must never block a scan */ }
1166
1210
  // M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): ADDITIVELY
@@ -1301,11 +1345,11 @@ async function cmdCi(args) {
1301
1345
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
1302
1346
  } else {
1303
1347
  await fsp.mkdir(stateDirPath, { recursive: true });
1304
- await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
1348
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
1305
1349
  JSON.stringify(toJSON(scan, meta), null, 2));
1306
- await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
1350
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
1307
1351
  JSON.stringify(toSARIF(scan, meta), null, 2));
1308
- await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
1352
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.junit.xml'),
1309
1353
  toJUnit(scan, meta));
1310
1354
  }
1311
1355
 
@@ -1534,7 +1578,7 @@ async function cmdTriage(args) {
1534
1578
  const lastScanPath = statePath(target, 'findings.json');
1535
1579
  if (fs.existsSync(lastScanPath)) {
1536
1580
  const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
1537
- triage.syncWithScan(target, last.findings || []);
1581
+ triage.syncWithScan(target, last.findings || [], { scanHealth: last.scanHealth || null });
1538
1582
  }
1539
1583
  if (sub === 'list') {
1540
1584
  const filter = {};
@@ -2587,8 +2631,8 @@ async function cmdCalibrationReport(args) {
2587
2631
  // Languages the Layer-1 IR parses. Anything else cannot be partitioned into a
2588
2632
  // call-graph focus area, so feeding it to a hunter would spend tokens on files
2589
2633
  // the confirmation gate can never corroborate.
2590
- const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb)$/i;
2591
- const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**'];
2634
+ const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb|l?hs|nix)$/i;
2635
+ const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**', 'dist-newstyle/**', '.stack-work/**', '.direnv/**'];
2592
2636
  const HUNT_MAX_FILES = 2000;
2593
2637
 
2594
2638
  async function cmdHunt(args) {
@@ -3408,6 +3452,41 @@ async function cmdFix(args) {
3408
3452
  return 0;
3409
3453
  }
3410
3454
 
3455
+ // Haskell and Nix findings have their own verified fixers (tiered, rescanned, reversible by `undo`); the generic path below
3456
+ // knows nothing about them and used to answer "no mechanical fix" for every one.
3457
+ {
3458
+ const { languageOfFinding, languageFixPreview, loadLanguageProject, isLanguageDependencyFinding, languageUpgradePreview } = await import('../src/language/context.js');
3459
+ if (isLanguageDependencyFinding(f)) {
3460
+ if (isApply && sigVerified !== true) {
3461
+ console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
3462
+ return 4;
3463
+ }
3464
+ const proj = loadLanguageProject(scanRoot);
3465
+ const res = await languageUpgradePreview(f, { ...proj.files, ...proj.manifests }, { apply: isApply, root: scanRoot, toRef: args.flags.to || null });
3466
+ if (!res.ok) { console.error(`No dependency fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
3467
+ console.log(`${res.label} (${res.tier}): ${res.explanation}`);
3468
+ console.log(res.diff);
3469
+ for (const c of res.consequences || []) console.log(` note: ${c}`);
3470
+ console.log(isApply ? '\nApplied. This is a source edit only: re-resolve or re-lock, then rescan. Revert with `agentic-security undo`.' : '\nRun with --apply to write this change. Use `agentic-security undo` to revert.');
3471
+ return 0;
3472
+ }
3473
+ if (languageOfFinding(f)) {
3474
+ if (isApply && sigVerified !== true) {
3475
+ console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
3476
+ return 4;
3477
+ }
3478
+ const proj = loadLanguageProject(scanRoot);
3479
+ const res = await languageFixPreview(f, proj.files, { apply: isApply, root: scanRoot });
3480
+ if (!res.ok) { console.error(`No verified fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
3481
+ console.log(`${res.label || 'FIX'}${res.tier ? ` (${res.tier})` : ''}: ${res.explanation || ''}`.trim());
3482
+ console.log(res.diff);
3483
+ for (const c of res.consequences || []) console.log(` note: ${c}`);
3484
+ if (isApply) { console.log(`\nApplied. Verified: ${Object.entries(res.gates || {}).map(([k, v]) => `${k}=${v && v.ok === false ? 'FAILED' : (v && v.ran === false ? 'not-run' : 'ok')}`).join(' ')}. Revert with \`agentic-security undo\`.`); }
3485
+ else console.log('\nVerified (syntax, rescan: the finding is gone and nothing new appears). Run with --apply to write this change. Use `agentic-security undo` to revert.');
3486
+ return 0;
3487
+ }
3488
+ }
3489
+
3411
3490
  // FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
3412
3491
  // before the write — a traversal or symlink-planted f.file was previously
3413
3492
  // followed unquestioned for both the preview diff and the apply write (no
@@ -5558,6 +5637,21 @@ async function cmdGovernancePropose(args) {
5558
5637
  // through to main()'s own outer catch/process.exit(4), the identical,
5559
5638
  // deliberate non-pattern cmdGovernancePropose itself relies on (no local
5560
5639
  // try/catch here either).
5640
+ function _federateEndpointScope(node, target, config, graph) {
5641
+ // Node-level limitations when present, plus those of every flow that starts or ends at the node: a Nix or Haskell
5642
+ // flow states its own limits (static configuration evidence, generated-script locations) and a link must not lose them.
5643
+ const lim = new Set(Array.isArray(node && node.limitations) ? node.limitations.filter((x) => typeof x === 'string') : []);
5644
+ for (const f of (graph && Array.isArray(graph.flows) ? graph.flows : [])) {
5645
+ if (f && (f.source === (node && node.id) || f.sink === (node && node.id)) && Array.isArray(f.limitations)) for (const x of f.limitations) if (typeof x === 'string') lim.add(x);
5646
+ }
5647
+ return {
5648
+ target: typeof target === 'string' ? target : null,
5649
+ config: typeof config === 'string' ? config : null,
5650
+ analysis: (node && node.analysis && typeof node.analysis === 'object') ? node.analysis : null,
5651
+ limitations: [...lim].sort(),
5652
+ };
5653
+ }
5654
+
5561
5655
  async function cmdFederateDeclare(args) {
5562
5656
  const target = args._[2] || '.'; // args._ = ['federate', 'declare', <path>?]
5563
5657
  const targetAbs = path.resolve(target);
@@ -5680,6 +5774,14 @@ async function cmdFederateDeclare(args) {
5680
5774
  sourceFile: path.resolve(remoteGraphFlag),
5681
5775
  graphId: remote.graph.graphId, graphDigest: remote.digest, nodeId: remoteNodeFlag,
5682
5776
  },
5777
+ // Scope and limitations of both endpoints, copied from the nodes themselves (a Haskell/Nix configuration node says it
5778
+ // is static configuration evidence), plus the operator's own target/config labels. A declared link is an assertion by
5779
+ // an operator, never an observed data flow, and the record says so.
5780
+ scope: {
5781
+ local: _federateEndpointScope(localNode, args.flags['local-target'], args.flags['local-config'], local.graph),
5782
+ remote: _federateEndpointScope(remoteNode, args.flags['remote-target'], args.flags['remote-config'], remote.graph),
5783
+ },
5784
+ evidence: { declared: true, observed: false },
5683
5785
  rationale: args.flags.rationale ?? null,
5684
5786
  declaredBy: process.env.USER || process.env.USERNAME || '(unspecified)',
5685
5787
  declaredAt: new Date().toISOString(),
@@ -5798,6 +5900,8 @@ async function cmdFederateList(args) {
5798
5900
  id: record?.id ?? null,
5799
5901
  local: { nodeId: record?.local?.nodeId ?? null, stillValid: localStillValid },
5800
5902
  remote: { sourceFile: sourceFile ?? null, nodeId: record?.remote?.nodeId ?? null, ...remoteStatus },
5903
+ scope: record?.scope ?? null,
5904
+ evidence: record?.evidence ?? { declared: true, observed: false },
5801
5905
  rationale: record?.rationale ?? null,
5802
5906
  declaredBy: record?.declaredBy ?? null,
5803
5907
  declaredAt: record?.declaredAt ?? null,
@@ -8,9 +8,10 @@ export const modules = {
8
8
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
9
  /* harmony export */ sweepGitHistory: () => (/* binding */ sweepGitHistory)
10
10
  /* harmony export */ });
11
- /* unused harmony exports extractAddedLines, scanHistoryDiff */
11
+ /* unused harmony exports extractAddedLines, splitDiffByFile, scanHistoryDiff */
12
12
  /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(31421);
13
13
  /* harmony import */ var _util_git_hardening_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(78844);
14
+ /* harmony import */ var _language_secrets_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(95108);
14
15
  // R15 (PRD §5) — git-history secret sweep.
15
16
  //
16
17
  // A secret removed from HEAD but present in any past commit is still
@@ -27,6 +28,7 @@ export const modules = {
27
28
 
28
29
 
29
30
 
31
+
30
32
  // Pull the post-image (added) lines out of a unified diff: lines starting with
31
33
  // a single '+' (not the '+++' file header). Returns reconstructed text.
32
34
  function extractAddedLines(diffText) {
@@ -37,25 +39,42 @@ function extractAddedLines(diffText) {
37
39
  return out.join('\n');
38
40
  }
39
41
 
42
+ // Split a unified diff into per-file sections with the POST-IMAGE line number of every added line, so a
43
+ // finding can name the file and line it was committed at (the combined text alone loses both).
44
+ function splitDiffByFile(diffText) {
45
+ const files = [];
46
+ let cur = null; let next = 0;
47
+ for (const line of String(diffText || '').split('\n')) {
48
+ const h = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
49
+ if (h) { cur = { path: h[2], added: [] }; files.push(cur); next = 0; continue; }
50
+ if (!cur) continue;
51
+ const hh = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
52
+ if (hh) { next = parseInt(hh[1], 10); continue; }
53
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
54
+ if (line.startsWith('+')) { cur.added.push({ text: line.slice(1), line: next }); next++; }
55
+ else if (!line.startsWith('-') && !line.startsWith('\\')) next++;
56
+ }
57
+ return files;
58
+ }
59
+
40
60
  // Pure: run the injected credential detector over a commit's added lines.
41
- // detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
42
- function scanHistoryDiff(diffText, commit, detectFn) {
61
+ // detectFn has the scanCredentials(fp, raw) shape and returns Finding[]. `extra` are additional detectors
62
+ // ((path, text) => Finding[]) run against each file's added lines WITH the real path, so extension-gated
63
+ // detectors (Haskell/Nix split-secret) apply to history too.
64
+ function scanHistoryDiff(diffText, commit, detectFn, extra = []) {
43
65
  if (typeof detectFn !== 'function') return [];
44
- const added = extractAddedLines(diffText);
45
- if (!added.trim()) return [];
46
- let findings = [];
47
- try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
48
- return findings.map((f) => {
49
- const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
50
- return {
51
- ...f,
66
+ const sections = splitDiffByFile(diffText);
67
+ const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
68
+ const wrap = (f, sec, addedLines) => {
69
+ const hit = Number.isInteger(f.line) && f.line >= 1 ? addedLines[f.line - 1] : null;
70
+ const prov = f.rotation ? {} : (0,_language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .providerInfo */ .cf)(f._rawProviderValue || '');
71
+ const out = {
72
+ ...f, ...prov,
52
73
  id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
53
- file: `git-history@${commit}`,
54
- line: 0,
55
- commit,
56
- _historical: true,
74
+ file: `git-history@${commit}`, line: 0, commit, _historical: true,
75
+ ...(sec ? { sourceFile: sec.path, sourceLine: hit ? hit.line : null } : {}),
57
76
  vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
58
- description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
77
+ description: `${f.description || 'A credential was committed.'} Found in commit ${commit}${sec ? ` (${sec.path}${hit ? `:${hit.line}` : ''})` : ''}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
59
78
  remediation,
60
79
  // report/index.js's _remediationOf checks `.fix` before `.remediation`
61
80
  // — the underlying detector already set `.fix` to a generic "remove
@@ -64,15 +83,41 @@ function scanHistoryDiff(diffText, commit, detectFn) {
64
83
  // insufficient") in every report format.
65
84
  fix: remediation,
66
85
  };
67
- });
86
+ delete out._rawProviderValue;
87
+ return out;
88
+ };
89
+ const results = [];
90
+ if (!sections.length) {
91
+ const added = extractAddedLines(diffText);
92
+ if (!added.trim()) return [];
93
+ let findings = [];
94
+ try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
95
+ return findings.map((f) => wrap(f, null, []));
96
+ }
97
+ for (const sec of sections) {
98
+ if (!sec.added.length) continue;
99
+ const text = sec.added.map((a) => a.text).join('\n');
100
+ if (!text.trim()) continue;
101
+ let findings = [];
102
+ try { findings = detectFn(`git-history@${commit}`, text) || []; } catch { findings = []; }
103
+ for (const f of findings) results.push(wrap(f, sec, sec.added));
104
+ for (const ex of extra || []) {
105
+ let more = [];
106
+ try { more = ex(sec.path, text) || []; } catch { more = []; }
107
+ for (const f of more) results.push(wrap(f, sec, sec.added));
108
+ }
109
+ }
110
+ return results;
68
111
  }
69
112
 
113
+ const defaultExtra = [_language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .scanLanguageSecretConcat */ ._d, _language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .scanDependencyUrlCredentials */ .y6];
114
+
70
115
  /**
71
116
  * Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
72
117
  * [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
73
118
  * that recurs across commits to its earliest sighting.
74
119
  */
75
- function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
120
+ function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000, extraDetectors = null } = {}) {
76
121
  if (!scanRoot || typeof detectFn !== 'function') return [];
77
122
  let out;
78
123
  try {
@@ -91,7 +136,7 @@ function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 2000
91
136
  const seen = new Set();
92
137
  for (let i = 1; i < parts.length; i += 2) {
93
138
  const sha = (parts[i] || '').slice(0, 12);
94
- for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
139
+ for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn, extraDetectors || defaultExtra)) {
95
140
  const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
96
141
  if (seen.has(key)) continue;
97
142
  seen.add(key);
@@ -13,6 +13,8 @@ __webpack_require__.d(__webpack_exports__, {
13
13
 
14
14
  // UNUSED EXPORTS: makeBudget, makeTaintProbe
15
15
 
16
+ // EXTERNAL MODULE: ./src/language/discovery.js
17
+ var discovery = __webpack_require__(30951);
16
18
  // EXTERNAL MODULE: external "node:crypto"
17
19
  var external_node_crypto_ = __webpack_require__(77598);
18
20
  ;// CONCATENATED MODULE: ./src/discovery/partition.js
@@ -32,6 +34,7 @@ var external_node_crypto_ = __webpack_require__(77598);
32
34
  // comes straight back.
33
35
 
34
36
 
37
+
35
38
  function focusAreaId(files) {
36
39
  const canon = [...new Set(files || [])].sort().join('\n');
37
40
  return external_node_crypto_.createHash('sha256').update(canon).digest('hex').slice(0, 12);
@@ -109,6 +112,33 @@ function partitionCallGraph(callGraph, opts = {}) {
109
112
  return areas;
110
113
  }
111
114
 
115
+ /**
116
+ * Focus areas for Nix configuration, which has no call graph: files joined by `imports` / path references form one
117
+ * area, so a NixOS entry point and the modules that decide its options are hunted together. Same shape as a call-graph
118
+ * area (`functions` is empty: a Nix file has none).
119
+ */
120
+ function partitionNixFiles(fileContents, opts = {}) {
121
+ const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
122
+ const nix = {};
123
+ for (const [f, t] of Object.entries(fileContents || {})) if (/\.nix$/i.test(f) && typeof t === 'string') nix[f] = t;
124
+ const files = Object.keys(nix).sort();
125
+ if (!files.length) return [];
126
+ const { graph } = (0,discovery/* buildImportGraph */.oK)(nix);
127
+ const dsu = makeDSU();
128
+ for (const f of files) dsu.find(f);
129
+ for (const [f, deps] of graph) for (const d of deps) dsu.union(f, d);
130
+ const groups = new Map();
131
+ for (const f of files) { const r = dsu.find(f); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(f); }
132
+ let areas = [...groups.values()].map((g) => { const sorted = [...g].sort(); return { id: focusAreaId(sorted), label: labelFor(sorted), files: sorted, functions: [], size: sorted.length, language: 'nix' }; });
133
+ areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
134
+ if (areas.length > maxAreas) {
135
+ const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1).flatMap((a) => a.files).sort();
136
+ kept.push({ id: focusAreaId(tail), label: 'misc', files: tail, functions: [], size: tail.length, language: 'nix' });
137
+ areas = kept;
138
+ }
139
+ return areas;
140
+ }
141
+
112
142
  // EXTERNAL MODULE: ./src/discovery/lenses.js
113
143
  var discovery_lenses = __webpack_require__(33499);
114
144
  // EXTERNAL MODULE: ./src/egress/policy.js
@@ -1057,7 +1087,16 @@ function makeBudget(opts = {}, now = Date.now) {
1057
1087
  }
1058
1088
 
1059
1089
  async function runDiscovery(ctx = {}, opts = {}) {
1060
- const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
1090
+ // Call-graph areas (every language the IR covers, Haskell included) plus Nix configuration areas, which have no call
1091
+ // graph. Together they respect the same ceiling.
1092
+ const maxAreas = opts.maxAreas ?? 8;
1093
+ let areas = [...partitionCallGraph(ctx.callGraph, { maxAreas }), ...partitionNixFiles(ctx.fileContents, { maxAreas })];
1094
+ if (areas.length > maxAreas) {
1095
+ const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1);
1096
+ const files = [...new Set(tail.flatMap((a) => a.files))].sort();
1097
+ kept.push({ id: tail.map((a) => a.id).join('+').slice(0, 64), label: 'misc', files, functions: tail.flatMap((a) => a.functions), size: tail.reduce((n, a) => n + a.size, 0) });
1098
+ areas = kept;
1099
+ }
1061
1100
 
1062
1101
  const reasons = [];
1063
1102
  const budget = makeBudget(opts);
@@ -1095,7 +1134,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
1095
1134
  for (const area of areas) {
1096
1135
  let areaDegradedCount = 0;
1097
1136
  for (const lens of lenses) {
1098
- const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
1137
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {}, scanRoot: opts.scanRoot }, { llmInvoke, scanRoot: opts.scanRoot });
1099
1138
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
1100
1139
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
1101
1140
  if (run.degraded) areaDegradedCount += 1;
@@ -1267,6 +1306,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
1267
1306
  /* harmony export */ LENSES: () => (/* binding */ LENSES),
1268
1307
  /* harmony export */ j: () => (/* binding */ buildHunterPrompt)
1269
1308
  /* harmony export */ });
1309
+ /* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(74831);
1270
1310
  //
1271
1311
  // The seven hunting lenses. Each hunter run is one (focus area × lens) pair.
1272
1312
  //
@@ -1276,6 +1316,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
1276
1316
  // same code than one told to look at crypto, so the union covers failure modes
1277
1317
  // no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
1278
1318
  // ceiling, and the classes worth finding are the ones not on the list.
1319
+
1279
1320
  const LENSES = Object.freeze([
1280
1321
  { key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
1281
1322
  brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
@@ -1308,7 +1349,9 @@ function buildHunterPrompt(focusArea, lens, ctx = {}) {
1308
1349
  let budget = maxChars;
1309
1350
  const blocks = [];
1310
1351
  for (const f of files) {
1311
- const src = contents[f];
1352
+ // Everything that leaves the machine is redacted first (secrets, PII/PHI/PCI-shaped fields, operator-declared
1353
+ // customer data, and whole proprietary paths), by the same choke point the validator and the proposers use.
1354
+ const src = (0,_egress_redact_js__WEBPACK_IMPORTED_MODULE_0__/* .redactPayload */ .cy)({ text: contents[f], filePath: f, scanRoot: ctx.scanRoot || null }).text;
1312
1355
  const slice = src.length > budget ? src.slice(0, Math.max(0, budget)) : src;
1313
1356
  const truncated = slice.length < src.length;
1314
1357
  blocks.push(`--- SOURCE FILE (untrusted data): ${f}${truncated ? ' (truncated)' : ''} ---\n${slice}\n--- END SOURCE FILE: ${f} ---`);