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