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

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
- 346477cad051a8190fb627b517a90fc33dc2510c1003a280f53fd19f3e4900bf agentic-security.mjs
1
+ 19dc2c52e129ad724c2604371ec085abff39ecbd70a9a48374e00a84668ba9fc 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.7",
3
+ "version": "0.195.9",
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,9 +93,9 @@
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-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",
97
- "test:nix": "node --test test/nix/nix-option-types.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
- "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/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",
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",
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
+ "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",
100
100
  "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
101
101
  "bench:language-support:check": "node ../bench/language-support/check.mjs",
@@ -228,7 +228,7 @@ function _escapeRegex(s) {
228
228
  // 'http-body' for req.body) that mean "this value arrived over an
229
229
  // incoming HTTP request" — the signal used to correlate a finding with a
230
230
  // declared `via: 'http'` cross-service edge.
231
- const HTTP_PROVENANCE = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie']);
231
+ const HTTP_PROVENANCE = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'upload']);
232
232
 
233
233
  // Every provenance value attached anywhere on a finding: the primary
234
234
  // source (`f.sourceProvenance`) plus every step in its source chain
@@ -66,7 +66,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
66
66
  - **Guards are recognised by what they do.** `haskell-guards.js`: a project function is an HTML escaper when it (or its local helpers) spells out the entities for `<`, `>` and `&` (`htmlEscapers`; never by name), and it neutralises a CWE-79 flow only when EVERY use of the value in the sink argument is under such a call (`allUsesEscaped`); a host allow-list may be a named constant and may sit in an `&&`; `sh -c SCRIPT arg0 arg1` is shell injection only when the SCRIPT is built from the value, and tainted positional parameters are data when the constant script expands them only inside double quotes after `--` (`quotedPositionals`, which decodes the Haskell string escapes the IR keeps raw).
67
67
  - **Handlers named by the framework's convention are routes.** `haskell-web.js` adds `getFooR`/`postFooR`/`handleFooR` functions whose signature mentions `Handler` in a module importing Yesod when no route table in an analysed file names them (the table usually lives in `config/routes`). Scotty's rejection order is judged by `inlineOrder` as well as by line: a `case` lists the success branch first, so `status 40x >> finish` in the LAST alternative guards it (a bare `status` with no halting call does not).
68
68
  - **IR idioms that must keep the action.** `m >>= \_ -> k` and `f <$> m` RUN `m` even when the lambda ignores its result (`betaReduce`, `via`); `maybe d f m` / `either f g e` are a two-way branch on the scrutinee (so the scrutinee is still examined); `\case` applied by `>>=` is a case run in place; `foldM`, `zipWithM_`, `uncurry`, `fst`/`snd`/`const` of a written-out tuple, `filter isAlphaNum`, and a let-bound lambda (`let go = \x -> ..`, treated as `go x = ..`) are lowered so their sinks are reached. The taint engine (`dataflow/engine.js`) is Haskell-scoped for three of these: a `union` handed to a combinator or bound as a statement is a sink site in each branch (`_nestedSinkFindings`, the `assign` case), and a `member` whose object is a call (the elements of `words s`) carries its object's taint.
69
- - **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process, CWE-78/88 only), `entry-parameter`, `data-input`, `unattributed`. `operator-controlled` and `api-contract` are lowered to `info` (never dropped; `originalSeverity`, `controls`, `guardNote` kept), the same convention `haskell-guards.js` uses. **Standard input is deliberately NOT covered by the command-line rule** (`getLine >>= callCommand` in `main` stays reported; `test/haskell/haskell-generalization.test.js` pins it), and **path wrappers are deliberately not `api-contract`** (`withFile name WriteMode` on a caller-named file is the vulnerable shape of three development splits). The role is `fn.hs.entryRole` from `assignEntryRoles` in `haskell-ir.js`: `cli` (reached only from `main`s whose reachable modules import no server framework or socket library), `service`, or `library` (no `main` reaches it; an incomplete call graph therefore yields `library`, never `cli`). Pinned both ways in `test/haskell/haskell-trust-boundary.test.js`.
69
+ - **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process or, since 0.195.9, a path operation: CWE-78/88/22), `entry-parameter`, `data-input`, `unattributed`. `operator-controlled` and `api-contract` are lowered to `info` (never dropped; `originalSeverity`, `controls`, `guardNote` kept), the same convention `haskell-guards.js` uses. **Standard input is covered by the command-line rule only for a path operation** (since 0.195.9: a command-line program reading a file NAME from its standard input is the operator naming a file, 14 of the 29 flagged path sites on the real-code sets; `getLine >>= callCommand` in `main` stays reported, pinned in `test/haskell/haskell-trust-boundary.test.js`). **Path wrappers became `api-contract` in 0.195.9**: `withFile name WriteMode` on a caller-named file is the vulnerable shape of three synthetic development splits and of no real-code true positive, while `renderFile path = responseFile .. path` was a flagged false positive on every real project that had it; the parameter under a fixed directory (`"/srv/" ++ name`) is still `entry-parameter` and still a finding. That is a decision against the synthetic labels, taken on the real-code sets; the development-set numbers that moved are recorded in the changelog. The role is `fn.hs.entryRole` from `assignEntryRoles` in `haskell-ir.js`: `cli` (reached only from `main`s whose reachable modules import no server framework or socket library), `service`, or `library` (no `main` reaches it; an incomplete call graph therefore yields `library`, never `cli`). Pinned both ways in `test/haskell/haskell-trust-boundary.test.js`.
70
70
  - **Origins are recorded, not guessed** (`dataflow/engine.js`). The engine records a source only against the variable a source call was assigned to; three additions make an evidence chain begin at the real origin: a value derived from a source inherits its records (`_inheritSources`), a callee analysed under an entry-taint state names its tainted parameters with placeholder records (`_paramPlaceholders`) that `_mergeSummaryFindings` resolves to the sources of the arguments at the merging call site (`_resolveParams`), and a function return keeps the sources behind it (`returnSources`, followed through `from` into `sourceRoots`). A parameter annotated as a source (`hs:caller-controlled`, `hs:handler-param:*`) is recorded too (`matchAnnotationParamSources`, `source.kind: 'parameter'` so stable ids do not change). This is language-neutral (`test/dataflow-source-attribution.test.js` pins it on JavaScript). `System.IO.hGetContents`/`hGetLine`/`hGetChar` (and the Text/ByteString forms) are modelled as `file-read`, not `stdin`: they read whatever handle they are given.
71
71
  - **Reachability must survive platform variants.** An import two modules both satisfy (the Posix and Windows variants behind an undecided CPP conditional) resolves to `unknown: ambiguous-import` with its `candidates`; `haskell-ir.js` records each candidate as a reference of the caller, so neither is an "uncalled entry point" whose parameters are caller-controlled. Without it every function of the platform module that was not chosen was reported.
72
72
  - **"No caller can exist" is a verdict with preconditions, and `null !== false`.** `haskell-ir.js` `projectReachability` stamps `fn.hs.reachability {status, rootKind, holeReason}` (`entrypoint-reachable | unreachable-in-project | external-api | unknown`); `haskell-reachability.js` `applyHaskellReachability` copies it onto each IR-TAINT finding as `f.reachability {status, reason, premise, roots, holeReason}`, a record SEPARATE from `trustBoundary` (that one says who controls the input, this one whether a caller can exist; either may be `unknown`). `unreachable-in-project` needs ALL of: `completeScan` (runScan/engine thread it; a changed-file, PR, subdirectory or caller-supplied scan is a subset, under which every export looks uncalled), no `policy-excluded`/`size-skipped` Haskell, `entrypointCount > 0` (a `main`; the sealed single-file corpora have none, so the rule is structurally inert on them), `exposedModules !== null` (`haskell-manifests.js` `exposedModuleIndex`: cabal `exposed-modules`, or hpack `exposed-modules`; an hpack library that exposes everything, `-main-is`, `-F/-pgmF`, a plugin, a non-test `build-tool-depends`, a foreign library, Backpack signatures are all `null`), the function's module not exposed (re-exports count: `reexportClosure`), not an instance method (instance methods are ROOTS, so what they call is reached, but their own verdict is `unknown/instance-method`), and not reached from a root. Roots: every `main`, framework-handler-typed functions (`hs.entryHandler`), `foreign export`s, instance methods, exported functions of exposed modules, and `withheld-caller` roots. Gaps are bounded where they can be and global where they cannot: Template Haskell/quasi-quotes the allow-lists did not accept (`preprocessForSyntaxInfo(...).opaque`), a foreign export, the function cap, an unresolved name nothing defines = global holes; a declaration the parser withheld or an expression it could not lower makes that module's own functions and the exported functions of every module it can name (re-export closure) roots, because withheld code can call only what its module can see. A call through a function VALUE is not a gap (its callee was mentioned in value position, which is a reference the walk follows). Holes are `unknown`, never `unreachable`; the order of `holeReason` is `HOLE_PRIORITY`. Pinned in `test/haskell/haskell-reachability.test.js`; the pin at `haskell-trust-boundary.test.js` (a lone `Svc.hs`) asserts `unknown/no-entrypoint`. **Severity:** only `unreachable-in-project` with `premise === 'caller-controlled-parameter'` (every recorded origin is the weak parameter label) is lowered to `info` (`lowerFinding`, guard `_reachLowered`, composes with `_trustLowered`); a finding with a recorded read keeps its severity and may only rank lower (`posture/relevance.js` `languageVerdict`). The verdict is recomputed in place per context (`buildHaskellIR(files, {manifests, completeScan, scanHoles})`; the BUILD cache is keyed by sources alone and the manifests are parsed once per context, counted by `EXPOSED_INDEX_WORK`). `fn.hs.reachedBy` and `fn.hs.paramEntry` record how a function was reached and what its parameters were before `paramAnnotations` is removed; the removal itself is unchanged. `callersUnknown(fn)` (`haskell-security-rules.js`) reads the verdict for the partial-function and unbounded-read rules; `external-api` is deliberately answered as before (turning it on added seven flagged sites and no true positive on the development set). Nothing reads a comment, a name or a path. Measured effect on real code is in `bench/dag/dev-haskell/results/README.md` (it changed no severity there) and the recorded baseline is gated by `bench/haskell-precision`.
@@ -141,7 +141,15 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
141
141
  | Real-code precision | `npm run bench:haskell-precision:check` | `bench/haskell-precision/`: the labelled development and fresh real-code sets, compared for EQUALITY against `baseline.json` (a regression and an unrecorded improvement both fail); the live scan runs only where the pinned clones exist; the sealed independent set is read from its committed result only and is required by `bench:haskell-precision:release`. `bench:haskell-precision:update` re-records deliberately |
142
142
  | Live feed | `npm run bench:live-feed -- <fetch\|plan\|scan\|merge\|sample\|report>` | `bench/live-feed/`: the opt-in Hackage feed against real projects and real OSV. Needs the network and is NOT in `npm test` or any gate. `RESULTS.md` is a dated measurement; labels in it are model-assessed |
143
143
 
144
- The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its failures were read and the engine was changed for their causes (provenance.json, `unseen-v3-development`; manifest `role: development`). `measure.mjs` records `splitRole` with every result, the registry labels the column "DEVELOPMENT set", and a generalisation claim needs a new unseen set (unseen-v4).
144
+ The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its failures were read and the engine was changed for their causes (provenance.json, `unseen-v3-development`; manifest `role: development`). `measure.mjs` records `splitRole` with every result, the registry labels the column "DEVELOPMENT set", and a generalisation claim needs an unseen set no engine change has been motivated by.
145
+
146
+ **Frozen splits (`bench/language-support/corpora/unseen-v4`, `unseen-v5`, ...).** Each is 78 single-flaw Haskell forms (13 families, 3 vulnerable and 3 safe) written blind to the engine and to every earlier set, frozen by digest (`corpora/frozen-split.mjs` is the shared builder, loader and summariser; `test/language/frozen-splits.test.js` checks every split on every run), measured once before the engine work that follows, and governed by its `USE-LOG.md`: reading its failures to change a rule turns it into a development set (`manifest.json` `role: development`, `developmentSince`), and the next generalisation claim needs the next split, written the same way (a separate agent session that cannot see the repository). **unseen-v4 and unseen-v5 are DEVELOPMENT sets since 2026-11-01** and **unseen-v6 is the Haskell generalisation measure; unseen-nix-v1 is the first Nix one** (the builder takes `eco`, a split's manifest records `ecosystem`, and a Nix form's marker is `#@@`). The lesson of 0.195.8 is the reason the discipline exists: the unseen-v4 misses were fixed (F1 64.7% to 94.9% on v4) and unseen-v5 moved from F1 63.8% to 65.7%, so most of those fixes answered v4's shapes, not Haskell's. 0.195.9 read unseen-v5 after freezing unseen-v6: v5 went from F1 65.7% to 84.1% and v6 from 62.9% to 72.5%, so this time part of it generalised (the Servant pairing from the end, the fixed-host request judgement, the credential header names, the harness's proven-clean rule). The Nix development set (unseen-v3) went to 100% and unseen-nix-v1 stayed at F1 71.0%: none of that generalised. Measure a new split before reading the old one; quote both numbers.
147
+
148
+ **The detection suite scores `actionable` findings (since 0.195.9):** a finding at `info`, or one the engine PROVED clean or infeasible (`proof.verdict` `proven-*`: a catalog sanitizer of the sink's family on the path, a dominating guard), is not a detection, the same rule `bench/language-support/lib.mjs` `actionable` already applied elsewhere. Before, `renderHtml (toHtml name)` into a Scotty `html` sink was scored a false positive although the proof gate had labelled it proven-clean. **Disclosure:** while the real-code precision work of 0.195.9 was being investigated, two frozen-holdout path-traversal sources (shapes s0 and s1, both a parameter joined under a fixed directory) were printed and read; no rule was changed to them (both are still findings), but the holdout has been seen by the person who changed the path-wrapper policy, and a reader weighing the holdout's independence should know that.
149
+
150
+ Rules added for the unseen-v5 shapes, pinned both ways in `test/haskell/haskell-unseen-v5-shapes.test.js`: a Servant handler pairs the API's reads with its LAST parameters (`orderServer conn ref`; `haskell-ir.js` `servantEndpointParams` paired from the front and annotated the closure parameter); a `Set-Cookie` header handed to `setHeader`/`addHeader` is judged like a hand-written tuple; a project helper the IR inlined at its call site (`hs.inlinedFrom`) reports a fast password hash or a credential log once, in the helper (`markInlined`, `inlineCtx`, the filter before the return); mwc-random and splitmix are predictable generators (`System.Random.MWC` in `HS_RULE_APIS` so the IR qualifies the names); a function that MAKES a key (`newKey`, `generateApiKey`) is security-shaped, and `code`/`pin`/`otp` is a security value in a function that talks about a reset or a verification (`CONTEXT_CODE_VAR`, `CODE_CONTEXT`); an authentication endpoint (`AUTH_ENDPOINT_PATH`/`_NAME`) is never missing authentication; `X-Session`, `X-Token` and similar project header names are credentials (`CREDENTIAL_HEADER`); a yes/no answer about a credential (`accepted <- checkCredentials attempt`, a comparison, a `logical`, a helper whose returns are such: `returnsYesNo`) carries no credential text, a record built from one logged through another field (`attemptUser attempt`, a `member` of it) is that field, the record logged whole is the credential, and `lastFour`/`takeEnd`/`fingerprint` are masking helpers by name; `pushLogStr(Ln)` is a log sink. In `haskell-guards.js`: a CWE-918 flow whose URL starts from a literal (or a constant bound to one) naming the scheme, the host and a `/`, or whose request is built by http-conduit's modifiers over such a base, is lowered to `info` with `guardNote.kind: fixed-host-request` (`fixedHostRequest`; a record update that sets a host field, or a literal that stops inside the host, is not); `judgeFinding` locates the sink in the CALLEE when the finding was found from a caller (the canonicalise-and-prefix guard in `persistDraft` refutes the flow found from `saveDraft`), and the XSS escaper judgement knows the catalog's HTML escapers and a project helper that uses the parameter receiving the tainted value only under an escaper. Nix (`test/nix/nix-development-shapes.test.js`): `AmbientCapabilities` as one string, `Defaults !authenticate`, a `*File` option handed a `writeText` of a literal (`nix-secret-plaintext`, exposure `store`), a TLS key path on any enabled service (`sslKey`, `sslServerKey`, `keyFile`, ...), and one script-taint finding per rule, line and origin class.
151
+
152
+ Rules added for the unseen-v4 shapes, each pinned both ways in `test/haskell/haskell-unseen-v4-shapes.test.js`: a value READ from the request inside a function (`param`, `strictRequestBody`, `requestHeaders`, any `REQUEST_PROVENANCE` source) is caller text for the partial-function, allocation and bounding rules whatever the function's parameters are; a presence test does not cover a partial call on a value SPLIT or parsed from the tested one (`SPLITTER`), while the same split of the same value does; a relational test on a count bounds the allocation it sizes; a `System.Random` value that reaches the result of a security-shaped function is predictable randomness; a project record parameter with a credential or card field logged whole (`show card`) is sensitive logging, through a masking helper by name it is not; wai-extra `requestSizeCheck` bounds the body read. In `haskell-web.js`: Servant closure arguments before the endpoint's values are paired from the end; a container lookup by the request id (`M.lookup`) is the object selection for BOLA only; BOLA without an authenticated principal needs either a WRITE to the selected object or OWNER EVIDENCE (`OWNER_COL`: a statement names an owner column its WHERE clause does not use; `OWNER_FIELD`: the record the handler works with has an owner field it never reads, where "works with" means another field of that record is read); a Scotty handler typed `ActionM` but registered elsewhere is a route with method `UNKNOWN` (ownership only, never a missing-auth finding); a Yesod `isAuthorized` clause that can answer `AuthenticationRequired` guards; a privileged path whose handler calls into unseen code is a lower-severity missing-auth finding, but a plain write with no visible operation is NOT (23 echo, upload-demo and health-check routes on four real projects). Taint: `Network.Wai.Parse.fileName` is an `upload` source; `Network.Wai.responseLBS`/`responseBuilder` are CWE-79 sinks judged by the header argument (`waiHtmlContext`: `text/html` keeps, another content type lowers to `info`, opaque headers keep with a stated uncertainty); the HTML-escaper judgement follows local bindings, and a definition with no value in it (a read) is the raw origin; a Shakespeare quasi-quote with interpolations is rewritten in place to an application of its interpolated expressions (`hamletInterpolations`, boundary kind `quasiquote-hamlet`, never opaque) so `#{preEscapedToHtml body}` is a sink site and `#{body}` is not. Two shapes stay as they were on purpose: `getArgs` reaching a shell is `operator-controlled` (info), and `fetch url = parseRequest url >>= httpNoBody` stays a finding (a URL wrapper is not an `api-contract`); unseen-v4 labels both the other way, and that is recorded, not decided.
145
153
 
146
154
  The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
147
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
@@ -17,6 +17,9 @@
17
17
  // has the prefix. A weak guard is reported as a note on the finding and does not refute it.
18
18
 
19
19
  const CANON_CALLS = new Set(['System.Directory.canonicalizePath', 'System.Directory.makeAbsolute', 'System.FilePath.normalise']);
20
+ import { HS_SANITIZERS } from './haskell-models.js';
21
+ // the catalog's HTML escapers (blaze, lucid): the same list the taint catalog is generated from
22
+ const CATALOG_HTML_ESCAPERS = new Set(HS_SANITIZERS.filter((s) => (s.appliesTo || []).includes('xss')).map((s) => `${s.module}.${s.name}`));
20
23
  const EXIT_CALLS = new Set(['Control.Exception.throwIO', 'Control.Exception.throw', 'System.Exit.die', 'System.Exit.exitFailure', 'System.Exit.exitWith', 'throwIO', 'throw', 'die', 'exitFailure', 'exitWith', 'error', 'fail', 'ioError', 'Control.Monad.Fail.fail']);
21
24
  const WHEN = new Set(['Control.Monad.when', 'when']);
22
25
  const UNLESS = new Set(['Control.Monad.unless', 'unless']);
@@ -319,8 +322,10 @@ function allUsesEscaped(fn, e, escapers, depth = 0) {
319
322
  if (e.kind === 'call' && escapers.has(e.callee)) return true; // whatever it receives is escaped
320
323
  if (e.kind === 'ident') {
321
324
  if (e.hs && e.hs.functionRef) return true;
325
+ // a name bound in this function stands for its definition (`page = "<p>" ++ path ++ "</p>"` with `path = escape (...)`): every use inside it must be escaped too
326
+ // A definition built from other values is judged by them; a definition with no value in it (`w <- param "w"`, a read) is the raw origin.
322
327
  const defs = nodeList(fn).filter((n) => n.kind === 'assign' && n.target === e.name && n.source);
323
- return defs.length > 0 && defs.every((n) => n.source.kind === 'call' && escapers.has(n.source.callee));
328
+ return defs.length > 0 && defs.every((n) => (n.source.kind === 'call' && escapers.has(n.source.callee)) || (identNames(n.source).size > 0 && allUsesEscaped(fn, n.source, escapers, depth + 1)));
324
329
  }
325
330
  for (const [, c] of exprChildren(e)) if (!allUsesEscaped(fn, c, escapers, depth + 1)) return false;
326
331
  return true;
@@ -334,10 +339,15 @@ export function judgeFinding(f, perFile) {
334
339
  const fam = FAMILY_BY_CWE[f.cwe];
335
340
  const ir = perFile && perFile[f.file];
336
341
  if (!fam || !ir || !f._funcQid || !f.callee) return { status: 'none' };
337
- const fn = ir.functions.find((x) => x.qid === f._funcQid);
338
- if (!fn) return { status: 'none' };
339
- const sites = findSites(fn, f.callee, f.line);
340
- if (!sites.length) return { status: 'none' };
342
+ // A finding found while analysing a CALLER (`_funcQid`) is located at the callee's line: the sink function is the one in the
343
+ // finding's file that has the call at that line (the caller first, then the others), the way haskell-trust.js locates it.
344
+ const direct = ir.functions.find((x) => x.qid === f._funcQid);
345
+ let fn = null; let sites = [];
346
+ for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
347
+ const s = findSites(cand, f.callee, f.line);
348
+ if (s.length) { fn = cand; sites = s; break; }
349
+ }
350
+ if (!fn || !sites.length) return { status: 'none' };
341
351
  let weak = null;
342
352
  let first = null;
343
353
  for (const site of sites) {
@@ -352,11 +362,52 @@ export function judgeFinding(f, perFile) {
352
362
  const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
353
363
  let refuted = null;
354
364
  if (fam === 'xss') {
355
- const escapers = htmlEscapers(perFile);
365
+ // the project's own escapers plus the catalog's HTML escapers (blaze/lucid `toHtml`, `text`, ...): the taint walk records the latter on
366
+ // the path for the proof gate, but a sink argument built by a HELPER (`html (renderHtml (greeting name))`) is judged here, where the
367
+ // helper's body can be read: a helper that uses the parameter receiving the tainted value only under an escaper escapes it
368
+ const escapers = new Set([...htmlEscapers(perFile), ...CATALOG_HTML_ESCAPERS]);
369
+ const helperByName = (callee) => ir.functions.find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`)) || null;
370
+ const paramEscaped = (helper, param, depth) => {
371
+ if (depth > 2) return false;
372
+ const under = (e, inEsc, d) => {
373
+ if (!e || typeof e !== 'object' || d > 40) return true;
374
+ if (e.kind === 'ident' && e.name === param) return inEsc;
375
+ if (e.kind === 'call' && typeof e.callee === 'string') {
376
+ if (escapers.has(e.callee)) return true;
377
+ const h = helperByName(e.callee);
378
+ if (h && Array.isArray(h.params)) {
379
+ // a nested helper: each argument that mentions the parameter must land on a parameter the nested helper escapes
380
+ return (e.args || []).every((a, i) => !identNames(a).has(param) || (h.params[i] && paramEscaped(h, h.params[i], depth + 1) && under(a, true, d + 1)));
381
+ }
382
+ }
383
+ for (const [, c] of exprChildren(e)) if (!under(c, inEsc, d + 1)) return false;
384
+ return true;
385
+ };
386
+ for (const n of nodeList(helper)) {
387
+ for (const k of ['source', 'value', 'cond']) if (n[k] && !under(n[k], false, 0)) return false;
388
+ if (Array.isArray(n.args)) for (const a of n.args) if (!under(a, false, 0)) return false;
389
+ }
390
+ return true;
391
+ };
392
+ const addHelperEscapers = (e, d) => {
393
+ if (!e || typeof e !== 'object' || d > 40) return;
394
+ if (e.kind === 'call' && typeof e.callee === 'string' && !escapers.has(e.callee)) {
395
+ const h = helperByName(e.callee);
396
+ if (h && Array.isArray(h.params) && (e.args || []).length) {
397
+ const taintedPositions = (e.args || []).map((a, i) => ([...identNames(a)].some((v) => vars.has(v) || derived.has(v)) ? i : -1)).filter((i) => i >= 0);
398
+ if (taintedPositions.length && taintedPositions.every((i) => h.params[i] && paramEscaped(h, h.params[i], 0))) escapers.add(e.callee);
399
+ }
400
+ }
401
+ for (const [, c] of exprChildren(e)) addHelperEscapers(c, d + 1);
402
+ };
403
+ addHelperEscapers(argExpr, 0);
404
+ for (const v of identNames(argExpr)) for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === v && n.source) addHelperEscapers(n.source, 0);
356
405
  const calls = [];
357
406
  const find = (e, d) => { if (!e || typeof e !== 'object' || d > 40) return; if (e.kind === 'call' && escapers.has(e.callee)) calls.push(e); for (const [, c] of exprChildren(e)) find(c, d + 1); };
358
- find(argExpr, 0);
359
- for (const n of nodeList(fn)) if (n.kind === 'assign' && n.source && identNames(argExpr).has(n.target)) find(n.source, 0);
407
+ // the escaper may sit behind one or more local bindings (`page = ... path ...`, `path = escape (...)`): follow them, each once
408
+ const followed = new Set();
409
+ const findIn = (e, d) => { if (!e || d > 40) return; find(e, d); for (const v of identNames(e)) { if (followed.has(v)) continue; followed.add(v); for (const n of nodeList(fn)) if (n.kind === 'assign' && n.source && n.target === v) findIn(n.source, d + 1); } };
410
+ findIn(argExpr, 0);
360
411
  if (calls.length && allUsesEscaped(fn, argExpr, escapers)) refuted = { kind: 'html-escaper', line: calls[0].line || fn.line, subject: calls[0].callee };
361
412
  }
362
413
  for (const fact of facts) {
@@ -473,10 +524,130 @@ export function quotedPositionals(f, perFile) {
473
524
  }
474
525
 
475
526
  /** Applies judgeFinding to every Haskell taint finding. Returns counts. */
527
+ /**
528
+ * A raw WAI response (`responseLBS status headers body`) is an HTML context only when its headers say so. Returns `html` (a `text/html`
529
+ * content type is written in the header list), `other` (another content type is written there), or `unknown` (the headers are not literal
530
+ * text the walk can read). Read from the call's own header argument; nothing else decides it.
531
+ */
532
+ export function waiHtmlContext(f, perFile) {
533
+ if (!f || f.cwe !== 'CWE-79' || !/^Network\.Wai\.response(?:LBS|Builder)$/.test(f.callee || '')) return null;
534
+ const ir = perFile && perFile[f.file];
535
+ if (!ir) return 'unknown';
536
+ const fns = [ir.functions.find((x) => x.qid === f._funcQid), ...ir.functions].filter(Boolean);
537
+ for (const fn of fns) {
538
+ const sites = findSites(fn, f.callee, f.line);
539
+ if (!sites.length) continue;
540
+ const texts = [];
541
+ const resolveLocal = (name) => { for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) return n.source; return null; };
542
+ const visit = (e, d) => {
543
+ if (!e || typeof e !== 'object' || d > 40) return;
544
+ if (e.kind === 'literal' && typeof e.value === 'string') texts.push(e.value);
545
+ if (e.kind === 'ident' && d < 2) { const src = resolveLocal(e.name); if (src) visit(src, d + 1); }
546
+ for (const [, c] of exprChildren(e)) visit(c, d + 1);
547
+ };
548
+ for (const site of sites) visit((site.call.args || [])[1], 0);
549
+ const types = texts.filter((t) => /^(?:text|application|image|audio|video|multipart)\/[\w.+-]+/i.test(t.trim()));
550
+ if (types.some((t) => /^text\/html/i.test(t.trim()))) return 'html';
551
+ if (types.length) return 'other';
552
+ return 'unknown';
553
+ }
554
+ return 'unknown';
555
+ }
556
+
557
+ // ── a request whose host is fixed ────────────────────────────────────────────
558
+ // SSRF is the client choosing the HOST. A URL built as `fixedBase <> clientText` where the fixed part is a literal (or a constant bound to
559
+ // one) that already names a scheme, a host and a "/" cannot reach another host: the client text lands in the path, query or fragment.
560
+ // Likewise a request value built by http-conduit's modifiers (`setRequestQueryString q base`, `setRequestPath p base`, ...) over a fixed
561
+ // base request keeps that base's host; the modifiers take the base as their LAST argument. A record update of such a value is judged by
562
+ // its base unless it sets a field that names the host.
563
+ const HOST_ANCHORED = /^[a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/[^\s]*$/i;
564
+ const REQUEST_BUILDERS = /(?:^|\.)(?:parseRequest|parseRequest_|parseUrlThrow|parseUrl|requestFromURI|requestFromURI_)$/;
565
+ const REQUEST_MODIFIERS = /(?:^|\.)(?:setRequestQueryString|setRequestQuery|setQueryString|setRequestPath|setRequestBody|setRequestBodyLBS|setRequestBodyJSON|setRequestBodyURLEncoded|setRequestBodyFile|setRequestBodySource|setRequestHeader|setRequestHeaders|addRequestHeader|setRequestMethod|setRequestBasicAuth|setRequestBearerAuth|setRequestIgnoreStatus|setRequestResponseTimeout|setRequestCheckStatus|setRequestManager|urlEncodedBody|applyBasicAuth|applyBearerAuth)$/;
566
+ const PEEL_WRAPPERS = /(?:^|\.)(?:liftIO|pure|return|unpack|pack|fromString|toStrict|fromStrict|encodeUtf8|decodeUtf8|toText|fromText)$/;
567
+ const HOST_FIELD = /^(?:host|port|secure|url|uri|requestHost)$/i;
568
+
569
+ function fixedHostRequest(f, perFile) {
570
+ if (!f || f.cwe !== 'CWE-918') return null;
571
+ const ir = perFile && perFile[f.file];
572
+ if (!ir || !f.callee) return null;
573
+ const direct = ir.functions.find((x) => x.qid === f._funcQid);
574
+ let fn = null; let sites = [];
575
+ for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
576
+ const s = findSites(cand, f.callee, f.line);
577
+ if (s.length) { fn = cand; sites = s; break; }
578
+ }
579
+ if (!fn || !sites.length) return null;
580
+ const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
581
+ // a local name bound once stands for its definition; a zero-parameter function of the module whose body is one literal is a constant
582
+ const localDef = (name) => { const defs = []; for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) defs.push(n.source); return defs.length === 1 ? defs[0] : null; };
583
+ const constDef = (name) => {
584
+ const bare = name.replace(/^.*\./, '');
585
+ const c = ir.functions.find((x) => (x.name === name || x.qid === name || (x.name || '').replace(/^.*\./, '') === bare) && (!Array.isArray(x.params) || x.params.length === 0));
586
+ if (!c) return null;
587
+ const rets = Object.values(c.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
588
+ return rets.length === 1 ? rets[0].value : null;
589
+ };
590
+ let evidence = null;
591
+ const fixed = (e, seen, depth) => {
592
+ if (!e || typeof e !== 'object' || depth > 24) return false;
593
+ if (e.kind === 'literal') { const ok = typeof e.value === 'string' && HOST_ANCHORED.test(e.value); if (ok) evidence = e.value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/).*$/i, '$1'); return ok; }
594
+ if (e.kind === 'ident') {
595
+ if (seen.has(e.name)) return false;
596
+ seen.add(e.name);
597
+ const d = localDef(e.name) || constDef(e.name);
598
+ return d ? fixed(d, seen, depth + 1) : false;
599
+ }
600
+ if (e.kind === 'union') return Array.isArray(e.branches) && e.branches.length > 0 && e.branches.every((b) => fixed(b, new Set(seen), depth + 1));
601
+ if (e.kind === 'object' && Array.isArray(e.props)) {
602
+ // a record update: `{ method = "POST" }` joined (as a union) with its base; a field that names the host makes it unknown
603
+ if (e.props.some((p) => HOST_FIELD.test(String(p.key || p.name || '')))) return false;
604
+ return e.object ? fixed(e.object, seen, depth + 1) : true;
605
+ }
606
+ if (e.kind === 'binary' && (e.op === '<>' || e.op === '++' || (e.hs && e.hs.concat))) return fixed(e.left, seen, depth + 1);
607
+ if (e.kind === 'call' && typeof e.callee === 'string') {
608
+ const args = e.args || [];
609
+ if (PEEL_WRAPPERS.test(e.callee) && args.length === 1) return fixed(args[0], seen, depth + 1);
610
+ if (REQUEST_BUILDERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
611
+ if (REQUEST_MODIFIERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
612
+ if (/(?:^|\.)(?:<\/>|combine|joinPath)$/.test(e.callee) && args.length === 2) return fixed(args[0], seen, depth + 1);
613
+ }
614
+ return false;
615
+ };
616
+ for (const site of sites) {
617
+ const arg = (site.call.args || [])[argIdx];
618
+ if (!arg) return null;
619
+ // a record update is a union of the base and the update: a union judged branch by branch above; an `object` branch with no base is the
620
+ // update alone, which is fixed when it names no host field, so the base branch decides
621
+ if (!fixed(arg, new Set(), 0)) return null;
622
+ }
623
+ return { kind: 'fixed-host-request', base: evidence };
624
+ }
625
+
476
626
  export function applyHaskellGuards(findings, perFile) {
477
627
  const stats = { refuted: 0, weak: 0 };
478
628
  for (const f of findings || []) {
479
629
  if (!f || f.parser !== 'IR-TAINT') continue;
630
+ try {
631
+ const ctx = waiHtmlContext(f, perFile);
632
+ if (ctx === 'other' && !f._notHtmlContext) {
633
+ f._notHtmlContext = true; f.severity = 'info';
634
+ f.controls = [...(f.controls || []), { kind: 'non-html-content-type', evidenceKind: 'source' }];
635
+ f.guardNote = { kind: 'non-html-content-type', reason: 'the response headers written at this call name a content type other than text/html; the body is not parsed as HTML by a browser that honours them' };
636
+ stats.weak++;
637
+ continue;
638
+ }
639
+ if (ctx === 'unknown' && !f._htmlContextUnknown) { f._htmlContextUnknown = true; f.uncertainty = [...(f.uncertainty || []), { kind: 'unread-content-type', detail: 'the response headers at this call are not literal text, so whether the body is served as HTML was not read' }]; }
640
+ } catch { /* judged below */ }
641
+ try {
642
+ const fh = fixedHostRequest(f, perFile);
643
+ if (fh && !f._fixedHost) {
644
+ f._fixedHost = true; f.severity = 'info';
645
+ f.controls = [...(f.controls || []), { kind: 'fixed-host-request', evidenceKind: 'source' }];
646
+ f.guardNote = { kind: 'fixed-host-request', reason: `the request's host is fixed by the literal${fh.base ? ` "${fh.base}"` : ''} the URL or base request starts from; the client's text can only choose the path, query or fragment, which is not a server-side request forgery (a path it chooses on that host is still worth a look)` };
647
+ stats.weak++;
648
+ continue;
649
+ }
650
+ } catch { /* judged below */ }
480
651
  try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
481
652
  try {
482
653
  const qp = quotedPositionals(f, perFile);
@@ -97,7 +97,8 @@ function sigInfo(tokens, sig) {
97
97
  // module proves it trusted. They are modelled as a source of kind "caller-controlled parameter" (a weaker claim than a
98
98
  // request or stdin read: the finding says so). A function without a signature, a non-exported function and a parameter
99
99
  // of any other type (a connection, a number, a record) are never sources.
100
- const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
100
+ // (an aeson `Value`/`Object` is a JSON document the caller wrote: every string a handler digs out of it is the caller's text)
101
+ const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|Value|Object|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
101
102
  // Parameters of an exported function whose declared type is a container or text (a list, a map, a set, a sequence, a vector, a string):
102
103
  // a PARTIAL function (`maximum`, `head`, `Map.!`) applied to one fails on the empty or missing case the caller can supply. Wider than
103
104
  // TEXT_LIKE on purpose, and used only by the partial-function rule, never as an injection source.
@@ -147,14 +148,20 @@ function servantEndpointParams(mod, fn, sig, exported) {
147
148
  };
148
149
  const body = slice.slice(eq + 1);
149
150
  if (split(body, ':<|>').filter((p) => p.length).length !== 1) return [];
151
+ const parts = split(body, ':>').filter((p) => p.length);
152
+ // The endpoint's values are the LAST parameters of the handler: `orderServer conn ref` for `QueryParam "ref" Text` takes `conn` from the
153
+ // closure. Pair from the end (the same rule haskell-web.js applies); a handler with fewer parameters than the API names is not paired.
154
+ const reads = parts.filter((p) => p[0] && p[0].t === 'con' && (SERVANT_PRINCIPAL.has(p[0].v) || SERVANT_READS.has(p[0].v))).length;
155
+ const offset = fn.params.length - reads;
156
+ if (offset < 0) return [];
150
157
  const out = [];
151
158
  let idx = 0;
152
- for (const part of split(body, ':>').filter((p) => p.length)) {
159
+ for (const part of parts) {
153
160
  const head = part[0];
154
161
  if (!head || head.t !== 'con') continue;
155
162
  const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
156
163
  if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
157
- const name = fn.params[idx];
164
+ const name = fn.params[offset + idx];
158
165
  idx++;
159
166
  if (isPrincipal || !name) continue;
160
167
  // the carried type is what follows the name string (or the content-type list for a body): its text decides whether it is text
@@ -162,7 +169,7 @@ function servantEndpointParams(mod, fn, sig, exported) {
162
169
  let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
163
170
  if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
164
171
  const type = rest.map((t) => t.v).join(' ').trim();
165
- if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: idx - 1, name, decorator: 'hs:handler-param:servant' });
172
+ if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: offset + idx - 1, name, decorator: 'hs:handler-param:servant' });
166
173
  }
167
174
  return out;
168
175
  }
@@ -162,6 +162,8 @@ for (const mod of ['Yesod.Core', 'Yesod']) {
162
162
  src('Yesod.Core.Json', ['parseJsonBody', 'requireJsonBody', 'parseCheckJsonBody', 'requireCheckJsonBody'], 'http-body', 'yesod request body', { package: 'yesod', framework: 'yesod' });
163
163
  // WAI: the multipart/form body parsers and the conduit request body, and the request's host and cookie headers
164
164
  src('Network.Wai.Parse', ['parseRequestBody', 'parseRequestBodyEx'], 'http-body', 'WAI parsed request body', { package: 'wai-extra', framework: 'wai' });
165
+ // the name and content type of an uploaded file are fields the client wrote into the multipart body (`fileName fi` is the browser's file name, verbatim)
166
+ src('Network.Wai.Parse', ['fileName', 'fileContentType', 'fileContent'], 'upload', 'uploaded file field', { package: 'wai-extra', framework: 'wai' });
165
167
  src('Network.Wai.Conduit', ['sourceRequestBody'], 'http-body', 'WAI request body stream', { package: 'wai-extra', framework: 'wai' });
166
168
 
167
169
  // A text parameter of an exported function that returns a web-framework HANDLER value (a Scotty action, a Yesod handler or widget, a Servant handler,
@@ -264,6 +266,9 @@ for (const mod of ['Yesod', 'Yesod.Core', 'Yesod.Core.Widget', 'Text.Blaze', 'Te
264
266
  sink(mod, ['preEscapedToMarkup', 'preEscapedToHtml'], { ...XSS, package: mod.startsWith('Yesod') ? 'yesod' : 'blaze-html', ...(mod.startsWith('Yesod') ? { framework: 'yesod' } : {}) });
265
267
  }
266
268
  sink('Web.Scotty', ['html'], { ...XSS, package: 'scotty', framework: 'scotty', vuln: 'Cross-Site Scripting (HTML response from text)' });
269
+ // A raw WAI response is HTML only when its headers say so: haskell-guards.js `waiHtmlContext` reads the header argument and lowers the finding to
270
+ // `info` for another content type (text/plain, application/json), keeps it for text/html, and keeps it with a stated uncertainty when the headers are opaque.
271
+ sink('Network.Wai', ['responseLBS', 'responseBuilder'], { ...XSS, argIndex: 2, package: 'wai', framework: 'wai', vuln: 'Cross-Site Scripting (HTML response body from text)' });
267
272
  sink('Web.Scotty.Trans', ['html'], { ...XSS, package: 'scotty', framework: 'scotty', vuln: 'Cross-Site Scripting (HTML response from text)' });
268
273
 
269
274
  // HTML assembled by concatenation and written out as text: only when the string being built contains markup (a literal
@@ -339,6 +344,8 @@ const SAN = [];
339
344
  const san = (module, names, appliesTo, note, extra = {}) => { for (const e of E(module, names)) SAN.push({ ...e, appliesTo, note, ...extra }); };
340
345
  san('Text.Blaze.Html', ['toHtml', 'toMarkup', 'text', 'string', 'lazyText'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context');
341
346
  san('Text.Blaze', ['toMarkup', 'toValue', 'text', 'string', 'lazyText'], ['xss'], 'escapes for HTML context');
347
+ // Text.Blaze.Html5 and Html4 re-export the escaping combinators, and most files import the element module rather than Text.Blaze.Html
348
+ for (const mod of ['Text.Blaze.Html5', 'Text.Blaze.Html4.Strict', 'Text.Blaze.Html4.Transitional', 'Text.Blaze.Html4.FrameSet', 'Text.Blaze.XHtml5', 'Text.Blaze.XHtml1.Strict']) san(mod, ['toHtml', 'toMarkup', 'toValue', 'text', 'string', 'lazyText', 'textValue', 'stringValue'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context');
342
349
  san('Lucid', ['toHtml', 'toHtmlRaw_'], ['xss'], 'Lucid.toHtml escapes');
343
350
  san('System.FilePath', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
344
351
  san('System.FilePath.Posix', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
@@ -373,6 +380,8 @@ export const HS_LOG_SINKS = Object.freeze([
373
380
  ...E('Text.Printf', ['hPrintf']).map((e) => ({ ...e, argIndex: 1, argFrom: 1, channel: 'handle' })),
374
381
  ...E('Katip', ['logFM', 'logTM', 'logItem']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
375
382
  ...E('System.Log.Logger', ['debugM', 'infoM', 'noticeM', 'warningM', 'errorM', 'criticalM']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
383
+ // fast-logger: the logger set is argument 0, the message argument 1
384
+ ...E('System.Log.FastLogger', ['pushLogStr', 'pushLogStrLn']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
376
385
  ]);
377
386
 
378
387
  // Library surfaces the structural rules (haskell-security-rules.js) look for; registering them here makes
@@ -386,6 +395,9 @@ export const HS_RULE_APIS = Object.freeze([
386
395
  ...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
387
396
  ...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
388
397
  ...E('System.Random', ['randomRIO', 'randomIO', 'newStdGen', 'getStdGen', 'mkStdGen', 'randomR', 'random', 'randoms', 'randomRs', 'getStdRandom', 'uniformR', 'uniform', 'initStdGen']),
398
+ // mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
399
+ ...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
400
+ ...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
389
401
  ...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
390
402
  ...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
391
403
  ...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
@@ -40,7 +40,38 @@ export const DEFAULT_PARSE_BUDGETS = Object.freeze({
40
40
 
41
41
  // Boundaries that are disclosed but do not make a file incomplete: the content was either parsed (cpp, ffi) or matched
42
42
  // by a closed, sound pattern and stated as an assumption (see haskell-boundaries.js).
43
- export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
43
+ export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert', 'quasiquote-hamlet']);
44
+
45
+ // Shakespeare's HTML template quoters: the interpolations are Haskell expressions of the enclosing scope (see absorbSafeBoundaries).
46
+ const HAMLET_QUOTERS = new Set(['hamlet', 'whamlet', 'shamlet', 'xhamlet', 'ihamlet', 'xshamlet']);
47
+
48
+ /**
49
+ * The interpolations of a hamlet body, in order: `#{e}` (escaped value), `^{e}` (embedded template or widget), `@{r}` / `@?{r}` (route) and `_{m}`
50
+ * (message). Braces nest (a record or a lambda inside), and a string literal may contain `}`. Returns offsets into the segment: the opener's
51
+ * first character, the expression's first character, and the closing brace.
52
+ */
53
+ export function hamletInterpolations(seg, from, to) {
54
+ const out = [];
55
+ for (let i = from; i < to; i++) {
56
+ const ch = seg[i];
57
+ if (!(ch === '#' || ch === '^' || ch === '@' || ch === '_')) continue;
58
+ let j = i + 1;
59
+ if (ch === '@' && seg[j] === '?') j++;
60
+ if (seg[j] !== '{') continue;
61
+ let depth = 1, k = j + 1, inStr = false;
62
+ for (; k < to && depth > 0; k++) {
63
+ const c = seg[k];
64
+ if (inStr) { if (c === '\\') k++; else if (c === '"') inStr = false; continue; }
65
+ if (c === '"') inStr = true;
66
+ else if (c === '{') depth++;
67
+ else if (c === '}') { depth--; if (depth === 0) break; }
68
+ }
69
+ if (depth !== 0 || k >= to) continue;
70
+ if (k > j + 1) out.push({ open: i, exprStart: j + 1, close: k });
71
+ i = k;
72
+ }
73
+ return out;
74
+ }
44
75
 
45
76
  class BudgetError extends Error {
46
77
  constructor(budget, limit) {
@@ -1078,6 +1109,36 @@ function analyze(source, file, dialect, ctx, res) {
1078
1109
  b.assumption = 'upstream-quoter';
1079
1110
  res.blankRanges.push({ kind: 'quasiquote-inert', startOffset: b.span.startOffset, endOffset: b.span.endOffset });
1080
1111
  }
1112
+ // Shakespeare's HTML quoters (`[whamlet|<p>#{name}|]`): the template text is inert, and each `#{e}` / `^{e}` / `@{e}` / `_{e}` interpolation is an
1113
+ // ordinary Haskell expression evaluated in the enclosing scope. The quasi-quote is rewritten IN PLACE, same length, same newlines, as an
1114
+ // application of an unknown function to the interpolated expressions, each kept at its original offset (`(hamletQ (preEscapedToHtml body))`),
1115
+ // so a sink inside an interpolation (`preEscapedToHtml`) and the request value it is handed stay visible to the IR, with their original spans.
1116
+ // Hamlet escapes a plain `#{e}` itself, so the wrapper is not a sink; the template's own text (tags, attributes, `$forall`) is not modelled.
1117
+ // Only with a Shakespeare/Yesod import, so a project quoter that borrows the name stays opaque.
1118
+ const shakespeare = res.imports.some((im) => /^(?:Yesod|Text\.Hamlet|Text\.Shakespeare|Text\.Blaze)(?:\.|$)/.test(im.module || ''));
1119
+ for (const b of res.boundaries) {
1120
+ if (b.kind !== 'quasiquote' || !shakespeare || !HAMLET_QUOTERS.has(b.quoter)) continue;
1121
+ const seg = source.slice(b.span.startOffset, b.span.endOffset);
1122
+ const bar = seg.indexOf('|');
1123
+ if (bar < 0 || !seg.endsWith('|]')) continue;
1124
+ const interps = hamletInterpolations(seg, bar + 1, seg.length - 2);
1125
+ if (!interps.length) continue;
1126
+ const chars = seg.split('').map((ch) => (ch === '\n' || ch === '\r' ? ch : ' '));
1127
+ const put = (at, s) => { for (let i = 0; i < s.length; i++) if (chars[at + i] !== '\n' && chars[at + i] !== '\r') chars[at + i] = s[i]; };
1128
+ put(0, ('(hamletQ').slice(0, bar + 1).padEnd(bar + 1));
1129
+ for (const it of interps) {
1130
+ put(it.open, ' ('.slice(-(it.exprStart - it.open)).padStart(it.exprStart - it.open)); // the `#{` / `^{` / `@?{` opener becomes spaces and one `(`
1131
+ put(it.exprStart, seg.slice(it.exprStart, it.close));
1132
+ put(it.close, ')');
1133
+ }
1134
+ put(interps[interps.length - 1].close + 1, ')'); // closes the application; the char after the last `}` is template text or the `|` of `|]`
1135
+ const text = chars.join('');
1136
+ if (text.length !== seg.length) continue;
1137
+ b.kind = 'quasiquote-hamlet';
1138
+ b.detail = `quasi-quote [${b.quoter}|...|] with ${interps.length} interpolation(s): the interpolated expressions are analysed in the enclosing scope; the template text is not modelled; ASSUMED to be the Shakespeare quoter`;
1139
+ b.assumption = 'upstream-quoter';
1140
+ res.blankRanges.push({ kind: 'quasiquote-hamlet', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
1141
+ }
1081
1142
  }
1082
1143
 
1083
1144
  // ── errors → withheld calls ──
@@ -1165,6 +1226,7 @@ export function preprocessForSyntaxInfo(source, opts = {}) {
1165
1226
  const seg = out.slice(b.startOffset, b.endOffset);
1166
1227
  let repl = blank(seg);
1167
1228
  if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
1229
+ if (b.kind === 'quasiquote-hamlet' && typeof b.text === 'string' && b.text.length === seg.length) repl = b.text; // the interpolations, at their offsets
1168
1230
  out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
1169
1231
  }
1170
1232
  return { text: out, opaque };