@clear-capabilities/agentic-security-scanner 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/CHANGELOG.md +1580 -0
  2. package/bin/.agentic-security/findings.json +1577 -0
  3. package/bin/.agentic-security/last-scan.json +1577 -0
  4. package/bin/.agentic-security/last-scan.json.sig +1 -0
  5. package/bin/.agentic-security/scan-history.json +465 -0
  6. package/bin/.agentic-security/streak.json +25 -0
  7. package/bin/agentic-security-audit.js +198 -0
  8. package/bin/agentic-security-consistency.js +80 -0
  9. package/bin/agentic-security-diff.js +136 -0
  10. package/bin/agentic-security-lsp.js +12 -0
  11. package/bin/agentic-security-mcp.js +40 -0
  12. package/bin/agentic-security-rule.js +153 -0
  13. package/bin/agentic-security.js +1683 -0
  14. package/dist/117.index.js +207 -0
  15. package/dist/178.index.js +250 -0
  16. package/dist/218.index.js +793 -0
  17. package/dist/227.index.js +192 -0
  18. package/dist/301.index.js +167 -0
  19. package/dist/384.index.js +18 -0
  20. package/dist/476.index.js +126 -0
  21. package/dist/513.index.js +373 -0
  22. package/dist/520.index.js +13 -0
  23. package/dist/601.index.js +1038 -0
  24. package/dist/634.index.js +1892 -0
  25. package/dist/637.index.js +216 -0
  26. package/dist/660.index.js +131 -0
  27. package/dist/675.index.js +451 -0
  28. package/dist/826.index.js +188 -0
  29. package/dist/830.index.js +133 -0
  30. package/dist/agentic-security.mjs +272 -0
  31. package/dist/agentic-security.mjs.sha256 +1 -0
  32. package/dist/calibration-seed.json +27 -0
  33. package/package.json +77 -0
  34. package/src/.agentic-security/findings.json +80844 -0
  35. package/src/.agentic-security/last-scan.json +80844 -0
  36. package/src/.agentic-security/last-scan.json.sig +1 -0
  37. package/src/.agentic-security/scan-history.json +8408 -0
  38. package/src/.agentic-security/streak.json +26 -0
  39. package/src/badge.js +188 -0
  40. package/src/compare.js +203 -0
  41. package/src/dataflow/.agentic-security/findings.json +3487 -0
  42. package/src/dataflow/.agentic-security/last-scan.json +3487 -0
  43. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  44. package/src/dataflow/.agentic-security/scan-history.json +735 -0
  45. package/src/dataflow/.agentic-security/streak.json +24 -0
  46. package/src/dataflow/CLAUDE.md +38 -0
  47. package/src/dataflow/access-paths.js +172 -0
  48. package/src/dataflow/async-sequencing.js +177 -0
  49. package/src/dataflow/backward.js +201 -0
  50. package/src/dataflow/catalog-expanded.js +485 -0
  51. package/src/dataflow/catalog.js +659 -0
  52. package/src/dataflow/cross-repo.js +219 -0
  53. package/src/dataflow/engine.js +588 -0
  54. package/src/dataflow/exception-flow.js +116 -0
  55. package/src/dataflow/exploit-prover.js +187 -0
  56. package/src/dataflow/higher-order.js +221 -0
  57. package/src/dataflow/ifds.js +347 -0
  58. package/src/dataflow/implicit-flow.js +129 -0
  59. package/src/dataflow/incremental.js +229 -0
  60. package/src/dataflow/index.js +181 -0
  61. package/src/dataflow/numeric-domain.js +192 -0
  62. package/src/dataflow/path-feasibility.js +114 -0
  63. package/src/dataflow/points-to.js +337 -0
  64. package/src/dataflow/polyglot.js +190 -0
  65. package/src/dataflow/proven-clean.js +159 -0
  66. package/src/dataflow/receiver-context.js +76 -0
  67. package/src/dataflow/sanitizer-proof.js +154 -0
  68. package/src/dataflow/soft-taint.js +140 -0
  69. package/src/dataflow/string-domain.js +234 -0
  70. package/src/dataflow/stub-aware-filter.js +100 -0
  71. package/src/dataflow/summaries.js +132 -0
  72. package/src/dataflow/symbolic-exec.js +238 -0
  73. package/src/dataflow/tabulation.js +135 -0
  74. package/src/engine.js +7763 -0
  75. package/src/history-scan.js +229 -0
  76. package/src/index.js +3 -0
  77. package/src/integrations/.agentic-security/findings.json +1504 -0
  78. package/src/integrations/.agentic-security/last-scan.json +1504 -0
  79. package/src/integrations/.agentic-security/scan-history.json +40 -0
  80. package/src/integrations/.agentic-security/streak.json +21 -0
  81. package/src/integrations/index.js +321 -0
  82. package/src/integrations/tickets.js +200 -0
  83. package/src/ir/.agentic-security/findings.json +3036 -0
  84. package/src/ir/.agentic-security/last-scan.json +3036 -0
  85. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  86. package/src/ir/.agentic-security/scan-history.json +364 -0
  87. package/src/ir/.agentic-security/streak.json +23 -0
  88. package/src/ir/CLAUDE.md +172 -0
  89. package/src/ir/callgraph.js +73 -0
  90. package/src/ir/class-hierarchy.js +195 -0
  91. package/src/ir/index.js +152 -0
  92. package/src/ir/parser-cs.js +260 -0
  93. package/src/ir/parser-java.js +286 -0
  94. package/src/ir/parser-js.js +413 -0
  95. package/src/ir/parser-kt.js +258 -0
  96. package/src/ir/parser-py-cst.js +136 -0
  97. package/src/ir/parser-py.helper.py +501 -0
  98. package/src/ir/parser-py.js +312 -0
  99. package/src/ir/ssa.js +315 -0
  100. package/src/ir/type-stubs.js +288 -0
  101. package/src/leaderboard.js +152 -0
  102. package/src/llm-validator/.agentic-security/findings.json +1891 -0
  103. package/src/llm-validator/.agentic-security/last-scan.json +1891 -0
  104. package/src/llm-validator/.agentic-security/last-scan.json.sig +1 -0
  105. package/src/llm-validator/.agentic-security/scan-history.json +168 -0
  106. package/src/llm-validator/.agentic-security/streak.json +20 -0
  107. package/src/llm-validator/consistency.js +141 -0
  108. package/src/llm-validator/index.js +437 -0
  109. package/src/lsp/.agentic-security/findings.json +28 -0
  110. package/src/lsp/.agentic-security/last-scan.json +28 -0
  111. package/src/lsp/.agentic-security/scan-history.json +79 -0
  112. package/src/lsp/.agentic-security/streak.json +22 -0
  113. package/src/lsp/server.js +275 -0
  114. package/src/mcp/.agentic-security/findings.json +8358 -0
  115. package/src/mcp/.agentic-security/last-scan.json +8358 -0
  116. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  117. package/src/mcp/.agentic-security/scan-history.json +1125 -0
  118. package/src/mcp/.agentic-security/streak.json +22 -0
  119. package/src/mcp/CLAUDE.md +54 -0
  120. package/src/mcp/audit.js +136 -0
  121. package/src/mcp/redact.js +75 -0
  122. package/src/mcp/server.js +158 -0
  123. package/src/mcp/stdio.js +83 -0
  124. package/src/mcp/tools.js +940 -0
  125. package/src/mcp/validate.js +49 -0
  126. package/src/personality.js +164 -0
  127. package/src/poc-video.js +239 -0
  128. package/src/posture/.agentic-security/findings.json +51239 -0
  129. package/src/posture/.agentic-security/last-scan.json +51239 -0
  130. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  131. package/src/posture/.agentic-security/scan-history.json +5557 -0
  132. package/src/posture/.agentic-security/streak.json +24 -0
  133. package/src/posture/CLAUDE.md +42 -0
  134. package/src/posture/adversarial-self-test.js +114 -0
  135. package/src/posture/adversary-agent.js +204 -0
  136. package/src/posture/agents-memory.js +135 -0
  137. package/src/posture/ai-code-fingerprint.js +171 -0
  138. package/src/posture/aibom.js +284 -0
  139. package/src/posture/api-inventory.js +96 -0
  140. package/src/posture/attack-playbooks.js +305 -0
  141. package/src/posture/auditor-agent.js +115 -0
  142. package/src/posture/auth-posture-import.js +135 -0
  143. package/src/posture/baseline-compare.js +114 -0
  144. package/src/posture/blast-radius.js +836 -0
  145. package/src/posture/bounty-prediction.js +141 -0
  146. package/src/posture/business-logic.js +239 -0
  147. package/src/posture/calibration-drift.js +93 -0
  148. package/src/posture/calibration-seed.json +27 -0
  149. package/src/posture/calibration.js +204 -0
  150. package/src/posture/clustering.js +75 -0
  151. package/src/posture/concurrency-checker.js +265 -0
  152. package/src/posture/confidence.js +65 -0
  153. package/src/posture/container-runtime.js +149 -0
  154. package/src/posture/counterfactual.js +109 -0
  155. package/src/posture/cross-lang-graphql.js +165 -0
  156. package/src/posture/cross-lang-grpc.js +166 -0
  157. package/src/posture/cross-lang-meta.js +101 -0
  158. package/src/posture/cross-lang-openapi.js +187 -0
  159. package/src/posture/cross-lang-orm.js +153 -0
  160. package/src/posture/cross-lang-queues.js +210 -0
  161. package/src/posture/crown-jewels.js +110 -0
  162. package/src/posture/custom-rules.js +361 -0
  163. package/src/posture/cve-alert-daemon.js +433 -0
  164. package/src/posture/cve-lookup.js +129 -0
  165. package/src/posture/dead-code.js +430 -0
  166. package/src/posture/defender-agent.js +158 -0
  167. package/src/posture/deploy-platform.js +204 -0
  168. package/src/posture/detector-fuzz.js +61 -0
  169. package/src/posture/deterministic.js +99 -0
  170. package/src/posture/drift.js +165 -0
  171. package/src/posture/epss.js +156 -0
  172. package/src/posture/exploitability-probability.js +212 -0
  173. package/src/posture/exploitability.js +121 -0
  174. package/src/posture/feature-flags.js +110 -0
  175. package/src/posture/finding-defaults.js +132 -0
  176. package/src/posture/fix-history.js +411 -0
  177. package/src/posture/fix-plan.js +121 -0
  178. package/src/posture/fix-verify-loop.js +157 -0
  179. package/src/posture/fix-verify.js +130 -0
  180. package/src/posture/flow-narration.js +105 -0
  181. package/src/posture/grader-calibration.js +156 -0
  182. package/src/posture/harness-discovery.js +113 -0
  183. package/src/posture/holdout-eval.js +144 -0
  184. package/src/posture/iac-reachability.js +163 -0
  185. package/src/posture/iam-policy.js +128 -0
  186. package/src/posture/integrity.js +97 -0
  187. package/src/posture/learning.js +166 -0
  188. package/src/posture/license-policy.js +109 -0
  189. package/src/posture/llm-redteam-prompts.js +418 -0
  190. package/src/posture/llm-redteam.js +303 -0
  191. package/src/posture/material-change.js +163 -0
  192. package/src/posture/mitigation-composite.js +55 -0
  193. package/src/posture/mttr.js +91 -0
  194. package/src/posture/network-policy-import.js +126 -0
  195. package/src/posture/path-predicates.js +99 -0
  196. package/src/posture/persona-prioritization.js +153 -0
  197. package/src/posture/poc-cwe-map.js +51 -0
  198. package/src/posture/poc-generator.js +500 -0
  199. package/src/posture/policy-gate.js +174 -0
  200. package/src/posture/pre-incident-archaeology.js +110 -0
  201. package/src/posture/profile.js +93 -0
  202. package/src/posture/reachability-filter.js +42 -0
  203. package/src/posture/regression-test-gen.js +200 -0
  204. package/src/posture/reverse-blast-radius.js +110 -0
  205. package/src/posture/router.js +109 -0
  206. package/src/posture/rule-overrides.js +198 -0
  207. package/src/posture/rule-pack-signing.js +209 -0
  208. package/src/posture/rule-packs.js +143 -0
  209. package/src/posture/rule-synthesis.js +108 -0
  210. package/src/posture/ruleset-version.js +71 -0
  211. package/src/posture/sbom.js +129 -0
  212. package/src/posture/schema-aware-bridge.js +207 -0
  213. package/src/posture/security-trend.js +87 -0
  214. package/src/posture/semantic-clone.js +114 -0
  215. package/src/posture/specification-mining.js +170 -0
  216. package/src/posture/stable-id.js +75 -0
  217. package/src/posture/stack-playbook.js +229 -0
  218. package/src/posture/streak.js +249 -0
  219. package/src/posture/suppressions.js +135 -0
  220. package/src/posture/telemetry-ingest.js +112 -0
  221. package/src/posture/threat-model.js +145 -0
  222. package/src/posture/three-agent-pipeline.js +74 -0
  223. package/src/posture/triage.js +146 -0
  224. package/src/posture/trust-boundary-diagram.js +115 -0
  225. package/src/posture/type-narrowing.js +129 -0
  226. package/src/posture/validator-metrics.js +179 -0
  227. package/src/posture/verifier-ephemeral.js +118 -0
  228. package/src/posture/verifier-target.js +147 -0
  229. package/src/posture/verifier.js +257 -0
  230. package/src/posture/version.js +75 -0
  231. package/src/posture/waf-ingest.js +200 -0
  232. package/src/posture/why-fired.js +141 -0
  233. package/src/pr-comment.js +172 -0
  234. package/src/pr-delta.js +198 -0
  235. package/src/report/.agentic-security/findings.json +79 -0
  236. package/src/report/.agentic-security/last-scan.json +79 -0
  237. package/src/report/.agentic-security/last-scan.json.sig +1 -0
  238. package/src/report/.agentic-security/scan-history.json +332 -0
  239. package/src/report/.agentic-security/streak.json +23 -0
  240. package/src/report/index.js +1136 -0
  241. package/src/report/mascot.js +42 -0
  242. package/src/runScan.js +141 -0
  243. package/src/sast/.agentic-security/findings.json +5051 -0
  244. package/src/sast/.agentic-security/last-scan.json +5051 -0
  245. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  246. package/src/sast/.agentic-security/scan-history.json +788 -0
  247. package/src/sast/.agentic-security/streak.json +23 -0
  248. package/src/sast/CLAUDE.md +39 -0
  249. package/src/sast/_comment-strip.js +46 -0
  250. package/src/sast/agent-tool-escalation.js +131 -0
  251. package/src/sast/auth-provider.js +171 -0
  252. package/src/sast/authz.js +236 -0
  253. package/src/sast/bench-shape/.agentic-security/findings.json +28 -0
  254. package/src/sast/bench-shape/.agentic-security/last-scan.json +28 -0
  255. package/src/sast/bench-shape/.agentic-security/scan-history.json +24 -0
  256. package/src/sast/bench-shape/.agentic-security/streak.json +22 -0
  257. package/src/sast/bench-shape/index.js +62 -0
  258. package/src/sast/claude-hook-injection.js +199 -0
  259. package/src/sast/claude-md-prompt-injection.js +170 -0
  260. package/src/sast/claude-settings.js +165 -0
  261. package/src/sast/client-side.js +149 -0
  262. package/src/sast/cpp-bench-extras.js +122 -0
  263. package/src/sast/cpp-dataflow.js +430 -0
  264. package/src/sast/cpp.js +248 -0
  265. package/src/sast/csharp.js +152 -0
  266. package/src/sast/csrf.js +82 -0
  267. package/src/sast/dart-flutter.js +173 -0
  268. package/src/sast/db-rls.js +147 -0
  269. package/src/sast/db-taint.js +215 -0
  270. package/src/sast/defi-deep.js +242 -0
  271. package/src/sast/deserialization-gadgets.js +113 -0
  272. package/src/sast/django-hardening.js +230 -0
  273. package/src/sast/env-hygiene.js +125 -0
  274. package/src/sast/fastapi-hardening.js +145 -0
  275. package/src/sast/go-extended.js +84 -0
  276. package/src/sast/host-header.js +106 -0
  277. package/src/sast/index.js +17 -0
  278. package/src/sast/java-ast-folding.js +561 -0
  279. package/src/sast/java-bench-extras.js +708 -0
  280. package/src/sast/java-collection-passthrough.js +178 -0
  281. package/src/sast/java-constant-fold.js +244 -0
  282. package/src/sast/java-deserialization.js +125 -0
  283. package/src/sast/jndi.js +104 -0
  284. package/src/sast/juliet-shape.js +324 -0
  285. package/src/sast/jwt-exp.js +104 -0
  286. package/src/sast/kotlin.js +82 -0
  287. package/src/sast/laravel-hardening.js +198 -0
  288. package/src/sast/ldap-injection.js +100 -0
  289. package/src/sast/llm-owasp.js +465 -0
  290. package/src/sast/llm-stored-prompt.js +103 -0
  291. package/src/sast/llm-trading-agent.js +161 -0
  292. package/src/sast/llm.js +308 -0
  293. package/src/sast/logic.js +140 -0
  294. package/src/sast/mass-assignment.js +101 -0
  295. package/src/sast/mcp-audit.js +242 -0
  296. package/src/sast/mobile-manifest.js +195 -0
  297. package/src/sast/model-load.js +164 -0
  298. package/src/sast/mutation-xss.js +87 -0
  299. package/src/sast/nosql-injection.js +82 -0
  300. package/src/sast/open-redirect.js +119 -0
  301. package/src/sast/php.js +91 -0
  302. package/src/sast/pipeline.js +122 -0
  303. package/src/sast/primary-cwe-java.js +155 -0
  304. package/src/sast/prompt-firewall.js +151 -0
  305. package/src/sast/prompt-template.js +157 -0
  306. package/src/sast/prototype-pollution.js +112 -0
  307. package/src/sast/python-sinks.js +195 -0
  308. package/src/sast/quarkus-hardening.js +102 -0
  309. package/src/sast/rag-poisoning.js +118 -0
  310. package/src/sast/rate-limit.js +128 -0
  311. package/src/sast/response-splitting.js +138 -0
  312. package/src/sast/ruby.js +108 -0
  313. package/src/sast/rust.js +105 -0
  314. package/src/sast/solidity.js +167 -0
  315. package/src/sast/springboot-hardening.js +186 -0
  316. package/src/sast/ssrf-cloud-metadata.js +80 -0
  317. package/src/sast/ssti.js +116 -0
  318. package/src/sast/swift.js +162 -0
  319. package/src/sast/toctou.js +95 -0
  320. package/src/sast/webhook.js +101 -0
  321. package/src/sast/xpath-injection.js +51 -0
  322. package/src/sast/xxe.js +140 -0
  323. package/src/sast/zip-slip.js +200 -0
  324. package/src/sca/base-images.json +45 -0
  325. package/src/sca/container.js +107 -0
  326. package/src/sca/dep-confusion.js +134 -0
  327. package/src/sca/index.js +6 -0
  328. package/src/sca/popular-packages.json +41 -0
  329. package/src/sca/sarif-ingest.js +187 -0
  330. package/src/sca/vuln-function-hints.json +89 -0
  331. package/src/secrets/index.js +4 -0
@@ -0,0 +1,500 @@
1
+ // PoC generator (FR-VER-2 — Phase-1 P1.1 of docs/PRD-next-gen-sast-phase1.md).
2
+ //
3
+ // Produces a runnable proof-of-concept file per finding for the top-10 CWE
4
+ // families. The output is consumed in two ways:
5
+ //
6
+ // 1. As metadata on the finding (`f.poc = { lang, code, runHint, kind }`)
7
+ // so reports can render it inline.
8
+ // 2. By the verifier (P1.2) which executes the PoC in a sandbox and tags
9
+ // the finding `verified-exploit` if the PoC demonstrates the vuln on
10
+ // the discovered fixture.
11
+ //
12
+ // SAFETY: templates use intentionally-readable payloads (cat /etc/passwd,
13
+ // alert(document.domain), etc.) — they are designed to PROVE the bug exists,
14
+ // not to weaponize it. No template performs destructive actions, attempts
15
+ // privilege escalation, or makes outbound network requests beyond the
16
+ // project's own localhost endpoints. The verifier sandbox (P1.2) will deny
17
+ // network egress and write access outside the working dir as a second line
18
+ // of defense.
19
+ //
20
+ // Out of scope for P1.1:
21
+ // - Sandbox execution.
22
+ // - Assigning a verified-exploit verdict.
23
+ // - Per-language template variants beyond the primary host language.
24
+ // Those land in P1.2.
25
+
26
+ import { CWE_TO_FAMILY, FAMILY_TO_PRIMARY_CWE } from './poc-cwe-map.js';
27
+
28
+ // ─── Template selectors ─────────────────────────────────────────────────────
29
+ //
30
+ // Each entry: { cwe, family, vulnContains, lang, render(finding, ctx) → code }
31
+ // `vulnContains` is an array of substrings; the first matching template wins.
32
+ // `render` returns the PoC body. The harness wraps it.
33
+
34
+ const TEMPLATES = [
35
+ {
36
+ cwe: 'CWE-89',
37
+ family: 'sql-injection',
38
+ vulnContains: ['SQL Injection', 'NoSQL Injection'],
39
+ lang: 'node',
40
+ kind: 'http-payload',
41
+ render: (f, ctx) => _httpPocNode(ctx, {
42
+ header: 'Demonstrates SQL injection by sending a UNION-style payload.',
43
+ payload: `' UNION SELECT username, password FROM users--`,
44
+ expect: 'response status 500 or body contains "syntax error" / leaked column / SQL stacktrace',
45
+ }),
46
+ },
47
+ {
48
+ cwe: 'CWE-78',
49
+ family: 'command-injection',
50
+ vulnContains: ['Command Injection'],
51
+ lang: 'node',
52
+ kind: 'http-payload',
53
+ render: (f, ctx) => _httpPocNode(ctx, {
54
+ header: 'Demonstrates OS command injection via a shell-metacharacter payload.',
55
+ payload: `; printf "POC_MARKER_$(whoami)\\n"`,
56
+ expect: 'response body contains "POC_MARKER_" — the marker proves the injected command ran',
57
+ }),
58
+ },
59
+ {
60
+ cwe: 'CWE-79',
61
+ family: 'xss',
62
+ vulnContains: ['XSS', 'Reflected XSS', 'Stored XSS', 'DOM XSS', 'document.write'],
63
+ lang: 'node',
64
+ kind: 'http-payload',
65
+ render: (f, ctx) => _httpPocNode(ctx, {
66
+ header: 'Demonstrates reflected XSS by checking the script payload appears unencoded.',
67
+ payload: `"><script>__POC_XSS_${Math.random().toString(36).slice(2, 8)}</script>`,
68
+ expect: 'response body contains the literal <script> payload (proves no HTML encoding)',
69
+ }),
70
+ },
71
+ {
72
+ cwe: 'CWE-22',
73
+ family: 'path-traversal',
74
+ vulnContains: ['Path Traversal'],
75
+ lang: 'node',
76
+ kind: 'http-payload',
77
+ render: (f, ctx) => _httpPocNode(ctx, {
78
+ header: 'Demonstrates path traversal by reading a sentinel file outside the intended dir.',
79
+ payload: `../../../../../../etc/hostname`,
80
+ expect: 'response body contains a hostname-shaped string (lowercased letters/digits, no traversal markers)',
81
+ }),
82
+ },
83
+ {
84
+ cwe: 'CWE-918',
85
+ family: 'ssrf',
86
+ vulnContains: ['SSRF'],
87
+ lang: 'node',
88
+ kind: 'http-payload',
89
+ render: (f, ctx) => _httpPocNode(ctx, {
90
+ header: 'Demonstrates SSRF by forcing the server to fetch a localhost sentinel URL.',
91
+ // Use a port we own in the verifier sandbox; never the real cloud metadata IP.
92
+ payload: `http://127.0.0.1:65533/poc-ssrf-sentinel`,
93
+ expect: 'sentinel server logs a request from the target — proves the target made an outbound call we controlled',
94
+ }),
95
+ },
96
+ {
97
+ cwe: 'CWE-94',
98
+ family: 'code-injection',
99
+ vulnContains: ['Code Injection', 'VM Sandbox'],
100
+ lang: 'node',
101
+ kind: 'http-payload',
102
+ render: (f, ctx) => _httpPocNode(ctx, {
103
+ header: 'Demonstrates JS code injection via an eval-ed expression with a side effect.',
104
+ // Set a global; the target evals it; we check it via a second request to a debug endpoint.
105
+ payload: `1; (globalThis.__POC_CODE_INJ__ = "yes"); 1`,
106
+ expect: 'server-side __POC_CODE_INJ__ becomes "yes" — observe via /debug or via observing 200 with no exception',
107
+ }),
108
+ },
109
+ {
110
+ cwe: 'CWE-352',
111
+ family: 'csrf',
112
+ vulnContains: ['CSRF', 'Missing CSRF protection'],
113
+ lang: 'node',
114
+ kind: 'http-csrf',
115
+ render: (f, ctx) => _csrfPocNode(ctx),
116
+ },
117
+ {
118
+ cwe: 'CWE-601',
119
+ family: 'open-redirect',
120
+ vulnContains: ['Open Redirect'],
121
+ lang: 'node',
122
+ kind: 'http-payload',
123
+ render: (f, ctx) => _httpPocNode(ctx, {
124
+ header: 'Demonstrates open redirect by checking Location header points at attacker-controlled host.',
125
+ payload: `https://poc-attacker.invalid/`,
126
+ expect: 'response status 30x AND Location header starts with https://poc-attacker.invalid/',
127
+ noFollow: true,
128
+ }),
129
+ },
130
+ {
131
+ cwe: 'CWE-611',
132
+ family: 'xxe',
133
+ vulnContains: ['XXE', 'Unsafe XML Parsing'],
134
+ lang: 'node',
135
+ kind: 'http-xml',
136
+ render: (f, ctx) => _xxePocNode(ctx),
137
+ },
138
+ {
139
+ cwe: 'CWE-502',
140
+ family: 'insecure-deserialization',
141
+ vulnContains: ['Insecure Deserialization', 'Insecure Java Deserialization', 'Unsafe Deserialization'],
142
+ lang: 'node',
143
+ kind: 'http-payload',
144
+ render: (f, ctx) => _httpPocNode(ctx, {
145
+ header: 'Demonstrates unsafe deserialization with a benign marker-emitting payload.',
146
+ payload: `{"__class__":"PocMarker","value":"deserialization-reached"}`,
147
+ expect: 'server-side log includes "PocMarker" — proves the deserialization callback fired',
148
+ }),
149
+ },
150
+ ];
151
+
152
+ // ─── PoC harness templates (Node.js) ────────────────────────────────────────
153
+ //
154
+ // Generic harness wraps the payload in a self-contained Node script with:
155
+ // - one fetch() call to the discovered route
156
+ // - exit 0 on demonstrated exploit, non-zero otherwise
157
+ // - all observations printed to stderr for the verifier to parse
158
+
159
+ function _httpPocNode(ctx, { header, payload, expect, noFollow = false }) {
160
+ const url = ctx.url || 'http://localhost:3000/REPLACE-WITH-ENDPOINT';
161
+ const method = (ctx.method || 'POST').toUpperCase();
162
+ const param = ctx.param || 'input';
163
+ const safePayload = String(payload).replace(/`/g, '\\`').replace(/\$/g, '\\$');
164
+ return `// ${header}
165
+ // Endpoint: ${method} ${url}
166
+ // Payload: ${safePayload.slice(0, 120)}${safePayload.length > 120 ? '…' : ''}
167
+ // Expect: ${expect}
168
+ // Run: node poc.mjs
169
+ // Exit code: 0 = exploit demonstrated, 1 = not demonstrated, 2 = error
170
+
171
+ const URL_ = ${JSON.stringify(url)};
172
+ const METHOD = ${JSON.stringify(method)};
173
+ const PAYLOAD = \`${safePayload}\`;
174
+
175
+ const body = METHOD === 'GET'
176
+ ? null
177
+ : JSON.stringify({ ${JSON.stringify(param)}: PAYLOAD });
178
+
179
+ const headers = { 'Content-Type': 'application/json' };
180
+
181
+ const reqUrl = METHOD === 'GET'
182
+ ? URL_ + (URL_.includes('?') ? '&' : '?') + ${JSON.stringify(param)} + '=' + encodeURIComponent(PAYLOAD)
183
+ : URL_;
184
+
185
+ try {
186
+ const r = await fetch(reqUrl, { method: METHOD, headers, body, redirect: ${noFollow ? "'manual'" : "'follow'"} });
187
+ const text = await r.text();
188
+ const sig = ${_evidenceSignal(expect, payload)};
189
+ if (sig) {
190
+ process.stderr.write('PoC: exploit demonstrated — ' + sig + '\\n');
191
+ process.exit(0);
192
+ }
193
+ process.stderr.write('PoC: payload sent (status ' + r.status + '), no exploit evidence in response\\n');
194
+ process.exit(1);
195
+ } catch (e) {
196
+ process.stderr.write('PoC: error reaching target — ' + e.message + '\\n');
197
+ process.exit(2);
198
+ }
199
+ `;
200
+ }
201
+
202
+ function _evidenceSignal(expect, payload) {
203
+ // Produce a JS expression that returns a non-empty string on demonstrated
204
+ // exploit. Each template's evidence shape differs slightly; we infer from
205
+ // the `expect` and `payload` what to check for.
206
+ const exp = String(expect || '').toLowerCase();
207
+ if (exp.includes('marker_')) return `(text.match(/POC_MARKER_\\w+/)?.[0] ?? '')`;
208
+ if (exp.includes('script>') || exp.includes('unencoded')) return `(text.includes('<script>__POC_XSS') ? 'unencoded <script> in response' : '')`;
209
+ if (exp.includes('syntax error') || exp.includes('sql')) return `(/syntax error|psql|mysql|sqlite|near \"/i.test(text) ? 'sql error reflected' : '')`;
210
+ if (exp.includes('hostname')) return `(text && /^[a-z0-9\\-]+$/i.test(text.trim().slice(0, 64)) ? 'hostname-shaped response' : '')`;
211
+ if (exp.includes('location header')) return `(r.status >= 300 && r.status < 400 && r.headers.get('location')?.startsWith('https://poc-attacker.invalid') ? 'redirect to attacker host' : '')`;
212
+ if (exp.includes('pocmarker') || exp.includes('marker')) return `(text.includes('PocMarker') ? 'deserialization marker echoed' : '')`;
213
+ if (exp.includes('__poc_code_inj__')) return `(r.status === 200 ? 'code-eval accepted (200 with no error)' : '')`;
214
+ // Default: presence of the payload string itself reflected in response.
215
+ return `(text.includes(${JSON.stringify(String(payload).slice(0, 40))}) ? 'payload reflected' : '')`;
216
+ }
217
+
218
+ function _csrfPocNode(ctx) {
219
+ const url = ctx.url || 'http://localhost:3000/REPLACE-WITH-ENDPOINT';
220
+ return `// Demonstrates CSRF by making a state-changing request from an off-origin
221
+ // context with NO csrf token AND a cookie-only session — if it succeeds,
222
+ // the route is unprotected.
223
+ // Run: node poc.mjs
224
+ // Exit code: 0 = state-changing request accepted (vulnerable), 1 = rejected
225
+
226
+ const URL_ = ${JSON.stringify(url)};
227
+ const ATTACKER_ORIGIN = 'https://attacker.invalid';
228
+
229
+ try {
230
+ const r = await fetch(URL_, {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json', Origin: ATTACKER_ORIGIN, Referer: ATTACKER_ORIGIN + '/' },
233
+ body: JSON.stringify({ csrfMarker: 'forged' }),
234
+ redirect: 'manual',
235
+ });
236
+ if (r.status >= 200 && r.status < 300) {
237
+ process.stderr.write('PoC: route accepted forged-origin state-change (status ' + r.status + ')\\n');
238
+ process.exit(0);
239
+ }
240
+ process.stderr.write('PoC: route rejected (status ' + r.status + ') — possibly CSRF-protected\\n');
241
+ process.exit(1);
242
+ } catch (e) {
243
+ process.stderr.write('PoC: error reaching target — ' + e.message + '\\n');
244
+ process.exit(2);
245
+ }
246
+ `;
247
+ }
248
+
249
+ function _xxePocNode(ctx) {
250
+ const url = ctx.url || 'http://localhost:3000/REPLACE-WITH-ENDPOINT';
251
+ return `// Demonstrates XXE by submitting an XML body that references an external
252
+ // entity. We pin to a local sentinel file the verifier sandbox provides.
253
+ // Run: node poc.mjs
254
+ // Exit code: 0 = sentinel content appears in response (XXE confirmed), 1 = not
255
+
256
+ const URL_ = ${JSON.stringify(url)};
257
+ const SENTINEL = '/tmp/poc-xxe-sentinel-' + Math.random().toString(36).slice(2,8);
258
+
259
+ const XML = \`<?xml version="1.0"?>
260
+ <!DOCTYPE root [<!ENTITY xxe SYSTEM "file://\${SENTINEL}">]>
261
+ <root>&xxe;</root>\`;
262
+
263
+ try {
264
+ // Verifier sandbox writes SENTINEL with a known string before running this PoC.
265
+ const r = await fetch(URL_, { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: XML });
266
+ const text = await r.text();
267
+ if (text.includes('XXE_SENTINEL_CONTENT')) {
268
+ process.stderr.write('PoC: XXE confirmed — sentinel content leaked into response\\n');
269
+ process.exit(0);
270
+ }
271
+ process.stderr.write('PoC: payload accepted (status ' + r.status + '), no sentinel content in response\\n');
272
+ process.exit(1);
273
+ } catch (e) {
274
+ process.stderr.write('PoC: error reaching target — ' + e.message + '\\n');
275
+ process.exit(2);
276
+ }
277
+ `;
278
+ }
279
+
280
+ // ─── Public API ─────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Select a template for a finding. Returns the template object or null.
284
+ */
285
+ function pickTemplate(finding) {
286
+ if (!finding || typeof finding !== 'object') return null;
287
+ const vuln = String(finding.vuln || '');
288
+ const cwe = String(finding.cwe || '').toUpperCase();
289
+ for (const t of TEMPLATES) {
290
+ if (t.cwe === cwe) return t;
291
+ }
292
+ for (const t of TEMPLATES) {
293
+ if (t.vulnContains.some(substr => vuln.includes(substr))) return t;
294
+ }
295
+ return null;
296
+ }
297
+
298
+ // Premortem #12: infer the request-body/query/params key the handler reads
299
+ // from the actual finding evidence — NOT a hardcoded 'id'. Sources looked at:
300
+ // 1. finding.source.label (taint engine sets this to e.g. "req.body.host")
301
+ // 2. finding.whyFired.evidence.sourceSnippet (regex parses out req.body.X)
302
+ // 3. finding.snippet (last-resort: regex over the sink line itself)
303
+ // Premortem #12: infer the param key from the actual handler code.
304
+ // Strategy:
305
+ // 1. Re-read a wide window from the file (line-2 .. line+25). This
306
+ // survives detector snippet misattribution (premortem 2R-D).
307
+ // 2. Find every req.body.X / req.query.X / req.params.X / req.headers.X
308
+ // AND every request.json["X"]/form.get("X")/args.get("X") match in
309
+ // the window, with the line on which it appears.
310
+ // 3. Find every "sink" keyword (exec, eval, query, system, spawn,
311
+ // Runtime.exec, fs.readFile, render, redirect) and its line.
312
+ // 4. Return the param whose line is closest to a sink keyword. Ties
313
+ // go to body > query > params (HTTP semantics: body is the most
314
+ // user-controlled vector). If we have nothing, fall back to the
315
+ // detector's snippet/source.label so we still produce SOMETHING.
316
+ const _SINK_KEYWORDS = /\b(?:exec|eval|spawn|spawnSync|execSync|system|popen|query|raw|readFile|readFileSync|writeFile|redirect|render|innerHTML|setAttribute|location|open|require)\b/;
317
+ const _PARAM_RES = [
318
+ { re: /\breq(?:uest)?\.body\.([A-Za-z_$][\w$]*)/g, score: 3 },
319
+ { re: /\breq(?:uest)?\.body\[["']([^"']+)["']\]/g, score: 3 },
320
+ { re: /\breq(?:uest)?\.query\.([A-Za-z_$][\w$]*)/g, score: 2 },
321
+ { re: /\breq(?:uest)?\.query\[["']([^"']+)["']\]/g, score: 2 },
322
+ { re: /\breq(?:uest)?\.params\.([A-Za-z_$][\w$]*)/g, score: 1 },
323
+ { re: /\breq(?:uest)?\.headers\.([A-Za-z_$][\w$]*)/g, score: 1 },
324
+ { re: /\brequest\.(?:json|form|args)(?:\.get)?\(["']([^"']+)["']\)/g, score: 3 },
325
+ { re: /\bctx\.request\.body\.([A-Za-z_$][\w$]*)/g, score: 3 },
326
+ ];
327
+ function _inferParamKey(finding, fileContents) {
328
+ // Premortem #12: some detectors set f.sink.line / f.source.line instead of
329
+ // f.line. Try all locations so window analysis works regardless of which
330
+ // detector emitted the finding (PoC runs before the normalizer collapses).
331
+ const effectiveLine = finding.line || finding.sink?.line || finding.source?.line || 0;
332
+ const effectiveFile = finding.file || finding.sink?.file || finding.source?.file || null;
333
+ if (process.env.AGENTIC_SECURITY_POC_DEBUG === '1') {
334
+ process.stderr.write(`[poc-debug] file=${effectiveFile} line=${effectiveLine} fc=${fileContents ? Object.keys(fileContents).length : 0}\n`);
335
+ }
336
+ // First-pass: file-window analysis. This is the most reliable.
337
+ if (fileContents && effectiveFile && effectiveLine) {
338
+ const code = fileContents[effectiveFile];
339
+ if (typeof code === 'string') {
340
+ const lines = code.split('\n');
341
+ const idx = (effectiveLine || 1) - 1;
342
+ const lo = Math.max(0, idx - 2);
343
+ const hi = Math.min(lines.length, idx + 26);
344
+ // Find sink keyword lines in window (relative to window start).
345
+ const sinkLines = [];
346
+ for (let i = lo; i < hi; i++) {
347
+ if (_SINK_KEYWORDS.test(lines[i])) sinkLines.push(i);
348
+ }
349
+ // Find param matches in window.
350
+ const matches = []; // { name, score, line }
351
+ for (let i = lo; i < hi; i++) {
352
+ const line = lines[i];
353
+ for (const { re, score } of _PARAM_RES) {
354
+ re.lastIndex = 0;
355
+ let m;
356
+ while ((m = re.exec(line)) !== null) {
357
+ if (m[1]) matches.push({ name: m[1], score, line: i });
358
+ }
359
+ }
360
+ }
361
+ if (matches.length) {
362
+ // Rank by (closest-to-sink, then higher base score, then earliest in
363
+ // file). When no sink line is detected, fall back to score+order.
364
+ const distTo = (ln) => sinkLines.length
365
+ ? Math.min(...sinkLines.map(s => Math.abs(s - ln)))
366
+ : 999;
367
+ matches.sort((a, b) => {
368
+ const da = distTo(a.line), db = distTo(b.line);
369
+ if (da !== db) return da - db;
370
+ if (a.score !== b.score) return b.score - a.score;
371
+ return a.line - b.line;
372
+ });
373
+ return matches[0].name;
374
+ }
375
+ }
376
+ }
377
+ // Fallbacks: snippet / source label (may be misattributed; LAST RESORT).
378
+ const candidates = [];
379
+ if (finding.source?.label) candidates.push(String(finding.source.label));
380
+ const wf = finding.whyFired && finding.whyFired.evidence;
381
+ if (wf?.sinkSnippet) candidates.push(String(wf.sinkSnippet));
382
+ if (finding.snippet) candidates.push(String(finding.snippet));
383
+ for (const c of candidates) {
384
+ for (const { re } of _PARAM_RES) {
385
+ re.lastIndex = 0;
386
+ const m = re.exec(c);
387
+ if (m && m[1]) return m[1];
388
+ }
389
+ }
390
+ return null;
391
+ }
392
+
393
+ /**
394
+ * Resolve the HTTP endpoint context for a finding from the project's
395
+ * discovered route list. Returns { url, method, param } or null.
396
+ */
397
+ function endpointFor(finding, routes, fileContents) {
398
+ if (!Array.isArray(routes) || routes.length === 0) return null;
399
+ // Match by file + line proximity.
400
+ const fp = finding.file || finding.sink?.file;
401
+ const ln = finding.line || finding.sink?.line || 0;
402
+ if (!fp) return null;
403
+ let best = null;
404
+ let bestDist = Infinity;
405
+ for (const r of routes) {
406
+ if (r.file !== fp) continue;
407
+ const dist = Math.abs((r.line || 0) - ln);
408
+ if (dist < bestDist) { bestDist = dist; best = r; }
409
+ }
410
+ if (!best) return null;
411
+ // Harness-engineering note (post-derived): when the deterministic inference
412
+ // fails, surface the uncertainty instead of falling back to a generic key.
413
+ // A PoC that posts to 'input' against a handler that reads 'host' is a
414
+ // silent failure — the scanner emitted something, the verifier ran it, and
415
+ // both reported "no exploit demonstrated" when the actual problem was that
416
+ // we asked the wrong question. Better to mark the PoC as low-confidence so
417
+ // downstream (verifier, regression-test-gen, reports) can route accordingly.
418
+ const inferred = _inferParamKey(finding, fileContents);
419
+ const fromSourceVar = finding.source?.variable;
420
+ const paramKey = inferred || fromSourceVar || 'input';
421
+ const paramKeyConfidence =
422
+ inferred ? 'high' // from real file-window analysis
423
+ : fromSourceVar ? 'medium' // detector hinted; might be stale
424
+ : 'low'; // pure default — PoC likely won't fire
425
+ return {
426
+ url: 'http://localhost:3000' + (best.path || '/REPLACE-WITH-ENDPOINT'),
427
+ method: best.method || 'POST',
428
+ param: paramKey,
429
+ paramKeyConfidence,
430
+ paramKeyInferred: !!inferred,
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Generate a PoC object for a finding. Returns:
436
+ * { lang, code, runHint, kind, cwe } when a template matches.
437
+ * null when no template covers this CWE family in v1.
438
+ */
439
+ export function generatePoc(finding, { routes = [], fileContents = null } = {}) {
440
+ const t = pickTemplate(finding);
441
+ if (!t) return null;
442
+ const ctx = endpointFor(finding, routes, fileContents) || {};
443
+ let code;
444
+ try { code = t.render(finding, ctx); }
445
+ catch (e) {
446
+ // Fail-closed: an exception in a template never crashes the scan.
447
+ return null;
448
+ }
449
+ if (!code || typeof code !== 'string' || code.length < 50) return null;
450
+ return {
451
+ lang: t.lang,
452
+ kind: t.kind,
453
+ cwe: t.cwe,
454
+ family: t.family,
455
+ runHint: t.lang === 'node' ? 'node poc.mjs' :
456
+ t.lang === 'python' ? 'python3 poc.py' :
457
+ t.lang === 'java' ? 'javac PoC.java && java PoC' :
458
+ null,
459
+ code,
460
+ // Surface the deterministic-inference confidence on the emitted PoC so
461
+ // the verifier and regression-test-gen can refuse to run uncertain ones.
462
+ paramKey: ctx.param || null,
463
+ paramKeyConfidence: ctx.paramKeyConfidence || 'low',
464
+ paramKeyInferred: !!ctx.paramKeyInferred,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Annotate findings with PoCs. Used by the engine's emit pipeline.
470
+ *
471
+ * Sets `f.poc` to either the generated PoC object or `null` (explicit "no
472
+ * template covers this CWE family in v1"). Never throws.
473
+ */
474
+ export function annotatePocs(findings, opts = {}) {
475
+ const routes = opts.routes || [];
476
+ const fileContents = opts.fileContents || null;
477
+ if (!Array.isArray(findings)) return;
478
+ for (const f of findings) {
479
+ if (!f || typeof f !== 'object') continue;
480
+ f.poc = generatePoc(f, { routes, fileContents });
481
+ }
482
+ }
483
+
484
+ // Surface table of CWE → family → primary lang → expected PoC presence for
485
+ // the reporter to render coverage in the "what we can/can't prove" section.
486
+ export function pocCoverageSummary(findings) {
487
+ const summary = { withPoc: 0, withoutPoc: 0, byFamily: {} };
488
+ for (const f of findings) {
489
+ if (!f || typeof f !== 'object') continue;
490
+ const fam = f.family || 'unknown';
491
+ summary.byFamily[fam] ||= { withPoc: 0, withoutPoc: 0 };
492
+ if (f.poc) { summary.withPoc++; summary.byFamily[fam].withPoc++; }
493
+ else { summary.withoutPoc++; summary.byFamily[fam].withoutPoc++; }
494
+ }
495
+ return summary;
496
+ }
497
+
498
+ // For tests and the no-dead-modules check — surfaces template count.
499
+ export function _templateCount() { return TEMPLATES.length; }
500
+ export const _knownCwes = TEMPLATES.map(t => t.cwe);
@@ -0,0 +1,174 @@
1
+ // Policy-as-code gate (FR-SDLC-9).
2
+ //
3
+ // Today's CI gate is `--fail-on <severity>`. That's coarse. Customers want
4
+ // to write nuanced rules: "fail if any sql-injection finding has
5
+ // confidence ≥ 0.8 AND the file is under src/api/", or "fail if total
6
+ // exploitability score across critical findings exceeds 5".
7
+ //
8
+ // We support two modes:
9
+ //
10
+ // 1. EXTERNAL OPA: if the `opa` binary is on PATH and `--policy <file.rego>`
11
+ // is supplied, we shell out to `opa eval -d <file> -i <findings.json>
12
+ // "data.<package>.deny"`. This is the right answer for customers who
13
+ // already use OPA elsewhere.
14
+ //
15
+ // 2. EMBEDDED MINI: when no opa binary is available, fall back to a tiny
16
+ // DSL that's a strict subset of rego. Rules read top-level
17
+ // `package`/`deny` blocks; each `deny` is a JS-evaluable expression
18
+ // over findings[]. This lets the v1 ship without an external binary
19
+ // dep while documenting the upgrade path.
20
+
21
+ import * as fs from 'node:fs';
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ // ─── External OPA ─────────────────────────────────────────────────────────
25
+
26
+ function _haveOpa() {
27
+ try {
28
+ const r = spawnSync('opa', ['version'], { stdio: 'ignore', timeout: 3000 });
29
+ return r.status === 0;
30
+ } catch { return false; }
31
+ }
32
+
33
+ function _runOpa(policyFile, findingsJsonPath, packageName) {
34
+ const r = spawnSync('opa', [
35
+ 'eval', '-d', policyFile, '-i', findingsJsonPath,
36
+ `data.${packageName}.deny`,
37
+ ], { encoding: 'utf8', timeout: 10_000 });
38
+ if (r.error) return { ok: false, reason: `opa-error:${r.error.code || r.error.message}` };
39
+ if (r.status !== 0) return { ok: false, reason: `opa-exit:${r.status}`, stderr: r.stderr };
40
+ try {
41
+ const parsed = JSON.parse(r.stdout);
42
+ const result = parsed.result?.[0]?.expressions?.[0]?.value;
43
+ return { ok: true, denials: Array.isArray(result) ? result : [] };
44
+ } catch (e) {
45
+ return { ok: false, reason: `opa-output-parse:${e.message}` };
46
+ }
47
+ }
48
+
49
+ // ─── Embedded mini DSL ────────────────────────────────────────────────────
50
+ //
51
+ // Rego is too big to reimplement. We support a tiny shape:
52
+ //
53
+ // # POLICY: agentic-security policy-gate v1
54
+ // deny[msg] {
55
+ // finding := input.findings[_]
56
+ // finding.severity == "critical"
57
+ // msg := sprintf("critical finding: %v at %v", [finding.vuln, finding.file])
58
+ // }
59
+ //
60
+ // Parser strategy: extract each `deny[msg] { ... }` block; translate the
61
+ // body to a JS predicate. The grammar we accept is:
62
+ //
63
+ // - `<lhs> == <value>` / `<lhs> != <value>` / `<lhs> > <num>` / `<lhs> < <num>`
64
+ // - `<lhs>` references `finding.<field>` or `input.<field>`
65
+ // - `msg := "..."` or `msg := sprintf("...", [args])` — the msg literal
66
+ // - newlines + `;` as separators
67
+ //
68
+ // Anything more complex requires the external OPA binary.
69
+
70
+ function _parseEmbedded(policyText) {
71
+ const blocks = [];
72
+ // Match each `deny[NAME] { ... }` block (or `deny { ... }`).
73
+ const blockRe = /\bdeny(?:\s*\[\s*(\w+)\s*\])?\s*\{([\s\S]*?)\}/g;
74
+ let m;
75
+ while ((m = blockRe.exec(policyText))) {
76
+ const body = m[2];
77
+ const conditions = [];
78
+ let msgExpr = `"policy violation"`;
79
+ for (const line of body.split(/[\n;]/)) {
80
+ const ln = line.trim();
81
+ if (!ln || ln.startsWith('#')) continue;
82
+ // Assignment: `<id> := <expr>`
83
+ const asn = ln.match(/^(\w+)\s*:=\s*(.+)$/);
84
+ if (asn && asn[1] !== 'msg') continue; // skip non-msg assignments
85
+ if (asn && asn[1] === 'msg') { msgExpr = asn[2].trim(); continue; }
86
+ // Comparison: `finding.<field> <op> <value>`
87
+ const cmp = ln.match(/^(finding|input)\.([a-zA-Z_][\w.]*)\s*(==|!=|<=|>=|<|>)\s*(.+)$/);
88
+ if (!cmp) continue;
89
+ const [, scope, field, op, valueRaw] = cmp;
90
+ let value = valueRaw.trim();
91
+ if (/^".*"$/.test(value)) value = JSON.stringify(value.slice(1, -1));
92
+ conditions.push({ scope, field, op, value });
93
+ }
94
+ blocks.push({ conditions, msgExpr });
95
+ }
96
+ return blocks;
97
+ }
98
+
99
+ function _evalBlock(block, finding) {
100
+ for (const c of block.conditions) {
101
+ const lhs = _resolvePath(finding, c.field);
102
+ let rhs;
103
+ try { rhs = JSON.parse(c.value); }
104
+ catch { rhs = c.value.replace(/^"|"$/g, ''); }
105
+ if (c.op === '==' && lhs !== rhs) return null;
106
+ if (c.op === '!=' && lhs === rhs) return null;
107
+ if (c.op === '>' && !(Number(lhs) > Number(rhs))) return null;
108
+ if (c.op === '<' && !(Number(lhs) < Number(rhs))) return null;
109
+ if (c.op === '>=' && !(Number(lhs) >= Number(rhs))) return null;
110
+ if (c.op === '<=' && !(Number(lhs) <= Number(rhs))) return null;
111
+ }
112
+ return _renderMsg(block.msgExpr, finding);
113
+ }
114
+
115
+ function _resolvePath(obj, dotPath) {
116
+ return dotPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
117
+ }
118
+
119
+ function _renderMsg(expr, finding) {
120
+ // Strip outer quotes if string literal.
121
+ const m = expr.match(/^["'](.+)["']$/);
122
+ if (m) return m[1];
123
+ // sprintf shape: sprintf("...", [a, b])
124
+ const sf = expr.match(/^sprintf\s*\(\s*"([^"]+)"\s*,\s*\[(.+)\]\s*\)$/);
125
+ if (sf) {
126
+ const fmt = sf[1];
127
+ const args = sf[2].split(',').map(s => _resolvePath(finding, s.trim().replace(/^finding\./, '')));
128
+ let i = 0;
129
+ return fmt.replace(/%v/g, () => String(args[i++] ?? ''));
130
+ }
131
+ return expr;
132
+ }
133
+
134
+ function _runEmbedded(policyText, findings) {
135
+ const blocks = _parseEmbedded(policyText);
136
+ const denials = [];
137
+ for (const f of findings) {
138
+ for (const b of blocks) {
139
+ const msg = _evalBlock(b, f);
140
+ if (msg) denials.push(msg);
141
+ }
142
+ }
143
+ return { ok: true, denials };
144
+ }
145
+
146
+ // ─── Public API ────────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Evaluate a policy file against the findings list.
150
+ * Returns { ok, denials, runner } — denials is an array of human-readable
151
+ * strings (one per violation). When denials.length > 0, the gate fails.
152
+ */
153
+ export function evaluatePolicy(policyPath, findings, opts = {}) {
154
+ if (!policyPath || !fs.existsSync(policyPath)) {
155
+ return { ok: false, reason: 'policy-file-missing' };
156
+ }
157
+ const policyText = fs.readFileSync(policyPath, 'utf8');
158
+ const useExternal = !opts.embeddedOnly && _haveOpa();
159
+ if (useExternal) {
160
+ // Write findings to a temp file the opa binary reads.
161
+ const tmp = `/tmp/as-policy-${Date.now()}.json`;
162
+ fs.writeFileSync(tmp, JSON.stringify({ findings }));
163
+ const pkgMatch = policyText.match(/^\s*package\s+([\w.]+)/m);
164
+ const pkg = pkgMatch ? pkgMatch[1] : 'main';
165
+ const r = _runOpa(policyPath, tmp, pkg);
166
+ try { fs.unlinkSync(tmp); } catch {}
167
+ if (r.ok) return { ...r, runner: 'opa' };
168
+ // Fall through to embedded on opa error.
169
+ }
170
+ const r = _runEmbedded(policyText, findings);
171
+ return { ...r, runner: 'embedded' };
172
+ }
173
+
174
+ export const _internals = { _parseEmbedded, _evalBlock, _runEmbedded };