@clear-capabilities/agentic-security-scanner 0.187.0 → 0.189.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.
package/src/ir/index.js CHANGED
@@ -202,7 +202,7 @@ export function buildProjectIR(fileContents, opts = {}) {
202
202
  // the CFGs (SSA numbering included), since PHP's `include` genuinely
203
203
  // executes in the caller's variable namespace. See php-include-merge.js's
204
204
  // module header for the full rationale and scope.
205
- const hsCg = mergeHaskellIR(perFile, hsFiles);
205
+ const hsCg = mergeHaskellIR(perFile, hsFiles, opts && opts.haskell);
206
206
  try { mergePhpIncludes(perFile); } catch {}
207
207
  if (isSSAEnabled()) {
208
208
  for (const ir of Object.values(perFile)) {
@@ -225,10 +225,11 @@ export function buildProjectIR(fileContents, opts = {}) {
225
225
  // Haskell is lowered as one project (module resolution crosses files), then its
226
226
  // per-file IR joins the same perFile map every consumer already reads. Its call graph
227
227
  // keeps its richer edge statuses and is merged into the shared one below.
228
- function mergeHaskellIR(perFile, hsFiles) {
228
+ function mergeHaskellIR(perFile, hsFiles, hsContext) {
229
229
  if (!Object.keys(hsFiles).length) return null;
230
230
  try {
231
- const hs = buildHaskellIR(hsFiles);
231
+ // `hsContext` ({manifests, completeScan, scanHoles}) is what the project-reachability verdict needs besides the sources; without it every verdict is `unknown`.
232
+ const hs = buildHaskellIR(hsFiles, hsContext || {});
232
233
  for (const [file, ir] of Object.entries(hs.perFile)) perFile[file] = ir;
233
234
  for (const d of hs.diagnostics) _noteParseFailure(d.file, new Error(`haskell ${d.kind}: ${d.detail}`));
234
235
  return hs.callGraph;
@@ -247,7 +248,7 @@ function withHaskellCallGraph(cg, hsCg) {
247
248
  const isHs = (f) => typeof f === 'string' && /\.hs$/i.test(f);
248
249
  const resolve = (name, callerFile) => (isHs(callerFile) ? hsCg.resolve(name) : cg.resolve(name, callerFile));
249
250
  const resolveKnownCallee = (name, callerFile) => (isHs(callerFile) ? hsCg.resolveKnownCallee(name) : cg.resolveKnownCallee(name, callerFile));
250
- return { ...cg, functions, edges, callersOf, resolve, resolveKnownCallee, haskell: hsCg };
251
+ return { ...cg, functions, edges, callersOf, resolve, resolveKnownCallee, haskell: hsCg, haskellReachability: hsCg.haskellReachability || null };
251
252
  }
252
253
 
253
254
  // Async variant — includes Java IR via java-parser.
@@ -306,7 +307,7 @@ export async function buildProjectIRAsync(fileContents, opts = {}) {
306
307
  // the CFGs (SSA numbering included), since PHP's `include` genuinely
307
308
  // executes in the caller's variable namespace. See php-include-merge.js's
308
309
  // module header for the full rationale and scope.
309
- const hsCg = mergeHaskellIR(perFile, hsFiles);
310
+ const hsCg = mergeHaskellIR(perFile, hsFiles, opts && opts.haskell);
310
311
  try { mergePhpIncludes(perFile); } catch {}
311
312
  if (isSSAEnabled()) {
312
313
  for (const ir of Object.values(perFile)) {
@@ -66,6 +66,13 @@ 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`.
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
+ - **"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`.
73
+ - **Comments never change a verdict, an id or a cluster.** `haskell-comments.js` finds Haskell comments lexically; a finding's `snippet` is `codeOfLine(line)` (code only), because the snippet feeds the stable id and the clone-shape hash, and both changed when someone edited a trailing comment. `test/haskell/haskell-comment-invariance.test.js` scans the examples and corpus-shaped cases as written, with every comment blanked, and with every comment (and a trailing comment on every line) replaced by text that lies in each direction; `bench/dag/dev-haskell/comment-invariance.mjs` does the same to a real project. An engine that varies is wrong.
74
+ - **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`). A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
75
+ - **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'`.
69
76
  - **Pragma** (`pragma.js`): comment-aware, line-scoped, exact rule-id match, logged. A line-less finding cannot be suppressed.
70
77
  - **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.
71
78
 
@@ -81,6 +88,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
81
88
  | 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` |
82
89
  | 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` |
83
90
  | 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` |
91
+ | Haskell trust | `haskell-trust.js` (trust-boundary verdicts, the shared `lowerFinding`, and the lowering of operator-controlled and pass-through findings), `haskell-reachability.js` (caller-aware reachability: copies the IR's per-function verdict onto each taint finding and lowers the ones whose only premise a missing caller withdraws), `haskell-comments.js` (lexical Haskell comments; the code-only snippet) |
84
92
  | Measurement | `accuracy.js` (family-scoped scoring, per-layer), `support-registry.js` (status rules, promotion refusals, frozen-hash demotion) |
85
93
 
86
94
  Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell-view.js`, `lineage/nix-view.js`,
@@ -125,6 +133,7 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
125
133
  | Stress | `npm run test:language-stress` | offline scale and a peak-memory ceiling; excluded from `npm test` (it must own the machine) |
126
134
  | Needs a compiler | `npm run test:language-tools` | HS-006.AC01 and the support gate; excluded from `npm test`; **fails** without `ghc` |
127
135
  | Support registry | `npm run bench:language-support:check` | recomputes the registry from the STORED measurement; fails on any difference |
136
+ | Real-code precision | `npm run bench:haskell-precision:check` | `bench/haskell-precision/`: the labelled development and fresh real-code sets, compared for EQUALITY against `baseline.json` (a regression and an unrecorded improvement both fail); the live scan runs only where the pinned clones exist; the sealed independent set is read from its committed result only and is required by `bench:haskell-precision:release`. `bench:haskell-precision:update` re-records deliberately |
128
137
  | 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 |
129
138
 
130
139
  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).
@@ -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
+ }
@@ -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
 
@@ -30,7 +30,7 @@ const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CW
30
30
 
31
31
  const isStr = (e) => e && e.kind === 'literal' && typeof e.value === 'string';
32
32
 
33
- function identNames(e, out = new Set(), depth = 0) {
33
+ export function identNames(e, out = new Set(), depth = 0) {
34
34
  if (!e || typeof e !== 'object' || depth > 30) return out;
35
35
  if (e.kind === 'ident' && e.name && !(e.hs && e.hs.functionRef)) out.add(e.name);
36
36
  for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') identNames(e[k], out, depth + 1);
@@ -118,7 +118,7 @@ const exprChildren = (e) => {
118
118
  };
119
119
 
120
120
  // Find the sink call at `line` with callee `callee`; returns [{ nodeId, facts, call }].
121
- function findSites(fn, callee, line) {
121
+ export function findSites(fn, callee, line) {
122
122
  const sites = [];
123
123
  const visit = (e, nodeId, facts, depth) => {
124
124
  if (!e || typeof e !== 'object' || depth > 60) return;