@clear-capabilities/agentic-security-scanner 0.186.2 → 0.188.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/1602.index.js +275 -19
  3. package/dist/{4d06c8944ae121d7085c.json → 92f78fd3e2dbe30fb58d.json} +5 -5
  4. package/dist/9560.index.js +61 -4
  5. package/dist/agentic-security.mjs +2 -2
  6. package/dist/agentic-security.mjs.sha256 +1 -1
  7. package/package.json +5 -5
  8. package/src/capabilities/decide.js +7 -1
  9. package/src/dataflow/CLAUDE.md +15 -0
  10. package/src/dataflow/catalog-haskell.js +7 -1
  11. package/src/dataflow/catalog.js +14 -0
  12. package/src/dataflow/engine.js +147 -10
  13. package/src/dataflow/privacy-catalog-haskell.js +2 -1
  14. package/src/engine.js +11 -3
  15. package/src/language/CLAUDE.md +26 -0
  16. package/src/language/compliance-map.js +6 -0
  17. package/src/language/context.js +20 -0
  18. package/src/language/haskell-comments.js +65 -0
  19. package/src/language/haskell-disclosure.js +16 -4
  20. package/src/language/haskell-findings.js +2 -1
  21. package/src/language/haskell-fix.js +118 -2
  22. package/src/language/haskell-guards.js +132 -5
  23. package/src/language/haskell-ir.js +228 -13
  24. package/src/language/haskell-models.js +108 -8
  25. package/src/language/haskell-security-rules.js +223 -41
  26. package/src/language/haskell-syntax.js +10 -1
  27. package/src/language/haskell-trust.js +268 -0
  28. package/src/language/haskell-web.js +46 -9
  29. package/src/language/support-registry.js +3 -2
  30. package/src/mcp/tools.js +54 -3
  31. package/src/posture/attack-taxonomy.js +10 -0
  32. package/src/posture/entrypoint-inventory.js +6 -2
  33. package/src/posture/proof-fabric/generate-haskell.js +266 -17
  34. package/src/posture/proof-fabric/generate.js +10 -3
  35. package/src/posture/proof-fabric/oracles.js +21 -0
  36. package/src/posture/stable-id.js +2 -1
  37. package/src/report/index.js +3 -0
  38. package/src/sast/cloud-iam.js +3 -1
  39. package/dist/2066.index.js +0 -851
package/src/engine.js CHANGED
@@ -320,6 +320,7 @@ import { annotateHaskellFindings } from './language/haskell-findings.js';
320
320
  import { annotateHaskellLlm, analyzeHaskellLlmRules } from './language/haskell-llm.js';
321
321
  import { languagePragmaOnLine as _languagePragmaOnLine, languagePragmaSuppresses as _languagePragmaSuppresses } from './language/pragma.js';
322
322
  import { applyHaskellGuards, consolidateFlowSinks } from './language/haskell-guards.js';
323
+ import { applyTrustBoundary } from './language/haskell-trust.js';
323
324
  import { annotateUnresolvedTargets, attachEvidenceChains } from './language/haskell-disclosure.js';
324
325
  import { analyzeHaskellRules } from './language/haskell-security-rules.js';
325
326
  import { scanLanguageSecretConcat, scanDependencyUrlCredentials, providerInfo as _secretProvider } from './language/secrets.js';
@@ -2556,6 +2557,10 @@ function scanStructuralVulns(fp, raw, _suppOut) {
2556
2557
  for (const pat of STRUCTURAL_VULN_PATTERNS) {
2557
2558
  if (!_ruleAppliesIn(pat, ctx)) { _supp.push({vuln:pat.vuln,file:fp,line:0,snippet:'',reason:'context-mismatch:'+ctx.kind}); continue; }
2558
2559
  if (pat.langScope && !pat.langScope.test(fp)) { continue; }
2560
+ // These patterns are written in the syntax of JavaScript, Python, Java and C-family code (`parseString(`, `$(document)`, `exec(`). Haskell and
2561
+ // Nix have their own analyzers; a pattern with no language scope has no meaning there, and one that fires (a record pattern named `String`
2562
+ // read as an XML parser call, a script inside an HTML quasi-quote) is an artefact of the text.
2563
+ if (!pat.langScope && isLanguageSource(fp)) continue;
2559
2564
  const re = new RegExp(pat.regex.source, pat.regex.flags);
2560
2565
  // Default: match against the string-stripped view so rule-library shapes
2561
2566
  // ('exec(' inside a fix-message) don't self-detect. Patterns that need to
@@ -9615,7 +9620,7 @@ function _deterministicFileTimings(timings) {
9615
9620
  f.unvalidated = true;
9616
9621
  f.validator_verdict = 'unvalidated';
9617
9622
  }
9618
- try { applyHaskellGuards(irFindings, perFile); consolidateFlowSinks(irFindings, perFile); annotateUnresolvedTargets(irFindings, perFile); attachEvidenceChains(irFindings, perFile); annotateHaskellFindings(irFindings, fc); annotateHaskellLlm(irFindings, fc); } catch (_) { /* metadata only; the finding stands without it */ }
9623
+ try { applyHaskellGuards(irFindings, perFile); applyTrustBoundary(irFindings, perFile); consolidateFlowSinks(irFindings, perFile); annotateUnresolvedTargets(irFindings, perFile); attachEvidenceChains(irFindings, perFile); annotateHaskellFindings(irFindings, fc); annotateHaskellLlm(irFindings, fc); } catch (_) { /* metadata only; the finding stands without it */ }
9619
9624
  aF.push(...irFindings);
9620
9625
  } catch (e) {
9621
9626
  // Deep mode is best-effort. A parser blowup in one file shouldn't kill
@@ -9823,7 +9828,10 @@ function _deterministicFileTimings(timings) {
9823
9828
  } catch { /* Java SCA enrichment is best-effort */ }
9824
9829
  }
9825
9830
  // Haskell crypto / randomness / resource / XML / sensitive-logging rules (project-level: imports resolve across modules).
9826
- try{aF.push(...analyzeHaskellRules(fc).findings);}catch(_){}
9831
+ // An `unsafePerformIO` / `unsafeCoerce` / `unsafeInterleaveIO` boundary note says (in its own text) that it claims no vulnerability class: it marks where the
9832
+ // type system is stepped around. In a scan it is information, not a finding that gates a release; the rules module keeps emitting it at `low` (its contract),
9833
+ // and the severity is lowered here with the original kept.
9834
+ try{aF.push(...analyzeHaskellRules(fc).findings.map((f)=>f&&f.rule==='hs-unsafe-io-boundary'?{...f,originalSeverity:f.severity,severity:'info'}:f));}catch(_){}
9827
9835
  try{aF.push(...analyzeHaskellLlmRules(fc));}catch(_){}
9828
9836
  try{const{analyzeLanguageFindings}=await import('./language/engine-pass.js');aF.push(...analyzeLanguageFindings(allFileContents).findings);}catch(_){}
9829
9837
  let _languageBridges=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$/i.test(f))){const{analyzePolyglotBridges}=await import('./language/bridges.js');_languageBridges=analyzePolyglotBridges(allFileContents);}}catch(_){}
@@ -10727,7 +10735,7 @@ function _deterministicFileTimings(timings) {
10727
10735
  // classes from one source" finding, not a duplicate of any single
10728
10736
  // existing finding) still gets its own standalone finding, tagged with
10729
10737
  // the honest generic CWE-20/'multi-sink-taint-chain' label.
10730
- try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;const sharedFamily=group.every(f=>f.family&&f.family===group[0].family)?group[0].family:null;const sharedCwe=group.every(f=>f.cwe&&f.cwe===group[0].cwe)?group[0].cwe:null;const sinks=group.map(f=>({file:f.file,line:f.line,vuln:f.vuln}));if(sharedFamily&&sharedCwe){for(const f of group)f.multiSinkChain={source:src,sinkCount:group.length,sinks};continue;}finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',family:'multi-sink-taint-chain',parser:'MULTI-SINK',confidence:0.85,sinks,_aggregated:true});}}catch(_){}
10738
+ try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(f.severity==='info')continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;const sharedFamily=group.every(f=>f.family&&f.family===group[0].family)?group[0].family:null;const sharedCwe=group.every(f=>f.cwe&&f.cwe===group[0].cwe)?group[0].cwe:null;const sinks=group.map(f=>({file:f.file,line:f.line,vuln:f.vuln}));if(sharedFamily&&sharedCwe){for(const f of group)f.multiSinkChain={source:src,sinkCount:group.length,sinks};continue;}finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',family:'multi-sink-taint-chain',parser:'MULTI-SINK',confidence:0.85,sinks,_aggregated:true});}}catch(_){}
10731
10739
  // SCA transitive dedup: collapse duplicate CVEs across dep chains
10732
10740
  try{const osvGroups=new Map();for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||!sc.osvId)continue;if(!osvGroups.has(sc.osvId))osvGroups.set(sc.osvId,[]);osvGroups.get(sc.osvId).push(sc);}for(const[osvId,group]of osvGroups){if(group.length<=1)continue;const primary=group.find(s=>s.isDirect)||group[0];primary.dependents=group.filter(s=>s!==primary).map(s=>({name:s.name,version:s.version,depChain:s.depChain,isDirect:s.isDirect}));primary._transitiveDeduped=group.length-1;for(const dup of group){if(dup!==primary)dup._deduplicatedInto=primary.osvId;}supplyChain.splice(0,supplyChain.length,...supplyChain.filter(s=>!s._deduplicatedInto));}}catch(_){}
10733
10741
  // ── World-class post-scan artifact emitters ──────────────────────────
@@ -60,6 +60,18 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
60
60
  before. The prefetch (`prefetchNixAdvisoryFeed`, `resolved-pass.js`) asks only for identities `closureIdentities` says the matcher
61
61
  will use; metadata comes only from the operator (`AGENTIC_SECURITY_NIX_META`). NVD is rate limited (5/30 s keyless): never raise
62
62
  the request caps or lower the spacing in `nix-advisory-feed.js` without re-reading the NVD limits.
63
+ - **The SEND is the SSRF sink, not the parse.** `parseRequest`/`parseUrlThrow`/`requestFromURI` only build a value, and the host check a defence needs (`host req \`elem\` allowed`) can only follow the parse, so a sink on the parse sits before every such guard and reports every guarded flow. The sinks are the senders (`httpLbs`, `httpNoBody`, `withResponse`, `httpLBS`, `simpleHttp`, wreq, and Req's `useURI`, where a parsed URI chooses the host; `req` itself is not a sink because `https "host" /: segment` keeps a tainted path segment on a fixed host).
64
+ - **A parameter source says what it is.** `haskell-ir.js` annotates a text parameter of an exported function with `hs:caller-controlled` (the weak label) or, when the module imports Scotty/Yesod/Servant/WAI and the declared RESULT type is that framework's handler type (`HS_HANDLER_PARAM_SOURCES`, `handlerFrameworkOf`), `hs:handler-param:<framework>` (request input, provenance `url-param`). A single-endpoint Servant function (`server a rowId = ...` for one `type API`) takes its parameters from the API type in order (`servantEndpointParams`: Capture/QueryParam/Header/ReqBody that are text; never the `AuthProtect` principal). A project record parameter is NOT a taint source (the lineage view reads it as a customer record): seeding its text fields was tried, and it put a new finding on a documented example (a customer email sent to a CRM read as a prompt), so it was removed. `haskell-disclosure.js` `buildChain` starts the evidence chain at the function head with that label when no read call exists.
65
+ - **A numeric annotation is a clean constant to the taint engine and still an expression to the rules.** `read s :: Int` and `readMaybe s :: Maybe Int` parse to a `typed` node (`NUMERIC_TYPE` in `haskell-syntax.js`, nothing else is kept) which lowers to a literal whose `hs.inner` carries the lowered expression; `haskell-security-rules.js` and `haskell-web.js` walk `hs.inner` (as they walk `hs.selector`), so an unsafe coercion in it and the request id it names stay visible. It is deliberately NOT a call of a sanitizer: the engine's sanitizer label is per expression, so a number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized.
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
+ - **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
+ - **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`.
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
+ - **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
+ - **Comments never change a verdict, an id or a cluster.** `haskell-comments.js` finds Haskell comments lexically; a finding's `snippet` is `codeOfLine(line)` (code only), because the snippet feeds the stable id and the clone-shape hash, and both changed when someone edited a trailing comment. `test/haskell/haskell-comment-invariance.test.js` scans the examples and corpus-shaped cases as written, with every comment blanked, and with every comment (and a trailing comment on every line) replaced by text that lies in each direction; `bench/dag/dev-haskell/comment-invariance.mjs` does the same to a real project. An engine that varies is wrong.
73
+ - **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`). A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
74
+ - **Fixes name the library the file uses.** `sqlDriverOf` picks `Database.PostgreSQL.Simple` or `Database.SQLite.Simple` from the file's own imports and refuses anything else; `hs-html-escape` needs blaze-html or a re-exporter; `hs-hash-sha256` refuses when the algorithm is named on another line (a type); `hs-path-takefilename` rewrites only a String/FilePath parameter spliced under a fixed directory in a FILE operation. Classes with no safe rewrite refuse with a class-level reason (`CLASS_REASONS`), `proposal: 'model-assisted'`.
63
75
  - **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
64
76
  - **Scale: no per-use scan of the project.** Pandoc and cabal-install (hundreds to thousands of modules) did not finish a scan because of four superlinear costs, now fixed; do not reintroduce the shapes. (1) `haskell-ir.js`: anything a lowering step needs "from every module" (a constructor by name, the record types, a definition by qid, a name's resolution) is an index on `Project` / a per-module memo (`findCon`, `recordTypes`, `defByQid`, `RESOLVE_MEMO`), built once, never a loop over `proj.modules` per use. Only a top-level resolution (depth 0) is memoised: a resolution inside a re-export chain depends on the path taken. (2) `aibom.js` looks a file's imports up in a map, not `Object.values(perFile).find`. (3) `sast/pipeline.js`: the workflow-wide `env:` secret rule is a one-pass matcher (`scanEnvSecrets`), because the single regular expression it replaced backtracked catastrophically on a comment-blanked workflow (blanking leaves runs of blank lines). A detector expression that runs on comment-blanked text must not let `\s*`, `\n` and a lazy line loop claim the same blank lines. (4) `dataflow/engine.js` `exprTaint`: a call's argument taint is computed once per visit (`_argTaint`); computing it twice re-walked the subtree and cost 2^N for N nested calls. Guards: `test/pipeline-env-secret.test.js`, `test/dataflow-expr-taint-scaling.test.js`, `test/haskell/haskell-ir-scaling.test.js`, `test/mark-used-vuln-functions.test.js` compare work counters between two sizes, never a clock.
65
77
 
@@ -75,6 +87,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
75
87
  | Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-eval-forms.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-eval-isolation.js`, `nix-fix.js` |
76
88
  | Haskell | `haskell-parser.js`, `haskell-grammar.js`, `haskell-adapter.js`, `haskell-ir.js`, `haskell-models.js` (the single registry of sources, sinks, sanitizers; `dataflow/catalog-haskell.js` is generated from it), `haskell-guards.js`, `haskell-security-rules.js`, `haskell-web.js`, `haskell-findings.js`, `haskell-syntax.js`, `haskell-sweep.js`, `haskell-disclosure.js`, `haskell-llm.js`, `haskell-manifests.js`, `haskell-resolved-graph.js`, `haskell-sca.js`, `haskell-advisory-feed.js` (the opt-in live Hackage feed: OSV fetch, per-package coverage, operator-only snapshot), `haskell-supply.js`, `haskell-fix.js` |
77
89
  | Nix | `nix-parser.js`, `nix-grammar.js`, `nix-ir.js`, `nix-adapter.js`, `nixos-module-resolver.js`, `nixos-option-catalog.js`, `nixos-hardening.js`, `nix-build-trust.js`, `nix-secrets.js`, `nix-script-taint.js`, `nix-privacy.js`, `nix-agents.js`, `nix-inventory.js`, `nix-closure.js`, `nix-sca.js`, `nix-advisory-feed.js`, `nix-eval-isolation.js`, `nix-fix.js` |
90
+ | Haskell trust | `haskell-trust.js` (trust-boundary verdicts and the lowering of operator-controlled and pass-through findings), `haskell-comments.js` (lexical Haskell comments; the code-only snippet) |
78
91
  | Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
79
92
 
80
93
  Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
@@ -121,6 +134,8 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
121
134
  | Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
122
135
  | 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 |
123
136
 
137
+ 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).
138
+
124
139
  The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
125
140
  against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
126
141
  frozen hashes and demotes rows until a deliberate re-measure. `docs/language-support.*` are generated; do not edit by hand.
@@ -142,3 +157,14 @@ the capture reads `dist/`.
142
157
  record the revision in `provenance.json`. Do not hand-edit labels.
143
158
  4. Run `test:haskell` / `test:nix` / `test:language`, `bench:mutation:check`, `bench:layer-recall:check` and
144
159
  `bench:language-support:check`; re-promote deliberately if a measured number legitimately changes.
160
+
161
+ ## Point-free aliases (`haskell-ir.js` `aliasBodyOf` / `applyAlias`)
162
+
163
+ `tidy = takeFileName` and `run = callCommand` (and `.` compositions of such names) are the functions they name, so applying one
164
+ lowers to the application of those functions, for a top-level, `let` and `where` binding alike. Found by `bench/mutation`
165
+ (`hs-san-path-where-helper`): without it a sanitizer or sink reached through the alias was an unresolved call and lost its identity.
166
+ Rules: only an alias of LIBRARY functions is expanded (a project function keeps its own summary and call-graph node); a local binding
167
+ at the use site that shares one of the names disables the expansion (it stays the unresolved call, which fails toward unsanitized);
168
+ the alias's call-graph edge is kept via `inlinedCalls`; arguments are lowered in the CALLER's module. Pinned both ways in
169
+ `test/haskell/haskell-alias-taint.test.js`. Haskell verdict-flip cases live in `bench/mutation/cases-haskell.mjs`; the 8
170
+ `bench/cve-replay/deep/hs-*-shape` entries are synthetic (self-authored, not from advisories).
@@ -18,6 +18,12 @@ export const LANGUAGE_FAMILY_ALIASES = Object.freeze({
18
18
  // credential handling: a secret that lands in the Nix store, a derivation or a log is exposed sensitive data
19
19
  'data-exposure': ['secret-in-store', 'secret-in-build', 'secret-in-log', 'sensitive-logging', 'dependency-url-credential'],
20
20
  'key-hygiene': ['secret-in-store', 'secret-in-build'],
21
+ // credentials written in source and certificate validation switched off (Haskell names them more specifically)
22
+ 'hardcoded-secret': ['hardcoded-credentials'],
23
+ 'crypto-tls-no-verify': ['tls-validation'],
24
+ // a session cookie built without HttpOnly or Secure is evidence for both cookie controls
25
+ 'session-cookie-missing-secure-flag': ['cookie-attributes'],
26
+ 'session-cookie-without-httponly-flag': ['cookie-attributes'],
21
27
  // cryptography
22
28
  'crypto-weak-hash': ['weak-hash'],
23
29
  'crypto-kdf-weak': ['password-hashing'],
@@ -82,6 +82,19 @@ export function languageOfFinding(f) {
82
82
  return null;
83
83
  }
84
84
 
85
+ const _importedModules = (text) => new Set([...String(text || '').matchAll(/^import\s+(?:safe\s+)?(?:qualified\s+)?([A-Z][\w.']*)/gm)].map((m) => m[1]));
86
+ /** Database-library modules a Haskell fix plan imports that the file did not import before the edit (sorted, deduplicated). */
87
+ export function addedDatabaseImports(plan) {
88
+ const edits = Array.isArray(plan && plan.edits) && plan.edits.length ? plan.edits : [plan];
89
+ const out = new Set();
90
+ for (const e of edits) {
91
+ if (!e || typeof e.before !== 'string' || typeof e.after !== 'string') continue;
92
+ const had = _importedModules(e.before);
93
+ for (const m of _importedModules(e.after)) if (!had.has(m) && /^Database\./.test(m)) out.add(m);
94
+ }
95
+ return [...out].sort();
96
+ }
97
+
85
98
  /**
86
99
  * A READ-ONLY fix preview for a Haskell/Nix finding: the planned patch, its tier, and what the verification gates say.
87
100
  * Never writes. `files` is the project's language sources (see loadLanguageProject).
@@ -91,6 +104,13 @@ export async function languageFixPreview(finding, files, { apply = false, root =
91
104
  if (!lang) return { ok: false, status: 'unsupported', reason: 'not a Haskell or Nix finding' };
92
105
  const plan = lang === 'haskell' ? planHaskellFix(finding, files) : planNixFix(finding, files);
93
106
  if (!plan || plan.ok === false) return { ok: false, status: (plan && plan.status) || 'unsupported', tier: (plan && plan.tier) || null, reason: (plan && plan.reason) || 'no deterministic fix', proposal: (plan && plan.proposal) || null };
107
+ if (lang === 'haskell') {
108
+ // A static rescan cannot see a wrong library, so this is decided here, before any gate can call the patch verified.
109
+ const added = addedDatabaseImports(plan);
110
+ if (added.length) {
111
+ return { ok: false, status: 'blocked', tier: null, label: plan.label || null, reason: `the patch adds an import of a database library the file did not use (${added.join(', ')}); which driver the project uses is not provable from this file, so the edit is refused rather than guessed. Apply the parameterised query by hand with the project's own driver`, proposal: null };
112
+ }
113
+ }
94
114
  let res;
95
115
  try { res = lang === 'haskell' ? await validateHaskellFix(finding, { files, apply, root }) : await validateNixFix(finding, { files, apply, root }); } catch (e) { res = { status: 'blocked', reason: `verification failed: ${String((e && e.message) || e)}` }; }
96
116
  return {
@@ -0,0 +1,65 @@
1
+ // Haskell comments, found lexically. Used so that text a finding carries from the source (its snippet, and through it the identity and the
2
+ // clone-shape of the finding) is the CODE of the line and never a comment on it: a verdict, an id and a cluster must not change because someone
3
+ // edited, or lied in, a comment. Strings, character literals and pragmas are not comments; a run of dashes inside an operator (`-->`, `|--`) is
4
+ // not a comment; block comments nest.
5
+
6
+ const SYMBOL = new Set([...'!#$%&*+./<=>?@\\^|~:-']);
7
+ const IDENT = /[A-Za-z0-9_']/;
8
+
9
+ /** The comments of a Haskell source: [{start, end, kind}] with `end` exclusive. */
10
+ export function findComments(src) {
11
+ const out = [];
12
+ const n = src.length;
13
+ let i = 0;
14
+ while (i < n) {
15
+ const c = src[i];
16
+ if (c === '"') { // string literal
17
+ i++;
18
+ while (i < n && src[i] !== '"') { if (src[i] === '\\') i++; i++; }
19
+ i++;
20
+ continue;
21
+ }
22
+ if (c === "'" && !(i > 0 && IDENT.test(src[i - 1]))) { // character literal (a prime after an identifier character is part of the name)
23
+ const m = /^'(?:\\(?:[^']|x[0-9A-Fa-f]+|[0-9]+|[A-Z]+)|[^'\\])'/.exec(src.slice(i, i + 12));
24
+ if (m) { i += m[0].length; continue; }
25
+ }
26
+ if (c === '{' && src[i + 1] === '-' && src[i + 2] !== '#') {
27
+ let depth = 1; const start = i; i += 2;
28
+ while (i < n && depth > 0) {
29
+ if (src[i] === '{' && src[i + 1] === '-') { depth++; i += 2; } else if (src[i] === '-' && src[i + 1] === '}') { depth--; i += 2; } else i++;
30
+ }
31
+ out.push({ start, end: i, kind: 'block' });
32
+ continue;
33
+ }
34
+ if (c === '-' && src[i + 1] === '-') {
35
+ let j = i; while (j < n && src[j] === '-') j++;
36
+ const prevSym = i > 0 && SYMBOL.has(src[i - 1]) && src[i - 1] !== '-';
37
+ const nextSym = j < n && SYMBOL.has(src[j]);
38
+ if (!prevSym && !nextSym) {
39
+ let e = j; while (e < n && src[e] !== '\n') e++;
40
+ out.push({ start: i, end: e, kind: 'line' });
41
+ i = e;
42
+ continue;
43
+ }
44
+ i = j;
45
+ continue;
46
+ }
47
+ i++;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const blank = (text) => text.replace(/[^\n]/g, ' ');
53
+
54
+ /** The source with every comment overwritten by spaces: same length, same line breaks, so locations do not move. */
55
+ export function stripComments(src) {
56
+ let out = ''; let at = 0;
57
+ for (const c of findComments(src)) { out += src.slice(at, c.start) + blank(src.slice(c.start, c.end)); at = c.end; }
58
+ return out + src.slice(at);
59
+ }
60
+
61
+ /** One source line without its comments, trimmed. A block comment that opened on an earlier line is not known here; a line that is only a comment becomes ''. */
62
+ export function codeOfLine(line) {
63
+ if (typeof line !== 'string') return '';
64
+ return stripComments(line).trim();
65
+ }
@@ -8,7 +8,7 @@
8
8
  // with the reason, and marks the finding's resolution as partial. It adds evidence; it never removes a
9
9
  // finding or changes its severity.
10
10
 
11
- import { sourceInfo } from './haskell-models.js';
11
+ import { sourceInfo, paramSourceInfo } from './haskell-models.js';
12
12
 
13
13
  const OPAQUE = {
14
14
  unknown: (c) => ({ kind: 'unresolved-target', detail: `${c.callee}: ${(c.hs && c.hs.reason) || 'unresolved'}` }),
@@ -111,6 +111,10 @@ export function buildChain(fn, finding) {
111
111
  const steps = [];
112
112
  const seen = new Set();
113
113
  let source = null;
114
+ // A parameter the IR annotated as a source (a request value handed to a framework handler, or caller-supplied text of an exported function) has no
115
+ // read call to point at: the flow starts at the function head, and the label says which kind of source it is.
116
+ const annotated = new Map((fn.paramAnnotations || []).map((a) => [a.name, a.decorator]));
117
+ let paramSource = null;
114
118
  const queue = [{ expr: arg, before: sinkNodeIdx }];
115
119
  let guard = 0;
116
120
  while (queue.length && !source && guard++ < 300) {
@@ -121,6 +125,10 @@ export function buildChain(fn, finding) {
121
125
  if (source) break;
122
126
  for (const name of identsOf(expr)) {
123
127
  const root = name.replace(/\.[^.]*$/, '');
128
+ if (!paramSource && (annotated.has(name) || annotated.has(root))) {
129
+ const info = paramSourceInfo(annotated.get(name) || annotated.get(root));
130
+ if (info) paramSource = { line: fn.line, label: info.label, provenance: info.provenance || undefined, viaParameter: true };
131
+ }
124
132
  for (let i = Math.min(before, nodes.length) - 1; i >= 0; i--) {
125
133
  const n = nodes[i];
126
134
  if (n.kind !== 'assign' || typeof n.target !== 'string' || !n.source) continue;
@@ -133,9 +141,10 @@ export function buildChain(fn, finding) {
133
141
  }
134
142
  }
135
143
  }
144
+ if (!source && paramSource) source = paramSource;
136
145
  if (!source) return null;
137
146
  const ordered = steps.filter((s) => s.line !== source.line || s.label).sort((a, b) => a.line - b.line);
138
- return [{ line: source.line, label: source.label, provenance: source.provenance }, ...ordered.filter((s) => s.line > source.line)];
147
+ return [{ line: source.line, label: source.label, provenance: source.provenance, ...(source.viaParameter ? { viaParameter: true } : {}) }, ...ordered.filter((s) => s.line > source.line)];
139
148
  }
140
149
 
141
150
  /** A callee as text. A Haskell finding carries a string; an IR finding carries `{kind:'ident', name}` or `{kind:'member', object, prop}`. */
@@ -157,7 +166,8 @@ function appendSinkStep(f) {
157
166
  const sinkLine = (f.sink && f.sink.line) || f.line;
158
167
  const last = f.chain[f.chain.length - 1];
159
168
  if (!Number.isInteger(sinkLine) || (last && last.kind === 'sink')) return;
160
- if (last && last.line === sinkLine && (last.file || f.file) === sinkFile && !/^source|standard input|stdin|argument|environment/i.test(String(last.label || ''))) { last.kind = 'sink'; return; }
169
+ // the first element of a chain is always the source: when the source and the sink share a line it stays the source and the sink is added after it
170
+ if (f.chain.length > 1 && last && last.line === sinkLine && (last.file || f.file) === sinkFile && !/^source|standard input|stdin|argument|environment/i.test(String(last.label || ''))) { last.kind = 'sink'; return; }
161
171
  f.chain.push({ file: sinkFile, line: sinkLine, label: `sink: ${calleeText(f.callee).replace(/^.*\./, '') || 'call'}`, kind: 'sink' });
162
172
  }
163
173
 
@@ -174,7 +184,9 @@ export function attachEvidenceChains(findings, perFile) {
174
184
  if (!chain || !chain.length) continue;
175
185
  f.chain = chain.map((c) => ({ file: f.file, line: c.line, label: c.label, ...(c.provenance ? { provenance: c.provenance } : {}) }));
176
186
  appendSinkStep(f);
177
- f.source = { file: f.file, line: chain[0].line, label: chain[0].label };
187
+ // a parameter source is marked `kind: 'parameter'`: it is not part of the finding's stable identity (`posture/stable-id.js`), so findings that
188
+ // already existed keep their ids when the source is first named
189
+ f.source = { file: f.file, line: chain[0].line, label: chain[0].label, ...(chain[0].viaParameter ? { kind: 'parameter' } : {}) };
178
190
  if (chain[0].provenance) f.sourceProvenance = chain[0].provenance;
179
191
  n++;
180
192
  }
@@ -4,6 +4,7 @@
4
4
  // finding is distinguishable, filterable and compliance-mappable without a second finding schema.
5
5
 
6
6
  import { HS_MODEL_VERSION } from './haskell-models.js';
7
+ import { codeOfLine } from './haskell-comments.js';
7
8
 
8
9
  const HS_FILE = /\.(?:hs|lhs|hsc)$/i;
9
10
 
@@ -32,7 +33,7 @@ export function annotateHaskellFindings(findings, fileContents = null) {
32
33
  if (!f || !isHaskellFile(f.file)) continue;
33
34
  if (!f.snippet && fileContents && typeof fileContents[f.file] === 'string' && Number.isInteger(f.line) && f.line >= 1) {
34
35
  const text = fileContents[f.file].split('\n')[f.line - 1];
35
- if (typeof text === 'string') f.snippet = text.trim().slice(0, 240);
36
+ if (typeof text === 'string') f.snippet = codeOfLine(text).slice(0, 240);
36
37
  }
37
38
  f.language = 'haskell';
38
39
  f.capability = f.capability || 'taint';
@@ -124,7 +124,19 @@ function findCall(lineText, names) {
124
124
  return m ? { prefix: m[1], name: m[2], index: m.index, after: m.index + m[0].length } : null;
125
125
  }
126
126
 
127
+ // The database driver whose `execute`/`query`/`Only` the rewrite uses is the one the FILE already imports: a hard-coded driver would add an
128
+ // import of a library the project may not depend on, and a patch that compiles against nothing is not a fix. Only the two drivers whose
129
+ // `execute_`/`query_`/`execute`/`query`/`Only` API is identical are rewritten; any other (mysql-simple, HDBC, persistent) or two at once is refused.
130
+ const SQL_DRIVERS = Object.freeze(['Database.PostgreSQL.Simple', 'Database.SQLite.Simple']);
131
+ const OTHER_SQL = /^import\s+(?:qualified\s+)?Database\.(?:MySQL|HDBC|Persist|Beam|Opaleye|Selda|Esqueleto|SQLite3|PostgreSQL\.LibPQ)/m;
132
+ export function sqlDriverOf(text) {
133
+ const used = SQL_DRIVERS.filter((m) => importInfo(text, m));
134
+ return used.length === 1 && !OTHER_SQL.test(text) ? used[0] : null;
135
+ }
136
+
127
137
  function fixSql(text, f) {
138
+ const driver = sqlDriverOf(text);
139
+ if (!driver) return null;
128
140
  const lb = lineBounds(text, f.line);
129
141
  const call = findCall(lb.text, ['execute_', 'query_']);
130
142
  if (!call) return null;
@@ -144,7 +156,7 @@ function fixSql(text, f) {
144
156
  if (q) { sql = sql.slice(0, -1); parts[i + 1] = { lit: after.lit.slice(1) }; }
145
157
  sql += '?'; params.push(p.var);
146
158
  }
147
- const imp = ensureImport(text, 'Database.PostgreSQL.Simple', ['execute', 'query', 'Only']);
159
+ const imp = ensureImport(text, driver, ['execute', 'query', 'Only']);
148
160
  if (!imp) return null;
149
161
  const only = `${call.prefix || imp.prefix}Only`;
150
162
  const tuple = params.length === 1 ? `(${only} ${params[0]})` : `(${params.join(', ')})`;
@@ -214,6 +226,9 @@ function fixProcess(text, f) {
214
226
  }
215
227
 
216
228
  function fixHtml(text, f) {
229
+ // `preEscapedToHtml` and `toHtml` are blaze-html's pair (Yesod and the shakespeare packages re-export them); another library's raw-markup call has a
230
+ // different name and a different escaping function, so a file that does not import blaze or one of its re-exporters is not rewritten.
231
+ if (!/^import\s+(?:qualified\s+)?(?:Text\.Blaze|Yesod|Text\.Hamlet)/m.test(text)) return null;
217
232
  const lb = lineBounds(text, f.line);
218
233
  const m = /(?<![\w'.])((?:[A-Z][\w']*\.)?)preEscapedToHtml(?![\w'])/.exec(lb.text);
219
234
  if (!m) return null;
@@ -227,12 +242,98 @@ function fixWeakHash(text, f) {
227
242
  const lb = lineBounds(text, f.line);
228
243
  const re = /(?<![\w'.])(MD5|SHA1)(?![\w'.])/g;
229
244
  if (!re.test(lb.text)) return null;
245
+ // The algorithm is often a TYPE as well (`Digest MD5` in a signature, an annotation on another line): replacing it on one line only leaves a
246
+ // type error behind. Any other mention outside the import list means the rewrite is not provably the same program, so it is not made.
247
+ const elsewhere = text.split('\n').some((l, i) => i !== f.line - 1 && !/^import\s/.test(l) && /(?<![\w'.])(MD5|SHA1)(?![\w'.])/.test(l));
248
+ if (elsewhere) return null;
230
249
  const imp = ensureImport(text, 'Crypto.Hash', ['SHA256(..)']); if (!imp) return null;
231
250
  const repl = lb.text.replace(/(?<![\w'.])(MD5|SHA1)(?![\w'.])/g, 'SHA256');
232
251
  const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, repl));
233
252
  return { ruleId: 'hs-hash-sha256', label: 'FULL', text: next, explanation: 'MD5/SHA-1 replaced by SHA-256. Digests change: anything that stored or compares old digests must be migrated.', behavior: { kind: 'hash', from: ['MD5', 'SHA1'], to: 'SHA256' } };
234
253
  }
235
254
 
255
+
256
+ // ── path traversal ───────────────────────────────────────────────────────────
257
+ // `readFile ("/srv/data/" ++ name)` -> `readFile ("/srv/data/" ++ takeFileName name)` when `name` is a String/FilePath PARAMETER of the enclosing function.
258
+ // The rewrite is a mitigation, not an equivalence: a name that carried directories now keeps only its last segment, and `.` / `..` survive
259
+ // `takeFileName`, which is why directory operations are not rewritten (only operations on a file). Every other shape is a proposal.
260
+ const PATH_FILE_OPS = ['readFile', 'writeFile', 'appendFile', 'removeFile', 'doesFileExist', 'openFile', 'withFile', 'openBinaryFile', 'withBinaryFile', 'getFileSize', 'getModificationTime'];
261
+
262
+ /** Splits `a ++ b </> c` at its top-level `++` / `<>` / `</>` operators, keeping the operator text: [part, op, part, ...] or null. */
263
+ function splitOperators(expr) {
264
+ const out = []; let depth = 0; let start = 0;
265
+ for (let i = 0; i < expr.length; i++) {
266
+ const c = expr[i];
267
+ if (c === '"') { const e = atomEnd(expr, i); if (e < 0) return null; i = e - 1; continue; }
268
+ if (c === '(' || c === '[') depth++;
269
+ else if (c === ')' || c === ']') depth--;
270
+ else if (depth === 0) {
271
+ const m = /^\s*(\+\+|<>|<\/>)\s*/.exec(expr.slice(i));
272
+ if (m && (i === start || /\s/.test(expr[i]) || true)) { out.push(expr.slice(start, i), m[0]); i += m[0].length - 1; start = i + 1; }
273
+ }
274
+ }
275
+ out.push(expr.slice(start));
276
+ return out;
277
+ }
278
+
279
+ /** The declared types of the parameters of the top-level function containing `line`: { name -> type text }, from its signature and first clause. */
280
+ function paramTypesAt(text, line) {
281
+ const lines = text.split('\n');
282
+ let clause = -1;
283
+ for (let i = Math.min(line - 1, lines.length - 1); i >= 0; i--) if (/^[a-z_][\w']*\b[^:\n]*=/.test(lines[i]) && !/^[a-z_][\w']*\s*::/.test(lines[i])) { clause = i; break; }
284
+ if (clause < 0) return null;
285
+ const m = /^([a-z_][\w']*)([^=]*)=/.exec(lines[clause]);
286
+ if (!m) return null;
287
+ const name = m[1]; const params = m[2].trim() ? m[2].trim().split(/\s+/) : [];
288
+ if (params.some((p) => !/^[a-z_][\w']*$/.test(p))) return null; // a pattern, not a plain name: not judged
289
+ const sigLine = lines.findIndex((l) => new RegExp(`^${name}\\s*::`).test(l));
290
+ if (sigLine < 0) return null;
291
+ let sig = lines[sigLine].replace(/^[^:]*::/, '');
292
+ for (let j = sigLine + 1; j < lines.length && /^\s+\S/.test(lines[j]) && !/^[a-z_][\w']*\s*(::|=)/.test(lines[j]); j++) sig += ` ${lines[j].trim()}`;
293
+ sig = sig.replace(/^.*=>/, '');
294
+ const types = []; let depth = 0; let cur = '';
295
+ for (let i = 0; i < sig.length; i++) {
296
+ const c = sig[i];
297
+ if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth--;
298
+ if (depth === 0 && sig.startsWith('->', i)) { types.push(cur.trim()); cur = ''; i++; continue; }
299
+ cur += c;
300
+ }
301
+ types.push(cur.trim());
302
+ const out = {};
303
+ params.forEach((p, i) => { if (types[i] !== undefined) out[p] = types[i]; });
304
+ return out;
305
+ }
306
+
307
+ function fixPath(text, f) {
308
+ const lb = lineBounds(text, f.line);
309
+ const call = findCall(lb.text, PATH_FILE_OPS);
310
+ if (!call) return null;
311
+ let aStart = call.after; while (lb.text[aStart] === ' ') aStart++;
312
+ let aEnd;
313
+ if (lb.text[aStart] === '$') { aStart++; while (lb.text[aStart] === ' ') aStart++; aEnd = lb.text.length; }
314
+ else aEnd = atomEnd(lb.text, aStart);
315
+ if (aEnd < 0) return null;
316
+ const arg = lb.text.slice(aStart, aEnd);
317
+ const inner = trimParens(arg);
318
+ const parts = splitOperators(inner);
319
+ if (!parts || parts.length < 3) return null;
320
+ const operands = parts.filter((_, i) => i % 2 === 0).map((x) => trimParens(x));
321
+ if (!operands.some((o) => STRING.test(o))) return null; // no fixed part: a pass-through, not a construction
322
+ const vars = operands.filter((o) => !STRING.test(o));
323
+ if (!vars.length || vars.some((v) => !IDENT.test(v))) return null;
324
+ const types = paramTypesAt(text, f.line);
325
+ if (!types || vars.some((v) => !/^(?:String|FilePath)$/.test(types[v] || ''))) return null; // takeFileName takes a String: any other type would not compile
326
+ if (new RegExp(`(?<![\\w'.])takeFileName\\s+(?:${vars.join('|')})(?![\\w'])`).test(lb.text)) return null;
327
+ const imp = ensureImport(text, 'System.FilePath', ['takeFileName']); if (!imp) return null;
328
+ const fn = `${imp.prefix}takeFileName`;
329
+ let rebuilt = '';
330
+ parts.forEach((part, i) => { rebuilt += i % 2 === 1 ? part : (vars.includes(trimParens(part)) ? part.replace(trimParens(part), `${fn} ${trimParens(part)}`) : part); });
331
+ const wrapped = arg.startsWith('(') && atomEnd(arg, 0) === arg.length ? `(${rebuilt})` : rebuilt;
332
+ const replaced = lb.text.slice(0, aStart) + wrapped + lb.text.slice(aEnd);
333
+ const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, replaced));
334
+ return { ruleId: 'hs-path-takefilename', label: 'MITIGATION', text: next, explanation: `Applied takeFileName to ${vars.join(', ')} so only its last path segment is used under the fixed directory. A value that carried subdirectories now names a file directly inside it; "." and ".." survive takeFileName, so this is not applied to directory operations.`, behavior: { kind: 'path', sanitizer: 'takeFileName', vars } };
335
+ }
336
+
236
337
  const LOG_CALLS = ['putStrLn', 'putStr', 'hPutStrLn', 'hPutStr', 'print', 'trace', 'traceShow', 'traceShowId', 'logInfoN', 'logDebugN', 'logWarnN', 'logErrorN', 'logInfo', 'logDebug', 'logWarn', 'logError'];
237
338
  function fixLogging(text, f) {
238
339
  const lb = lineBounds(text, f.line);
@@ -256,6 +357,7 @@ const FIXERS = [
256
357
  { id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
257
358
  { id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
258
359
  { id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
360
+ { id: 'hs-path-takefilename', applies: (f) => f.cwe === 'CWE-22', run: fixPath },
259
361
  ];
260
362
  export const SUPPORTED_FIX_IDS = Object.freeze(FIXERS.map((x) => x.id));
261
363
 
@@ -300,9 +402,23 @@ export function planHaskellFix(finding, files) {
300
402
  if (r && r.text && r.text !== before) return { ok: true, ruleId: r.ruleId, label: r.label, file: finding.file, before, after: r.text, explanation: r.explanation, behavior: r.behavior, ...(line !== finding.line ? { fixedAtLine: line } : {}) };
301
403
  }
302
404
  }
303
- return { ok: false, reason: 'no deterministic fix is supported for this exact shape', proposal: 'model-assisted' };
405
+ return { ok: false, reason: CLASS_REASONS[finding.cwe] || 'no deterministic fix is supported for this exact shape', proposal: 'model-assisted' };
304
406
  }
305
407
 
408
+ // Classes whose repair needs a choice the code does not contain (a policy, a library, a limit). The scanner reports them; it does not guess.
409
+ const CLASS_REASONS = Object.freeze({
410
+ 'CWE-918': 'server-side request forgery is fixed by an allow-list of hosts or a resolved-address policy, which is a decision about this service, not a rewrite of this line',
411
+ 'CWE-88': 'argument injection is fixed by validating the value against what the program accepts; inserting "--" only helps programs that honour it, so no patch is made',
412
+ 'CWE-916': 'password hashing needs a memory-hard or iterated KDF (Argon2id, scrypt, bcrypt, PBKDF2) from a library the project may not depend on, and existing stored hashes must be migrated',
413
+ 'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (Crypto.Random), whose result type differs from System.Random: the surrounding code must change with it',
414
+ 'CWE-1004': 'cookie attributes live in different places for each framework (a record field, a header string, a helper); the rewrite is not provably the same program',
415
+ 'CWE-306': 'a missing authentication check needs a policy (which credential, which rejection); the scanner reports the handler and does not invent the guard',
416
+ 'CWE-770': 'a resource limit is a number only the operator can choose (how many bytes, how many requests)',
417
+ 'CWE-295': 'enabling certificate validation can break connections the project depends on; the trust store to use is a deployment decision',
418
+ 'CWE-798': 'a hard-coded secret must be rotated and moved to a secret store; rewriting the line would leave the leaked value valid',
419
+ 'CWE-611': 'XML entity handling depends on the parser configuration in use',
420
+ });
421
+
306
422
  // ── gates ────────────────────────────────────────────────────────────────────
307
423
  const key = (f) => `${f.file}|${f.cwe || ""}`;
308
424