@clear-capabilities/agentic-security-scanner 0.136.9 → 0.137.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.
@@ -1 +1 @@
1
- 97962939dfdb49667c5808ca441d6db28a2032f1ecf34f582e106adca48c1f16 agentic-security.mjs
1
+ c8726408d88159f1954448e223340231893809e7cfeaa72d0fce196a39a1395c agentic-security.mjs
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.136.9",
4
- "description": "Scanner engine for the agentic-security Claude Code plugin \u2014 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, pipeline integrity, compliance attestation, and more.",
3
+ "version": "0.137.0",
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, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "@babel/preset-react": "^8.0.1",
41
41
  "@babel/preset-typescript": "^8.0.1",
42
42
  "java-parser": "^3.0.1",
43
- "js-yaml": "^5.2.3",
43
+ "js-yaml": "^5.3.0",
44
44
  "safe-regex": "^2.1.1"
45
45
  },
46
46
  "optionalDependencies": {
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^26.2.0",
52
- "@vercel/ncc": "^0.44.1"
52
+ "@vercel/ncc": "^0.45.0"
53
53
  },
54
54
  "overrides": {
55
55
  "lodash": "^4.18.1",
@@ -61,11 +61,11 @@
61
61
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs && node ../scripts/release-check.mjs",
62
62
  "test": "npm run test:smoke && npm run test:glob && 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 && npm run test:discovery && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js && npm run test:python",
63
63
  "test:python": "node ../scripts/run-python-tests.mjs",
64
- "test:smoke": "node --test test/smoke.test.js",
64
+ "test:smoke": "node --test test/smoke.test.js test/demo-app.test.js",
65
65
  "test:glob": "node --test test/glob-compat.test.js",
66
- "test:sast": "node --test test/crypto-specialist.test.js 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/mcp-audit-scan.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/secret-redaction.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 test/adversarial-robustness.test.js test/secret-entropy.test.js test/comment-strip.test.js",
67
- "test:posture": "node --test test/privacy-framework.test.js test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/predeploy-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/independent-population.test.js test/evidence-bundle.test.js test/discovery-memory.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/epss-enrich.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/logic-claims.test.js test/attestation.test.js test/verify-attestation-cli.test.js test/comparison.test.js test/determinism-cross-machine.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/ignore-pragma.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/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js test/world-class-batch2.test.js test/world-class-modules.test.js test/integration-end-to-end.test.js test/streak-cli-wiring.test.js test/stack-playbook.test.js test/machine-output-flag.test.js test/only-flag-channels.test.js test/confidence.test.js test/security-trend.test.js test/calibration-drift.test.js test/validator-metrics.test.js",
68
- "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/layer-recall.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/sanitizer-typed-flow.test.js test/sanitizer-gate-unit.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/parser-js-decorators.test.js test/parser-js-if-else-cfg.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js test/catalog-expanded.test.js test/builtin-summaries.test.js test/interproc-mutation-crash.test.js test/mutated-param-taint.test.js test/higher-order-inline-callback.test.js test/interproc-findings-discard.test.js test/runscan-deep-option.test.js test/summary-cache-convergence.test.js test/parser-rb-calls.test.js test/balanced-call.test.js test/destructuring-taint.test.js test/computed-member-taint.test.js test/string-interpolation-taint.test.js",
66
+ "test:sast": "node --test test/crypto-specialist.test.js 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/mcp-audit-scan.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/guard-window-identifier-correlation.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/secret-redaction.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 test/adversarial-robustness.test.js test/secret-entropy.test.js test/comment-strip.test.js test/juliet-path-filter-gate.test.js test/java-bench-shape-opt-in.test.js test/reachability-missing-callgraph-data.test.js",
67
+ "test:posture": "node --test test/privacy-framework.test.js test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/predeploy-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/independent-population.test.js test/evidence-bundle.test.js test/discovery-memory.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/epss-enrich.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/logic-claims.test.js test/attestation.test.js test/verify-attestation-cli.test.js test/comparison.test.js test/determinism-cross-machine.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/ignore-pragma.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/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js test/world-class-batch2.test.js test/world-class-modules.test.js test/integration-end-to-end.test.js test/streak-cli-wiring.test.js test/stack-playbook.test.js test/machine-output-flag.test.js test/only-flag-channels.test.js test/confidence.test.js test/security-trend.test.js test/calibration-drift.test.js test/validator-metrics.test.js test/clustering-ir-taint-line.test.js",
68
+ "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-cs-annotations.test.js test/parser-cs-control-flow.test.js test/parser-kt-control-flow.test.js test/parser-js-annotations.test.js test/parser-java-annotations.test.js test/parser-java-control-flow.test.js test/parser-java-calls.test.js test/parser-go.test.js test/parser-php-rb.test.js test/parser-php-control-flow.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/layer-recall.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/sanitizer-typed-flow.test.js test/sanitizer-gate-unit.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/parser-js-decorators.test.js test/parser-js-if-else-cfg.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js test/catalog-annotation-source.test.js test/catalog-expanded.test.js test/builtin-summaries.test.js test/interproc-mutation-crash.test.js test/mutated-param-taint.test.js test/higher-order-inline-callback.test.js test/interproc-findings-discard.test.js test/runscan-deep-option.test.js test/summary-cache-convergence.test.js test/parser-rb-calls.test.js test/parser-rb-module-level.test.js test/balanced-call.test.js test/destructuring-taint.test.js test/computed-member-taint.test.js test/string-interpolation-taint.test.js test/dataflow-deadcode-severity.test.js test/catalog-dotted-callee-lookup.test.js test/builtin-summary-family-blind-kill.test.js test/deep-mode-annotator-pipeline.test.js test/receiver-type-and-nested-calls.test.js test/member-write-and-loop-taint.test.js test/parser-py-module-level.test.js test/parser-php-module-level.test.js test/r14b-module-level-e2e.test.js test/annotation-taint-engine.test.js",
69
69
  "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 test/lsp-server.test.js",
70
70
  "test:report": "node --test 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",
71
71
  "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",
@@ -6,7 +6,9 @@ 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 matching.** Add entries in `catalog.js`. Each entry: `kind` ∈ {source, sink, sanitizer}, plus language + framework + match shape. 641 entries (136 source, 124 sink, 381 sanitizer — re-derive via `CATALOG.length` / a per-`kind` count rather than trusting a hardcoded number here again) 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. `match.type` ∈ {`call`, `member`, `global`, `annotation`} — 655 entries (149 source, 124 sink, 382 sanitizer — re-derive via `CATALOG.length` / a per-`kind` count rather than trusting a hardcoded number here again; this has already drifted once) spanning Express/Flask/FastAPI/Django/Rails/PHP/Go-net-http/Gin/Echo.
10
+ - **`match.type: 'call'`/`'member'`/`'global'`** are matched against an *expression* the taint-walker encounters while visiting a CFG node (`matchSource` / `matchSinkOrSanitizer`) — `call` against a call expression's callee, `member` against a property-access chain (`match.object` + `match.prop`), `global` against a bare identifier read.
11
+ - **`match.type: 'annotation'`** (R14(a): Spring `@RequestParam`/`@PathVariable`/`@RequestBody`/`@RequestHeader`, ASP.NET Core `[FromQuery]`/`[FromBody]`/`[FromForm]`/`[FromRoute]`/`[FromHeader]`, NestJS `@Query()`/`@Body()`/`@Param()`/`@Headers()`) works differently from the other three, and this is the one fact worth internalizing before touching it: **it is not consulted during the CFG walk.** There is no CFG node for "this function's own declared parameter list." Instead `matchAnnotationParams` (`catalog.js`) is called once per function — by `engine.js`'s `_unionAnnotationTaint`, before `analyzeFunction` runs — against the IR's `fn.paramAnnotations` side-channel (`{index, name, decorator}[]`, populated by `parser-cs.js`/`parser-js.js`/`parser-java.js`; see `../ir/CLAUDE.md`), and the resulting tainted param names are unioned into the entry taint-state for that function/call-site. A reader who assumes `annotation` entries get matched against expressions mid-walk, the way `call`/`member`/`global` do, will be wrong.
10
12
  - **Sanitizer entries are RECORDED, never trusted to kill taint.** `_sanitizersForExpr()` collects the sanitizer callees applied to the value reaching each sink argument — inline in the argument, or inherited from the variable it reads (`_sanitizersByVar`, updated on every assignment and cleared when the RHS is clean). They are stamped on the finding as `_sanitizersOnPath` and consumed by `sanitizer-gate.js`, which sets `sanitized` + `sanitizerProof` **only when the sanitizer's `appliesTo` family covers the finding's threat class** (`*` covers everything). The proof gate then demotes. Taint itself still dies only on clean re-assignment (`removePathAndDescendants`).
11
13
  - **Why the sanitizer does not kill the taint.** A blanket "any sanitizer clears the flow" rule scores well on benchmarks and silently drops a real SQL injection whenever the code applied an HTML escaper — `htmlspecialchars` neutralises XSS and does nothing for SQLi. Labelling demotes confidence; killing taint would delete the finding. Recall-preserving, same precedent as `falsification.js` / `proof-gate.js`: never removed, never severity-touched.
12
14
  - **The sanitization signal is may-, not must-.** `_sanitizersByVar` is keyed per variable within a function and is not branch-sensitive, so a value sanitized on only one path still carries the label. That is deliberate and safe *because* the label only demotes — the same imprecision under a taint-killing design would be a false negative.
@@ -117,6 +117,7 @@ const JS_XML_JSON = [
117
117
  ];
118
118
 
119
119
  const JS_VALIDATORS = [
120
+ san('js', 'validator', 'validator.escape', ['xss']),
120
121
  san('js', 'validator', 'validator.isEmail', ['xss', 'sql']),
121
122
  san('js', 'validator', 'validator.isURL', ['url']),
122
123
  san('js', 'validator', 'validator.isUUID', ['*']),
@@ -63,10 +63,10 @@ export const CATALOG = [
63
63
 
64
64
  // ─── SINKS (JS/TS) ─────────────────────────────────────────────────────────
65
65
  // SQL.
66
- { kind: 'sink', id: 'js-sql-query', language: 'js', framework: 'sql', match: { type: 'call', callee: 'query' }, argIndex: 0,
66
+ { kind: 'sink', id: 'js-sql-query', language: 'js', framework: 'sql', match: { type: 'call', callee: 'query', receiverTypeIn: ['db|pool|conn(?:ection)?|client|sql|database|pg|mysql|sequelize|knex|prisma'] }, argIndex: 0,
67
67
  vuln: { name: 'SQL Injection (db.query)', severity: 'critical', cwe: 'CWE-89',
68
68
  remediation: 'Use parameterized queries: db.query("SELECT * FROM t WHERE id = ?", [id]). Never interpolate untrusted strings into SQL.' } },
69
- { kind: 'sink', id: 'js-sql-execute', language: 'js', framework: 'sql', match: { type: 'call', callee: 'execute' }, argIndex: 0,
69
+ { kind: 'sink', id: 'js-sql-execute', language: 'js', framework: 'sql', match: { type: 'call', callee: 'execute', receiverTypeIn: ['db|pool|conn(?:ection)?|client|sql|database|pg|mysql|sequelize|knex|prisma'] }, argIndex: 0,
70
70
  vuln: { name: 'SQL Injection (db.execute)', severity: 'critical', cwe: 'CWE-89',
71
71
  remediation: 'Use parameterized queries: db.execute("SELECT * FROM t WHERE id = ?", [id]).' } },
72
72
  // OS command.
@@ -173,8 +173,25 @@ export const CATALOG = [
173
173
  { kind: 'source', id: 'java-request-getReader', language: 'java', framework: 'servlet', match: { type: 'call', callee: 'getReader' }, label: 'request.getReader' },
174
174
  { kind: 'source', id: 'java-system-getenv', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'getenv' }, label: 'System.getenv' },
175
175
  { kind: 'source', id: 'java-system-getProperty', language: 'java', framework: 'stdlib', match: { type: 'call', callee: 'getProperty' }, label: 'System.getProperty' },
176
- // Spring annotation-style sources are detected per-rule, not as catalog
177
- // members (they're parameter decorators rather than callable shapes).
176
+
177
+ // ─── SOURCES (Annotation/Decorator-shaped) ────────────────────────────────
178
+ // R14(a): annotation/decorator-shaped framework sources (Spring @RequestParam,
179
+ // ASP.NET Core [FromQuery], NestJS @Query()). These are indexed and matched via
180
+ // matchAnnotationParams, which is called once per function against the IR's
181
+ // paramAnnotations side-channel.
182
+ { kind: 'source', id: 'java-spring-requestparam', language: 'java', framework: 'spring', match: { type: 'annotation', name: 'RequestParam' }, label: '@RequestParam (Spring)' },
183
+ { kind: 'source', id: 'java-spring-pathvariable', language: 'java', framework: 'spring', match: { type: 'annotation', name: 'PathVariable' }, label: '@PathVariable (Spring)' },
184
+ { kind: 'source', id: 'java-spring-requestbody', language: 'java', framework: 'spring', match: { type: 'annotation', name: 'RequestBody' }, label: '@RequestBody (Spring)' },
185
+ { kind: 'source', id: 'java-spring-requestheader', language: 'java', framework: 'spring', match: { type: 'annotation', name: 'RequestHeader' }, label: '@RequestHeader (Spring)' },
186
+ { kind: 'source', id: 'cs-aspnet-fromquery', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromQuery' }, label: '[FromQuery] (ASP.NET Core)' },
187
+ { kind: 'source', id: 'cs-aspnet-frombody', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromBody' }, label: '[FromBody] (ASP.NET Core)' },
188
+ { kind: 'source', id: 'cs-aspnet-fromform', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromForm' }, label: '[FromForm] (ASP.NET Core)' },
189
+ { kind: 'source', id: 'cs-aspnet-fromroute', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromRoute' }, label: '[FromRoute] (ASP.NET Core)' },
190
+ { kind: 'source', id: 'cs-aspnet-fromheader', language: 'cs', framework: 'aspnet', match: { type: 'annotation', name: 'FromHeader' }, label: '[FromHeader] (ASP.NET Core)' },
191
+ { kind: 'source', id: 'js-nestjs-query', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Query' }, label: '@Query() (NestJS)', provenance: 'url-param' },
192
+ { kind: 'source', id: 'js-nestjs-body', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Body' }, label: '@Body() (NestJS)', provenance: 'http-body' },
193
+ { kind: 'source', id: 'js-nestjs-param', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Param' }, label: '@Param() (NestJS)', provenance: 'path-param' },
194
+ { kind: 'source', id: 'js-nestjs-headers', language: 'js', framework: 'nestjs', match: { type: 'annotation', name: 'Headers' }, label: '@Headers() (NestJS)', provenance: 'header' },
178
195
 
179
196
  // ─── SOURCES (Go) ─────────────────────────────────────────────────────────
180
197
  { kind: 'source', id: 'go-r-form', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'Form' }, label: 'r.Form' },
@@ -360,7 +377,7 @@ export const CATALOG = [
360
377
  remediation: 'Sanitize the __html field via DOMPurify before passing it to dangerouslySetInnerHTML — better, render text via children.' } },
361
378
 
362
379
  // ─── SINKS (HTTP outbound / SSRF) ─────────────────────────────────────────
363
- { kind: 'sink', id: 'py-requests-get', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get' }, argIndex: 0,
380
+ { kind: 'sink', id: 'py-requests-get', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get', receiverTypeIn: ['requests|session|client|http'] }, argIndex: 0,
364
381
  vuln: { name: 'SSRF (requests.get)', severity: 'high', cwe: 'CWE-918',
365
382
  remediation: 'Resolve the URL host and reject RFC1918 + metadata endpoints before fetching. Use an allow-list.' } },
366
383
  { kind: 'sink', id: 'py-requests-post', language: 'py', framework: 'requests', match: { type: 'call', callee: 'post' }, argIndex: 0,
@@ -414,7 +431,7 @@ export const CATALOG = [
414
431
  { kind: 'sink', id: 'py-jinja-from-string', language: 'py', framework: 'jinja2', match: { type: 'call', callee: 'from_string' }, argIndex: 0,
415
432
  vuln: { name: 'SSTI (Jinja2.from_string)', severity: 'critical', cwe: 'CWE-94',
416
433
  remediation: 'Never feed a user-supplied string into a template engine. Use pre-registered templates and pass values as variables.' } },
417
- { kind: 'sink', id: 'rb-erb-new', language: 'rb', framework: 'erb', match: { type: 'call', callee: 'new' }, argIndex: 0,
434
+ { kind: 'sink', id: 'rb-erb-new', language: 'rb', framework: 'erb', match: { type: 'call', callee: 'new', receiverTypeIn: ['^ERB$'] }, argIndex: 0,
418
435
  vuln: { name: 'SSTI (ERB.new)', severity: 'critical', cwe: 'CWE-94',
419
436
  remediation: 'Use pre-existing templates with binding/locals — never construct a template from user input.' } },
420
437
  { kind: 'sink', id: 'js-handlebars-compile',language: 'js', framework: 'handlebars', match: { type: 'call', callee: 'compile' }, argIndex: 0,
@@ -605,7 +622,7 @@ export const CATALOG = [
605
622
  vuln: { name: 'Unsafe Deserialization (yaml.load)', severity: 'high', cwe: 'CWE-502',
606
623
  remediation: 'Use yaml.safe_load instead of yaml.load on untrusted YAML.' } },
607
624
  // SSRF / HTTP-out.
608
- { kind: 'sink', id: 'py-requests-get-v2', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get' }, argIndex: 0,
625
+ { kind: 'sink', id: 'py-requests-get-v2', language: 'py', framework: 'requests', match: { type: 'call', callee: 'get', receiverTypeIn: ['requests|session|client|http'] }, argIndex: 0,
609
626
  vuln: { name: 'SSRF (requests.get)', severity: 'high', cwe: 'CWE-918',
610
627
  remediation: 'Resolve the host first, reject 169.254.169.254 / RFC1918 / localhost; or proxy through a server-side allow-list.' } },
611
628
  { kind: 'sink', id: 'py-requests-post-v2', language: 'py', framework: 'requests', match: { type: 'call', callee: 'post' }, argIndex: 0,
@@ -903,6 +920,46 @@ function _receiverAllowed(entry, calleeExpr) {
903
920
  if (basePat && !segs.some((s) => new RegExp(basePat).test(String(s)))) return false;
904
921
  return true;
905
922
  }
923
+
924
+ // PRD R6 (docs/DETECTION_GAP_REMEDIATION_PRD.md): a SECOND, independent
925
+ // receiver constraint — this one checked against the CHA-INFERRED TYPE of
926
+ // the receiver (computed by the caller, engine.js's `_receiverTypeFor`), not
927
+ // the textual receiver-chain segments `_receiverAllowed` above checks. The
928
+ // two are complementary: `_receiverAllowed`'s `match.receiver` regex can only
929
+ // ever see the SOURCE TEXT of the call site (`db.query` vs `cache.query` —
930
+ // both pass any receiver regex that doesn't special-case exact names); this
931
+ // gate can additionally use a resolved class/variable-type hint, so a bare
932
+ // `.query()` on something confidently NOT database-shaped can be excluded
933
+ // without hand-listing every possible non-DB variable name as a `receiver`
934
+ // exclusion (which `_receiverAllowed`'s regex form cannot express at all —
935
+ // it only expresses required patterns, never forbidden ones).
936
+ //
937
+ // Unknown != clean: a null/undefined receiverType (CHA could not resolve
938
+ // anything for this call site) NEVER suppresses a match — only a receiver
939
+ // type that was confidently resolved and does not appear in the entry's
940
+ // `receiverTypeIn` allow-list does. An entry with no `receiverTypeIn` is
941
+ // completely unaffected by this gate (returns true unconditionally), exactly
942
+ // like `_receiverAllowed` when neither `receiver` nor `receiverBase` is set.
943
+ //
944
+ // The vocabulary patterns are deliberately UNANCHORED (substring, case-
945
+ // insensitive). They were originally written `^(?:db|pool|conn…)$` back when
946
+ // the value reaching this gate could be a bare variable/field NAME. It no
947
+ // longer can — after the whole-branch-review fix, `_receiverTypeFor` only
948
+ // ever yields a real `classOfVar`-resolved CLASS name — and real class names
949
+ // are compound: `DatabaseConnection`, `PrismaClient`, `MySQLConnection` all
950
+ // failed the exact-anchored form and were silently suppressed, while only a
951
+ // class literally named `Db` survived. Substring matching is the honest
952
+ // shape for a vocabulary check over compound identifiers. `rb-erb-new`
953
+ // stays anchored (`^ERB$`) because it names ONE exact class, not a
954
+ // vocabulary. Over-matching here is the safe direction: it only ever
955
+ // *permits* a match the pattern layer already made.
956
+ function _receiverTypeAllowed(entry, receiverType) {
957
+ const pats = entry.match && entry.match.receiverTypeIn;
958
+ if (!pats || !pats.length) return true;
959
+ if (!receiverType) return true;
960
+ return pats.some((p) => new RegExp(p, 'i').test(String(receiverType)));
961
+ }
962
+
906
963
  // Merge the expanded sanitizer catalog. We dedupe on `id` (case-insensitive)
907
964
  // so a base-catalog entry always wins over a same-id expanded one — the base
908
965
  // catalog is the curated/blessed surface; the expansion is additive coverage.
@@ -940,6 +997,7 @@ for (const e of CATALOG) {
940
997
  const CALLEE_INDEX = new Map();
941
998
  const MEMBER_INDEX = new Map();
942
999
  const GLOBAL_INDEX = new Map();
1000
+ const ANNOTATION_INDEX = new Map();
943
1001
  for (const e of CATALOG) {
944
1002
  if (!e.match) continue;
945
1003
  if (e.match.type === 'call' && e.match.callee && e.match.callee !== '*') {
@@ -958,6 +1016,10 @@ for (const e of CATALOG) {
958
1016
  const k = e.match.name;
959
1017
  if (!GLOBAL_INDEX.has(k)) GLOBAL_INDEX.set(k, []);
960
1018
  GLOBAL_INDEX.get(k).push(e);
1019
+ } else if (e.match.type === 'annotation' && e.match.name) {
1020
+ const k = e.match.name;
1021
+ if (!ANNOTATION_INDEX.has(k)) ANNOTATION_INDEX.set(k, []);
1022
+ ANNOTATION_INDEX.get(k).push(e);
961
1023
  }
962
1024
  }
963
1025
 
@@ -1007,6 +1069,35 @@ function _globalKey(name) {
1007
1069
  return typeof name === 'string' && name.charCodeAt(0) === 36 /* '$' */ ? name.slice(1) : name;
1008
1070
  }
1009
1071
 
1072
+ // PRD R4a (docs/DETECTION_GAP_REMEDIATION_PRD.md): this module's own shape
1073
+ // contract above documents two callee forms — 'name' (bare, matched by last
1074
+ // segment) and 'name.foo' (matched by FULL PATH) — but both lookup sites
1075
+ // used to reduce every callee to its last segment unconditionally, so an
1076
+ // entry indexed under a full dotted key (catalog-expanded.js's `san()`
1077
+ // helper builds many: 'Encode.forHtml', 'pg.escapeLiteral',
1078
+ // 'filepath.Clean', ...) could never be retrieved. An entry lives under
1079
+ // exactly one CALLEE_INDEX key (its own `match.callee`, dotted or bare), so
1080
+ // looking up both the full path and the last segment and concatenating
1081
+ // hits never double-counts — it just also finds entries the last-segment
1082
+ // key alone was missing.
1083
+ function _calleeIndexHits(calleeExpr) {
1084
+ let last = null;
1085
+ let full = null;
1086
+ if (typeof calleeExpr === 'string') {
1087
+ full = calleeExpr;
1088
+ last = calleeExpr.includes('.') ? calleeExpr.slice(calleeExpr.lastIndexOf('.') + 1) : calleeExpr;
1089
+ } else if (calleeExpr && calleeExpr.kind === 'member' && calleeExpr.prop) {
1090
+ last = calleeExpr.prop;
1091
+ if (calleeExpr.object && calleeExpr.object.kind === 'ident') full = `${calleeExpr.object.name}.${calleeExpr.prop}`;
1092
+ } else if (calleeExpr && calleeExpr.kind === 'ident') {
1093
+ last = calleeExpr.name || null;
1094
+ }
1095
+ const raw = [];
1096
+ if (full && full !== last) { const h = CALLEE_INDEX.get(full); if (h) raw.push(...h); }
1097
+ if (last) { const h = CALLEE_INDEX.get(last); if (h) raw.push(...h); }
1098
+ return raw;
1099
+ }
1100
+
1010
1101
  export function matchSource(expr, file) {
1011
1102
  if (!expr) return null;
1012
1103
  // Member sources (req.query): the original path — unchanged.
@@ -1041,41 +1132,76 @@ export function matchSource(expr, file) {
1041
1132
  // sources were never recognized at the assignment RHS. Match by callee last
1042
1133
  // segment (Go gives a dotted string; JS/Py a member/ident expr).
1043
1134
  if (expr.kind === 'call') {
1044
- let cn = null;
1045
- if (typeof expr.callee === 'string') cn = expr.callee.includes('.') ? expr.callee.slice(expr.callee.lastIndexOf('.') + 1) : expr.callee;
1046
- else if (expr.callee && expr.callee.kind === 'member') cn = expr.callee.prop;
1047
- else if (expr.callee && expr.callee.kind === 'ident') cn = expr.callee.name;
1048
- if (cn) {
1049
- const raw = CALLEE_INDEX.get(cn);
1050
- if (raw) {
1051
- const hits = filterByProvenance(raw)
1052
- .filter(h => _languageAllowed(h, file))
1053
- .filter(h => _receiverAllowed(h, expr.callee));
1054
- const s = hits.find(h => h.kind === 'source');
1055
- if (s) return s;
1056
- }
1135
+ const raw = _calleeIndexHits(expr.callee);
1136
+ if (raw.length) {
1137
+ const hits = filterByProvenance(raw)
1138
+ .filter(h => _languageAllowed(h, file))
1139
+ .filter(h => _receiverAllowed(h, expr.callee));
1140
+ const s = hits.find(h => h.kind === 'source');
1141
+ if (s) return s;
1057
1142
  }
1058
1143
  }
1059
1144
  return null;
1060
1145
  }
1061
1146
 
1062
- export function matchSinkOrSanitizer(calleeExpr, file) {
1147
+ export function matchSinkOrSanitizer(calleeExpr, file, receiverType) {
1063
1148
  if (!calleeExpr) return null;
1064
- let calleeName = null;
1065
- // R3 (PRD §5): the Go IR (and other string-callee parsers) represent a call
1066
- // target as a dotted STRING ("db.Query") rather than a member expr. Match on
1067
- // the last segment so those languages' sinks/sanitizers are recognized too.
1068
- if (typeof calleeExpr === 'string') calleeName = calleeExpr.includes('.') ? calleeExpr.slice(calleeExpr.lastIndexOf('.') + 1) : calleeExpr;
1069
- else if (calleeExpr.kind === 'ident') calleeName = calleeExpr.name;
1070
- else if (calleeExpr.kind === 'member') calleeName = calleeExpr.prop;
1071
- if (!calleeName) return null;
1072
- const raw = CALLEE_INDEX.get(calleeName);
1149
+ const raw = _calleeIndexHits(calleeExpr);
1150
+ if (!raw.length) return null;
1151
+ const hits = filterByProvenance(raw)
1152
+ .filter(h => _languageAllowed(h, file))
1153
+ .filter(h => _receiverAllowed(h, calleeExpr))
1154
+ .filter(h => _receiverTypeAllowed(h, receiverType));
1155
+ return hits.length ? hits : null;
1156
+ }
1157
+
1158
+ // PRD R13(a) (docs/DETECTION_GAP_REMEDIATION_PRD.md): three sink entries
1159
+ // (js-innerHTML-assign, js-outerHTML-assign, react-dangerouslySetInnerHTML)
1160
+ // have sat in MEMBER_INDEX under the object:'_any_' wildcard since they were
1161
+ // added, but nothing ever queried MEMBER_INDEX for an ASSIGNMENT TARGET —
1162
+ // matchSinkOrSanitizer only reads CALLEE_INDEX, and matchSource's MEMBER_INDEX
1163
+ // lookups are for READS keyed by a SPECIFIC object name (e.g. "document.cookie"),
1164
+ // not the "any receiver" wildcard a DOM sink needs. `targetPath` is the
1165
+ // flattened LHS access-path string parser-js.js's lhsPath already produces
1166
+ // for a member-expression assignment target (e.g. "el.innerHTML" for
1167
+ // `el.innerHTML = x`) — this function extracts the property name and looks
1168
+ // it up under the wildcard key the same way `object:'_any_'` entries are
1169
+ // indexed (catalog.js's own indexing loop keys every entry by
1170
+ // `${match.object}.${match.prop}` regardless of what object names are, so a
1171
+ // wildcard entry lives under the literal key "_any_.<prop>").
1172
+ export function matchMemberWriteSink(targetPath, file) {
1173
+ if (typeof targetPath !== 'string' || !targetPath.includes('.')) return null;
1174
+ const prop = targetPath.slice(targetPath.lastIndexOf('.') + 1);
1175
+ if (!prop) return null;
1176
+ const raw = MEMBER_INDEX.get(`_any_.${prop}`);
1073
1177
  if (!raw) return null;
1074
1178
  const hits = filterByProvenance(raw)
1075
1179
  .filter(h => _languageAllowed(h, file))
1076
- .filter(h => _receiverAllowed(h, calleeExpr));
1180
+ .filter(h => h.kind === 'sink');
1077
1181
  return hits.length ? hits : null;
1078
1182
  }
1079
1183
 
1184
+ // R14(a): annotation/decorator-shaped framework sources (Spring @RequestParam,
1185
+ // ASP.NET Core [FromQuery], NestJS @Query()). Unlike matchSource/matchSinkOrSanitizer,
1186
+ // this does not consult an expression encountered while walking a CFG node —
1187
+ // there is no CFG node for "this function's own declared parameter list." It is
1188
+ // consulted once per function, against the IR's paramAnnotations side-channel,
1189
+ // by engine.js's _unionAnnotationTaint before each analyzeFunction call.
1190
+ export function matchAnnotationParams(paramAnnotations, file) {
1191
+ const tainted = new Set();
1192
+ if (!paramAnnotations || !paramAnnotations.length) return tainted;
1193
+ for (const pa of paramAnnotations) {
1194
+ const raw = ANNOTATION_INDEX.get(pa.decorator);
1195
+ if (!raw) continue;
1196
+ const hits = filterByProvenance(raw)
1197
+ .filter(h => _languageAllowed(h, file))
1198
+ .filter(h => h.kind === 'source');
1199
+ if (hits.length) {
1200
+ tainted.add(pa.name);
1201
+ }
1202
+ }
1203
+ return tainted;
1204
+ }
1205
+
1080
1206
  // For tests and reflection.
1081
1207
  export function _catalogSize() { return CATALOG.length; }