@clear-capabilities/agentic-security-scanner 0.195.13 → 0.195.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/dist/{1f01fe10027e772f73a1.json → 9699866704e8a4a5839f.json} +2 -2
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/dataflow/engine.js +19 -4
- package/src/language/CLAUDE.md +3 -0
- package/src/language/haskell-ir.js +88 -14
- package/src/language/haskell-models.js +11 -0
- package/src/language/haskell-parser.js +104 -4
- package/src/language/haskell-security-rules.js +41 -2
- package/src/language/haskell-syntax.js +86 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3d395cf95358af8a372a685bf6119f46dac36e3502237a525812949a53776c3c agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.195.
|
|
3
|
+
"version": "0.195.15",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin: SAST, SCA with reachability and CISA KEV, secrets, IaC, Haskell and Nix/NixOS, prompt-injection, MCP/agent-tool audit, authZ analysis, SBOM, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"test:lineage": "node --test test/lineage/analysis-completion.test.js test/lineage/schema.test.js test/lineage/ids.test.js test/lineage/protection.test.js test/lineage/classification.test.js test/lineage/validate.test.js test/lineage/json-schema-parity.test.js test/lineage/flagship-fixture.test.js test/lineage/flagship-fixture-semantics.test.js test/lineage/field-identity.test.js test/lineage/engine-expr-resolver.test.js test/lineage/engine-walker.test.js test/lineage/engine-integration.test.js test/lineage/engine-receiver-identity.test.js test/lineage/summaries.test.js test/lineage/driver.test.js test/lineage/engine-provenance.test.js test/lineage/engine-provenance-interprocedural.test.js test/lineage/path-store.test.js test/lineage/path-query.test.js test/lineage/flow-grade.test.js test/lineage/source-registry.test.js test/lineage/sink-registry.test.js test/lineage/transform-catalog.test.js test/lineage/handling-analyzer.test.js test/lineage/registry-real-code.test.js test/lineage/source-seeding.test.js test/lineage/graph-builder.test.js test/lineage/coverage.test.js test/lineage/resolve-destination.test.js test/lineage/queue-detail.test.js test/lineage/index.test.js test/lineage/obligation-mapping.test.js test/lineage/obligation-predicates.test.js test/lineage/obligation-predicates-walkthrough.test.js test/lineage/transit-protection.test.js test/lineage/at-rest-protection.test.js test/lineage/protection-summary.test.js test/lineage/ac01-multi-sink.test.js test/lineage/policy-verdict.test.js test/lineage/governance-refs.test.js test/lineage/export-privacy.test.js test/lineage/export-json.test.js test/lineage/export-csv.test.js test/lineage/decision-story.test.js test/lineage/export-briefing.test.js test/lineage/graph-snapshot.test.js test/lineage/graph-diff.test.js test/lineage/drift-policy.test.js test/lineage/recipient-profile.test.js test/lineage/recipient-registry.test.js test/lineage/recipient-wiring.test.js test/lineage/redact-graph.test.js test/lineage-scan-wiring.test.js test/lineage-artifact-write.test.js test/bench-data-lineage-runner.test.js test/bundle-frontend.test.js test/bundle-frontend-golden.test.js test/generate-html-report.test.js test/export-image.test.js test/lineage/scenario.test.js test/lineage/scenario-engine.test.js test/lineage/scenario-diff.test.js test/lineage/scenario-no-obligation-wiring.test.js test/lineage/impact-assessment.test.js test/lineage/impact-engine.test.js test/lineage/impact-no-obligation-wiring.test.js test/lineage/governance-edit.test.js test/lineage/remediation.test.js test/lineage/runtime-observation.test.js test/lineage/observation-correlation.test.js test/lineage/observation-adapters.test.js test/lineage/observation-store.test.js test/lineage/runtime-corroboration-wiring.test.js test/lineage/cross-repo-link.test.js test/lineage/federation-loader.test.js test/lineage/cross-repo-link-wiring.test.js test/lineage/runtime-assurance.test.js test/lineage/runtime-edge-states.test.js test/lineage/runtime-explorer-view.test.js test/cli/runtime-explorer-cli.test.js test/cli/runtime-identity-cli.test.js test/cli/runtime-twin-cli.test.js test/lineage/cut-planner.test.js test/lineage/scc.test.js test/lineage/scc-solver.test.js test/graph/graph-views.test.js test/lineage/dag07-completion.test.js test/lineage/owned-build.test.js test/dataflow/boundary-propagation.test.js test/lineage/boundary-contract.test.js test/lineage/correlation-v2.test.js test/lineage/path-control-evidence.test.js test/lineage/smt-z3-backend.test.js test/cli/dag-commands.test.js",
|
|
94
94
|
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
|
|
95
95
|
"test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
|
|
96
|
-
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
96
|
+
"test:haskell": "node --test test/haskell/haskell-reachability.test.js test/haskell/haskell-real-code-shapes.test.js test/haskell/haskell-unseen-v5-shapes.test.js test/haskell/haskell-unseen-v4-shapes.test.js test/haskell/haskell-alias-taint.test.js test/haskell/haskell-weak-hash-names.test.js test/haskell/haskell-models-extended.test.js test/haskell/haskell-rules-extended.test.js test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-remediation-0190.test.js test/haskell/haskell-remediation-kdf.test.js test/haskell/haskell-remediation-html-text.test.js test/haskell/haskell-remediation-scotty-body.test.js test/haskell/haskell-taint-shapes.test.js test/haskell/haskell-real-advisory-shapes.test.js test/haskell/haskell-discovery-shapes.test.js test/haskell/haskell-quasiquote-interp.test.js test/haskell/haskell-record-dot.test.js test/haskell/haskell-global-cell.test.js test/haskell/haskell-syntax-extensions.test.js test/haskell/haskell-rule-precision.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-sca-severity.test.js test/haskell/haskell-web-guards.test.js test/haskell/haskell-advisory-feed.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js test/haskell/haskell-boundaries.test.js test/haskell/haskell-live-feed-scan.test.js test/haskell/haskell-supply-bounds.test.js test/haskell/haskell-ir-scaling.test.js test/haskell/haskell-boundary-propagation.test.js test/haskell/haskell-generalization.test.js test/haskell/haskell-trust-boundary.test.js test/haskell/haskell-comment-invariance.test.js test/haskell/haskell-remediation-classes.test.js test/haskell/haskell-trust-boundary-frameworks.test.js test/haskell/haskell-generic-detectors.test.js",
|
|
97
97
|
"test:nix": "node --test test/nix/nix-option-types.test.js test/nix/nix-development-shapes.test.js test/nix/nixos-hardening-daemon.test.js test/nix/nixos-hardening-extended.test.js test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-sca-severity.test.js test/nix/nix-haskell-closure-names.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js test/nix/nix-fix-multifile.test.js test/nix/nixos-evaluator-forms.test.js test/nix/nix-advisory-feed.test.js test/nix/nix-fix-prewrite.test.js test/nix/nix-boundary-scope.test.js",
|
|
98
98
|
"test:language": "node --test test/bench-haskell-precision.test.js test/bench-independent-haskell-panel.test.js test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/frozen-splits.test.js test/language/language-gap-fixes.test.js test/language/language-gap-fixes-2.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-parity-haskell.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
|
|
99
99
|
"test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
|
package/src/dataflow/engine.js
CHANGED
|
@@ -824,11 +824,13 @@ function _isInternalModuleCall(calleeExpr, callContext) {
|
|
|
824
824
|
const _JS_FILE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
825
825
|
function _isJsFile(file) { return typeof file === 'string' && _JS_FILE.test(file); }
|
|
826
826
|
|
|
827
|
-
function _arrayLiteralTaint(expr, prefix, state, callContext) {
|
|
827
|
+
function _arrayLiteralTaint(expr, prefix, state, callContext, bracket = false) {
|
|
828
828
|
const out = { whole: false, paths: [] };
|
|
829
829
|
(expr.elements || []).forEach((el, i) => {
|
|
830
830
|
if (!el) return;
|
|
831
|
-
|
|
831
|
+
// Haskell reads a tuple component as the member `[i]` (haskell-ir.js bindPattern), so its paths are written that way (`bracket`).
|
|
832
|
+
const seg = bracket ? `[${i}]` : String(i);
|
|
833
|
+
const path = prefix ? `${prefix}.${seg}` : seg;
|
|
832
834
|
if (el.kind === 'object') {
|
|
833
835
|
const inner = _objectLiteralTaint(el, path, state, callContext);
|
|
834
836
|
if (inner.whole) out.paths.push(path);
|
|
@@ -1632,6 +1634,8 @@ function step(node, stateIn, callContext) {
|
|
|
1632
1634
|
case 'assign': {
|
|
1633
1635
|
const src = exprIsSource(node.source, callContext);
|
|
1634
1636
|
const target = typeof node.target === 'string' ? node.target : null;
|
|
1637
|
+
// A Haskell module-level cell written with tainted data: every function reads it tainted from now on (see _cellSeeds).
|
|
1638
|
+
if (target && target.startsWith('$cell:') && !_cellSeeds.has(target) && exprTaint(node.source, state, callContext)) _cellSeeds.add(target);
|
|
1635
1639
|
// Sink matching is additive to this case's existing source/target/taint
|
|
1636
1640
|
// handling below: an assignment's RHS can itself be a sink call (e.g.
|
|
1637
1641
|
// `const rows = db.query(tainted)`), which previously went unreported
|
|
@@ -1829,10 +1833,13 @@ function step(node, stateIn, callContext) {
|
|
|
1829
1833
|
}
|
|
1830
1834
|
}
|
|
1831
1835
|
}
|
|
1832
|
-
|
|
1836
|
+
// Haskell: a tuple literal (`hs.tuple`) is assigned component by component the same way, so `(a, _) = (clean, tainted)` leaves `a` clean
|
|
1837
|
+
// (a `\cases` or a `case` on `(x, y)` matches through a tuple scratch variable). Other Haskell arrays (lists, constructors) keep the whole rule.
|
|
1838
|
+
const hsTuple = node.source && node.source.kind === 'array' && node.source.hs && node.source.hs.tuple && /\.l?hs$/i.test(_currentFile || '');
|
|
1839
|
+
if (target && node.source && node.source.kind === 'array' && !src && (_isJsFile(_currentFile) || hsTuple) && !(node.source.spreads && node.source.spreads.length)) {
|
|
1833
1840
|
// E2-03: an array literal without spread elements is assigned index by index, so `[tainted, 'ls']` leaves `arr[1]` clean. Reading the
|
|
1834
1841
|
// array as a whole (`arr.join()`, passing `arr`) is still tainted through the descendant rule. A literal with a spread keeps the whole-array rule.
|
|
1835
|
-
const lit = _arrayLiteralTaint(node.source, '', newState, callContext);
|
|
1842
|
+
const lit = _arrayLiteralTaint(node.source, '', newState, callContext, !!hsTuple);
|
|
1836
1843
|
newState = removePathAndDescendants(newState, target);
|
|
1837
1844
|
if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
|
|
1838
1845
|
for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
|
|
@@ -2228,8 +2235,13 @@ function _siteKey(expr) {
|
|
|
2228
2235
|
// only for fields some function assigns, so `req.other` stays clean. Cross-file middleware chains need a router model and are out of scope.
|
|
2229
2236
|
const _REQ_PARAM = /^(?:req|request|ctx)$/;
|
|
2230
2237
|
let _requestFieldSeeds = new Map(); // file -> Set<'field' | 'a.b'>
|
|
2238
|
+
// Haskell module-level cells (`$cell:<qid>`, haskell-ir.js lowerCellOp): a cell that any function wrote with tainted data is tainted at the
|
|
2239
|
+
// entry of every Haskell function from then on (flow-insensitive, project-wide, like a global). Filled while the fixed-point pre-pass walks
|
|
2240
|
+
// the writers; a change re-runs the pass so the readers see it.
|
|
2241
|
+
let _cellSeeds = new Set();
|
|
2231
2242
|
function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
|
|
2232
2243
|
_requestFieldSeeds = new Map();
|
|
2244
|
+
_cellSeeds = new Set();
|
|
2233
2245
|
for (const fn of fnList) {
|
|
2234
2246
|
if (!fn || !Array.isArray(fn.params) || !_REQ_PARAM.test(fn.params[0] || '') || !fn.cfg || !fn.cfg.nodes) continue;
|
|
2235
2247
|
const p0 = fn.params[0];
|
|
@@ -2253,6 +2265,7 @@ function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
|
|
|
2253
2265
|
}
|
|
2254
2266
|
|
|
2255
2267
|
function _unionAnnotationTaint(fn, entrySet) {
|
|
2268
|
+
if (_cellSeeds.size && /\.l?hs$/i.test(fn.file || '')) entrySet = new Set([...entrySet, ..._cellSeeds]);
|
|
2256
2269
|
const seeds = _requestFieldSeeds.get(fn.file);
|
|
2257
2270
|
if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
|
|
2258
2271
|
entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
|
|
@@ -2595,6 +2608,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2595
2608
|
// iteration; if nothing changed, the fixed point has been reached and
|
|
2596
2609
|
// further iterations would recompute byte-identical results.
|
|
2597
2610
|
let changedThisIter = false;
|
|
2611
|
+
const cellSeedsBefore = _cellSeeds.size;
|
|
2598
2612
|
for (const fn of fnList) {
|
|
2599
2613
|
if (Date.now() > deadlineMs) break;
|
|
2600
2614
|
// DAG-05 begin: a function proved clean is not re-walked; its recorded exit state and findings stand in.
|
|
@@ -2659,6 +2673,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2659
2673
|
// write, silently delivering 2 rounds of fixed-point refinement instead
|
|
2660
2674
|
// of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
|
|
2661
2675
|
// `changedThisIter` tracks actual value changes instead.
|
|
2676
|
+
if (_cellSeeds.size !== cellSeedsBefore) changedThisIter = true; // a newly tainted Haskell cell changes every reader's entry state
|
|
2662
2677
|
if (!changedThisIter) { _fpConverged = true; break; }
|
|
2663
2678
|
}
|
|
2664
2679
|
// DAG-05 begin
|
package/src/language/CLAUDE.md
CHANGED
|
@@ -66,6 +66,9 @@ or a capability.** Product-facing guides: `docs/guides/haskell.md`, `docs/guides
|
|
|
66
66
|
- **Guards are recognised by what they do.** `haskell-guards.js`: a project function is an HTML escaper when it (or its local helpers) spells out the entities for `<`, `>` and `&` (`htmlEscapers`; never by name), and it neutralises a CWE-79 flow only when EVERY use of the value in the sink argument is under such a call (`allUsesEscaped`); a host allow-list may be a named constant and may sit in an `&&`; `sh -c SCRIPT arg0 arg1` is shell injection only when the SCRIPT is built from the value, and tainted positional parameters are data when the constant script expands them only inside double quotes after `--` (`quotedPositionals`, which decodes the Haskell string escapes the IR keeps raw).
|
|
67
67
|
- **Handlers named by the framework's convention are routes.** `haskell-web.js` adds `getFooR`/`postFooR`/`handleFooR` functions whose signature mentions `Handler` in a module importing Yesod when no route table in an analysed file names them (the table usually lives in `config/routes`). Scotty's rejection order is judged by `inlineOrder` as well as by line: a `case` lists the success branch first, so `status 40x >> finish` in the LAST alternative guards it (a bare `status` with no halting call does not).
|
|
68
68
|
- **A project definition of a modelled name keeps the registry's meaning (since 0.195.11).** `haskell-ir.js` `applyVar`/`lowerVarValue`: a resolution to a project `def` whose module-qualified name `isKnownApi` (the project IS the framework, or vendors it) is lowered as the LIBRARY call (`hs.status: 'external'`, `hs.modelledDefinition: qid`) and the definition is still referenced (`noteRef(..., 'modelled-definition')`, so it is never an uncalled entry point). Before this, IHP's own `IHP.Controller.Param.param` shadowed the catalog when IHP scanned itself, and a request parameter reaching `Process.system` through a helper was not reported (four lost true positives on the fresh set). A test stub named like a catalog module gets the same treatment, which is why the html-text remediation test keeps its scotty stub out of the scan. Pinned both ways in `test/haskell/haskell-taint-shapes.test.js`, with the three shapes below.
|
|
69
|
+
- **Rule precision from semantics (since 0.195.15; `test/haskell/haskell-rule-precision.test.js`).** `haskell-security-rules.js`: a partial list function (`NONEMPTY_TOTAL`) on a list that is a cons, a non-empty literal, or a concatenation with one of those (`nonEmptyList`) is total and not reported; the conditions a function tests include the first argument of `when`/`unless`/`guard` (`COND_COMBINATOR`), not only `if` nodes; a comparison of a list's LENGTH with a number (`case length xs of 3`, `length xs == 2`; `LENGTH_CALL`, `lengthVars` for the scratch variable the IR puts a case scrutinee in) tests the list. `hash x :: Digest MD5` / `hashlazy x :: Digest SHA1`: `haskell-syntax.js` keeps a `Digest <algo>` annotation as a `typed` node (`DIGEST_TYPE`), `haskell-ir.js` returns the inner call stamped `hs.digest`, and `hs-weak-hash` treats it like `hashWith MD5`. `hs-unsafe-io-boundary` is already lowered to `info` for every site by `engine.js` (`originalSeverity` kept), so no shape rule was added for it.
|
|
70
|
+
- **Record dot, labels, global cells, lambda-form functions, more syntax (since 0.195.14).** `haskell-syntax.js` reads `r.field` / `(e).field` / `(.field)` as a field selection (`sel` token, `RECORD_DOT_PRAGMA`; the IR lowers `field r` to a member read of a known record field) and `#field` as a label value (`LABELS_PRAGMA`; the IR lowers it to a literal, so IHP's `get #f r` / `set #f v r` are library calls carrying the record's and the value's taint, not a field selection: a stated limit); both only under the LANGUAGE pragma. `\cases` (`lamcases`), `mdo` (a `do` under `RECDO_PRAGMA`; `rec` blocks are not read), GADT `data T where` constructors (names, record fields, arity by arrows) and `pattern P ..` (a `patsyn` declaration, not a function named `pattern`) parse. `haskell-ir.js` `lambdaBodied` rewrites `f = \x -> ..`, `f = \case ..`, `f = \cases ..` into `f x = ..` at the top level and in `let`/`where` (a call then resolves to the function instead of applying a closure value). `cellBodyOf` recognises a module-level cell (`name = unsafePerformIO (newIORef ..)`, MVar/TVar/STRef alike) and `lowerCellOp` names it `$cell:<qid>` for the whole project (a read is that ident with `hs.globalCell`, a write an assignment to it); `dataflow/engine.js` `_cellSeeds` records a cell any function assigned tainted data to and `_unionAnnotationTaint` seeds it at the entry of every Haskell function, the fixed-point pre-pass re-running when the set grows (flow-insensitive; the root is not carried across, so the trust verdict of the reader is `unattributed`). `aliasBodyOf` accepts a partial application of a library function to names or literals (`remember = writeIORef lastName`), with the argument names checked for shadowing. `haskell-models.js`: typed-process `proc` (CWE-78 program, CWE-88 arguments), esqueleto `rawSql`/`rawExecute`, optparse-applicative `execParser`/`customExecParser` as a `cli` source, versions recorded in `HS_PACKAGES`. Pinned in `test/haskell/haskell-{record-dot,global-cell,syntax-extensions}.test.js`.
|
|
71
|
+
- **String-interpolating quasi-quoters (since 0.195.14; `test/haskell/haskell-quasiquote-interp.test.js`).** `haskell-parser.js` `INTERP_QUOTERS` maps a module to its quoters and interpolation syntax (`hash` `#{e}`: Data.String.Interpolate, Data.String.Interpolate.IsString, Text.Shakespeare.Text; `dollar` `$name`/`${e}`: NeatInterpolation; `brace` `{e}`/`{e:spec}`: PyF); `interpolationsOf` reads them (escapes `$$`, `{{`; a format spec after a top-level `:` is dropped). An accepted quote is rewritten in place, same length and newlines, as `(iQ (e1) (e2))` (kind `quasiquote-interp`, non-opaque, assumption `upstream-quoter`); a literal-only body stays `quasiquote-inert`; an unimported module or an unknown quoter stays `quasiquote` (opaque). `haskell-ir.js` `applyVar` lowers an UNBOUND `iQ` application to a concatenation (`binary +`, `hs.concat`, `hs.quasiquote`), so the taint engine follows it and a sanitizer inside an interpolation is on the path. The template text is not modelled (no markup literal for `htmlSkeleton` sinks).
|
|
69
72
|
- **The discovery set and two layout rules (since 0.195.13; `bench/dag/discovery-haskell`, pinned in `test/haskell/haskell-discovery-shapes.test.js`).** Six projects the engine had never been run on (hoogle, the wai and servant monorepos, mighttpd2, postgres-websockets, the yesod scaffold), scanned once and every flagged site read and labelled (`labels.json`, `USE-LOG.md`): a DEVELOPMENT set, not the precision gate's fresh set and not the sealed set. Hoogle's request handler was dropped by two layout rules, fixed in BOTH parsers: a `where` that starts a line at an implicit block's own indent closes the block (Haskell's parse-error(t) rule; `haskell-parser.js` `openBlock`, as `haskell-syntax.js` `blockAfter` already did), and a `;` separates the items of an implicit block only on a line that starts at the block's indent and opened no nested block (`do`, `of`, `let`, `where`, `\case`) earlier on that line (`splitItems` in both; `s <- act pay; pure s` on one line of a nested `do`, `remod t = do (a,_) <- f t; pure a` in a `where`). The route-contract detector (`posture/api-contract.js` `contractScope`) now scopes a contract file to the package that holds it, which is what turned a servant monorepo's two example `swagger.json` files into 78 Haskell route findings.
|
|
70
73
|
- **Shapes from the real code behind published advisories (since 0.195.12; `bench/cve-replay/real-haskell-advisories.md`, pinned in `test/haskell/haskell-real-advisory-shapes.test.js`).** `haskell-syntax.js`: a record pattern accepts whitespace before its brace (`Con {..}`; a `{` after a constructor inside a pattern is always a record pattern), a top-level `|` after an `of`, a `\case` or a multiway `if` inside a list literal is that construct's guard and not a comprehension bar, a `$(..)`/`$$(..)`/`$name` in prefix position is one `splice` token when the module's LANGUAGE pragma enables TemplateHaskell (`TH_PRAGMA`; a cabal `default-extensions` entry is not seen, a stated limit) and `aexp` reads it as an opaque `{t:'splice'}` atom (the IR lowers it to an unknown value without a lowering-gap diagnostic, the parser's `th-splice` boundary already discloses it), and `var@Pat = ..` in a declaration is a pattern binding, not a function named by the variable. `haskell-parser.js`: the Hamlet replacement closes its parenthesis at the `|]` (it used to go after the last `}`, a newline when the interpolation ended its line, so the paren was lost and the function failed to parse). `haskell-ir.js`: `fieldFunctionCandidates(con, field)` (the project class) indexes the function values every construction of `con` stores in `field` (`Con { f = g }`, `Con{..}` with `f <- pure g` / `f = g` in the building clause, `case`/`if` branches, `pure`/`return` peeled, `const <$> e` / `fmap const e` / `const e` as `ignores`; a lambda or anything else is `opaque`); a record-pattern-bound variable remembers its field (`{kind:'local', field:{con,name}}`, set in `bindPattern`) and `applyFieldCandidates` lowers the application as the union over the candidates (names applied in the module of the construction, `ignores` as an unknown value not derived from the arguments), only when NO candidate is opaque. `dataflow/engine.js` `_nestedSinkFindings` walks the elements of a Haskell tuple or constructor `array` (`hs.tuple`/`hs.con`) like arguments, so a sink built inside `return (a, Con (sink ..))` is a site. `haskell-guards.js` `waiHtmlContext` returns `redirect` for a header list with `Location` and no content type and the finding is lowered to info (`redirect-response-body`). `haskell-models.js`: blaze-builder's HTML escapers are xss sanitizers (names read from the Hackage index, `tested: ['0.4.4']`). `posture/corpus-match.js` scores neither a `proven-*` finding nor an `info` finding with a `guardNote` (see `bench/cve-replay/CONTRIBUTING.md`).
|
|
71
74
|
- **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.
|
|
@@ -199,6 +199,7 @@ function buildModuleInfo(file, src, cpp) {
|
|
|
199
199
|
const pp = preprocessForSyntaxInfo(src, { file, cpp });
|
|
200
200
|
const parse = parseSyntax(pp.text);
|
|
201
201
|
const groups = groupDecls(parse.decls);
|
|
202
|
+
groups.funs = groups.funs.map(lambdaBodied);
|
|
202
203
|
const name = parse.module && parse.module.name ? parse.module.name : 'Main';
|
|
203
204
|
const mod = {
|
|
204
205
|
name, file, parse, groups, exports: parse.module ? parse.module.exports : null, imports: parse.imports,
|
|
@@ -479,14 +480,54 @@ function aliasBodyOf(fnAst) {
|
|
|
479
480
|
const c = fnAst.clauses[0];
|
|
480
481
|
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;
|
|
481
482
|
const names = [];
|
|
483
|
+
const argNames = []; // names the alias applies its function to (`remember = writeIORef lastName`): checked for shadowing, not for being library names
|
|
482
484
|
const ok = (e) => {
|
|
483
485
|
const x = unparen(e);
|
|
484
486
|
if (!x) return false;
|
|
485
487
|
if (x.t === 'var') { if (!x.qual) names.push(x.name); return x.name !== fnAst.name; }
|
|
486
488
|
if (x.t === 'op' && x.op === '.' && !x.qual) return ok(x.l) && ok(x.r);
|
|
489
|
+
// A partial application of a library function to names or literals is an alias of that function with those arguments fixed.
|
|
490
|
+
if (x.t === 'app') return ok(x.f) && x.args.every((a) => { const u = unparen(a); if (!u) return false; if (u.t === 'lit') return true; if (u.t === 'var') { if (!u.qual) argNames.push(u.name); return u.name !== fnAst.name; } return false; });
|
|
487
491
|
return false;
|
|
488
492
|
};
|
|
489
|
-
return ok(c.rhs.body) ? { body: c.rhs.body, names } : null;
|
|
493
|
+
return ok(c.rhs.body) ? { body: c.rhs.body, names, argNames } : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// `f = \x y -> ..`, `f = \case ..`, `f = \cases ..`: a function written in lambda form is the function `f x y = ..` (a `\case` takes one
|
|
497
|
+
// parameter and matches it; a `\cases` takes as many as its widest alternative and matches them as a tuple), so a call `f c` is resolved
|
|
498
|
+
// and analysed with the value it is given instead of being the unknown application of a closure value. Applied to top-level, `let` and
|
|
499
|
+
// `where` bindings alike (a guard or a `where` on the binding keeps it as written).
|
|
500
|
+
function lambdaBodied(f) {
|
|
501
|
+
if (!f || f.clauses.length !== 1) return f;
|
|
502
|
+
const c = f.clauses[0];
|
|
503
|
+
const body = c.pats.length === 0 && c.rhs && !(c.rhs.guards && c.rhs.guards.length) && !(c.rhs.where && c.rhs.where.length) ? unparen(c.rhs.body) : null;
|
|
504
|
+
if (!body) return f;
|
|
505
|
+
if (body.t === 'lam' && body.params.every((p) => p && p.t === 'pvar')) return { ...f, clauses: [{ ...c, pats: body.params, rhs: { ...c.rhs, body: body.body } }] };
|
|
506
|
+
if (body.t === 'lamcase') return { ...f, clauses: [{ ...c, pats: [{ t: 'pvar', name: '$lc' }], rhs: { ...c.rhs, body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: body.alts, line: body.line } } }] };
|
|
507
|
+
if (body.t === 'lamcases') {
|
|
508
|
+
const n = Math.max(1, ...body.alts.map((a) => (a.pats || []).length));
|
|
509
|
+
const pats = Array.from({ length: n }, (_, i) => ({ t: 'pvar', name: `$lc${i}` }));
|
|
510
|
+
const scrut = n === 1 ? { t: 'var', name: '$lc0', qual: null } : { t: 'tuple', items: pats.map((p) => ({ t: 'var', name: p.name, qual: null })) };
|
|
511
|
+
const alts = body.alts.map((a) => ({ pat: n === 1 ? (a.pats[0] || { t: 'pwild' }) : { t: 'ptuple', items: Array.from({ length: n }, (_, i) => a.pats[i] || { t: 'pwild' }) }, rhs: a.rhs, line: a.line }));
|
|
512
|
+
return { ...f, clauses: [{ ...c, pats, rhs: { ...c.rhs, body: { t: 'case', scrut, alts, line: body.line } } }] };
|
|
513
|
+
}
|
|
514
|
+
return f;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// A module-level mutable cell: `name = unsafePerformIO (newIORef ..)` (MVar, TVar, STRef alike; the NOINLINE pragma is a comment the parser
|
|
518
|
+
// does not see). A write anywhere in the project reaches a read anywhere (see lowerCellOp and the taint engine's cell seeds).
|
|
519
|
+
const CELL_NEW = new Set(['newIORef', 'newMVar', 'newEmptyMVar', 'newTVarIO', 'newTVar', 'newSTRef']);
|
|
520
|
+
function cellBodyOf(fnAst) {
|
|
521
|
+
if (!fnAst || fnAst.clauses.length !== 1) return false;
|
|
522
|
+
const c = fnAst.clauses[0];
|
|
523
|
+
if (c.pats.length || !c.rhs || !c.rhs.body || (c.rhs.guards && c.rhs.guards.length)) return false;
|
|
524
|
+
const b = unparen(c.rhs.body);
|
|
525
|
+
let inner = null;
|
|
526
|
+
if (b && b.t === 'app' && b.args.length === 1) { const h = unparen(b.f); if (h && h.t === 'var' && h.name === 'unsafePerformIO') inner = unparen(b.args[0]); }
|
|
527
|
+
else if (b && b.t === 'op' && b.op === '$') { const h = unparen(b.l); if (h && h.t === 'var' && h.name === 'unsafePerformIO') inner = unparen(b.r); }
|
|
528
|
+
if (!inner) return false;
|
|
529
|
+
const head = inner.t === 'app' ? unparen(inner.f) : inner;
|
|
530
|
+
return !!(head && head.t === 'var' && CELL_NEW.has(head.name));
|
|
490
531
|
}
|
|
491
532
|
|
|
492
533
|
// ── per-function lowering ────────────────────────────────────────────────────
|
|
@@ -558,7 +599,14 @@ class FnLowerer {
|
|
|
558
599
|
// `read s :: Int`: the value is a number, so it is a clean constant for the taint engine. The lowered inner expression rides on `hs.inner`, which the rule and web
|
|
559
600
|
// analyses read (an unsafe coercion in it, the id it names), exactly as they read `hs.selector`. (A sanitizer call would be wrong here: the engine's sanitizer label is per expression, so a
|
|
560
601
|
// number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
|
|
561
|
-
case 'typed':
|
|
602
|
+
case 'typed': {
|
|
603
|
+
if (e.digest) { // `hash x :: Digest MD5`: the call itself, stamped with the algorithm the annotation selects
|
|
604
|
+
const inner = this.lowerExpr(e.e, ln);
|
|
605
|
+
if (inner && inner.kind === 'call') inner.hs = { ...(inner.hs || {}), digest: e.digest };
|
|
606
|
+
return inner;
|
|
607
|
+
}
|
|
608
|
+
return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
|
|
609
|
+
}
|
|
562
610
|
case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
|
|
563
611
|
case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
|
|
564
612
|
case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
|
|
@@ -575,6 +623,14 @@ class FnLowerer {
|
|
|
575
623
|
case 'multiif': return this.lowerGuards(e.guards, ln);
|
|
576
624
|
case 'case': return this.lowerCase(e, ln);
|
|
577
625
|
case 'lamcase': return this.liftClosure({ params: [{ t: 'pvar', name: '$lc' }], body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: e.alts, line: ln }, line: ln }, ln);
|
|
626
|
+
// `\cases`: a lambda of as many parameters as the widest alternative, matched as a tuple.
|
|
627
|
+
case 'lamcases': {
|
|
628
|
+
const n = Math.max(1, ...e.alts.map((a) => (a.pats || []).length));
|
|
629
|
+
const params = Array.from({ length: n }, (_, i) => ({ t: 'pvar', name: `$lc${i}` }));
|
|
630
|
+
const scrut = n === 1 ? { t: 'var', name: '$lc0', qual: null } : { t: 'tuple', items: params.map((p) => ({ t: 'var', name: p.name, qual: null })) };
|
|
631
|
+
const alts = e.alts.map((a) => ({ pat: n === 1 ? (a.pats[0] || { t: 'pwild' }) : { t: 'ptuple', items: Array.from({ length: n }, (_, i) => a.pats[i] || { t: 'pwild' }) }, rhs: a.rhs, line: a.line }));
|
|
632
|
+
return this.liftClosure({ params, body: { t: 'case', scrut, alts, line: ln }, line: ln }, ln);
|
|
633
|
+
}
|
|
578
634
|
case 'lam': return this.liftClosure(e, ln);
|
|
579
635
|
case 'let': {
|
|
580
636
|
this.push();
|
|
@@ -592,6 +648,9 @@ class FnLowerer {
|
|
|
592
648
|
// A Template Haskell splice in expression position: generated code the analysis never sees. The parser has already disclosed the
|
|
593
649
|
// `th-splice` boundary for the module, so this is not a lowering gap of the IR; the value is unknown.
|
|
594
650
|
case 'splice': return unknownExpr({ splice: true });
|
|
651
|
+
// `#name` (OverloadedLabels): a label value, a literal naming the field. An IHP `get #name r` is then a library call with the label and
|
|
652
|
+
// the record as arguments (the record's taint reaches the result; the field is not selected, a stated limit).
|
|
653
|
+
case 'label': return { kind: 'literal', value: e.name, hs: { label: true } };
|
|
595
654
|
default: this.diagnostics.push({ kind: 'lowering-gap', detail: e.t || 'unknown' }); return unknownExpr({ ast: e.t || 'unknown' });
|
|
596
655
|
}
|
|
597
656
|
}
|
|
@@ -697,6 +756,12 @@ class FnLowerer {
|
|
|
697
756
|
|
|
698
757
|
applyVar(f, argAsts, ln, via) {
|
|
699
758
|
const r = this.resolve(f.name, f.qual);
|
|
759
|
+
// `iQ`: the parser's stand-in for a string-interpolating quasi-quote (`[i|.. #{a} .. #{b}|]` becomes `(iQ (a) (b))`, haskell-parser.js
|
|
760
|
+
// INTERP_QUOTERS): the value is the concatenation of the interpolated expressions. Only the unbound name is taken; a project `iQ` is itself.
|
|
761
|
+
if (f.name === 'iQ' && !f.qual && r.kind === 'unknown' && argAsts.length) {
|
|
762
|
+
const parts = argAsts.map((a) => this.lowerExpr(a, ln));
|
|
763
|
+
return parts.reduce((acc, p) => ({ kind: 'binary', op: '+', left: acc, right: p, hs: { concat: '<>', quasiquote: true } }));
|
|
764
|
+
}
|
|
700
765
|
// Bounded collection summaries only apply to library names, never a user definition.
|
|
701
766
|
if (HOF[f.name] && (r.kind === 'builtin' || r.kind === 'external')) {
|
|
702
767
|
const out = this.lowerHof(f.name, argAsts, ln);
|
|
@@ -903,13 +968,27 @@ class FnLowerer {
|
|
|
903
968
|
const op = CELL_OPS[callee];
|
|
904
969
|
if (!op) return null;
|
|
905
970
|
const ref = args[0];
|
|
906
|
-
|
|
971
|
+
let name = null; let global = false;
|
|
972
|
+
if (ref && ref.kind === 'ident' && ref.name && !ref.hs) name = ref.name;
|
|
973
|
+
else {
|
|
974
|
+
// A module-level cell (cellBodyOf): one variable for the whole project, `$cell:<qid>`, which the taint engine seeds across functions.
|
|
975
|
+
const u = unparen(argAsts[0]);
|
|
976
|
+
if (u && u.t === 'var') {
|
|
977
|
+
const r = this.resolve(u.name, u.qual);
|
|
978
|
+
if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
|
|
979
|
+
const mod = this.proj.modules.get(r.mod);
|
|
980
|
+
if (cellBodyOf(mod && mod.groups.funs.find((f) => f.name === r.name))) { name = `$cell:${r.def.qid}`; global = true; this.noteRef(r.def.qid, 'reference', ln); }
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (!name) return null;
|
|
907
985
|
this.sawIO = true;
|
|
908
|
-
|
|
909
|
-
if (op === '
|
|
986
|
+
const cell = global ? { kind: 'ident', name, hs: { globalCell: true } } : { kind: 'ident', name };
|
|
987
|
+
if (op === 'read') return cell;
|
|
988
|
+
if (op === 'write' && args.length >= 2) { this.cfg.add('assign', ln, { target: name, source: args[1], hs: { demand: 'executed', cellWrite: callee, ...(global ? { globalCell: true } : {}) } }); return call; }
|
|
910
989
|
if (op === 'modify' && argAsts.length >= 2) {
|
|
911
|
-
const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr:
|
|
912
|
-
this.cfg.add('assign', ln, { target:
|
|
990
|
+
const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: cell }], ln, callee);
|
|
991
|
+
this.cfg.add('assign', ln, { target: name, source: applied, hs: { demand: 'executed', cellWrite: callee, ...(global ? { globalCell: true } : {}) } });
|
|
913
992
|
return call;
|
|
914
993
|
}
|
|
915
994
|
return null;
|
|
@@ -933,7 +1012,7 @@ class FnLowerer {
|
|
|
933
1012
|
alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
|
|
934
1013
|
}
|
|
935
1014
|
if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
|
|
936
|
-
if (alias.names.some((n) => this.lookupLocal(n))) return null;
|
|
1015
|
+
if ([...alias.names, ...(alias.argNames || [])].some((n) => this.lookupLocal(n))) return null;
|
|
937
1016
|
const savedMod = this.mod;
|
|
938
1017
|
// Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
|
|
939
1018
|
// expanding it here would replace the edge to it with edges to what it calls.
|
|
@@ -1258,12 +1337,7 @@ class FnLowerer {
|
|
|
1258
1337
|
const g = groupDecls(decls);
|
|
1259
1338
|
// `let run = \x -> ...` is a function of one parameter written in lambda form: treat it as `run x = ...` so a call `run c` is resolved and analysed
|
|
1260
1339
|
// for the value it is given, instead of being an unknown call of a local value.
|
|
1261
|
-
g.funs = g.funs.map(
|
|
1262
|
-
if (f.clauses.length !== 1) return f;
|
|
1263
|
-
const c = f.clauses[0];
|
|
1264
|
-
const body = c.pats.length === 0 && c.rhs && !(c.rhs.guards && c.rhs.guards.length) && !(c.rhs.where && c.rhs.where.length) ? unparen(c.rhs.body) : null;
|
|
1265
|
-
return body && body.t === 'lam' && body.params.every((p) => p && p.t === 'pvar') ? { ...f, clauses: [{ ...c, pats: body.params, rhs: { ...c.rhs, body: body.body } }] } : f;
|
|
1266
|
-
});
|
|
1340
|
+
g.funs = g.funs.map(lambdaBodied);
|
|
1267
1341
|
const scope = this.scopes[this.scopes.length - 1];
|
|
1268
1342
|
const parent = this.info.parentName || this.info.name;
|
|
1269
1343
|
for (const f of g.funs) {
|
|
@@ -43,6 +43,9 @@ export const HS_PACKAGES = Object.freeze({
|
|
|
43
43
|
wreq: { modules: ['Network.Wreq', 'Network.Wreq.Session'], tested: ['0.5'] },
|
|
44
44
|
'blaze-html': { modules: ['Text.Blaze', 'Text.Blaze.Html', 'Text.Blaze.Html5', 'Text.Blaze.Internal'], tested: ['0.9'] },
|
|
45
45
|
'blaze-builder': { modules: ['Blaze.ByteString.Builder.Html.Utf8', 'Blaze.ByteString.Builder.Html.Word'], tested: ['0.4.4'] },
|
|
46
|
+
'typed-process': { modules: ['System.Process.Typed'], tested: ['0.2.13'] },
|
|
47
|
+
esqueleto: { modules: ['Database.Esqueleto', 'Database.Esqueleto.Experimental', 'Database.Esqueleto.Legacy'], tested: ['3.6'] },
|
|
48
|
+
'optparse-applicative': { modules: ['Options.Applicative'], tested: ['0.19'] },
|
|
46
49
|
lucid: { modules: ['Lucid', 'Lucid.Base'], tested: ['2.11'] },
|
|
47
50
|
shakespeare: { modules: ['Text.Hamlet', 'Text.Shakespeare'], tested: ['2.0'] },
|
|
48
51
|
text: { modules: ['Data.Text', 'Data.Text.IO', 'Data.Text.Lazy', 'Data.Text.Lazy.IO', 'Data.Text.Encoding'], tested: ['2.0'] },
|
|
@@ -98,6 +101,8 @@ src('Data.ByteString', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input',
|
|
|
98
101
|
src('Data.ByteString.Char8', ['getLine', 'getContents'], 'stdin', 'bytes input', { package: 'bytestring' });
|
|
99
102
|
src('Data.ByteString.Char8', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input', { package: 'bytestring' });
|
|
100
103
|
src('System.Environment', ['getArgs'], 'cli', 'command-line argument', { package: 'base' });
|
|
104
|
+
// optparse-applicative (0.19.0.0): the parsed options record is the command line (`opts <- execParser parserInfo`; each field is an argument).
|
|
105
|
+
src('Options.Applicative', ['execParser', 'customExecParser'], 'cli', 'command-line options (optparse-applicative)', { package: 'optparse-applicative' });
|
|
101
106
|
src('System.Environment', ['getEnv', 'lookupEnv', 'getEnvironment'], 'env', 'environment variable', { package: 'base' });
|
|
102
107
|
src('Prelude', ['readFile'], 'file-read', 'file content', { package: 'base' });
|
|
103
108
|
src('System.IO', ['readFile'], 'file-read', 'file content', { package: 'base' });
|
|
@@ -208,6 +213,10 @@ sink('System.Process', ['callProcess', 'spawnProcess', 'readProcess', 'readProce
|
|
|
208
213
|
sink('System.Process', ['callProcess', 'spawnProcess', 'proc'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'process', vuln: 'Argument Injection (process arguments)', remediation: 'Shell-free argument lists prevent shell parsing but not option injection: validate each argument against an allow-list and insert `--` before untrusted values.' });
|
|
209
214
|
sink('System.Process', ['readProcess', 'readProcessWithExitCode'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'process', vuln: 'Argument Injection (process arguments)', remediation: 'Validate each argument against an allow-list and insert `--` before untrusted values.' });
|
|
210
215
|
sink('System.Process.Typed', ['shell'], { family: 'cmd', cwe: 'CWE-78', severity: 'critical', argIndex: 0, shell: true, package: 'process', vuln: 'OS Command Injection (typed-process shell)', remediation: 'Use `proc` with a fixed program and an argument list.' });
|
|
216
|
+
// typed-process (names read from the 0.2.13.0 Haddock index): `proc prog args` builds the ProcessConfig that `readProcess`, `runProcess`,
|
|
217
|
+
// `startProcess` and `withProcess*` run, so the construction is the sink: the program (CWE-78) and the argument list (CWE-88), as for process.
|
|
218
|
+
sink('System.Process.Typed', ['proc'], { family: 'cmd', cwe: 'CWE-78', severity: 'high', argIndex: 0, shell: false, package: 'typed-process', vuln: 'OS Command Injection (attacker-chosen executable)', remediation: 'The program path must be a fixed value or chosen from an allow-list.' });
|
|
219
|
+
sink('System.Process.Typed', ['proc'], { family: 'cmd', cwe: 'CWE-88', severity: 'medium', argIndex: 1, shell: false, argv: true, package: 'typed-process', vuln: 'Argument Injection (process arguments)', remediation: 'Shell-free argument lists prevent shell parsing but not option injection: validate each argument against an allow-list and insert `--` before untrusted values.' });
|
|
211
220
|
|
|
212
221
|
// CWE-22. Path-taking operations.
|
|
213
222
|
const PATH_FN = ['readFile', 'writeFile', 'appendFile', 'openFile', 'withFile', 'readFile\'', 'openBinaryFile', 'withBinaryFile'];
|
|
@@ -301,6 +310,8 @@ sink('Database.HDBC', ['run', 'quickQuery', "quickQuery'", 'prepare', 'runRaw'],
|
|
|
301
310
|
sink('Database.SQLite3', ['exec', 'prepare'], { ...SQL, argIndex: 1, package: 'direct-sqlite', vuln: 'SQL Injection (SQLite statement text)' });
|
|
302
311
|
sink('Database.PostgreSQL.LibPQ', ['exec', 'execParams', 'prepare'], { ...SQL, argIndex: 1, package: 'postgresql-libpq', vuln: 'SQL Injection (libpq command text)' });
|
|
303
312
|
for (const mod of ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Database.Persist.MySQL']) sink(mod, ['rawSql', 'rawExecute', 'rawQuery'], { ...SQL, argIndex: 0, package: 'persistent-backends', vuln: 'SQL Injection (raw SQL text)' });
|
|
313
|
+
// esqueleto re-exports persistent's raw SQL (names read from the 3.6.0.3 Haddock index of Database.Esqueleto.Experimental).
|
|
314
|
+
for (const mod of ['Database.Esqueleto', 'Database.Esqueleto.Experimental', 'Database.Esqueleto.Legacy']) sink(mod, ['rawSql', 'rawExecute'], { ...SQL, argIndex: 0, package: 'esqueleto', vuln: 'SQL Injection (raw SQL text)' });
|
|
304
315
|
|
|
305
316
|
// CWE-918. Req builds a URL from typed pieces, so the HOST is the attacker-steered part; HTTP's request constructors take the URL text.
|
|
306
317
|
sink('Network.HTTP.Req', ['https', 'http'], { ...SSRF, argIndex: 0, package: 'req', vuln: 'Server-Side Request Forgery (request host)' });
|
|
@@ -40,21 +40,90 @@ 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', 'quasiquote-hamlet']);
|
|
43
|
+
export const NON_OPAQUE_BOUNDARIES = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert', 'quasiquote-hamlet', 'quasiquote-interp']);
|
|
44
44
|
|
|
45
45
|
// Shakespeare's HTML template quoters: the interpolations are Haskell expressions of the enclosing scope (see absorbSafeBoundaries).
|
|
46
46
|
const HAMLET_QUOTERS = new Set(['hamlet', 'whamlet', 'shamlet', 'xhamlet', 'ihamlet', 'xshamlet']);
|
|
47
47
|
|
|
48
|
+
// String-interpolating quoters: the body is TEXT with Haskell expressions spliced in, so `[i|SELECT .. '#{name}'|]` is a concatenation of the
|
|
49
|
+
// text and `name`. Each is accepted only under an import of its documented module (a project quoter that borrows the name stays opaque),
|
|
50
|
+
// and the interpolation syntax is the quoter's own: `#{e}` (string-interpolate, interpolate, shakespeare-text), `$name` / `${e}`
|
|
51
|
+
// (neat-interpolation, `$$` escapes), `{e}` / `{e:spec}` (PyF, `{{` escapes). Names read from each package's Haddock index.
|
|
52
|
+
const INTERP_QUOTERS = {
|
|
53
|
+
'Data.String.Interpolate': { syntax: 'hash', quoters: ['i', '__i', 'iii', "__i'E", "__i'L", "iii'E", "iii'L"] },
|
|
54
|
+
'Data.String.Interpolate.IsString': { syntax: 'hash', quoters: ['i'] },
|
|
55
|
+
'Text.Shakespeare.Text': { syntax: 'hash', quoters: ['st', 'lt', 'sbt', 'lbt', 'stext', 'ltext', 'text'] },
|
|
56
|
+
'NeatInterpolation': { syntax: 'dollar', quoters: ['text', 'trimming', 'untrimming'] },
|
|
57
|
+
'PyF': { syntax: 'brace', quoters: ['fmt', 'fmtTrim', 'str', 'strTrim'] },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The interpolations of a string-interpolating quoter's body (see INTERP_QUOTERS): `{open, exprStart, exprEnd, close, bare}` offsets. */
|
|
61
|
+
export function interpolationsOf(seg, from, to, syntax) {
|
|
62
|
+
if (syntax === 'hash') return hamletInterpolations(seg, from, to, '#').map((it) => ({ ...it, exprEnd: it.close }));
|
|
63
|
+
const out = [];
|
|
64
|
+
const closeOf = (j) => { // the index of the `}` matching the `{` before j, brace-nesting and string-aware; -1 when unclosed
|
|
65
|
+
let depth = 1, k = j, inStr = false;
|
|
66
|
+
for (; k < to && depth > 0; k++) {
|
|
67
|
+
const c = seg[k];
|
|
68
|
+
if (inStr) { if (c === '\\') k++; else if (c === '"') inStr = false; continue; }
|
|
69
|
+
if (c === '"') inStr = true;
|
|
70
|
+
else if (c === '{') depth++;
|
|
71
|
+
else if (c === '}') { depth--; if (depth === 0) return k; }
|
|
72
|
+
}
|
|
73
|
+
return -1;
|
|
74
|
+
};
|
|
75
|
+
if (syntax === 'dollar') {
|
|
76
|
+
for (let i = from; i < to; i++) {
|
|
77
|
+
if (seg[i] !== '$') continue;
|
|
78
|
+
if (seg[i + 1] === '$') { i++; continue; }
|
|
79
|
+
if (seg[i + 1] === '{') {
|
|
80
|
+
const close = closeOf(i + 2);
|
|
81
|
+
if (close < 0) continue;
|
|
82
|
+
if (close > i + 2) out.push({ open: i, exprStart: i + 2, exprEnd: close, close, bare: false });
|
|
83
|
+
i = close;
|
|
84
|
+
} else if (/[a-z_]/.test(seg[i + 1] || '')) {
|
|
85
|
+
let k = i + 1;
|
|
86
|
+
while (k < to && /[A-Za-z0-9_']/.test(seg[k])) k++;
|
|
87
|
+
out.push({ open: i, exprStart: i + 1, exprEnd: k, close: -1, bare: true });
|
|
88
|
+
i = k - 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
if (syntax === 'brace') {
|
|
94
|
+
for (let i = from; i < to; i++) {
|
|
95
|
+
if (seg[i] !== '{') continue;
|
|
96
|
+
if (seg[i + 1] === '{') { i++; continue; }
|
|
97
|
+
const close = closeOf(i + 1);
|
|
98
|
+
if (close < 0) continue;
|
|
99
|
+
// `{expr:spec}`: the format spec after a top-level `:` is not an expression (a `:` inside brackets or quotes is part of the expression)
|
|
100
|
+
let exprEnd = close, depth = 0, inStr = false;
|
|
101
|
+
for (let k = i + 1; k < close; k++) {
|
|
102
|
+
const c = seg[k];
|
|
103
|
+
if (inStr) { if (c === '\\') k++; else if (c === '"') inStr = false; continue; }
|
|
104
|
+
if (c === '"') inStr = true;
|
|
105
|
+
else if (c === '(' || c === '[' || c === '{') depth++;
|
|
106
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
107
|
+
else if (c === ':' && depth === 0) { exprEnd = k; break; }
|
|
108
|
+
}
|
|
109
|
+
if (exprEnd > i + 1) out.push({ open: i, exprStart: i + 1, exprEnd, close, bare: false });
|
|
110
|
+
i = close;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
48
117
|
/**
|
|
49
118
|
* The interpolations of a hamlet body, in order: `#{e}` (escaped value), `^{e}` (embedded template or widget), `@{r}` / `@?{r}` (route) and `_{m}`
|
|
50
119
|
* (message). Braces nest (a record or a lambda inside), and a string literal may contain `}`. Returns offsets into the segment: the opener's
|
|
51
120
|
* first character, the expression's first character, and the closing brace.
|
|
52
121
|
*/
|
|
53
|
-
export function hamletInterpolations(seg, from, to) {
|
|
122
|
+
export function hamletInterpolations(seg, from, to, markers = '#^@_') {
|
|
54
123
|
const out = [];
|
|
55
124
|
for (let i = from; i < to; i++) {
|
|
56
125
|
const ch = seg[i];
|
|
57
|
-
if (!(ch
|
|
126
|
+
if (!markers.includes(ch)) continue;
|
|
58
127
|
let j = i + 1;
|
|
59
128
|
if (ch === '@' && seg[j] === '?') j++;
|
|
60
129
|
if (seg[j] !== '{') continue;
|
|
@@ -1153,6 +1222,37 @@ function analyze(source, file, dialect, ctx, res) {
|
|
|
1153
1222
|
b.assumption = 'upstream-quoter';
|
|
1154
1223
|
res.blankRanges.push({ kind: 'quasiquote-hamlet', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
|
|
1155
1224
|
}
|
|
1225
|
+
// String-interpolating quoters (`[i|SELECT .. '#{name}'|]`, INTERP_QUOTERS): rewritten in place, same length, same newlines, as
|
|
1226
|
+
// `(iQ (e1) (e2))`, an application the IR lowers to a CONCATENATION of the interpolated expressions (haskell-ir.js), so text reaching a
|
|
1227
|
+
// sink through the quoter is followed. The template text itself is not modelled (a text sink that needs a markup literal, `putStrLn`,
|
|
1228
|
+
// does not fire through a quoter: a stated limit). A quoter of an unimported module, or an unknown quoter, stays an opaque boundary.
|
|
1229
|
+
const interpModules = res.imports.map((im) => im.module).filter((m) => INTERP_QUOTERS[m]);
|
|
1230
|
+
for (const b of res.boundaries) {
|
|
1231
|
+
if (b.kind !== 'quasiquote' || !interpModules.length) continue;
|
|
1232
|
+
const mod = interpModules.find((m) => INTERP_QUOTERS[m].quoters.includes(b.quoter));
|
|
1233
|
+
if (!mod) continue;
|
|
1234
|
+
const seg = source.slice(b.span.startOffset, b.span.endOffset);
|
|
1235
|
+
const bar = seg.indexOf('|');
|
|
1236
|
+
if (bar < 0 || !seg.endsWith('|]')) continue;
|
|
1237
|
+
const interps = interpolationsOf(seg, bar + 1, seg.length - 2, INTERP_QUOTERS[mod].syntax);
|
|
1238
|
+
if (!interps.length) continue; // a literal-only body is left to the inert rule above
|
|
1239
|
+
const chars = seg.split('').map((ch) => (ch === '\n' || ch === '\r' ? ch : ' '));
|
|
1240
|
+
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]; };
|
|
1241
|
+
put(0, '(iQ'.padEnd(bar + 1));
|
|
1242
|
+
for (const it of interps) {
|
|
1243
|
+
if (it.bare) { put(it.open, ' '); put(it.exprStart, seg.slice(it.exprStart, it.exprEnd)); continue; } // `$name`: the name at its offset
|
|
1244
|
+
put(it.open, ' ('.slice(-(it.exprStart - it.open)).padStart(it.exprStart - it.open)); // `#{`, `${` become ` (`; `{` becomes `(`
|
|
1245
|
+
put(it.exprStart, seg.slice(it.exprStart, it.exprEnd));
|
|
1246
|
+
put(it.close, ')');
|
|
1247
|
+
}
|
|
1248
|
+
put(seg.length - 2, ')');
|
|
1249
|
+
const text = chars.join('');
|
|
1250
|
+
if (text.length !== seg.length) continue;
|
|
1251
|
+
b.kind = 'quasiquote-interp';
|
|
1252
|
+
b.detail = `quasi-quote [${b.quoter}|...|] with ${interps.length} interpolation(s), read as the concatenation of the interpolated expressions (analysed in the enclosing scope); the template text is not modelled; ASSUMED to be the ${mod} quoter`;
|
|
1253
|
+
b.assumption = 'upstream-quoter';
|
|
1254
|
+
res.blankRanges.push({ kind: 'quasiquote-interp', startOffset: b.span.startOffset, endOffset: b.span.endOffset, text });
|
|
1255
|
+
}
|
|
1156
1256
|
}
|
|
1157
1257
|
|
|
1158
1258
|
// ── errors → withheld calls ──
|
|
@@ -1240,7 +1340,7 @@ export function preprocessForSyntaxInfo(source, opts = {}) {
|
|
|
1240
1340
|
const seg = out.slice(b.startOffset, b.endOffset);
|
|
1241
1341
|
let repl = blank(seg);
|
|
1242
1342
|
if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
|
|
1243
|
-
if (b.kind === 'quasiquote-hamlet' && typeof b.text === 'string' && b.text.length === seg.length) repl = b.text; // the interpolations, at their offsets
|
|
1343
|
+
if ((b.kind === 'quasiquote-hamlet' || b.kind === 'quasiquote-interp') && typeof b.text === 'string' && b.text.length === seg.length) repl = b.text; // the interpolations, at their offsets
|
|
1244
1344
|
out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
|
|
1245
1345
|
}
|
|
1246
1346
|
return { text: out, opaque };
|
|
@@ -49,6 +49,10 @@ export const BOUNDARIES = Object.freeze({
|
|
|
49
49
|
|
|
50
50
|
const WEAK_HASH_CALLS = new Set(['Crypto.Hash.MD5.hash', 'Crypto.Hash.MD5.hashlazy', 'Crypto.Hash.SHA1.hash', 'Crypto.Hash.SHA1.hashlazy', 'Data.Digest.Pure.MD5.md5', 'Data.Digest.Pure.MD5.md5DigestBytes', 'Data.Digest.Pure.SHA.sha1', 'Data.Digest.Pure.SHA.sha1Incremental']);
|
|
51
51
|
const WEAK_ALGO_CONS = new Set(['MD5', 'MD4', 'MD2', 'SHA1']);
|
|
52
|
+
// Partial list functions that are total on a list with at least one element (see nonEmptyList), and the length calls a size test may name.
|
|
53
|
+
const NONEMPTY_TOTAL = /(?:^|\.)(?:head|last|init|tail|maximum|minimum|foldr1|foldl1|maximumBy|minimumBy|cycle)$/;
|
|
54
|
+
const LENGTH_CALL = /(?:^|\.)(?:length|genericLength|size)$/;
|
|
55
|
+
const COND_COMBINATOR = /(?:^|\.)(?:when|unless|guard)$/;
|
|
52
56
|
const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.Hash.hashlazy', 'Crypto.Hash.hashWith', 'Crypto.Hash.SHA256.hash', 'Crypto.Hash.SHA256.hashlazy', 'Crypto.Hash.SHA512.hash', 'Crypto.Hash.SHA512.hashlazy', 'Data.Digest.Pure.SHA.sha256', 'Data.Digest.Pure.SHA.sha512']);
|
|
53
57
|
const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
|
|
54
58
|
const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
|
|
@@ -547,7 +551,40 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
547
551
|
if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of directNames(x)) testedVars.add(nm);
|
|
548
552
|
}
|
|
549
553
|
}
|
|
554
|
+
// `case length xs of 3 -> ..`, `if length xs == 3`: a comparison of the list's LENGTH with a number is a test of the list (the IR lowers the
|
|
555
|
+
// case alternative to an equality on the scrutinee variable, so the length call sits behind an assignment).
|
|
556
|
+
const lengthVars = new Set();
|
|
557
|
+
for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && n.source.kind === 'call' && typeof n.source.callee === 'string' && LENGTH_CALL.test(n.source.callee)) lengthVars.add(n.target);
|
|
558
|
+
// The conditions this function tests: every `if`, plus the first argument of `when` / `unless` / `guard` wherever the call sits
|
|
559
|
+
// (`when (length xs > 2) (putStrLn (xs !! 2))` is a guard written as a combinator, not an `if` node).
|
|
560
|
+
const condExprs = [];
|
|
561
|
+
for (const n of fnNodes(fn)) {
|
|
562
|
+
if (n.kind === 'if' && n.cond) condExprs.push(n.cond);
|
|
563
|
+
for (const ex of [n.value, n.source, ...(n.args || [])]) if (ex) for (const x of walk(ex)) if (x.kind === 'call' && typeof x.callee === 'string' && COND_COMBINATOR.test(x.callee) && x.args && x.args[0]) condExprs.push(x.args[0]);
|
|
564
|
+
}
|
|
565
|
+
for (const cond of condExprs) {
|
|
566
|
+
for (const x of walk(cond)) {
|
|
567
|
+
if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) {
|
|
568
|
+
for (const nm of directNames(x)) testedVars.add(nm);
|
|
569
|
+
for (const s of splitsIn(x)) testedSplits.add(s);
|
|
570
|
+
// `case length xs of 3 -> ..`: the match is on a scratch variable holding the length; the list behind it is what was tested
|
|
571
|
+
for (const a of x.args || []) if (a && a.kind === 'ident' && lengthVars.has(a.name)) for (const nm of assignSources.get(a.name) || []) testedVars.add(nm);
|
|
572
|
+
}
|
|
573
|
+
if (x.kind !== 'binary' || !['==', '/=', '!='].includes(x.op)) continue;
|
|
574
|
+
for (const side of [x.left, x.right]) {
|
|
575
|
+
if (side && side.kind === 'call' && typeof side.callee === 'string' && LENGTH_CALL.test(side.callee)) for (const nm of directNames(side)) testedVars.add(nm);
|
|
576
|
+
if (side && side.kind === 'ident' && lengthVars.has(side.name)) for (const nm of assignSources.get(side.name) || []) testedVars.add(nm);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
550
580
|
const testedAll = withOrigins(testedVars);
|
|
581
|
+
// `head (x : xs)`, `last (xs ++ [x])`, `head [a, b]`: the list has at least one element, so the call is total (real code: xmonad's `last (["Root"] ++ ..)`).
|
|
582
|
+
const nonEmptyList = (e) => {
|
|
583
|
+
if (!e || typeof e !== 'object') return false;
|
|
584
|
+
if (e.kind === 'array') return !!(e.hs && e.hs.cons) || (!(e.hs && (e.hs.tuple || e.hs.con || e.hs.range)) && (e.elements || []).length > 0);
|
|
585
|
+
if (e.kind === 'binary' && e.hs && e.hs.concat) return nonEmptyList(e.left) || nonEmptyList(e.right);
|
|
586
|
+
return false;
|
|
587
|
+
};
|
|
551
588
|
const isTested = (args) => {
|
|
552
589
|
if (args.some((a) => splitsIn(a).some((s) => testedSplits.has(s)))) return true;
|
|
553
590
|
const mine = withOrigins([...args.flatMap((a) => [...directNames(a)])]);
|
|
@@ -686,7 +723,9 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
686
723
|
// a signing/encryption secret that is a constant in the source
|
|
687
724
|
if (SECRET_KEY_CALLS.has(c) && args[0] && isLiteralLike(args[0]) && !(args[0].kind === 'literal' && String(args[0].value).length < 8)) emit('hs-hardcoded-secret', file, line, { detail: `${c} given a constant`, confidence: 0.85, evidence: { call: c } });
|
|
688
725
|
// weak hash
|
|
689
|
-
|
|
726
|
+
// `hash x :: Digest MD5` / `hashlazy x :: Digest SHA1`: the digest is chosen by the type annotation (haskell-syntax.js keeps a
|
|
727
|
+
// `Digest <algo>` annotation as a `typed` node; haskell-ir.js stamps `hs.digest` on the call).
|
|
728
|
+
if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value)) || (ANY_HASH_CALLS.has(c) && e.hs && e.hs.digest && WEAK_ALGO_CONS.has(e.hs.digest))) {
|
|
690
729
|
const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
|
|
691
730
|
// MD5 and SHA-1 are broken for collision and preimage resistance, which only matters where an attacker gains from a
|
|
692
731
|
// collision: a digest that is VERIFIED (compared with an expected value), that authenticates or signs, that hashes a
|
|
@@ -732,7 +771,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
732
771
|
emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
|
|
733
772
|
}
|
|
734
773
|
// a partial function applied to caller text: one malformed value raises an uncaught exception
|
|
735
|
-
if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c }, inlineCtx: e.hs && e.hs.inlineCtx });
|
|
774
|
+
if (PARTIALS.has(c) && !(NONEMPTY_TOTAL.test(c) && nonEmptyList(args[0])) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c }, inlineCtx: e.hs && e.hs.inlineCtx });
|
|
736
775
|
// a session/token cookie assembled without HttpOnly or Secure
|
|
737
776
|
if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
|
|
738
777
|
// unbounded body
|