@clear-capabilities/agentic-security-scanner 0.195.13 → 0.195.14
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 +17 -0
- package/dist/{1f01fe10027e772f73a1.json → 4fe5c024ca0626112826.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 +2 -0
- package/src/language/haskell-ir.js +80 -13
- package/src/language/haskell-models.js +11 -0
- package/src/language/haskell-parser.js +104 -4
- package/src/language/haskell-syntax.js +83 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
bb2980af58a2c5183cbe0c14efd1d611922784ee2f051111e2f3bf03d1846e79 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.14",
|
|
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-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,8 @@ 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
|
+
- **Record dot, labels, global cells, lambda-form functions, more syntax (since 0.195.14).** `haskell-syntax.js` reads `r.field` / `(e).field` / `(.field)` as a field selection (`sel` token, `RECORD_DOT_PRAGMA`; the IR lowers `field r` to a member read of a known record field) and `#field` as a label value (`LABELS_PRAGMA`; the IR lowers it to a literal, so IHP's `get #f r` / `set #f v r` are library calls carrying the record's and the value's taint, not a field selection: a stated limit); both only under the LANGUAGE pragma. `\cases` (`lamcases`), `mdo` (a `do` under `RECDO_PRAGMA`; `rec` blocks are not read), GADT `data T where` constructors (names, record fields, arity by arrows) and `pattern P ..` (a `patsyn` declaration, not a function named `pattern`) parse. `haskell-ir.js` `lambdaBodied` rewrites `f = \x -> ..`, `f = \case ..`, `f = \cases ..` into `f x = ..` at the top level and in `let`/`where` (a call then resolves to the function instead of applying a closure value). `cellBodyOf` recognises a module-level cell (`name = unsafePerformIO (newIORef ..)`, MVar/TVar/STRef alike) and `lowerCellOp` names it `$cell:<qid>` for the whole project (a read is that ident with `hs.globalCell`, a write an assignment to it); `dataflow/engine.js` `_cellSeeds` records a cell any function assigned tainted data to and `_unionAnnotationTaint` seeds it at the entry of every Haskell function, the fixed-point pre-pass re-running when the set grows (flow-insensitive; the root is not carried across, so the trust verdict of the reader is `unattributed`). `aliasBodyOf` accepts a partial application of a library function to names or literals (`remember = writeIORef lastName`), with the argument names checked for shadowing. `haskell-models.js`: typed-process `proc` (CWE-78 program, CWE-88 arguments), esqueleto `rawSql`/`rawExecute`, optparse-applicative `execParser`/`customExecParser` as a `cli` source, versions recorded in `HS_PACKAGES`. Pinned in `test/haskell/haskell-{record-dot,global-cell,syntax-extensions}.test.js`.
|
|
70
|
+
- **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
71
|
- **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
72
|
- **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
73
|
- **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 ────────────────────────────────────────────────────
|
|
@@ -575,6 +616,14 @@ class FnLowerer {
|
|
|
575
616
|
case 'multiif': return this.lowerGuards(e.guards, ln);
|
|
576
617
|
case 'case': return this.lowerCase(e, ln);
|
|
577
618
|
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);
|
|
619
|
+
// `\cases`: a lambda of as many parameters as the widest alternative, matched as a tuple.
|
|
620
|
+
case 'lamcases': {
|
|
621
|
+
const n = Math.max(1, ...e.alts.map((a) => (a.pats || []).length));
|
|
622
|
+
const params = Array.from({ length: n }, (_, i) => ({ t: 'pvar', name: `$lc${i}` }));
|
|
623
|
+
const scrut = n === 1 ? { t: 'var', name: '$lc0', qual: null } : { t: 'tuple', items: params.map((p) => ({ t: 'var', name: p.name, qual: null })) };
|
|
624
|
+
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 }));
|
|
625
|
+
return this.liftClosure({ params, body: { t: 'case', scrut, alts, line: ln }, line: ln }, ln);
|
|
626
|
+
}
|
|
578
627
|
case 'lam': return this.liftClosure(e, ln);
|
|
579
628
|
case 'let': {
|
|
580
629
|
this.push();
|
|
@@ -592,6 +641,9 @@ class FnLowerer {
|
|
|
592
641
|
// A Template Haskell splice in expression position: generated code the analysis never sees. The parser has already disclosed the
|
|
593
642
|
// `th-splice` boundary for the module, so this is not a lowering gap of the IR; the value is unknown.
|
|
594
643
|
case 'splice': return unknownExpr({ splice: true });
|
|
644
|
+
// `#name` (OverloadedLabels): a label value, a literal naming the field. An IHP `get #name r` is then a library call with the label and
|
|
645
|
+
// the record as arguments (the record's taint reaches the result; the field is not selected, a stated limit).
|
|
646
|
+
case 'label': return { kind: 'literal', value: e.name, hs: { label: true } };
|
|
595
647
|
default: this.diagnostics.push({ kind: 'lowering-gap', detail: e.t || 'unknown' }); return unknownExpr({ ast: e.t || 'unknown' });
|
|
596
648
|
}
|
|
597
649
|
}
|
|
@@ -697,6 +749,12 @@ class FnLowerer {
|
|
|
697
749
|
|
|
698
750
|
applyVar(f, argAsts, ln, via) {
|
|
699
751
|
const r = this.resolve(f.name, f.qual);
|
|
752
|
+
// `iQ`: the parser's stand-in for a string-interpolating quasi-quote (`[i|.. #{a} .. #{b}|]` becomes `(iQ (a) (b))`, haskell-parser.js
|
|
753
|
+
// INTERP_QUOTERS): the value is the concatenation of the interpolated expressions. Only the unbound name is taken; a project `iQ` is itself.
|
|
754
|
+
if (f.name === 'iQ' && !f.qual && r.kind === 'unknown' && argAsts.length) {
|
|
755
|
+
const parts = argAsts.map((a) => this.lowerExpr(a, ln));
|
|
756
|
+
return parts.reduce((acc, p) => ({ kind: 'binary', op: '+', left: acc, right: p, hs: { concat: '<>', quasiquote: true } }));
|
|
757
|
+
}
|
|
700
758
|
// Bounded collection summaries only apply to library names, never a user definition.
|
|
701
759
|
if (HOF[f.name] && (r.kind === 'builtin' || r.kind === 'external')) {
|
|
702
760
|
const out = this.lowerHof(f.name, argAsts, ln);
|
|
@@ -903,13 +961,27 @@ class FnLowerer {
|
|
|
903
961
|
const op = CELL_OPS[callee];
|
|
904
962
|
if (!op) return null;
|
|
905
963
|
const ref = args[0];
|
|
906
|
-
|
|
964
|
+
let name = null; let global = false;
|
|
965
|
+
if (ref && ref.kind === 'ident' && ref.name && !ref.hs) name = ref.name;
|
|
966
|
+
else {
|
|
967
|
+
// A module-level cell (cellBodyOf): one variable for the whole project, `$cell:<qid>`, which the taint engine seeds across functions.
|
|
968
|
+
const u = unparen(argAsts[0]);
|
|
969
|
+
if (u && u.t === 'var') {
|
|
970
|
+
const r = this.resolve(u.name, u.qual);
|
|
971
|
+
if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
|
|
972
|
+
const mod = this.proj.modules.get(r.mod);
|
|
973
|
+
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); }
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (!name) return null;
|
|
907
978
|
this.sawIO = true;
|
|
908
|
-
|
|
909
|
-
if (op === '
|
|
979
|
+
const cell = global ? { kind: 'ident', name, hs: { globalCell: true } } : { kind: 'ident', name };
|
|
980
|
+
if (op === 'read') return cell;
|
|
981
|
+
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
982
|
if (op === 'modify' && argAsts.length >= 2) {
|
|
911
|
-
const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr:
|
|
912
|
-
this.cfg.add('assign', ln, { target:
|
|
983
|
+
const applied = this.lowerApp(argAsts[1], [{ t: 'lowered', expr: cell }], ln, callee);
|
|
984
|
+
this.cfg.add('assign', ln, { target: name, source: applied, hs: { demand: 'executed', cellWrite: callee, ...(global ? { globalCell: true } : {}) } });
|
|
913
985
|
return call;
|
|
914
986
|
}
|
|
915
987
|
return null;
|
|
@@ -933,7 +1005,7 @@ class FnLowerer {
|
|
|
933
1005
|
alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
|
|
934
1006
|
}
|
|
935
1007
|
if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
|
|
936
|
-
if (alias.names.some((n) => this.lookupLocal(n))) return null;
|
|
1008
|
+
if ([...alias.names, ...(alias.argNames || [])].some((n) => this.lookupLocal(n))) return null;
|
|
937
1009
|
const savedMod = this.mod;
|
|
938
1010
|
// Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
|
|
939
1011
|
// expanding it here would replace the edge to it with edges to what it calls.
|
|
@@ -1258,12 +1330,7 @@ class FnLowerer {
|
|
|
1258
1330
|
const g = groupDecls(decls);
|
|
1259
1331
|
// `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
1332
|
// 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
|
-
});
|
|
1333
|
+
g.funs = g.funs.map(lambdaBodied);
|
|
1267
1334
|
const scope = this.scopes[this.scopes.length - 1];
|
|
1268
1335
|
const parent = this.info.parentName || this.info.name;
|
|
1269
1336
|
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 };
|