@clear-capabilities/agentic-security-scanner 0.195.10 → 0.195.11
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 +16 -0
- package/dist/{312ef9976a988b322ade.json → 13e6d1463f0aec440afa.json} +3 -3
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +6 -3
- package/src/language/haskell-fix.js +108 -2
- package/src/language/haskell-ir.js +135 -8
- package/src/language/haskell-models.js +9 -2
- package/src/language/haskell-security-rules.js +82 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
e1846ef5978aaa4531a365d9c3ddc0e35cfa84bf684ca7a5151ddb6d317b31cc 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.11",
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"node": ">=24.0.0"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@babel/core": "^8.0
|
|
43
|
+
"@babel/core": "^8.1.0",
|
|
44
44
|
"@babel/preset-react": "^8.0.1",
|
|
45
45
|
"@babel/preset-typescript": "^8.0.1",
|
|
46
46
|
"js-yaml": "^5.5.0",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
|
|
94
94
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
95
95
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
96
|
-
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
96
|
+
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
97
97
|
"test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
|
|
98
98
|
"test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
99
99
|
"test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
|
package/src/engine.js
CHANGED
|
@@ -317,6 +317,7 @@ import {
|
|
|
317
317
|
import { SCANNER_VERSION as _ENGINE_VERSION } from './posture/version.js';
|
|
318
318
|
import { isLanguageSource, isLanguageExcludedPath, withLanguageClosure } from './language/discovery.js';
|
|
319
319
|
import { annotateHaskellFindings } from './language/haskell-findings.js';
|
|
320
|
+
import { stripComments as stripHaskellComments } from './language/haskell-comments.js';
|
|
320
321
|
import { annotateHaskellLlm, analyzeHaskellLlmRules } from './language/haskell-llm.js';
|
|
321
322
|
import { languagePragmaOnLine as _languagePragmaOnLine, languagePragmaSuppresses as _languagePragmaSuppresses } from './language/pragma.js';
|
|
322
323
|
import { applyHaskellGuards, consolidateFlowSinks } from './language/haskell-guards.js';
|
|
@@ -364,6 +365,10 @@ function _commentLangFor(fp) {
|
|
|
364
365
|
if (_ALL_COMMENT_EXTS.has(ext)) return 'php';
|
|
365
366
|
if (_RUBY_COMMENT_EXTS.has(ext)) return 'rb';
|
|
366
367
|
if (_HASH_COMMENT_EXTS.has(ext)) return 'py';
|
|
368
|
+
// Haskell: `--` lines and nesting `{- -}` blocks (language/haskell-comments.js, the same lexer the Haskell findings' snippets use). The
|
|
369
|
+
// language-neutral detectors that read the comment-blanked view (ciphers, protocol shapes, structural patterns) fired on API names quoted
|
|
370
|
+
// in a Haskell doc comment; a comment is never a call. Literate `.lhs` is left to the Haskell parser (its prose is not a comment form).
|
|
371
|
+
if (ext === '.hs') return 'hs';
|
|
367
372
|
return undefined; // C-family default: `//` and `/* */` only, `#` left alone.
|
|
368
373
|
}
|
|
369
374
|
// Single-entry memo for the comment-blanked view.
|
|
@@ -383,7 +388,7 @@ function _commentLangFor(fp) {
|
|
|
383
388
|
let _blankMemoSrc = null, _blankMemoLang, _blankMemoOut = null;
|
|
384
389
|
function _blankCached(src, lang) {
|
|
385
390
|
if (src === _blankMemoSrc && lang === _blankMemoLang) return _blankMemoOut;
|
|
386
|
-
const out = blankComments(src, lang);
|
|
391
|
+
const out = lang === 'hs' ? stripHaskellComments(src) : blankComments(src, lang);
|
|
387
392
|
_blankMemoSrc = src; _blankMemoLang = lang; _blankMemoOut = out;
|
|
388
393
|
return out;
|
|
389
394
|
}
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -65,15 +65,18 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
65
65
|
- **A numeric annotation is a clean constant to the taint engine and still an expression to the rules.** `read s :: Int` and `readMaybe s :: Maybe Int` parse to a `typed` node (`NUMERIC_TYPE` in `haskell-syntax.js`, nothing else is kept) which lowers to a literal whose `hs.inner` carries the lowered expression; `haskell-security-rules.js` and `haskell-web.js` walk `hs.inner` (as they walk `hs.selector`), so an unsafe coercion in it and the request id it names stay visible. It is deliberately NOT a call of a sanitizer: the engine's sanitizer label is per expression, so a number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized.
|
|
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
|
+
- **A project definition of a modelled name keeps the registry's meaning (since 0.195.11).** `haskell-ir.js` `applyVar`/`lowerVarValue`: a resolution to a project `def` whose module-qualified name `isKnownApi` (the project IS the framework, or vendors it) is lowered as the LIBRARY call (`hs.status: 'external'`, `hs.modelledDefinition: qid`) and the definition is still referenced (`noteRef(..., 'modelled-definition')`, so it is never an uncalled entry point). Before this, IHP's own `IHP.Controller.Param.param` shadowed the catalog when IHP scanned itself, and a request parameter reaching `Process.system` through a helper was not reported (four lost true positives on the fresh set). A test stub named like a catalog module gets the same treatment, which is why the html-text remediation test keeps its scotty stub out of the scan. Pinned both ways in `test/haskell/haskell-taint-shapes.test.js`, with the three shapes below.
|
|
69
|
+
- **Mutable cells, instance dispatch, functions in record fields (since 0.195.11).** `haskell-ir.js`: `CELL_OPS` lowers `writeIORef ref v` (MVar, TVar, STRef alike) to an assignment of the cell's local variable, a read to that variable, `modifyIORef ref f` to `ref = f ref` (`lowerCellOp`; only a cell held in a local variable, never across functions, disclosed in the guide). `dispatchInstance`: a class-method call with a constructor argument of a type that has exactly one visible instance resolves to that instance method (`hs.status: 'resolved'`, `hs.dispatchedOn`); instance functions are now named `Mod.$inst.Class.Type.method` (`instanceMeta`, `instanceTypeOf` reads the instance head) so two instances of one class resolve to different functions by name, which the dataflow engine needs because it resolves callees by name. `heldFieldFunction`: `exec ops x` with `ops` a top-level constant record literal applies the field's expression (lambda or name), lowered in the constant's module with the arguments lowered in the caller's. The cell and STM names are in `HS_RULE_APIS` so wildcard imports qualify them.
|
|
68
70
|
- **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
71
|
- **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
72
|
- **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
73
|
- **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
74
|
- **"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`.
|
|
75
|
+
- **The language-neutral detectors see Haskell comments blanked (since 0.195.11).** `engine.js` `_commentLangFor` maps `.hs` to `'hs'` and `_blankCached` uses `haskell-comments.js` `stripComments` for it: the cipher, protocol-shape and structural text detectors read the comment-blanked view, and under the C-family view an API name quoted in a Haskell doc comment (a Node MD5 hash call, a JWT verify that accepts the unsigned algorithm) was reported as a call. (This file is in the self-scan's targets too: do not quote such a call here verbatim, the generic detectors read a guide as code.) Pinned both ways in `test/haskell/haskell-generic-detectors.test.js`. `.lhs` is left alone (its prose is not a comment form).
|
|
73
76
|
- **Comments never change a verdict, an id or a cluster.** `haskell-comments.js` finds Haskell comments lexically; a finding's `snippet` is `codeOfLine(line)` (code only), because the snippet feeds the stable id and the clone-shape hash, and both changed when someone edited a trailing comment. `test/haskell/haskell-comment-invariance.test.js` scans the examples and corpus-shaped cases as written, with every comment blanked, and with every comment (and a trailing comment on every line) replaced by text that lies in each direction; `bench/dag/dev-haskell/comment-invariance.mjs` does the same to a real project. An engine that varies is wrong.
|
|
74
|
-
- **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`). A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
|
|
77
|
+
- **Rules that read the function around a call** (`haskell-security-rules.js`): `hs-weak-hash` is a vulnerability only where `weakDigestUse` finds a use that depends on the digest's strength (compared, hashed secret, credential-shaped binding or function, handed to a signing/verifying call); otherwise it is emitted at `info`. `hs-unbounded-input-read` needs a socket handle, or standard input in a function nothing calls (`readsOutsideInput`). Partial-function and allocation rules use a parameter as caller-supplied only for a framework-handler parameter or an uncalled function (`fn.hs.reached`), and, since 0.195.11, for a parameter a CALLER fills with request-derived text (`requestTextParams`: a pre-pass over the resolved call edges, including the edges of inlined helpers, which now carry their lowered arguments; one hop per round, four rounds). `list !! tableId` with a caller-supplied INDEX is reported like a caller-supplied list (a relational test on the index covers it); the partial-function emits carry `inlineCtx`, so the copy at an inlined call site is dropped when the helper reports it. A scotty `body` read is bounded by `setMaxRequestBodySize` in the same function or in a ScottyM function that references the handler (`scottyLimitFor`, certain, `controls` kind `scotty-body-limit`); a limit elsewhere in the project keeps the finding at `low` with the reach stated as unverified. A salt is not an unpredictability-sensitive value. A cookie whose whole value is constant is not a credential. Own-property lookups only (`Object.hasOwn`): `constructor` and `toString` are legal Haskell names and were reported as "Unsafe boundary: function Object() { [native code] }".
|
|
75
78
|
- **Fixes name the library the file uses.** `sqlDriverOf` picks `Database.PostgreSQL.Simple` or `Database.SQLite.Simple` from the file's own imports and refuses anything else; `hs-html-escape` needs blaze-html or a re-exporter; `hs-hash-sha256` refuses when the algorithm is named on another line (a type); `hs-path-takefilename` rewrites only a String/FilePath parameter spliced under a fixed directory in a FILE operation. Classes with no safe rewrite refuse with a class-level reason (`CLASS_REASONS`), `proposal: 'model-assisted'`, `tier: 'guidance-only'`, and every reason ends `The decision needed: ...` (returned as `decision`). Never invent a library API: the TLS, cookie, entropy and wai shapes were written against the package documentation and their tests type-check against DECLARED STUBS of that API shape, which proves coherence with the documented shape, not a build against the real package.
|
|
76
|
-
- **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need). Two more since 0.195.10, both MITIGATIONS written against the real-code true positives: `hs-html-escape-text` (CWE-79: scotty's `html $ mconcat ["<h1>", v, "</h1>"]`, or `html` of one literal-free expression, gets an escaper DEFINED IN THE FILE on every non-literal part; `htmlEscapers` recognises it by its entities, so the rescan goes quiet for the right reason; only with `Web.Scotty` and `Data.Text.Lazy` imported, never a nested construction or a renderer) and `hs-password-kdf-migrate` (CWE-916: the migration story the old refusal asked for, written out in the file: the old pure one-line `name salt password` of one text type is kept as `nameLegacy` for verification, `name` becomes PBKDF2-HMAC-SHA256 with a tag in front of the hex, `nameNeedsRehash` reads the tag, `nameVerify stored salt password` checks either form, every `stored == name salt password` in the file calls it, an export list gains the helpers; only with a cryptonite/crypton module imported, and REFUSED when another file compares with the function, because that comparison would lock those users out). `haskell-security-rules.js` `legacyVerifierOnly` then reports the kept weak hash at `info`: every use of the function, in every file, is an operand of an equality and one of those functions also calls a password KDF within one project hop (the migration shape, decided from the IR; `register salt pw = oldHash salt pw` or a comparison beside no KDF keeps the full finding). `defaultRescan` drops a finding the engine LOWERED to `info` for a stated reason (`originalSeverity`, or the legacy-verifier evidence) as it drops `proven-*` ones, so a patch that earns such a lowering passes the "original gone" gate; a finding that is `info` in its own right (an MD5 with no visible use) is still counted, so fixing it can still be shown to remove it. SSRF, argument injection, missing auth and hard-coded secrets keep refusing and name the decision. The compile-gated remediation suites set `GHC_ENVIRONMENT=-` in their own process: a host's package environment file (`cabal install --lib`) hid `bytestring` under the recorded GHC and turned every stub compile into not-run.
|
|
79
|
+
- **Haskell plans carry `tier`, `consequences` and (for attribute fixes) `expectEffective`.** `planHaskellFix` returns `tier: 'full-source-edit'` and a `consequences` array for every plan (a patch that changes behavior must say how: stored digests, rejected connections, HTTP-only cookies, a request-body limit), the way `nix-fix.js` does. Ten fixers: the six rewrites above plus `hs-tls-validate` (CWE-295, the `connection` package's `TLSSettingsSimple` literal `True` to `False`; OpenSSL `VerifyNone` refused), `hs-cookie-attributes` (CWE-1004, four framework shapes; the scotty helper is written out as the record update because the wrapped form is invisible to the rule), `hs-random-os-entropy` (CWE-338, only `name = randomIO` of type `IO <fixed-width integer>` in a file that already imports `System.Entropy`) and `hs-request-body-bound` (CWE-770, a MITIGATION: wai `strictRequestBody` becomes a bounded reader defined in the file, with a 1 MiB limit that is a declared default, not a measured need). Two more since 0.195.10, both MITIGATIONS written against the real-code true positives: `hs-html-escape-text` (CWE-79: scotty's `html $ mconcat ["<h1>", v, "</h1>"]`, or `html` of one literal-free expression, gets an escaper DEFINED IN THE FILE on every non-literal part; `htmlEscapers` recognises it by its entities, so the rescan goes quiet for the right reason; only with `Web.Scotty` and `Data.Text.Lazy` imported, never a nested construction or a renderer) and `hs-password-kdf-migrate` (CWE-916: the migration story the old refusal asked for, written out in the file: the old pure one-line `name salt password` of one text type is kept as `nameLegacy` for verification, `name` becomes PBKDF2-HMAC-SHA256 with a tag in front of the hex, `nameNeedsRehash` reads the tag, `nameVerify stored salt password` checks either form, every `stored == name salt password` in the file calls it, an export list gains the helpers; only with a cryptonite/crypton module imported, and REFUSED when another file compares with the function, because that comparison would lock those users out). `haskell-security-rules.js` `legacyVerifierOnly` then reports the kept weak hash at `info`: every use of the function, in every file, is an operand of an equality and one of those functions also calls a password KDF within one project hop (the migration shape, decided from the IR; `register salt pw = oldHash salt pw` or a comparison beside no KDF keeps the full finding). `defaultRescan` drops a finding the engine LOWERED to `info` for a stated reason (`originalSeverity`, or the legacy-verifier evidence) as it drops `proven-*` ones, so a patch that earns such a lowering passes the "original gone" gate; a finding that is `info` in its own right (an MD5 with no visible use) is still counted, so fixing it can still be shown to remove it. Since 0.195.11, `hs-scotty-body-limit` (CWE-770, MITIGATION): scotty's `body` has no per-read bound, so `setMaxRequestBodySize agenticRequestBodyLimitKb` (1024 KB, a declared default) is inserted as the first statement of the ScottyM block that contains the read or names the handler (`scottyBlocksOf`: `scotty <port> $ do` lines, or `name :: ScottyM ()` with `name = do`), with a qualified import under `AgenticScotty`; refused when the block is in another file, when `scottyBoundExcludesLimit` finds a cabal bound admitting no scotty version from 0.12 on, or when a name is taken; the effective check (`scotty-body-limit`) requires exactly one added call at the planned line and unchanged body reads. SSRF, argument injection, missing auth and hard-coded secrets keep refusing and name the decision. The compile-gated remediation suites set `GHC_ENVIRONMENT=-` in their own process: a host's package environment file (`cabal install --lib`) hid `bytestring` under the recorded GHC and turned every stub compile into not-run.
|
|
77
80
|
- **The effective-change check is not the rescan.** `haskellEffectiveCheck` (wired as the lifecycle's `effectiveCheck`) re-reads the intended attribute from the patched file with readers independent of the editor (`tlsSettingsOf`, `cookieSitesOf`, call counts) and fails when the planned site did not change or another site did. A cookie renamed to a non-session name silences the rule without adding an attribute; the check says so (`haskell-remediation-0190.test.js`).
|
|
78
81
|
- **The compile gate is requested by the preview.** `languageFixPreview` passes `compile: true` (`compile: false` opts out). `compileGate` type-checks only the patched file(s) (`targets`; local imports found through the `module` header's source root) and takes the original tree as `baseline`: a project that does not type-check on this host (a package it lacks), or a patch that imports a module this host lacks, is `{ran:false, ok:null}` (not-run), a patch that breaks a compiling file is `{ran:true, ok:false}`. A missing GHC is not-run, never a pass. `requireCompile` turns not-run into a block.
|
|
79
82
|
- **Witnesses.** `witness.js` `haskellFixWitness(finding)` runs the original and the patched file under `ghc -e ':load *File' -e 'fn "<payload>"'` in the sandbox and judges them with `judgeFixWitness`. It is built only for command-injection fixes in a top-level `String -> IO` function whose file imports only `bootModuleSet` modules; every other class returns `verified: false` with the reason (a TLS peer, a framework response header, a database, a digest, a distribution cannot be shown by a program GHC runs alone). Opt in with `languageFixPreview(..., { witness: true })`.
|
|
@@ -143,7 +146,7 @@ Pinned both ways in `test/nix/nix-option-types.test.js`.
|
|
|
143
146
|
|
|
144
147
|
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
148
|
|
|
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.
|
|
149
|
+
**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. 0.195.11 measured unseen-v6 once more after the taint shapes found by self-written probes and the real-code sets (its USE-LOG): identical, F1 72.5%, taint families 66.7%; those shapes are not the ones the split exercises, and that is the recorded reading.
|
|
147
150
|
|
|
148
151
|
**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
152
|
|
|
@@ -20,7 +20,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
20
20
|
import { parseHaskell } from './haskell-parser.js';
|
|
21
21
|
import { SENSITIVE } from './haskell-security-rules.js';
|
|
22
22
|
import { stripComments } from './haskell-comments.js';
|
|
23
|
-
import { compareVersions, parseVersion } from './haskell-manifests.js';
|
|
23
|
+
import { compareVersions, parseVersion, parseCabalFile, versionSatisfies } from './haskell-manifests.js';
|
|
24
24
|
import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
|
|
25
25
|
|
|
26
26
|
export const HS_FIX_VERSION = 'haskell-fix/1';
|
|
@@ -920,7 +920,102 @@ function fixPasswordKdf(text, f) {
|
|
|
920
920
|
};
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
+
// ── scotty request body limit (CWE-770) ───────────────────────────────────────
|
|
924
|
+
// scotty's `body` reads the whole request body. The bound scotty offers is application-wide: `setMaxRequestBodySize :: Kilobytes -> ScottyM ()`
|
|
925
|
+
// (scotty 0.12 and later; a request past it gets a 413 before any handler runs). The mitigation adds ONE call, as the first statement of the
|
|
926
|
+
// ScottyM block that builds the application this handler belongs to: the block that contains the read (an inline route), or the block in this
|
|
927
|
+
// file that names the handler function. The limit is a declared default (1 MiB) written as a named constant the operator must review. Refused:
|
|
928
|
+
// a block in another file (the rule can then only say "a limit exists somewhere"), a `do` that keeps a statement on its own line, a name of ours
|
|
929
|
+
// already taken, and a cabal file whose scotty bound excludes every version from 0.12 on.
|
|
930
|
+
const SCOTTY_LIMIT_KB = 1024;
|
|
931
|
+
const SCOTTY_LIMIT_CONST = 'agenticRequestBodyLimitKb';
|
|
932
|
+
const SCOTTY_LIMIT_ALIAS = 'AgenticScotty';
|
|
933
|
+
const SCOTTY_LIMIT_DEF = `-- Upper bound, in kilobytes, for any request body this application accepts: a request past it is answered 413 before a handler runs. This is a default chosen by the fixer, not a measured need: set it for this service.
|
|
934
|
+
${SCOTTY_LIMIT_CONST} :: Int
|
|
935
|
+
${SCOTTY_LIMIT_CONST} = ${SCOTTY_LIMIT_KB}
|
|
936
|
+
`;
|
|
937
|
+
export { SCOTTY_LIMIT_DEF, SCOTTY_LIMIT_KB };
|
|
938
|
+
const SCOTTY_VERSIONS_WITH_LIMIT = ['0.12', '0.12.1', '0.20', '0.21', '0.22', '0.30', '0.31'];
|
|
939
|
+
const TOP_DECL = /^([a-z_][\w']*)\b/;
|
|
940
|
+
const NOT_A_DEF = new Set(['import', 'module', 'data', 'type', 'newtype', 'class', 'instance', 'where', 'infix', 'infixl', 'infixr', 'foreign', 'default', 'deriving', 'pattern']);
|
|
941
|
+
// The ScottyM blocks of a file: `main = scotty 3000 $ do`, `app = scottyOpts opts $ do`, or `app :: ScottyM ()` with `app = do`. Each block:
|
|
942
|
+
// the top-level function that holds it, the line of its `do`, and the indentation of its first statement.
|
|
943
|
+
function scottyBlocksOf(code) {
|
|
944
|
+
const ls = code.split('\n');
|
|
945
|
+
const starts = []; ls.forEach((l, i) => { const m = TOP_DECL.exec(l); if (m && !NOT_A_DEF.has(m[1])) starts.push({ line: i, name: m[1] }); });
|
|
946
|
+
const fnAt = (i) => { let cur = null; for (const s of starts) { if (s.line <= i) cur = s; else break; } return cur ? cur.name : null; };
|
|
947
|
+
const sigs = new Set(); ls.forEach((l) => { const m = /^([a-z_][\w']*)\s*::\s*ScottyM\s*\(\)\s*$/.exec(l); if (m) sigs.add(m[1]); });
|
|
948
|
+
const out = [];
|
|
949
|
+
ls.forEach((l, i) => {
|
|
950
|
+
const inline = /(?:^|[\s$(])(?:[A-Z][\w']*\.)?scotty(?:Opts|T|OptsT|App|AppT)?\b[^\n]*\$\s*do\s*$/.test(l);
|
|
951
|
+
const named = (() => { const m = /^([a-z_][\w']*)\s*=\s*do\s*$/.exec(l); return !!(m && sigs.has(m[1])); })();
|
|
952
|
+
if (!inline && !named) return;
|
|
953
|
+
let j = i + 1; while (j < ls.length && ls[j].trim() === '') j++;
|
|
954
|
+
if (j >= ls.length) return;
|
|
955
|
+
const indent = /^(\s*)/.exec(ls[j])[1];
|
|
956
|
+
if (!indent.length || ls[j].trim().startsWith('where')) return;
|
|
957
|
+
out.push({ fn: fnAt(i), doLine: i, firstStatement: j, indent });
|
|
958
|
+
});
|
|
959
|
+
return { blocks: out, fnAt };
|
|
960
|
+
}
|
|
961
|
+
// The scotty bound every cabal file in the tree declares, judged against the versions that have the limit: null when no bound is declared.
|
|
962
|
+
function scottyBoundExcludesLimit(files) {
|
|
963
|
+
for (const [name, text] of Object.entries(files || {})) {
|
|
964
|
+
if (!/\.cabal$/i.test(name) || typeof text !== 'string') continue;
|
|
965
|
+
let parsed = null; try { parsed = parseCabalFile(text); } catch { parsed = null; }
|
|
966
|
+
for (const c of (parsed && parsed.components) || []) for (const d of c.dependencies || []) {
|
|
967
|
+
if (!d || d.name !== 'scotty' || !d.range) continue;
|
|
968
|
+
let admits = false;
|
|
969
|
+
for (const v of SCOTTY_VERSIONS_WITH_LIMIT) { try { if (versionSatisfies({ ok: true, ast: d.range }, v)) { admits = true; break; } } catch { admits = true; break; } }
|
|
970
|
+
if (!admits) return { file: name, rangeText: d.rangeText || '' };
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
function fixScottyBody(text, f) {
|
|
976
|
+
if (!/Web\.Scotty(?:\.Trans)?\.body\b/.test(String(f.description || f.detail || ''))) return null;
|
|
977
|
+
const trans = !importInfo(text, 'Web.Scotty') && importInfo(text, 'Web.Scotty.Trans');
|
|
978
|
+
if (!importInfo(text, 'Web.Scotty') && !trans) return null;
|
|
979
|
+
const code = stripComments(text);
|
|
980
|
+
if (new RegExp(`(?<![\\w'.])(?:${SCOTTY_LIMIT_CONST}|setMaxRequestBodySize)(?![\\w'])`).test(code) || new RegExp(`\\b${SCOTTY_LIMIT_ALIAS}\\b`).test(code)) return null;
|
|
981
|
+
const { blocks, fnAt } = scottyBlocksOf(code);
|
|
982
|
+
if (!blocks.length) return null;
|
|
983
|
+
const holder = fnAt(f.line - 1);
|
|
984
|
+
let block = blocks.find((b) => b.fn && b.fn === holder) || null;
|
|
985
|
+
if (!block && holder) {
|
|
986
|
+
const ls = code.split('\n');
|
|
987
|
+
const starts = []; ls.forEach((l, i) => { const m = TOP_DECL.exec(l); if (m && !NOT_A_DEF.has(m[1])) starts.push(i); });
|
|
988
|
+
const spanOf = (b) => { const s = b.doLine; let e = ls.length; for (const i of starts) if (i > s) { e = i; break; } return ls.slice(s, e).join('\n'); };
|
|
989
|
+
const naming = blocks.filter((b) => new RegExp(`(?<![\\w'.])${holder}(?![\\w'])`).test(spanOf(b)));
|
|
990
|
+
if (naming.length === 1) block = naming[0];
|
|
991
|
+
}
|
|
992
|
+
if (!block) return null;
|
|
993
|
+
const excluded = scottyBoundExcludesLimit(f._files);
|
|
994
|
+
if (excluded) return null;
|
|
995
|
+
const lines = text.split('\n');
|
|
996
|
+
const statement = `${block.indent}${SCOTTY_LIMIT_ALIAS}.setMaxRequestBodySize ${SCOTTY_LIMIT_CONST}`;
|
|
997
|
+
lines.splice(block.firstStatement, 0, statement);
|
|
998
|
+
// the last import of the CODE: an `import` inside a block comment (a commented-out alternative main) is not one
|
|
999
|
+
let at = -1; stripComments(lines.join('\n')).split('\n').forEach((l, i) => { if (/^import\s/.test(l)) at = i; });
|
|
1000
|
+
if (at < 0) return null;
|
|
1001
|
+
lines.splice(at + 1, 0, `import qualified ${trans ? 'Web.Scotty.Trans' : 'Web.Scotty'} as ${SCOTTY_LIMIT_ALIAS}`);
|
|
1002
|
+
const next = lines.join('\n').replace(/\n*$/, '\n') + '\n' + SCOTTY_LIMIT_DEF;
|
|
1003
|
+
const insertedLine = block.firstStatement + 2; // 1-based, after the import line added above it
|
|
1004
|
+
const bound = (() => { for (const [name, t] of Object.entries(f._files || {})) if (/\.cabal$/i.test(name) && typeof t === 'string' && /\bscotty\b/.test(t)) return name; return null; })();
|
|
1005
|
+
return {
|
|
1006
|
+
ruleId: 'hs-scotty-body-limit', label: 'MITIGATION', text: next,
|
|
1007
|
+
explanation: `Added setMaxRequestBodySize ${SCOTTY_LIMIT_CONST} (${SCOTTY_LIMIT_KB} KB, a declared default, not a measured need) as the first statement of the ScottyM block in \`${block.fn || 'the application'}\`, which ${block.fn === holder ? 'contains this body read' : `registers \`${holder}\``}.`,
|
|
1008
|
+
consequences: [
|
|
1009
|
+
`Every route of the application built in \`${block.fn || 'this block'}\` now answers a request whose body exceeds ${SCOTTY_LIMIT_KB} KB with a 413 before the handler runs; legitimate large uploads are rejected until ${SCOTTY_LIMIT_CONST} is raised.`,
|
|
1010
|
+
`Requires scotty 0.12 or later (setMaxRequestBodySize); ${bound ? `the bound declared in ${bound} admits such a version, and the build is not checked against it` : 'no cabal file in the tree declares a scotty bound, so the version is not checked'}.`,
|
|
1011
|
+
],
|
|
1012
|
+
behavior: { kind: 'scotty-body-limit', fn: block.fn, kb: SCOTTY_LIMIT_KB, line: insertedLine },
|
|
1013
|
+
expectEffective: { kind: 'scotty-body-limit', line: insertedLine, statement },
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
923
1017
|
const FIXERS = [
|
|
1018
|
+
{ id: 'hs-scotty-body-limit', applies: (f) => f.rule === 'hs-unbounded-request-body' && /Web\.Scotty(?:\.Trans)?\.body\b/.test(String(f.description || f.detail || '')), run: fixScottyBody, reportedLineOnly: true },
|
|
924
1019
|
{ id: 'hs-password-kdf-migrate', applies: (f) => f.cwe === 'CWE-916' && (f.rule === 'hs-fast-password-hash' || /fast general-purpose hash/i.test(String(f.vuln || ''))), run: fixPasswordKdf },
|
|
925
1020
|
{ id: 'hs-tls-validate', applies: (f) => f.cwe === 'CWE-295' || f.rule === 'hs-tls-validation-disabled', run: fixTls, reportedLineOnly: true },
|
|
926
1021
|
{ id: 'hs-cookie-attributes', applies: (f) => f.cwe === 'CWE-1004' || f.rule === 'hs-insecure-cookie', run: fixCookie, reportedLineOnly: true },
|
|
@@ -1010,7 +1105,7 @@ const CLASS_REASONS = Object.freeze({
|
|
|
1010
1105
|
'CWE-338': 'a predictable generator is replaced by the operating system CSPRNG (System.Entropy or Crypto.Random), whose result type is bytes, not System.Random\'s: the surrounding code must change with it. Only a one-line action of type IO <fixed-width integer> equal to randomIO, in a file that already imports System.Entropy, is rewritten. The decision needed: the token\'s type and size (a 30-bit integer cannot be made unguessable by changing the generator) and the entropy library the project declares.',
|
|
1011
1106
|
'CWE-1004': 'cookie attributes live in different places per framework. The recognised shapes are a Set-Cookie header string literal, a Web.Cookie SetCookie record update, servant-auth cookieIsSecure, and scotty setSimpleCookie/makeSimpleCookie; this is none of them, or an attribute is set from an expression. The decision needed: which attributes to send (Secure, HttpOnly, SameSite) and whether this cookie must work over plain HTTP.',
|
|
1012
1107
|
'CWE-306': 'a missing authentication check needs a policy; the scanner reports the handler and does not invent the guard. The decision needed: which credential authenticates this route (session, token, API key) and what an unauthenticated caller receives (401, a redirect to login).',
|
|
1013
|
-
'CWE-770': 'a resource limit is a number only the operator can choose (how many bytes a body may have, how many requests, how large an allocation).
|
|
1108
|
+
'CWE-770': 'a resource limit is a number only the operator can choose (how many bytes a body may have, how many requests, how large an allocation). Two shapes are recognised, each with a declared default: wai strictRequestBody of a Request variable in a file importing Network.Wai and Data.ByteString.Lazy, which gets a bounded reader, and scotty body in a file whose ScottyM block contains the read or names the handler, which gets setMaxRequestBodySize as that block\'s first statement (scotty 0.12 and later; refused when the block is in another file or a cabal bound excludes those versions); this is neither shape. The decision needed: the limit for this input, and what happens past it (reject with 413, truncate, spool to disk).',
|
|
1014
1109
|
'CWE-295': 'enabling certificate validation can break connections the project depends on. Only the connection package\'s TLSSettingsSimple with a literal True is flipped. The decision needed: the trust store (the system store, a pinned CA bundle, a private authority) and host-name verification; an OpenSSL VerifyNone is not rewritten because VerifyPeer verifies nothing until a CA bundle is loaded and does not check the host name.',
|
|
1015
1110
|
'CWE-798': 'a hard-coded secret must be rotated and moved to a secret store; rewriting the line would leave the leaked value valid. The decision needed: where the secret is read from at run time (environment, a secrets manager, a file outside the repository) and rotating the exposed value, which no edit of this file can do.',
|
|
1016
1111
|
'CWE-611': 'XML entity handling depends on the parser configuration in use. The decision needed: whether this document source is trusted and which entity and DTD features the parser may enable.',
|
|
@@ -1132,6 +1227,17 @@ export function haskellEffectiveCheck(plan, patched) {
|
|
|
1132
1227
|
if (!new RegExp(`getEntropy\\s+${want.bytes}\\s*$`).test(d)) return { ok: false, ran: true, detail: `the action does not read ${want.bytes} entropy byte(s)` };
|
|
1133
1228
|
return { ok: true, ran: true, detail: `${want.fn} reads ${want.bytes} byte(s) from getEntropy and no longer calls System.Random` };
|
|
1134
1229
|
}
|
|
1230
|
+
if (want.kind === 'scotty-body-limit') {
|
|
1231
|
+
const count = (t, re) => (stripComments(t).match(re) || []).length;
|
|
1232
|
+
const limits = (t) => count(t, /(?<![\w'])(?:[A-Z][\w']*\.)?setMaxRequestBodySize(?![\w'])/g);
|
|
1233
|
+
const bodies = (t) => count(t, /(?<![\w'.])body(?![\w'])/g);
|
|
1234
|
+
if (limits(after) !== limits(before) + 1) return { ok: false, ran: true, detail: 'exactly one setMaxRequestBodySize call should have been added' };
|
|
1235
|
+
const ls = stripComments(after).split('\n');
|
|
1236
|
+
if (ls[want.line - 1] !== want.statement) return { ok: false, ran: true, detail: `the limit is not the statement at line ${want.line} as planned` };
|
|
1237
|
+
if (bodies(after) !== bodies(before)) return { ok: false, ran: true, detail: 'the body reads changed; the patch should only add the limit' };
|
|
1238
|
+
if (!new RegExp(`^${SCOTTY_LIMIT_CONST}\\s*=\\s*${SCOTTY_LIMIT_KB}\\b`, 'm').test(after)) return { ok: false, ran: true, detail: 'the declared limit is not the one the patch states' };
|
|
1239
|
+
return { ok: true, ran: true, detail: `setMaxRequestBodySize is the first statement of the application block, with the declared constant (${SCOTTY_LIMIT_KB} KB), and no body read changed` };
|
|
1240
|
+
}
|
|
1135
1241
|
if (want.kind === 'body') {
|
|
1136
1242
|
const count = (t, re) => (stripComments(t).match(re) || []).length;
|
|
1137
1243
|
const calls = (t) => count(t, /(?<![\w'])(?:[A-Z][\w']*\.)?strictRequestBody(?![\w'])/g);
|
|
@@ -43,6 +43,26 @@ const IO_NAMES = new Set(['putStr', 'putStrLn', 'print', 'getLine', 'getContents
|
|
|
43
43
|
|
|
44
44
|
// name -> positions of the function, the collection and (folds) the seed.
|
|
45
45
|
const BODY_SETTER = /^Network\.HTTP\.(?:Simple|Conduit|Client)\.setRequestBody(?:JSON|LBS|BS|URLEncoded|File)$/;
|
|
46
|
+
// A mutable cell carries what was written to it: `writeIORef ref v` assigns the cell's variable, `readIORef ref` reads it, `modifyIORef ref f`
|
|
47
|
+
// is `ref = f ref`. The same for MVar, TVar and STRef. Only a cell held in a LOCAL variable of the function that reads it is modelled
|
|
48
|
+
// (created there, or handed in as a parameter): a cell written in one function and read in another is not followed (see the guide's limits).
|
|
49
|
+
const CELL_OPS = Object.freeze({
|
|
50
|
+
'Data.IORef.writeIORef': 'write', 'Data.IORef.readIORef': 'read', 'Data.IORef.modifyIORef': 'modify', "Data.IORef.modifyIORef'": 'modify', 'Data.IORef.atomicWriteIORef': 'write',
|
|
51
|
+
'Control.Concurrent.MVar.putMVar': 'write', 'Control.Concurrent.MVar.takeMVar': 'read', 'Control.Concurrent.MVar.readMVar': 'read', 'Control.Concurrent.MVar.modifyMVar_': 'modify', 'Control.Concurrent.MVar.swapMVar': 'write',
|
|
52
|
+
'Control.Concurrent.putMVar': 'write', 'Control.Concurrent.takeMVar': 'read', 'Control.Concurrent.readMVar': 'read', 'Control.Concurrent.modifyMVar_': 'modify',
|
|
53
|
+
'Control.Concurrent.STM.writeTVar': 'write', 'Control.Concurrent.STM.readTVar': 'read', 'Control.Concurrent.STM.readTVarIO': 'read', 'Control.Concurrent.STM.modifyTVar': 'modify', "Control.Concurrent.STM.modifyTVar'": 'modify',
|
|
54
|
+
'Control.Concurrent.STM.TVar.writeTVar': 'write', 'Control.Concurrent.STM.TVar.readTVar': 'read', 'Control.Concurrent.STM.TVar.readTVarIO': 'read', 'Control.Concurrent.STM.TVar.modifyTVar': 'modify', "Control.Concurrent.STM.TVar.modifyTVar'": 'modify',
|
|
55
|
+
'Data.STRef.writeSTRef': 'write', 'Data.STRef.readSTRef': 'read', 'Data.STRef.modifySTRef': 'modify', "Data.STRef.modifySTRef'": 'modify',
|
|
56
|
+
});
|
|
57
|
+
// `instance Runner Shell`, `instance Show a => Runner (Box a)`: the type constructor the instance is for (the first constructor after the class name).
|
|
58
|
+
function instanceTypeOf(inst) {
|
|
59
|
+
const head = String((inst && inst.headText) || '').replace(/^.*?=>\s*/, '');
|
|
60
|
+
const cls = inst && inst.name ? String(inst.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : null;
|
|
61
|
+
if (!cls) return null;
|
|
62
|
+
const after = head.replace(new RegExp(`^\\s*(?:[A-Z][\\w']*\\.)*${cls}(?![\\w'])`), '');
|
|
63
|
+
const m = /^[\s([]*(?:[A-Z][\w']*\.)*([A-Z][\w']*)/.exec(after);
|
|
64
|
+
return m ? m[1] : null;
|
|
65
|
+
}
|
|
46
66
|
const HOF = {
|
|
47
67
|
map: { fn: 0, coll: 1, kind: 'map' }, fmap: { fn: 0, coll: 1, kind: 'map' }, concatMap: { fn: 0, coll: 1, kind: 'map' },
|
|
48
68
|
traverse: { fn: 0, coll: 1, kind: 'traverse' }, mapM: { fn: 0, coll: 1, kind: 'traverse' },
|
|
@@ -232,6 +252,11 @@ function exportedLookup(proj, target, name, depth, seen) {
|
|
|
232
252
|
if (depth > HS_IR_LIMITS.maxResolveDepth) return { kind: 'unknown', reason: 'resolution-depth-cap' };
|
|
233
253
|
const next = new Set(seen); next.add(key);
|
|
234
254
|
if (!target.exports) return target.defs.has(name) ? defResult(target, name) : null;
|
|
255
|
+
// A wildcard re-export of a module the project does not contain (`module CorePrelude` over `import CorePrelude hiding (..)`) can only say the
|
|
256
|
+
// name MIGHT come from there. It is kept as the fallback while the rest of the export list is searched: a definition the project holds, or a
|
|
257
|
+
// name an import lists, is the answer when one exists. IHP's preludes re-export `CorePrelude` first and `IHP.Controller.Param` later, and
|
|
258
|
+
// returning the possibility first made every `param` an unknown external name instead of the in-tree request read.
|
|
259
|
+
let fallback = null;
|
|
235
260
|
for (const ex of target.exports) {
|
|
236
261
|
if (ex.kind === 'var' && ex.name === name) return resolveGlobal(proj, target, name, ex.qual || null, depth + 1, next);
|
|
237
262
|
if (ex.kind === 'type' && ex.all) {
|
|
@@ -245,11 +270,13 @@ function exportedLookup(proj, target, name, depth, seen) {
|
|
|
245
270
|
if (imp.items && (imp.hiding ? imp.items.includes(name) : !imp.items.includes(name))) continue;
|
|
246
271
|
const t2 = proj.modules.get(imp.module);
|
|
247
272
|
const r = t2 ? exportedLookup(proj, t2, name, depth + 1, next) : { kind: 'external', module: imp.module, name, certain: !!(imp.items && !imp.hiding && imp.items.includes(name)) };
|
|
248
|
-
if (r)
|
|
273
|
+
if (!r) continue;
|
|
274
|
+
if (r.kind === 'external' && !r.certain) { if (!fallback) fallback = r; continue; }
|
|
275
|
+
return r;
|
|
249
276
|
}
|
|
250
277
|
}
|
|
251
278
|
}
|
|
252
|
-
return
|
|
279
|
+
return fallback;
|
|
253
280
|
}
|
|
254
281
|
|
|
255
282
|
// A top-level resolution (depth 0, nothing seen yet) is a pure function of the module table, which is complete before any function
|
|
@@ -285,7 +312,9 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
285
312
|
const owner = r.kind === 'def' ? (r.def.kind === 'field' ? (proj.modules.get(r.mod)?.cons.get(r.def.con)?.type) : r.def.kind === 'method' ? r.def.cls : null) : null;
|
|
286
313
|
if (!owner || !(imp.typeAll || []).includes(owner)) continue;
|
|
287
314
|
}
|
|
288
|
-
if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; }
|
|
315
|
+
if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; }
|
|
316
|
+
else if (r.kind === 'external' && !r.certain) wildcard.push(r.module || imp.module); // a re-export chain's "might come from there" is a possibility, not a hit that makes a definition ambiguous
|
|
317
|
+
else hits.push(r);
|
|
289
318
|
} else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
|
|
290
319
|
else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
|
|
291
320
|
}
|
|
@@ -590,6 +619,11 @@ class FnLowerer {
|
|
|
590
619
|
const r = this.resolve(e.name, e.qual);
|
|
591
620
|
if (r.kind === 'alias') return r.expr;
|
|
592
621
|
if (r.kind === 'def' && r.def.kind === 'field') return { kind: 'ident', name: e.name, hs: { fieldSelector: r.name } };
|
|
622
|
+
if (r.kind === 'def' && r.def.kind === 'fun' && isKnownApi(r.mod, r.name)) {
|
|
623
|
+
// a modelled name the project itself defines (see applyVar): the registry's meaning, the definition still referenced
|
|
624
|
+
this.noteRef(r.qid, 'modelled-definition', ln);
|
|
625
|
+
return { kind: 'ident', name: `${r.mod}.${r.name}`, hs: { resolution: 'external', certain: true, modelledDefinition: r.qid } };
|
|
626
|
+
}
|
|
593
627
|
if (r.kind === 'def' || r.kind === 'localfn') {
|
|
594
628
|
this.noteRef(r.qid, 'reference', ln);
|
|
595
629
|
return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
|
|
@@ -683,6 +717,16 @@ class FnLowerer {
|
|
|
683
717
|
if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
|
|
684
718
|
}
|
|
685
719
|
if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
|
|
720
|
+
// `exec ops x` where `ops` is a top-level constant record whose `exec` field holds a function: the application is of THAT function. The
|
|
721
|
+
// held function is found in the constant's record syntax (`Ops { exec = \x -> .. }` or `Ops { exec = pingHost }`); a positional
|
|
722
|
+
// construction, a field set by an update, or a record built at run time stay an indirect call.
|
|
723
|
+
const held = argAsts.length > 1 ? this.heldFieldFunction(r, argAsts[0], ln) : null;
|
|
724
|
+
if (held) {
|
|
725
|
+
const lowered = argAsts.slice(1).map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
|
|
726
|
+
const savedMod = this.mod; const savedOverride = this.lineOverride;
|
|
727
|
+
this.lineOverride = this.lineOverride || ln; this.inlineDepth = (this.inlineDepth || 0) + 1; this.mod = held.mod;
|
|
728
|
+
try { return this.lowerApp(held.expr, lowered, ln, via); } finally { this.mod = savedMod; this.lineOverride = savedOverride; this.inlineDepth -= 1; }
|
|
729
|
+
}
|
|
686
730
|
const obj = this.lowerExpr(argAsts[0], ln);
|
|
687
731
|
const mem = { kind: 'member', object: obj, prop: r.name };
|
|
688
732
|
return argAsts.length === 1 ? mem : { kind: 'call', callee: '<indirect>', args: [mem, ...argAsts.slice(1).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'field-applied' } };
|
|
@@ -700,7 +744,18 @@ class FnLowerer {
|
|
|
700
744
|
if (selector) hs.selector = selector;
|
|
701
745
|
let callee = f.qual ? `${f.qual}.${f.name}` : f.name;
|
|
702
746
|
const arity = (r.kind === 'def' && r.def.kind === 'fun') ? r.def.arity : (r.kind === 'localfn' ? r.arity : -1);
|
|
703
|
-
if (
|
|
747
|
+
if (r.kind === 'def' && r.def.kind === 'fun' && isKnownApi(r.mod, r.name)) {
|
|
748
|
+
// The project defines a module the model registry models: the project IS the framework (a framework repository scanning itself) or
|
|
749
|
+
// carries a copy of it. The registry says what the name means to its caller (a request read, a sink, a sanitizer), which the
|
|
750
|
+
// definition's body, written in terms the registry does not model (an implicit request parameter, a parser over it), does not show.
|
|
751
|
+
// The call is lowered as the library call; the definition is still referenced, so it is never an uncalled entry point, and its own
|
|
752
|
+
// body is still analysed as a function of the project. Pinned both ways in test/haskell/haskell-taint-shapes.test.js.
|
|
753
|
+
this.noteRef(r.qid, 'modelled-definition', ln);
|
|
754
|
+
callee = `${r.mod}.${r.name}`;
|
|
755
|
+
hs.status = 'external'; hs.certain = true; hs.modelledDefinition = r.qid;
|
|
756
|
+
hs.effect = r.def.sig ? (r.def.sig.returnsIO ? 'io' : 'pure') : (IO_NAMES.has(f.name) ? 'io' : 'unknown');
|
|
757
|
+
if (hs.effect === 'io') this.sawIO = true;
|
|
758
|
+
} else if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'localfn') {
|
|
704
759
|
callee = this.canonical(r);
|
|
705
760
|
hs.status = 'resolved'; hs.target = r.qid;
|
|
706
761
|
if (r.kind === 'def' && arity === args.length && arity > 0 && (this.inlineDepth || 0) < INLINE_MAX_DEPTH) {
|
|
@@ -722,6 +777,10 @@ class FnLowerer {
|
|
|
722
777
|
hs.status = 'typeclass'; hs.cls = r.def.cls; hs.method = r.name; hs.target = r.qid;
|
|
723
778
|
hs.candidates = this.proj.instancesOf(r.def.cls, r.name);
|
|
724
779
|
hs.effect = 'unknown';
|
|
780
|
+
// `run Shell x`: an argument that is a constructor of a type with exactly one visible instance of the class names the instance, so the
|
|
781
|
+
// call is a call of that instance's method (a resolved edge, not a set of candidates). An argument of unknown type keeps the candidates.
|
|
782
|
+
const picked = this.dispatchInstance(r.def.cls, r.name, args);
|
|
783
|
+
if (picked) { callee = picked.name; hs.status = 'resolved'; hs.target = picked.qid; hs.dispatchedOn = picked.type; delete hs.candidates; }
|
|
725
784
|
} else if (r.kind === 'param') {
|
|
726
785
|
hs.status = 'param'; hs.paramIndex = r.index; hs.effect = 'unknown';
|
|
727
786
|
} else if (r.kind === 'local') {
|
|
@@ -747,6 +806,10 @@ class FnLowerer {
|
|
|
747
806
|
if (ctxTargets.some((t) => t.length)) hs.argTargets = ctxTargets;
|
|
748
807
|
}
|
|
749
808
|
const call = { kind: 'call', callee, args, line: ln, hs };
|
|
809
|
+
if (hs.status === 'external' || hs.status === 'builtin') {
|
|
810
|
+
const cell = this.lowerCellOp(callee, args, argAsts, ln, call);
|
|
811
|
+
if (cell) return cell;
|
|
812
|
+
}
|
|
750
813
|
// `setRequestBodyJSON body req` returns the REQUEST with a body: the body's taint is a property of the body, and
|
|
751
814
|
// must not make the request's URL tainted. The call still gets its own node so a sink on the body argument fires.
|
|
752
815
|
if ((hs.status === 'external' || hs.status === 'builtin') && BODY_SETTER.test(callee) && args.length >= 2) {
|
|
@@ -757,6 +820,64 @@ class FnLowerer {
|
|
|
757
820
|
return (hs.status === 'external' || hs.status === 'builtin') && isSourceApi(callee) ? this.sourceValue(call, ln) : call;
|
|
758
821
|
}
|
|
759
822
|
|
|
823
|
+
// The instance a class-method call dispatches to, when an argument is a constructor (nullary, positional or record) of a type that has
|
|
824
|
+
// exactly one visible instance of the class. Several candidate types, or no constructor argument, is null (the candidates stay).
|
|
825
|
+
dispatchInstance(cls, method, args) {
|
|
826
|
+
const cands = this.proj.instancesOf(cls, method);
|
|
827
|
+
if (!cands.length) return null;
|
|
828
|
+
const types = new Set();
|
|
829
|
+
for (const a of args) {
|
|
830
|
+
const con = a && a.hs && a.hs.con;
|
|
831
|
+
if (!con) continue;
|
|
832
|
+
const c = this.mod.cons.get(con) || this.proj.findCon(con);
|
|
833
|
+
if (c && c.type) types.add(c.type);
|
|
834
|
+
}
|
|
835
|
+
if (types.size !== 1) return null;
|
|
836
|
+
const [type] = types;
|
|
837
|
+
const hits = cands.filter((q) => { const m = this.proj.instanceMeta.get(q); return m && m.type === type; });
|
|
838
|
+
if (hits.length !== 1) return null;
|
|
839
|
+
return { qid: hits[0], name: this.proj.instanceMeta.get(hits[0]).name, type };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// The function a record field of a top-level constant holds: `ops = Ops { exec = \x -> .. }` for `exec ops`. Returns the field's expression
|
|
843
|
+
// and the module it was written in, or null (the constant is not a single record literal, or the field is not set there).
|
|
844
|
+
heldFieldFunction(r, objAst, ln) {
|
|
845
|
+
if ((this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
|
|
846
|
+
const u = unparen(objAst);
|
|
847
|
+
if (!u || u.t !== 'var') return null;
|
|
848
|
+
const r0 = this.resolve(u.name, u.qual);
|
|
849
|
+
if (r0.kind !== 'def' || r0.def.kind !== 'fun' || r0.def.arity !== 0) return null;
|
|
850
|
+
const mod = this.proj.modules.get(r0.mod);
|
|
851
|
+
const fnAst = mod && mod.groups.funs.find((f) => f.name === r0.name);
|
|
852
|
+
if (!fnAst || fnAst.clauses.length !== 1) return null;
|
|
853
|
+
const c = fnAst.clauses[0];
|
|
854
|
+
if (c.pats.length || !c.rhs || (c.rhs.guards && c.rhs.guards.length) || (c.rhs.where && c.rhs.where.length) || !c.rhs.body) return null;
|
|
855
|
+
const body = unparen(c.rhs.body);
|
|
856
|
+
if (!body || body.t !== 'rec' || !body.con || body.con.name !== r.def.con) return null;
|
|
857
|
+
const fld = (body.fields || []).find((f) => !f.wildcard && f.name === r.name && f.e);
|
|
858
|
+
if (!fld) return null;
|
|
859
|
+
this.noteRef(r0.qid, 'reference', ln);
|
|
860
|
+
return { mod, expr: fld.e };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// A mutable cell held in a local variable (see CELL_OPS): a write is an assignment of the cell's variable, a read is that variable, a modify is
|
|
864
|
+
// the function applied to it. Anything else (a cell that is not a local variable, a missing argument) is the plain call.
|
|
865
|
+
lowerCellOp(callee, args, argAsts, ln, call) {
|
|
866
|
+
const op = CELL_OPS[callee];
|
|
867
|
+
if (!op) return null;
|
|
868
|
+
const ref = args[0];
|
|
869
|
+
if (!ref || ref.kind !== 'ident' || !ref.name || ref.hs) return null;
|
|
870
|
+
this.sawIO = true;
|
|
871
|
+
if (op === 'read') return { kind: 'ident', name: ref.name };
|
|
872
|
+
if (op === 'write' && args.length >= 2) { this.cfg.add('assign', ln, { target: ref.name, source: args[1], hs: { demand: 'executed', cellWrite: callee } }); return call; }
|
|
873
|
+
if (op === 'modify' && argAsts.length >= 2) {
|
|
874
|
+
const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: { kind: 'ident', name: ref.name } }], ln, callee);
|
|
875
|
+
this.cfg.add('assign', ln, { target: ref.name, source: applied, hs: { demand: 'executed', cellWrite: callee } });
|
|
876
|
+
return call;
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
|
|
760
881
|
// A modelled source read gets its own assignment node, so the evidence chain names the exact line the
|
|
761
882
|
// untrusted data entered, even when the read sits inside a larger expression (`f =<< getLine`).
|
|
762
883
|
sourceValue(call, ln) {
|
|
@@ -813,7 +934,8 @@ class FnLowerer {
|
|
|
813
934
|
try {
|
|
814
935
|
inl.params.forEach((name, i) => this.declare(name, { kind: 'alias', expr: loweredArgs[i] }));
|
|
815
936
|
this.mod = calleeMod;
|
|
816
|
-
|
|
937
|
+
// the lowered arguments are kept on the edge: a rule asking what a helper's parameters receive reads them (haskell-security-rules.js requestTextParams)
|
|
938
|
+
(this.inlinedCalls ||= []).push({ site: null, callee: this.canonical(r), args: loweredArgs, line: ln, hs: { status: 'resolved', target: r.qid, effect: 'unknown', inlined: true, ...(via ? { via } : {}) } });
|
|
817
939
|
const out = this.lowerExpr(inl.body, ln);
|
|
818
940
|
return out && typeof out === 'object' ? { ...out, hs: { ...(out.hs || {}), inlinedFrom: r.qid } } : out;
|
|
819
941
|
} finally {
|
|
@@ -1297,6 +1419,7 @@ class Project {
|
|
|
1297
1419
|
this.fns = [];
|
|
1298
1420
|
this.byFile = new Map();
|
|
1299
1421
|
this.instanceIndex = new Map(); // `${cls}.${method}` -> qids
|
|
1422
|
+
this.instanceMeta = new Map(); // instance-method qid -> { type, name }: the type the instance is for and the function's call-graph name
|
|
1300
1423
|
this.diagnostics = [];
|
|
1301
1424
|
}
|
|
1302
1425
|
|
|
@@ -1467,7 +1590,11 @@ function buildHaskellIRUncached(fileContents) {
|
|
|
1467
1590
|
const key = `${inst.name}.${f.name}`;
|
|
1468
1591
|
if (!proj.instanceIndex.has(key)) proj.instanceIndex.set(key, []);
|
|
1469
1592
|
proj.instanceIndex.get(key).push(qid);
|
|
1470
|
-
|
|
1593
|
+
// the call-graph name carries the instance's type when it is known, so two instances of one class resolve to different functions by name
|
|
1594
|
+
const type = instanceTypeOf(inst);
|
|
1595
|
+
const name = `${mod.name}.$inst.${inst.name}${type ? `.${type}` : ''}.${f.name}`;
|
|
1596
|
+
proj.instanceMeta.set(qid, { type, name });
|
|
1597
|
+
instQids.push({ mod, inst, f, qid, name });
|
|
1471
1598
|
}
|
|
1472
1599
|
}
|
|
1473
1600
|
}
|
|
@@ -1481,8 +1608,8 @@ function buildHaskellIRUncached(fileContents) {
|
|
|
1481
1608
|
try { proj.addFn(lowerPatbind(proj, mod, pb)); } catch (err) { proj.diagnostics.push({ file: mod.file, line: pb.line, kind: 'ir-lowering-failure', detail: String(err && err.message) }); }
|
|
1482
1609
|
}
|
|
1483
1610
|
}
|
|
1484
|
-
for (const { mod, inst, f, qid } of instQids) {
|
|
1485
|
-
const info = { qid, name
|
|
1611
|
+
for (const { mod, inst, f, qid, name } of instQids) {
|
|
1612
|
+
const info = { qid, name, line: f.line, parentName: name };
|
|
1486
1613
|
try {
|
|
1487
1614
|
const L = new FnLowerer(proj, mod, info, []);
|
|
1488
1615
|
L.lowerClauses(f.clauses, f.line);
|
|
@@ -398,6 +398,13 @@ export const HS_RULE_APIS = Object.freeze([
|
|
|
398
398
|
// mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
|
|
399
399
|
...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
|
|
400
400
|
...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
|
|
401
|
+
// mutable cells (haskell-ir.js CELL_OPS): named here so the IR qualifies them whichever wildcard imports a module has
|
|
402
|
+
...E('Data.IORef', ['newIORef', 'readIORef', 'writeIORef', 'modifyIORef', "modifyIORef'", 'atomicWriteIORef']),
|
|
403
|
+
...E('Control.Concurrent.MVar', ['newMVar', 'newEmptyMVar', 'putMVar', 'takeMVar', 'readMVar', 'modifyMVar_', 'swapMVar']),
|
|
404
|
+
...E('Control.Concurrent', ['newMVar', 'newEmptyMVar', 'putMVar', 'takeMVar', 'readMVar', 'modifyMVar_']),
|
|
405
|
+
...E('Control.Concurrent.STM', ['atomically', 'newTVar', 'newTVarIO', 'readTVar', 'readTVarIO', 'writeTVar', 'modifyTVar', "modifyTVar'"]),
|
|
406
|
+
...E('Control.Concurrent.STM.TVar', ['newTVar', 'newTVarIO', 'readTVar', 'readTVarIO', 'writeTVar', 'modifyTVar', "modifyTVar'"]),
|
|
407
|
+
...E('Data.STRef', ['newSTRef', 'readSTRef', 'writeSTRef', 'modifySTRef', "modifySTRef'"]),
|
|
401
408
|
...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
|
|
402
409
|
...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
|
|
403
410
|
...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
|
|
@@ -441,8 +448,8 @@ export const HS_WEB_FRAMEWORKS = Object.freeze({
|
|
|
441
448
|
yesod: { modules: ['Yesod', 'Yesod.Core', 'Yesod.Auth'], package: 'yesod', tested: ['1.6'] },
|
|
442
449
|
});
|
|
443
450
|
export const HS_WEB_APIS = Object.freeze([
|
|
444
|
-
...E('Web.Scotty', ['get', 'post', 'put', 'delete', 'patch', 'options', 'addroute', 'matchAny', 'middleware', 'scotty', 'scottyApp', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'file', 'setHeader', 'header', 'headers', 'param', 'params', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'files', 'rescue', 'liftIO', 'notFound']),
|
|
445
|
-
...E('Web.Scotty.Trans', ['get', 'post', 'put', 'delete', 'patch', 'middleware', 'scottyT', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'header', 'param', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData']),
|
|
451
|
+
...E('Web.Scotty', ['get', 'post', 'put', 'delete', 'patch', 'options', 'addroute', 'matchAny', 'middleware', 'scotty', 'scottyApp', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'file', 'setHeader', 'header', 'headers', 'param', 'params', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'files', 'rescue', 'liftIO', 'notFound', 'setMaxRequestBodySize']),
|
|
452
|
+
...E('Web.Scotty.Trans', ['get', 'post', 'put', 'delete', 'patch', 'middleware', 'scottyT', 'status', 'finish', 'raise', 'raiseStatus', 'redirect', 'text', 'json', 'html', 'header', 'param', 'captureParam', 'queryParam', 'formParam', 'body', 'jsonData', 'setMaxRequestBodySize']),
|
|
446
453
|
...E('Network.Wai', ['pathInfo', 'requestMethod', 'requestHeaders', 'responseLBS', 'queryString', 'rawPathInfo']),
|
|
447
454
|
...E('Network.Wai.Handler.Warp', ['run', 'runSettings']),
|
|
448
455
|
...E('Network.HTTP.Types', ['status200', 'status201', 'status400', 'status401', 'status403', 'status404', 'unauthorized401', 'forbidden403', 'methodGet', 'methodPost', 'methodPut', 'methodDelete', 'hAuthorization', 'hCookie']),
|