@clear-capabilities/agentic-security-scanner 0.195.8 → 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
- 0ab4e06e4afbe2190dfc789ed14282b05a74c3fdfae828a682afb7a250f8ef83 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.8",
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,8 +93,8 @@
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-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/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",
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
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",
@@ -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`.
@@ -143,7 +143,11 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
143
143
 
144
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
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 is a DEVELOPMENT set since 2026-11-01** and **unseen-v5 is the generalisation measure.** 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. Measure a new split before reading the old one; quote both numbers.
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.
147
151
 
148
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.
149
153
 
@@ -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']);
@@ -336,10 +339,15 @@ export function judgeFinding(f, perFile) {
336
339
  const fam = FAMILY_BY_CWE[f.cwe];
337
340
  const ir = perFile && perFile[f.file];
338
341
  if (!fam || !ir || !f._funcQid || !f.callee) return { status: 'none' };
339
- const fn = ir.functions.find((x) => x.qid === f._funcQid);
340
- if (!fn) return { status: 'none' };
341
- const sites = findSites(fn, f.callee, f.line);
342
- 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' };
343
351
  let weak = null;
344
352
  let first = null;
345
353
  for (const site of sites) {
@@ -354,7 +362,46 @@ export function judgeFinding(f, perFile) {
354
362
  const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
355
363
  let refuted = null;
356
364
  if (fam === 'xss') {
357
- 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);
358
405
  const calls = [];
359
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); };
360
407
  // the escaper may sit behind one or more local bindings (`page = ... path ...`, `path = escape (...)`): follow them, each once
@@ -507,6 +554,75 @@ export function waiHtmlContext(f, perFile) {
507
554
  return 'unknown';
508
555
  }
509
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
+
510
626
  export function applyHaskellGuards(findings, perFile) {
511
627
  const stats = { refuted: 0, weak: 0 };
512
628
  for (const f of findings || []) {
@@ -522,6 +638,16 @@ export function applyHaskellGuards(findings, perFile) {
522
638
  }
523
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' }]; }
524
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 */ }
525
651
  try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
526
652
  try {
527
653
  const qp = quotedPositionals(f, perFile);
@@ -148,14 +148,20 @@ function servantEndpointParams(mod, fn, sig, exported) {
148
148
  };
149
149
  const body = slice.slice(eq + 1);
150
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 [];
151
157
  const out = [];
152
158
  let idx = 0;
153
- for (const part of split(body, ':>').filter((p) => p.length)) {
159
+ for (const part of parts) {
154
160
  const head = part[0];
155
161
  if (!head || head.t !== 'con') continue;
156
162
  const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
157
163
  if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
158
- const name = fn.params[idx];
164
+ const name = fn.params[offset + idx];
159
165
  idx++;
160
166
  if (isPrincipal || !name) continue;
161
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
@@ -163,7 +169,7 @@ function servantEndpointParams(mod, fn, sig, exported) {
163
169
  let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
164
170
  if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
165
171
  const type = rest.map((t) => t.v).join(' ').trim();
166
- 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' });
167
173
  }
168
174
  return out;
169
175
  }
@@ -380,6 +380,8 @@ export const HS_LOG_SINKS = Object.freeze([
380
380
  ...E('Text.Printf', ['hPrintf']).map((e) => ({ ...e, argIndex: 1, argFrom: 1, channel: 'handle' })),
381
381
  ...E('Katip', ['logFM', 'logTM', 'logItem']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
382
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' })),
383
385
  ]);
384
386
 
385
387
  // Library surfaces the structural rules (haskell-security-rules.js) look for; registering them here makes
@@ -393,6 +395,9 @@ export const HS_RULE_APIS = Object.freeze([
393
395
  ...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
394
396
  ...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
395
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']),
396
401
  ...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
397
402
  ...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
398
403
  ...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
@@ -53,7 +53,11 @@ const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.
53
53
  const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
54
54
  const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
55
55
  const IV_CALLS = new Set(['Crypto.Cipher.Types.makeIV', 'Crypto.Cipher.ChaChaPoly1305.nonce12', 'Crypto.Cipher.ChaChaPoly1305.nonce8']);
56
- const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen']);
56
+ const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen',
57
+ // mwc-random is a statistical generator (multiply-with-carry); `createSystemRandom` only seeds it from the OS, its output is not a CSPRNG
58
+ 'System.Random.MWC.uniformR', 'System.Random.MWC.uniform', 'System.Random.MWC.uniformRM', 'System.Random.MWC.uniformM', 'System.Random.MWC.createSystemRandom', 'System.Random.MWC.create', 'System.Random.MWC.initialize', 'System.Random.MWC.withSystemRandom', 'System.Random.MWC.withSystemRandomST',
59
+ // splitmix is the pure generator behind random
60
+ 'System.Random.SplitMix.nextWord64', 'System.Random.SplitMix.nextInt', 'System.Random.SplitMix.nextDouble', 'System.Random.SplitMix.mkSMGen', 'System.Random.SplitMix.newSMGen', 'System.Random.SplitMix.initSMGen']);
57
61
  const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body', 'Network.Wai.Conduit.sourceRequestBody']);
58
62
  const SOCKET_READS = new Set(['Network.Socket.ByteString.recv', 'Network.Socket.ByteString.recvFrom', 'Network.Socket.ByteString.Lazy.recv']);
59
63
  const SET_COOKIE_HEADER = new Set(['Network.HTTP.Types.Header.hSetCookie', 'Network.HTTP.Types.hSetCookie']);
@@ -71,7 +75,8 @@ const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(
71
75
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
72
76
  // A salt is deliberately absent: it must be unique, not unpredictable or secret (it is stored beside the hash), so a non-cryptographic generator
73
77
  // producing one is not a weakness the way it is for a token, nonce or key.
74
- const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification)/i;
78
+ // (`newKey`, `generateApiKey`, `mkKey`: a function that MAKES a key is security-shaped; `key` alone is not, it names map keys too)
79
+ const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification|(?:new|gen|generate|make|mk|create|issue|mint|fresh)_?(?:api_?)?key)/i;
75
80
  // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
76
81
  const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
77
82
  const SECURITY_FN = { test: (name) => SECURITY_FN_SUBSTR.test(name) || String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => SECURITY_WORDS.has(w)) };
@@ -102,8 +107,64 @@ const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secre
102
107
  const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
103
108
  const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
104
109
  const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
110
+ // a variable whose name says it holds the HASH of a credential (`hashedPassword`, `passwordHash`, `tokenDigest`): the stored form, not the secret
111
+ const HASH_NAMED = /(?:hash|digest)/i;
112
+ // Every use of the identifier `n` inside `arg` is as the object of a member access whose field is not credential-shaped (`attemptUser attempt`).
113
+ function usedOnlyAsNonSensitiveField(arg, n) {
114
+ let uses = 0; let fields = 0;
115
+ const visit = (e, parent, d) => {
116
+ if (!e || typeof e !== 'object' || d > 40) return;
117
+ if (e.kind === 'ident' && e.name === n) {
118
+ uses++;
119
+ if (parent && parent.kind === 'member' && parent.object === e && typeof parent.prop === 'string' && !SENSITIVE.test(parent.prop)) fields++;
120
+ return;
121
+ }
122
+ for (const k of Object.keys(e)) {
123
+ if (k === 'hs') continue;
124
+ const v = e[k];
125
+ if (Array.isArray(v)) { for (const x of v) visit(x, e, d + 1); } else if (v && typeof v === 'object') visit(v, e, d + 1);
126
+ }
127
+ };
128
+ visit(arg, null, 0);
129
+ return uses > 0 && uses === fields;
130
+ }
131
+ // a value that is a one-time code only in context: `code` is a security value where the function talks about a reset, a verification, an OTP
132
+ const CONTEXT_CODE_VAR = /^(?:code|pin|passcode|otp)$/i;
133
+ const CODE_CONTEXT = /reset|verif|otp|one.?time|activation|confirm|2fa|mfa|two.?factor/i;
134
+ // A project function of this file whose every return value is a comparison, a conjunction or a boolean predicate (after `pure`/`return`).
135
+ function returnsYesNo(fir, callee) {
136
+ const fn = (fir.functions || []).find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`));
137
+ if (!fn) return false;
138
+ const rets = Object.values(fn.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
139
+ if (!rets.length) return false;
140
+ return rets.every((n) => {
141
+ let e = n.value;
142
+ for (let i = 0; i < 6 && e && e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:pure|return|liftIO)$/.test(e.callee) && (e.args || []).length === 1; i++) e = e.args[0];
143
+ if (!e) return false;
144
+ if (e.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(e.op)) return true;
145
+ if (e.kind === 'logical') return true;
146
+ if (e.kind === 'literal' && typeof e.value === 'boolean') return true;
147
+ return e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(e.callee);
148
+ });
149
+ }
150
+
151
+ function fnMentions(fn, re) {
152
+ for (const n of Object.values(fn.cfg.nodes)) {
153
+ for (const k of ['source', 'value', 'cond']) {
154
+ if (!n[k]) continue;
155
+ for (const x of walk(n[k])) {
156
+ if (x.kind === 'literal' && typeof x.value === 'string' && re.test(x.value)) return true;
157
+ if (x.kind === 'call' && typeof x.callee === 'string' && re.test(x.callee)) return true;
158
+ }
159
+ }
160
+ if (n.kind === 'call' && typeof n.callee === 'string' && re.test(n.callee)) return true;
161
+ if (Array.isArray(n.args)) for (const a of n.args) for (const x of walk(a)) if (x.kind === 'literal' && typeof x.value === 'string' && re.test(x.value)) return true;
162
+ }
163
+ return false;
164
+ }
105
165
  export const SENSITIVE = /(?:pass(?:word|wd|phrase)?|pwd|(?:^|[^a-z])pw(?:[^a-z]|$)|secret|token|api_?key|ssn|credit_?card|card_?number|cvv|authorization|bearer|private_?key|session_?id)/i;
106
- const REDACTOR = /(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|length|null)$|(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor)/i;
166
+ // (a helper that keeps only a few characters of the value, `lastFour`, `last4`, `fingerprint`, `takeEnd`, is a masking helper by its name too)
167
+ const REDACTOR = /(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|length|null|takeEnd|fingerprint|abbreviate|truncate)$|(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|last(?:four|4|digits|chars)|fingerprint)/i;
107
168
 
108
169
  const kids = (e) => {
109
170
  const out = [];
@@ -115,6 +176,19 @@ const kids = (e) => {
115
176
  return out;
116
177
  };
117
178
 
179
+ // Marks every node under an inlined helper's root with the helper's qid (`hs.inlineCtx`); idempotent, so a cached IR is marked once.
180
+ function markInlined(e, ctx, depth) {
181
+ if (!e || typeof e !== 'object' || depth > 60) return;
182
+ const here = (e.hs && e.hs.inlinedFrom) || ctx;
183
+ if (here && !(e.hs && e.hs.inlineCtx)) e.hs = { ...(e.hs || {}), inlineCtx: here };
184
+ for (const k of Object.keys(e)) {
185
+ if (k === 'hs') continue;
186
+ const v = e[k];
187
+ if (Array.isArray(v)) { for (const x of v) markInlined(x, here, depth + 1); }
188
+ else if (v && typeof v === 'object') markInlined(v, here, depth + 1);
189
+ }
190
+ }
191
+
118
192
  function* walk(e, depth = 0) {
119
193
  if (!e || typeof e !== 'object' || depth > 80) return;
120
194
  yield e;
@@ -279,11 +353,13 @@ export function analyzeHaskellRules(files, opts = {}) {
279
353
  if (/import\s+(?:qualified\s+)?Network\.Wai\.Middleware\.RequestSizeLimit\b/.test(text) || /\brequestSizeLimitMiddleware\b/.test(text)) { requestLimit = { file, line: (text.split('\n').findIndex((l) => /RequestSizeLimit|requestSizeLimitMiddleware/.test(l)) + 1) || 1 }; break; }
280
354
  }
281
355
 
356
+ let currentFn = null;
282
357
  const emit = (rule, file, line, extra = {}) => {
283
358
  const r = RULES[rule];
284
359
  const sev = extra.severity || r.severity;
285
360
  const f = {
286
361
  id: `hs-rule:${rule}:${file}:${line}`, severity: sev, file, line, vuln: extra.vuln || r.vuln, cwe: r.cwe,
362
+ _fnQid: currentFn ? currentFn.qid : null, ...(extra.inlineCtx ? { inlineCtx: extra.inlineCtx } : {}),
287
363
  description: `${extra.vuln || r.vuln}${extra.detail ? `: ${extra.detail}` : ''}`,
288
364
  remediation: r.remediation, parser: 'HS-RULES', family: r.family,
289
365
  language: 'haskell', capability: 'sast', analysisKind: 'application', evidenceKind: 'source',
@@ -306,6 +382,14 @@ export function analyzeHaskellRules(files, opts = {}) {
306
382
  }
307
383
  }
308
384
  for (const fn of fir.functions) {
385
+ currentFn = fn;
386
+ // A project helper the IR inlined at its call site (`digestFor (credPassword creds)` becomes the helper's body, rooted at a node with
387
+ // `hs.inlinedFrom`) is walked here as well as in the helper itself. Every node under such a root is marked with the helper's qid, so
388
+ // a rule that reports the same thing inside the helper can drop the call-site copy (the filter before the return).
389
+ for (const n of Object.values(fn.cfg.nodes)) {
390
+ for (const k of ['source', 'value', 'cond']) if (n[k]) markInlined(n[k], null, 0);
391
+ if (Array.isArray(n.args)) for (const a of n.args) markInlined(a, null, 0);
392
+ }
309
393
  if (fn.name.endsWith('.<module>')) continue;
310
394
  const bound = new Map(); // target var -> {callee, line}
311
395
  const sensitive = new Map(); // var -> {class:'low'|'high', hashed:boolean, line}
@@ -422,9 +506,13 @@ export function analyzeHaskellRules(files, opts = {}) {
422
506
  if (INPUT_READS.has(e.name) && !bounded && readsOutsideInput(fn, e.name, [], importsNetwork)) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
423
507
  continue;
424
508
  }
425
- // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
426
- if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) {
427
- const [k, v] = e.elements;
509
+ // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`, or handed to a framework's header
510
+ // setter: Scotty `setHeader "Set-Cookie" ("session=" <> tok <> "; Path=/")`, Spock `setHeader`, Yesod `addHeader`
511
+ let cookiePair = null;
512
+ if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) cookiePair = e.elements;
513
+ else if (e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:setHeader|addHeader|setResponseHeader|modifyHeader)$/.test(e.callee) && (e.args || []).length >= 2) cookiePair = [e.args[0], e.args[1]];
514
+ if (cookiePair) {
515
+ const [k, v] = cookiePair;
428
516
  const isSetCookie = k && ((k.kind === 'ident' && SET_COOKIE_HEADER.has(k.name)) || (k.kind === 'literal' && /^set-cookie$/i.test(String(k.value))) || (k.kind === 'call' && /(?:^|\.)(?:pack|fromString)$/.test(k.callee || '') && k.args && k.args[0] && k.args[0].kind === 'literal' && /^set-cookie$/i.test(String(k.args[0].value))));
429
517
  if (isSetCookie && v) {
430
518
  const text = literalTextOf(v);
@@ -477,7 +565,10 @@ export function analyzeHaskellRules(files, opts = {}) {
477
565
  // scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
478
566
  if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
479
567
  const nm = args[0];
480
- if (nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value))) emit('hs-insecure-cookie', file, line, { detail: 'HttpOnly missing, Secure missing', evidence: { httpOnly: false, secure: false, helper: c } });
568
+ // A name that is a PARAMETER of this function is decided by the caller: the function is itself a cookie helper
569
+ // (scotty's own `setSimpleCookie n v`, `deleteCookie c`), and the call that passes a session name is the site to report.
570
+ const ownParam = nm.kind === 'ident' && Array.isArray(fn.params) && fn.params.includes(nm.name);
571
+ if (!ownParam && (nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value)))) emit('hs-insecure-cookie', file, line, { detail: 'HttpOnly missing, Secure missing', evidence: { httpOnly: false, secure: false, helper: c } });
481
572
  }
482
573
  // a socket read repeated by a recursive loop, with no size test anywhere in it
483
574
  if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
@@ -502,8 +593,9 @@ export function analyzeHaskellRules(files, opts = {}) {
502
593
  }
503
594
  }
504
595
  // password hashed with a fast hash
505
- if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
506
- emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value` });
596
+ // (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
597
+ 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 });
507
599
  }
508
600
  if (PBKDF2_CALLS.has(c)) {
509
601
  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) {
@@ -514,7 +606,7 @@ export function analyzeHaskellRules(files, opts = {}) {
514
606
  if (ECB_CALLS.has(c)) emit('hs-ecb-mode', file, line, { detail: c });
515
607
  if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
516
608
  // randomness bound to a security-shaped name
517
- if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
609
+ if (RANDOM_CALLS.has(c) && node.kind === 'assign' && (SECURITY_VAR.test(node.target || '') || (CONTEXT_CODE_VAR.test(node.target || '') && fnMentions(fn, CODE_CONTEXT)))) {
518
610
  emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
519
611
  }
520
612
  if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
@@ -562,6 +654,14 @@ export function analyzeHaskellRules(files, opts = {}) {
562
654
  for (const n of srcNames) if (sensitive.has(n)) { const s = sensitive.get(n); cls = { name: s.name, low: s.low }; hashed = hashed || s.hashed; }
563
655
  if (cls) {
564
656
  let cleared = false;
657
+ // a yes/no answer about the credential (`accepted <- checkCredentials attempt`, a comparison, a conjunction) carries no credential text
658
+ let root = src; for (let i = 0; i < 6 && root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:liftIO|pure|return|lift)$/.test(root.callee) && (root.args || []).length === 1; i++) root = root.args[0];
659
+ if (root && ((root.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(root.op)) || root.kind === 'logical')) cleared = true;
660
+ // the binding says it holds a hash or digest (`hashedPassword <- hashPassword plain`): the stored form, whatever made it
661
+ if (HASH_NAMED.test(node.target || '')) cleared = true;
662
+ if (root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(root.callee)) cleared = true;
663
+ // a project helper whose every return is such an answer (`checkCredentials a = pure (user a == "x" && password a == "y")`)
664
+ if (root && root.kind === 'call' && typeof root.callee === 'string' && returnsYesNo(fir, root.callee)) cleared = true;
565
665
  for (const x of walk(src)) {
566
666
  if (x.kind !== 'call' || typeof x.callee !== 'string') continue;
567
667
  if (REDACTOR.test(x.callee)) cleared = true;
@@ -600,13 +700,19 @@ export function analyzeHaskellRules(files, opts = {}) {
600
700
  for (const n of names(arg)) {
601
701
  if (hit) break;
602
702
  const bare = n.replace(/^\./, '');
603
- if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
604
- else if (SENSITIVE.test(bare) && !/^[A-Z][A-Za-z0-9_.]*\./.test(n)) hit = { name: bare, low: LOW_ENTROPY.test(bare), hashed: false, var: n };
703
+ // a value built from a credential (`attempt = Attempt user password`) logged through a field that is not the credential
704
+ // (`attemptUser attempt`, lowered to a member of `attempt`) is that field, not the secret
705
+ if (sensitive.has(n) && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(n), var: n };
706
+ // a name that says it holds a hash or digest of the credential (`hashedPassword`, `tokenDigest`) is the stored form, not the secret
707
+ else if (SENSITIVE.test(bare) && !/^[A-Z][A-Za-z0-9_.]*\./.test(n) && !HASH_NAMED.test(bare)) hit = { name: bare, low: LOW_ENTROPY.test(bare), hashed: false, var: n };
708
+ // the record itself, logged whole (`show attempt`), when one of its fields was built from a credential (`attempt.attemptPassword`)
709
+ else if (!sensitive.has(n)) { const field = [...sensitive.keys()].find((k) => k.startsWith(`${n}.`)); if (field && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(field), name: field, var: n }; }
605
710
  if (hit) break;
606
711
  }
607
712
  if (!hit) { const rec = wholeRecord(arg); if (rec) hit = { name: `${rec.type}.${rec.field}`, low: LOW_ENTROPY.test(rec.field), hashed: false, var: rec.name }; }
608
713
  if (hit && !isRedacted(arg)) {
609
714
  emit('hs-sensitive-logging', file, e.line || node.line, {
715
+ inlineCtx: e.hs && e.hs.inlineCtx,
610
716
  severity: hit.hashed ? 'low' : 'high',
611
717
  detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
612
718
  evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
@@ -639,5 +745,16 @@ export function analyzeHaskellRules(files, opts = {}) {
639
745
  const out = [];
640
746
  for (const f of findings) { if (seen.has(f.id)) continue; seen.add(f.id); out.push(f); }
641
747
  void notes; void opts;
642
- return { findings: out, controls, notes };
748
+ // A call-site copy of a finding the helper itself reports (the helper's body was inlined at the call) is dropped; a call-site finding
749
+ // whose helper reports nothing (the password shape is visible only at the call) is kept.
750
+ // The kept finding lists the call sites it also covers (`alsoAt`), so a reviewer still sees every line of the one weakness.
751
+ const byKey = new Map(out.map((f) => [`${f.rule}|${f.file}|${f._fnQid}`, f]));
752
+ const deduped = out.filter((f) => {
753
+ const helper = f.inlineCtx && f._fnQid !== f.inlineCtx ? byKey.get(`${f.rule}|${f.file}|${f.inlineCtx}`) : null;
754
+ if (!helper) return true;
755
+ helper.alsoAt = [...(helper.alsoAt || []), { line: f.line, reason: 'the same helper inlined at this call' }];
756
+ return false;
757
+ });
758
+ for (const f of deduped) delete f._fnQid;
759
+ return { findings: deduped, controls, notes };
643
760
  }