@clear-capabilities/agentic-security-scanner 0.195.14 → 0.195.15

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
- bb2980af58a2c5183cbe0c14efd1d611922784ee2f051111e2f3bf03d1846e79 agentic-security.mjs
1
+ 3d395cf95358af8a372a685bf6119f46dac36e3502237a525812949a53776c3c 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.14",
3
+ "version": "0.195.15",
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,7 +93,7 @@
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-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-quasiquote-interp.test.js test/haskell/haskell-record-dot.test.js test/haskell/haskell-global-cell.test.js test/haskell/haskell-syntax-extensions.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-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-quasiquote-interp.test.js test/haskell/haskell-record-dot.test.js test/haskell/haskell-global-cell.test.js test/haskell/haskell-syntax-extensions.test.js test/haskell/haskell-rule-precision.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/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
98
98
  "test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
99
99
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
@@ -66,6 +66,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
66
66
  - **Guards are recognised by what they do.** `haskell-guards.js`: a project function is an HTML escaper when it (or its local helpers) spells out the entities for `<`, `>` and `&` (`htmlEscapers`; never by name), and it neutralises a CWE-79 flow only when EVERY use of the value in the sink argument is under such a call (`allUsesEscaped`); a host allow-list may be a named constant and may sit in an `&&`; `sh -c SCRIPT arg0 arg1` is shell injection only when the SCRIPT is built from the value, and tainted positional parameters are data when the constant script expands them only inside double quotes after `--` (`quotedPositionals`, which decodes the Haskell string escapes the IR keeps raw).
67
67
  - **Handlers named by the framework's convention are routes.** `haskell-web.js` adds `getFooR`/`postFooR`/`handleFooR` functions whose signature mentions `Handler` in a module importing Yesod when no route table in an analysed file names them (the table usually lives in `config/routes`). Scotty's rejection order is judged by `inlineOrder` as well as by line: a `case` lists the success branch first, so `status 40x >> finish` in the LAST alternative guards it (a bare `status` with no halting call does not).
68
68
  - **A project definition of a modelled name keeps the registry's meaning (since 0.195.11).** `haskell-ir.js` `applyVar`/`lowerVarValue`: a resolution to a project `def` whose module-qualified name `isKnownApi` (the project IS the framework, or vendors it) is lowered as the LIBRARY call (`hs.status: 'external'`, `hs.modelledDefinition: qid`) and the definition is still referenced (`noteRef(..., 'modelled-definition')`, so it is never an uncalled entry point). Before this, IHP's own `IHP.Controller.Param.param` shadowed the catalog when IHP scanned itself, and a request parameter reaching `Process.system` through a helper was not reported (four lost true positives on the fresh set). A test stub named like a catalog module gets the same treatment, which is why the html-text remediation test keeps its scotty stub out of the scan. Pinned both ways in `test/haskell/haskell-taint-shapes.test.js`, with the three shapes below.
69
+ - **Rule precision from semantics (since 0.195.15; `test/haskell/haskell-rule-precision.test.js`).** `haskell-security-rules.js`: a partial list function (`NONEMPTY_TOTAL`) on a list that is a cons, a non-empty literal, or a concatenation with one of those (`nonEmptyList`) is total and not reported; the conditions a function tests include the first argument of `when`/`unless`/`guard` (`COND_COMBINATOR`), not only `if` nodes; a comparison of a list's LENGTH with a number (`case length xs of 3`, `length xs == 2`; `LENGTH_CALL`, `lengthVars` for the scratch variable the IR puts a case scrutinee in) tests the list. `hash x :: Digest MD5` / `hashlazy x :: Digest SHA1`: `haskell-syntax.js` keeps a `Digest <algo>` annotation as a `typed` node (`DIGEST_TYPE`), `haskell-ir.js` returns the inner call stamped `hs.digest`, and `hs-weak-hash` treats it like `hashWith MD5`. `hs-unsafe-io-boundary` is already lowered to `info` for every site by `engine.js` (`originalSeverity` kept), so no shape rule was added for it.
69
70
  - **Record dot, labels, global cells, lambda-form functions, more syntax (since 0.195.14).** `haskell-syntax.js` reads `r.field` / `(e).field` / `(.field)` as a field selection (`sel` token, `RECORD_DOT_PRAGMA`; the IR lowers `field r` to a member read of a known record field) and `#field` as a label value (`LABELS_PRAGMA`; the IR lowers it to a literal, so IHP's `get #f r` / `set #f v r` are library calls carrying the record's and the value's taint, not a field selection: a stated limit); both only under the LANGUAGE pragma. `\cases` (`lamcases`), `mdo` (a `do` under `RECDO_PRAGMA`; `rec` blocks are not read), GADT `data T where` constructors (names, record fields, arity by arrows) and `pattern P ..` (a `patsyn` declaration, not a function named `pattern`) parse. `haskell-ir.js` `lambdaBodied` rewrites `f = \x -> ..`, `f = \case ..`, `f = \cases ..` into `f x = ..` at the top level and in `let`/`where` (a call then resolves to the function instead of applying a closure value). `cellBodyOf` recognises a module-level cell (`name = unsafePerformIO (newIORef ..)`, MVar/TVar/STRef alike) and `lowerCellOp` names it `$cell:<qid>` for the whole project (a read is that ident with `hs.globalCell`, a write an assignment to it); `dataflow/engine.js` `_cellSeeds` records a cell any function assigned tainted data to and `_unionAnnotationTaint` seeds it at the entry of every Haskell function, the fixed-point pre-pass re-running when the set grows (flow-insensitive; the root is not carried across, so the trust verdict of the reader is `unattributed`). `aliasBodyOf` accepts a partial application of a library function to names or literals (`remember = writeIORef lastName`), with the argument names checked for shadowing. `haskell-models.js`: typed-process `proc` (CWE-78 program, CWE-88 arguments), esqueleto `rawSql`/`rawExecute`, optparse-applicative `execParser`/`customExecParser` as a `cli` source, versions recorded in `HS_PACKAGES`. Pinned in `test/haskell/haskell-{record-dot,global-cell,syntax-extensions}.test.js`.
70
71
  - **String-interpolating quasi-quoters (since 0.195.14; `test/haskell/haskell-quasiquote-interp.test.js`).** `haskell-parser.js` `INTERP_QUOTERS` maps a module to its quoters and interpolation syntax (`hash` `#{e}`: Data.String.Interpolate, Data.String.Interpolate.IsString, Text.Shakespeare.Text; `dollar` `$name`/`${e}`: NeatInterpolation; `brace` `{e}`/`{e:spec}`: PyF); `interpolationsOf` reads them (escapes `$$`, `{{`; a format spec after a top-level `:` is dropped). An accepted quote is rewritten in place, same length and newlines, as `(iQ (e1) (e2))` (kind `quasiquote-interp`, non-opaque, assumption `upstream-quoter`); a literal-only body stays `quasiquote-inert`; an unimported module or an unknown quoter stays `quasiquote` (opaque). `haskell-ir.js` `applyVar` lowers an UNBOUND `iQ` application to a concatenation (`binary +`, `hs.concat`, `hs.quasiquote`), so the taint engine follows it and a sanitizer inside an interpolation is on the path. The template text is not modelled (no markup literal for `htmlSkeleton` sinks).
71
72
  - **The discovery set and two layout rules (since 0.195.13; `bench/dag/discovery-haskell`, pinned in `test/haskell/haskell-discovery-shapes.test.js`).** Six projects the engine had never been run on (hoogle, the wai and servant monorepos, mighttpd2, postgres-websockets, the yesod scaffold), scanned once and every flagged site read and labelled (`labels.json`, `USE-LOG.md`): a DEVELOPMENT set, not the precision gate's fresh set and not the sealed set. Hoogle's request handler was dropped by two layout rules, fixed in BOTH parsers: a `where` that starts a line at an implicit block's own indent closes the block (Haskell's parse-error(t) rule; `haskell-parser.js` `openBlock`, as `haskell-syntax.js` `blockAfter` already did), and a `;` separates the items of an implicit block only on a line that starts at the block's indent and opened no nested block (`do`, `of`, `let`, `where`, `\case`) earlier on that line (`splitItems` in both; `s <- act pay; pure s` on one line of a nested `do`, `remod t = do (a,_) <- f t; pure a` in a `where`). The route-contract detector (`posture/api-contract.js` `contractScope`) now scopes a contract file to the package that holds it, which is what turned a servant monorepo's two example `swagger.json` files into 78 Haskell route findings.
@@ -599,7 +599,14 @@ class FnLowerer {
599
599
  // `read s :: Int`: the value is a number, so it is a clean constant for the taint engine. The lowered inner expression rides on `hs.inner`, which the rule and web
600
600
  // analyses read (an unsafe coercion in it, the id it names), exactly as they read `hs.selector`. (A sanitizer call would be wrong here: the engine's sanitizer label is per expression, so a
601
601
  // number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
602
- case 'typed': return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
602
+ case 'typed': {
603
+ if (e.digest) { // `hash x :: Digest MD5`: the call itself, stamped with the algorithm the annotation selects
604
+ const inner = this.lowerExpr(e.e, ln);
605
+ if (inner && inner.kind === 'call') inner.hs = { ...(inner.hs || {}), digest: e.digest };
606
+ return inner;
607
+ }
608
+ return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
609
+ }
603
610
  case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
604
611
  case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
605
612
  case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
@@ -49,6 +49,10 @@ export const BOUNDARIES = Object.freeze({
49
49
 
50
50
  const WEAK_HASH_CALLS = new Set(['Crypto.Hash.MD5.hash', 'Crypto.Hash.MD5.hashlazy', 'Crypto.Hash.SHA1.hash', 'Crypto.Hash.SHA1.hashlazy', 'Data.Digest.Pure.MD5.md5', 'Data.Digest.Pure.MD5.md5DigestBytes', 'Data.Digest.Pure.SHA.sha1', 'Data.Digest.Pure.SHA.sha1Incremental']);
51
51
  const WEAK_ALGO_CONS = new Set(['MD5', 'MD4', 'MD2', 'SHA1']);
52
+ // Partial list functions that are total on a list with at least one element (see nonEmptyList), and the length calls a size test may name.
53
+ const NONEMPTY_TOTAL = /(?:^|\.)(?:head|last|init|tail|maximum|minimum|foldr1|foldl1|maximumBy|minimumBy|cycle)$/;
54
+ const LENGTH_CALL = /(?:^|\.)(?:length|genericLength|size)$/;
55
+ const COND_COMBINATOR = /(?:^|\.)(?:when|unless|guard)$/;
52
56
  const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.Hash.hashlazy', 'Crypto.Hash.hashWith', 'Crypto.Hash.SHA256.hash', 'Crypto.Hash.SHA256.hashlazy', 'Crypto.Hash.SHA512.hash', 'Crypto.Hash.SHA512.hashlazy', 'Data.Digest.Pure.SHA.sha256', 'Data.Digest.Pure.SHA.sha512']);
53
57
  const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
54
58
  const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
@@ -547,7 +551,40 @@ export function analyzeHaskellRules(files, opts = {}) {
547
551
  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);
548
552
  }
549
553
  }
554
+ // `case length xs of 3 -> ..`, `if length xs == 3`: a comparison of the list's LENGTH with a number is a test of the list (the IR lowers the
555
+ // case alternative to an equality on the scrutinee variable, so the length call sits behind an assignment).
556
+ const lengthVars = new Set();
557
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && n.source.kind === 'call' && typeof n.source.callee === 'string' && LENGTH_CALL.test(n.source.callee)) lengthVars.add(n.target);
558
+ // The conditions this function tests: every `if`, plus the first argument of `when` / `unless` / `guard` wherever the call sits
559
+ // (`when (length xs > 2) (putStrLn (xs !! 2))` is a guard written as a combinator, not an `if` node).
560
+ const condExprs = [];
561
+ for (const n of fnNodes(fn)) {
562
+ if (n.kind === 'if' && n.cond) condExprs.push(n.cond);
563
+ for (const ex of [n.value, n.source, ...(n.args || [])]) if (ex) for (const x of walk(ex)) if (x.kind === 'call' && typeof x.callee === 'string' && COND_COMBINATOR.test(x.callee) && x.args && x.args[0]) condExprs.push(x.args[0]);
564
+ }
565
+ for (const cond of condExprs) {
566
+ for (const x of walk(cond)) {
567
+ if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) {
568
+ for (const nm of directNames(x)) testedVars.add(nm);
569
+ for (const s of splitsIn(x)) testedSplits.add(s);
570
+ // `case length xs of 3 -> ..`: the match is on a scratch variable holding the length; the list behind it is what was tested
571
+ for (const a of x.args || []) if (a && a.kind === 'ident' && lengthVars.has(a.name)) for (const nm of assignSources.get(a.name) || []) testedVars.add(nm);
572
+ }
573
+ if (x.kind !== 'binary' || !['==', '/=', '!='].includes(x.op)) continue;
574
+ for (const side of [x.left, x.right]) {
575
+ if (side && side.kind === 'call' && typeof side.callee === 'string' && LENGTH_CALL.test(side.callee)) for (const nm of directNames(side)) testedVars.add(nm);
576
+ if (side && side.kind === 'ident' && lengthVars.has(side.name)) for (const nm of assignSources.get(side.name) || []) testedVars.add(nm);
577
+ }
578
+ }
579
+ }
550
580
  const testedAll = withOrigins(testedVars);
581
+ // `head (x : xs)`, `last (xs ++ [x])`, `head [a, b]`: the list has at least one element, so the call is total (real code: xmonad's `last (["Root"] ++ ..)`).
582
+ const nonEmptyList = (e) => {
583
+ if (!e || typeof e !== 'object') return false;
584
+ if (e.kind === 'array') return !!(e.hs && e.hs.cons) || (!(e.hs && (e.hs.tuple || e.hs.con || e.hs.range)) && (e.elements || []).length > 0);
585
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return nonEmptyList(e.left) || nonEmptyList(e.right);
586
+ return false;
587
+ };
551
588
  const isTested = (args) => {
552
589
  if (args.some((a) => splitsIn(a).some((s) => testedSplits.has(s)))) return true;
553
590
  const mine = withOrigins([...args.flatMap((a) => [...directNames(a)])]);
@@ -686,7 +723,9 @@ export function analyzeHaskellRules(files, opts = {}) {
686
723
  // a signing/encryption secret that is a constant in the source
687
724
  if (SECRET_KEY_CALLS.has(c) && args[0] && isLiteralLike(args[0]) && !(args[0].kind === 'literal' && String(args[0].value).length < 8)) emit('hs-hardcoded-secret', file, line, { detail: `${c} given a constant`, confidence: 0.85, evidence: { call: c } });
688
725
  // weak hash
689
- if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
726
+ // `hash x :: Digest MD5` / `hashlazy x :: Digest SHA1`: the digest is chosen by the type annotation (haskell-syntax.js keeps a
727
+ // `Digest <algo>` annotation as a `typed` node; haskell-ir.js stamps `hs.digest` on the call).
728
+ if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value)) || (ANY_HASH_CALLS.has(c) && e.hs && e.hs.digest && WEAK_ALGO_CONS.has(e.hs.digest))) {
690
729
  const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
691
730
  // MD5 and SHA-1 are broken for collision and preimage resistance, which only matters where an attacker gains from a
692
731
  // collision: a digest that is VERIFIED (compared with an expected value), that authenticates or signs, that hashes a
@@ -732,7 +771,7 @@ export function analyzeHaskellRules(files, opts = {}) {
732
771
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
733
772
  }
734
773
  // a partial function applied to caller text: one malformed value raises an uncaught exception
735
- if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c }, inlineCtx: e.hs && e.hs.inlineCtx });
774
+ if (PARTIALS.has(c) && !(NONEMPTY_TOTAL.test(c) && nonEmptyList(args[0])) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c }, inlineCtx: e.hs && e.hs.inlineCtx });
736
775
  // a session/token cookie assembled without HttpOnly or Secure
737
776
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
738
777
  // unbounded body
@@ -23,6 +23,7 @@ const TH_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bTemplateHaskell\b/;
23
23
  const RECORD_DOT_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bOverloadedRecordDot\b/;
24
24
  const LABELS_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bOverloadedLabels\b/;
25
25
  const RECDO_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bRecursiveDo\b/;
26
+ const DIGEST_TYPE = /^\(?\s*Digest\s+([A-Za-z][A-Za-z0-9_]*)\s*\)?$/;
26
27
  const RESERVED_OPS = new Set(['=', '|', '->', '<-', '::', '=>', '\\', '@', '~', '..']);
27
28
  const SYM = new Set('!#$%&*+./<=>?@\\^|-~:'.split(''));
28
29
  const OPEN = { '(': ')', '[': ']', '{': '}' };
@@ -459,6 +460,8 @@ export function parseSyntax(src, opts = {}) {
459
460
  // lowers to a constant (no caller text survives such a type). Every other annotation is dropped as before.
460
461
  const typeText = T.slice(from, pos).map((x) => x.v).join(' ');
461
462
  if (NUMERIC_TYPE.test(typeText)) e = { t: 'typed', e, type: typeText, line: e && e.line };
463
+ // `hash x :: Digest MD5`: the annotation chooses the digest algorithm (type-directed selection); kept for the weak-hash rule.
464
+ else { const m = DIGEST_TYPE.exec(typeText); if (m) e = { t: 'typed', e, type: typeText, digest: m[1], line: e && e.line }; }
462
465
  }
463
466
  return e;
464
467
  } finally { depth--; }