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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +17 -2
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +125 -14
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/pipeline.js +34 -1
@@ -1 +1 @@
1
- dce16625abaef7a52aad4a8d0ca0402d22ea681c9a1479f7378401458e38d55e agentic-security.mjs
1
+ e460f63891e70f53f1a0d6061a5393cc92c5ade0ddde80bd8bfaf8eb00dbb849 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.153.2",
3
+ "version": "0.154.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -65,6 +65,8 @@
65
65
  "prepare": "node scripts/prepare.mjs",
66
66
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs && node ../scripts/release-check.mjs",
67
67
  "test": "node ../scripts/run-unit-tests.mjs && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js && node --experimental-test-module-mocks --test test/fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection-k8s-admission.test.js && node --experimental-test-module-mocks --test test/lineage-fault-injection.test.js && npm run test:python",
68
+ "loop": "node ../scripts/loop-engineering/run.mjs",
69
+ "test:loop": "node --test --test-concurrency=1 ../scripts/loop-engineering/test/loop-manifest.test.js ../scripts/loop-engineering/test/loop-watchdog.test.js ../scripts/loop-engineering/test/loop-background.test.js ../scripts/loop-engineering/test/loop-dashboard.test.js ../scripts/loop-engineering/test/loop-evidence.test.js ../scripts/loop-engineering/test/loop-recovery.test.js ../scripts/loop-engineering/test/loop-runbook.test.js",
68
70
  "test:python": "node ../scripts/run-python-tests.mjs",
69
71
  "test:smoke": "node --test test/smoke.test.js test/demo-app.test.js",
70
72
  "test:ollama-e2e": "AGENTIC_SECURITY_OLLAMA_E2E=1 node --test test/ollama-e2e.test.js",
@@ -77,6 +79,13 @@
77
79
  "test:lineage": "node --test test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js",
78
80
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
79
81
  "test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
82
+ "test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
83
+ "test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
84
+ "test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js",
85
+ "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
86
+ "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
87
+ "bench:language-support:check": "node ../bench/language-support/check.mjs",
88
+ "bench:language-support:perf": "node ../bench/language-support/perf.mjs",
80
89
  "test:lifecycle": "node --test test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
81
90
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
82
91
  "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js",
@@ -187,7 +196,13 @@
187
196
  "bench:independent:merge": "node ../bench/independent/merge-chunks.mjs",
188
197
  "bench:independent:agent-delta": "node ../bench/independent/agent-boundary-delta.mjs",
189
198
  "bench:independent:deep": "node ../bench/independent/runner.mjs --deep",
190
- "test:ci-parity": "CI=true GITHUB_ACTIONS=true node --test test/backward-default.test.js test/ci-parity.test.js test/container-taint.test.js test/deep-taint.test.js test/engine-recall.test.js test/engine-reconnect.test.js test/gate-verdict-cache.test.js test/ignore-pragma.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/py-annotation-sources.test.js test/runscan-deep-option.test.js test/sanitizer-typed-flow.test.js"
199
+ "test:ci-parity": "CI=true GITHUB_ACTIONS=true node --test test/backward-default.test.js test/ci-parity.test.js test/container-taint.test.js test/deep-taint.test.js test/engine-recall.test.js test/engine-reconnect.test.js test/gate-verdict-cache.test.js test/ignore-pragma.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/py-annotation-sources.test.js test/runscan-deep-option.test.js test/sanitizer-typed-flow.test.js",
200
+ "docs:render-language": "node ../scripts/render-language-docs.mjs",
201
+ "docs:capture-language": "node ../scripts/capture-language-examples.mjs",
202
+ "docs:check-language": "node ../scripts/render-language-docs.mjs --check && node ../scripts/capture-language-examples.mjs --check",
203
+ "test:nixos-host": "node --test test/nix/nixos-host-runtime.test.js",
204
+ "test:language-gates": "node --test --test-concurrency=1 test/language/language-benchmark-gates.test.js",
205
+ "test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js"
191
206
  },
192
207
  "author": "Ross Young <ross@clearcapabilities.com>",
193
208
  "license": "PolyForm-Internal-Use-1.0.0",
@@ -98,3 +98,12 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
98
98
  - **`AGENTIC_SECURITY_BLIND_BENCH=1` does NOT disable the deep engine.** It gates the bench-shape detectors; `_deepRequested` in `engine.js` keys on `AGENTIC_SECURITY_DEEP === '1'` alone, so deep mode runs regardless. (This bullet previously claimed the opposite — verified false while building `bench/layer-recall/`, where setting `BLIND_BENCH=1` left taint recall unchanged at 20/210.) To turn the taint engine off, set `AGENTIC_SECURITY_DEEP=0`.
99
99
  - **The corpus barely exercises this engine.** `bench/cve-replay` enables deep mode only for its 6 `deep`-tier entries, and with the deep engine off entirely, corpus detection falls only 210 → 204. Taint quality is measured by `npm run bench:layer-recall` (which forces deep on for all 210), not by the corpus gate — see `docs/METRICS.md`.
100
100
  - **Resolved: `stub-aware-filter.js` no longer mutates `severity`.** It used to (`f.severity = downgrade[f.severity]`, opt-in via `AGENTIC_SECURITY_TYPE_STUBS=1`), which violated the recall-preserving convention every other precision annotator in this directory follows. Flagged by documentation audit, then fixed on maintainer decision: it now demotes `confidence` (× 0.4, floor 0.01) + `confidenceTier` + `exploitabilityTier` by one step each, mirroring `proof-gate.js`'s own demotion shape exactly (`_confidenceBeforeStubFilter` records the pre-demotion value, a distinct field from `proof-gate.js`'s `_confidenceBeforeProofGate` so the two compose if both fire on the same finding). Severity is never touched.
101
+
102
+ ## Haskell catalog
103
+
104
+ `catalog-haskell.js` is **generated** from `language/haskell-models.js` (one registry of sources, sinks and sanitizers; entries
105
+ match import-qualified callees, so a user function with the same name cannot match). Two extra sources are annotation- and
106
+ member-based: `hs-src-caller-controlled-param` (a text parameter of an exported function nothing in the module calls) and
107
+ `hs-lin-src-record-parameter` (the lineage form of a record parameter). `engine.js` skips the literal-skeleton filter for Haskell
108
+ catalog entries and requires a markup literal for `htmlSkeleton` sinks. Pinned counts in the lineage registry tests change
109
+ deliberately when the model registry grows. Details: `scanner/src/language/CLAUDE.md`.
@@ -0,0 +1,37 @@
1
+ // Haskell taint catalog, GENERATED from the model registry (language/haskell-models.js) so there is
2
+ // exactly one list of Haskell sources, sinks and sanitizers. Every entry matches the IMPORT-QUALIFIED
3
+ // callee the Haskell IR produces (`System.Process.callCommand`), never a bare name, so a user's own
4
+ // function with the same name resolves to its own module and cannot match.
5
+
6
+ import { HS_SOURCES, HS_SINKS, HS_SANITIZERS } from '../language/haskell-models.js';
7
+
8
+ const idOf = (kind, e, extra = '') => `hs-${kind}-${e.module}.${e.name}${extra}`.replace(/[^A-Za-z0-9.\-_]/g, '-').toLowerCase();
9
+
10
+ export const HASKELL_CATALOG = Object.freeze([
11
+ ...HS_SOURCES.map((e) => ({
12
+ kind: 'source', id: idOf('src', e), language: 'hs', framework: e.framework || 'haskell',
13
+ match: { type: 'call', callee: `${e.module}.${e.name}` }, label: e.label, provenance: e.provenance,
14
+ })),
15
+ ...HS_SINKS.map((e) => ({
16
+ kind: 'sink', id: idOf('sink', e, `.${e.cwe}.${e.argIndex}`), language: 'hs', framework: e.framework || 'haskell',
17
+ match: { type: 'call', callee: `${e.module}.${e.name}` }, argIndex: e.argIndex,
18
+ vuln: { name: e.vuln, severity: e.severity, cwe: e.cwe, remediation: e.remediation },
19
+ hs: { family: e.family, shell: e.shell === true, argv: e.argv === true, package: e.package, htmlSkeleton: e.htmlSkeleton === true },
20
+ })),
21
+ // LINEAGE form of every source: a member read on the synthetic root `$hs` (see lineage/haskell-view.js, which
22
+ // rewrites a CLONE of each Haskell function so a monadic source read becomes a seedable member read). The
23
+ // taint engine never sees this form: the shared IR keeps the call form above.
24
+ ...HS_SOURCES.map((e) => ({
25
+ kind: 'source', id: idOf('lin-src', e), language: 'hs', framework: e.framework || 'haskell',
26
+ match: { type: 'member', object: '$hs', prop: `${e.module}.${e.name}` }, label: e.label, provenance: e.provenance,
27
+ })),
28
+ // A text-typed parameter of an exported function (see language/haskell-ir.js `callerControlledParams`). The label says what
29
+ // it is: the caller supplies the value and nothing in the module proves it trusted.
30
+ // LINEAGE form of a record parameter of an exported function (see lineage/haskell-view.js)
31
+ { kind: 'source', id: 'hs-lin-src-record-parameter', language: 'hs', framework: 'haskell', match: { type: 'member', object: '$hs', prop: 'hs:record-parameter' }, label: 'record parameter of an exported function (caller-supplied customer data)' },
32
+ { kind: 'source', id: 'hs-src-caller-controlled-param', language: 'hs', framework: 'haskell', match: { type: 'annotation', name: 'hs:caller-controlled' }, label: 'text parameter of an exported function (caller-controlled)' },
33
+ ...HS_SANITIZERS.map((e) => ({
34
+ kind: 'sanitizer', id: idOf('san', e), language: 'hs',
35
+ match: { type: 'call', callee: `${e.module}.${e.name}` }, effect: 'strip', appliesTo: e.appliesTo, note: e.note,
36
+ })),
37
+ ]);
@@ -2174,6 +2174,7 @@ export const CATALOG = [
2174
2174
  // Lives in catalog-expanded.js to keep the diff reviewable. Merged into
2175
2175
  // the main CATALOG below so the indexer treats them identically.
2176
2176
  import { EXPANDED_SANITIZERS } from './catalog-expanded.js';
2177
+ import { HASKELL_CATALOG } from './catalog-haskell.js';
2177
2178
  import { cppExtRe } from '../ir/parser-cpp.js';
2178
2179
 
2179
2180
  // Language-scope guard. `file` is optional — when absent, behavior is
@@ -2200,6 +2201,7 @@ const _LANG_EXT = {
2200
2201
  rb: /\.rb$/i,
2201
2202
  java: /\.java$/i,
2202
2203
  rs: /\.rs$/i,
2204
+ hs: /\.(?:hs|lhs)$/i,
2203
2205
  };
2204
2206
 
2205
2207
  // cpp delegates to cppExtRe() rather than duplicating a literal set, so it
@@ -2395,6 +2397,8 @@ function _receiverTypeConfirms(entry, receiverType) {
2395
2397
  _ids.add(k);
2396
2398
  CATALOG.push(e);
2397
2399
  }
2400
+ // Haskell models (generated from language/haskell-models.js). Entries are import-qualified.
2401
+ for (const e of HASKELL_CATALOG) { const k = String(e.id).toLowerCase(); if (_ids.has(k)) continue; _ids.add(k); CATALOG.push(e); }
2398
2402
  }
2399
2403
 
2400
2404
  // Provenance defaults (Sentinel-parity audit P1-10):
@@ -1073,7 +1073,11 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
1073
1073
  ? argTaints.findIndex(Boolean) : e.argIndex;
1074
1074
  const taintedArgExpr = (argExprs || [])[taintedArgIdx];
1075
1075
  // String content analysis: skip if literal skeleton doesn't match injection family
1076
- if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
1076
+ // Haskell entries are import-qualified, API-specific sinks (`preEscapedToHtml`, `callCommand`), never a generic
1077
+ // `.query()`-style call that may carry an unrelated string, so the literal-skeleton filter does not apply to them.
1078
+ if (e.vuln && taintedArgExpr && !e.hs && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
1079
+ // A text-output sink that is an XSS sink ONLY when the string being assembled contains markup (`putStrLn`).
1080
+ if (e.hs && e.hs.htmlSkeleton && !(taintedArgExpr && /<[A-Za-z!\/]/.test(_literalPartsOfExpr(taintedArgExpr).join(' ')))) continue;
1077
1081
  // Taint-recall PRD (80%): `match.requireLiteralArg` — a precision
1078
1082
  // gate for sinks whose danger depends on a SIBLING argument's
1079
1083
  // literal value, not the tainted one. Go's `exec.Command(path,
@@ -1141,6 +1145,9 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
1141
1145
  findings.push({
1142
1146
  ...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
1143
1147
  ...(_unsanNames.size ? { _unsanitizersOnPath: [..._unsanNames] } : {}),
1148
+ // Haskell: two sinks of the same kind on one line (`if b then f (clean x) else f x`) are distinct
1149
+ // sites; without a site key they share an id and one silently replaces the other.
1150
+ ...(/\.(?:hs|lhs)$/i.test(_currentFile || '') ? { siteKey: _siteKey(taintedArgExpr) } : {}),
1144
1151
  kind: 'taint',
1145
1152
  sinkId: e.id,
1146
1153
  vuln: e.vuln?.name || 'Tainted Sink',
@@ -1742,6 +1749,18 @@ function step(node, stateIn, callContext) {
1742
1749
  findings.push(..._sinkFindingsForCall(
1743
1750
  node.value.callee, node.value.args, cat, argTaints, state, callContext, node.line, node.value.kwargs).findings);
1744
1751
  findings.push(..._nestedSinkFindings(node.value.args, state, callContext, node.line));
1752
+ } else if (node.value && node.value.kind === 'union' && /\.(?:hs|lhs)$/i.test(_currentFile || '')) {
1753
+ // Haskell: `if c then A else B` and guards return a union of the branch ACTIONS, each of which
1754
+ // runs when its branch is taken, so every branch call is a sink site in its own right. (Scoped
1755
+ // to Haskell: other IRs lower conditional expressions differently and are unchanged.)
1756
+ const leaves = [];
1757
+ const walk = (v, d) => { if (!v || d > 40) return; if (v.kind === 'union') (v.branches || []).forEach((b) => walk(b, d + 1)); else if (v.kind === 'call') leaves.push(v); };
1758
+ walk(node.value, 0);
1759
+ for (const c of leaves) {
1760
+ const { cat, argTaints } = _matchCallCatalog(c.callee, c.args, state, callContext);
1761
+ findings.push(..._sinkFindingsForCall(c.callee, c.args, cat, argTaints, state, callContext, c.line || node.line, c.kwargs).findings);
1762
+ findings.push(..._nestedSinkFindings(c.args, state, callContext, c.line || node.line));
1763
+ }
1745
1764
  }
1746
1765
  if (exprTaint(node.value, state, callContext)) {
1747
1766
  callContext._returnTainted = true;
@@ -1764,6 +1783,14 @@ function step(node, stateIn, callContext) {
1764
1783
  // state, never into the SummaryCache key (that stays exactly what the caller
1765
1784
  // supplied). Returns the ORIGINAL Set unchanged when there's nothing to add,
1766
1785
  // so callers that never touch annotation-shaped params pay zero extra cost.
1786
+ // Short stable fingerprint of an argument expression (ignores line numbers and IR bookkeeping).
1787
+ function _siteKey(expr) {
1788
+ let h = 5381;
1789
+ const str = JSON.stringify(expr, (k, v) => (k === 'line' || k === 'hs' ? undefined : v)) || '';
1790
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
1791
+ return h.toString(16);
1792
+ }
1793
+
1767
1794
  function _unionAnnotationTaint(fn, entrySet) {
1768
1795
  if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
1769
1796
  const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
@@ -2541,6 +2568,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2541
2568
  if (!fn) continue;
2542
2569
  if (calledQids.has(f._funcQid)) continue;
2543
2570
  if (fn.name === '<module>' || /handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
2571
+ // Haskell: an exported function or `main` is reachable from outside the analysed files.
2572
+ if (fn.hs && (fn.hs.exported || /\.main$/.test(fn.name || ''))) continue;
2544
2573
  f._inDeadCode = true;
2545
2574
  const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
2546
2575
  if (dg[f.severity]) f.severity = dg[f.severity];
@@ -2572,12 +2601,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2572
2601
  // every path pushing into `_findings` sets `file` explicitly as of
2573
2602
  // this fix.
2574
2603
  const file = f.file || attributedFn.file;
2575
- const key = `${f.sinkId}:${file}:${f.line}`;
2604
+ const key = `${f.sinkId}:${file}:${f.line}${f.siteKey ? ':' + f.siteKey : ''}`;
2576
2605
  if (seen.has(key)) continue;
2577
2606
  seen.add(key);
2578
2607
  const fn = attributedFn;
2579
2608
  all.push({
2580
- id: `ir-taint:${file}:${f.line}:${f.sinkId}`,
2609
+ id: `ir-taint:${file}:${f.line}:${f.sinkId}${f.siteKey ? ':' + f.siteKey : ''}`,
2610
+ ...(f.siteKey ? { siteKey: f.siteKey } : {}),
2581
2611
  file,
2582
2612
  line: f.line,
2583
2613
  vuln: f.vuln,
@@ -0,0 +1,50 @@
1
+ // Haskell privacy sinks (X-004), kept SEPARATE from the security sinks of haskell-models.js: a field that is
2
+ // legitimately written to a database or sent to an API is not a vulnerability, it is a privacy-relevant flow
3
+ // that the Data Flow Explorer classifies. Entries use the same shape as privacy-catalog.js and match the
4
+ // IMPORT-QUALIFIED callee the Haskell IR produces (`Database.PostgreSQL.Simple.execute`), so a user's own
5
+ // function of the same name cannot match.
6
+ //
7
+ // Package/function names are curated from each library's documented API; `argIndex` is the position of the
8
+ // DATA argument (the row/parameter tuple, the message, the body), never the query text or a connection.
9
+
10
+ import { HS_LOG_SINKS } from '../language/haskell-models.js';
11
+
12
+ const mk = (category, id, module, names, argIndex, framework, vuln, severity = 'high') => names.map((name) => ({
13
+ kind: 'sink', id: `privacy-hs-${id}-${name}`.toLowerCase(), language: 'hs', framework, category,
14
+ match: { type: 'call', callee: `${module}.${name}` }, argIndex,
15
+ vuln: { name: vuln, severity, cwe: 'CWE-359', remediation: 'Regulated data (PII/PHI/PCI) reaching this destination needs a documented purpose, retention and protection; redact, hash or drop the field first.' },
16
+ }));
17
+
18
+ export const HASKELL_PRIVACY_SINKS = Object.freeze([
19
+ // log: derived from the same table the sensitive-logging rule reads
20
+ ...HS_LOG_SINKS.map((e) => ({
21
+ kind: 'sink', id: `privacy-hs-log-${e.module}.${e.name}`.toLowerCase(), language: 'hs', framework: 'haskell', category: 'log',
22
+ match: { type: 'call', callee: `${e.module}.${e.name}` }, argIndex: e.argIndex,
23
+ vuln: { name: `Privacy Leak (${e.channel} log)`, severity: 'medium', cwe: 'CWE-359', remediation: 'Do not log regulated data (PII/PHI/PCI). Redact or hash the field before logging.' },
24
+ })),
25
+ // response
26
+ ...mk('response', 'scotty', 'Web.Scotty', ['text', 'html', 'json', 'raw'], 0, 'scotty', 'Privacy Leak (response body)'),
27
+ ...mk('response', 'scotty-trans', 'Web.Scotty.Trans', ['text', 'html', 'json', 'raw'], 0, 'scotty', 'Privacy Leak (response body)'),
28
+ ...mk('response', 'wai', 'Network.Wai', ['responseLBS'], 2, 'wai', 'Privacy Leak (response body)'),
29
+ // outbound HTTP (the request body is the data carrier)
30
+ ...mk('outboundHttp', 'http-simple', 'Network.HTTP.Simple', ['setRequestBodyJSON', 'setRequestBodyLBS', 'setRequestBodyURLEncoded'], 0, 'http-conduit', 'Privacy Leak (outbound HTTP request)'),
31
+ ...mk('outboundHttp', 'wreq', 'Network.Wreq', ['post', 'put'], 1, 'wreq', 'Privacy Leak (outbound HTTP request)'),
32
+ ...mk('outboundHttp', 'wreq-with', 'Network.Wreq', ['postWith', 'putWith'], 2, 'wreq', 'Privacy Leak (outbound HTTP request)'),
33
+ // storage
34
+ ...mk('storage', 'pgsimple', 'Database.PostgreSQL.Simple', ['execute', 'executeMany', 'query', 'returning'], 2, 'postgresql-simple', 'Privacy Leak (database write)', 'medium'),
35
+ ...mk('storage', 'sqlite', 'Database.SQLite.Simple', ['execute', 'executeMany', 'query', 'executeNamed'], 2, 'sqlite-simple', 'Privacy Leak (database write)', 'medium'),
36
+ ...mk('storage', 'mysql', 'Database.MySQL.Simple', ['execute', 'executeMany', 'query'], 2, 'mysql-simple', 'Privacy Leak (database write)', 'medium'),
37
+ ...mk('storage', 'persistent', 'Database.Persist', ['insert', 'insert_', 'insertMany', 'insertMany_', 'repsert', 'replace', 'upsert'], 0, 'persistent', 'Privacy Leak (database write)', 'medium'),
38
+ ...mk('storage', 'persistent-sql', 'Database.Persist.Sql', ['insert', 'insert_', 'insertMany', 'insertMany_', 'repsert', 'replace', 'upsert'], 0, 'persistent', 'Privacy Leak (database write)', 'medium'),
39
+ // file write
40
+ ...mk('fileWrite', 'prelude', 'Prelude', ['writeFile', 'appendFile'], 1, 'base', 'Privacy Leak (file write)', 'medium'),
41
+ ...mk('fileWrite', 'system-io', 'System.IO', ['writeFile', 'appendFile'], 1, 'base', 'Privacy Leak (file write)', 'medium'),
42
+ ...mk('fileWrite', 'text-io', 'Data.Text.IO', ['writeFile', 'appendFile'], 1, 'text', 'Privacy Leak (file write)', 'medium'),
43
+ ...mk('fileWrite', 'bytestring', 'Data.ByteString', ['writeFile', 'appendFile'], 1, 'bytestring', 'Privacy Leak (file write)', 'medium'),
44
+ // object storage / email / queues
45
+ ...mk('s3Upload', 'amazonka', 'Amazonka.S3', ['newPutObject'], 2, 'amazonka', 'Privacy Leak (object storage upload)'),
46
+ ...mk('emailSend', 'smtp', 'Network.Mail.SMTP', ['sendMail', 'simpleMail', 'plainTextPart', 'htmlPart'], 0, 'smtp', 'Privacy Leak (outbound email)', 'medium'),
47
+ ...mk('emailSend', 'mime', 'Network.Mail.Mime', ['renderSendMail'], 0, 'mime-mail', 'Privacy Leak (outbound email)', 'medium'),
48
+ ...mk('queue', 'amqp', 'Network.AMQP', ['publishMsg'], 2, 'amqp', 'Privacy Leak (queue publish)', 'medium'),
49
+ ...mk('queue', 'kafka', 'Kafka.Producer', ['produceMessage'], 1, 'hw-kafka-client', 'Privacy Leak (queue publish)', 'medium'),
50
+ ]);
@@ -36,6 +36,7 @@
36
36
 
37
37
  import { classifyFieldAgainst } from './privacy-taxonomy.js';
38
38
  import { _languageFamilyExtensions } from './catalog.js';
39
+ import { HASKELL_PRIVACY_SINKS } from './privacy-catalog-haskell.js';
39
40
 
40
41
  export const PRIVACY_SINK_CATALOG = [
41
42
  // ── log ──────────────────────────────────────────────────────────────────
@@ -129,6 +130,7 @@ export const PRIVACY_SINK_CATALOG = [
129
130
  remediation: 'Regulated data (PII/PHI/PCI) placed on a queue is readable by every consumer with queue access — document who can subscribe before sending it.' } },
130
131
  ];
131
132
 
133
+
132
134
  // All 9 sink categories FR-403 names (7 already covered by privacy-taint.js's
133
135
  // own SINK_PATTERNS, plus storage/queues) — used by this module's own tests
134
136
  // to assert coverage is genuinely complete, not just "some entries exist."
@@ -242,7 +244,9 @@ export function matchPrivacySink(calleeExpr, file, receiverType) {
242
244
  if (!calleeExpr) return null;
243
245
  const { last, full } = _privacyCalleeNames(calleeExpr);
244
246
  if (!last && !full) return null;
245
- const hits = PRIVACY_SINK_CATALOG.filter((e) => {
247
+ // Haskell privacy sinks live in their own table (privacy-catalog-haskell.js): the pinned catalog above is the
248
+ // JS/Python/... surface other suites count, and a new language does not silently change those counts.
249
+ const hits = [...PRIVACY_SINK_CATALOG, ...HASKELL_PRIVACY_SINKS].filter((e) => {
246
250
  if (!e || e.kind !== 'sink') return false;
247
251
  const cName = e.match && e.match.callee;
248
252
  if (!cName) return false;
@@ -46,3 +46,9 @@ panel · `judge.js` shapes findings and dedupes against the prior scan ·
46
46
  it was not examined, and the report says so.
47
47
  - **Coverage is reported.** Degraded runs and their reasons appear in every
48
48
  report. A half-failed pass must never read as a clean one.
49
+
50
+ ## Nix partitioning
51
+
52
+ `partition.js` `partitionNixFiles` groups `.nix` files by their import structure (a configuration and the modules it imports are
53
+ one area), merged areas are capped, and hunter prompts for Haskell and Nix are redacted (split literals joined first) before they
54
+ leave the process. `hunt` includes `.hs`, `.lhs` and `.nix`. With no endpoint every run is degraded and says so.
@@ -7,7 +7,7 @@
7
7
  // planned versus hunted and how many runs degraded, with reasons. A discovery
8
8
  // pass that half failed and reports "no findings" is indistinguishable from a
9
9
  // clean codebase unless it says so.
10
- import { partitionCallGraph } from './partition.js';
10
+ import { partitionCallGraph, partitionNixFiles } from './partition.js';
11
11
  import { LENSES, lensByKey } from './lenses.js';
12
12
  import { runHunter } from './hunter.js';
13
13
  import { confirmAll } from './confirm.js';
@@ -131,7 +131,16 @@ export function makeBudget(opts = {}, now = Date.now) {
131
131
  }
132
132
 
133
133
  export async function runDiscovery(ctx = {}, opts = {}) {
134
- const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
134
+ // Call-graph areas (every language the IR covers, Haskell included) plus Nix configuration areas, which have no call
135
+ // graph. Together they respect the same ceiling.
136
+ const maxAreas = opts.maxAreas ?? 8;
137
+ let areas = [...partitionCallGraph(ctx.callGraph, { maxAreas }), ...partitionNixFiles(ctx.fileContents, { maxAreas })];
138
+ if (areas.length > maxAreas) {
139
+ const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1);
140
+ const files = [...new Set(tail.flatMap((a) => a.files))].sort();
141
+ 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) });
142
+ areas = kept;
143
+ }
135
144
 
136
145
  const reasons = [];
137
146
  const budget = makeBudget(opts);
@@ -169,7 +178,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
169
178
  for (const area of areas) {
170
179
  let areaDegradedCount = 0;
171
180
  for (const lens of lenses) {
172
- const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
181
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {}, scanRoot: opts.scanRoot }, { llmInvoke, scanRoot: opts.scanRoot });
173
182
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
174
183
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
175
184
  if (run.degraded) areaDegradedCount += 1;
@@ -7,6 +7,7 @@
7
7
  // same code than one told to look at crypto, so the union covers failure modes
8
8
  // no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
9
9
  // ceiling, and the classes worth finding are the ones not on the list.
10
+ import { redactPayload } from '../egress/redact.js';
10
11
  export const LENSES = Object.freeze([
11
12
  { key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
12
13
  brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
@@ -39,7 +40,9 @@ export function buildHunterPrompt(focusArea, lens, ctx = {}) {
39
40
  let budget = maxChars;
40
41
  const blocks = [];
41
42
  for (const f of files) {
42
- const src = contents[f];
43
+ // Everything that leaves the machine is redacted first (secrets, PII/PHI/PCI-shaped fields, operator-declared
44
+ // customer data, and whole proprietary paths), by the same choke point the validator and the proposers use.
45
+ const src = redactPayload({ text: contents[f], filePath: f, scanRoot: ctx.scanRoot || null }).text;
43
46
  const slice = src.length > budget ? src.slice(0, Math.max(0, budget)) : src;
44
47
  const truncated = slice.length < src.length;
45
48
  blocks.push(`--- SOURCE FILE (untrusted data): ${f}${truncated ? ' (truncated)' : ''} ---\n${slice}\n--- END SOURCE FILE: ${f} ---`);
@@ -12,6 +12,7 @@
12
12
  // components share a file they are merged, otherwise the same source lands in
13
13
  // two hunters' context and the convergence this module exists to prevent
14
14
  // comes straight back.
15
+ import { buildImportGraph } from '../language/discovery.js';
15
16
  import * as crypto from 'node:crypto';
16
17
 
17
18
  export function focusAreaId(files) {
@@ -90,3 +91,30 @@ export function partitionCallGraph(callGraph, opts = {}) {
90
91
  }
91
92
  return areas;
92
93
  }
94
+
95
+ /**
96
+ * Focus areas for Nix configuration, which has no call graph: files joined by `imports` / path references form one
97
+ * area, so a NixOS entry point and the modules that decide its options are hunted together. Same shape as a call-graph
98
+ * area (`functions` is empty: a Nix file has none).
99
+ */
100
+ export function partitionNixFiles(fileContents, opts = {}) {
101
+ const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
102
+ const nix = {};
103
+ for (const [f, t] of Object.entries(fileContents || {})) if (/\.nix$/i.test(f) && typeof t === 'string') nix[f] = t;
104
+ const files = Object.keys(nix).sort();
105
+ if (!files.length) return [];
106
+ const { graph } = buildImportGraph(nix);
107
+ const dsu = makeDSU();
108
+ for (const f of files) dsu.find(f);
109
+ for (const [f, deps] of graph) for (const d of deps) dsu.union(f, d);
110
+ const groups = new Map();
111
+ for (const f of files) { const r = dsu.find(f); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(f); }
112
+ 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' }; });
113
+ areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
114
+ if (areas.length > maxAreas) {
115
+ const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1).flatMap((a) => a.files).sort();
116
+ kept.push({ id: focusAreaId(tail), label: 'misc', files: tail, functions: [], size: tail.length, language: 'nix' });
117
+ areas = kept;
118
+ }
119
+ return areas;
120
+ }
@@ -43,6 +43,7 @@
43
43
  // HOW MUCH was removed per category (FR-604's audit metadata wants exactly
44
44
  // this: counts, not content) without retaining what was actually removed.
45
45
 
46
+ import { redactLanguageSecrets } from '../language/secrets.js';
46
47
  import { loadPolicyConfig } from './policy.js';
47
48
  import { redactSecrets } from '../llm-validator/redact.js';
48
49
  import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
@@ -159,6 +160,13 @@ export function redactPayload({ text, filePath = null, scanRoot = null, taxonomy
159
160
  const secretResult = redactSecrets(out, { filePath });
160
161
  out = secretResult.text;
161
162
  categories.secrets = secretResult.redactions;
163
+ // Haskell and Nix: a credential split across string literals (`"sk_live_" ++ "..."`) is invisible to a
164
+ // single-literal pattern, so those files get a token-based pass as well.
165
+ if (filePath && /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(filePath)) {
166
+ const lang = redactLanguageSecrets(filePath, out);
167
+ out = lang.text;
168
+ categories.secrets += lang.redactions;
169
+ }
162
170
 
163
171
  const piiEnabled = cfg.redactPii !== false; // default ON — this category has a safe built-in default, unlike customerDataPatterns
164
172
  if (piiEnabled) {