@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -1 +1 @@
1
- 424585bd11f792465c48ad4fcf7bcb61be3b3351b80f3fdb500eb7f89cb6755c agentic-security.mjs
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.127.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": "^7.29.7",
39
- "@babel/preset-react": "^7.29.7",
40
- "@babel/preset-typescript": "^7.29.7",
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": "^4.1.1",
43
+ "js-yaml": "^5.2.2",
44
44
  "safe-regex": "^2.1.1"
45
45
  },
46
46
  "optionalDependencies": {
47
- "web-tree-sitter": "0.20.8",
48
- "tree-sitter-wasms": "0.1.13"
47
+ "tree-sitter-wasms": "0.1.13",
48
+ "web-tree-sitter": "0.20.8"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/node": "^24.0.0",
52
- "@vercel/ncc": "^0.38.3"
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"
@@ -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/sanitizer 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.
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
- export function isAsyncSourceFromSummary(calleeName, summaryCache, callGraph) {
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
- const resolved = callGraph.resolve ? callGraph.resolve(calleeName) : null;
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());
@@ -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
- { kind: 'sink', id: 'js-document-write', language: 'js', framework: 'dom', match: { type: 'call', callee: 'write' }, argIndex: 0,
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
- { kind: 'sink', id: 'py-pickle-loads', language: 'py', framework: 'pickle', match: { type: 'call', callee: 'loads' }, argIndex: 0,
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
- { kind: 'sink', id: 'py-pickle-loads-v2', language: 'py', framework: 'std', match: { type: 'call', callee: 'loads' }, argIndex: 0,
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
- export function matchSource(expr) {
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-yaml';
26
+ import * as yaml from '../util/yaml.js';
27
27
 
28
28
  const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
29
29
 
@@ -42,7 +42,7 @@
42
42
 
43
43
  import * as fs from 'node:fs';
44
44
  import * as path from 'node:path';
45
- import * as yaml from 'js-yaml';
45
+ import * as yaml from '../util/yaml.js';
46
46
 
47
47
  const SERVICES_FILE_NAMES = ['services.yml', 'services.yaml'];
48
48