@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. package/src/sast/pipeline.js +34 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,53 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.155.0 - premortem remediation (trust boundary, unseen-shape measurement), NixOS VM check
13
+
14
+ An adversarial premortem of 0.154.0 found the following; each is fixed here and covered by `test/language/language-trust-boundary.test.js`.
15
+
16
+ - **Trust boundary.** Advisory snapshots are read only from the operator (environment variable or `$XDG_CONFIG_HOME/agentic-security/`). A snapshot inside the scanned project is ignored and the scan says so, so a repository can no longer ship an empty feed to look clean. Nix export freshness is derived by the scanner from `flake.lock`, project-supplied exports and plans are disclosed, a refused export contributes nothing, and a Cabal plan that contradicts the freeze file is flagged.
17
+ - **Unseen-shape split.** The holdout shares its code shapes with development, so it measures robustness to renaming only. A new `unseen` split (300 author-labelled cases, 3 new vulnerable and 3 new safe shapes per family) is measured once and never tuned against. Its result is stored beside every holdout row with 95% Clopper-Pearson intervals and strict precision. It is **below target** for Haskell SAST (R 79.2%), Haskell taint (P 77.8%) and Nix config-sast (P 86.1%); those are engine gaps, not fixed here. The `auth` row is now promoted from its own holdout evidence.
18
+ - **Completion honesty.** A release check (`completion-declared`) fails when `docs/completion-status.json` lists unverified requirements and the changelog has no paragraph naming them. Chrome-dependent export tests are a separate informational job rather than a silent skip.
19
+ - **Nix package fix (found by the first hosted NixOS guest run).** The packaged `agentic-security-mcp` and `agentic-security-lsp` wrappers ran `bin/*.js`, which import `src/` and therefore npm dependencies the Nix package does not ship, so both would have crashed for a Nix user. They now run from the self-contained bundle, and the CLI gained an `lsp` subcommand (the same server the editor plugins start). The flake's package source is now exactly the files it installs, so an offline rebuild elsewhere reproduces the same derivation.
20
+ - **Destructive-command guard.** `hooks/pre-bash-guard.js` judged `rm` by text, so it refused any absolute path (and `docker run --rm -v /abs/...`) while missing nothing it should. Deletion (`rm`, `rmdir`, `unlink`, `find -delete`) is now path-aware: targets are parsed and resolved (`~`, `$HOME`, relative paths, `cd`, symlinks) and allowed only strictly inside an allowed root (default `~/code`, set with `allowedRoots` in `.agentic-security/destructive-guard.json`). Anything outside, anything unresolvable (`$VAR`, command substitution, `xargs`) and the root itself is refused. It cannot see deletion done inside another program, so it guards against mistakes and is not a sandbox. Pinned by `test/pre-bash-guard.test.js`.
21
+ - **Not done.** Peak scan memory is measured under a stated heap cap and was **not reduced**: a lazy-token experiment retained less but changed no measurement, so it was reverted. `flake.nix` is now locked (`flake.lock`), built and checked on aarch64-linux in a `nixos/nix` container (Nix 2.35.2) and on x86_64-linux on a hosted runner; aarch64-darwin is declared but unbuilt, and x86_64-darwin is dropped (current nixpkgs no longer supports it).
22
+
23
+ Partial release: the loop controller lists NIX-011, NIX-012 and REL-001 as not verified. NIX-011 and NIX-012 passed on hosted CI in this release's pull request (the NIX-011 suite on Nix 2.24.10; the NIX-012 suite inside a real NixOS guest booted by the flake's `nixos-host` check on an x86_64 runner with KVM), but that is CI evidence, not controller-signed evidence, because the controller runs where `nix` and `/etc/NIXOS` are absent. The aarch64 NixOS guest was not run: the arm runner has no KVM and a software-emulated guest does not boot in any usable time. REL-001 is the controller's own final acceptance and needs those two to be controller-verified first.
24
+
25
+ ## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
26
+
27
+ Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
28
+ network. This entry records what is **measured** and what is **not**; nothing here announces completion beyond the evidence.
29
+
30
+ - **Analysis.** Dependency-free Haskell and Nix parsers over SHA-256-pinned grammar tables; Haskell taint (SQL, command, path,
31
+ SSRF, HTML), weak crypto, logging, resource and web-route authentication rules; the NixOS effective-configuration resolver with
32
+ hardening, build-trust and store-secret rules; Haskell and Nix personal-data flows in the Data Flow Explorer; a Haskell
33
+ catalog generated from one model registry. Unresolved constructs, conditional options, stale inputs and missing feeds become
34
+ scan-health conditions, so a scan that could not check something is `partial`, never complete.
35
+ - **Supply chain and BOM.** Hackage components with PVP-aware advisory matching (a snapshot you provide), a resolved graph from a
36
+ fresh Cabal plan or Stack export, Nix flake inputs, an exported closure matched by upstream identity with patch evidence, and
37
+ AI-BOM entries for model calls and inference services. License data is absent for these records and is disclosed, not guessed.
38
+ - **Fixes.** Verified, tiered, reversible fixes for five Haskell shapes and for Nix effective settings, plus dependency-bound
39
+ and flake-input edits labelled as needing a re-resolve or re-lock. The planner now follows a flow into the callee that holds
40
+ the dangerous call. A fix a gate refuses is reported, not applied.
41
+ - **CLI reach.** `fix` and `undo` now work for these languages, `hunt` includes `.hs`/`.nix`, `--since-baseline` filters every
42
+ bucket (a baselined dependency finding no longer reappears as new), the edit hook catches credentials split across literals, and
43
+ a Cabal or Nix project is recognised as a project root for state.
44
+ - **Support, measured.** `docs/language-support.md` records per-capability status from a frozen holdout. On the synthetic,
45
+ template-generated QA-001 corpus every measured layer scores P/R/F1 100%, which says the engine is robust over those shapes and
46
+ **not** that it is accurate on arbitrary real code. `auth` (Haskell) and `nix-eval` / `nixos-host` (Nix) are **blocked**: no
47
+ `ghc`, `nix` or NixOS host was available where this was built.
48
+ - **Tooling.** The `npm test` gate now runs the Haskell, Nix and language scopes; `language-stress` and `language-tools` have
49
+ their own scripts. New CI jobs (`language-suites`, `language-tools-ghc`, `nixos-runtime`) carry explicit deadlines; a
50
+ `language-support-gate` joins pre-push and the release check. A bounded background-loop controller and runbook
51
+ (`docs/guides/loop-engineering.md`) are a development tool, not part of the package.
52
+ - **Docs.** New Haskell, Nix/NixOS, NixOS-install and loop guides; tables and example outputs are generated from the registries and
53
+ from the built bundle, and stale ones fail `npm run docs:check-language`.
54
+ - **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
55
+ real controlled evaluation) and NIX-012 (the scanner on a NixOS host, VM checks, and the new `flake.nix`) need Nix and NixOS, which
56
+ were never available where this was built: they stay blocked rather than faked, and the flake has never been built. The CI jobs
57
+ that would exercise them are written but have not run.
58
+
12
59
  ## 0.153.2 - Scanner F1 benchmark and dependency-currency CI repaired
13
60
 
14
61
  The daily `Scanner F1 benchmark` workflow had failed every run since about 2026-08-10. Root causes, each
@@ -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.
@@ -125,6 +137,7 @@ Commands:
125
137
  tickets sync --provider <p> Two-way sync findings ↔ GitHub Issues / Linear / Jira
126
138
  digest --slack <webhook> Vibecoder: send daily digest to Slack
127
139
  mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
140
+ lsp Start the Language Server Protocol server on stdio (what the editor plugins run)
128
141
  validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
129
142
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
130
143
  ask "<question>" [target] Bounded local-model Q&A with read-only tool access (requires AGENTIC_SECURITY_LLM_PRESET=ollama)
@@ -194,6 +207,8 @@ Commands:
194
207
  --remote-node <node-id> [--repository <label>]
195
208
  [--relationship data_flow] [--rationale <text>]
196
209
  [--output <file>] [--yes] [--base-digest <hex>]
210
+ [--local-target <t>] [--local-config <c>]
211
+ [--remote-target <t>] [--remote-config <c>]
197
212
  Declare a CrossRepoLink between a node in the
198
213
  current locally-scanned graph and a node in a
199
214
  remote graph export (dataflow export --format
@@ -234,7 +249,7 @@ Commands:
234
249
  Options:
235
250
  --profile vibecoder|pro Override profile for this run
236
251
  --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
252
+ --format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md | mlbom
238
253
  --pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
239
254
  --baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
240
255
  --fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
@@ -470,12 +485,12 @@ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
470
485
  }
471
486
  await fsp.mkdir(stateDirPath, { recursive: true });
472
487
  // Always JSON (used by /security-fix and /security-report).
473
- await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
488
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
474
489
  JSON.stringify(toJSON(scan, meta), null, 2));
475
490
  if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
476
- await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
491
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
477
492
  JSON.stringify(toSARIF(scan, meta), null, 2));
478
- await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
493
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
479
494
  }
480
495
  }
481
496
 
@@ -613,6 +628,12 @@ async function cmdScan(args) {
613
628
  // Load persona profile (R1). Persona-aware defaults flow from here.
614
629
  const profile = loadPersonaProfile(targetAbs, args);
615
630
  const format = args.flags.format || (profile.profile === 'pro' ? 'cli' : 'ship');
631
+ // An output label this command does not produce is an error, not a quiet fallback to the summary: a CI job that
632
+ // asked for `sarif ` or `junit-xml` and got human text would upload garbage and report success.
633
+ if (!SCAN_FORMATS.has(String(format))) {
634
+ process.stderr.write(`agentic-security scan: unsupported --format ${JSON.stringify(String(format))}. Supported: ${[...SCAN_FORMATS].join(' | ')}.\n`);
635
+ return 2;
636
+ }
616
637
  const verbose = !!args.flags.verbose;
617
638
  const output = args.flags.output;
618
639
  const noNet = !!args.flags['no-network'];
@@ -782,6 +803,9 @@ async function cmdScan(args) {
782
803
  if (args.flags['set-baseline']) {
783
804
  const { normalizeFindings } = await import('../src/report/index.js');
784
805
  const baselineIds = new Set(normalizeFindings(scan).map(f => f.stableId || f.id));
806
+ // A supply-chain, secret or logic finding's normalised id includes its line, so an unrelated edit above it would make it
807
+ // look new. Its own producer id (when it has one) does not, so both are recorded.
808
+ 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
809
  fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
786
810
  fs.writeFileSync(baselinePath, JSON.stringify({ ids: [...baselineIds], createdAt: new Date().toISOString(), count: baselineIds.size }, null, 2));
787
811
  process.stderr.write(`[baseline] saved ${baselineIds.size} findings as baseline\n`);
@@ -793,6 +817,20 @@ async function cmdScan(args) {
793
817
  const baselineSet = new Set(baseline.ids || []);
794
818
  const before = scan.findings.length;
795
819
  scan.findings = (scan.findings || []).filter(f => !baselineSet.has(f.stableId || f.id));
820
+ // The baseline is built from every bucket (findings, secrets, supply chain, logic), so it must be applied to every bucket:
821
+ // filtering only `findings` left a baselined dependency or secret finding reappearing as "new" on every run.
822
+ // Identity is the one the baseline was written with: the NORMALISED id (raw supply-chain entries carry a different one).
823
+ const { normalizeFindings: _norm } = await import('../src/report/index.js');
824
+ const _empty = { findings: [], secrets: [], supplyChain: [], logicVulns: [], suppressions: [], components: [], routes: [], filesScanned: 0 };
825
+ for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) {
826
+ if (!Array.isArray(scan[bucket])) continue;
827
+ scan[bucket] = scan[bucket].filter((f) => {
828
+ if (!f) return true;
829
+ let id = f.stableId || f.id;
830
+ try { const n = _norm({ ..._empty, [bucket]: [f] })[0]; if (n) id = n.stableId || n.id; } catch { /* keep the raw identity */ }
831
+ return !baselineSet.has(id) && !(typeof f.id === 'string' && f.id.includes(':') && baselineSet.has(f.id));
832
+ });
833
+ }
796
834
  process.stderr.write(`[baseline] filtered ${before - scan.findings.length} baseline findings, ${scan.findings.length} new\n`);
797
835
  } catch { /* baseline file unreadable, skip */ }
798
836
  }
@@ -997,9 +1035,10 @@ async function cmdScan(args) {
997
1035
  else if (format === 'cyclonedx' || format === 'sbom') body = JSON.stringify(toCycloneDX(scan, meta), null, 2);
998
1036
  else if (format === 'spdx') body = JSON.stringify(toSPDX(scan, meta), null, 2);
999
1037
  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));
1038
+ 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);
1039
+ else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), null, 2);
1040
+ else if (format === 'mlbom') body = JSON.stringify(toCycloneDXMLBOM(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), meta), null, 2);
1041
+ else if (format === 'aibom-md') body = aibomToMarkdown(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }));
1003
1042
  else if (format === 'ship') body = toShipVerdict(scan, { profile: effProfile });
1004
1043
  else if (format === 'pro') body = toProTable(scan, { profile: effProfile, columns: args.flags.columns });
1005
1044
  else if (format === 'cli') body = toCLIByProfile(scan, { profile: effProfile, columns: args.flags.columns, verbose });
@@ -1143,10 +1182,16 @@ async function cmdScan(args) {
1143
1182
  } catch { /* best-effort, offline-degrading — never block a scan */ }
1144
1183
  }
1145
1184
  const lastScanBody = JSON.stringify(persistedScan, null, 2);
1146
- await fsp.writeFile(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
1185
+ await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
1147
1186
  try {
1148
- await fsp.writeFile(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
1187
+ await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
1149
1188
  } catch { /* non-fatal — sig file is best-effort */ }
1189
+ // X-016: Haskell/Nix analysis metadata and BOM as registered, signed artifacts. Written only when the scan has
1190
+ // Haskell or Nix content, so every other project's state directory is unchanged.
1191
+ try {
1192
+ const { persistLanguageArtifacts } = await import('../src/language/state-artifacts.js');
1193
+ persistLanguageArtifacts(path.resolve(target), { ...scan, scanHealth: persistedScan.scanHealth || scan.scanHealth });
1194
+ } catch { /* non-fatal: metadata artifacts never block a scan */ }
1150
1195
  // Sub-project E, increment 5: persist the lineage graph as its own
1151
1196
  // artifact, mirroring last-scan.json's own write+sign pattern exactly
1152
1197
  // — signLastScan/verifyLastScan are fully generic (an arbitrary string
@@ -1158,9 +1203,9 @@ async function cmdScan(args) {
1158
1203
  if (scan.lineageGraph) {
1159
1204
  try {
1160
1205
  const lineageBody = JSON.stringify(scan.lineageGraph, null, 2);
1161
- await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
1206
+ await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
1162
1207
  try {
1163
- await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
1208
+ await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
1164
1209
  } catch { /* non-fatal — sig file is best-effort, same precedent as last-scan.json.sig above */ }
1165
1210
  } catch { /* non-fatal — the lineage artifact write is best-effort and must never block a scan */ }
1166
1211
  // M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): ADDITIVELY
@@ -1301,11 +1346,11 @@ async function cmdCi(args) {
1301
1346
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
1302
1347
  } else {
1303
1348
  await fsp.mkdir(stateDirPath, { recursive: true });
1304
- await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
1349
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
1305
1350
  JSON.stringify(toJSON(scan, meta), null, 2));
1306
- await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
1351
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
1307
1352
  JSON.stringify(toSARIF(scan, meta), null, 2));
1308
- await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
1353
+ await _writeStateNoFollow(path.join(stateDirPath, 'findings.junit.xml'),
1309
1354
  toJUnit(scan, meta));
1310
1355
  }
1311
1356
 
@@ -1534,7 +1579,7 @@ async function cmdTriage(args) {
1534
1579
  const lastScanPath = statePath(target, 'findings.json');
1535
1580
  if (fs.existsSync(lastScanPath)) {
1536
1581
  const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
1537
- triage.syncWithScan(target, last.findings || []);
1582
+ triage.syncWithScan(target, last.findings || [], { scanHealth: last.scanHealth || null });
1538
1583
  }
1539
1584
  if (sub === 'list') {
1540
1585
  const filter = {};
@@ -2587,8 +2632,8 @@ async function cmdCalibrationReport(args) {
2587
2632
  // Languages the Layer-1 IR parses. Anything else cannot be partitioned into a
2588
2633
  // call-graph focus area, so feeding it to a hunter would spend tokens on files
2589
2634
  // 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/**'];
2635
+ const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb|l?hs|nix)$/i;
2636
+ const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**', 'dist-newstyle/**', '.stack-work/**', '.direnv/**'];
2592
2637
  const HUNT_MAX_FILES = 2000;
2593
2638
 
2594
2639
  async function cmdHunt(args) {
@@ -3408,6 +3453,41 @@ async function cmdFix(args) {
3408
3453
  return 0;
3409
3454
  }
3410
3455
 
3456
+ // Haskell and Nix findings have their own verified fixers (tiered, rescanned, reversible by `undo`); the generic path below
3457
+ // knows nothing about them and used to answer "no mechanical fix" for every one.
3458
+ {
3459
+ const { languageOfFinding, languageFixPreview, loadLanguageProject, isLanguageDependencyFinding, languageUpgradePreview } = await import('../src/language/context.js');
3460
+ if (isLanguageDependencyFinding(f)) {
3461
+ if (isApply && sigVerified !== true) {
3462
+ 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.`);
3463
+ return 4;
3464
+ }
3465
+ const proj = loadLanguageProject(scanRoot);
3466
+ const res = await languageUpgradePreview(f, { ...proj.files, ...proj.manifests }, { apply: isApply, root: scanRoot, toRef: args.flags.to || null });
3467
+ if (!res.ok) { console.error(`No dependency fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
3468
+ console.log(`${res.label} (${res.tier}): ${res.explanation}`);
3469
+ console.log(res.diff);
3470
+ for (const c of res.consequences || []) console.log(` note: ${c}`);
3471
+ 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.');
3472
+ return 0;
3473
+ }
3474
+ if (languageOfFinding(f)) {
3475
+ if (isApply && sigVerified !== true) {
3476
+ 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.`);
3477
+ return 4;
3478
+ }
3479
+ const proj = loadLanguageProject(scanRoot);
3480
+ const res = await languageFixPreview(f, proj.files, { apply: isApply, root: scanRoot });
3481
+ if (!res.ok) { console.error(`No verified fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
3482
+ console.log(`${res.label || 'FIX'}${res.tier ? ` (${res.tier})` : ''}: ${res.explanation || ''}`.trim());
3483
+ console.log(res.diff);
3484
+ for (const c of res.consequences || []) console.log(` note: ${c}`);
3485
+ 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\`.`); }
3486
+ 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.');
3487
+ return 0;
3488
+ }
3489
+ }
3490
+
3411
3491
  // FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
3412
3492
  // before the write — a traversal or symlink-planted f.file was previously
3413
3493
  // followed unquestioned for both the preview diff and the apply write (no
@@ -5558,6 +5638,21 @@ async function cmdGovernancePropose(args) {
5558
5638
  // through to main()'s own outer catch/process.exit(4), the identical,
5559
5639
  // deliberate non-pattern cmdGovernancePropose itself relies on (no local
5560
5640
  // try/catch here either).
5641
+ function _federateEndpointScope(node, target, config, graph) {
5642
+ // Node-level limitations when present, plus those of every flow that starts or ends at the node: a Nix or Haskell
5643
+ // flow states its own limits (static configuration evidence, generated-script locations) and a link must not lose them.
5644
+ const lim = new Set(Array.isArray(node && node.limitations) ? node.limitations.filter((x) => typeof x === 'string') : []);
5645
+ for (const f of (graph && Array.isArray(graph.flows) ? graph.flows : [])) {
5646
+ 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);
5647
+ }
5648
+ return {
5649
+ target: typeof target === 'string' ? target : null,
5650
+ config: typeof config === 'string' ? config : null,
5651
+ analysis: (node && node.analysis && typeof node.analysis === 'object') ? node.analysis : null,
5652
+ limitations: [...lim].sort(),
5653
+ };
5654
+ }
5655
+
5561
5656
  async function cmdFederateDeclare(args) {
5562
5657
  const target = args._[2] || '.'; // args._ = ['federate', 'declare', <path>?]
5563
5658
  const targetAbs = path.resolve(target);
@@ -5680,6 +5775,14 @@ async function cmdFederateDeclare(args) {
5680
5775
  sourceFile: path.resolve(remoteGraphFlag),
5681
5776
  graphId: remote.graph.graphId, graphDigest: remote.digest, nodeId: remoteNodeFlag,
5682
5777
  },
5778
+ // Scope and limitations of both endpoints, copied from the nodes themselves (a Haskell/Nix configuration node says it
5779
+ // is static configuration evidence), plus the operator's own target/config labels. A declared link is an assertion by
5780
+ // an operator, never an observed data flow, and the record says so.
5781
+ scope: {
5782
+ local: _federateEndpointScope(localNode, args.flags['local-target'], args.flags['local-config'], local.graph),
5783
+ remote: _federateEndpointScope(remoteNode, args.flags['remote-target'], args.flags['remote-config'], remote.graph),
5784
+ },
5785
+ evidence: { declared: true, observed: false },
5683
5786
  rationale: args.flags.rationale ?? null,
5684
5787
  declaredBy: process.env.USER || process.env.USERNAME || '(unspecified)',
5685
5788
  declaredAt: new Date().toISOString(),
@@ -5798,6 +5901,8 @@ async function cmdFederateList(args) {
5798
5901
  id: record?.id ?? null,
5799
5902
  local: { nodeId: record?.local?.nodeId ?? null, stillValid: localStillValid },
5800
5903
  remote: { sourceFile: sourceFile ?? null, nodeId: record?.remote?.nodeId ?? null, ...remoteStatus },
5904
+ scope: record?.scope ?? null,
5905
+ evidence: record?.evidence ?? { declared: true, observed: false },
5801
5906
  rationale: record?.rationale ?? null,
5802
5907
  declaredBy: record?.declaredBy ?? null,
5803
5908
  declaredAt: record?.declaredAt ?? null,
@@ -6952,6 +7057,13 @@ async function main() {
6952
7057
  runStdio({ sessionRoot: path.resolve(root) });
6953
7058
  return;
6954
7059
  }
7060
+ case 'lsp': {
7061
+ // The same server as bin/agentic-security-lsp.js, reachable from the self-contained bundle: a package that ships only the bundle
7062
+ // (the Nix package does) has no node_modules for the src/ entry point to import from.
7063
+ const { startLspServer } = await import('../src/lsp/server.js');
7064
+ startLspServer();
7065
+ return;
7066
+ }
6955
7067
  case 'explore': process.exit(await cmdExplore(args));
6956
7068
  case 'dataflow': {
6957
7069
  const sub = args._[1];
@@ -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} ---`);