@clear-capabilities/agentic-security-scanner 0.195.9 → 0.195.10

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.
@@ -1 +1 @@
1
- 19dc2c52e129ad724c2604371ec085abff39ecbd70a9a48374e00a84668ba9fc agentic-security.mjs
1
+ 4104b02904216922e410b459fcde235a0bf73ec5b3515a3bc07acec62312fe65 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.195.9",
3
+ "version": "0.195.10",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -93,7 +93,7 @@
93
93
  "test:lineage": "node --test test/lineage/analysis-completion.test.js 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 test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
94
94
  "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",
95
95
  "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",
96
- "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js 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-remediation-0190.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
96
+ "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js 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-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
97
97
  "test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js 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-sca-severity.test.js test/nix/nix-haskell-closure-names.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 test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
98
98
  "test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js 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/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.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-parity-haskell.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 test/language/language-trust-boundary.test.js",
99
99
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
@@ -73,7 +73,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
73
73
  - **Comments never change a verdict, an id or a cluster.** `haskell-comments.js` finds Haskell comments lexically; a finding's `snippet` is `codeOfLine(line)` (code only), because the snippet feeds the stable id and the clone-shape hash, and both changed when someone edited a trailing comment. `test/haskell/haskell-comment-invariance.test.js` scans the examples and corpus-shaped cases as written, with every comment blanked, and with every comment (and a trailing comment on every line) replaced by text that lies in each direction; `bench/dag/dev-haskell/comment-invariance.mjs` does the same to a real project. An engine that varies is wrong.
74
74
  - **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`). A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
75
75
  - **Fixes name the library the file uses.** `sqlDriverOf` picks `Database.PostgreSQL.Simple` or `Database.SQLite.Simple` from the file's own imports and refuses anything else; `hs-html-escape` needs blaze-html or a re-exporter; `hs-hash-sha256` refuses when the algorithm is named on another line (a type); `hs-path-takefilename` rewrites only a String/FilePath parameter spliced under a fixed directory in a FILE operation. Classes with no safe rewrite refuse with a class-level reason (`CLASS_REASONS`), `proposal: 'model-assisted'`, `tier: 'guidance-only'`, and every reason ends `The decision needed: ...` (returned as `decision`). Never invent a library API: the TLS, cookie, entropy and wai shapes were written against the package documentation and their tests type-check against DECLARED STUBS of that API shape, which proves coherence with the documented shape, not a build against the real package.
76
- - **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need). Password hashing (CWE-916), SSRF, argument injection, missing auth and hard-coded secrets keep refusing and name the decision; do not add a KDF fixer without a migration story for stored hashes.
76
+ - **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need). Two more since 0.195.10, both MITIGATIONS written against the real-code true positives: `hs-html-escape-text` (CWE-79: scotty's `html $ mconcat ["<h1>", v, "</h1>"]`, or `html` of one literal-free expression, gets an escaper DEFINED IN THE FILE on every non-literal part; `htmlEscapers` recognises it by its entities, so the rescan goes quiet for the right reason; only with `Web.Scotty` and `Data.Text.Lazy` imported, never a nested construction or a renderer) and `hs-password-kdf-migrate` (CWE-916: the migration story the old refusal asked for, written out in the file: the old pure one-line `name salt password` of one text type is kept as `nameLegacy` for verification, `name` becomes PBKDF2-HMAC-SHA256 with a tag in front of the hex, `nameNeedsRehash` reads the tag, `nameVerify stored salt password` checks either form, every `stored == name salt password` in the file calls it, an export list gains the helpers; only with a cryptonite/crypton module imported, and REFUSED when another file compares with the function, because that comparison would lock those users out). `haskell-security-rules.js` `legacyVerifierOnly` then reports the kept weak hash at `info`: every use of the function, in every file, is an operand of an equality and one of those functions also calls a password KDF within one project hop (the migration shape, decided from the IR; `register salt pw = oldHash salt pw` or a comparison beside no KDF keeps the full finding). `defaultRescan` drops a finding the engine LOWERED to `info` for a stated reason (`originalSeverity`, or the legacy-verifier evidence) as it drops `proven-*` ones, so a patch that earns such a lowering passes the "original gone" gate; a finding that is `info` in its own right (an MD5 with no visible use) is still counted, so fixing it can still be shown to remove it. SSRF, argument injection, missing auth and hard-coded secrets keep refusing and name the decision. The compile-gated remediation suites set `GHC_ENVIRONMENT=-` in their own process: a host's package environment file (`cabal install --lib`) hid `bytestring` under the recorded GHC and turned every stub compile into not-run.
77
77
  - **The effective-change check is not the rescan.** `haskellEffectiveCheck` (wired as the lifecycle's `effectiveCheck`) re-reads the intended attribute from the patched file with readers independent of the editor (`tlsSettingsOf`, `cookieSitesOf`, call counts) and fails when the planned site did not change or another site did. A cookie renamed to a non-session name silences the rule without adding an attribute; the check says so (`haskell-remediation-0190.test.js`).
78
78
  - **The compile gate is requested by the preview.** `languageFixPreview` passes `compile: true` (`compile: false` opts out). `compileGate` type-checks only the patched file(s) (`targets`; local imports found through the `module` header's source root) and takes the original tree as `baseline`: a project that does not type-check on this host (a package it lacks), or a patch that imports a module this host lacks, is `{ran:false, ok:null}` (not-run), a patch that breaks a compiling file is `{ran:true, ok:false}`. A missing GHC is not-run, never a pass. `requireCompile` turns not-run into a block.
79
79
  - **Witnesses.** `witness.js` `haskellFixWitness(finding)` runs the original and the patched file under `ghc -e ':load *File' -e 'fn "<payload>"'` in the sandbox and judges them with `judgeFixWitness`. It is built only for command-injection fixes in a top-level `String -> IO` function whose file imports only `bootModuleSet` modules; every other class returns `verified: false` with the reason (a TLS peer, a framework response header, a database, a digest, a distribution cannot be shown by a program GHC runs alone). Opt in with `languageFixPreview(..., { witness: true })`.
@@ -154,6 +154,13 @@ Rules added for the unseen-v4 shapes, each pinned both ways in `test/haskell/has
154
154
  The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
155
155
  against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
156
156
  frozen hashes and demotes rows until a deliberate re-measure. `docs/language-support.*` are generated; do not edit by hand.
157
+ **The GHC on PATH must be the recorded toolchain** (`docs/language-toolchain.json`, 9.4.8): the route fixtures' packages are installed per
158
+ GHC version, so another GHC fails the `auth` compile criterion for a missing package and the registry would record an engine failure that
159
+ is not one (it happened on 2026-11-01). Since 0.195.10 `probeTools` records `ghcVersion` and whether it matches, `promote.mjs` refuses to
160
+ measure the suites under a mismatch (`--allow-ghc-mismatch` records it in the limits instead), the AC01 criterion's message says
161
+ "unavailable compiler" under a mismatch so the row is `blocked` rather than `failed`, and `judgeGhc` is pinned both ways in
162
+ `test/language/language-package-ci.test.js`. To re-run only the suites, delete `bench/language-support/results/suites.json` and run
163
+ `promote.mjs --reuse` (the holdout result is reused; the suites are measured again).
157
164
 
158
165
  ## Docs generated from here
159
166
 
@@ -703,7 +703,225 @@ function fixBody(text, f) {
703
703
  };
704
704
  }
705
705
 
706
+ // ── HTML from text (CWE-79): scotty's `html` of concatenated text ─────────────
707
+ // `html $ mconcat ["<h1>", v, "</h1>"]`: scotty's `html` sends lazy Text as text/html, so a value concatenated into the markup IS markup. The
708
+ // rewrite applies an escaper DEFINED IN THE FILE (the five characters that open markup or end a quoted attribute) to every non-literal part of
709
+ // the concatenation, or to the whole argument when it is one expression that contains no literal and no renderer. A MITIGATION: a value inside
710
+ // a <script>, a style, a URL or an unquoted attribute is not made safe by this escaping, and markup the value deliberately carried is now shown
711
+ // as text. Only in a file importing Web.Scotty (or Web.Scotty.Trans) and Data.Text.Lazy (so `text` is declared by the component). The rescan
712
+ // recognises the escaper by the entities it spells out, never by its name (`htmlEscapers` in haskell-guards.js).
713
+ const HTML_TEXT_HELPER = `-- Escapes the five characters that open HTML markup or end a quoted attribute, so a value is shown as text. Covers HTML text and quoted
714
+ -- attribute values; not a script, a style, a URL or an unquoted-attribute context.
715
+ agenticEscapeHtml :: AgenticTL.Text -> AgenticTL.Text
716
+ agenticEscapeHtml = AgenticTL.concatMap escapeChar
717
+ where
718
+ escapeChar '<' = AgenticTL.pack "&lt;"
719
+ escapeChar '>' = AgenticTL.pack "&gt;"
720
+ escapeChar '&' = AgenticTL.pack "&amp;"
721
+ escapeChar '"' = AgenticTL.pack "&quot;"
722
+ escapeChar '\\'' = AgenticTL.pack "&#39;"
723
+ escapeChar c = AgenticTL.singleton c
724
+ `;
725
+ export { HTML_TEXT_HELPER };
726
+ const HTML_TEXT_ESCAPER = 'agenticEscapeHtml';
727
+ const RENDERER = /(?<![\w'])(?:render\w*|toHtml|preEscaped\w*|toMarkup|unsafeByteString|html\w*)(?![\w'])/;
728
+ function fixHtmlText(text, f) {
729
+ if (!/^import\s+(?:qualified\s+)?Web\.Scotty(?:\.Trans)?(?:\s|$|\()/m.test(text)) return null;
730
+ const tl = importInfo(text, 'Data.Text.Lazy'); if (!tl || tl.hiding) return null;
731
+ if (hasPragma(text, 'NoImplicitPrelude') || /^import\s+(?:qualified\s+)?Prelude\s*(?:\(|hiding\b|as\b)/m.test(text)) return null;
732
+ const code = stripComments(text);
733
+ const hasHelper = text.includes(HTML_TEXT_HELPER);
734
+ if (!hasHelper && (new RegExp(`(?<![\\w'.])${HTML_TEXT_ESCAPER}(?![\\w'])`).test(code) || /(?<![\w'.])AgenticTL\./.test(text))) return null; // a name of ours is already taken
735
+ const lb = lineBounds(text, f.line);
736
+ const call = findCall(lb.text, ['html']);
737
+ if (!call) return null;
738
+ let aStart = call.after; while (lb.text[aStart] === ' ') aStart++;
739
+ let aEnd; let dollar = false;
740
+ if (lb.text[aStart] === '$') { dollar = true; aStart++; while (lb.text[aStart] === ' ') aStart++; aEnd = lb.text.length; }
741
+ else aEnd = atomEnd(lb.text, aStart);
742
+ if (aEnd < 0 || aEnd <= aStart) return null;
743
+ const arg = lb.text.slice(aStart, aEnd).trim();
744
+ if (/\$/.test(arg) && !dollar) return null;
745
+ const inner = trimParens(arg);
746
+ if (!inner || STRING.test(inner)) return null; // a literal alone is not a flow
747
+ const wrap = (e) => (IDENT.test(e) ? `${HTML_TEXT_ESCAPER} ${e}` : `${HTML_TEXT_ESCAPER} (${e})`);
748
+ let rebuilt = null; const wrapped = [];
749
+ const cat = /^(?:mconcat|(?:[A-Z][\w']*\.)?concat)\s*\[([\s\S]*)\]$/.exec(inner);
750
+ if (cat) {
751
+ const parts = topLevelSplit(cat[1], ','); if (!parts) return null;
752
+ const out = [];
753
+ let literals = 0;
754
+ for (const raw of parts) {
755
+ const e = raw.trim();
756
+ if (!e) return null;
757
+ if (STRING.test(e)) { literals++; out.push(e); continue; }
758
+ if (/"/.test(e) || RENDERER.test(e)) return null; // a nested construction or a renderer: not judged
759
+ if (new RegExp(`^${HTML_TEXT_ESCAPER}\\s`).test(e)) { out.push(e); continue; } // already escaped
760
+ out.push(wrap(e)); wrapped.push(e);
761
+ }
762
+ if (!wrapped.length || !literals) return null; // nothing raw, or no markup to be concatenated into
763
+ rebuilt = `${inner.slice(0, inner.indexOf('['))}[${out.join(', ')}]`;
764
+ } else {
765
+ if (/"/.test(inner) || RENDERER.test(inner) || /\bagenticEscapeHtml\b/.test(inner)) return null;
766
+ rebuilt = wrap(inner); wrapped.push(inner);
767
+ }
768
+ const replaced = lb.text.slice(0, aStart) + (dollar ? rebuilt : `(${rebuilt})`) + lb.text.slice(aEnd);
769
+ let next = splice(text, lb.start, lb.end, replaced);
770
+ if (!hasHelper) {
771
+ const lines = next.split('\n'); let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
772
+ if (at < 0) return null;
773
+ lines.splice(at + 1, 0, 'import qualified Data.Text.Lazy as AgenticTL');
774
+ next = lines.join('\n').replace(/\n*$/, '\n') + '\n' + HTML_TEXT_HELPER;
775
+ }
776
+ const n = wrapped.length;
777
+ return {
778
+ ruleId: 'hs-html-escape-text', label: 'MITIGATION', text: next,
779
+ explanation: `Applied ${HTML_TEXT_ESCAPER}, defined in this file, to ${n} value${n === 1 ? '' : 's'} (${wrapped.join(', ')}) before ${call.prefix}html sends the text as HTML: <, >, &, " and ' become entities, so the value is shown as text.`,
780
+ consequences: [
781
+ 'Markup the value deliberately contained is now shown as text.',
782
+ 'The escaping covers HTML text and quoted attribute values. A value placed inside a <script> or <style> element, in a URL or in an unquoted attribute is not made safe by it; those contexts need their own encoding.',
783
+ 'Requires the `text` package to be declared by the component (this file already imports Data.Text.Lazy, so it is); the build is not checked against the cabal file.',
784
+ ],
785
+ behavior: { kind: 'html-text', escaper: HTML_TEXT_ESCAPER, wrapped },
786
+ expectEffective: { kind: 'html-text', line: replaced, wrapped: n },
787
+ };
788
+ }
789
+
790
+ // ── password hashing (CWE-916): the migration shape ───────────────────────────
791
+ // A password hashed with a fast digest cannot be swapped for a KDF on its line: every stored hash was made by the old function, and a swap locks
792
+ // every user out. The rewrite that keeps them in is a MIGRATION, written out in the file: the old function is kept verbatim under `<name>Legacy`
793
+ // (verification of stored hashes only), `<name>` becomes PBKDF2-HMAC-SHA256 over the same salt and password with a tag in front of the hex
794
+ // (`pbkdf2-sha256$600000$...`, so a stored hash says how it was made), `<name>NeedsRehash` reads the tag, and `<name>Verify stored salt pw`
795
+ // checks either form. Every `stored == <name> salt pw` comparison in the file becomes `<name>Verify stored salt pw`; the caller re-hashes at
796
+ // the next successful login when `<name>NeedsRehash` says so (that write is the caller's, not the patch's). Recognised ONLY where the
797
+ // migration can be shown to keep behavior:
798
+ // - a one-line, top-level, pure definition `name a b = ...` of two plain parameters, one password-shaped and one salt-shaped, with a
799
+ // signature `T -> T -> T` for one T among String, Text and (strict) ByteString;
800
+ // - the file imports a module of cryptonite / crypton (`Crypto.Hash`, `Crypto.KDF.*`, ...), so `Crypto.KDF.PBKDF2` is in a declared package,
801
+ // and `Data.Text` (String and Text forms: the bytes are the UTF-8 encoding) or `Data.ByteString` (ByteString form);
802
+ // - no other file compares with the function (a comparison the patch cannot rewrite would lock those users out), and the four names are free.
803
+ // The iteration count (600000, the OWASP figure for PBKDF2-HMAC-SHA256) is a declared default the operator reviews, not a measured cost.
804
+ // The API is cryptonite's / crypton's, from the package documentation: `fastPBKDF2_SHA256 :: (ByteArrayAccess password, ByteArrayAccess salt,
805
+ // ByteArray out) => Parameters -> password -> salt -> out`, `data Parameters = Parameters { iterCounts :: Int, outputLength :: Int }`.
806
+ const KDF_ITERATIONS = 600000;
807
+ const KDF_TAG = `pbkdf2-sha256$${KDF_ITERATIONS}$`;
808
+ const KDF_LIB_IMPORT = /^import\s+(?:qualified\s+)?Crypto\.(?:Hash|KDF|MAC|Random|Cipher|Error|PubKey|Number)(?:\.|\s|$)/m;
809
+ const PASSWORD_PARAM = /pass(?:word|wd|phrase)?|pwd|^pw$|secret/i;
810
+ const SALT_PARAM = /salt|nonce|pepper/i;
811
+ const KDF_TYPES = Object.freeze({
812
+ String: { bytes: (v) => `AgenticTE.encodeUtf8 (AgenticT.pack ${v})`, hex: (h) => h, tag: `"${KDF_TAG}"`, prefix: 'AgenticList.isPrefixOf', needs: 'Data.Text' },
813
+ Text: { bytes: (v) => `AgenticTE.encodeUtf8 ${v}`, hex: (h) => `AgenticT.pack (${h})`, tag: `AgenticT.pack "${KDF_TAG}"`, prefix: 'AgenticT.isPrefixOf', needs: 'Data.Text' },
814
+ ByteString: { bytes: (v) => v, hex: (h) => `AgenticBC.pack (${h})`, tag: `AgenticBC.pack "${KDF_TAG}"`, prefix: 'AgenticBS.isPrefixOf', needs: 'Data.ByteString' },
815
+ });
816
+ const KDF_IMPORTS = ['import qualified Crypto.KDF.PBKDF2 as AgenticPBKDF2', 'import qualified Data.ByteString as AgenticBS', 'import qualified Data.ByteString.Char8 as AgenticBC', 'import qualified Data.Text as AgenticT', 'import qualified Data.Text.Encoding as AgenticTE', 'import qualified Data.List as AgenticList'];
817
+ const KDF_ALIASES = ['AgenticPBKDF2', 'AgenticBS', 'AgenticBC', 'AgenticT', 'AgenticTE', 'AgenticList'];
818
+
819
+ /** `stored == name a b` / `name a b == stored` on one line of code (atoms only); returns the rewritten line or null. */
820
+ function rewriteHashComparisons(lineText, name, verify) {
821
+ const atom = `(?:[A-Za-z_][\\w'.]*|"(?:[^"\\\\]|\\\\.)*"|\\([^()]*\\))`;
822
+ const call = `${name}\\s+(${atom})\\s+(${atom})`;
823
+ let n = 0;
824
+ let out = lineText.replace(new RegExp(`(?<![\\w'.])(${atom})\\s*==\\s*(?:${call}|\\(${call}\\))(?![\\w'])`, 'g'), (_, stored, a1, b1, a2, b2) => { n++; return `${verify} ${stored} ${a1 || a2} ${b1 || b2}`; });
825
+ out = out.replace(new RegExp(`(?<![\\w'.])(?:${call}|\\(${call}\\))\\s*==\\s*(${atom})(?![\\w'.])`, 'g'), (_, a1, b1, a2, b2, stored) => { n++; return `${verify} ${stored} ${a1 || a2} ${b1 || b2}`; });
826
+ return n ? { text: out, n } : null;
827
+ }
828
+
829
+ function fixPasswordKdf(text, f) {
830
+ const lb = lineBounds(text, f.line);
831
+ const def = /^([a-z_][\w']*)\s+([a-z_][\w']*)\s+([a-z_][\w']*)\s*=\s*(\S.*)$/.exec(lb.text);
832
+ if (!def) return null;
833
+ const [, name, p1, p2, body] = def;
834
+ if (/(?<![\w'])(?:do|unsafePerformIO)(?![\w'])|<-/.test(body)) return null; // not a pure one-line definition
835
+ const pwIdx = [p1, p2].findIndex((p) => PASSWORD_PARAM.test(p) && !SALT_PARAM.test(p));
836
+ const saltIdx = [p1, p2].findIndex((p) => SALT_PARAM.test(p));
837
+ if (pwIdx < 0 || saltIdx < 0 || pwIdx === saltIdx) return null;
838
+ const sig = new RegExp(`^${name}\\s*::\\s*(\\S+)\\s*->\\s*(\\S+)\\s*->\\s*(\\S+)\\s*$`, 'm').exec(text);
839
+ if (!sig) return null;
840
+ const tyOf = (t) => { const m = /^(?:[A-Z][\w']*\.)?(String|Text|ByteString)$/.exec(t); return m ? m[1] : null; };
841
+ const T = tyOf(sig[1]);
842
+ if (!T || tyOf(sig[2]) !== T || tyOf(sig[3]) !== T) return null;
843
+ const ty = KDF_TYPES[T];
844
+ const code = stripComments(text);
845
+ if ((code.match(new RegExp(`^${name}\\s+[a-z_]`, 'gm')) || []).length !== 1) return null; // one clause, defined once
846
+ if (!KDF_LIB_IMPORT.test(text)) return null; // Crypto.KDF.PBKDF2 is not in a declared package
847
+ if (T === 'ByteString' && importInfo(text, 'Data.ByteString.Lazy') && !importInfo(text, 'Data.ByteString')) return null; // a lazy ByteString: not the strict type the KDF takes
848
+ if (!importInfo(text, ty.needs) && !(T !== 'ByteString' && importInfo(text, 'Data.Text.Encoding'))) return null;
849
+ const pb = importInfo(text, 'Crypto.KDF.PBKDF2'); if (pb && pb.hiding) return null;
850
+ if (hasPragma(text, 'NoImplicitPrelude') || /^import\s+(?:qualified\s+)?Prelude\s*(?:\(|hiding\b|as\b)/m.test(text)) return null;
851
+ const legacy = `${name}Legacy`, verify = `${name}Verify`, needs = `${name}NeedsRehash`, tagName = `${name}Tag`, hexName = `${name}Hex`;
852
+ for (const nm of [legacy, verify, needs, tagName, hexName]) if (new RegExp(`(?<![\\w'.])${nm}(?![\\w'])`).test(code)) return null;
853
+ if (new RegExp(`(?<![\\w'.])(?:${KDF_ALIASES.join('|')})\\.`).test(text)) return null;
854
+ // every comparison with the function, in this file, is rewritten; one in another file would be left comparing against the new form
855
+ const lines = text.split('\n');
856
+ const sigLine = lines.findIndex((l) => new RegExp(`^${name}\\s*::`).test(l));
857
+ let compared = 0;
858
+ const useRe = new RegExp(`(?<![\\w'.])${name}(?![\\w'])`);
859
+ for (let i = 0; i < lines.length; i++) {
860
+ if (i === f.line - 1 || i === sigLine || /^import\s/.test(lines[i])) continue;
861
+ const r = rewriteHashComparisons(lines[i], name, verify);
862
+ if (r) { lines[i] = r.text; compared += r.n; continue; }
863
+ if (useRe.test(stripComments(lines[i])) && /==/.test(lines[i])) return null; // a comparison of a shape the rewrite does not cover
864
+ }
865
+ for (const [other, t] of Object.entries(f._files || {})) {
866
+ if (other === f.file || typeof t !== 'string' || !/\.l?hs$/i.test(other)) continue;
867
+ if (t.split('\n').some((l) => !/^import\s/.test(l) && useRe.test(stripComments(l)) && /==/.test(l))) return null;
868
+ }
869
+ const pw = pwIdx === 0 ? p1 : p2, salt = saltIdx === 0 ? p1 : p2;
870
+ const hexOf = `${hexName} (AgenticPBKDF2.fastPBKDF2_SHA256 (AgenticPBKDF2.Parameters { AgenticPBKDF2.iterCounts = ${KDF_ITERATIONS}, AgenticPBKDF2.outputLength = 32 }) (${ty.bytes(pw)}) (${ty.bytes(salt)}))`;
871
+ // the original signature stays where it is, above the new definition; the kept function gets its own
872
+ const block = [
873
+ `-- PBKDF2-HMAC-SHA256, ${KDF_ITERATIONS} iterations (a declared default: review the cost for this service), tagged so a stored hash says how it was made.`,
874
+ `${name} ${p1} ${p2} = ${tagName} <> ${ty.hex(hexOf)}`,
875
+ '',
876
+ `${tagName} :: ${sig[3]}`,
877
+ `${tagName} = ${ty.tag}`,
878
+ '',
879
+ `${hexName} :: AgenticBS.ByteString -> String`,
880
+ `${hexName} = AgenticBS.foldr (\\w acc -> hexDigit (w \`div\` 16) : hexDigit (w \`mod\` 16) : acc) ""`,
881
+ ` where hexDigit d = "0123456789abcdef" !! fromIntegral d`,
882
+ '',
883
+ `-- Kept, under a new name, for passwords stored before the migration: ${verify} checks a stored hash with it when ${needs} says so.`,
884
+ `${legacy} :: ${sig[1]} -> ${sig[2]} -> ${sig[3]}`,
885
+ `${legacy} ${p1} ${p2} = ${body}`,
886
+ '',
887
+ `-- True for a hash made by ${legacy}: verify it with ${verify}, then store ${name} of the password at the next successful login.`,
888
+ `${needs} :: ${sig[3]} -> Bool`,
889
+ `${needs} stored = not (${tagName} \`${ty.prefix}\` stored)`,
890
+ '',
891
+ `-- Checks a password against a stored hash of either form, in place of \`stored == ${name} ${salt} ${pw}\`.`,
892
+ `${verify} :: ${sig[3]} -> ${sig[1]} -> ${sig[2]} -> Bool`,
893
+ `${verify} stored ${p1} ${p2} = if ${needs} stored then stored == ${legacy} ${p1} ${p2} else stored == ${name} ${p1} ${p2}`,
894
+ ];
895
+ lines.splice(f.line - 1, 1, ...block);
896
+ let at = -1; lines.forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
897
+ if (at < 0) return null;
898
+ lines.splice(at + 1, 0, ...KDF_IMPORTS);
899
+ let next = lines.join('\n');
900
+ // an export list that names the function exports the helpers too: a caller in another module needs the verify helper
901
+ const hdr = /^module\s+[\w.']+\s*\(/m.exec(next);
902
+ if (hdr) {
903
+ const open = hdr.index + hdr[0].length - 1;
904
+ const close = atomEnd(next, open);
905
+ if (close > 0 && /^\s*where/.test(next.slice(close))) {
906
+ const list = next.slice(open + 1, close - 1);
907
+ if (useRe.test(list)) next = next.slice(0, open + 1) + list.replace(useRe, `${name}, ${legacy}, ${verify}, ${needs}`) + next.slice(close - 1);
908
+ }
909
+ }
910
+ return {
911
+ ruleId: 'hs-password-kdf-migrate', label: 'MITIGATION', text: next,
912
+ explanation: `${name} now computes PBKDF2-HMAC-SHA256 (${KDF_ITERATIONS} iterations, 32 bytes, over the same salt and password) and tags the result; the old function is kept as ${legacy} for verification only, ${verify} checks a stored hash of either form, and ${needs} says which hashes to re-hash at the next successful login. ${compared} comparison${compared === 1 ? '' : 's'} in this file now call${compared === 1 ? 's' : ''} ${verify}.`,
913
+ consequences: [
914
+ `Requires cryptonite or crypton (Crypto.KDF.PBKDF2; this file already imports a module of that package), bytestring and text to be declared by the component; the build is not checked against the cabal file.`,
915
+ `A new password is stored in the tagged PBKDF2 form; a stored hash of the old form still verifies through ${legacy}, so no user is locked out, and it stays in the old form until the caller re-hashes it (store ${name} of the password when ${needs} is True after a successful login). ${compared === 0 ? `No comparison with ${name} was found in this file: a caller elsewhere that compares with == must call ${verify} instead.` : ''}`.trim(),
916
+ `Each hash now costs ${KDF_ITERATIONS} HMAC-SHA256 iterations (tenths of a second per attempt, by design); the count is a declared default, not a measured cost for this service. The comparison in ${verify} is the same == the original used, not a constant-time one.`,
917
+ ],
918
+ behavior: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, iterations: KDF_ITERATIONS, rewrittenComparisons: compared },
919
+ expectEffective: { kind: 'kdf', fn: name, legacy, verify, needs, type: T, rewrittenComparisons: compared },
920
+ };
921
+ }
922
+
706
923
  const FIXERS = [
924
+ { id: 'hs-password-kdf-migrate', applies: (f) => f.cwe === 'CWE-916' && (f.rule === 'hs-fast-password-hash' || /fast general-purpose hash/i.test(String(f.vuln || ''))), run: fixPasswordKdf },
707
925
  { id: 'hs-tls-validate', applies: (f) => f.cwe === 'CWE-295' || f.rule === 'hs-tls-validation-disabled', run: fixTls, reportedLineOnly: true },
708
926
  { id: 'hs-cookie-attributes', applies: (f) => f.cwe === 'CWE-1004' || f.rule === 'hs-insecure-cookie', run: fixCookie, reportedLineOnly: true },
709
927
  { id: 'hs-random-os-entropy', applies: (f) => f.cwe === 'CWE-338' || f.rule === 'hs-predictable-random', run: fixRandom, reportedLineOnly: true },
@@ -711,6 +929,7 @@ const FIXERS = [
711
929
  { id: 'hs-sql-parameterize', applies: (f) => f.cwe === 'CWE-89', run: fixSql },
712
930
  { id: 'hs-process-argv', applies: (f) => f.cwe === 'CWE-78', run: fixProcess },
713
931
  { id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
932
+ { id: 'hs-html-escape-text', applies: (f) => f.cwe === 'CWE-79', run: fixHtmlText, reportedLineOnly: true },
714
933
  { id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
715
934
  { id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
716
935
  { id: 'hs-path-takefilename', applies: (f) => f.cwe === 'CWE-22', run: fixPath },
@@ -761,7 +980,7 @@ export function planHaskellFix(finding, files) {
761
980
  // An attribute, a generator or a limit is fixed where the finding says it is, never in the body of a function called on that line (that would change a
762
981
  // helper for all of its callers: the scotty cookie helpers, for instance, are the library's own definitions).
763
982
  for (const line of fx.reportedLineOnly ? [finding.line] : [finding.line, ...calleeLines(before, finding.line)]) {
764
- let r = null; try { r = fx.run(before, { ...finding, line }); } catch { r = null; }
983
+ let r = null; try { r = fx.run(before, { ...finding, line, _files: files }); } catch { r = null; }
765
984
  if (r && r.text && r.text !== before) return { ok: true, tier: TIERS.edit, ruleId: r.ruleId, label: r.label, file: finding.file, before, after: r.text, explanation: r.explanation, consequences: r.consequences || DEFAULT_CONSEQUENCES[r.ruleId] || [], behavior: r.behavior, expectEffective: r.expectEffective || null, ...(line !== finding.line ? { fixedAtLine: line } : {}) };
766
985
  }
767
986
  }
@@ -787,7 +1006,7 @@ const DEFAULT_CONSEQUENCES = Object.freeze({
787
1006
  const CLASS_REASONS = Object.freeze({
788
1007
  'CWE-918': 'server-side request forgery has no line-level repair: a check on the URL string is bypassed by redirects, alternate encodings and DNS. The decision needed: which hosts or networks this service may call (an allow-list of exact hosts, or an address policy that rejects loopback, link-local and private ranges after name resolution and on every redirect).',
789
1008
  'CWE-88': 'argument injection is repaired by a rule about what the called program accepts; inserting "--" only helps programs that honour it, so no patch is made. The decision needed: the permitted values for this argument (a closed list or a strict pattern), or the option of that program that ends option parsing.',
790
- 'CWE-916': 'password hashing needs a password KDF (Argon2id, scrypt, bcrypt, or PBKDF2 with a high iteration count) from a library the project may not declare as a dependency, and every stored hash was produced by the old function: swapping it makes every existing password fail to verify and locks the users out. The decision needed: which KDF and library, with which cost parameters, and the migration (verify the old hash once, then re-hash with the new KDF at the next successful login, or force a password reset).',
1009
+ 'CWE-916': 'password hashing needs a password KDF (Argon2id, scrypt, bcrypt, or PBKDF2 with a high iteration count) from a library the project may not declare as a dependency, and every stored hash was produced by the old function: swapping it makes every existing password fail to verify and locks the users out. The one shape rewritten is a migration: a pure one-line `name salt password = ...` of one text type (String, Text or strict ByteString) in a file that already imports cryptonite or crypton and Data.Text or Data.ByteString, whose comparisons all sit in this file; it keeps the old function for verification, adds PBKDF2-HMAC-SHA256 and a verify helper, and this is not that shape. The decision needed: which KDF and library, with which cost parameters, and the migration (verify the old hash once, then re-hash with the new KDF at the next successful login, or force a password reset).',
791
1010
  'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (System.Entropy or Crypto.Random), whose result type is bytes, not System.Random\'s: the surrounding code must change with it. Only a one-line action of type IO <fixed-width integer> equal to randomIO, in a file that already imports System.Entropy, is rewritten. The decision needed: the token\'s type and size (a 30-bit integer cannot be made unguessable by changing the generator) and the entropy library the project declares.',
792
1011
  'CWE-1004': 'cookie attributes live in different places per framework. The recognised shapes are a Set-Cookie header string literal, a Web.Cookie SetCookie record update, servant-auth cookieIsSecure, and scotty setSimpleCookie/makeSimpleCookie; this is none of them, or an attribute is set from an expression. The decision needed: which attributes to send (Secure, HttpOnly, SameSite) and whether this cookie must work over plain HTTP.',
793
1012
  'CWE-306': 'a missing authentication check needs a policy; the scanner reports the handler and does not invent the guard. The decision needed: which credential authenticates this route (session, token, API key) and what an unauthenticated caller receives (401, a redirect to login).',
@@ -796,7 +1015,7 @@ const CLASS_REASONS = Object.freeze({
796
1015
  'CWE-798': 'a hard-coded secret must be rotated and moved to a secret store; rewriting the line would leave the leaked value valid. The decision needed: where the secret is read from at run time (environment, a secrets manager, a file outside the repository) and rotating the exposed value, which no edit of this file can do.',
797
1016
  'CWE-611': 'XML entity handling depends on the parser configuration in use. The decision needed: whether this document source is trusted and which entity and DTD features the parser may enable.',
798
1017
  'CWE-22': 'path traversal is repaired by confining the final path to a base directory. takeFileName is applied only to a String/FilePath parameter spliced under a fixed directory in a file operation; this shape (several segments, a base chosen at run time, a directory operation, a path handed through other functions) is not that. The decision needed: the directory the files must stay in, and whether a name containing separators or ".." is rejected or reduced to its last segment.',
799
- 'CWE-79': 'output encoding depends on the context the value is written into (HTML text, attribute, script, URL) and on the library that renders it; only blaze-html\'s preEscapedToHtml is rewritten. The decision needed: render the value through the project\'s escaping HTML library, or send it as text/plain.',
1018
+ 'CWE-79': 'output encoding depends on the context the value is written into (HTML text, attribute, script, URL) and on the library that renders it; only blaze-html\'s preEscapedToHtml and scotty\'s html of concatenated text are rewritten (the latter with an escaper defined in the file, for HTML text and quoted attribute values). The decision needed: render the value through the project\'s escaping HTML library, or send it as text/plain.',
800
1019
  'CWE-601': 'an open redirect is repaired by restricting where the redirect may go. The decision needed: the permitted destinations (an allow-list of hosts, or relative paths only).',
801
1020
  });
802
1021
 
@@ -810,8 +1029,12 @@ export async function defaultRescan(files) {
810
1029
  try {
811
1030
  for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
812
1031
  const r = await runScan(dir, { deep: true });
813
- // a flow the proof gate discharged (a dominating guard, a sanitizer) is reported but is not still a finding
814
- return (r.scan.findings || []).filter((f) => !(f.proof && /^proven-/.test(f.proof.verdict)));
1032
+ // a flow the proof gate discharged (a dominating guard, a sanitizer) is reported but is not still a finding, and neither is a finding the
1033
+ // engine LOWERED to information for a reason it states (`originalSeverity`: a recognised guard, an operator-controlled origin; the legacy
1034
+ // verifier of a password-hash migration): the same `actionable` rule the benches score by. A finding that is `info` in its own right (an MD5
1035
+ // whose use is not visible) is still counted, so a fix of it can still be shown to have removed it.
1036
+ const lowered = (f) => f.severity === 'info' && (f.originalSeverity || (f.evidence && f.evidence.legacyVerifier === true));
1037
+ return (r.scan.findings || []).filter((f) => !(f.proof && /^proven-/.test(f.proof.verdict)) && !lowered(f));
815
1038
  } finally { rmSync(dir, { recursive: true, force: true }); }
816
1039
  }
817
1040
 
@@ -917,6 +1140,29 @@ export function haskellEffectiveCheck(plan, patched) {
917
1140
  if (!new RegExp(`requestBodyLimitBytes\\s*=\\s*${BODY_LIMIT_DEFAULT}\\b`).test(after)) return { ok: false, ran: true, detail: 'the declared limit is not the one the patch states' };
918
1141
  return { ok: true, ran: true, detail: `${want.replaced} strictRequestBody call(s) were replaced by the bounded reader, whose limit is the declared constant` };
919
1142
  }
1143
+ if (want.kind === 'html-text') {
1144
+ const code = stripComments(after);
1145
+ if (!code.split('\n').includes(want.line)) return { ok: false, ran: true, detail: 'the rewritten html call is not in the patched file as planned' };
1146
+ if (!after.includes(HTML_TEXT_HELPER)) return { ok: false, ran: true, detail: 'the escaper is not defined in the patched file' };
1147
+ const uses = (t) => (stripComments(t).split('\n').filter((l) => !new RegExp(`^${HTML_TEXT_ESCAPER}\\s`).test(l)).join('\n').match(new RegExp(`(?<![\\w'.])${HTML_TEXT_ESCAPER}(?![\\w'])`, 'g')) || []).length;
1148
+ if (uses(after) - uses(before) !== want.wrapped) return { ok: false, ran: true, detail: `exactly ${want.wrapped} value(s) should have been wrapped; ${uses(after) - uses(before)} were` };
1149
+ return { ok: true, ran: true, detail: `${want.wrapped} value(s) pass through the file's escaper before the html call and the escaper spells out the entities` };
1150
+ }
1151
+ if (want.kind === 'kdf') {
1152
+ const code = stripComments(after); const ls = code.split('\n');
1153
+ const defs = (nm) => ls.filter((l) => new RegExp(`^${nm}\\s+[a-z_][\\w']*\\s+[a-z_][\\w']*\\s*=`).test(l));
1154
+ if (defs(want.legacy).length !== 1) return { ok: false, ran: true, detail: `${want.legacy} is not defined exactly once` };
1155
+ const oldBody = (stripComments(before).split('\n').find((l) => new RegExp(`^${want.fn}\\s+[a-z_][\\w']*\\s+[a-z_][\\w']*\\s*=`).test(l)) || '').replace(/^[^=]*=\s*/, '');
1156
+ if (!oldBody || defs(want.legacy)[0].replace(/^[^=]*=\s*/, '') !== oldBody) return { ok: false, ran: true, detail: `${want.legacy} is not the original definition kept verbatim` };
1157
+ const nd = defs(want.fn);
1158
+ if (nd.length !== 1 || !new RegExp(`fastPBKDF2_SHA256 .*iterCounts = ${KDF_ITERATIONS}\\b`).test(nd[0])) return { ok: false, ran: true, detail: `${want.fn} does not compute PBKDF2-HMAC-SHA256 with ${KDF_ITERATIONS} iterations` };
1159
+ const vd = ls.filter((l) => new RegExp(`^${want.verify}\\s+stored\\s`).test(l));
1160
+ if (vd.length !== 1 || !new RegExp(`${want.needs} stored then stored == ${want.legacy} .* else stored == ${want.fn} `).test(vd[0])) return { ok: false, ran: true, detail: `${want.verify} does not check both forms` };
1161
+ const comparisons = (t) => (stripComments(t).split('\n').filter((l) => !new RegExp(`^${want.verify}\\s`).test(l)).join('\n').match(new RegExp(`==\\s*\\(?${want.fn}\\s|${want.fn}\\s+\\S+\\s+\\S+\\)?\\s*==`, 'g')) || []).length;
1162
+ const verifyCalls = ls.filter((l) => !/^module\s/.test(l) && !new RegExp(`^${want.verify}\\s`).test(l) && new RegExp(`(?<![\\w'.])${want.verify}(?![\\w'])`).test(l)).length;
1163
+ if (comparisons(after) !== 0 || verifyCalls !== want.rewrittenComparisons) return { ok: false, ran: true, detail: `expected ${want.rewrittenComparisons} comparison(s) with ${want.fn} to call ${want.verify}; ${comparisons(after)} comparison(s) remain and ${verifyCalls} call it` };
1164
+ return { ok: true, ran: true, detail: `${want.legacy} keeps the original definition, ${want.fn} computes PBKDF2-HMAC-SHA256 with ${KDF_ITERATIONS} iterations, ${want.verify} checks both forms and the ${want.rewrittenComparisons} comparison(s) call it` };
1165
+ }
920
1166
  return { ok: false, ran: true, detail: `unknown effective check ${want.kind}` };
921
1167
  }
922
1168
 
@@ -331,6 +331,44 @@ function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
331
331
  return reasons;
332
332
  }
333
333
 
334
+ /**
335
+ * The migration shape: a fast password hash kept ONLY to verify hashes stored before a password KDF was introduced. True when every
336
+ * use of the function, in every file, is an operand of an equality (nothing new is stored with it) and a function that compares with
337
+ * it also computes a password KDF, directly or through one project function (`verify stored salt pw = if legacy stored then stored ==
338
+ * oldHash salt pw else stored == kdfHash salt pw`). The weak hash is still computed, so the finding is kept at `info` with this reason;
339
+ * a function that is also used to STORE a hash, or whose comparisons sit beside no KDF, is the full finding. Decided from the IR only,
340
+ * never from a name (`hashPasswordLegacy` is not evidence; the comparison and the KDF are).
341
+ */
342
+ function legacyVerifierOnly(fn, ir, memo) {
343
+ if (memo.has(fn.qid)) return memo.get(fn.qid);
344
+ const allFns = Object.values(ir.perFile).flatMap((x) => x.functions || []);
345
+ const byQid = new Map(allFns.map((x) => [x.qid, x]));
346
+ const usesF = (e) => !!e.hs && ((e.kind === 'call' && e.hs.target === fn.qid) || e.hs.inlinedFrom === fn.qid);
347
+ const kdfCall = (c) => typeof c === 'string' && (STRONG_KDF.test(c) || PBKDF2_CALLS.has(c));
348
+ const hasKdf = (g, depth) => (g.calls || []).some((c) => kdfCall(c.callee) || (depth < 1 && c.hs && c.hs.target && c.hs.target !== g.qid && byQid.has(c.hs.target) && hasKdf(byQid.get(c.hs.target), depth + 1)));
349
+ let uses = 0; let compared = 0; let besideKdf = false;
350
+ const visit = (e, parent, g, depth) => {
351
+ if (!e || typeof e !== 'object' || depth > 60) return;
352
+ if (usesF(e)) {
353
+ uses++;
354
+ if (parent && parent.kind === 'binary' && EQUALITY_OPS.has(parent.op)) { compared++; if (hasKdf(g, 0)) besideKdf = true; }
355
+ return; // under an inlined root the nodes are the helper's own body, not further uses of it
356
+ }
357
+ for (const k of Object.keys(e)) {
358
+ if (k === 'hs') continue;
359
+ const v = e[k];
360
+ if (Array.isArray(v)) { for (const x of v) visit(x, e, g, depth + 1); } else if (v && typeof v === 'object') visit(v, e, g, depth + 1);
361
+ }
362
+ };
363
+ for (const g of allFns) {
364
+ if (g.qid === fn.qid) continue;
365
+ for (const n of Object.values(g.cfg.nodes)) for (const root of [n.source, n.value, n.cond, ...(n.args || [])]) visit(root, null, g, 0);
366
+ }
367
+ const r = uses > 0 && compared === uses && besideKdf;
368
+ memo.set(fn.qid, r);
369
+ return r;
370
+ }
371
+
334
372
  /**
335
373
  * @param {Record<string,string>} files path -> source (only Haskell files are read)
336
374
  * @param {object} [opts]
@@ -354,6 +392,7 @@ export function analyzeHaskellRules(files, opts = {}) {
354
392
  }
355
393
 
356
394
  let currentFn = null;
395
+ const legacyMemo = new Map();
357
396
  const emit = (rule, file, line, extra = {}) => {
358
397
  const r = RULES[rule];
359
398
  const sev = extra.severity || r.severity;
@@ -595,7 +634,11 @@ export function analyzeHaskellRules(files, opts = {}) {
595
634
  // password hashed with a fast hash
596
635
  // (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
597
636
  if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && !(e.hs && e.hs.inlinedFrom) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
598
- emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
637
+ if (!(e.hs && e.hs.inlineCtx) && legacyVerifierOnly(fn, ir, legacyMemo)) {
638
+ emit('hs-fast-password-hash', file, line, { severity: 'info', confidence: 0.5, detail: `${c} applied to a password-shaped value, in a function used only to verify hashes made before a migration (every use of it is an equality, beside a password KDF): re-hash with the KDF at the next successful login and remove it once no stored hash needs it`, inlineCtx: e.hs && e.hs.inlineCtx, evidence: { legacyVerifier: true } });
639
+ } else {
640
+ emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
641
+ }
599
642
  }
600
643
  if (PBKDF2_CALLS.has(c)) {
601
644
  for (const a of args) for (const x of walk(a)) if (x.kind === 'object') for (const p of x.props || []) if (p.key === 'iterCounts' && p.value && p.value.kind === 'literal' && typeof p.value.value === 'number' && p.value.value < 10000) {
@@ -243,6 +243,8 @@ export function evaluateSupport(language, input) {
243
243
  for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
244
244
  const r = out.rows[cap];
245
245
  if (r && !tools[req.tool]) out.rows[cap] = { ...r, status: 'blocked', reasons: [`${req.tool} is not available on the measuring host: ${req.why}`, ...r.reasons] };
246
+ // a GHC other than the recorded toolchain compiles the fixtures against an empty package set: not a verdict on the engine
247
+ else if (r && req.tool === 'ghc' && tools.ghcMatchesRecorded === false && r.status !== 'supported') out.rows[cap] = { ...r, status: 'blocked', reasons: [`ghc ${tools.ghcVersion} on the measuring host is not the recorded toolchain (${tools.ghcRecorded}); the criterion that needs it could not be judged`, ...r.reasons] };
246
248
  }
247
249
  const rows = Object.values(out.rows);
248
250
  out.summary = { required: rows.length, supported: rows.filter((r) => r.status === 'supported').length, notSupported: rows.filter((r) => r.status !== 'supported').map((r) => `${r.capability}:${r.status}`), dataOk: dataOk.ok };