@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.0
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 +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/pr-comment.js +3 -1
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/untrusted.js +148 -0
- package/src/util/yaml.js +24 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
dd72d7c2dcc4df5e96f24285cf531a6476881867a330000427a6a8edaf71b06e agentic-security.mjs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clear-capabilities/agentic-security-scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.130.0",
|
|
4
4
|
"description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -35,21 +35,21 @@
|
|
|
35
35
|
"node": ">=24.0.0"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@babel/core": "^
|
|
39
|
-
"@babel/preset-react": "^
|
|
40
|
-
"@babel/preset-typescript": "^
|
|
38
|
+
"@babel/core": "^8.0.1",
|
|
39
|
+
"@babel/preset-react": "^8.0.1",
|
|
40
|
+
"@babel/preset-typescript": "^8.0.1",
|
|
41
41
|
"fast-glob": "^3.3.3",
|
|
42
42
|
"java-parser": "^3.0.1",
|
|
43
|
-
"js-yaml": "^
|
|
43
|
+
"js-yaml": "^5.2.2",
|
|
44
44
|
"safe-regex": "^2.1.1"
|
|
45
45
|
},
|
|
46
46
|
"optionalDependencies": {
|
|
47
|
-
"
|
|
48
|
-
"tree-sitter
|
|
47
|
+
"tree-sitter-wasms": "0.1.13",
|
|
48
|
+
"web-tree-sitter": "0.20.8"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/node": "^
|
|
52
|
-
"@vercel/ncc": "^0.
|
|
51
|
+
"@types/node": "^26.1.1",
|
|
52
|
+
"@vercel/ncc": "^0.44.1"
|
|
53
53
|
},
|
|
54
54
|
"overrides": {
|
|
55
55
|
"lodash": "^4.18.1",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"test": "npm run test:smoke && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
|
|
62
62
|
"test:smoke": "node --test test/smoke.test.js",
|
|
63
63
|
"test:sast": "node --test test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
|
|
64
|
-
"test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js",
|
|
65
|
-
"test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js",
|
|
64
|
+
"test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/attestation.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
|
|
65
|
+
"test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js",
|
|
66
66
|
"test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js",
|
|
67
67
|
"test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
|
|
68
|
-
"test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js",
|
|
69
|
-
"test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js && node ../scripts/lint-command-descriptions.mjs",
|
|
70
|
-
"test:eval": "node --test test/independent-eval.test.js",
|
|
68
|
+
"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",
|
|
69
|
+
"test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js && node ../scripts/lint-command-descriptions.mjs",
|
|
70
|
+
"test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
|
|
71
71
|
"eval:independent": "node ../bench/independent-eval/runner.mjs",
|
|
72
72
|
"eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
|
|
73
73
|
"smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
|
|
@@ -87,7 +87,15 @@
|
|
|
87
87
|
"bench:bigquery:smoke": "node ../bench/bigquery-github/bin/run-cycle.mjs --smoke",
|
|
88
88
|
"bench:bigquery": "node ../bench/bigquery-github/bin/run-cycle.mjs",
|
|
89
89
|
"bench:agent-tasks": "node ../bench/agent-tasks/security-fixer/runner.mjs",
|
|
90
|
-
"bench:polyglot": "node ../bench/polyglot/runner.mjs"
|
|
90
|
+
"bench:polyglot": "node ../bench/polyglot/runner.mjs",
|
|
91
|
+
"bench:proof-corpus": "node ../bench/proof-corpus/runner.mjs",
|
|
92
|
+
"bench:proof-corpus:pins": "node ../bench/proof-corpus/runner.mjs --refresh-pins",
|
|
93
|
+
"bench:engine-reconnect": "node ../bench/engine-reconnect/measure.mjs",
|
|
94
|
+
"bench:self-scan": "node ../bench/self-scan/measure.mjs",
|
|
95
|
+
"bench:self-scan:check": "node ../bench/self-scan/check.mjs",
|
|
96
|
+
"bench:self-scan:update-baseline": "node ../bench/self-scan/check.mjs --update-baseline",
|
|
97
|
+
"bench:engine-recall": "node ../bench/engine-recall/measure.mjs",
|
|
98
|
+
"scorecard": "node ../scripts/scorecard.mjs"
|
|
91
99
|
},
|
|
92
100
|
"author": "Ross Young <ross@clearcapabilities.com>",
|
|
93
101
|
"license": "PolyForm-Internal-Use-1.0.0"
|
package/src/dataflow/CLAUDE.md
CHANGED
|
@@ -6,7 +6,10 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
|
|
|
6
6
|
|
|
7
7
|
- **Intra-procedural field-sensitive taint** with access-path lattice (`access-paths.js`). `user.email` is distinguishable from `user.password`.
|
|
8
8
|
- **Value-context-sensitive interprocedural taint (FR-SEM-2).** `SummaryCache` (`summaries.js`) holds a distinct summary per distinct entry-taint-state. A pre-pass computes the empty-entry base for every function; call sites then lazily compute the summary under their actual tainted-arg context (at both assign-from-call and plain-call sites) so a helper that is clean with clean args but tainted with user input is detected per call site. Bounded by a per-function context cap (`AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`, default 16; 0 = monovariant). Over the cap → reuse the empty-entry summary.
|
|
9
|
-
- **Catalog-driven source/sink
|
|
9
|
+
- **Catalog-driven source/sink matching.** Add entries in `catalog.js`. Each entry: `kind` ∈ {source, sink, sanitizer}, plus language + framework + match shape. 200+ entries spanning Express/Flask/FastAPI/Django/Rails/PHP/Go-net-http/Gin/Echo.
|
|
10
|
+
- **Sanitizer entries are NOT consulted by this engine.** `matchSinkOrSanitizer()` returns hits of both kinds, but every consumer in `dataflow/*.js` keeps only `e.kind === 'sink'` — there is no `'sanitizer'` branch anywhere in this directory. Taint dies here only on clean re-assignment of a variable (`removePathAndDescendants`), independent of whether the RHS is a catalog sanitizer. Consequence: `engine.js`'s `applySanitizerGate` call is **inert** (`sanitizersOnPath` is always `{}`) because there is nothing to populate it from. Making it live needs (1) a `kind === 'sanitizer'` branch at the call site in `engine.js`, and (2) the sanitizer callee threaded onto the finding.
|
|
11
|
+
- **`match.receiver`** (optional, regex source string) constrains a call to call sites whose receiver chain has a matching segment. Callee matching is by *bare name*, so `callee: 'write'` otherwise matches `process.stdout.write` and `fh.write` as readily as `document.write`. Works for both sinks (via `matchSinkOrSanitizer`) and sources (via `matchSource`'s CALL-source path). `match.receiverBase` is an optional, independent second pattern (both `_receiverAllowed` checks must pass) — use it when `receiver` alone would still match an unrelated same-named local, e.g. `py-flask-args-get` requires *both* a property segment (`args`/`form`/`headers`/…) *and* a `request`/`req` segment, so `args = parse(); args.get(x)` doesn't fire just because some catalog entry happens to be named `get`.
|
|
12
|
+
- **`language`** is enforced by `_languageAllowed` (Phase 2) via a table (`_LANG_EXT` in `catalog.js`) covering all nine catalog languages — `js`, `py`, `cs`, `kt`, `go`, `php`, `rb`, `java` by literal regex kept byte-identical to `ir/index.js`'s own dispatch regexes (pinned by `test/phase2-scoping.test.js`, which compares `.source` directly, not just "does this regex literal appear somewhere in the file"), and `cpp` by delegating to `ir/parser-cpp.js`'s `cppExtRe()` so it can't drift from the parser. A language with no table entry stays permissive (matches any file) — additive by design, so it can't regress a language before its entry exists.
|
|
10
13
|
- **Path feasibility.** Constant-folds `if` conditions to prune unreachable branches.
|
|
11
14
|
- **Per-flow source attribution.** Sources reported on a finding are the ones actually reaching the sink argument (via free-var matching in the sink expression), NOT the first source the worklist happened to see. Premortem-derived.
|
|
12
15
|
|
|
@@ -71,7 +71,7 @@ export function describeChain(callExpr, opts = {}) {
|
|
|
71
71
|
let isPromise = isPromiseRoot(cur);
|
|
72
72
|
if (!isPromise && opts.summaryCache && opts.callGraph && cur && cur.kind === 'call') {
|
|
73
73
|
const name = typeof cur.callee === 'string' ? cur.callee : (cur.callee?.name || null);
|
|
74
|
-
if (name) isPromise = isAsyncSourceFromSummary(name, opts.summaryCache, opts.callGraph);
|
|
74
|
+
if (name) isPromise = isAsyncSourceFromSummary(name, opts.summaryCache, opts.callGraph, opts.callerFile || null);
|
|
75
75
|
}
|
|
76
76
|
return { ops, rootCallee: cur, isPromise };
|
|
77
77
|
}
|
|
@@ -91,9 +91,14 @@ function isPromiseRoot(expr) {
|
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// `callerFile` is the file the call site lives in. Passing it enables the
|
|
95
|
+
// call graph's same-file preference — without it a bare name (`handler`)
|
|
96
|
+
// binds to whichever file's Map iterates first.
|
|
97
|
+
export function isAsyncSourceFromSummary(calleeName, summaryCache, callGraph, callerFile = null) {
|
|
95
98
|
if (!calleeName || !summaryCache || !callGraph) return false;
|
|
96
|
-
|
|
99
|
+
// resolveKnownCallee, never the permissive resolve(): a guessed bare-tail
|
|
100
|
+
// edge fabricates a dataflow path that does not exist.
|
|
101
|
+
const resolved = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(calleeName, callerFile) : null;
|
|
97
102
|
const qid = resolved && (resolved.qid || resolved);
|
|
98
103
|
if (typeof qid !== 'string') return false;
|
|
99
104
|
const sum = summaryCache.get(qid, new Set());
|
package/src/dataflow/catalog.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
// Sources / sinks / sanitizers catalog.
|
|
2
2
|
//
|
|
3
|
+
// Premortem (post-C/C++ taint catalog review): CALLEE_INDEX is keyed by
|
|
4
|
+
// callee name ALONE, with no language filter at match time. That's fine for
|
|
5
|
+
// most of the catalog because cross-language name collisions are rare and
|
|
6
|
+
// even beneficial (a Python entry matching a Go call of the same name still
|
|
7
|
+
// carries a real vuln). It is NOT fine for the C/C++ entries below, whose
|
|
8
|
+
// names are generic libc calls (`read`, `memcpy`, `sprintf`, `fopen`, `gets`,
|
|
9
|
+
// `scanf`) that also appear as ordinary, unrelated method/function names in
|
|
10
|
+
// JS/Python/PHP/Java source (`stream.read()`, a user-defined `memcpy()`
|
|
11
|
+
// polyfill, PHP's own `sprintf`/`fopen`). Matching those unconditionally
|
|
12
|
+
// turned every `.read()` in a JS file into a recognized C source and every
|
|
13
|
+
// PHP `sprintf`/`fopen` call into a C buffer-overflow/path-traversal sink.
|
|
14
|
+
// `matchSource`/`matchSinkOrSanitizer` therefore accept an optional `file`
|
|
15
|
+
// argument; when present, any candidate entry whose `language === 'cpp'` is
|
|
16
|
+
// dropped unless `file` has a C/C++ extension. Every other entry (all other
|
|
17
|
+
// languages, or a `cpp` entry when no file is supplied) is completely
|
|
18
|
+
// unaffected — this is intentionally NOT general language filtering.
|
|
3
19
|
// Each entry describes a callable or member-access pattern. The taint engine
|
|
4
20
|
// consults this catalog when it sees a call site or a property read.
|
|
5
21
|
//
|
|
@@ -70,7 +86,11 @@ export const CATALOG = [
|
|
|
70
86
|
// XSS / DOM sinks (assignment-form, not match).
|
|
71
87
|
// innerHTML and outerHTML are handled in the engine via assignment LHS matching.
|
|
72
88
|
// DOM sinks.
|
|
73
|
-
|
|
89
|
+
// `receiver` is required here: a bare `write` callee is overwhelmingly
|
|
90
|
+
// `process.stdout.write` / `stream.write` / `fh.write`, not a DOM write.
|
|
91
|
+
// Only a document-ish or window-ish receiver chain qualifies.
|
|
92
|
+
{ kind: 'sink', id: 'js-document-write', language: 'js', framework: 'dom',
|
|
93
|
+
match: { type: 'call', callee: 'write', receiver: '^(?:[A-Za-z_$][\\w$]*[Dd]ocument|document|doc|window|win|top|parent|self|frames|iframe|frame)$' }, argIndex: 0,
|
|
74
94
|
vuln: { name: 'XSS (document.write)', severity: 'high', cwe: 'CWE-79',
|
|
75
95
|
remediation: 'document.write is universally unsafe — use textContent or a typed templating engine.' } },
|
|
76
96
|
// SSRF / HTTP-client sinks: matched by callee; rich-CWE classification in engine.
|
|
@@ -117,6 +137,23 @@ export const CATALOG = [
|
|
|
117
137
|
{ kind: 'source', id: 'py-flask-request-cookies',language: 'py', framework: 'flask', match: { type: 'member', object: 'request', prop: 'cookies' }, label: 'request.cookies' },
|
|
118
138
|
{ kind: 'source', id: 'py-flask-request-headers',language: 'py', framework: 'flask', match: { type: 'member', object: 'request', prop: 'headers' }, label: 'request.headers' },
|
|
119
139
|
{ kind: 'source', id: 'py-flask-request-data', language: 'py', framework: 'flask', match: { type: 'member', object: 'request', prop: 'data' }, label: 'request.data' },
|
|
140
|
+
// Call-shaped variant of the member sources above — `request.args.get(...)`
|
|
141
|
+
// (and Flask's `.form`/`.values`/`.headers`/`.cookies`/`.json`/`.data`,
|
|
142
|
+
// Django's `.GET`/`.POST`/`.FILES`/`.META`) is at least as common in real
|
|
143
|
+
// Flask/Django code as the bare-member form, but matchSource only
|
|
144
|
+
// recognized the member itself. The alternation covers every request
|
|
145
|
+
// property this catalog already trusts as a member source
|
|
146
|
+
// (py-flask-request-args/form/json/values/cookies/headers/data above) so
|
|
147
|
+
// the `.get()` chain restores the same recall the member form has, rather
|
|
148
|
+
// than a subset of it — a first cut here covered only args/form/values and
|
|
149
|
+
// silently left request.headers.get()/cookies.get()/json.get() undetected
|
|
150
|
+
// (measured 4→0 against the pre-scoping cross-language leak, vs. 4→2 for
|
|
151
|
+
// args/form/values; see phase2-scoping.test.js's shape-by-shape A/B test).
|
|
152
|
+
// Gated by `match.receiver` (checked by matchSource via _receiverAllowed,
|
|
153
|
+
// same mechanism matchSinkOrSanitizer already uses for sinks) so a plain
|
|
154
|
+
// `dict.get(...)`/`config.get(...)` elsewhere in the file does not also
|
|
155
|
+
// fire.
|
|
156
|
+
{ kind: 'source', id: 'py-flask-args-get', language: 'py', framework: 'flask', match: { type: 'call', callee: 'get', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values/headers/cookies/json/data.get() (Flask/Django)', provenance: 'url-param' },
|
|
120
157
|
{ kind: 'source', id: 'py-fastapi-request-query',language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Query' }, label: 'fastapi.Query()' },
|
|
121
158
|
{ kind: 'source', id: 'py-fastapi-request-body', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Body' }, label: 'fastapi.Body()' },
|
|
122
159
|
{ kind: 'source', id: 'py-fastapi-form', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Form' }, label: 'fastapi.Form()' },
|
|
@@ -348,10 +385,19 @@ export const CATALOG = [
|
|
|
348
385
|
remediation: 'When the first arg is "/bin/sh" or "bash" with a -c string built from user input, the shell parses it. Pass argv array values directly to exec.Command.' } },
|
|
349
386
|
|
|
350
387
|
// ─── SINKS (deserialization) ──────────────────────────────────────────────
|
|
351
|
-
|
|
388
|
+
// `load`/`loads` are among the most overloaded names in Python: `json.load`,
|
|
389
|
+
// `json.loads`, `tomllib.load` and `configparser`-style readers all share
|
|
390
|
+
// them and none of them are deserialization sinks. Bare-name matching made
|
|
391
|
+
// every one of those fire once sinks began matching in assignment position
|
|
392
|
+
// (`rules = json.load(fh)`), so the pyyaml/pickle entries are pinned to
|
|
393
|
+
// their own receiver — the same `match.receiver` mechanism `py-flask-args-get`
|
|
394
|
+
// already uses. Cost: a `from yaml import load; load(x)` import-style call no
|
|
395
|
+
// longer matches (no receiver segment); that is the accepted trade, and the
|
|
396
|
+
// dotted form is by far the dominant shape in real code.
|
|
397
|
+
{ kind: 'sink', id: 'py-pickle-loads', language: 'py', framework: 'pickle', match: { type: 'call', callee: 'loads', receiver: '^(?:pickle|cPickle|_pickle|dill|jsonpickle)$' }, argIndex: 0,
|
|
352
398
|
vuln: { name: 'Insecure Deserialization (pickle.loads)', severity: 'critical', cwe: 'CWE-502',
|
|
353
399
|
remediation: 'Never pickle-load attacker-controlled data. Use JSON / msgpack with an explicit schema.' } },
|
|
354
|
-
{ kind: 'sink', id: 'py-yaml-load', language: 'py', framework: 'pyyaml', match: { type: 'call', callee: 'load' }, argIndex: 0,
|
|
400
|
+
{ kind: 'sink', id: 'py-yaml-load', language: 'py', framework: 'pyyaml', match: { type: 'call', callee: 'load', receiver: '^(?:yaml|ruamel)$' }, argIndex: 0,
|
|
355
401
|
vuln: { name: 'Insecure Deserialization (yaml.load)', severity: 'critical', cwe: 'CWE-502',
|
|
356
402
|
remediation: 'Use yaml.safe_load.' } },
|
|
357
403
|
{ kind: 'sink', id: 'java-ois-readObject', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'readObject' }, argIndex: 'all',
|
|
@@ -547,13 +593,15 @@ export const CATALOG = [
|
|
|
547
593
|
vuln: { name: 'Code Injection (compile)', severity: 'high', cwe: 'CWE-95',
|
|
548
594
|
remediation: 'compile() followed by exec is equivalent to eval. Avoid on untrusted input.' } },
|
|
549
595
|
// Deserialization.
|
|
550
|
-
|
|
596
|
+
// Receiver-pinned for the same reason as the base-catalog pyyaml/pickle
|
|
597
|
+
// entries above — see the comment there.
|
|
598
|
+
{ kind: 'sink', id: 'py-pickle-loads-v2', language: 'py', framework: 'std', match: { type: 'call', callee: 'loads', receiver: '^(?:pickle|cPickle|_pickle|dill|jsonpickle)$' }, argIndex: 0,
|
|
551
599
|
vuln: { name: 'Unsafe Deserialization (pickle.loads)', severity: 'critical', cwe: 'CWE-502',
|
|
552
600
|
remediation: 'pickle.loads on untrusted data is RCE. Use JSON / msgpack with explicit schema.' } },
|
|
553
|
-
{ kind: 'sink', id: 'py-pickle-load', language: 'py', framework: 'std', match: { type: 'call', callee: 'load' }, argIndex: 0,
|
|
601
|
+
{ kind: 'sink', id: 'py-pickle-load', language: 'py', framework: 'std', match: { type: 'call', callee: 'load', receiver: '^(?:pickle|cPickle|_pickle|dill|jsonpickle)$' }, argIndex: 0,
|
|
554
602
|
vuln: { name: 'Unsafe Deserialization (pickle.load)', severity: 'critical', cwe: 'CWE-502',
|
|
555
603
|
remediation: 'pickle.load on untrusted streams is RCE.' } },
|
|
556
|
-
{ kind: 'sink', id: 'py-yaml-load-v2', language: 'py', framework: 'yaml', match: { type: 'call', callee: 'load' }, argIndex: 0,
|
|
604
|
+
{ kind: 'sink', id: 'py-yaml-load-v2', language: 'py', framework: 'yaml', match: { type: 'call', callee: 'load', receiver: '^(?:yaml|ruamel)$' }, argIndex: 0,
|
|
557
605
|
vuln: { name: 'Unsafe Deserialization (yaml.load)', severity: 'high', cwe: 'CWE-502',
|
|
558
606
|
remediation: 'Use yaml.safe_load instead of yaml.load on untrusted YAML.' } },
|
|
559
607
|
// SSRF / HTTP-out.
|
|
@@ -681,6 +729,47 @@ export const CATALOG = [
|
|
|
681
729
|
{ kind: 'sanitizer', id: 'kt-int-toIntOrNull',language: 'kt', match: { type: 'call', callee: 'toIntOrNull' }, effect: 'strip', appliesTo: ['*'] },
|
|
682
730
|
{ kind: 'sanitizer', id: 'kt-path-canonical',language: 'kt', match: { type: 'call', callee: 'canonicalFile' },effect: 'taintIf-not-pinned', appliesTo: ['path'] },
|
|
683
731
|
{ kind: 'sanitizer', id: 'kt-jdbc-setstring',language: 'kt', match: { type: 'call', callee: 'setString' }, effect: 'strip', appliesTo: ['sql'] },
|
|
732
|
+
|
|
733
|
+
// ─── SOURCES (C / C++) ─────────────────────────────────────────────────────
|
|
734
|
+
{ kind: 'source', id: 'cpp-getenv', language: 'cpp', framework: null, match: { type: 'call', callee: 'getenv' }, label: 'getenv()', provenance: 'env' },
|
|
735
|
+
{ kind: 'source', id: 'cpp-recv', language: 'cpp', framework: null, match: { type: 'call', callee: 'recv' }, label: 'recv()', provenance: 'network' },
|
|
736
|
+
{ kind: 'source', id: 'cpp-recvfrom',language: 'cpp', framework: null, match: { type: 'call', callee: 'recvfrom'}, label: 'recvfrom()',provenance: 'network' },
|
|
737
|
+
{ kind: 'source', id: 'cpp-read', language: 'cpp', framework: null, match: { type: 'call', callee: 'read' }, label: 'read()', provenance: 'file-read' },
|
|
738
|
+
{ kind: 'source', id: 'cpp-fread', language: 'cpp', framework: null, match: { type: 'call', callee: 'fread' }, label: 'fread()', provenance: 'file-read' },
|
|
739
|
+
{ kind: 'source', id: 'cpp-fgets', language: 'cpp', framework: null, match: { type: 'call', callee: 'fgets' }, label: 'fgets()', provenance: 'file-read' },
|
|
740
|
+
{ kind: 'source', id: 'cpp-gets', language: 'cpp', framework: null, match: { type: 'call', callee: 'gets' }, label: 'gets()', provenance: 'stdin' },
|
|
741
|
+
{ kind: 'source', id: 'cpp-scanf', language: 'cpp', framework: null, match: { type: 'call', callee: 'scanf' }, label: 'scanf()', provenance: 'stdin' },
|
|
742
|
+
|
|
743
|
+
// ─── SINKS (C / C++) ───────────────────────────────────────────────────────
|
|
744
|
+
{ kind: 'sink', id: 'cpp-system', language: 'cpp', framework: null, match: { type: 'call', callee: 'system' }, argIndex: 0,
|
|
745
|
+
vuln: { name: 'Command injection via system()', severity: 'critical', cwe: 'CWE-78', remediation: 'Use execve() with an argument vector instead of passing a shell string; never interpolate untrusted input into a command.' } },
|
|
746
|
+
{ kind: 'sink', id: 'cpp-popen', language: 'cpp', framework: null, match: { type: 'call', callee: 'popen' }, argIndex: 0,
|
|
747
|
+
vuln: { name: 'Command injection via popen()', severity: 'critical', cwe: 'CWE-78', remediation: 'Use a pipe with execve() and an argument vector rather than a shell command string.' } },
|
|
748
|
+
{ kind: 'sink', id: 'cpp-execl', language: 'cpp', framework: null, match: { type: 'call', callee: 'execl' }, argIndex: 0,
|
|
749
|
+
vuln: { name: 'Command injection via execl()', severity: 'critical', cwe: 'CWE-78', remediation: 'Validate the executable path against an allow-list; never build it from untrusted input.' } },
|
|
750
|
+
{ kind: 'sink', id: 'cpp-strcpy', language: 'cpp', framework: null, match: { type: 'call', callee: 'strcpy' }, argIndex: 1,
|
|
751
|
+
vuln: { name: 'Buffer overflow via strcpy()', severity: 'high', cwe: 'CWE-120', remediation: 'Use strncpy() or snprintf() with an explicit destination size.' } },
|
|
752
|
+
{ kind: 'sink', id: 'cpp-strcat', language: 'cpp', framework: null, match: { type: 'call', callee: 'strcat' }, argIndex: 1,
|
|
753
|
+
vuln: { name: 'Buffer overflow via strcat()', severity: 'high', cwe: 'CWE-120', remediation: 'Use strncat() or snprintf() with an explicit destination size.' } },
|
|
754
|
+
{ kind: 'sink', id: 'cpp-sprintf', language: 'cpp', framework: null, match: { type: 'call', callee: 'sprintf' }, argIndex: 'all',
|
|
755
|
+
vuln: { name: 'Buffer overflow via sprintf()', severity: 'high', cwe: 'CWE-120', remediation: 'Use snprintf() with an explicit destination size.' } },
|
|
756
|
+
{ kind: 'sink', id: 'cpp-memcpy', language: 'cpp', framework: null, match: { type: 'call', callee: 'memcpy' }, argIndex: 2,
|
|
757
|
+
vuln: { name: 'Buffer overflow via unchecked memcpy() length', severity: 'high', cwe: 'CWE-787', remediation: 'Bound the copy length by the destination size before copying.' } },
|
|
758
|
+
{ kind: 'sink', id: 'cpp-fopen', language: 'cpp', framework: null, match: { type: 'call', callee: 'fopen' }, argIndex: 0,
|
|
759
|
+
vuln: { name: 'Path traversal via fopen()', severity: 'high', cwe: 'CWE-22', remediation: 'Canonicalise with realpath() and confirm the result stays within an allowed base directory.' } },
|
|
760
|
+
{ kind: 'sink', id: 'cpp-dlopen', language: 'cpp', framework: null, match: { type: 'call', callee: 'dlopen' }, argIndex: 0,
|
|
761
|
+
vuln: { name: 'Untrusted library load via dlopen()', severity: 'critical', cwe: 'CWE-114', remediation: 'Load only from a fixed, trusted path; never build the library path from untrusted input.' } },
|
|
762
|
+
|
|
763
|
+
// ─── SANITIZERS (C / C++) ──────────────────────────────────────────────────
|
|
764
|
+
{ kind: 'sanitizer', id: 'cpp-realpath', language: 'cpp', framework: null, match: { type: 'call', callee: 'realpath' }, effect: 'strip' },
|
|
765
|
+
// Deliberately NOT listed as sanitizers: snprintf() and strncpy() bound the
|
|
766
|
+
// COPY LENGTH, they do not touch the CONTENT. `snprintf(buf, n, "ls %s",
|
|
767
|
+
// user); system(buf);` is still command injection — truncating the string
|
|
768
|
+
// doesn't remove shell metacharacters. Nothing in the engine consumes
|
|
769
|
+
// `effect` for a non-'sql' appliesTo today, so marking them 'strip' was
|
|
770
|
+
// inert, but a future sanitizer consumer would silently turn this into a
|
|
771
|
+
// false negative. realpath() stays: it genuinely canonicalises a path,
|
|
772
|
+
// which is the property CWE-22 sinks care about.
|
|
684
773
|
];
|
|
685
774
|
|
|
686
775
|
// ─── Expanded sanitizer catalog (v0.65.0) ────────────────────────────────
|
|
@@ -688,6 +777,132 @@ export const CATALOG = [
|
|
|
688
777
|
// Lives in catalog-expanded.js to keep the diff reviewable. Merged into
|
|
689
778
|
// the main CATALOG below so the indexer treats them identically.
|
|
690
779
|
import { EXPANDED_SANITIZERS } from './catalog-expanded.js';
|
|
780
|
+
import { cppExtRe } from '../ir/parser-cpp.js';
|
|
781
|
+
|
|
782
|
+
// Language-scope guard. `file` is optional — when absent, behavior is
|
|
783
|
+
// unchanged from before this guard existed (every entry is allowed).
|
|
784
|
+
//
|
|
785
|
+
// Table-driven across all nine catalog languages (Phase 2). Extension sets
|
|
786
|
+
// MUST equal the ones ir/index.js uses to dispatch each parser. Narrower
|
|
787
|
+
// silently drops true positives; wider re-opens the cross-language leak that
|
|
788
|
+
// put a js DOM rule on Python files in Phase 1 (the `js-document-write` sink
|
|
789
|
+
// fired on `sys.stderr.write(...)` / `fh.write(...)` because callee matching
|
|
790
|
+
// is by bare name). phase2-scoping.test.js pins both directions against the
|
|
791
|
+
// real dispatch source in ir/index.js.
|
|
792
|
+
//
|
|
793
|
+
// A language with NO entry here stays permissive (matches unconditionally) —
|
|
794
|
+
// that makes this table additive and means it cannot regress a language
|
|
795
|
+
// before its mapping exists.
|
|
796
|
+
const _LANG_EXT = {
|
|
797
|
+
js: /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i,
|
|
798
|
+
py: /\.py$/i,
|
|
799
|
+
cs: /\.cs$/i,
|
|
800
|
+
kt: /\.kt$/i,
|
|
801
|
+
go: /\.go$/i,
|
|
802
|
+
php: /\.(?:php|phtml)$/i,
|
|
803
|
+
rb: /\.rb$/i,
|
|
804
|
+
java: /\.java$/i,
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// cpp delegates to cppExtRe() rather than duplicating a literal set, so it
|
|
808
|
+
// stays in lockstep with the parser dispatch in ir/index.js.
|
|
809
|
+
export function _languageExtensions() {
|
|
810
|
+
return { ..._LANG_EXT, cpp: cppExtRe() };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ─── Runtime families ─────────────────────────────────────────────────────
|
|
814
|
+
// A catalog `language` names a catalog *dialect*, not a file type. The two are
|
|
815
|
+
// usually the same thing, but not always: `java` and `kt` are two dialects over
|
|
816
|
+
// ONE runtime with ONE library surface. Kotlin code calls the Java standard
|
|
817
|
+
// library, JDBC, Hibernate and the servlet API constantly, and Java code can
|
|
818
|
+
// call Kotlin stdlib extensions — so scoping each dialect to its own extension
|
|
819
|
+
// alone silently deletes real detections in both directions.
|
|
820
|
+
//
|
|
821
|
+
// It did exactly that: after the per-language scoping landed, `.kt` files
|
|
822
|
+
// stopped matching 12 `java`-language sinks (executeUpdate, execute,
|
|
823
|
+
// prepareStatement, addBatch, createQuery, createSQLQuery, createNativeQuery,
|
|
824
|
+
// File, search, compile, sendRedirect, parse) and 4 `java` sources
|
|
825
|
+
// (getCookies, getInputStream, getReader, getProperty), and `.java` files
|
|
826
|
+
// stopped matching `readText`. A `.kt` file with
|
|
827
|
+
// `val q = req.getParameter("q"); stmt.executeUpdate(q)` went from 1 IR-TAINT
|
|
828
|
+
// finding to 0 — JDBC/Hibernate SQLi, servlet open-redirect and XXE going
|
|
829
|
+
// silent in Kotlin.
|
|
830
|
+
//
|
|
831
|
+
// So scoping is by FAMILY, not by dialect: an entry may match any file
|
|
832
|
+
// extension belonging to any dialect in its family. A dialect absent from this
|
|
833
|
+
// map is its own family (the common case). Keep this a declarative map rather
|
|
834
|
+
// than a conditional in `_languageAllowed` — the whole point is that the next
|
|
835
|
+
// person reading it sees "these dialects share a runtime" without having to
|
|
836
|
+
// reverse-engineer a special case. `phase2-scoping.test.js` pins the
|
|
837
|
+
// cross-family behaviour directly (executeUpdate on .kt, readText on .java);
|
|
838
|
+
// the extension-set equality test cannot catch a family error, because the
|
|
839
|
+
// extension sets were never wrong — the one-dialect-one-file-type assumption
|
|
840
|
+
// was.
|
|
841
|
+
const _LANG_FAMILY = {
|
|
842
|
+
java: ['java', 'kt'], // JVM
|
|
843
|
+
kt: ['java', 'kt'], // JVM
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// Extension regexes a given catalog language is allowed to match against.
|
|
847
|
+
export function _languageFamilyExtensions(language) {
|
|
848
|
+
const all = _languageExtensions();
|
|
849
|
+
const dialects = _LANG_FAMILY[language] || [language];
|
|
850
|
+
return dialects.map(d => all[d]).filter(Boolean);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function _languageAllowed(entry, file) {
|
|
854
|
+
if (!file) return true;
|
|
855
|
+
const res = _languageFamilyExtensions(entry.language);
|
|
856
|
+
if (!res.length) return true; // unmapped language stays permissive
|
|
857
|
+
return res.some(re => re.test(file));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Receiver constraint (`match.receiver`), evaluated against the object chain
|
|
861
|
+
// of a member-call callee. Exists because callee matching is by bare NAME:
|
|
862
|
+
// `js-document-write` matches `write`, which is `document.write` (a real DOM
|
|
863
|
+
// XSS sink) but equally `process.stdout.write`, `fh.write`, `stream.write`.
|
|
864
|
+
// An entry that declares `receiver` only fires when some segment of its
|
|
865
|
+
// receiver chain matches — a bare `write(x)` with no receiver never fires.
|
|
866
|
+
//
|
|
867
|
+
// Segments are collected from both expression callees (member chains) and
|
|
868
|
+
// string callees (dotted names from the Go / C++ parsers).
|
|
869
|
+
function _receiverSegments(calleeExpr) {
|
|
870
|
+
const segs = [];
|
|
871
|
+
if (typeof calleeExpr === 'string') {
|
|
872
|
+
const parts = calleeExpr.split('.');
|
|
873
|
+
parts.pop(); // drop the method name itself
|
|
874
|
+
return parts;
|
|
875
|
+
}
|
|
876
|
+
if (!calleeExpr || calleeExpr.kind !== 'member') return segs;
|
|
877
|
+
let cur = calleeExpr.object;
|
|
878
|
+
let depth = 0;
|
|
879
|
+
while (cur && depth++ < 8) {
|
|
880
|
+
if (cur.kind === 'ident') { segs.push(cur.name); break; }
|
|
881
|
+
if (cur.kind === 'member') { if (typeof cur.prop === 'string') segs.push(cur.prop); cur = cur.object; continue; }
|
|
882
|
+
if (cur.kind === 'call') { cur = cur.callee; continue; }
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
return segs;
|
|
886
|
+
}
|
|
887
|
+
function _receiverAllowed(entry, calleeExpr) {
|
|
888
|
+
const pat = entry.match && entry.match.receiver;
|
|
889
|
+
// `receiverBase` (optional, additive) requires a SECOND, independent
|
|
890
|
+
// segment match — e.g. `receiver` pins the property name (`headers`,
|
|
891
|
+
// `args`) while `receiverBase` pins the object it hangs off (`request`,
|
|
892
|
+
// `req`). Both are satisfied independently against the segment set (order
|
|
893
|
+
// doesn't matter — `self.request.args.get()` has `request` in the chain
|
|
894
|
+
// just like `request.args.get()` does), so a bare `args.get(...)` on an
|
|
895
|
+
// unrelated local (`args = parse(); args.get("cmd")`) fails the base check
|
|
896
|
+
// even though it still matches `receiver` on its own. Existing entries
|
|
897
|
+
// that only set `receiver` are unaffected.
|
|
898
|
+
const basePat = entry.match && entry.match.receiverBase;
|
|
899
|
+
if (!pat && !basePat) return true;
|
|
900
|
+
const segs = _receiverSegments(calleeExpr);
|
|
901
|
+
if (!segs.length) return false; // bare `write(x)` — not a DOM call
|
|
902
|
+
if (pat && !segs.some((s) => new RegExp(pat).test(String(s)))) return false;
|
|
903
|
+
if (basePat && !segs.some((s) => new RegExp(basePat).test(String(s)))) return false;
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
691
906
|
// Merge the expanded sanitizer catalog. We dedupe on `id` (case-insensitive)
|
|
692
907
|
// so a base-catalog entry always wins over a same-id expanded one — the base
|
|
693
908
|
// catalog is the curated/blessed surface; the expansion is additive coverage.
|
|
@@ -724,6 +939,7 @@ for (const e of CATALOG) {
|
|
|
724
939
|
// filter runs per-match by reading the env each call.
|
|
725
940
|
const CALLEE_INDEX = new Map();
|
|
726
941
|
const MEMBER_INDEX = new Map();
|
|
942
|
+
const GLOBAL_INDEX = new Map();
|
|
727
943
|
for (const e of CATALOG) {
|
|
728
944
|
if (!e.match) continue;
|
|
729
945
|
if (e.match.type === 'call' && e.match.callee && e.match.callee !== '*') {
|
|
@@ -734,6 +950,14 @@ for (const e of CATALOG) {
|
|
|
734
950
|
const k = `${e.match.object}.${e.match.prop}`;
|
|
735
951
|
if (!MEMBER_INDEX.has(k)) MEMBER_INDEX.set(k, []);
|
|
736
952
|
MEMBER_INDEX.get(k).push(e);
|
|
953
|
+
} else if (e.match.type === 'global' && e.match.name) {
|
|
954
|
+
// Globals (PHP superglobals, rails params/session, JS location) were
|
|
955
|
+
// indexed nowhere, so matchSource could never return one — every entry
|
|
956
|
+
// declared this way was dead. Keyed by the bare name the source appears
|
|
957
|
+
// under in code.
|
|
958
|
+
const k = e.match.name;
|
|
959
|
+
if (!GLOBAL_INDEX.has(k)) GLOBAL_INDEX.set(k, []);
|
|
960
|
+
GLOBAL_INDEX.get(k).push(e);
|
|
737
961
|
}
|
|
738
962
|
}
|
|
739
963
|
|
|
@@ -762,13 +986,52 @@ function filterByProvenance(entries) {
|
|
|
762
986
|
return list;
|
|
763
987
|
}
|
|
764
988
|
|
|
765
|
-
|
|
989
|
+
// Round-1 fix follow-up: the PHP IR frontend (parser-php.js `_lowerExpr`)
|
|
990
|
+
// keeps the `$` sigil on variable idents — `$_GET['cmd']` lowers to
|
|
991
|
+
// `{ kind:'member', object:{ kind:'ident', name:'$_GET' }, prop:'cmd' }` —
|
|
992
|
+
// but the catalog's global entries are keyed without it (`_GET`). Every
|
|
993
|
+
// real PHP superglobal therefore missed GLOBAL_INDEX even after it was
|
|
994
|
+
// indexed, and the earlier synthetic-ident unit tests (which built their
|
|
995
|
+
// own sigil-free `{ kind:'ident', name:'_GET' }` nodes) could not catch
|
|
996
|
+
// this because they never exercised the real parser's output.
|
|
997
|
+
//
|
|
998
|
+
// Fixed by normalizing at the lookup, not by re-keying all five PHP
|
|
999
|
+
// entries with a `$`: the catalog's `match.name` is the single spot that
|
|
1000
|
+
// already reads naturally for every other language (Ruby `params`, JS
|
|
1001
|
+
// `location` carry no sigil), and a lookup-side strip keeps that uniform
|
|
1002
|
+
// — a future language whose IR *does* prefix variables doesn't need its
|
|
1003
|
+
// own catalog dialect. Ruby (`params`, `cookies`, `session`, `ENV`) and JS
|
|
1004
|
+
// (`location`) were checked against their real parsers in this round and
|
|
1005
|
+
// need no such stripping — confirmed no sigil or other prefix there.
|
|
1006
|
+
function _globalKey(name) {
|
|
1007
|
+
return typeof name === 'string' && name.charCodeAt(0) === 36 /* '$' */ ? name.slice(1) : name;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function matchSource(expr, file) {
|
|
766
1011
|
if (!expr) return null;
|
|
767
1012
|
// Member sources (req.query): the original path — unchanged.
|
|
768
1013
|
if (expr.kind === 'member' && expr.object?.kind === 'ident') {
|
|
769
1014
|
const raw = MEMBER_INDEX.get(`${expr.object.name}.${expr.prop}`);
|
|
770
1015
|
if (raw) {
|
|
771
|
-
const hits = filterByProvenance(raw);
|
|
1016
|
+
const hits = filterByProvenance(raw).filter(h => _languageAllowed(h, file));
|
|
1017
|
+
const s = hits.find(h => h.kind === 'source');
|
|
1018
|
+
if (s) return s;
|
|
1019
|
+
}
|
|
1020
|
+
// Global sources reached as a member read off the global itself:
|
|
1021
|
+
// $_GET['x'], params[:id]. Match on the member's root, not the pair.
|
|
1022
|
+
const rawGlobal = GLOBAL_INDEX.get(_globalKey(expr.object.name));
|
|
1023
|
+
if (rawGlobal) {
|
|
1024
|
+
const hits = filterByProvenance(rawGlobal).filter(h => _languageAllowed(h, file));
|
|
1025
|
+
const s = hits.find(h => h.kind === 'source');
|
|
1026
|
+
if (s) return s;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// Bare-identifier globals: PHP superglobals, Rails params/session/cookies,
|
|
1030
|
+
// Ruby ENV, JS location — referenced directly without a member access.
|
|
1031
|
+
if (expr.kind === 'ident' && expr.name) {
|
|
1032
|
+
const raw = GLOBAL_INDEX.get(_globalKey(expr.name));
|
|
1033
|
+
if (raw) {
|
|
1034
|
+
const hits = filterByProvenance(raw).filter(h => _languageAllowed(h, file));
|
|
772
1035
|
const s = hits.find(h => h.kind === 'source');
|
|
773
1036
|
if (s) return s;
|
|
774
1037
|
}
|
|
@@ -785,7 +1048,9 @@ export function matchSource(expr) {
|
|
|
785
1048
|
if (cn) {
|
|
786
1049
|
const raw = CALLEE_INDEX.get(cn);
|
|
787
1050
|
if (raw) {
|
|
788
|
-
const hits = filterByProvenance(raw)
|
|
1051
|
+
const hits = filterByProvenance(raw)
|
|
1052
|
+
.filter(h => _languageAllowed(h, file))
|
|
1053
|
+
.filter(h => _receiverAllowed(h, expr.callee));
|
|
789
1054
|
const s = hits.find(h => h.kind === 'source');
|
|
790
1055
|
if (s) return s;
|
|
791
1056
|
}
|
|
@@ -794,7 +1059,7 @@ export function matchSource(expr) {
|
|
|
794
1059
|
return null;
|
|
795
1060
|
}
|
|
796
1061
|
|
|
797
|
-
export function matchSinkOrSanitizer(calleeExpr) {
|
|
1062
|
+
export function matchSinkOrSanitizer(calleeExpr, file) {
|
|
798
1063
|
if (!calleeExpr) return null;
|
|
799
1064
|
let calleeName = null;
|
|
800
1065
|
// R3 (PRD §5): the Go IR (and other string-callee parsers) represent a call
|
|
@@ -806,7 +1071,9 @@ export function matchSinkOrSanitizer(calleeExpr) {
|
|
|
806
1071
|
if (!calleeName) return null;
|
|
807
1072
|
const raw = CALLEE_INDEX.get(calleeName);
|
|
808
1073
|
if (!raw) return null;
|
|
809
|
-
const hits = filterByProvenance(raw)
|
|
1074
|
+
const hits = filterByProvenance(raw)
|
|
1075
|
+
.filter(h => _languageAllowed(h, file))
|
|
1076
|
+
.filter(h => _receiverAllowed(h, calleeExpr));
|
|
810
1077
|
return hits.length ? hits : null;
|
|
811
1078
|
}
|
|
812
1079
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// name is the GitHub slug ('owner/name'); specPath is relative within
|
|
24
24
|
// that repo; specContent is the raw YAML/JSON.
|
|
25
25
|
|
|
26
|
-
import * as yaml from 'js
|
|
26
|
+
import * as yaml from '../util/yaml.js';
|
|
27
27
|
|
|
28
28
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
29
29
|
|