@clear-capabilities/agentic-security-scanner 0.195.6 → 0.195.8

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
- 0659b03ccca5cf47c75d137372f0a17277ef3a3f0c4234fcb80c0868ef6c3b92 agentic-security.mjs
1
+ 0ab4e06e4afbe2190dfc789ed14282b05a74c3fdfae828a682afb7a250f8ef83 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.6",
3
+ "version": "0.195.8",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -93,9 +93,9 @@
93
93
  "test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
94
94
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
95
95
  "test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
96
- "test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
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
97
  "test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
98
- "test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
98
+ "test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
99
99
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
100
100
  "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
101
101
  "bench:language-support:check": "node ../bench/language-support/check.mjs",
@@ -299,6 +299,8 @@
299
299
  "test:nixos-host": "node --test test/nix/nixos-host-runtime.test.js",
300
300
  "test:language-gates": "node --test --test-concurrency=1 test/language/language-benchmark-gates.test.js",
301
301
  "test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js",
302
+ "test:language-slow:a": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js",
303
+ "test:language-slow:b": "node --test --test-concurrency=1 test/language/language-package-ci.test.js test/language/language-scan-modes.test.js",
302
304
  "test:chrome": "AGENTIC_SECURITY_CHROME_TESTS=1 node --test test/export-image.test.js test/server/cmd-dataflow-export.test.js",
303
305
  "ee:parse-reuse": "node ../scripts/engineering-excellence/parse-reuse-measure.mjs",
304
306
  "ee:analysis-cache": "node ../scripts/engineering-excellence/analysis-cache-measure.mjs",
@@ -228,7 +228,7 @@ function _escapeRegex(s) {
228
228
  // 'http-body' for req.body) that mean "this value arrived over an
229
229
  // incoming HTTP request" — the signal used to correlate a finding with a
230
230
  // declared `via: 'http'` cross-service edge.
231
- const HTTP_PROVENANCE = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie']);
231
+ const HTTP_PROVENANCE = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'upload']);
232
232
 
233
233
  // Every provenance value attached anywhere on a finding: the primary
234
234
  // source (`f.sourceProvenance`) plus every step in its source chain
@@ -141,7 +141,11 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
141
141
  | Real-code precision | `npm run bench:haskell-precision:check` | `bench/haskell-precision/`: the labelled development and fresh real-code sets, compared for EQUALITY against `baseline.json` (a regression and an unrecorded improvement both fail); the live scan runs only where the pinned clones exist; the sealed independent set is read from its committed result only and is required by `bench:haskell-precision:release`. `bench:haskell-precision:update` re-records deliberately |
142
142
  | Live feed | `npm run bench:live-feed -- <fetch\|plan\|scan\|merge\|sample\|report>` | `bench/live-feed/`: the opt-in Hackage feed against real projects and real OSV. Needs the network and is NOT in `npm test` or any gate. `RESULTS.md` is a dated measurement; labels in it are model-assessed |
143
143
 
144
- The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its failures were read and the engine was changed for their causes (provenance.json, `unseen-v3-development`; manifest `role: development`). `measure.mjs` records `splitRole` with every result, the registry labels the column "DEVELOPMENT set", and a generalisation claim needs a new unseen set (unseen-v4).
144
+ The `unseen` split (unseen-v3) is a **DEVELOPMENT set since 2026-10-26**: its failures were read and the engine was changed for their causes (provenance.json, `unseen-v3-development`; manifest `role: development`). `measure.mjs` records `splitRole` with every result, the registry labels the column "DEVELOPMENT set", and a generalisation claim needs an unseen set no engine change has been motivated by.
145
+
146
+ **Frozen splits (`bench/language-support/corpora/unseen-v4`, `unseen-v5`, ...).** Each is 78 single-flaw Haskell forms (13 families, 3 vulnerable and 3 safe) written blind to the engine and to every earlier set, frozen by digest (`corpora/frozen-split.mjs` is the shared builder, loader and summariser; `test/language/frozen-splits.test.js` checks every split on every run), measured once before the engine work that follows, and governed by its `USE-LOG.md`: reading its failures to change a rule turns it into a development set (`manifest.json` `role: development`, `developmentSince`), and the next generalisation claim needs the next split, written the same way (a separate agent session that cannot see the repository). **unseen-v4 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.
147
+
148
+ Rules added for the unseen-v4 shapes, each pinned both ways in `test/haskell/haskell-unseen-v4-shapes.test.js`: a value READ from the request inside a function (`param`, `strictRequestBody`, `requestHeaders`, any `REQUEST_PROVENANCE` source) is caller text for the partial-function, allocation and bounding rules whatever the function's parameters are; a presence test does not cover a partial call on a value SPLIT or parsed from the tested one (`SPLITTER`), while the same split of the same value does; a relational test on a count bounds the allocation it sizes; a `System.Random` value that reaches the result of a security-shaped function is predictable randomness; a project record parameter with a credential or card field logged whole (`show card`) is sensitive logging, through a masking helper by name it is not; wai-extra `requestSizeCheck` bounds the body read. In `haskell-web.js`: Servant closure arguments before the endpoint's values are paired from the end; a container lookup by the request id (`M.lookup`) is the object selection for BOLA only; BOLA without an authenticated principal needs either a WRITE to the selected object or OWNER EVIDENCE (`OWNER_COL`: a statement names an owner column its WHERE clause does not use; `OWNER_FIELD`: the record the handler works with has an owner field it never reads, where "works with" means another field of that record is read); a Scotty handler typed `ActionM` but registered elsewhere is a route with method `UNKNOWN` (ownership only, never a missing-auth finding); a Yesod `isAuthorized` clause that can answer `AuthenticationRequired` guards; a privileged path whose handler calls into unseen code is a lower-severity missing-auth finding, but a plain write with no visible operation is NOT (23 echo, upload-demo and health-check routes on four real projects). Taint: `Network.Wai.Parse.fileName` is an `upload` source; `Network.Wai.responseLBS`/`responseBuilder` are CWE-79 sinks judged by the header argument (`waiHtmlContext`: `text/html` keeps, another content type lowers to `info`, opaque headers keep with a stated uncertainty); the HTML-escaper judgement follows local bindings, and a definition with no value in it (a read) is the raw origin; a Shakespeare quasi-quote with interpolations is rewritten in place to an application of its interpolated expressions (`hamletInterpolations`, boundary kind `quasiquote-hamlet`, never opaque) so `#{preEscapedToHtml body}` is a sink site and `#{body}` is not. Two shapes stay as they were on purpose: `getArgs` reaching a shell is `operator-controlled` (info), and `fetch url = parseRequest url >>= httpNoBody` stays a finding (a URL wrapper is not an `api-contract`); unseen-v4 labels both the other way, and that is recorded, not decided.
145
149
 
146
150
  The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
147
151
  against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
@@ -319,8 +319,10 @@ function allUsesEscaped(fn, e, escapers, depth = 0) {
319
319
  if (e.kind === 'call' && escapers.has(e.callee)) return true; // whatever it receives is escaped
320
320
  if (e.kind === 'ident') {
321
321
  if (e.hs && e.hs.functionRef) return true;
322
+ // a name bound in this function stands for its definition (`page = "<p>" ++ path ++ "</p>"` with `path = escape (...)`): every use inside it must be escaped too
323
+ // A definition built from other values is judged by them; a definition with no value in it (`w <- param "w"`, a read) is the raw origin.
322
324
  const defs = nodeList(fn).filter((n) => n.kind === 'assign' && n.target === e.name && n.source);
323
- return defs.length > 0 && defs.every((n) => n.source.kind === 'call' && escapers.has(n.source.callee));
325
+ return defs.length > 0 && defs.every((n) => (n.source.kind === 'call' && escapers.has(n.source.callee)) || (identNames(n.source).size > 0 && allUsesEscaped(fn, n.source, escapers, depth + 1)));
324
326
  }
325
327
  for (const [, c] of exprChildren(e)) if (!allUsesEscaped(fn, c, escapers, depth + 1)) return false;
326
328
  return true;
@@ -355,8 +357,10 @@ export function judgeFinding(f, perFile) {
355
357
  const escapers = htmlEscapers(perFile);
356
358
  const calls = [];
357
359
  const find = (e, d) => { if (!e || typeof e !== 'object' || d > 40) return; if (e.kind === 'call' && escapers.has(e.callee)) calls.push(e); for (const [, c] of exprChildren(e)) find(c, d + 1); };
358
- find(argExpr, 0);
359
- for (const n of nodeList(fn)) if (n.kind === 'assign' && n.source && identNames(argExpr).has(n.target)) find(n.source, 0);
360
+ // the escaper may sit behind one or more local bindings (`page = ... path ...`, `path = escape (...)`): follow them, each once
361
+ const followed = new Set();
362
+ const findIn = (e, d) => { if (!e || d > 40) return; find(e, d); for (const v of identNames(e)) { if (followed.has(v)) continue; followed.add(v); for (const n of nodeList(fn)) if (n.kind === 'assign' && n.source && n.target === v) findIn(n.source, d + 1); } };
363
+ findIn(argExpr, 0);
360
364
  if (calls.length && allUsesEscaped(fn, argExpr, escapers)) refuted = { kind: 'html-escaper', line: calls[0].line || fn.line, subject: calls[0].callee };
361
365
  }
362
366
  for (const fact of facts) {
@@ -473,10 +477,51 @@ export function quotedPositionals(f, perFile) {
473
477
  }
474
478
 
475
479
  /** Applies judgeFinding to every Haskell taint finding. Returns counts. */
480
+ /**
481
+ * A raw WAI response (`responseLBS status headers body`) is an HTML context only when its headers say so. Returns `html` (a `text/html`
482
+ * content type is written in the header list), `other` (another content type is written there), or `unknown` (the headers are not literal
483
+ * text the walk can read). Read from the call's own header argument; nothing else decides it.
484
+ */
485
+ export function waiHtmlContext(f, perFile) {
486
+ if (!f || f.cwe !== 'CWE-79' || !/^Network\.Wai\.response(?:LBS|Builder)$/.test(f.callee || '')) return null;
487
+ const ir = perFile && perFile[f.file];
488
+ if (!ir) return 'unknown';
489
+ const fns = [ir.functions.find((x) => x.qid === f._funcQid), ...ir.functions].filter(Boolean);
490
+ for (const fn of fns) {
491
+ const sites = findSites(fn, f.callee, f.line);
492
+ if (!sites.length) continue;
493
+ const texts = [];
494
+ const resolveLocal = (name) => { for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) return n.source; return null; };
495
+ const visit = (e, d) => {
496
+ if (!e || typeof e !== 'object' || d > 40) return;
497
+ if (e.kind === 'literal' && typeof e.value === 'string') texts.push(e.value);
498
+ if (e.kind === 'ident' && d < 2) { const src = resolveLocal(e.name); if (src) visit(src, d + 1); }
499
+ for (const [, c] of exprChildren(e)) visit(c, d + 1);
500
+ };
501
+ for (const site of sites) visit((site.call.args || [])[1], 0);
502
+ const types = texts.filter((t) => /^(?:text|application|image|audio|video|multipart)\/[\w.+-]+/i.test(t.trim()));
503
+ if (types.some((t) => /^text\/html/i.test(t.trim()))) return 'html';
504
+ if (types.length) return 'other';
505
+ return 'unknown';
506
+ }
507
+ return 'unknown';
508
+ }
509
+
476
510
  export function applyHaskellGuards(findings, perFile) {
477
511
  const stats = { refuted: 0, weak: 0 };
478
512
  for (const f of findings || []) {
479
513
  if (!f || f.parser !== 'IR-TAINT') continue;
514
+ try {
515
+ const ctx = waiHtmlContext(f, perFile);
516
+ if (ctx === 'other' && !f._notHtmlContext) {
517
+ f._notHtmlContext = true; f.severity = 'info';
518
+ f.controls = [...(f.controls || []), { kind: 'non-html-content-type', evidenceKind: 'source' }];
519
+ f.guardNote = { kind: 'non-html-content-type', reason: 'the response headers written at this call name a content type other than text/html; the body is not parsed as HTML by a browser that honours them' };
520
+ stats.weak++;
521
+ continue;
522
+ }
523
+ 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
+ } catch { /* judged below */ }
480
525
  try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
481
526
  try {
482
527
  const qp = quotedPositionals(f, perFile);
@@ -97,7 +97,8 @@ function sigInfo(tokens, sig) {
97
97
  // module proves it trusted. They are modelled as a source of kind "caller-controlled parameter" (a weaker claim than a
98
98
  // request or stdin read: the finding says so). A function without a signature, a non-exported function and a parameter
99
99
  // of any other type (a connection, a number, a record) are never sources.
100
- const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
100
+ // (an aeson `Value`/`Object` is a JSON document the caller wrote: every string a handler digs out of it is the caller's text)
101
+ const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|Value|Object|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
101
102
  // Parameters of an exported function whose declared type is a container or text (a list, a map, a set, a sequence, a vector, a string):
102
103
  // a PARTIAL function (`maximum`, `head`, `Map.!`) applied to one fails on the empty or missing case the caller can supply. Wider than
103
104
  // TEXT_LIKE on purpose, and used only by the partial-function rule, never as an injection source.
@@ -162,6 +162,8 @@ for (const mod of ['Yesod.Core', 'Yesod']) {
162
162
  src('Yesod.Core.Json', ['parseJsonBody', 'requireJsonBody', 'parseCheckJsonBody', 'requireCheckJsonBody'], 'http-body', 'yesod request body', { package: 'yesod', framework: 'yesod' });
163
163
  // WAI: the multipart/form body parsers and the conduit request body, and the request's host and cookie headers
164
164
  src('Network.Wai.Parse', ['parseRequestBody', 'parseRequestBodyEx'], 'http-body', 'WAI parsed request body', { package: 'wai-extra', framework: 'wai' });
165
+ // the name and content type of an uploaded file are fields the client wrote into the multipart body (`fileName fi` is the browser's file name, verbatim)
166
+ src('Network.Wai.Parse', ['fileName', 'fileContentType', 'fileContent'], 'upload', 'uploaded file field', { package: 'wai-extra', framework: 'wai' });
165
167
  src('Network.Wai.Conduit', ['sourceRequestBody'], 'http-body', 'WAI request body stream', { package: 'wai-extra', framework: 'wai' });
166
168
 
167
169
  // A text parameter of an exported function that returns a web-framework HANDLER value (a Scotty action, a Yesod handler or widget, a Servant handler,
@@ -264,6 +266,9 @@ for (const mod of ['Yesod', 'Yesod.Core', 'Yesod.Core.Widget', 'Text.Blaze', 'Te
264
266
  sink(mod, ['preEscapedToMarkup', 'preEscapedToHtml'], { ...XSS, package: mod.startsWith('Yesod') ? 'yesod' : 'blaze-html', ...(mod.startsWith('Yesod') ? { framework: 'yesod' } : {}) });
265
267
  }
266
268
  sink('Web.Scotty', ['html'], { ...XSS, package: 'scotty', framework: 'scotty', vuln: 'Cross-Site Scripting (HTML response from text)' });
269
+ // A raw WAI response is HTML only when its headers say so: haskell-guards.js `waiHtmlContext` reads the header argument and lowers the finding to
270
+ // `info` for another content type (text/plain, application/json), keeps it for text/html, and keeps it with a stated uncertainty when the headers are opaque.
271
+ sink('Network.Wai', ['responseLBS', 'responseBuilder'], { ...XSS, argIndex: 2, package: 'wai', framework: 'wai', vuln: 'Cross-Site Scripting (HTML response body from text)' });
267
272
  sink('Web.Scotty.Trans', ['html'], { ...XSS, package: 'scotty', framework: 'scotty', vuln: 'Cross-Site Scripting (HTML response from text)' });
268
273
 
269
274
  // HTML assembled by concatenation and written out as text: only when the string being built contains markup (a literal
@@ -339,6 +344,8 @@ const SAN = [];
339
344
  const san = (module, names, appliesTo, note, extra = {}) => { for (const e of E(module, names)) SAN.push({ ...e, appliesTo, note, ...extra }); };
340
345
  san('Text.Blaze.Html', ['toHtml', 'toMarkup', 'text', 'string', 'lazyText'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context');
341
346
  san('Text.Blaze', ['toMarkup', 'toValue', 'text', 'string', 'lazyText'], ['xss'], 'escapes for HTML context');
347
+ // Text.Blaze.Html5 and Html4 re-export the escaping combinators, and most files import the element module rather than Text.Blaze.Html
348
+ for (const mod of ['Text.Blaze.Html5', 'Text.Blaze.Html4.Strict', 'Text.Blaze.Html4.Transitional', 'Text.Blaze.Html4.FrameSet', 'Text.Blaze.XHtml5', 'Text.Blaze.XHtml1.Strict']) san(mod, ['toHtml', 'toMarkup', 'toValue', 'text', 'string', 'lazyText', 'textValue', 'stringValue'], ['xss'], 'escapes &, <, >, quotes for HTML text/attribute context');
342
349
  san('Lucid', ['toHtml', 'toHtmlRaw_'], ['xss'], 'Lucid.toHtml escapes');
343
350
  san('System.FilePath', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
344
351
  san('System.FilePath.Posix', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
@@ -40,7 +40,38 @@ export const DEFAULT_PARSE_BUDGETS = Object.freeze({
40
40
 
41
41
  // Boundaries that are disclosed but do not make a file incomplete: the content was either parsed (cpp, ffi) or matched
42
42
  // by a closed, sound pattern and stated as an assumption (see haskell-boundaries.js).
43
- export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
43
+ export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert', 'quasiquote-hamlet']);
44
+
45
+ // Shakespeare's HTML template quoters: the interpolations are Haskell expressions of the enclosing scope (see absorbSafeBoundaries).
46
+ const HAMLET_QUOTERS = new Set(['hamlet', 'whamlet', 'shamlet', 'xhamlet', 'ihamlet', 'xshamlet']);
47
+
48
+ /**
49
+ * The interpolations of a hamlet body, in order: `#{e}` (escaped value), `^{e}` (embedded template or widget), `@{r}` / `@?{r}` (route) and `_{m}`
50
+ * (message). Braces nest (a record or a lambda inside), and a string literal may contain `}`. Returns offsets into the segment: the opener's
51
+ * first character, the expression's first character, and the closing brace.
52
+ */
53
+ export function hamletInterpolations(seg, from, to) {
54
+ const out = [];
55
+ for (let i = from; i < to; i++) {
56
+ const ch = seg[i];
57
+ if (!(ch === '#' || ch === '^' || ch === '@' || ch === '_')) continue;
58
+ let j = i + 1;
59
+ if (ch === '@' && seg[j] === '?') j++;
60
+ if (seg[j] !== '{') continue;
61
+ let depth = 1, k = j + 1, inStr = false;
62
+ for (; k < to && depth > 0; k++) {
63
+ const c = seg[k];
64
+ if (inStr) { if (c === '\\') k++; else if (c === '"') inStr = false; continue; }
65
+ if (c === '"') inStr = true;
66
+ else if (c === '{') depth++;
67
+ else if (c === '}') { depth--; if (depth === 0) break; }
68
+ }
69
+ if (depth !== 0 || k >= to) continue;
70
+ if (k > j + 1) out.push({ open: i, exprStart: j + 1, close: k });
71
+ i = k;
72
+ }
73
+ return out;
74
+ }
44
75
 
45
76
  class BudgetError extends Error {
46
77
  constructor(budget, limit) {
@@ -1078,6 +1109,36 @@ function analyze(source, file, dialect, ctx, res) {
1078
1109
  b.assumption = 'upstream-quoter';
1079
1110
  res.blankRanges.push({ kind: 'quasiquote-inert', startOffset: b.span.startOffset, endOffset: b.span.endOffset });
1080
1111
  }
1112
+ // Shakespeare's HTML quoters (`[whamlet|<p>#{name}|]`): the template text is inert, and each `#{e}` / `^{e}` / `@{e}` / `_{e}` interpolation is an
1113
+ // ordinary Haskell expression evaluated in the enclosing scope. The quasi-quote is rewritten IN PLACE, same length, same newlines, as an
1114
+ // application of an unknown function to the interpolated expressions, each kept at its original offset (`(hamletQ (preEscapedToHtml body))`),
1115
+ // so a sink inside an interpolation (`preEscapedToHtml`) and the request value it is handed stay visible to the IR, with their original spans.
1116
+ // Hamlet escapes a plain `#{e}` itself, so the wrapper is not a sink; the template's own text (tags, attributes, `$forall`) is not modelled.
1117
+ // Only with a Shakespeare/Yesod import, so a project quoter that borrows the name stays opaque.
1118
+ const shakespeare = res.imports.some((im) => /^(?:Yesod|Text\.Hamlet|Text\.Shakespeare|Text\.Blaze)(?:\.|$)/.test(im.module || ''));
1119
+ for (const b of res.boundaries) {
1120
+ if (b.kind !== 'quasiquote' || !shakespeare || !HAMLET_QUOTERS.has(b.quoter)) continue;
1121
+ const seg = source.slice(b.span.startOffset, b.span.endOffset);
1122
+ const bar = seg.indexOf('|');
1123
+ if (bar < 0 || !seg.endsWith('|]')) continue;
1124
+ const interps = hamletInterpolations(seg, bar + 1, seg.length - 2);
1125
+ if (!interps.length) continue;
1126
+ const chars = seg.split('').map((ch) => (ch === '\n' || ch === '\r' ? ch : ' '));
1127
+ const put = (at, s) => { for (let i = 0; i < s.length; i++) if (chars[at + i] !== '\n' && chars[at + i] !== '\r') chars[at + i] = s[i]; };
1128
+ put(0, ('(hamletQ').slice(0, bar + 1).padEnd(bar + 1));
1129
+ for (const it of interps) {
1130
+ put(it.open, ' ('.slice(-(it.exprStart - it.open)).padStart(it.exprStart - it.open)); // the `#{` / `^{` / `@?{` opener becomes spaces and one `(`
1131
+ put(it.exprStart, seg.slice(it.exprStart, it.close));
1132
+ put(it.close, ')');
1133
+ }
1134
+ put(interps[interps.length - 1].close + 1, ')'); // closes the application; the char after the last `}` is template text or the `|` of `|]`
1135
+ const text = chars.join('');
1136
+ if (text.length !== seg.length) continue;
1137
+ b.kind = 'quasiquote-hamlet';
1138
+ b.detail = `quasi-quote [${b.quoter}|...|] with ${interps.length} interpolation(s): the interpolated expressions are analysed in the enclosing scope; the template text is not modelled; ASSUMED to be the Shakespeare quoter`;
1139
+ b.assumption = 'upstream-quoter';
1140
+ res.blankRanges.push({ kind: 'quasiquote-hamlet', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
1141
+ }
1081
1142
  }
1082
1143
 
1083
1144
  // ── errors → withheld calls ──
@@ -1165,6 +1226,7 @@ export function preprocessForSyntaxInfo(source, opts = {}) {
1165
1226
  const seg = out.slice(b.startOffset, b.endOffset);
1166
1227
  let repl = blank(seg);
1167
1228
  if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
1229
+ if (b.kind === 'quasiquote-hamlet' && typeof b.text === 'string' && b.text.length === seg.length) repl = b.text; // the interpolations, at their offsets
1168
1230
  out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
1169
1231
  }
1170
1232
  return { text: out, opaque };
@@ -14,7 +14,7 @@
14
14
  // Nothing here executes code, evaluates a type, or reads a path outside the files it is handed.
15
15
 
16
16
  import { buildHaskellIR } from './haskell-ir.js';
17
- import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
17
+ import { HS_LOG_SINKS, HS_MODEL_VERSION, sourceInfo } from './haskell-models.js';
18
18
  import { codeOfLine } from './haskell-comments.js';
19
19
 
20
20
  export const HS_RULES_VERSION = 'haskell-security-rules/1';
@@ -77,6 +77,11 @@ const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'cred
77
77
  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)) };
78
78
  const INPUT_READS = new Set(['Prelude.getContents', 'System.IO.getContents', 'System.IO.hGetContents', 'Data.ByteString.getContents', 'Data.ByteString.hGetContents', 'Data.ByteString.Char8.getContents', 'Data.ByteString.Char8.hGetContents', 'Data.ByteString.Lazy.getContents', 'Data.ByteString.Lazy.hGetContents', 'Data.ByteString.Lazy.Char8.getContents', 'Data.ByteString.Lazy.Char8.hGetContents', 'Data.Text.IO.getContents', 'Data.Text.IO.hGetContents', 'Data.Text.Lazy.IO.getContents', 'Data.Text.Lazy.IO.hGetContents']);
79
79
  const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limitedRead)$/;
80
+ // Source provenances (haskell-models.js) that mean "the HTTP client supplied this text": a read with one of these inside a function makes the
81
+ // value caller-supplied for the partial-function, allocation and bounding rules, whatever the function's own parameters are.
82
+ const REQUEST_PROVENANCE = new Set(['url-param', 'path-param', 'http-body', 'header', 'cookie', 'upload']);
83
+ // wai-extra's `requestSizeCheck (Just n) req` wraps the request so the body read after it fails past n bytes: a bound on every body read of the function.
84
+ const WAI_SIZE_CHECK = /(?:^|\.)requestSizeCheck$/;
80
85
  // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
81
86
  const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate', 'Control.Monad.replicateM', 'Control.Monad.replicateM_', 'Data.Vector.replicateM', 'Data.List.genericReplicate']);
82
87
  const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
@@ -89,6 +94,8 @@ const PARTIALS = new Set([...PARTIAL_NAMES, ...PARTIAL_NAMES.flatMap((n) => PART
89
94
  ...['Data.Map', 'Data.Map.Strict'].flatMap((m) => ['findMin', 'findMax', 'deleteFindMin', 'elemAt'].map((n) => `${m}.${n}`)), 'Data.Set.findMin', 'Data.Set.findMax', 'Data.Set.elemAt']);
90
95
  // A call that tests whether a container is empty, a key is present or a value is available: the partial call beside it is conditional on that test.
91
96
  const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNothing|member|notMember|lookup|elem|notElem|nonEmpty|<match>)$/;
97
+ // A call whose result has a different shape from its argument (pieces of it, or a parse of it): a test of the argument says nothing about the result.
98
+ const SPLITTER = /(?:^|\.)(?:words|lines|splitOn|split|splitWhen|chunksOf|breakOn|decode|decodeStrict|eitherDecode|decodeEither|parseMaybe|parseEither|parse|readMaybe|readEither|reads)$/;
92
99
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
93
100
 
94
101
  // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
@@ -312,45 +319,89 @@ export function analyzeHaskellRules(files, opts = {}) {
312
319
  const fnHs = fn.hs || {};
313
320
  const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
314
321
  const entryLike = callersUnknown(fn);
322
+ const callsIn = (e) => { const out = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') out.push(x); return out; };
323
+ // A value READ from the request inside the function (`raw <- param "page"`, `body <- strictRequestBody req`, `lookup h (requestHeaders req)`)
324
+ // is caller-supplied text wherever the function sits: the framework's own read says who supplied it, so no parameter label is needed.
325
+ const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
315
326
  const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
316
- if (derivedText.size) {
317
- for (let round = 0; round < 8; round++) {
318
- let grew = false;
319
- for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && !derivedText.has(n.target) && [...names(n.source)].some((x) => derivedText.has(x))) { derivedText.add(n.target); grew = true; }
320
- if (!grew) break;
327
+ const fromRequest = new Set();
328
+ for (let round = 0; round < 8; round++) {
329
+ let grew = false;
330
+ for (const n of fnNodes(fn)) {
331
+ if (n.kind !== 'assign' || !n.target || !n.source) continue;
332
+ const nm = [...names(n.source)];
333
+ const reads = callsIn(n.source).some(requestRead);
334
+ if (!derivedText.has(n.target) && (reads || nm.some((x) => derivedText.has(x)))) { derivedText.add(n.target); grew = true; }
335
+ if (!fromRequest.has(n.target) && (reads || nm.some((x) => fromRequest.has(x)))) { fromRequest.add(n.target); grew = true; }
321
336
  }
337
+ if (!grew) break;
322
338
  }
323
- const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x));
324
- const callsIn = (e) => { const out = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') out.push(x); return out; };
339
+ const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x)) || callsIn(e).some(requestRead);
340
+ const mentionsRequest = (e) => [...names(e)].some((x) => fromRequest.has(x)) || callsIn(e).some(requestRead);
325
341
  const fnReturns = fnNodes(fn).filter((n) => n.kind === 'return' && n.value);
326
342
  const bounded = fnNodes(fn).some((n) => [n.value, n.source, ...(n.args || [])].some((e) => e && callsIn(e).some((c) => BOUNDING.test(c.callee))));
327
343
 
328
344
  // Values a condition in this function tests for emptiness or presence (`null ws`, `M.member k t`, `xs /= []`, a pattern match on it) and,
329
345
  // transitively, the values they were built from: a partial call on one of them is the other branch of that test.
346
+ // A test covers a partial call only on the value it tested, not on a value SPLIT or PARSED from it afterwards: `maybe "" id (lookup h hs)`
347
+ // proves the header is present, not that `words auth !! 1` has a second word; `isJust (decode body)` does cover `fromJust (decode body)`
348
+ // (the same split, of the same value, is what was tested). Names are therefore followed through assignments but not into a splitting call.
349
+ const directNames = (e) => { const out = new Set(); const visit = (x, d) => { if (!x || typeof x !== 'object' || d > 60) return; if (x.kind === 'call' && typeof x.callee === 'string' && SPLITTER.test(x.callee)) return; if (x.kind === 'ident' && x.name && !(x.hs && x.hs.functionRef)) out.add(x.name); for (const c of kids(x)) visit(c, d + 1); }; visit(e, 0); return out; };
350
+ const splitsIn = (e) => callsIn(e).filter((x) => SPLITTER.test(x.callee)).map((x) => `${x.callee.replace(/^.*\./, '')}|${[...names(x)].sort().join(',')}`);
330
351
  const assignSources = new Map();
331
- for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source) assignSources.set(n.target, [...(assignSources.get(n.target) || []), ...names(n.source)]);
352
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source) assignSources.set(n.target, [...(assignSources.get(n.target) || []), ...directNames(n.source)]);
332
353
  const withOrigins = (seed) => { const out = new Set(seed); const queue = [...seed]; while (queue.length) { const v = queue.pop(); for (const o of assignSources.get(v) || []) if (!out.has(o)) { out.add(o); queue.push(o); } } return out; };
333
354
  const testedVars = new Set();
355
+ const testedSplits = new Set();
334
356
  for (const n of fnNodes(fn)) {
335
357
  if (n.kind !== 'if' || !n.cond) continue;
336
358
  for (const x of walk(n.cond)) {
337
- if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) for (const nm of names(x)) testedVars.add(nm);
338
- if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of names(x)) testedVars.add(nm);
359
+ if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) { for (const nm of directNames(x)) testedVars.add(nm); for (const s of splitsIn(x)) testedSplits.add(s); }
360
+ if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of directNames(x)) testedVars.add(nm);
339
361
  }
340
362
  }
341
363
  const testedAll = withOrigins(testedVars);
342
- const isTested = (args) => { const mine = withOrigins([...args.flatMap((a) => [...names(a)])]); for (const v of mine) if (testedAll.has(v) && derivedText.has(v)) return true; return false; };
364
+ const isTested = (args) => {
365
+ if (args.some((a) => splitsIn(a).some((s) => testedSplits.has(s)))) return true;
366
+ const mine = withOrigins([...args.flatMap((a) => [...directNames(a)])]);
367
+ for (const v of mine) if (testedAll.has(v) && derivedText.has(v)) return true;
368
+ return false;
369
+ };
343
370
 
344
371
  // A relational test against a number or a length (`total >= limit`, `size <= 65536`) in this function: the evidence that a read or a body is capped.
345
372
  const hasSizeTest = fnNodes(fn).some((n) => n.kind === 'if' && n.cond && [...walk(n.cond)].some((x) => x.kind === 'binary' && RELATIONAL.has(x.op)));
373
+ // The values a relational test in this function compares with something (`n > maxBatch`, `count <= 100`): a count one of them sizes is bounded
374
+ // by that test (the direction is not judged; a test that only rejects negatives still counts, which errs toward silence).
375
+ const sizeTestedVars = new Set();
376
+ for (const n of fnNodes(fn)) if (n.kind === 'if' && n.cond) for (const x of walk(n.cond)) if (x.kind === 'binary' && RELATIONAL.has(x.op)) for (const nm of names(x)) sizeTestedVars.add(nm);
346
377
  const readsRequestLength = fnNodes(fn).some((n) => [n.value, n.source, n.cond, ...(n.args || [])].some((ex) => ex && [...walk(ex)].some((x) => (x.kind === 'ident' && REQUEST_LENGTH.has(x.name)) || (x.kind === 'call' && REQUEST_LENGTH.has(x.callee)))));
347
- const sizeChecked = hasSizeTest && readsRequestLength;
378
+ const waiSizeChecked = fnNodes(fn).some((n) => [n.value, n.source, n.cond, ...(n.args || [])].some((ex) => ex && callsIn(ex).some((x) => WAI_SIZE_CHECK.test(x.callee))));
379
+ const sizeChecked = (hasSizeTest && readsRequestLength) || waiSizeChecked;
348
380
  // One predictable-random finding per function: a clock reading that seeds a generator in a token-shaped function is a single flaw,
349
381
  // however many of its steps (the clock, the seed, the generator) the rule can see.
350
382
  let randomEmitted = false;
351
383
  const emitRandom = (line, extra) => { if (randomEmitted) return; randomEmitted = true; emit('hs-predictable-random', file, line, extra); };
352
384
 
353
385
  const seeds = (e) => { for (const n of names(e)) if (SENSITIVE.test(n.replace(/^\./, ''))) return n.replace(/^\./, ''); return null; };
386
+ // A parameter whose declared type is a record of this project with a credential or card field (`Card { cardNumber, cardCvv }`), written
387
+ // to a log WHOLE: `show card`, or the value spliced into the message. Handed to any other function (`masked card`) it is that function's
388
+ // result that is logged, which is judged by the names it carries, not here.
389
+ const sensitiveRecords = new Map((fnHs.recordParams || []).filter((r) => (r.fields || []).some((x) => SENSITIVE.test(x))).map((r) => [r.name, r]));
390
+ const FORMAT_WHOLE = /(?:^|\.)(?:show|tshow|showt|pack|unpack|encode|toJSON|encodeUtf8|decodeUtf8|fromStrict|toStrict|toLazyText|fromString|toString|displayShow|pShow|ppShow|textDisplay|toLogStr|logStr)$/;
391
+ const wholeRecord = (arg) => {
392
+ if (!sensitiveRecords.size) return null;
393
+ let found = null;
394
+ const visit = (e, d) => {
395
+ if (!e || typeof e !== 'object' || d > 40 || found) return;
396
+ if (e.kind === 'ident' && sensitiveRecords.has(e.name)) { const r = sensitiveRecords.get(e.name); found = { name: e.name, type: r.type, field: (r.fields || []).find((x) => SENSITIVE.test(x)) }; return; }
397
+ if (e.kind === 'binary') { visit(e.left, d + 1); visit(e.right, d + 1); return; }
398
+ if (e.kind === 'call' && typeof e.callee === 'string') { if (FORMAT_WHOLE.test(e.callee)) for (const a of e.args || []) visit(a, d + 1); return; }
399
+ if (e.kind === 'array') { for (const a of e.elements || []) visit(a, d + 1); return; }
400
+ if (e.hs && e.hs.inner) visit(e.hs.inner, d + 1);
401
+ };
402
+ visit(arg, 0);
403
+ return found;
404
+ };
354
405
 
355
406
  // pass over nodes in program order
356
407
  for (const node of fnNodes(fn)) {
@@ -472,7 +523,8 @@ export function analyzeHaskellRules(files, opts = {}) {
472
523
  if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emitRandom(line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
473
524
  if (INPUT_READS.has(c) && !bounded && readsOutsideInput(fn, c, args, importsNetwork)) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
474
525
  // an allocation whose size is parsed caller text and is not clamped
475
- if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee)) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee))) {
526
+ // (a number the framework parsed from the request, `n <- param "count" :: ActionM Int`, is parsed caller text too)
527
+ if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && (mentionsRequest(args[0]) || callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee))) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee)) && ![...names(args[0])].some((v) => sizeTestedVars.has(v))) {
476
528
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
477
529
  }
478
530
  // a partial function applied to caller text: one malformed value raises an uncaught exception
@@ -532,7 +584,8 @@ export function analyzeHaskellRules(files, opts = {}) {
532
584
  if (!argList.length || argList.every((a) => !a)) continue;
533
585
  // a file append is a log only when its path says so (a log, audit or trace file)
534
586
  if (sink.channel === 'file') { const p = (e.args || [])[0]; if (!(p && p.kind === 'literal' && /(?:log|audit|trace|journal)/i.test(String(p.value)))) continue; }
535
- const isRedacted = (a) => { for (const x of walk(a)) if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) return true; return false; };
587
+ // (a project function the IR inlined into the argument, `masked card`, keeps its origin in `hs.inlinedFrom`: a redactor by name earns the same credit inlined as called)
588
+ const isRedacted = (a) => { for (const x of walk(a)) { if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) return true; if (x.hs && typeof x.hs.inlinedFrom === 'string' && REDACTOR.test(x.hs.inlinedFrom.replace(/^.*::/, ''))) return true; } return false; };
536
589
  for (const arg of argList) {
537
590
  if (!arg) continue;
538
591
  let hit = null;
@@ -551,6 +604,7 @@ export function analyzeHaskellRules(files, opts = {}) {
551
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 };
552
605
  if (hit) break;
553
606
  }
607
+ 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 }; }
554
608
  if (hit && !isRedacted(arg)) {
555
609
  emit('hs-sensitive-logging', file, e.line || node.line, {
556
610
  severity: hit.hashed ? 'low' : 'high',
@@ -563,6 +617,20 @@ export function analyzeHaskellRules(files, opts = {}) {
563
617
  }
564
618
  }
565
619
  }
620
+ // A security-shaped function (`generateApiKey`, `newResetCode`) whose RESULT is built from a System.Random value: the generator was bound
621
+ // to an ordinary name (`g <- newStdGen`, `let bytes = take 16 (randoms g)`), so neither the binding rule nor the "feeds the function" rule
622
+ // above saw it; the value reaching the function's return is what makes it the token.
623
+ if (!randomEmitted && SECURITY_FN.test(fnBase)) {
624
+ const hasRandom = (e) => { for (const x of walk(e)) if ((x.kind === 'call' && RANDOM_CALLS.has(x.callee)) || (x.kind === 'ident' && RANDOM_CALLS.has(x.name) && !(x.hs && x.hs.functionRef))) return true; return false; };
625
+ const carriers = new Set(); let seedLine = null;
626
+ for (let round = 0; round < 8; round++) {
627
+ let grew = false;
628
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && !carriers.has(n.target) && (hasRandom(n.source) || [...names(n.source)].some((x) => carriers.has(x)))) { carriers.add(n.target); if (seedLine === null || (Number.isFinite(n.line) && n.line < seedLine)) seedLine = n.line; grew = true; }
629
+ if (!grew) break;
630
+ }
631
+ const ret = fnReturns.find((n) => hasRandom(n.value) || [...names(n.value)].some((x) => carriers.has(x)));
632
+ if (ret) emitRandom(ret.line || seedLine || fn.line, { detail: `a System.Random value reaches the result of \`${fnBase}\``, evidence: { function: fnBase, via: [...carriers].slice(0, 4) } });
633
+ }
566
634
  }
567
635
  }
568
636
 
@@ -26,9 +26,12 @@ import { findSites, identNames } from './haskell-guards.js';
26
26
  export const TRUST_VERDICTS = Object.freeze(['attacker-reachable', 'operator-controlled', 'api-contract', 'entry-parameter', 'data-input', 'unattributed']);
27
27
 
28
28
  const OPERATOR = new Set(['cli', 'env']);
29
- const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network']);
29
+ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network', 'upload']);
30
30
  const DATA = new Set(['stdin', 'file-read']);
31
31
  const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
32
+ // CWE-918 is deliberately NOT a wrapper family: `fetch url = parseRequest url >>= httpNoBody` is pinned as a finding in
33
+ // test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets), the same way path
34
+ // wrappers are kept. unseen-v4 labels that shape safe when the URL is configuration; that disagreement is recorded, not decided here.
32
35
  const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88']);
33
36
  const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText)$/;
34
37
  const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
@@ -176,7 +179,8 @@ function passThroughVerdict(perFile, f) {
176
179
  if (!weak.size) return null;
177
180
  const own = new Set(Array.isArray(analysed.params) ? analysed.params : []);
178
181
  const res = localResolver(analysed);
179
- return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') seen.add(e.name); for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
182
+ // (a name bound once in the function stands for its definition here too, so `req <- parseRequest url; httpLbs req m` reaches `url`; a record update is a union of base and update)
183
+ return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') { seen.add(e.name); const r = res(e); if (r && r !== e) walkIds(r, d + 1); } for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements', 'branches']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
180
184
  }
181
185
  // the sink is in a callee
182
186
  const own = new Set(Array.isArray(sink.fn.params) ? sink.fn.params : []);