@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1

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 (128) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/bin/agentic-security.js +78 -1
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/415.index.js +90 -0
  6. package/dist/444.index.js +89 -0
  7. package/dist/503.index.js +288 -0
  8. package/dist/637.index.js +15 -1
  9. package/dist/838.index.js +1 -1
  10. package/dist/985.index.js +5 -1
  11. package/dist/agentic-security.mjs +84 -84
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/package.json +21 -10
  14. package/src/dataflow/CLAUDE.md +18 -6
  15. package/src/dataflow/catalog.js +59 -11
  16. package/src/dataflow/engine.js +106 -5
  17. package/src/dataflow/implicit-flow.js +4 -2
  18. package/src/dataflow/index.js +17 -4
  19. package/src/dataflow/proof-gate.js +100 -0
  20. package/src/dataflow/summaries.js +63 -6
  21. package/src/engine.js +292 -10
  22. package/src/ir/CLAUDE.md +1 -0
  23. package/src/ir/callgraph.js +12 -1
  24. package/src/ir/parser-go.js +8 -0
  25. package/src/ir/tree-sitter-loader.js +108 -0
  26. package/src/mcp/tools.js +6 -2
  27. package/src/posture/corpus-status.js +74 -0
  28. package/src/posture/coverage-report.js +113 -0
  29. package/src/posture/fix-history.js +24 -0
  30. package/src/posture/holdout-eval.js +57 -0
  31. package/src/posture/provenance.js +39 -0
  32. package/src/posture/router.js +33 -3
  33. package/src/posture/sca-verdict.js +130 -0
  34. package/src/posture/secret-history.js +72 -0
  35. package/src/pr-delta.js +13 -0
  36. package/src/report/index.js +67 -0
  37. package/src/sast/CLAUDE.md +9 -3
  38. package/src/sast/agent-untrusted-flow.js +107 -0
  39. package/src/sast/api-authz.js +71 -0
  40. package/src/sast/code-injection-multilang.js +117 -0
  41. package/src/sast/cross-service.js +98 -0
  42. package/src/sast/crypto-protocol.js +43 -1
  43. package/src/sast/csharp-structural.js +65 -0
  44. package/src/sast/csharp.js +3 -0
  45. package/src/sast/csrf.js +24 -3
  46. package/src/sast/csv-injection.js +84 -0
  47. package/src/sast/event-entrypoint.js +90 -0
  48. package/src/sast/frontend-hygiene.js +106 -0
  49. package/src/sast/go-structural.js +60 -0
  50. package/src/sast/iac-terraform.js +107 -0
  51. package/src/sast/java-structural.js +81 -0
  52. package/src/sast/js-framework-structural.js +115 -0
  53. package/src/sast/kotlin.js +72 -0
  54. package/src/sast/ldap-injection.js +73 -17
  55. package/src/sast/open-redirect.js +47 -5
  56. package/src/sast/php.js +35 -0
  57. package/src/sast/python-structural.js +83 -0
  58. package/src/sast/rbac-consistency.js +113 -0
  59. package/src/sast/redos-nfa.js +75 -0
  60. package/src/sast/response-splitting.js +48 -19
  61. package/src/sast/ruby.js +38 -0
  62. package/src/sast/secret-concat.js +60 -0
  63. package/src/sast/ssrf-cloud-metadata.js +13 -0
  64. package/src/sast/stored-taint.js +88 -0
  65. package/src/sast/tree-sitter-sinks.js +62 -0
  66. package/src/sast/weak-password-hash.js +28 -0
  67. package/src/sast/weak-randomness.js +46 -5
  68. package/src/sast/wrong-context-sanitizer.js +149 -0
  69. package/src/sast/xpath-injection.js +30 -6
  70. package/src/sast/xss-reflected-multilang.js +116 -0
  71. package/src/sast/xxe.js +67 -0
  72. package/src/sca/image-packages.js +74 -0
  73. package/src/sca/import-reachability.js +271 -0
  74. package/src/sca/install-script-analysis.js +82 -0
  75. package/bin/.agentic-security/findings.json +0 -1907
  76. package/bin/.agentic-security/last-scan.json +0 -1907
  77. package/bin/.agentic-security/last-scan.json.sig +0 -1
  78. package/bin/.agentic-security/scan-history.json +0 -166
  79. package/bin/.agentic-security/streak.json +0 -20
  80. package/src/.agentic-security/findings.json +0 -117732
  81. package/src/.agentic-security/last-scan.json +0 -117732
  82. package/src/.agentic-security/last-scan.json.sig +0 -1
  83. package/src/.agentic-security/scan-history.json +0 -12946
  84. package/src/.agentic-security/streak.json +0 -21
  85. package/src/dataflow/.agentic-security/findings.json +0 -6086
  86. package/src/dataflow/.agentic-security/last-scan.json +0 -6086
  87. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  88. package/src/dataflow/.agentic-security/scan-history.json +0 -250
  89. package/src/dataflow/.agentic-security/streak.json +0 -21
  90. package/src/ir/.agentic-security/findings.json +0 -4011
  91. package/src/ir/.agentic-security/last-scan.json +0 -4011
  92. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  93. package/src/ir/.agentic-security/scan-history.json +0 -193
  94. package/src/ir/.agentic-security/streak.json +0 -20
  95. package/src/mcp/.agentic-security/findings.json +0 -8632
  96. package/src/mcp/.agentic-security/last-scan.json +0 -8632
  97. package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
  98. package/src/mcp/.agentic-security/scan-history.json +0 -331
  99. package/src/mcp/.agentic-security/streak.json +0 -20
  100. package/src/posture/.agentic-security/dpia.md +0 -26
  101. package/src/posture/.agentic-security/findings.json +0 -77181
  102. package/src/posture/.agentic-security/last-scan.json +0 -77181
  103. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  104. package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
  105. package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
  106. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  107. package/src/posture/.agentic-security/scan-history.json +0 -8904
  108. package/src/posture/.agentic-security/streak.json +0 -21
  109. package/src/posture/.agentic-security/threat-model.json +0 -2038
  110. package/src/posture/.agentic-security/threat-model.md +0 -73
  111. package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
  112. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
  113. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
  114. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
  115. package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
  116. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  117. package/src/report/.agentic-security/threat-model.json +0 -7
  118. package/src/report/.agentic-security/threat-model.md +0 -22
  119. package/src/sast/.agentic-security/findings.json +0 -6154
  120. package/src/sast/.agentic-security/last-scan.json +0 -6154
  121. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  122. package/src/sast/.agentic-security/scan-history.json +0 -941
  123. package/src/sast/.agentic-security/streak.json +0 -22
  124. package/src/sca/.agentic-security/findings.json +0 -7460
  125. package/src/sca/.agentic-security/last-scan.json +0 -7460
  126. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  127. package/src/sca/.agentic-security/scan-history.json +0 -113
  128. package/src/sca/.agentic-security/streak.json +0 -21
@@ -1 +1 @@
1
- 3bcd69ed73117d5a5954acfd240bd0f870ade6ca6fab6ce6ff255fbc3ce340aa agentic-security.mjs
1
+ 40d6882dc7b5dbd62ac41f10cb01ec4e43fa42081dbcc0427553e0282748fedb agentic-security.mjs
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.86.0",
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, SARIF ingest, pipeline integrity, compliance attestation, and more.",
3
+ "version": "0.119.1",
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",
7
7
  "bin": {
@@ -27,7 +27,9 @@
27
27
  "src/",
28
28
  "bin/",
29
29
  "dist/",
30
- "CHANGELOG.md"
30
+ "CHANGELOG.md",
31
+ "!**/.agentic-security",
32
+ "!**/.agentic-security/**"
31
33
  ],
32
34
  "engines": {
33
35
  "node": ">=24.0.0"
@@ -41,6 +43,10 @@
41
43
  "js-yaml": "^4.1.1",
42
44
  "safe-regex": "^2.1.1"
43
45
  },
46
+ "optionalDependencies": {
47
+ "web-tree-sitter": "0.20.8",
48
+ "tree-sitter-wasms": "0.1.13"
49
+ },
44
50
  "devDependencies": {
45
51
  "@types/node": "^24.0.0",
46
52
  "@vercel/ncc": "^0.38.3"
@@ -50,17 +56,20 @@
50
56
  "lodash-es": "^4.18.1"
51
57
  },
52
58
  "scripts": {
53
- "build": "ncc build bin/agentic-security.js -o dist --minify && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
54
- "prepublishOnly": "npm run build && node -e \"const fs=require('fs');const cur=fs.existsSync('CHANGELOG.md')?fs.readFileSync('CHANGELOG.md','utf8'):'';const src=fs.readFileSync('../CHANGELOG.md','utf8');if(cur && cur!==src){console.error('scanner/CHANGELOG.md has local edits that differ from ../CHANGELOG.md.');console.error('Refusing to overwrite. Either commit the edit upstream first or rm scanner/CHANGELOG.md to accept the upstream copy.');process.exit(1);}fs.writeFileSync('CHANGELOG.md',src);\"",
55
- "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 && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
59
+ "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
60
+ "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs",
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",
56
62
  "test:smoke": "node --test test/smoke.test.js",
57
- "test:sast": "node --test test/llm.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/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",
58
- "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/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/sca-route-reachable.test.js test/sca-policy.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.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/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/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",
59
- "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/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",
63
+ "test:sast": "node --test test/llm.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/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/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/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/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/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",
60
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",
61
- "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",
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",
62
68
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js",
63
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 && node ../scripts/lint-command-descriptions.mjs",
70
+ "test:eval": "node --test test/independent-eval.test.js",
71
+ "eval:independent": "node ../bench/independent-eval/runner.mjs",
72
+ "eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
64
73
  "smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
65
74
  "prebench": "npm run build",
66
75
  "bench": "node test/benchmark/bench.js",
@@ -70,6 +79,8 @@
70
79
  "bench:llm-goats": "node test/benchmark/llm-goats/bench-llm-goats.js",
71
80
  "bench:cve-replay": "node ../bench/cve-replay/runner.mjs",
72
81
  "bench:cve-replay:ci": "node ../bench/cve-replay/runner.mjs --ci",
82
+ "bench:cve-replay:check": "node ../bench/cve-replay/runner.mjs --check-baseline",
83
+ "bench:cve-replay:update-baseline": "node ../bench/cve-replay/runner.mjs --update-baseline",
73
84
  "bench:bigquery:smoke": "node ../bench/bigquery-github/bin/run-cycle.mjs --smoke",
74
85
  "bench:bigquery": "node ../bench/bigquery-github/bin/run-cycle.mjs",
75
86
  "bench:agent-tasks": "node ../bench/agent-tasks/security-fixer/runner.mjs",
@@ -5,23 +5,34 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
5
5
  ## Scope — what we actually model
6
6
 
7
7
  - **Intra-procedural field-sensitive taint** with access-path lattice (`access-paths.js`). `user.email` is distinguishable from `user.password`.
8
- - **k=1 monovariant interprocedural return-taint.** `SummaryCache` (`summaries.js`) holds one summary per function under empty entry state. At an assign-from-call site, if the resolved callee's summary says `returnTainted`, the LHS becomes tainted. Premortem-derived; was previously dead code.
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
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.
10
10
  - **Path feasibility.** Constant-folds `if` conditions to prune unreachable branches.
11
11
  - **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
12
 
13
- ## Scope — what we do NOT model (today)
13
+ ## Scope — now modelled (was previously listed as gaps; closed in v0.66)
14
14
 
15
- - **Arbitrary entry-taint-state context-sensitivity.** Each function gets ONE summary, computed under empty entry. A function that's pure when called with clean args but vulnerable when called with tainted args is modelled as the empty-state result. Track FR-SEM-2 to lift this.
16
- - **Mutated-parameter taint at call sites.** The `SummaryCache.applyAtCallSite` helper exists for it; the engine doesn't consult it yet. If you want a helper that mutates its argument (`Object.assign(target, tainted)`) to taint the caller's `target`, this is the modelling gap.
17
- - **Higher-order taint flow** partial. `higher-order.js` propagates taint into `arr.map(fn)` / `promise.then(fn)` callbacks at the IR level, but the recorded `_higherOrderInvocations` aren't fed back into the worklist yet.
15
+ - **Mutated-parameter taint at call sites.** `engine.js` consults `SummaryCache.applyAtCallSite` at both assign-from-call and plain-call sites: a callee that mutates a param (e.g. `Object.assign(target, tainted)`, `_.merge`) taints the caller's argument variable. Covered by `test/interproc-k2.test.js`.
16
+ - **Higher-order taint flow.** `_higherOrderInvocations` recorded during `analyzeFunction` are consumed in `runTaintEngine`: the callback is resolved, analyzed with a tainted parameter, and its findings merged back into the caller (`_via: 'higher-order'`, capped at `HO_CAP`). Covered by `test/closure-capture.test.js` and `test/phase6-taint.test.js`.
17
+ - **Recursion via fixed point.** `runTaintEngine` runs a multi-pass fixed-point loop (`MAX_FP_ITERS=3`) until the summary cache stabilizes, so recursive cycles and call chains converge instead of under-approximating on a single pass.
18
+
19
+ ## Scope — what we still do NOT model (today)
20
+
21
+ - **Call-string (k>1) context-sensitivity.** Context is the *value* abstraction — which params are tainted at entry — not the call stack. Two call paths that reach a helper with the same tainted-arg shape share a summary. Entry-state granularity is also param-level, not arbitrary access paths (`f(obj)` with `obj.a` tainted ≡ `obj.b` tainted).
22
+ - **Contexts beyond the per-function cap.** Once a function has been computed under `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS` distinct tainted-arg shapes, further shapes fall back to the empty-entry summary (an under-approximation, bounded on purpose).
18
23
  - **Implicit flow.** `implicit-flow.js` exists for `if (tainted) { x = "yes" }` propagation but is conservative-by-default.
19
24
 
25
+ ## Precision: centralized SSRF/path guard recognition
26
+
27
+ `engine.js` `dropGuardedFindings(findings, fc)` runs after all detectors and drops a CWE-918 (SSRF) finding when the sink window has a host allow/deny check (deny/allow-list, `getHost`/`hostname` comparison, RFC1918/`169.254.169.254` prefix check, `ipaddress`/`getaddrinfo`/`ssrf-req-filter`), or a CWE-22 (path) finding when the window has a containment guard (`basename`/`GetFileName`/`secure_filename`/`send_from_directory`, or canonicalize+`startsWith`). It's the single source of truth so every emitter (regex, structural, per-language flow, PY-SAST, CSHARP, GO) is treated uniformly. The window is **comment-stripped** (a "no allow-list / 169.254…" vuln comment must not read as a guard). Opt out: `AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1`.
28
+
20
29
  ## Entry points
21
30
 
22
- - `runTaintEngine(perFileIR, callGraph, opts)` — the public entry. Called from `engine.js` when `AGENTIC_SECURITY_DEEP=1` (or auto-enabled outside CI).
31
+ - `runTaintEngine(perFileIR, callGraph, opts)` — the public entry. Runs from `engine.js` when `AGENTIC_SECURITY_DEEP=1`. **R1 (PRD §5):** the CLI entry (`bin/agentic-security.js#cmdScan`) sets that env var by default for local/interactive scans (not in CI, and not when `--no-deep`/`AGENTIC_SECURITY_DEEP=0`), so the default `/scan --all` runs deep. In-process callers (tests, the cve-replay corpus) invoke `runScan()` directly without the CLI default and therefore stay deep-off unless they set the env var themselves (e.g. `test/deep-taint.test.js`).
23
32
  - `applyPathFeasibility` — constant-fold pass that runs before the worklist.
24
33
  - `annotateBackwardSlices` — backward-slice annotation for already-emitted findings.
34
+ - `annotateProvenClean(findings, perFileIR)` (`proven-clean.js`) — proves a SQL sink is reached only through a parameterizer; sets `provenClean`. Wired by default in `runDeepAnalysis` (opt out: `AGENTIC_SECURITY_NO_PROOF_GATE=1`).
35
+ - `annotateProofGate(findings)` (`proof-gate.js`) — the precision gate. Consolidates `provenClean` + `_provenUnreachable` into one `finding.proof = { verdict, reasons }` and applies a **recall-preserving demotion** (lowers `confidence` + `confidenceTier` + `exploitabilityTier`, never `severity`). Runs in `engine.js` after confidence/exploitability, before mitigation/composite-risk. Default on.
25
36
 
26
37
  ## Configuration / opt-in
27
38
 
@@ -29,6 +40,7 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
29
40
  - `AGENTIC_SECURITY_DEEP_TIMEOUT_MS` — global walltime budget (default 300_000).
30
41
  - `AGENTIC_SECURITY_DEEP_FN_LIMIT` — function-count budget (default 5000).
31
42
  - `AGENTIC_SECURITY_DEEP_IN_CI=1` — also enable in CI (off by default; CI runs are time-bounded).
43
+ - `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS` — distinct non-empty entry contexts kept per function (default 16; 0 = monovariant).
32
44
 
33
45
  ## Gotchas
34
46
 
@@ -99,6 +99,15 @@ export const CATALOG = [
99
99
  { kind: 'sanitizer', id: 'js-String-coerce', language: 'js', match: { type: 'call', callee: 'String' }, effect: 'strip', appliesTo: ['mongo-operator'] },
100
100
  { kind: 'sanitizer', id: 'js-validator-escape', language: 'js', match: { type: 'call', callee: 'escape' }, effect: 'strip', appliesTo: ['xss'] },
101
101
  { kind: 'sanitizer', id: 'js-strip_tags', language: 'js', match: { type: 'call', callee: 'stripTags' }, effect: 'strip', appliesTo: ['xss'] },
102
+ // Schema-validation libraries (#7). Scoped to NoSQL/operator injection ONLY:
103
+ // validating that input matches a typed shape defeats operator injection
104
+ // (a `{$gt:''}` object can't satisfy `z.string()`). It does NOT sanitize the
105
+ // value for XSS/SQL/cmd — a validated string is still a payload — so these
106
+ // are deliberately NOT tagged for those families (doing so would cause false
107
+ // negatives). Only distinctive callees, to avoid colliding with JSON.parse.
108
+ { kind: 'sanitizer', id: 'js-zod-safeParse', language: 'js', match: { type: 'call', callee: 'safeParse' }, effect: 'strip', appliesTo: ['mongo-operator'] },
109
+ { kind: 'sanitizer', id: 'js-zod-parseAsync', language: 'js', match: { type: 'call', callee: 'parseAsync' }, effect: 'strip', appliesTo: ['mongo-operator'] },
110
+ { kind: 'sanitizer', id: 'js-class-validator', language: 'js', match: { type: 'call', callee: 'validateOrReject' }, effect: 'strip', appliesTo: ['mongo-operator'] },
102
111
 
103
112
  // ─── SOURCES (Python — Flask / FastAPI / Django) ──────────────────────────
104
113
  { kind: 'source', id: 'py-flask-request-args', language: 'py', framework: 'flask', match: { type: 'member', object: 'request', prop: 'args' }, label: 'request.args' },
@@ -157,6 +166,21 @@ export const CATALOG = [
157
166
  { kind: 'source', id: 'go-buffalo-request',language:'go', framework:'buffalo',match: { type: 'member', object: 'c', prop: 'Request' }, label: 'c.Request (buffalo)' },
158
167
  { kind: 'source', id: 'go-gorilla-vars', language: 'go', framework: 'gorilla',match: { type: 'call', callee: 'Vars' }, label: 'mux.Vars (gorilla)' },
159
168
 
169
+ // ─── SINKS (Go — database/sql) — R3 (PRD §5) ──────────────────────────────
170
+ // callee 'Query' is also a net/http SOURCE (r.URL.Query). The engine
171
+ // disambiguates by position — source at the assignment RHS, sink at a call
172
+ // with a tainted query argument — so coexistence is benign (a source call
173
+ // like r.URL.Query() has no tainted arg, so it never fires the sink).
174
+ { kind: 'sink', id: 'go-sql-query', language: 'go', framework: 'database/sql', match: { type: 'call', callee: 'Query' }, argIndex: 0,
175
+ vuln: { name: 'SQL Injection (db.Query — Go)', severity: 'critical', cwe: 'CWE-89',
176
+ remediation: 'Use parameterized queries: db.Query("SELECT … WHERE id = $1", id). Never concatenate untrusted input into the SQL string.' } },
177
+ { kind: 'sink', id: 'go-sql-queryrow', language: 'go', framework: 'database/sql', match: { type: 'call', callee: 'QueryRow' }, argIndex: 0,
178
+ vuln: { name: 'SQL Injection (db.QueryRow — Go)', severity: 'critical', cwe: 'CWE-89',
179
+ remediation: 'Use placeholders ($1 / ?) and pass args separately.' } },
180
+ { kind: 'sink', id: 'go-sql-exec', language: 'go', framework: 'database/sql', match: { type: 'call', callee: 'Exec' }, argIndex: 0,
181
+ vuln: { name: 'SQL Injection (db.Exec — Go)', severity: 'critical', cwe: 'CWE-89',
182
+ remediation: 'Use parameterized statements: db.Exec("UPDATE t SET x=$1 WHERE id=$2", x, id).' } },
183
+
160
184
  // ─── SOURCES (Ruby — Rails / Sinatra) ─────────────────────────────────────
161
185
  { kind: 'source', id: 'rb-rails-params', language: 'rb', framework: 'rails', match: { type: 'global', name: 'params' }, label: 'params (Rails)' },
162
186
  { kind: 'source', id: 'rb-rails-cookies', language: 'rb', framework: 'rails', match: { type: 'global', name: 'cookies' }, label: 'cookies (Rails)' },
@@ -738,22 +762,46 @@ function filterByProvenance(entries) {
738
762
  return list;
739
763
  }
740
764
 
741
- export function matchSource(memberExpr) {
742
- // memberExpr is exprDesc: { kind: 'member', object: {kind:'ident',name}, prop }
743
- if (!memberExpr || memberExpr.kind !== 'member') return null;
744
- if (memberExpr.object?.kind !== 'ident') return null;
745
- const k = `${memberExpr.object.name}.${memberExpr.prop}`;
746
- const raw = MEMBER_INDEX.get(k);
747
- if (!raw) return null;
748
- const hits = filterByProvenance(raw);
749
- if (!hits.length) return null;
750
- return hits.find(h => h.kind === 'source') || null;
765
+ export function matchSource(expr) {
766
+ if (!expr) return null;
767
+ // Member sources (req.query): the original path — unchanged.
768
+ if (expr.kind === 'member' && expr.object?.kind === 'ident') {
769
+ const raw = MEMBER_INDEX.get(`${expr.object.name}.${expr.prop}`);
770
+ if (raw) {
771
+ const hits = filterByProvenance(raw);
772
+ const s = hits.find(h => h.kind === 'source');
773
+ if (s) return s;
774
+ }
775
+ }
776
+ // R3 (PRD §5): CALL sources (r.FormValue(), r.URL.Query(), c.QueryParam()).
777
+ // matchSource previously handled only member reads, so Go's call-shaped
778
+ // sources were never recognized at the assignment RHS. Match by callee last
779
+ // segment (Go gives a dotted string; JS/Py a member/ident expr).
780
+ if (expr.kind === 'call') {
781
+ let cn = null;
782
+ if (typeof expr.callee === 'string') cn = expr.callee.includes('.') ? expr.callee.slice(expr.callee.lastIndexOf('.') + 1) : expr.callee;
783
+ else if (expr.callee && expr.callee.kind === 'member') cn = expr.callee.prop;
784
+ else if (expr.callee && expr.callee.kind === 'ident') cn = expr.callee.name;
785
+ if (cn) {
786
+ const raw = CALLEE_INDEX.get(cn);
787
+ if (raw) {
788
+ const hits = filterByProvenance(raw);
789
+ const s = hits.find(h => h.kind === 'source');
790
+ if (s) return s;
791
+ }
792
+ }
793
+ }
794
+ return null;
751
795
  }
752
796
 
753
797
  export function matchSinkOrSanitizer(calleeExpr) {
754
798
  if (!calleeExpr) return null;
755
799
  let calleeName = null;
756
- if (calleeExpr.kind === 'ident') calleeName = calleeExpr.name;
800
+ // R3 (PRD §5): the Go IR (and other string-callee parsers) represent a call
801
+ // target as a dotted STRING ("db.Query") rather than a member expr. Match on
802
+ // the last segment so those languages' sinks/sanitizers are recognized too.
803
+ if (typeof calleeExpr === 'string') calleeName = calleeExpr.includes('.') ? calleeExpr.slice(calleeExpr.lastIndexOf('.') + 1) : calleeExpr;
804
+ else if (calleeExpr.kind === 'ident') calleeName = calleeExpr.name;
757
805
  else if (calleeExpr.kind === 'member') calleeName = calleeExpr.prop;
758
806
  if (!calleeName) return null;
759
807
  const raw = CALLEE_INDEX.get(calleeName);
@@ -39,6 +39,7 @@ import { aliasesForVar } from './points-to.js';
39
39
  import { higherOrderTaintFlow } from './higher-order.js';
40
40
  import { SummaryCache, entryStateFromCall } from './summaries.js';
41
41
  import { lookupBuiltinSummary } from './builtin-summaries.js';
42
+ import { isImplicitFlowEnabled, buildImplicitContext, implicitAssignTarget, markImplicitTaint, createImplicitFinding } from './implicit-flow.js';
42
43
 
43
44
  // v0.70 #2 — addPath that also taints every alias of the variable.
44
45
  // When `target` is a dotted path like "a.x" and the root `a` has aliases
@@ -157,6 +158,13 @@ function exprIsSource(expr) {
157
158
  const hit = matchSource(expr);
158
159
  if (hit) return hit;
159
160
  }
161
+ // R3 (PRD §5): call-shaped sources (r.FormValue(), r.URL.Query(), c.Query()).
162
+ // Previously only member reads were recognized, so Go's call-style sources
163
+ // never tainted the assignment target. matchSource now resolves call sources.
164
+ if (expr.kind === 'call') {
165
+ const hit = matchSource(expr);
166
+ if (hit) return hit;
167
+ }
160
168
  if (expr.kind === 'member' && expr.object) {
161
169
  return exprIsSource(expr.object);
162
170
  }
@@ -215,7 +223,8 @@ function step(node, stateIn, callContext) {
215
223
  const calleeName = node.source && node.source.kind === 'call' && typeof node.source.callee === 'string'
216
224
  ? node.source.callee : null;
217
225
  if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
218
- const resolved = callContext._callGraph.resolve ? callContext._callGraph.resolve(calleeName) : null;
226
+ const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
227
+ const resolved = callContext._callGraph.resolve ? callContext._callGraph.resolve(calleeName, _callerFile) : null;
219
228
  const fn = resolved && resolved.qid ? resolved : null;
220
229
  const qid = resolved && (resolved.qid || resolved);
221
230
  if (typeof qid === 'string') {
@@ -322,8 +331,9 @@ function step(node, stateIn, callContext) {
322
331
  // Object.assign(target, tainted) → target becomes tainted in caller.
323
332
  if (callContext._summaryCache && callContext._callGraph
324
333
  && typeof node.callee === 'string') {
334
+ const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
325
335
  const resolved = callContext._callGraph.resolve
326
- ? callContext._callGraph.resolve(node.callee) : null;
336
+ ? callContext._callGraph.resolve(node.callee, _callerFile) : null;
327
337
  const fn = resolved && resolved.qid ? resolved : null;
328
338
  const qid = resolved && (resolved.qid || resolved);
329
339
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
@@ -331,7 +341,31 @@ function step(node, stateIn, callContext) {
331
341
  const entry = paramNames.length
332
342
  ? entryStateFromCall(paramNames, node.args || [], state)
333
343
  : new Set();
334
- const sum = callContext._summaryCache.get(qid, entry);
344
+ let sum = callContext._summaryCache.get(qid, entry);
345
+ // FR-SEM-2: context-sensitive lazy compute at the plain-call site,
346
+ // mirroring the assign-call site. On a miss for a NON-empty entry,
347
+ // compute the callee's summary UNDER that tainted-arg context so a
348
+ // param mutated only when called with user input is detected here
349
+ // too (not just when the call's result is assigned). Bounded by the
350
+ // SummaryCache context cap.
351
+ if (!sum && entry.size && fn && fn.cfg) {
352
+ sum = callContext._summaryCache.compute(qid, entry, () => {
353
+ const inner = {
354
+ _findings: [], _taintSources: [], _returnTainted: false,
355
+ _stack: new Set(), deadlineMs: callContext.deadlineMs,
356
+ _summaryCache: callContext._summaryCache,
357
+ _callGraph: callContext._callGraph,
358
+ _mutatedParamsOut: new Set(),
359
+ };
360
+ try { analyzeFunction(fn, entry, inner); } catch {}
361
+ return {
362
+ returnTainted: !!inner._returnTainted,
363
+ mutatedParams: inner._mutatedParamsOut || new Set(),
364
+ taintedGlobals: new Set(),
365
+ findings: [],
366
+ };
367
+ });
368
+ }
335
369
  if (sum && sum.mutatedParams && sum.mutatedParams.size) {
336
370
  const mutated = callContext._summaryCache.applyAtCallSite(
337
371
  sum, paramNames, node.args || [], state);
@@ -357,6 +391,20 @@ function step(node, stateIn, callContext) {
357
391
  });
358
392
  }
359
393
  }
394
+ // R4 (PRD §5): array-element taint. A mutating array method (push/unshift/
395
+ // splice/fill/copyWithin) called with a tainted argument taints the
396
+ // receiver array; an index read (a[0] → access path "a.0") is then covered
397
+ // by the receiver prefix. Object-property taint already flows via the
398
+ // access-path lattice — this closes the array case.
399
+ if (node.callee && node.callee.kind === 'member' && typeof node.callee.prop === 'string'
400
+ && /^(?:push|unshift|splice|fill|copyWithin)$/.test(node.callee.prop)
401
+ && Array.isArray(argTaints) && argTaints.some(Boolean)) {
402
+ // Mutate the state Set IN PLACE (the binding is const; the call case
403
+ // returns this same Set ref). Avoids touching the unrelated mutated-param
404
+ // paths in this case, keeping the blast radius to array-element taint only.
405
+ const _arrRecv = accessPathOf(node.callee.object);
406
+ if (_arrRecv) state.add(_arrRecv);
407
+ }
360
408
  if (cat) {
361
409
  for (const e of cat) {
362
410
  if (e.kind === 'sink' && (
@@ -467,6 +515,11 @@ function step(node, stateIn, callContext) {
467
515
  // control flow) can otherwise hold past the global timeout.
468
516
  function analyzeFunction(fn, entryState, callContext) {
469
517
  const nodes = fn.cfg.nodes;
518
+ // R2 (PRD §5): set the call-string caller context to THIS function while its
519
+ // worklist computes callee summaries (so a callee is keyed by its caller).
520
+ // No-op for the key unless AGENTIC_SECURITY_KCFA_CALLSTRING=1. Restored below.
521
+ const _prevCallerCtx = (callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext)
522
+ ? callContext._summaryCache.setCallerContext(fn.qid) : undefined;
470
523
  const work = [];
471
524
  const inStates = new Map();
472
525
  const outStates = new Map();
@@ -509,6 +562,47 @@ function analyzeFunction(fn, entryState, callContext) {
509
562
  }
510
563
  }
511
564
 
565
+ // R4 (PRD §5) — implicit / control-dependence flow. OPT-IN (default OFF, see
566
+ // isImplicitFlowEnabled). Post-pass over the converged CFG: a sink reached
567
+ // INSIDE a tainted-condition branch can leak information even when its
568
+ // argument is constant or only implicitly tainted (a var assigned in that
569
+ // branch). Findings carry implicit:true + capped confidence.
570
+ if (isImplicitFlowEnabled() && fn.cfg) {
571
+ try {
572
+ const union = new Set();
573
+ for (const s of inStates.values()) for (const p of s) union.add(p);
574
+ const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union));
575
+ // Mark vars assigned inside a tainted branch as implicit-tainted.
576
+ let implicitState = new Set();
577
+ for (const [nid, ctx] of ictx) {
578
+ const t = implicitAssignTarget(nodes[nid], ctx);
579
+ if (t) implicitState = markImplicitTaint(implicitState, t);
580
+ }
581
+ // A sink in a tainted branch whose arg is implicit-tainted (or constant)
582
+ // — and NOT already explicitly tainted (the normal pass covers that).
583
+ for (const [nid, ctx] of ictx) {
584
+ const node = nodes[nid];
585
+ if (!node || node.kind !== 'call') continue;
586
+ const cat = matchSinkOrSanitizer(node.callee);
587
+ const sink = cat && cat.find((e) => e.kind === 'sink');
588
+ if (!sink) continue;
589
+ const inS = inStates.get(nid) || new Set();
590
+ if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
591
+ const argRefsImplicit = (node.args || []).some((a) => {
592
+ const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
593
+ });
594
+ const allConst = (node.args || []).length > 0 && (node.args || []).every((a) => a && a.kind === 'literal');
595
+ if (argRefsImplicit || allConst) {
596
+ callContext._findings.push({
597
+ ...createImplicitFinding(node, ctx.conditionLabel),
598
+ _funcQid: fn.qid, sinkId: sink.id,
599
+ cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
600
+ });
601
+ }
602
+ }
603
+ } catch { /* implicit flow is best-effort + opt-in */ }
604
+ }
605
+
512
606
  const exit = outStates.get(fn.cfg.exit) || new Set();
513
607
  // v0.66 — record which params are tainted at function exit so the
514
608
  // caller's applyAtCallSite can propagate that mutated taint back. We
@@ -520,6 +614,10 @@ function analyzeFunction(fn, entryState, callContext) {
520
614
  if (isCoveredBy(exit, p)) callContext._mutatedParamsOut.add(p);
521
615
  }
522
616
  }
617
+ // R2: restore the caller context for the enclosing function's analysis.
618
+ if (_prevCallerCtx !== undefined && callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext) {
619
+ callContext._summaryCache.setCallerContext(_prevCallerCtx);
620
+ }
523
621
  return exit;
524
622
  }
525
623
 
@@ -685,7 +783,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
685
783
  if (Date.now() > deadlineMs) break;
686
784
  const inv = hoInvocations[hi];
687
785
  if (!inv.callee || !inv.taintedParam) continue;
688
- const resolved = callGraph.resolve ? callGraph.resolve(inv.callee) : null;
786
+ const resolved = callGraph.resolve ? callGraph.resolve(inv.callee, fn && fn.file) : null;
689
787
  const cbFn = resolved && resolved.qid ? resolved : null;
690
788
  if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
691
789
  const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
@@ -723,7 +821,10 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
723
821
  cwe: f.cwe,
724
822
  remediation: f.remediation,
725
823
  parser: 'IR-TAINT',
726
- confidence: 0.75,
824
+ // R4 implicit-flow: preserve the implicit flag + its capped confidence
825
+ // (a control-dependence finding, not an explicit data-flow one).
826
+ confidence: (f.implicit && typeof f.confidence === 'number') ? f.confidence : 0.75,
827
+ ...(f.implicit === true ? { implicit: true } : {}),
727
828
  source: f.trace && f.trace.length ? {
728
829
  file: fn.file,
729
830
  line: f.trace[0].line,
@@ -34,8 +34,10 @@
34
34
  import { addPath } from './access-paths.js';
35
35
 
36
36
  export function isImplicitFlowEnabled() {
37
- if (process.env.AGENTIC_SECURITY_IMPLICIT_FLOW === '0') return false;
38
- return true;
37
+ // OPT-IN (default OFF): implicit/control-dependence flow is famously noisy, so
38
+ // it must be explicitly requested. Findings carry implicit:true + capped
39
+ // confidence so the standard pipeline ranks them below explicit flows.
40
+ return process.env.AGENTIC_SECURITY_IMPLICIT_FLOW === '1';
39
41
  }
40
42
 
41
43
  /**
@@ -1,5 +1,6 @@
1
1
  // Layer 2 entry point.
2
2
  import { runTaintEngine } from './engine.js';
3
+ import { annotateProvenClean } from './proven-clean.js';
3
4
  import { CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize } from './catalog.js';
4
5
  import { applyPathFeasibility } from './path-feasibility.js';
5
6
  import { SummaryCache, entryStateFromCall } from './summaries.js';
@@ -47,12 +48,17 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
47
48
  scanner: opts.scannerVersion || 'unknown',
48
49
  rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
49
50
  };
51
+ // Compute current file hashes UNCONDITIONALLY — the first scan (and any
52
+ // scan after a version/rule invalidation) must persist a baseline so the
53
+ // NEXT scan has something to reuse. This previously lived inside the
54
+ // valid-state branch, so a cold cache never committed and incremental was
55
+ // a permanent no-op (the commit guard at the bottom keys on currentFileHashes).
56
+ currentFileHashes = {};
57
+ for (const [fp, content] of Object.entries(opts.fileContents)) {
58
+ currentFileHashes[fp] = hashFileContent(content);
59
+ }
50
60
  const valid = validateIncrementalState(priorState, currentVersion);
51
61
  if (valid.valid) {
52
- currentFileHashes = {};
53
- for (const [fp, content] of Object.entries(opts.fileContents)) {
54
- currentFileHashes[fp] = hashFileContent(content);
55
- }
56
62
  const diff = diffFileHashes(priorState.files || {}, currentFileHashes);
57
63
  const changedQids = new Set();
58
64
  // Map a changed file to the qids it owns. perFileIR exposes file→fns.
@@ -114,6 +120,13 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
114
120
  if (process.env.AGENTIC_SECURITY_BACKWARD_SLICE === '1') {
115
121
  findings = annotateBackwardSlices(findings, perFileIR, callGraph);
116
122
  }
123
+ // Roadmap #6 — flow-proof: prove SQL sinks reached only through a
124
+ // parameterizer (`provenClean`). Runs by default in the deep pass; it only
125
+ // touches IR-TAINT SQL findings and never drops anything. The proof-gate
126
+ // annotator (engine.js) consolidates this into the demotion verdict.
127
+ if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
128
+ try { findings = annotateProvenClean(findings, perFileIR); } catch { /* proof failure must not fail the scan */ }
129
+ }
117
130
  // v0.70 #6 — probabilistic / soft taint. Walks each finding's trace +
118
131
  // chain, multiplies (1 - effectiveness) across sanitizers, demotes
119
132
  // below-threshold findings to lower severity (never drops).
@@ -0,0 +1,100 @@
1
+ // Proof-gate precision pass — roadmap item #6 ("perfect multi-language SAST").
2
+ //
3
+ // The engine produces two INDEPENDENT flow-proof signals that, until now,
4
+ // were either informational-only or wired in isolation:
5
+ //
6
+ // f.provenClean (proven-clean.js) — every reaching path to a SQL
7
+ // sink passes a parameterizer.
8
+ // f._provenUnreachable (exploit-prover.js) — a sanitizer on the path emits
9
+ // output that cannot contain the
10
+ // vuln family's metacharacters.
11
+ //
12
+ // This pass consolidates them into ONE verdict per finding and applies a
13
+ // precision demotion — the central idea of the precision gate:
14
+ //
15
+ // "Report only provably-feasible flows. A flow we can PROVE is clean or
16
+ // infeasible is demoted, not dropped — the auditor still sees it, and a
17
+ // severity-based CI gate still fires, but it stops dominating the risk
18
+ // ranking and stops tripping confidence filters."
19
+ //
20
+ // Output stamped on every finding:
21
+ // f.proof = { verdict, reasons[] }
22
+ // verdict ∈ 'proven-clean' | 'proven-infeasible' | 'feasible' | 'unproven'
23
+ // f.proofGated = true (only when a demotion was applied)
24
+ //
25
+ // Demotion policy is deliberately RECALL-PRESERVING:
26
+ // - lower `confidence` by DEMOTE_FACTOR and recompute its tier,
27
+ // - drop `confidenceTier` / `exploitabilityTier` one notch,
28
+ // - record an `exploitabilityFactors` breadcrumb,
29
+ // - leave `severity` UNTOUCHED. The proofs here are heuristic (path-
30
+ // existence / regex-exclusion), so they must never hide a finding from a
31
+ // severity gate. Confidence/exploitability are the safe levers.
32
+
33
+ const DEMOTE_FACTOR = 0.4;
34
+
35
+ // Tier ladders, lowest → highest. demoteTier moves one step down.
36
+ const CONFIDENCE_TIERS = ['very-low', 'low', 'medium', 'high'];
37
+ const EXPLOITABILITY_TIERS = ['low', 'medium', 'high', 'critical'];
38
+
39
+ function demoteTier(tier, ladder) {
40
+ const i = ladder.indexOf(tier);
41
+ if (i <= 0) return ladder[0];
42
+ return ladder[i - 1];
43
+ }
44
+
45
+ // Decide the proof verdict for a single finding from the upstream signals.
46
+ export function verdictForFinding(f) {
47
+ if (f.provenClean === true) {
48
+ const sanitizers = (f.provenanceProof && f.provenanceProof.sanitizers) || [];
49
+ return { verdict: 'proven-clean', reason: sanitizers.length ? `parameterized via ${sanitizers.join(', ')}` : 'sql-parameterizer-on-path' };
50
+ }
51
+ if (f._provenUnreachable === true) {
52
+ return { verdict: 'proven-infeasible', reason: f._provenUnreachableReason || 'sanitizer-excludes-metacharacters' };
53
+ }
54
+ // A taint finding that reached a sink with attributed sources, for which we
55
+ // could NOT discharge a clean/infeasible proof, is "feasible" — the flows
56
+ // a precision-gated report should lead with. Non-taint findings are simply
57
+ // outside the proof model → "unproven" (no demotion, no claim).
58
+ if (f.parser === 'IR-TAINT') return { verdict: 'feasible', reason: 'reaches-sink, no clean/infeasible proof discharged' };
59
+ return { verdict: 'unproven', reason: 'no flow-proof applicable to this finding class' };
60
+ }
61
+
62
+ // Annotate findings in place. Returns the same array with a non-enumerable
63
+ // `_proofGateStats` for benchmarking.
64
+ export function annotateProofGate(findings, opts = {}) {
65
+ if (!Array.isArray(findings)) return findings;
66
+ const factor = typeof opts.demoteFactor === 'number' ? opts.demoteFactor : DEMOTE_FACTOR;
67
+ const stats = { gated: 0, feasible: 0, provenClean: 0, provenInfeasible: 0, unproven: 0 };
68
+
69
+ for (const f of findings) {
70
+ if (!f || typeof f !== 'object') continue;
71
+ const { verdict, reason } = verdictForFinding(f);
72
+ f.proof = { verdict, reasons: [reason] };
73
+
74
+ if (verdict === 'feasible') { stats.feasible++; continue; }
75
+ if (verdict === 'unproven') { stats.unproven++; continue; }
76
+
77
+ // proven-clean | proven-infeasible → recall-preserving demotion.
78
+ if (verdict === 'proven-clean') stats.provenClean++; else stats.provenInfeasible++;
79
+ // R13 (PRD §5): make "provably safe" a first-class, queryable verdict — not
80
+ // just a quiet confidence demotion. "We proved this can't fire" is a
81
+ // stronger, rarer claim than any confidence score, and lets the report
82
+ // optionally hide discharged findings (--hide-proven-safe).
83
+ f.provablySafe = true;
84
+
85
+ if (typeof f.confidence === 'number') {
86
+ f._confidenceBeforeProofGate = f.confidence;
87
+ f.confidence = Math.max(0.01, Number((f.confidence * factor).toFixed(4)));
88
+ }
89
+ if (f.confidenceTier) f.confidenceTier = demoteTier(f.confidenceTier, CONFIDENCE_TIERS);
90
+ if (f.exploitabilityTier) f.exploitabilityTier = demoteTier(f.exploitabilityTier, EXPLOITABILITY_TIERS);
91
+ if (Array.isArray(f.exploitabilityFactors)) f.exploitabilityFactors.push(`proof:${verdict}`);
92
+ f.proofGated = true;
93
+ stats.gated++;
94
+ }
95
+
96
+ Object.defineProperty(findings, '_proofGateStats', { value: stats, enumerable: false, configurable: true });
97
+ return findings;
98
+ }
99
+
100
+ export const _internals = { DEMOTE_FACTOR, CONFIDENCE_TIERS, EXPLOITABILITY_TIERS, demoteTier };