@clear-capabilities/agentic-security-scanner 0.195.7 → 0.195.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/{966315fcdef8a956d535.json → 851efefbfcfa2a9aa691.json} +4 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/language/CLAUDE.md +10 -2
- package/src/language/haskell-guards.js +179 -8
- package/src/language/haskell-ir.js +11 -4
- package/src/language/haskell-models.js +12 -0
- package/src/language/haskell-parser.js +63 -1
- package/src/language/haskell-security-rules.js +213 -28
- package/src/language/haskell-trust.js +23 -7
- package/src/language/haskell-web.js +122 -12
- package/src/language/nix-script-taint.js +13 -0
- package/src/language/nix-secrets.js +14 -0
- package/src/language/nixos-hardening.js +20 -3
- package/src/lineage/source-registry.js +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
19dc2c52e129ad724c2604371ec085abff39ecbd70a9a48374e00a84668ba9fc agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.195.
|
|
3
|
+
"version": "0.195.9",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -93,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",
|
|
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",
|
|
96
|
+
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
97
|
+
"test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
|
|
98
|
+
"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",
|
|
@@ -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
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -66,7 +66,7 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
66
66
|
- **Guards are recognised by what they do.** `haskell-guards.js`: a project function is an HTML escaper when it (or its local helpers) spells out the entities for `<`, `>` and `&` (`htmlEscapers`; never by name), and it neutralises a CWE-79 flow only when EVERY use of the value in the sink argument is under such a call (`allUsesEscaped`); a host allow-list may be a named constant and may sit in an `&&`; `sh -c SCRIPT arg0 arg1` is shell injection only when the SCRIPT is built from the value, and tainted positional parameters are data when the constant script expands them only inside double quotes after `--` (`quotedPositionals`, which decodes the Haskell string escapes the IR keeps raw).
|
|
67
67
|
- **Handlers named by the framework's convention are routes.** `haskell-web.js` adds `getFooR`/`postFooR`/`handleFooR` functions whose signature mentions `Handler` in a module importing Yesod when no route table in an analysed file names them (the table usually lives in `config/routes`). Scotty's rejection order is judged by `inlineOrder` as well as by line: a `case` lists the success branch first, so `status 40x >> finish` in the LAST alternative guards it (a bare `status` with no halting call does not).
|
|
68
68
|
- **IR idioms that must keep the action.** `m >>= \_ -> k` and `f <$> m` RUN `m` even when the lambda ignores its result (`betaReduce`, `via`); `maybe d f m` / `either f g e` are a two-way branch on the scrutinee (so the scrutinee is still examined); `\case` applied by `>>=` is a case run in place; `foldM`, `zipWithM_`, `uncurry`, `fst`/`snd`/`const` of a written-out tuple, `filter isAlphaNum`, and a let-bound lambda (`let go = \x -> ..`, treated as `go x = ..`) are lowered so their sinks are reached. The taint engine (`dataflow/engine.js`) is Haskell-scoped for three of these: a `union` handed to a combinator or bound as a statement is a sink site in each branch (`_nestedSinkFindings`, the `assign` case), and a `member` whose object is a call (the elements of `words s`) carries its object's taint.
|
|
69
|
-
- **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process, CWE-78/88
|
|
69
|
+
- **A Haskell taint finding says who controls its input.** `haskell-trust.js` `applyTrustBoundary` (run in `engine.js` right after `applyHaskellGuards`) reads the origins the engine recorded (`sourceRoots`, or the evidence chain) and stamps `trustBoundary {verdict, reason, role, roots}`. Verdicts: `attacker-reachable` (request, socket, header, cookie, remote response; never lowered), `operator-controlled` (cli/env roots, and file-read roots in a command-line program; also an EMPTY origin in a command-line program, marked `inferred: true`), `api-contract` (a bare/whole-handoff parameter of an uncalled exported function reaching a process or, since 0.195.9, a path operation: CWE-78/88/22), `entry-parameter`, `data-input`, `unattributed`. `operator-controlled` and `api-contract` are lowered to `info` (never dropped; `originalSeverity`, `controls`, `guardNote` kept), the same convention `haskell-guards.js` uses. **Standard input is covered by the command-line rule only for a path operation** (since 0.195.9: a command-line program reading a file NAME from its standard input is the operator naming a file, 14 of the 29 flagged path sites on the real-code sets; `getLine >>= callCommand` in `main` stays reported, pinned in `test/haskell/haskell-trust-boundary.test.js`). **Path wrappers became `api-contract` in 0.195.9**: `withFile name WriteMode` on a caller-named file is the vulnerable shape of three synthetic development splits and of no real-code true positive, while `renderFile path = responseFile .. path` was a flagged false positive on every real project that had it; the parameter under a fixed directory (`"/srv/" ++ name`) is still `entry-parameter` and still a finding. That is a decision against the synthetic labels, taken on the real-code sets; the development-set numbers that moved are recorded in the changelog. The role is `fn.hs.entryRole` from `assignEntryRoles` in `haskell-ir.js`: `cli` (reached only from `main`s whose reachable modules import no server framework or socket library), `service`, or `library` (no `main` reaches it; an incomplete call graph therefore yields `library`, never `cli`). Pinned both ways in `test/haskell/haskell-trust-boundary.test.js`.
|
|
70
70
|
- **Origins are recorded, not guessed** (`dataflow/engine.js`). The engine records a source only against the variable a source call was assigned to; three additions make an evidence chain begin at the real origin: a value derived from a source inherits its records (`_inheritSources`), a callee analysed under an entry-taint state names its tainted parameters with placeholder records (`_paramPlaceholders`) that `_mergeSummaryFindings` resolves to the sources of the arguments at the merging call site (`_resolveParams`), and a function return keeps the sources behind it (`returnSources`, followed through `from` into `sourceRoots`). A parameter annotated as a source (`hs:caller-controlled`, `hs:handler-param:*`) is recorded too (`matchAnnotationParamSources`, `source.kind: 'parameter'` so stable ids do not change). This is language-neutral (`test/dataflow-source-attribution.test.js` pins it on JavaScript). `System.IO.hGetContents`/`hGetLine`/`hGetChar` (and the Text/ByteString forms) are modelled as `file-read`, not `stdin`: they read whatever handle they are given.
|
|
71
71
|
- **Reachability must survive platform variants.** An import two modules both satisfy (the Posix and Windows variants behind an undecided CPP conditional) resolves to `unknown: ambiguous-import` with its `candidates`; `haskell-ir.js` records each candidate as a reference of the caller, so neither is an "uncalled entry point" whose parameters are caller-controlled. Without it every function of the platform module that was not chosen was reported.
|
|
72
72
|
- **"No caller can exist" is a verdict with preconditions, and `null !== false`.** `haskell-ir.js` `projectReachability` stamps `fn.hs.reachability {status, rootKind, holeReason}` (`entrypoint-reachable | unreachable-in-project | external-api | unknown`); `haskell-reachability.js` `applyHaskellReachability` copies it onto each IR-TAINT finding as `f.reachability {status, reason, premise, roots, holeReason}`, a record SEPARATE from `trustBoundary` (that one says who controls the input, this one whether a caller can exist; either may be `unknown`). `unreachable-in-project` needs ALL of: `completeScan` (runScan/engine thread it; a changed-file, PR, subdirectory or caller-supplied scan is a subset, under which every export looks uncalled), no `policy-excluded`/`size-skipped` Haskell, `entrypointCount > 0` (a `main`; the sealed single-file corpora have none, so the rule is structurally inert on them), `exposedModules !== null` (`haskell-manifests.js` `exposedModuleIndex`: cabal `exposed-modules`, or hpack `exposed-modules`; an hpack library that exposes everything, `-main-is`, `-F/-pgmF`, a plugin, a non-test `build-tool-depends`, a foreign library, Backpack signatures are all `null`), the function's module not exposed (re-exports count: `reexportClosure`), not an instance method (instance methods are ROOTS, so what they call is reached, but their own verdict is `unknown/instance-method`), and not reached from a root. Roots: every `main`, framework-handler-typed functions (`hs.entryHandler`), `foreign export`s, instance methods, exported functions of exposed modules, and `withheld-caller` roots. Gaps are bounded where they can be and global where they cannot: Template Haskell/quasi-quotes the allow-lists did not accept (`preprocessForSyntaxInfo(...).opaque`), a foreign export, the function cap, an unresolved name nothing defines = global holes; a declaration the parser withheld or an expression it could not lower makes that module's own functions and the exported functions of every module it can name (re-export closure) roots, because withheld code can call only what its module can see. A call through a function VALUE is not a gap (its callee was mentioned in value position, which is a reference the walk follows). Holes are `unknown`, never `unreachable`; the order of `holeReason` is `HOLE_PRIORITY`. Pinned in `test/haskell/haskell-reachability.test.js`; the pin at `haskell-trust-boundary.test.js` (a lone `Svc.hs`) asserts `unknown/no-entrypoint`. **Severity:** only `unreachable-in-project` with `premise === 'caller-controlled-parameter'` (every recorded origin is the weak parameter label) is lowered to `info` (`lowerFinding`, guard `_reachLowered`, composes with `_trustLowered`); a finding with a recorded read keeps its severity and may only rank lower (`posture/relevance.js` `languageVerdict`). The verdict is recomputed in place per context (`buildHaskellIR(files, {manifests, completeScan, scanHoles})`; the BUILD cache is keyed by sources alone and the manifests are parsed once per context, counted by `EXPOSED_INDEX_WORK`). `fn.hs.reachedBy` and `fn.hs.paramEntry` record how a function was reached and what its parameters were before `paramAnnotations` is removed; the removal itself is unchanged. `callersUnknown(fn)` (`haskell-security-rules.js`) reads the verdict for the partial-function and unbounded-read rules; `external-api` is deliberately answered as before (turning it on added seven flagged sites and no true positive on the development set). Nothing reads a comment, a name or a path. Measured effect on real code is in `bench/dag/dev-haskell/results/README.md` (it changed no severity there) and the recorded baseline is gated by `bench/haskell-precision`.
|
|
@@ -141,7 +141,15 @@ 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
|
|
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 and unseen-v5 are DEVELOPMENT sets since 2026-11-01** and **unseen-v6 is the Haskell generalisation measure; unseen-nix-v1 is the first Nix one** (the builder takes `eco`, a split's manifest records `ecosystem`, and a Nix form's marker is `#@@`). The lesson of 0.195.8 is the reason the discipline exists: the unseen-v4 misses were fixed (F1 64.7% to 94.9% on v4) and unseen-v5 moved from F1 63.8% to 65.7%, so most of those fixes answered v4's shapes, not Haskell's. 0.195.9 read unseen-v5 after freezing unseen-v6: v5 went from F1 65.7% to 84.1% and v6 from 62.9% to 72.5%, so this time part of it generalised (the Servant pairing from the end, the fixed-host request judgement, the credential header names, the harness's proven-clean rule). The Nix development set (unseen-v3) went to 100% and unseen-nix-v1 stayed at F1 71.0%: none of that generalised. Measure a new split before reading the old one; quote both numbers.
|
|
147
|
+
|
|
148
|
+
**The detection suite scores `actionable` findings (since 0.195.9):** a finding at `info`, or one the engine PROVED clean or infeasible (`proof.verdict` `proven-*`: a catalog sanitizer of the sink's family on the path, a dominating guard), is not a detection, the same rule `bench/language-support/lib.mjs` `actionable` already applied elsewhere. Before, `renderHtml (toHtml name)` into a Scotty `html` sink was scored a false positive although the proof gate had labelled it proven-clean. **Disclosure:** while the real-code precision work of 0.195.9 was being investigated, two frozen-holdout path-traversal sources (shapes s0 and s1, both a parameter joined under a fixed directory) were printed and read; no rule was changed to them (both are still findings), but the holdout has been seen by the person who changed the path-wrapper policy, and a reader weighing the holdout's independence should know that.
|
|
149
|
+
|
|
150
|
+
Rules added for the unseen-v5 shapes, pinned both ways in `test/haskell/haskell-unseen-v5-shapes.test.js`: a Servant handler pairs the API's reads with its LAST parameters (`orderServer conn ref`; `haskell-ir.js` `servantEndpointParams` paired from the front and annotated the closure parameter); a `Set-Cookie` header handed to `setHeader`/`addHeader` is judged like a hand-written tuple; a project helper the IR inlined at its call site (`hs.inlinedFrom`) reports a fast password hash or a credential log once, in the helper (`markInlined`, `inlineCtx`, the filter before the return); mwc-random and splitmix are predictable generators (`System.Random.MWC` in `HS_RULE_APIS` so the IR qualifies the names); a function that MAKES a key (`newKey`, `generateApiKey`) is security-shaped, and `code`/`pin`/`otp` is a security value in a function that talks about a reset or a verification (`CONTEXT_CODE_VAR`, `CODE_CONTEXT`); an authentication endpoint (`AUTH_ENDPOINT_PATH`/`_NAME`) is never missing authentication; `X-Session`, `X-Token` and similar project header names are credentials (`CREDENTIAL_HEADER`); a yes/no answer about a credential (`accepted <- checkCredentials attempt`, a comparison, a `logical`, a helper whose returns are such: `returnsYesNo`) carries no credential text, a record built from one logged through another field (`attemptUser attempt`, a `member` of it) is that field, the record logged whole is the credential, and `lastFour`/`takeEnd`/`fingerprint` are masking helpers by name; `pushLogStr(Ln)` is a log sink. In `haskell-guards.js`: a CWE-918 flow whose URL starts from a literal (or a constant bound to one) naming the scheme, the host and a `/`, or whose request is built by http-conduit's modifiers over such a base, is lowered to `info` with `guardNote.kind: fixed-host-request` (`fixedHostRequest`; a record update that sets a host field, or a literal that stops inside the host, is not); `judgeFinding` locates the sink in the CALLEE when the finding was found from a caller (the canonicalise-and-prefix guard in `persistDraft` refutes the flow found from `saveDraft`), and the XSS escaper judgement knows the catalog's HTML escapers and a project helper that uses the parameter receiving the tainted value only under an escaper. Nix (`test/nix/nix-development-shapes.test.js`): `AmbientCapabilities` as one string, `Defaults !authenticate`, a `*File` option handed a `writeText` of a literal (`nix-secret-plaintext`, exposure `store`), a TLS key path on any enabled service (`sslKey`, `sslServerKey`, `keyFile`, ...), and one script-taint finding per rule, line and origin class.
|
|
151
|
+
|
|
152
|
+
Rules added for the unseen-v4 shapes, each pinned both ways in `test/haskell/haskell-unseen-v4-shapes.test.js`: a value READ from the request inside a function (`param`, `strictRequestBody`, `requestHeaders`, any `REQUEST_PROVENANCE` source) is caller text for the partial-function, allocation and bounding rules whatever the function's parameters are; a presence test does not cover a partial call on a value SPLIT or parsed from the tested one (`SPLITTER`), while the same split of the same value does; a relational test on a count bounds the allocation it sizes; a `System.Random` value that reaches the result of a security-shaped function is predictable randomness; a project record parameter with a credential or card field logged whole (`show card`) is sensitive logging, through a masking helper by name it is not; wai-extra `requestSizeCheck` bounds the body read. In `haskell-web.js`: Servant closure arguments before the endpoint's values are paired from the end; a container lookup by the request id (`M.lookup`) is the object selection for BOLA only; BOLA without an authenticated principal needs either a WRITE to the selected object or OWNER EVIDENCE (`OWNER_COL`: a statement names an owner column its WHERE clause does not use; `OWNER_FIELD`: the record the handler works with has an owner field it never reads, where "works with" means another field of that record is read); a Scotty handler typed `ActionM` but registered elsewhere is a route with method `UNKNOWN` (ownership only, never a missing-auth finding); a Yesod `isAuthorized` clause that can answer `AuthenticationRequired` guards; a privileged path whose handler calls into unseen code is a lower-severity missing-auth finding, but a plain write with no visible operation is NOT (23 echo, upload-demo and health-check routes on four real projects). Taint: `Network.Wai.Parse.fileName` is an `upload` source; `Network.Wai.responseLBS`/`responseBuilder` are CWE-79 sinks judged by the header argument (`waiHtmlContext`: `text/html` keeps, another content type lowers to `info`, opaque headers keep with a stated uncertainty); the HTML-escaper judgement follows local bindings, and a definition with no value in it (a read) is the raw origin; a Shakespeare quasi-quote with interpolations is rewritten in place to an application of its interpolated expressions (`hamletInterpolations`, boundary kind `quasiquote-hamlet`, never opaque) so `#{preEscapedToHtml body}` is a sink site and `#{body}` is not. Two shapes stay as they were on purpose: `getArgs` reaching a shell is `operator-controlled` (info), and `fetch url = parseRequest url >>= httpNoBody` stays a finding (a URL wrapper is not an `api-contract`); unseen-v4 labels both the other way, and that is recorded, not decided.
|
|
145
153
|
|
|
146
154
|
The support registry is measured **once per promotion** on a frozen holdout (`bench/language-support/promote.mjs`); never tune
|
|
147
155
|
against the holdout, and any further holdout run counts as repeated use. A change to the engine, the corpus or a label changes the
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// has the prefix. A weak guard is reported as a note on the finding and does not refute it.
|
|
18
18
|
|
|
19
19
|
const CANON_CALLS = new Set(['System.Directory.canonicalizePath', 'System.Directory.makeAbsolute', 'System.FilePath.normalise']);
|
|
20
|
+
import { HS_SANITIZERS } from './haskell-models.js';
|
|
21
|
+
// the catalog's HTML escapers (blaze, lucid): the same list the taint catalog is generated from
|
|
22
|
+
const CATALOG_HTML_ESCAPERS = new Set(HS_SANITIZERS.filter((s) => (s.appliesTo || []).includes('xss')).map((s) => `${s.module}.${s.name}`));
|
|
20
23
|
const EXIT_CALLS = new Set(['Control.Exception.throwIO', 'Control.Exception.throw', 'System.Exit.die', 'System.Exit.exitFailure', 'System.Exit.exitWith', 'throwIO', 'throw', 'die', 'exitFailure', 'exitWith', 'error', 'fail', 'ioError', 'Control.Monad.Fail.fail']);
|
|
21
24
|
const WHEN = new Set(['Control.Monad.when', 'when']);
|
|
22
25
|
const UNLESS = new Set(['Control.Monad.unless', 'unless']);
|
|
@@ -319,8 +322,10 @@ function allUsesEscaped(fn, e, escapers, depth = 0) {
|
|
|
319
322
|
if (e.kind === 'call' && escapers.has(e.callee)) return true; // whatever it receives is escaped
|
|
320
323
|
if (e.kind === 'ident') {
|
|
321
324
|
if (e.hs && e.hs.functionRef) return true;
|
|
325
|
+
// 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
|
|
326
|
+
// 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
327
|
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));
|
|
328
|
+
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
329
|
}
|
|
325
330
|
for (const [, c] of exprChildren(e)) if (!allUsesEscaped(fn, c, escapers, depth + 1)) return false;
|
|
326
331
|
return true;
|
|
@@ -334,10 +339,15 @@ export function judgeFinding(f, perFile) {
|
|
|
334
339
|
const fam = FAMILY_BY_CWE[f.cwe];
|
|
335
340
|
const ir = perFile && perFile[f.file];
|
|
336
341
|
if (!fam || !ir || !f._funcQid || !f.callee) return { status: 'none' };
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
342
|
+
// A finding found while analysing a CALLER (`_funcQid`) is located at the callee's line: the sink function is the one in the
|
|
343
|
+
// finding's file that has the call at that line (the caller first, then the others), the way haskell-trust.js locates it.
|
|
344
|
+
const direct = ir.functions.find((x) => x.qid === f._funcQid);
|
|
345
|
+
let fn = null; let sites = [];
|
|
346
|
+
for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
|
|
347
|
+
const s = findSites(cand, f.callee, f.line);
|
|
348
|
+
if (s.length) { fn = cand; sites = s; break; }
|
|
349
|
+
}
|
|
350
|
+
if (!fn || !sites.length) return { status: 'none' };
|
|
341
351
|
let weak = null;
|
|
342
352
|
let first = null;
|
|
343
353
|
for (const site of sites) {
|
|
@@ -352,11 +362,52 @@ export function judgeFinding(f, perFile) {
|
|
|
352
362
|
const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
|
|
353
363
|
let refuted = null;
|
|
354
364
|
if (fam === 'xss') {
|
|
355
|
-
|
|
365
|
+
// the project's own escapers plus the catalog's HTML escapers (blaze/lucid `toHtml`, `text`, ...): the taint walk records the latter on
|
|
366
|
+
// the path for the proof gate, but a sink argument built by a HELPER (`html (renderHtml (greeting name))`) is judged here, where the
|
|
367
|
+
// helper's body can be read: a helper that uses the parameter receiving the tainted value only under an escaper escapes it
|
|
368
|
+
const escapers = new Set([...htmlEscapers(perFile), ...CATALOG_HTML_ESCAPERS]);
|
|
369
|
+
const helperByName = (callee) => ir.functions.find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`)) || null;
|
|
370
|
+
const paramEscaped = (helper, param, depth) => {
|
|
371
|
+
if (depth > 2) return false;
|
|
372
|
+
const under = (e, inEsc, d) => {
|
|
373
|
+
if (!e || typeof e !== 'object' || d > 40) return true;
|
|
374
|
+
if (e.kind === 'ident' && e.name === param) return inEsc;
|
|
375
|
+
if (e.kind === 'call' && typeof e.callee === 'string') {
|
|
376
|
+
if (escapers.has(e.callee)) return true;
|
|
377
|
+
const h = helperByName(e.callee);
|
|
378
|
+
if (h && Array.isArray(h.params)) {
|
|
379
|
+
// a nested helper: each argument that mentions the parameter must land on a parameter the nested helper escapes
|
|
380
|
+
return (e.args || []).every((a, i) => !identNames(a).has(param) || (h.params[i] && paramEscaped(h, h.params[i], depth + 1) && under(a, true, d + 1)));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const [, c] of exprChildren(e)) if (!under(c, inEsc, d + 1)) return false;
|
|
384
|
+
return true;
|
|
385
|
+
};
|
|
386
|
+
for (const n of nodeList(helper)) {
|
|
387
|
+
for (const k of ['source', 'value', 'cond']) if (n[k] && !under(n[k], false, 0)) return false;
|
|
388
|
+
if (Array.isArray(n.args)) for (const a of n.args) if (!under(a, false, 0)) return false;
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
};
|
|
392
|
+
const addHelperEscapers = (e, d) => {
|
|
393
|
+
if (!e || typeof e !== 'object' || d > 40) return;
|
|
394
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && !escapers.has(e.callee)) {
|
|
395
|
+
const h = helperByName(e.callee);
|
|
396
|
+
if (h && Array.isArray(h.params) && (e.args || []).length) {
|
|
397
|
+
const taintedPositions = (e.args || []).map((a, i) => ([...identNames(a)].some((v) => vars.has(v) || derived.has(v)) ? i : -1)).filter((i) => i >= 0);
|
|
398
|
+
if (taintedPositions.length && taintedPositions.every((i) => h.params[i] && paramEscaped(h, h.params[i], 0))) escapers.add(e.callee);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const [, c] of exprChildren(e)) addHelperEscapers(c, d + 1);
|
|
402
|
+
};
|
|
403
|
+
addHelperEscapers(argExpr, 0);
|
|
404
|
+
for (const v of identNames(argExpr)) for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === v && n.source) addHelperEscapers(n.source, 0);
|
|
356
405
|
const calls = [];
|
|
357
406
|
const find = (e, d) => { if (!e || typeof e !== 'object' || d > 40) return; if (e.kind === 'call' && escapers.has(e.callee)) calls.push(e); for (const [, c] of exprChildren(e)) find(c, d + 1); };
|
|
358
|
-
|
|
359
|
-
|
|
407
|
+
// the escaper may sit behind one or more local bindings (`page = ... path ...`, `path = escape (...)`): follow them, each once
|
|
408
|
+
const followed = new Set();
|
|
409
|
+
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); } };
|
|
410
|
+
findIn(argExpr, 0);
|
|
360
411
|
if (calls.length && allUsesEscaped(fn, argExpr, escapers)) refuted = { kind: 'html-escaper', line: calls[0].line || fn.line, subject: calls[0].callee };
|
|
361
412
|
}
|
|
362
413
|
for (const fact of facts) {
|
|
@@ -473,10 +524,130 @@ export function quotedPositionals(f, perFile) {
|
|
|
473
524
|
}
|
|
474
525
|
|
|
475
526
|
/** Applies judgeFinding to every Haskell taint finding. Returns counts. */
|
|
527
|
+
/**
|
|
528
|
+
* A raw WAI response (`responseLBS status headers body`) is an HTML context only when its headers say so. Returns `html` (a `text/html`
|
|
529
|
+
* content type is written in the header list), `other` (another content type is written there), or `unknown` (the headers are not literal
|
|
530
|
+
* text the walk can read). Read from the call's own header argument; nothing else decides it.
|
|
531
|
+
*/
|
|
532
|
+
export function waiHtmlContext(f, perFile) {
|
|
533
|
+
if (!f || f.cwe !== 'CWE-79' || !/^Network\.Wai\.response(?:LBS|Builder)$/.test(f.callee || '')) return null;
|
|
534
|
+
const ir = perFile && perFile[f.file];
|
|
535
|
+
if (!ir) return 'unknown';
|
|
536
|
+
const fns = [ir.functions.find((x) => x.qid === f._funcQid), ...ir.functions].filter(Boolean);
|
|
537
|
+
for (const fn of fns) {
|
|
538
|
+
const sites = findSites(fn, f.callee, f.line);
|
|
539
|
+
if (!sites.length) continue;
|
|
540
|
+
const texts = [];
|
|
541
|
+
const resolveLocal = (name) => { for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) return n.source; return null; };
|
|
542
|
+
const visit = (e, d) => {
|
|
543
|
+
if (!e || typeof e !== 'object' || d > 40) return;
|
|
544
|
+
if (e.kind === 'literal' && typeof e.value === 'string') texts.push(e.value);
|
|
545
|
+
if (e.kind === 'ident' && d < 2) { const src = resolveLocal(e.name); if (src) visit(src, d + 1); }
|
|
546
|
+
for (const [, c] of exprChildren(e)) visit(c, d + 1);
|
|
547
|
+
};
|
|
548
|
+
for (const site of sites) visit((site.call.args || [])[1], 0);
|
|
549
|
+
const types = texts.filter((t) => /^(?:text|application|image|audio|video|multipart)\/[\w.+-]+/i.test(t.trim()));
|
|
550
|
+
if (types.some((t) => /^text\/html/i.test(t.trim()))) return 'html';
|
|
551
|
+
if (types.length) return 'other';
|
|
552
|
+
return 'unknown';
|
|
553
|
+
}
|
|
554
|
+
return 'unknown';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ── a request whose host is fixed ────────────────────────────────────────────
|
|
558
|
+
// SSRF is the client choosing the HOST. A URL built as `fixedBase <> clientText` where the fixed part is a literal (or a constant bound to
|
|
559
|
+
// one) that already names a scheme, a host and a "/" cannot reach another host: the client text lands in the path, query or fragment.
|
|
560
|
+
// Likewise a request value built by http-conduit's modifiers (`setRequestQueryString q base`, `setRequestPath p base`, ...) over a fixed
|
|
561
|
+
// base request keeps that base's host; the modifiers take the base as their LAST argument. A record update of such a value is judged by
|
|
562
|
+
// its base unless it sets a field that names the host.
|
|
563
|
+
const HOST_ANCHORED = /^[a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/[^\s]*$/i;
|
|
564
|
+
const REQUEST_BUILDERS = /(?:^|\.)(?:parseRequest|parseRequest_|parseUrlThrow|parseUrl|requestFromURI|requestFromURI_)$/;
|
|
565
|
+
const REQUEST_MODIFIERS = /(?:^|\.)(?:setRequestQueryString|setRequestQuery|setQueryString|setRequestPath|setRequestBody|setRequestBodyLBS|setRequestBodyJSON|setRequestBodyURLEncoded|setRequestBodyFile|setRequestBodySource|setRequestHeader|setRequestHeaders|addRequestHeader|setRequestMethod|setRequestBasicAuth|setRequestBearerAuth|setRequestIgnoreStatus|setRequestResponseTimeout|setRequestCheckStatus|setRequestManager|urlEncodedBody|applyBasicAuth|applyBearerAuth)$/;
|
|
566
|
+
const PEEL_WRAPPERS = /(?:^|\.)(?:liftIO|pure|return|unpack|pack|fromString|toStrict|fromStrict|encodeUtf8|decodeUtf8|toText|fromText)$/;
|
|
567
|
+
const HOST_FIELD = /^(?:host|port|secure|url|uri|requestHost)$/i;
|
|
568
|
+
|
|
569
|
+
function fixedHostRequest(f, perFile) {
|
|
570
|
+
if (!f || f.cwe !== 'CWE-918') return null;
|
|
571
|
+
const ir = perFile && perFile[f.file];
|
|
572
|
+
if (!ir || !f.callee) return null;
|
|
573
|
+
const direct = ir.functions.find((x) => x.qid === f._funcQid);
|
|
574
|
+
let fn = null; let sites = [];
|
|
575
|
+
for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
|
|
576
|
+
const s = findSites(cand, f.callee, f.line);
|
|
577
|
+
if (s.length) { fn = cand; sites = s; break; }
|
|
578
|
+
}
|
|
579
|
+
if (!fn || !sites.length) return null;
|
|
580
|
+
const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
|
|
581
|
+
// a local name bound once stands for its definition; a zero-parameter function of the module whose body is one literal is a constant
|
|
582
|
+
const localDef = (name) => { const defs = []; for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) defs.push(n.source); return defs.length === 1 ? defs[0] : null; };
|
|
583
|
+
const constDef = (name) => {
|
|
584
|
+
const bare = name.replace(/^.*\./, '');
|
|
585
|
+
const c = ir.functions.find((x) => (x.name === name || x.qid === name || (x.name || '').replace(/^.*\./, '') === bare) && (!Array.isArray(x.params) || x.params.length === 0));
|
|
586
|
+
if (!c) return null;
|
|
587
|
+
const rets = Object.values(c.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
|
|
588
|
+
return rets.length === 1 ? rets[0].value : null;
|
|
589
|
+
};
|
|
590
|
+
let evidence = null;
|
|
591
|
+
const fixed = (e, seen, depth) => {
|
|
592
|
+
if (!e || typeof e !== 'object' || depth > 24) return false;
|
|
593
|
+
if (e.kind === 'literal') { const ok = typeof e.value === 'string' && HOST_ANCHORED.test(e.value); if (ok) evidence = e.value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/).*$/i, '$1'); return ok; }
|
|
594
|
+
if (e.kind === 'ident') {
|
|
595
|
+
if (seen.has(e.name)) return false;
|
|
596
|
+
seen.add(e.name);
|
|
597
|
+
const d = localDef(e.name) || constDef(e.name);
|
|
598
|
+
return d ? fixed(d, seen, depth + 1) : false;
|
|
599
|
+
}
|
|
600
|
+
if (e.kind === 'union') return Array.isArray(e.branches) && e.branches.length > 0 && e.branches.every((b) => fixed(b, new Set(seen), depth + 1));
|
|
601
|
+
if (e.kind === 'object' && Array.isArray(e.props)) {
|
|
602
|
+
// a record update: `{ method = "POST" }` joined (as a union) with its base; a field that names the host makes it unknown
|
|
603
|
+
if (e.props.some((p) => HOST_FIELD.test(String(p.key || p.name || '')))) return false;
|
|
604
|
+
return e.object ? fixed(e.object, seen, depth + 1) : true;
|
|
605
|
+
}
|
|
606
|
+
if (e.kind === 'binary' && (e.op === '<>' || e.op === '++' || (e.hs && e.hs.concat))) return fixed(e.left, seen, depth + 1);
|
|
607
|
+
if (e.kind === 'call' && typeof e.callee === 'string') {
|
|
608
|
+
const args = e.args || [];
|
|
609
|
+
if (PEEL_WRAPPERS.test(e.callee) && args.length === 1) return fixed(args[0], seen, depth + 1);
|
|
610
|
+
if (REQUEST_BUILDERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
|
|
611
|
+
if (REQUEST_MODIFIERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
|
|
612
|
+
if (/(?:^|\.)(?:<\/>|combine|joinPath)$/.test(e.callee) && args.length === 2) return fixed(args[0], seen, depth + 1);
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
};
|
|
616
|
+
for (const site of sites) {
|
|
617
|
+
const arg = (site.call.args || [])[argIdx];
|
|
618
|
+
if (!arg) return null;
|
|
619
|
+
// a record update is a union of the base and the update: a union judged branch by branch above; an `object` branch with no base is the
|
|
620
|
+
// update alone, which is fixed when it names no host field, so the base branch decides
|
|
621
|
+
if (!fixed(arg, new Set(), 0)) return null;
|
|
622
|
+
}
|
|
623
|
+
return { kind: 'fixed-host-request', base: evidence };
|
|
624
|
+
}
|
|
625
|
+
|
|
476
626
|
export function applyHaskellGuards(findings, perFile) {
|
|
477
627
|
const stats = { refuted: 0, weak: 0 };
|
|
478
628
|
for (const f of findings || []) {
|
|
479
629
|
if (!f || f.parser !== 'IR-TAINT') continue;
|
|
630
|
+
try {
|
|
631
|
+
const ctx = waiHtmlContext(f, perFile);
|
|
632
|
+
if (ctx === 'other' && !f._notHtmlContext) {
|
|
633
|
+
f._notHtmlContext = true; f.severity = 'info';
|
|
634
|
+
f.controls = [...(f.controls || []), { kind: 'non-html-content-type', evidenceKind: 'source' }];
|
|
635
|
+
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' };
|
|
636
|
+
stats.weak++;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (ctx === 'unknown' && !f._htmlContextUnknown) { f._htmlContextUnknown = true; f.uncertainty = [...(f.uncertainty || []), { kind: 'unread-content-type', detail: 'the response headers at this call are not literal text, so whether the body is served as HTML was not read' }]; }
|
|
640
|
+
} catch { /* judged below */ }
|
|
641
|
+
try {
|
|
642
|
+
const fh = fixedHostRequest(f, perFile);
|
|
643
|
+
if (fh && !f._fixedHost) {
|
|
644
|
+
f._fixedHost = true; f.severity = 'info';
|
|
645
|
+
f.controls = [...(f.controls || []), { kind: 'fixed-host-request', evidenceKind: 'source' }];
|
|
646
|
+
f.guardNote = { kind: 'fixed-host-request', reason: `the request's host is fixed by the literal${fh.base ? ` "${fh.base}"` : ''} the URL or base request starts from; the client's text can only choose the path, query or fragment, which is not a server-side request forgery (a path it chooses on that host is still worth a look)` };
|
|
647
|
+
stats.weak++;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
} catch { /* judged below */ }
|
|
480
651
|
try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
|
|
481
652
|
try {
|
|
482
653
|
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
|
-
|
|
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.
|
|
@@ -147,14 +148,20 @@ function servantEndpointParams(mod, fn, sig, exported) {
|
|
|
147
148
|
};
|
|
148
149
|
const body = slice.slice(eq + 1);
|
|
149
150
|
if (split(body, ':<|>').filter((p) => p.length).length !== 1) return [];
|
|
151
|
+
const parts = split(body, ':>').filter((p) => p.length);
|
|
152
|
+
// The endpoint's values are the LAST parameters of the handler: `orderServer conn ref` for `QueryParam "ref" Text` takes `conn` from the
|
|
153
|
+
// closure. Pair from the end (the same rule haskell-web.js applies); a handler with fewer parameters than the API names is not paired.
|
|
154
|
+
const reads = parts.filter((p) => p[0] && p[0].t === 'con' && (SERVANT_PRINCIPAL.has(p[0].v) || SERVANT_READS.has(p[0].v))).length;
|
|
155
|
+
const offset = fn.params.length - reads;
|
|
156
|
+
if (offset < 0) return [];
|
|
150
157
|
const out = [];
|
|
151
158
|
let idx = 0;
|
|
152
|
-
for (const part of
|
|
159
|
+
for (const part of parts) {
|
|
153
160
|
const head = part[0];
|
|
154
161
|
if (!head || head.t !== 'con') continue;
|
|
155
162
|
const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
|
|
156
163
|
if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
|
|
157
|
-
const name = fn.params[idx];
|
|
164
|
+
const name = fn.params[offset + idx];
|
|
158
165
|
idx++;
|
|
159
166
|
if (isPrincipal || !name) continue;
|
|
160
167
|
// the carried type is what follows the name string (or the content-type list for a body): its text decides whether it is text
|
|
@@ -162,7 +169,7 @@ function servantEndpointParams(mod, fn, sig, exported) {
|
|
|
162
169
|
let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
|
|
163
170
|
if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
|
|
164
171
|
const type = rest.map((t) => t.v).join(' ').trim();
|
|
165
|
-
if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: idx - 1, name, decorator: 'hs:handler-param:servant' });
|
|
172
|
+
if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: offset + idx - 1, name, decorator: 'hs:handler-param:servant' });
|
|
166
173
|
}
|
|
167
174
|
return out;
|
|
168
175
|
}
|
|
@@ -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');
|
|
@@ -373,6 +380,8 @@ export const HS_LOG_SINKS = Object.freeze([
|
|
|
373
380
|
...E('Text.Printf', ['hPrintf']).map((e) => ({ ...e, argIndex: 1, argFrom: 1, channel: 'handle' })),
|
|
374
381
|
...E('Katip', ['logFM', 'logTM', 'logItem']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
|
|
375
382
|
...E('System.Log.Logger', ['debugM', 'infoM', 'noticeM', 'warningM', 'errorM', 'criticalM']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
|
|
383
|
+
// fast-logger: the logger set is argument 0, the message argument 1
|
|
384
|
+
...E('System.Log.FastLogger', ['pushLogStr', 'pushLogStrLn']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
|
|
376
385
|
]);
|
|
377
386
|
|
|
378
387
|
// Library surfaces the structural rules (haskell-security-rules.js) look for; registering them here makes
|
|
@@ -386,6 +395,9 @@ export const HS_RULE_APIS = Object.freeze([
|
|
|
386
395
|
...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
|
|
387
396
|
...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
|
|
388
397
|
...E('System.Random', ['randomRIO', 'randomIO', 'newStdGen', 'getStdGen', 'mkStdGen', 'randomR', 'random', 'randoms', 'randomRs', 'getStdRandom', 'uniformR', 'uniform', 'initStdGen']),
|
|
398
|
+
// mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
|
|
399
|
+
...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
|
|
400
|
+
...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
|
|
389
401
|
...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
|
|
390
402
|
...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
|
|
391
403
|
...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
|
|
@@ -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 };
|