@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,204 @@
1
+ // Deployment-platform security checklist.
2
+ //
3
+ // Detects which hosting platform a project targets from config files and
4
+ // returns platform-specific security findings: missing headers, public previews,
5
+ // no health checks, unsafe infra settings.
6
+ //
7
+ // Platforms: Vercel, Railway, Fly.io, Render, Netlify, AWS Amplify, Cloudflare
8
+
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+
12
+ function _readJson(filePath) {
13
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
14
+ }
15
+ function _readText(filePath) {
16
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
17
+ }
18
+ function _exists(filePath) {
19
+ try { fs.accessSync(filePath); return true; } catch { return false; }
20
+ }
21
+
22
+ // ── Vercel ────────────────────────────────────────────────────────────────────
23
+
24
+ function checkVercel(root) {
25
+ const cfgPath = path.join(root, 'vercel.json');
26
+ const cfg = _readJson(cfgPath);
27
+ const findings = [];
28
+
29
+ // Check next.config.js / next.config.ts for security headers
30
+ const nextCfgPath = ['next.config.js','next.config.ts','next.config.mjs'].map(f=>path.join(root,f)).find(_exists);
31
+ const nextCfg = nextCfgPath ? _readText(nextCfgPath) : null;
32
+
33
+ const hasSecurityHeaders = (cfg && cfg.headers && JSON.stringify(cfg.headers).includes('X-Frame-Options')) ||
34
+ (nextCfg && /X-Frame-Options|Content-Security-Policy|X-Content-Type-Options/.test(nextCfg));
35
+
36
+ if (!hasSecurityHeaders) {
37
+ findings.push({
38
+ id: `deploy-platform:VERCEL_NO_SECURITY_HEADERS:${cfgPath || 'vercel.json'}:1`,
39
+ title: 'Vercel deployment missing security headers',
40
+ severity: 'medium',
41
+ file: cfgPath || 'vercel.json',
42
+ line: 1,
43
+ description: 'No X-Frame-Options, Content-Security-Policy, or X-Content-Type-Options headers are configured. These headers block clickjacking, MIME sniffing, and XSS attacks at the CDN layer for zero performance cost.',
44
+ remediation: 'Add a `headers` array to vercel.json or a `headers()` function in next.config.js:\n { key: "X-Frame-Options", value: "DENY" }\n { key: "X-Content-Type-Options", value: "nosniff" }\n { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }',
45
+ cwe: 'CWE-693',
46
+ });
47
+ }
48
+
49
+ // Preview deployments expose the app publicly by default
50
+ const hasPasswordProtection = cfg && (cfg.password || (cfg.passwordProtection));
51
+ if (!hasPasswordProtection && _exists(path.join(root, '.vercel'))) {
52
+ findings.push({
53
+ id: `deploy-platform:VERCEL_PUBLIC_PREVIEWS:vercel.json:1`,
54
+ title: 'Vercel preview deployments are publicly accessible',
55
+ severity: 'low',
56
+ file: 'vercel.json',
57
+ line: 1,
58
+ description: 'Preview deployments on Vercel are publicly accessible by default. Staging data, admin interfaces, and unreleased features are visible to anyone with the URL.',
59
+ remediation: 'Enable Vercel\'s Deployment Protection (passwordProtection or Vercel Authentication) for preview branches in your project settings, or add `"protection": { "deploymentType": "all" }` to vercel.json (Vercel Pro).',
60
+ cwe: 'CWE-284',
61
+ });
62
+ }
63
+
64
+ return findings;
65
+ }
66
+
67
+ // ── Railway ───────────────────────────────────────────────────────────────────
68
+
69
+ function checkRailway(root) {
70
+ const cfgPath = path.join(root, 'railway.json');
71
+ const tomlPath = path.join(root, 'railway.toml');
72
+ const cfg = _readJson(cfgPath);
73
+ const tomlRaw = _readText(tomlPath);
74
+ const findings = [];
75
+
76
+ const hasHealthCheck = (cfg && (cfg.deploy?.healthcheckPath || cfg.healthcheck)) ||
77
+ (tomlRaw && /healthcheck/.test(tomlRaw));
78
+
79
+ if (!hasHealthCheck && (cfg || tomlRaw)) {
80
+ findings.push({
81
+ id: `deploy-platform:RAILWAY_NO_HEALTHCHECK:${cfgPath || tomlPath}:1`,
82
+ title: 'Railway deployment missing health check',
83
+ severity: 'low',
84
+ file: cfgPath || tomlPath || 'railway.json',
85
+ line: 1,
86
+ description: 'No health check endpoint is configured. Without one, Railway cannot detect a crashed or deadlocked process and will continue routing traffic to an unhealthy instance.',
87
+ remediation: 'Add a health check to railway.json:\n { "deploy": { "healthcheckPath": "/api/health", "healthcheckTimeout": 10 } }\nAnd implement a GET /api/health endpoint that returns 200 when the app is ready.',
88
+ cwe: 'CWE-400',
89
+ });
90
+ }
91
+
92
+ return findings;
93
+ }
94
+
95
+ // ── Fly.io ────────────────────────────────────────────────────────────────────
96
+
97
+ function checkFly(root) {
98
+ const cfgPath = path.join(root, 'fly.toml');
99
+ const raw = _readText(cfgPath);
100
+ if (!raw) return [];
101
+ const findings = [];
102
+
103
+ // Check for services exposed without auto_stop_machines
104
+ if (/\[services\]/.test(raw) && !/auto_stop_machines\s*=\s*true/.test(raw)) {
105
+ findings.push({
106
+ id: `deploy-platform:FLY_NO_SCALE_TO_ZERO:fly.toml:1`,
107
+ title: 'Fly.io app keeps machines running indefinitely',
108
+ severity: 'low',
109
+ file: 'fly.toml',
110
+ line: 1,
111
+ description: 'auto_stop_machines is not enabled. Idle machines continue running and accumulating cost, and a compromised idle machine persists longer than necessary.',
112
+ remediation: 'Set `auto_stop_machines = true` and `auto_start_machines = true` in the [http_service] section of fly.toml to enable scale-to-zero.',
113
+ cwe: 'CWE-400',
114
+ });
115
+ }
116
+
117
+ // Check for HTTP→HTTPS redirect
118
+ if (!/force_https\s*=\s*true/.test(raw)) {
119
+ findings.push({
120
+ id: `deploy-platform:FLY_NO_HTTPS_REDIRECT:fly.toml:1`,
121
+ title: 'Fly.io app does not enforce HTTPS',
122
+ severity: 'medium',
123
+ file: 'fly.toml',
124
+ line: 1,
125
+ description: 'force_https is not set. HTTP requests are served unencrypted, exposing session cookies and auth tokens to network interception.',
126
+ remediation: 'Add `force_https = true` to the [[services]] section in fly.toml.',
127
+ cwe: 'CWE-319',
128
+ });
129
+ }
130
+
131
+ return findings;
132
+ }
133
+
134
+ // ── Netlify ───────────────────────────────────────────────────────────────────
135
+
136
+ function checkNetlify(root) {
137
+ const cfgPath = path.join(root, 'netlify.toml');
138
+ const raw = _readText(cfgPath);
139
+ if (!raw) return [];
140
+ const findings = [];
141
+
142
+ if (!/X-Frame-Options|Content-Security-Policy/.test(raw)) {
143
+ findings.push({
144
+ id: `deploy-platform:NETLIFY_NO_SECURITY_HEADERS:netlify.toml:1`,
145
+ title: 'Netlify deployment missing security headers',
146
+ severity: 'medium',
147
+ file: 'netlify.toml',
148
+ line: 1,
149
+ description: 'No security headers (X-Frame-Options, CSP) are configured in netlify.toml. These are free protections against clickjacking and XSS.',
150
+ remediation: 'Add to netlify.toml:\n [[headers]]\n for = "/*"\n [headers.values]\n X-Frame-Options = "DENY"\n X-Content-Type-Options = "nosniff"\n Referrer-Policy = "strict-origin-when-cross-origin"',
151
+ cwe: 'CWE-693',
152
+ });
153
+ }
154
+
155
+ return findings;
156
+ }
157
+
158
+ // ── Cloudflare ────────────────────────────────────────────────────────────────
159
+
160
+ function checkCloudflare(root) {
161
+ const wranglerPath = ['wrangler.toml','wrangler.json'].map(f=>path.join(root,f)).find(_exists);
162
+ if (!wranglerPath) return [];
163
+ const raw = _readText(wranglerPath);
164
+ const findings = [];
165
+
166
+ // Workers with no compatibility_date are using legacy APIs
167
+ if (raw && !/compatibility_date/.test(raw)) {
168
+ findings.push({
169
+ id: `deploy-platform:CF_NO_COMPAT_DATE:${wranglerPath}:1`,
170
+ title: 'Cloudflare Worker missing compatibility_date',
171
+ severity: 'low',
172
+ file: wranglerPath,
173
+ line: 1,
174
+ description: 'Without compatibility_date, your Worker uses Cloudflare\'s oldest runtime behaviour, which may include known-insecure APIs.',
175
+ remediation: `Set compatibility_date = "${new Date().toISOString().slice(0,10)}" in wrangler.toml to opt into the latest, most secure runtime semantics.`,
176
+ cwe: 'CWE-1104',
177
+ });
178
+ }
179
+
180
+ return findings;
181
+ }
182
+
183
+ // ── Entry point ───────────────────────────────────────────────────────────────
184
+
185
+ function scanDeployPlatform(scanRoot) {
186
+ const findings = [];
187
+ if (!scanRoot) return findings;
188
+
189
+ const vercelIndicators = ['vercel.json', '.vercel', 'next.config.js', 'next.config.ts', 'next.config.mjs'];
190
+ const railwayIndicators = ['railway.json', 'railway.toml'];
191
+ const flyIndicators = ['fly.toml'];
192
+ const netlifyIndicators = ['netlify.toml'];
193
+ const cfIndicators = ['wrangler.toml', 'wrangler.json'];
194
+
195
+ if (vercelIndicators.some(f => _exists(path.join(scanRoot, f)))) findings.push(...checkVercel(scanRoot));
196
+ if (railwayIndicators.some(f => _exists(path.join(scanRoot, f)))) findings.push(...checkRailway(scanRoot));
197
+ if (flyIndicators.some(f => _exists(path.join(scanRoot, f)))) findings.push(...checkFly(scanRoot));
198
+ if (netlifyIndicators.some(f => _exists(path.join(scanRoot, f)))) findings.push(...checkNetlify(scanRoot));
199
+ if (cfIndicators.some(f => _exists(path.join(scanRoot, f)))) findings.push(...checkCloudflare(scanRoot));
200
+
201
+ return findings;
202
+ }
203
+
204
+ export { scanDeployPlatform };
@@ -0,0 +1,61 @@
1
+ // FR-ADV-6 — Adversarial fuzzing of detectors.
2
+ //
3
+ // Take a known-vuln fixture, mutate it across N strategies (using the
4
+ // adversarial-self-test mutator), and ask "does the scanner still catch
5
+ // every mutation?" Mutations that escape detection become regression fixtures.
6
+ //
7
+ // This module is the bench-side orchestrator. The mutation library lives in
8
+ // adversarial-self-test.js. The runner here is purely structural — it does
9
+ // NOT execute the scanner against the mutated text; that's the caller's
10
+ // responsibility (the runner has access to the scanner; this module is pure
11
+ // data).
12
+ //
13
+ // Public API:
14
+ // prepareFuzzCorpus(fixtures) → returns the mutation matrix ready to run
15
+ // recordOutcome(matrixEntry, detected) → folds result back into matrix
16
+ // summarize(matrix) → per-family escape rate
17
+
18
+ import { mutateSnippet } from './adversarial-self-test.js';
19
+
20
+ export function prepareFuzzCorpus(fixtures) {
21
+ if (!Array.isArray(fixtures)) return [];
22
+ const out = [];
23
+ for (const fx of fixtures) {
24
+ if (!fx || !fx.family || !fx.code) continue;
25
+ const mutations = mutateSnippet(fx.code, fx.family);
26
+ for (let i = 0; i < mutations.length; i++) {
27
+ out.push({
28
+ fixtureId: fx.id || `${fx.family}-${fx.file || 'inline'}`,
29
+ family: fx.family,
30
+ mutationIndex: i,
31
+ mutationStrategy: `mut-${i + 1}`,
32
+ mutatedCode: mutations[i],
33
+ detected: null,
34
+ });
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export function recordOutcome(entry, detected) {
41
+ if (!entry || typeof entry !== 'object') return;
42
+ entry.detected = !!detected;
43
+ }
44
+
45
+ export function summarize(matrix) {
46
+ if (!Array.isArray(matrix)) return { perFamily: {}, totalEscaped: 0 };
47
+ const perFamily = {};
48
+ let totalEscaped = 0;
49
+ for (const e of matrix) {
50
+ if (e.detected === null) continue;
51
+ if (!perFamily[e.family]) perFamily[e.family] = { run: 0, escaped: 0, detected: 0 };
52
+ perFamily[e.family].run++;
53
+ if (e.detected) perFamily[e.family].detected++;
54
+ else { perFamily[e.family].escaped++; totalEscaped++; }
55
+ }
56
+ for (const k of Object.keys(perFamily)) {
57
+ const v = perFamily[k];
58
+ v.escapeRate = v.run ? Number((v.escaped / v.run).toFixed(2)) : 0;
59
+ }
60
+ return { perFamily, totalEscaped };
61
+ }
@@ -0,0 +1,99 @@
1
+ // Deterministic mode + rule version lockfile.
2
+ //
3
+ // `--deterministic` makes scan output byte-stable for the same input:
4
+ // - stable-sorts every findings array by (file, line, vuln, id)
5
+ // - strips timing/scanId variance from meta
6
+ // - sets AGENTIC_SECURITY_DETERMINISTIC=1 so other modules (network calls,
7
+ // KEV cache invalidation, EPSS fetches, blast-radius timestamps) can opt
8
+ // into deterministic behavior
9
+ //
10
+ // `agentic-security rules lock` writes .agentic-security/rules.lock.json
11
+ // pinning the active rule-pack hash + scanner version. Subsequent scans with
12
+ // `--deterministic` verify the lock matches before running.
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import * as crypto from 'node:crypto';
17
+ import { PACKS } from './rule-packs.js';
18
+
19
+ export const SCANNER_VERSION = '0.39.2';
20
+ const LOCK_FILE = 'rules.lock.json';
21
+
22
+ // Hash the union of pack CWE sets — stable across runs as long as PACKS is unchanged.
23
+ export function computeRulePackHash() {
24
+ const sorted = Object.entries(PACKS)
25
+ .map(([name, p]) => [name, [...p.cwes].sort()])
26
+ .sort(([a], [b]) => a.localeCompare(b));
27
+ return crypto.createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 16);
28
+ }
29
+
30
+ export function buildLockfile() {
31
+ return {
32
+ schema: 1,
33
+ scannerVersion: SCANNER_VERSION,
34
+ rulePackHash: computeRulePackHash(),
35
+ rulePacks: Object.fromEntries(
36
+ Object.entries(PACKS).map(([n, p]) => [n, { cweCount: p.cwes.length }])
37
+ ),
38
+ generatedAt: new Date().toISOString(),
39
+ };
40
+ }
41
+
42
+ export function writeLockfile(scanRoot) {
43
+ const dir = path.join(scanRoot, '.agentic-security');
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const fp = path.join(dir, LOCK_FILE);
46
+ const lock = buildLockfile();
47
+ fs.writeFileSync(fp, JSON.stringify(lock, null, 2));
48
+ return { path: fp, lock };
49
+ }
50
+
51
+ export function readLockfile(scanRoot) {
52
+ const fp = path.join(scanRoot, '.agentic-security', LOCK_FILE);
53
+ if (!fs.existsSync(fp)) return null;
54
+ try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
55
+ }
56
+
57
+ // Verify the current engine matches a previously-written lockfile.
58
+ // Returns { ok, mismatches: [...] }.
59
+ export function verifyLockfile(scanRoot) {
60
+ const lock = readLockfile(scanRoot);
61
+ if (!lock) return { ok: false, mismatches: ['no lockfile present'] };
62
+ const mismatches = [];
63
+ if (lock.scannerVersion !== SCANNER_VERSION) {
64
+ mismatches.push(`scanner version: lock=${lock.scannerVersion} current=${SCANNER_VERSION}`);
65
+ }
66
+ const currentHash = computeRulePackHash();
67
+ if (lock.rulePackHash !== currentHash) {
68
+ mismatches.push(`rule-pack hash: lock=${lock.rulePackHash} current=${currentHash}`);
69
+ }
70
+ return { ok: mismatches.length === 0, mismatches };
71
+ }
72
+
73
+ // Stable-sort all findings arrays in a scan in place. Stable across runs.
74
+ function sortFn(a, b) {
75
+ const af = (a.file || ''), bf = (b.file || '');
76
+ if (af !== bf) return af.localeCompare(bf);
77
+ const al = a.line || 0, bl = b.line || 0;
78
+ if (al !== bl) return al - bl;
79
+ const av = (a.vuln || a.title || ''), bv = (b.vuln || b.title || '');
80
+ if (av !== bv) return av.localeCompare(bv);
81
+ return String(a.id || '').localeCompare(String(b.id || ''));
82
+ }
83
+
84
+ export function makeDeterministic(scan, meta) {
85
+ for (const k of ['findings', 'secrets', 'logicVulns', 'supplyChain']) {
86
+ if (Array.isArray(scan[k])) scan[k].sort(sortFn);
87
+ }
88
+ if (meta) {
89
+ meta.scanId = 'deterministic';
90
+ meta.startedAt = '1970-01-01T00:00:00.000Z';
91
+ meta.durationMs = 0;
92
+ meta.deterministic = true;
93
+ }
94
+ return { scan, meta };
95
+ }
96
+
97
+ export function isDeterministic() {
98
+ return process.env.AGENTIC_SECURITY_DETERMINISTIC === '1';
99
+ }
@@ -0,0 +1,165 @@
1
+ // 0.6.0 Feat-4: Drift report — diff two scans (or two refs) and surface
2
+ // posture changes: new/removed endpoints, new/removed deps, lost or added
3
+ // auth boundaries, severity deltas, data-class deltas.
4
+ //
5
+ // Inputs are two scan JSONs (the result of toJSON or runFullScan). The output
6
+ // is a structured object that the HTML report renders as a "Drift" tab and the
7
+ // PR-comment script renders as a Markdown summary.
8
+
9
+ function _routeKey(r) { return `${r.method || 'ANY'} ${r.path || '(file)'} @ ${r.file}:${r.line}`; }
10
+ function _depKey(c) { return `${c.ecosystem}:${c.name}@${c.version}`; }
11
+ function _findingKey(f) { return `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
12
+
13
+ function _toMap(arr, keyFn) {
14
+ const m = new Map();
15
+ for (const x of arr || []) m.set(keyFn(x), x);
16
+ return m;
17
+ }
18
+
19
+ function _diffSets(a, b, keyFn) {
20
+ const ma = _toMap(a, keyFn), mb = _toMap(b, keyFn);
21
+ const added = [], removed = [], unchanged = [];
22
+ for (const [k, v] of mb) (ma.has(k) ? unchanged : added).push(v);
23
+ for (const [k, v] of ma) if (!mb.has(k)) removed.push(v);
24
+ return { added, removed, unchanged };
25
+ }
26
+
27
+ // Severity-tier deltas: count by tier on each side, take diff.
28
+ function _severityDelta(a, b) {
29
+ const counts = (arr) => {
30
+ const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
31
+ for (const f of arr || []) c[f.severity] = (c[f.severity] || 0) + 1;
32
+ return c;
33
+ };
34
+ const ca = counts(a), cb = counts(b);
35
+ const delta = {};
36
+ for (const k of Object.keys(ca)) delta[k] = (cb[k] || 0) - (ca[k] || 0);
37
+ return { from: ca, to: cb, delta };
38
+ }
39
+
40
+ // Auth boundary deltas: a route is an auth boundary iff hasAuth === true.
41
+ // Lost: was authed, no longer is. Added: now authed, wasn't.
42
+ function _authBoundaryDelta(routesA, routesB) {
43
+ const k = (r) => `${r.method} ${r.path} @ ${r.file}`;
44
+ const a = _toMap(routesA, k);
45
+ const b = _toMap(routesB, k);
46
+ const lost = [], added = [];
47
+ for (const [key, ra] of a) {
48
+ const rb = b.get(key);
49
+ if (ra.hasAuth && rb && !rb.hasAuth) lost.push(rb);
50
+ if (!ra.hasAuth && rb && rb.hasAuth) added.push(rb);
51
+ }
52
+ return { lost, added };
53
+ }
54
+
55
+ // Data-class deltas across routes: "now exposed PII", "no longer exposes PHI".
56
+ function _dataClassDelta(routesA, routesB) {
57
+ const aClasses = new Set();
58
+ const bClasses = new Set();
59
+ for (const r of routesA || []) for (const c of r.classifications || []) aClasses.add(c);
60
+ for (const r of routesB || []) for (const c of r.classifications || []) bClasses.add(c);
61
+ const newlyExposed = [...bClasses].filter(c => !aClasses.has(c));
62
+ const noLongerExposed = [...aClasses].filter(c => !bClasses.has(c));
63
+ return { newlyExposed, noLongerExposed };
64
+ }
65
+
66
+ export function driftBetween(scanA, scanB) {
67
+ const routes = _diffSets(scanA.routes || [], scanB.routes || [], _routeKey);
68
+ const deps = _diffSets(scanA.components || [], scanB.components || [], _depKey);
69
+ const sca = _diffSets((scanA.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
70
+ (scanB.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
71
+ s => `${s.ecosystem}:${s.name}@${s.version}:${s.osvId || s.advisory || ''}`);
72
+ // Findings — use kind+file+line+vuln as the stable key.
73
+ const allFindingsA = [...(scanA.findings || []), ...(scanA.logicVulns || [])];
74
+ const allFindingsB = [...(scanB.findings || []), ...(scanB.logicVulns || [])];
75
+ const findings = _diffSets(allFindingsA, allFindingsB, _findingKey);
76
+ const severity = _severityDelta(allFindingsA, allFindingsB);
77
+ const authBoundaries = _authBoundaryDelta(scanA.routes || [], scanB.routes || []);
78
+ const dataClasses = _dataClassDelta(scanA.routes || [], scanB.routes || []);
79
+
80
+ const totalChanged =
81
+ routes.added.length + routes.removed.length +
82
+ deps.added.length + deps.removed.length +
83
+ sca.added.length + sca.removed.length +
84
+ findings.added.length + findings.removed.length +
85
+ authBoundaries.lost.length + authBoundaries.added.length;
86
+
87
+ // Headline tier: critical if ANY auth boundary lost, OR a critical finding added,
88
+ // OR a vulnerable dep added at high+ severity. High if any finding added or any
89
+ // auth boundary added without authn improvements. Otherwise informational.
90
+ let tier = 'info';
91
+ if (authBoundaries.lost.length > 0) tier = 'critical';
92
+ else if (findings.added.some(f => f.severity === 'critical')) tier = 'critical';
93
+ else if (sca.added.some(s => /critical|high/.test(s.severity || ''))) tier = 'high';
94
+ else if (findings.added.length > 0 || routes.added.some(r => !r.hasAuth)) tier = 'high';
95
+ else if (deps.added.length > 0 || routes.added.length > 0) tier = 'medium';
96
+ else if (totalChanged > 0) tier = 'low';
97
+
98
+ return {
99
+ tier,
100
+ routes, deps, sca, findings,
101
+ severity, authBoundaries, dataClasses,
102
+ totalChanged,
103
+ };
104
+ }
105
+
106
+ export function driftToMarkdown(drift) {
107
+ const lines = [];
108
+ lines.push(`### Posture drift — tier: **${drift.tier}**`);
109
+ lines.push('');
110
+ if (drift.tier === 'info' && drift.totalChanged === 0) {
111
+ lines.push('No posture changes detected between the two scans.');
112
+ return lines.join('\n');
113
+ }
114
+ if (drift.authBoundaries.lost.length) {
115
+ lines.push(`**Auth boundaries LOST: ${drift.authBoundaries.lost.length}**`);
116
+ for (const r of drift.authBoundaries.lost) lines.push(`- \`${r.method} ${r.path}\` (\`${r.file}:${r.line}\`)`);
117
+ lines.push('');
118
+ }
119
+ if (drift.authBoundaries.added.length) {
120
+ lines.push(`**Auth boundaries ADDED: ${drift.authBoundaries.added.length}**`);
121
+ for (const r of drift.authBoundaries.added) lines.push(`- \`${r.method} ${r.path}\` (\`${r.file}:${r.line}\`)`);
122
+ lines.push('');
123
+ }
124
+ if (drift.routes.added.length) {
125
+ lines.push(`**New endpoints (${drift.routes.added.length}):**`);
126
+ for (const r of drift.routes.added.slice(0, 10)) {
127
+ const auth = r.hasAuth ? '🔒' : '⚠️ unauthenticated';
128
+ lines.push(`- ${auth} \`${r.method} ${r.path}\` (\`${r.file}:${r.line}\`)`);
129
+ }
130
+ if (drift.routes.added.length > 10) lines.push(`- … and ${drift.routes.added.length - 10} more`);
131
+ lines.push('');
132
+ }
133
+ if (drift.routes.removed.length) {
134
+ lines.push(`**Removed endpoints: ${drift.routes.removed.length}**`);
135
+ lines.push('');
136
+ }
137
+ if (drift.deps.added.length) {
138
+ lines.push(`**New dependencies (${drift.deps.added.length}):**`);
139
+ for (const c of drift.deps.added.slice(0, 10)) lines.push(`- \`${c.ecosystem}:${c.name}@${c.version}\``);
140
+ if (drift.deps.added.length > 10) lines.push(`- … and ${drift.deps.added.length - 10} more`);
141
+ lines.push('');
142
+ }
143
+ if (drift.deps.removed.length) {
144
+ lines.push(`**Removed dependencies: ${drift.deps.removed.length}**`);
145
+ lines.push('');
146
+ }
147
+ if (drift.sca.added.length) {
148
+ lines.push(`**New CVEs introduced (${drift.sca.added.length}):**`);
149
+ for (const s of drift.sca.added.slice(0, 10)) lines.push(`- \`${s.severity}\` ${s.osvId || s.advisory || s.name} (${s.name}@${s.version})`);
150
+ lines.push('');
151
+ }
152
+ if (drift.findings.added.length) {
153
+ lines.push(`**New findings: ${drift.findings.added.length}** (severity delta: ${Object.entries(drift.severity.delta).filter(([,v])=>v!==0).map(([k,v])=>`${k} ${v>0?'+':''}${v}`).join(', ')||'none'})`);
154
+ lines.push('');
155
+ }
156
+ if (drift.findings.removed.length) {
157
+ lines.push(`**Findings fixed: ${drift.findings.removed.length}**`);
158
+ lines.push('');
159
+ }
160
+ if (drift.dataClasses.newlyExposed.length) {
161
+ lines.push(`**Newly exposed data classes: ${drift.dataClasses.newlyExposed.join(', ')}**`);
162
+ lines.push('');
163
+ }
164
+ return lines.join('\n');
165
+ }
@@ -0,0 +1,156 @@
1
+ // EPSS exploit-prediction enrichment.
2
+ //
3
+ // EPSS (Exploit Prediction Scoring System, FIRST.org) gives every CVE a
4
+ // probability of being exploited in the next 30 days plus a percentile rank.
5
+ // Layered on top of CISA KEV, this lets us distinguish "theoretical" CVEs
6
+ // from those attackers are actively weaponizing.
7
+ //
8
+ // Decoration shape (added to each SCA finding with a CVE):
9
+ // epss: 0.92345
10
+ // epssPercentile: 0.987
11
+ // exploitedNow: true ← percentile >= 0.95
12
+ //
13
+ // Source: https://api.first.org/data/v1/epss?cve=CVE-...,CVE-...
14
+ // Cached on disk: ~/.claude/agentic-security/epss-cache/<sha256>.json
15
+ // 24-hour TTL. Falls back gracefully when offline.
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import * as os from 'node:os';
20
+ import * as crypto from 'node:crypto';
21
+
22
+ const CACHE_DIR = path.join(os.homedir(), '.claude', 'agentic-security', 'epss-cache');
23
+ const TTL_MS = 24 * 60 * 60 * 1000;
24
+ const EXPLOITED_NOW_THRESHOLD = 0.95; // percentile
25
+
26
+ function ensureCache() { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch {} }
27
+ function cachePath(cveListKey) {
28
+ const h = crypto.createHash('sha256').update(cveListKey).digest('hex');
29
+ return path.join(CACHE_DIR, h + '.json');
30
+ }
31
+
32
+ function readCache(key) {
33
+ const fp = cachePath(key);
34
+ if (!fs.existsSync(fp)) return null;
35
+ try {
36
+ const stat = fs.statSync(fp);
37
+ if (Date.now() - stat.mtimeMs > TTL_MS) return null;
38
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
39
+ } catch { return null; }
40
+ }
41
+
42
+ function writeCache(key, data) {
43
+ ensureCache();
44
+ try { fs.writeFileSync(cachePath(key), JSON.stringify(data)); } catch {}
45
+ }
46
+
47
+ // Returns Map<CVE, {epss: number, percentile: number}> for the supplied CVE IDs.
48
+ // Batched in groups of 100 to keep URLs short.
49
+ export async function fetchEPSS(cveIds) {
50
+ const out = new Map();
51
+ if (!cveIds || cveIds.length === 0) return out;
52
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
53
+ // Try cache anyway — return whatever we have.
54
+ const k = [...cveIds].sort().join(',');
55
+ const c = readCache(k);
56
+ if (c) for (const [cve, v] of Object.entries(c)) out.set(cve, v);
57
+ return out;
58
+ }
59
+ const unique = [...new Set(cveIds)].filter(c => /^CVE-\d{4}-\d{4,}$/i.test(c));
60
+ if (!unique.length) return out;
61
+
62
+ const cached = readCache([...unique].sort().join(','));
63
+ if (cached) {
64
+ for (const [cve, v] of Object.entries(cached)) out.set(cve, v);
65
+ return out;
66
+ }
67
+
68
+ const fresh = {};
69
+ for (let i = 0; i < unique.length; i += 100) {
70
+ const batch = unique.slice(i, i + 100);
71
+ const url = `https://api.first.org/data/v1/epss?cve=${encodeURIComponent(batch.join(','))}`;
72
+ try {
73
+ const res = await fetch(url, { headers: { 'User-Agent': 'agentic-security' } });
74
+ if (!res.ok) continue;
75
+ const body = await res.json();
76
+ for (const row of (body.data || [])) {
77
+ const epss = parseFloat(row.epss);
78
+ const percentile = parseFloat(row.percentile);
79
+ if (Number.isFinite(epss) && Number.isFinite(percentile)) {
80
+ const v = { epss, percentile };
81
+ out.set(row.cve, v);
82
+ fresh[row.cve] = v;
83
+ }
84
+ }
85
+ } catch { /* network error → caller continues without enrichment */ }
86
+ }
87
+ if (Object.keys(fresh).length) writeCache([...unique].sort().join(','), fresh);
88
+ return out;
89
+ }
90
+
91
+ // Extract CVE IDs from a finding regardless of where they live in the schema.
92
+ function cvesIn(finding) {
93
+ const found = new Set();
94
+ if (typeof finding.cve === 'string') found.add(finding.cve.toUpperCase());
95
+ if (Array.isArray(finding.cves)) for (const c of finding.cves) found.add(String(c).toUpperCase());
96
+ if (Array.isArray(finding.vulnerabilities)) {
97
+ for (const v of finding.vulnerabilities) {
98
+ if (typeof v.id === 'string' && v.id.startsWith('CVE-')) found.add(v.id.toUpperCase());
99
+ if (Array.isArray(v.aliases)) for (const a of v.aliases) {
100
+ if (typeof a === 'string' && a.startsWith('CVE-')) found.add(a.toUpperCase());
101
+ }
102
+ }
103
+ }
104
+ // Fallback: scan title/description for CVE refs.
105
+ for (const k of ['title', 'description', 'vuln']) {
106
+ const v = finding[k];
107
+ if (typeof v === 'string') {
108
+ const m = v.match(/\bCVE-\d{4}-\d{4,}\b/gi);
109
+ if (m) for (const c of m) found.add(c.toUpperCase());
110
+ }
111
+ }
112
+ return [...found];
113
+ }
114
+
115
+ // Decorate every SCA finding (and any other finding with a CVE) in place.
116
+ // Returns { decorated, exploitedNow }.
117
+ export async function enrichWithEPSS(scan) {
118
+ const buckets = ['supplyChain', 'findings'];
119
+ const allCves = new Set();
120
+ for (const b of buckets) {
121
+ for (const f of (scan[b] || [])) {
122
+ for (const c of cvesIn(f)) allCves.add(c);
123
+ }
124
+ }
125
+ if (allCves.size === 0) return { decorated: 0, exploitedNow: 0 };
126
+
127
+ const epssMap = await fetchEPSS([...allCves]);
128
+ let decorated = 0, exploitedNow = 0;
129
+
130
+ for (const b of buckets) {
131
+ for (const f of (scan[b] || [])) {
132
+ const cves = cvesIn(f);
133
+ let bestEpss = 0, bestPct = 0, bestCve = null;
134
+ for (const c of cves) {
135
+ const v = epssMap.get(c);
136
+ if (v && v.epss > bestEpss) { bestEpss = v.epss; bestPct = v.percentile; bestCve = c; }
137
+ }
138
+ if (bestCve) {
139
+ f.epssScore = bestEpss;
140
+ f.epssPercentile = bestPct;
141
+ f.epssCve = bestCve;
142
+ if (bestPct >= EXPLOITED_NOW_THRESHOLD) {
143
+ f.exploitedNow = true;
144
+ exploitedNow++;
145
+ // Bump severity one notch for actively-exploited CVEs (medium → high → critical).
146
+ if (f.severity === 'medium') f.severity = 'high';
147
+ else if (f.severity === 'high') f.severity = 'critical';
148
+ if (!Array.isArray(f.tags)) f.tags = [];
149
+ if (!f.tags.includes('exploited-now')) f.tags.push('exploited-now');
150
+ }
151
+ decorated++;
152
+ }
153
+ }
154
+ }
155
+ return { decorated, exploitedNow };
156
+ }