patchi 0.7.5__py3-none-any.whl

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 (496) hide show
  1. patchi/__init__.py +1 -0
  2. patchi/__main__.py +13 -0
  3. patchi/cli/__init__.py +0 -0
  4. patchi/cli/command_families.py +235 -0
  5. patchi/cli/commands/__init__.py +0 -0
  6. patchi/cli/commands/access_cmd.py +65 -0
  7. patchi/cli/commands/agent_stats_cmd.py +273 -0
  8. patchi/cli/commands/agents_cmd.py +187 -0
  9. patchi/cli/commands/ai_cmd.py +308 -0
  10. patchi/cli/commands/assure_cmd.py +538 -0
  11. patchi/cli/commands/audit_cmd.py +286 -0
  12. patchi/cli/commands/auto_cmd.py +110 -0
  13. patchi/cli/commands/blame_cmd.py +53 -0
  14. patchi/cli/commands/chains_cmd.py +327 -0
  15. patchi/cli/commands/charter_cmd.py +173 -0
  16. patchi/cli/commands/chat_cmd.py +597 -0
  17. patchi/cli/commands/check_cmd.py +232 -0
  18. patchi/cli/commands/cleanup_cmd.py +328 -0
  19. patchi/cli/commands/cockpit_cmd.py +208 -0
  20. patchi/cli/commands/commands_cmd.py +160 -0
  21. patchi/cli/commands/cross_repo_cmd.py +113 -0
  22. patchi/cli/commands/deps_cmd.py +110 -0
  23. patchi/cli/commands/dev_check_cmd.py +342 -0
  24. patchi/cli/commands/dev_cmd.py +445 -0
  25. patchi/cli/commands/doctor_cmd.py +457 -0
  26. patchi/cli/commands/findings_cmd.py +187 -0
  27. patchi/cli/commands/fix_cmd.py +648 -0
  28. patchi/cli/commands/fix_review_cmd.py +273 -0
  29. patchi/cli/commands/heatmap_cmd.py +62 -0
  30. patchi/cli/commands/help_cmd.py +159 -0
  31. patchi/cli/commands/hosted_cmd.py +980 -0
  32. patchi/cli/commands/init.py +456 -0
  33. patchi/cli/commands/key_cmd.py +571 -0
  34. patchi/cli/commands/learn_cmd.py +140 -0
  35. patchi/cli/commands/link_cmd.py +104 -0
  36. patchi/cli/commands/log_cmd.py +57 -0
  37. patchi/cli/commands/memory_cmd.py +215 -0
  38. patchi/cli/commands/model_cmd.py +207 -0
  39. patchi/cli/commands/notify_cmd.py +213 -0
  40. patchi/cli/commands/notify_prompts.py +29 -0
  41. patchi/cli/commands/patch_cmd.py +231 -0
  42. patchi/cli/commands/plan_cmd.py +128 -0
  43. patchi/cli/commands/plugins_cmd.py +307 -0
  44. patchi/cli/commands/queue_cmd.py +152 -0
  45. patchi/cli/commands/quick_cmd.py +159 -0
  46. patchi/cli/commands/ready_cmd.py +292 -0
  47. patchi/cli/commands/report_cmd.py +416 -0
  48. patchi/cli/commands/restrict_cmd.py +127 -0
  49. patchi/cli/commands/review_cmd.py +225 -0
  50. patchi/cli/commands/rules_cmd.py +233 -0
  51. patchi/cli/commands/scan_cmd.py +1908 -0
  52. patchi/cli/commands/settings_cmd.py +45 -0
  53. patchi/cli/commands/status_cmd.py +679 -0
  54. patchi/cli/commands/test_cmd.py +1052 -0
  55. patchi/cli/commands/trend_cmd.py +296 -0
  56. patchi/cli/commands/undo_cmd.py +235 -0
  57. patchi/cli/commands/update_cmd.py +345 -0
  58. patchi/cli/commands/verify_cmd.py +56 -0
  59. patchi/cli/commands/vr_cmd.py +440 -0
  60. patchi/cli/commands/watch_cmd.py +304 -0
  61. patchi/cli/commands/web_cmd.py +114 -0
  62. patchi/cli/console.py +101 -0
  63. patchi/cli/display/__init__.py +1 -0
  64. patchi/cli/display/live_audit.py +269 -0
  65. patchi/cli/display/live_progress.py +169 -0
  66. patchi/cli/framework.py +241 -0
  67. patchi/cli/logo.py +128 -0
  68. patchi/cli/main.py +222 -0
  69. patchi/cli/registry.py +1171 -0
  70. patchi/cli/style.py +68 -0
  71. patchi/cli/themes.py +187 -0
  72. patchi/cli/ux.py +444 -0
  73. patchi/core/__init__.py +0 -0
  74. patchi/core/agents/__init__.py +0 -0
  75. patchi/core/agents/api_fuzzer_agent.py +141 -0
  76. patchi/core/agents/attack_agent.py +242 -0
  77. patchi/core/agents/base.py +1012 -0
  78. patchi/core/agents/bug_predictor_agent.py +58 -0
  79. patchi/core/agents/build_tool_validator.py +150 -0
  80. patchi/core/agents/cache.py +192 -0
  81. patchi/core/agents/chaos_agent.py +133 -0
  82. patchi/core/agents/cicd_generator.py +240 -0
  83. patchi/core/agents/comment_scanner.py +280 -0
  84. patchi/core/agents/console_logging_agent.py +156 -0
  85. patchi/core/agents/coordinator.py +858 -0
  86. patchi/core/agents/core_scanner.py +269 -0
  87. patchi/core/agents/coverage_prioritizer.py +181 -0
  88. patchi/core/agents/dead_code_hygiene.py +296 -0
  89. patchi/core/agents/dead_code_scanner.py +692 -0
  90. patchi/core/agents/dependency_scanner.py +725 -0
  91. patchi/core/agents/doc_claim_agent.py +573 -0
  92. patchi/core/agents/duplicate_scanner.py +568 -0
  93. patchi/core/agents/env_scanner.py +410 -0
  94. patchi/core/agents/feature_flag_agent.py +89 -0
  95. patchi/core/agents/frontend_framework_agent.py +133 -0
  96. patchi/core/agents/goroutine_agent.py +82 -0
  97. patchi/core/agents/governor.py +1972 -0
  98. patchi/core/agents/i18n_agent.py +95 -0
  99. patchi/core/agents/license_compliance.py +247 -0
  100. patchi/core/agents/linking_agent.py +251 -0
  101. patchi/core/agents/memory_profiler_agent.py +102 -0
  102. patchi/core/agents/modernization_agent.py +84 -0
  103. patchi/core/agents/mutation_agent.py +174 -0
  104. patchi/core/agents/nl_query_agent.py +74 -0
  105. patchi/core/agents/promise_rejection_agent.py +100 -0
  106. patchi/core/agents/refactoring_agent.py +286 -0
  107. patchi/core/agents/resource_leak_agent.py +91 -0
  108. patchi/core/agents/route_graph_scanner.py +951 -0
  109. patchi/core/agents/run_agent.py +106 -0
  110. patchi/core/agents/rust_unwrap_agent.py +100 -0
  111. patchi/core/agents/sbom_generator.py +284 -0
  112. patchi/core/agents/scanners.py +67 -0
  113. patchi/core/agents/side/__init__.py +1 -0
  114. patchi/core/agents/side/build_agent.py +65 -0
  115. patchi/core/agents/side/format_agent.py +50 -0
  116. patchi/core/agents/side/install_agent.py +63 -0
  117. patchi/core/agents/side_file_scanner.py +549 -0
  118. patchi/core/agents/snapshot_drift_detector.py +146 -0
  119. patchi/core/agents/spa_route_inventory.py +203 -0
  120. patchi/core/agents/test_scanner.py +442 -0
  121. patchi/core/agents/tool_health.py +109 -0
  122. patchi/core/agents/tool_runner.py +162 -0
  123. patchi/core/agents/type_scanner.py +168 -0
  124. patchi/core/agents/ui_scanner.py +558 -0
  125. patchi/core/ai/__init__.py +20 -0
  126. patchi/core/ai/agent_audit.py +153 -0
  127. patchi/core/ai/agent_profiler.py +277 -0
  128. patchi/core/ai/client.py +499 -0
  129. patchi/core/ai/cost_tracker.py +107 -0
  130. patchi/core/ai/model_router.py +318 -0
  131. patchi/core/ai/orchestrator.py +625 -0
  132. patchi/core/ai/prompts.py +681 -0
  133. patchi/core/ai/smart.py +302 -0
  134. patchi/core/ai/tool_executor.py +24 -0
  135. patchi/core/ai/tools/__init__.py +10 -0
  136. patchi/core/ai/tools/executor.py +621 -0
  137. patchi/core/ai/tools/realize.py +1160 -0
  138. patchi/core/ai/tools/registry.py +1577 -0
  139. patchi/core/assurance/__init__.py +33 -0
  140. patchi/core/assurance/builder.py +211 -0
  141. patchi/core/assurance/graph.py +342 -0
  142. patchi/core/assurance/invariants.py +168 -0
  143. patchi/core/atomic.py +61 -0
  144. patchi/core/attackers/__init__.py +17 -0
  145. patchi/core/attackers/api_abuse.py +89 -0
  146. patchi/core/attackers/auth_bypass.py +73 -0
  147. patchi/core/attackers/base.py +91 -0
  148. patchi/core/attackers/business_logic.py +88 -0
  149. patchi/core/attackers/planner.py +108 -0
  150. patchi/core/attackers/priv_escalation.py +88 -0
  151. patchi/core/attackers/recon.py +87 -0
  152. patchi/core/brain/__init__.py +0 -0
  153. patchi/core/brain/ast_utils/__init__.py +81 -0
  154. patchi/core/brain/ast_utils/assignments.py +219 -0
  155. patchi/core/brain/ast_utils/calls.py +139 -0
  156. patchi/core/brain/ast_utils/config.py +132 -0
  157. patchi/core/brain/ast_utils/control_flow.py +74 -0
  158. patchi/core/brain/ast_utils/dead_symbols.py +205 -0
  159. patchi/core/brain/ast_utils/decorators.py +108 -0
  160. patchi/core/brain/ast_utils/helpers.py +144 -0
  161. patchi/core/brain/ast_utils/imports.py +169 -0
  162. patchi/core/brain/ast_utils/scan.py +143 -0
  163. patchi/core/brain/ast_utils/taint.py +310 -0
  164. patchi/core/brain/audit.py +379 -0
  165. patchi/core/brain/baseline.py +195 -0
  166. patchi/core/brain/blast_radius.py +312 -0
  167. patchi/core/brain/body_tags.py +144 -0
  168. patchi/core/brain/brain.py +1161 -0
  169. patchi/core/brain/brain_context.py +393 -0
  170. patchi/core/brain/brain_watcher.py +340 -0
  171. patchi/core/brain/charter.py +473 -0
  172. patchi/core/brain/classifier.py +214 -0
  173. patchi/core/brain/cockpit.py +350 -0
  174. patchi/core/brain/code_query.py +710 -0
  175. patchi/core/brain/contract.py +889 -0
  176. patchi/core/brain/contract_diff.py +155 -0
  177. patchi/core/brain/council.py +800 -0
  178. patchi/core/brain/cross_repo.py +104 -0
  179. patchi/core/brain/dead_code_tools.py +157 -0
  180. patchi/core/brain/doc_validator.py +197 -0
  181. patchi/core/brain/domain_activator.py +2183 -0
  182. patchi/core/brain/enriched_context.py +220 -0
  183. patchi/core/brain/file_corpus.py +220 -0
  184. patchi/core/brain/framework.py +953 -0
  185. patchi/core/brain/freshness.py +273 -0
  186. patchi/core/brain/git_aware.py +206 -0
  187. patchi/core/brain/heatmap.py +104 -0
  188. patchi/core/brain/ignore_parser.py +153 -0
  189. patchi/core/brain/import_graph.py +486 -0
  190. patchi/core/brain/languages.py +397 -0
  191. patchi/core/brain/layered_brain.py +419 -0
  192. patchi/core/brain/learning.py +229 -0
  193. patchi/core/brain/orphaned_endpoints.py +313 -0
  194. patchi/core/brain/personas/__init__.py +648 -0
  195. patchi/core/brain/personas/bad_user.py +165 -0
  196. patchi/core/brain/personas/base.py +349 -0
  197. patchi/core/brain/proactive.py +807 -0
  198. patchi/core/brain/project_context.py +121 -0
  199. patchi/core/brain/project_reader.py +521 -0
  200. patchi/core/brain/reasoning.py +269 -0
  201. patchi/core/brain/route_detector/__init__.py +15 -0
  202. patchi/core/brain/route_detector/base.py +31 -0
  203. patchi/core/brain/route_detector/csharp.py +209 -0
  204. patchi/core/brain/route_detector/file_based.py +81 -0
  205. patchi/core/brain/route_detector/go.py +152 -0
  206. patchi/core/brain/route_detector/java.py +195 -0
  207. patchi/core/brain/route_detector/javascript.py +197 -0
  208. patchi/core/brain/route_detector/php.py +158 -0
  209. patchi/core/brain/route_detector/python.py +193 -0
  210. patchi/core/brain/route_detector/registry.py +25 -0
  211. patchi/core/brain/route_detector/ruby.py +134 -0
  212. patchi/core/brain/route_detector/rust.py +216 -0
  213. patchi/core/brain/route_detector/swift.py +118 -0
  214. patchi/core/brain/route_mapper.py +265 -0
  215. patchi/core/brain/scanner.py +2136 -0
  216. patchi/core/brain/secrets.py +280 -0
  217. patchi/core/brain/symbol_graph.py +1955 -0
  218. patchi/core/brain/team.py +28 -0
  219. patchi/core/brain/trace_log.py +132 -0
  220. patchi/core/brain/type_checker/__init__.py +65 -0
  221. patchi/core/brain/type_checker/base.py +60 -0
  222. patchi/core/brain/type_checker/csharp.py +59 -0
  223. patchi/core/brain/type_checker/dart.py +59 -0
  224. patchi/core/brain/type_checker/go.py +81 -0
  225. patchi/core/brain/type_checker/java.py +75 -0
  226. patchi/core/brain/type_checker/kotlin.py +75 -0
  227. patchi/core/brain/type_checker/php.py +88 -0
  228. patchi/core/brain/type_checker/python.py +87 -0
  229. patchi/core/brain/type_checker/rust.py +81 -0
  230. patchi/core/brain/type_checker/swift.py +59 -0
  231. patchi/core/brain/type_checker/typescript.py +298 -0
  232. patchi/core/brain/understander.py +130 -0
  233. patchi/core/brain/verify.py +379 -0
  234. patchi/core/campaigns/__init__.py +14 -0
  235. patchi/core/campaigns/base.py +62 -0
  236. patchi/core/campaigns/data_flow_abuse.py +153 -0
  237. patchi/core/campaigns/orchestrator.py +140 -0
  238. patchi/core/campaigns/state_transitions.py +178 -0
  239. patchi/core/ci_bundle.py +104 -0
  240. patchi/core/config.py +311 -0
  241. patchi/core/constants.py +245 -0
  242. patchi/core/debug/__init__.py +25 -0
  243. patchi/core/debug/adapters/__init__.py +11 -0
  244. patchi/core/debug/adapters/codelldb.py +379 -0
  245. patchi/core/debug/adapters/node.py +222 -0
  246. patchi/core/debug/adapters/powershell.py +210 -0
  247. patchi/core/debug/adapters/python.py +244 -0
  248. patchi/core/debug/capture.py +210 -0
  249. patchi/core/debug/dap_client.py +268 -0
  250. patchi/core/detector/__init__.py +0 -0
  251. patchi/core/detector/audit.py +194 -0
  252. patchi/core/detector/bus.py +226 -0
  253. patchi/core/detector/dispatcher.py +275 -0
  254. patchi/core/detector/event.py +254 -0
  255. patchi/core/detector/sigma_engine.py +378 -0
  256. patchi/core/detector/triage.py +289 -0
  257. patchi/core/export/sarif.py +113 -0
  258. patchi/core/fix/__init__.py +17 -0
  259. patchi/core/fix/applier.py +574 -0
  260. patchi/core/fix/base.py +510 -0
  261. patchi/core/fix/code_fixer.py +169 -0
  262. patchi/core/fix/dead_code_remover.py +131 -0
  263. patchi/core/fix/fix_agents.py +775 -0
  264. patchi/core/fix/patch.py +444 -0
  265. patchi/core/fix/risk_gate.py +383 -0
  266. patchi/core/fix/security_fixer.py +93 -0
  267. patchi/core/fix/verify_loop.py +204 -0
  268. patchi/core/fuzz/__init__.py +8 -0
  269. patchi/core/fuzz/corpus.py +137 -0
  270. patchi/core/fuzz/input_fuzzer.py +256 -0
  271. patchi/core/fuzz/sequence_fuzzer.py +162 -0
  272. patchi/core/fuzz/state_fuzzer.py +236 -0
  273. patchi/core/health.py +407 -0
  274. patchi/core/hosted/__init__.py +1 -0
  275. patchi/core/hosted/anomaly.py +298 -0
  276. patchi/core/hosted/audit_log.py +102 -0
  277. patchi/core/hosted/compliance_report.py +148 -0
  278. patchi/core/hosted/ip_reputation.py +169 -0
  279. patchi/core/hosted/log_parsers.py +251 -0
  280. patchi/core/hosted/tokens.py +127 -0
  281. patchi/core/hosted/watchlist.py +164 -0
  282. patchi/core/hosted/webhooks.py +156 -0
  283. patchi/core/memory.py +437 -0
  284. patchi/core/notifications/__init__.py +18 -0
  285. patchi/core/notifications/channels.py +192 -0
  286. patchi/core/notifications/digest.py +141 -0
  287. patchi/core/notifications/escalation.py +158 -0
  288. patchi/core/notifications/notifier.py +182 -0
  289. patchi/core/notifications/quiet_hours.py +69 -0
  290. patchi/core/plugins/__init__.py +48 -0
  291. patchi/core/plugins/analyzer.py +326 -0
  292. patchi/core/plugins/analyzers/__init__.py +6 -0
  293. patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
  294. patchi/core/plugins/analyzers/secret_scanner.py +158 -0
  295. patchi/core/plugins/analyzers/todo_scanner.py +115 -0
  296. patchi/core/plugins/registry.py +335 -0
  297. patchi/core/queue.py +375 -0
  298. patchi/core/reliability/__init__.py +13 -0
  299. patchi/core/reliability/chaos.py +163 -0
  300. patchi/core/reliability/fault_injector.py +172 -0
  301. patchi/core/reliability/idempotency.py +128 -0
  302. patchi/core/reliability/recovery.py +147 -0
  303. patchi/core/runtime/__init__.py +5 -0
  304. patchi/core/runtime/tracer.py +230 -0
  305. patchi/core/scan_bus.py +156 -0
  306. patchi/core/security/__init__.py +1 -0
  307. patchi/core/security/ai_validator.py +462 -0
  308. patchi/core/security/app_mapper.py +216 -0
  309. patchi/core/security/app_profile.py +464 -0
  310. patchi/core/security/attack_feedback.py +141 -0
  311. patchi/core/security/attack_scenarios/__init__.py +1 -0
  312. patchi/core/security/attack_tree.py +176 -0
  313. patchi/core/security/auth_audit_agent.py +344 -0
  314. patchi/core/security/authz_agent.py +602 -0
  315. patchi/core/security/auto_fix_proactive.py +620 -0
  316. patchi/core/security/auto_fixer.py +660 -0
  317. patchi/core/security/auto_ticket.py +53 -0
  318. patchi/core/security/bandit_agent.py +152 -0
  319. patchi/core/security/blast_radius.py +226 -0
  320. patchi/core/security/browser_tester.py +333 -0
  321. patchi/core/security/business_logic_agent.py +263 -0
  322. patchi/core/security/catch_block_auditor.py +395 -0
  323. patchi/core/security/cdn_cache_agent.py +357 -0
  324. patchi/core/security/chain_analyzer.py +321 -0
  325. patchi/core/security/chain_to_assurance.py +168 -0
  326. patchi/core/security/charter.py +510 -0
  327. patchi/core/security/cloud_waf_detector.py +295 -0
  328. patchi/core/security/codeql_agent.py +233 -0
  329. patchi/core/security/compliance_agent.py +348 -0
  330. patchi/core/security/confidence_gate.py +423 -0
  331. patchi/core/security/container_scanner.py +282 -0
  332. patchi/core/security/crypto_agent.py +378 -0
  333. patchi/core/security/cve_monitor.py +262 -0
  334. patchi/core/security/dast_agent.py +865 -0
  335. patchi/core/security/defectdojo.py +238 -0
  336. patchi/core/security/defenders/__init__.py +75 -0
  337. patchi/core/security/defenders/auth_middleware.py +115 -0
  338. patchi/core/security/defenders/base.py +97 -0
  339. patchi/core/security/defenders/block_ip.py +71 -0
  340. patchi/core/security/defenders/block_ws_origin.py +36 -0
  341. patchi/core/security/defenders/crypto_fix.py +118 -0
  342. patchi/core/security/defenders/enforce_rate_limit.py +108 -0
  343. patchi/core/security/defenders/fix_code.py +93 -0
  344. patchi/core/security/defenders/invalidate_session.py +40 -0
  345. patchi/core/security/defenders/patch_config.py +70 -0
  346. patchi/core/security/defenders/rotate_secret.py +54 -0
  347. patchi/core/security/defenders/suspend_account.py +41 -0
  348. patchi/core/security/defenders/update_dependency.py +160 -0
  349. patchi/core/security/defense_layer.py +217 -0
  350. patchi/core/security/dependency_vulnerability_agent.py +616 -0
  351. patchi/core/security/detection_pipeline.py +289 -0
  352. patchi/core/security/dns_security_agent.py +497 -0
  353. patchi/core/security/domain_activator_v2.py +913 -0
  354. patchi/core/security/domain_loader.py +1004 -0
  355. patchi/core/security/domains/__init__.py +0 -0
  356. patchi/core/security/email_authentication_agent.py +417 -0
  357. patchi/core/security/env_var_validator.py +304 -0
  358. patchi/core/security/evidence.py +100 -0
  359. patchi/core/security/falco_runtime_agent.py +387 -0
  360. patchi/core/security/fix-playbooks/__init__.py +0 -0
  361. patchi/core/security/gated_finding.py +85 -0
  362. patchi/core/security/git_diff_activator.py +367 -0
  363. patchi/core/security/governance.py +208 -0
  364. patchi/core/security/gradual_gate.py +34 -0
  365. patchi/core/security/history.py +315 -0
  366. patchi/core/security/iac_scanner.py +250 -0
  367. patchi/core/security/ignore_expiry.py +111 -0
  368. patchi/core/security/ignore_learner.py +416 -0
  369. patchi/core/security/injection_agent.py +319 -0
  370. patchi/core/security/insecure_randomness_agent.py +288 -0
  371. patchi/core/security/intent_analyzer.py +326 -0
  372. patchi/core/security/jwt_agent.py +453 -0
  373. patchi/core/security/kubernetes_agent.py +638 -0
  374. patchi/core/security/layer2_orchestrator.py +341 -0
  375. patchi/core/security/llm_security_agent.py +252 -0
  376. patchi/core/security/misconfig_agent.py +600 -0
  377. patchi/core/security/mobile_security_agent.py +474 -0
  378. patchi/core/security/network_agent.py +376 -0
  379. patchi/core/security/noise_filter.py +274 -0
  380. patchi/core/security/orchestrator.py +313 -0
  381. patchi/core/security/pattern_context.py +125 -0
  382. patchi/core/security/pattern_loader.py +101 -0
  383. patchi/core/security/pentest/__init__.py +4 -0
  384. patchi/core/security/pentest/base.py +58 -0
  385. patchi/core/security/pentest/dalfox_adapter.py +40 -0
  386. patchi/core/security/pentest/ffuf_adapter.py +48 -0
  387. patchi/core/security/pentest/nuclei_adapter.py +50 -0
  388. patchi/core/security/pentest/registry.py +80 -0
  389. patchi/core/security/pentest/shannon_adapter.py +107 -0
  390. patchi/core/security/pentest/sqlmap_adapter.py +56 -0
  391. patchi/core/security/pentest/zap_adapter.py +52 -0
  392. patchi/core/security/plan_auditor.py +276 -0
  393. patchi/core/security/policy_engine.py +272 -0
  394. patchi/core/security/prechecks.py +172 -0
  395. patchi/core/security/precommit_hook.py +247 -0
  396. patchi/core/security/privacy_agent.py +361 -0
  397. patchi/core/security/push_notification_agent.py +417 -0
  398. patchi/core/security/pysa_agent.py +143 -0
  399. patchi/core/security/reasoning.py +442 -0
  400. patchi/core/security/red_team_agent.py +184 -0
  401. patchi/core/security/red_team_engine.py +1142 -0
  402. patchi/core/security/remediation.py +360 -0
  403. patchi/core/security/request_interceptor.py +294 -0
  404. patchi/core/security/runtime_validator.py +245 -0
  405. patchi/core/security/saml_sso_agent.py +401 -0
  406. patchi/core/security/sast_agent.py +213 -0
  407. patchi/core/security/sast_gate.py +95 -0
  408. patchi/core/security/scheduler.py +287 -0
  409. patchi/core/security/secrets_runtime_agent.py +476 -0
  410. patchi/core/security/security_agents.py +175 -0
  411. patchi/core/security/security_config.py +551 -0
  412. patchi/core/security/security_probe.py +762 -0
  413. patchi/core/security/security_taint.py +1005 -0
  414. patchi/core/security/sensitive_data_agent.py +271 -0
  415. patchi/core/security/service_mesh_agent.py +412 -0
  416. patchi/core/security/session_management_agent.py +245 -0
  417. patchi/core/security/ssrf_agent.py +422 -0
  418. patchi/core/security/supply_chain.py +449 -0
  419. patchi/core/security/threat_model_generator.py +378 -0
  420. patchi/core/security/threat_model_updater.py +196 -0
  421. patchi/core/security/tool_adapters.py +135 -0
  422. patchi/core/security/tool_verify.py +173 -0
  423. patchi/core/security/websocket_security_agent.py +188 -0
  424. patchi/core/snapshot.py +242 -0
  425. patchi/core/tenant.py +386 -0
  426. patchi/core/testing/__init__.py +17 -0
  427. patchi/core/testing/_browser.py +147 -0
  428. patchi/core/testing/ads_agent.py +141 -0
  429. patchi/core/testing/api_contract_agent.py +450 -0
  430. patchi/core/testing/app_discovery_agent.py +641 -0
  431. patchi/core/testing/app_launcher.py +184 -0
  432. patchi/core/testing/browser_test_agent.py +516 -0
  433. patchi/core/testing/e2e_flow_agent.py +354 -0
  434. patchi/core/testing/flake_detector_agent.py +292 -0
  435. patchi/core/testing/gate.py +31 -0
  436. patchi/core/testing/live_test_runner.py +304 -0
  437. patchi/core/testing/live_v2/__init__.py +1 -0
  438. patchi/core/testing/live_v2/browser_pool.py +349 -0
  439. patchi/core/testing/live_v2/browser_test_runner.py +313 -0
  440. patchi/core/testing/live_v2/runner.py +626 -0
  441. patchi/core/testing/live_v2/screenshot_manager.py +474 -0
  442. patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
  443. patchi/core/testing/live_v2/video_recorder.py +303 -0
  444. patchi/core/testing/regression_agent.py +302 -0
  445. patchi/core/testing/security_test_agent.py +253 -0
  446. patchi/core/testing/stress_test_agent.py +203 -0
  447. patchi/core/testing/test_agents.py +50 -0
  448. patchi/core/testing/test_config.py +194 -0
  449. patchi/core/testing/ui_accessibility_agent.py +324 -0
  450. patchi/core/testing/ui_button_agent.py +294 -0
  451. patchi/core/testing/ui_layout_agent.py +330 -0
  452. patchi/core/testing/unit_test_agent.py +1149 -0
  453. patchi/core/testing/visual_regression_agent.py +260 -0
  454. patchi/web/__init__.py +1 -0
  455. patchi/web/api/__init__.py +1 -0
  456. patchi/web/api/brain_map.py +114 -0
  457. patchi/web/api/charts.py +47 -0
  458. patchi/web/api/chat.py +224 -0
  459. patchi/web/api/cicd.py +576 -0
  460. patchi/web/api/dast_evidence.py +49 -0
  461. patchi/web/api/dev_check.py +320 -0
  462. patchi/web/api/fix.py +134 -0
  463. patchi/web/api/guard.py +65 -0
  464. patchi/web/api/hosted.py +128 -0
  465. patchi/web/api/hosted_v2.py +234 -0
  466. patchi/web/api/live_testing.py +699 -0
  467. patchi/web/api/scan.py +582 -0
  468. patchi/web/api/smart.py +111 -0
  469. patchi/web/api/tenant.py +117 -0
  470. patchi/web/api/tools.py +85 -0
  471. patchi/web/api_legacy.py +2401 -0
  472. patchi/web/app.py +310 -0
  473. patchi/web/events.py +483 -0
  474. patchi/web/routes/__init__.py +1 -0
  475. patchi/web/routes/assurance.py +541 -0
  476. patchi/web/routes/brain.py +36 -0
  477. patchi/web/routes/charter.py +227 -0
  478. patchi/web/routes/chat.py +23 -0
  479. patchi/web/routes/dashboard.py +558 -0
  480. patchi/web/routes/dashboard_v2.py +402 -0
  481. patchi/web/routes/findings.py +330 -0
  482. patchi/web/routes/guard.py +35 -0
  483. patchi/web/routes/history.py +126 -0
  484. patchi/web/routes/landing.py +52 -0
  485. patchi/web/routes/live_testing.py +24 -0
  486. patchi/web/routes/review.py +57 -0
  487. patchi/web/routes/self_improvement.py +110 -0
  488. patchi/web/routes/settings.py +49 -0
  489. patchi/web/routes/tokens.py +29 -0
  490. patchi/web/spawn.py +139 -0
  491. patchi/web/ws.py +141 -0
  492. patchi-0.7.5.dist-info/METADATA +315 -0
  493. patchi-0.7.5.dist-info/RECORD +496 -0
  494. patchi-0.7.5.dist-info/WHEEL +5 -0
  495. patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
  496. patchi-0.7.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import socket
6
+ import time
7
+
8
+ _log = logging.getLogger("patchi.debug.dap_client")
9
+
10
+
11
+ class DAPError(Exception):
12
+ """A DAP request returned success=false."""
13
+
14
+
15
+ class DAPClient:
16
+ """Minimal Debug Adapter Protocol client over TCP.
17
+
18
+ Drives any DAP-compliant debug adapter (debugpy, dlv dap, codelldb, …)
19
+ through the same code path. This is *not* a full DAP implementation —
20
+ only the subset needed to capture post-exception state.
21
+ """
22
+
23
+ def __init__(self, host: str = "127.0.0.1", port: int = 5678):
24
+ self._host = host
25
+ self._port = port
26
+ self._sock: socket.socket | None = None
27
+ self._seq = 0
28
+ self._buffer = b""
29
+
30
+ # ── connection lifecycle ────────────────────────────────────────────
31
+
32
+ def connect(self, timeout: float = 15) -> None:
33
+ deadline = time.monotonic() + timeout
34
+ last_err = None
35
+ while time.monotonic() < deadline:
36
+ try:
37
+ self._sock = socket.create_connection(
38
+ (self._host, self._port), timeout=min(5.0, deadline - time.monotonic())
39
+ )
40
+ self._sock.settimeout(timeout)
41
+ return
42
+ except (ConnectionRefusedError, OSError) as e:
43
+ last_err = e
44
+ time.sleep(0.5)
45
+ raise ConnectionError(
46
+ f"Could not connect to {self._host}:{self._port} after {timeout}s"
47
+ ) from last_err
48
+
49
+ def close(self) -> None:
50
+ if self._sock:
51
+ try:
52
+ self._sock.close()
53
+ except OSError:
54
+ pass
55
+ self._sock = None
56
+ self._buffer = b""
57
+
58
+ # ── low-level I/O ───────────────────────────────────────────────────
59
+
60
+ def _send(self, body: str) -> None:
61
+ raw = f"Content-Length: {len(body)}\r\n\r\n{body}".encode()
62
+ self._sock.sendall(raw)
63
+
64
+ def _recv(self, size: int = 8192) -> bytes:
65
+ chunk = self._sock.recv(size)
66
+ if not chunk:
67
+ raise ConnectionError("debug adapter closed the connection")
68
+ return chunk
69
+
70
+ def read_message(self) -> dict:
71
+ self._buffer += self._recv()
72
+ # peel off one complete DAP message
73
+ while True:
74
+ idx = self._buffer.find(b"\r\n\r\n")
75
+ if idx == -1:
76
+ self._buffer += self._recv()
77
+ continue
78
+ header = self._buffer[:idx].decode("ascii", errors="replace")
79
+ length = 0
80
+ for line in header.split("\r\n"):
81
+ if line.lower().startswith("content-length:"):
82
+ length = int(line.split(":", 1)[1].strip())
83
+ break
84
+ body_start = idx + 4
85
+ if len(self._buffer) < body_start + length:
86
+ self._buffer += self._recv()
87
+ continue
88
+ body = self._buffer[body_start : body_start + length]
89
+ self._buffer = self._buffer[body_start + length :]
90
+ return json.loads(body.decode("utf-8"))
91
+
92
+ # ── high-level wire helpers ─────────────────────────────────────────
93
+
94
+ def send_request(self, command: str, arguments: dict | None = None) -> int:
95
+ self._seq += 1
96
+ body = json.dumps(
97
+ {
98
+ "seq": self._seq,
99
+ "type": "request",
100
+ "command": command,
101
+ "arguments": arguments or {},
102
+ }
103
+ )
104
+ self._send(body)
105
+ return self._seq
106
+
107
+ def read_response(self, expected_seq: int) -> dict:
108
+ while True:
109
+ msg = self.read_message()
110
+ if msg["type"] == "response" and msg.get("request_seq") == expected_seq:
111
+ if not msg.get("success", False):
112
+ err = msg.get(
113
+ "message", msg.get("body", {}).get("error", {}).get("format", "unknown")
114
+ )
115
+ raise DAPError(f"{msg.get('command', '?')} failed: {err}")
116
+ return msg.get("body", {})
117
+ if msg["type"] == "event":
118
+ _log.debug("DAP event during response wait: %s", msg.get("event"))
119
+
120
+ def request(self, command: str, arguments: dict | None = None) -> dict:
121
+ seq = self.send_request(command, arguments)
122
+ return self.read_response(seq)
123
+
124
+ def wait_for_event(self, event: str) -> dict:
125
+ while True:
126
+ msg = self.read_message()
127
+ if msg["type"] == "event" and msg.get("event") == event:
128
+ return msg.get("body", {})
129
+
130
+ # ── standard DAP requests ──────────────────────────────────────────
131
+
132
+ def initialize(self) -> dict:
133
+ return self.request(
134
+ "initialize",
135
+ {
136
+ "clientID": "patchi-debug",
137
+ "clientName": "Patchi Debugger",
138
+ "adapterID": "python",
139
+ "pathFormat": "path",
140
+ "linesStartAt1": True,
141
+ "columnsStartAt1": True,
142
+ "supportsVariableType": True,
143
+ "supportsRunInTerminalRequest": False,
144
+ },
145
+ )
146
+
147
+ def launch(
148
+ self,
149
+ program: str,
150
+ args: list[str] | None = None,
151
+ *,
152
+ type: str = "python",
153
+ console: str = "none",
154
+ sourceLanguages: list[str] | None = None,
155
+ ) -> dict:
156
+ args_body = {
157
+ "type": type,
158
+ "request": "launch",
159
+ "name": "Patchi Debug",
160
+ "program": program,
161
+ "args": args or [],
162
+ "console": console,
163
+ }
164
+ if sourceLanguages is not None:
165
+ args_body["sourceLanguages"] = sourceLanguages
166
+ return self.request("launch", args_body)
167
+
168
+ def set_exception_breakpoints(self, filters: list[str] | None = None) -> dict:
169
+ return self.request(
170
+ "setExceptionBreakpoints",
171
+ {
172
+ "filters": filters or ["uncaught"],
173
+ },
174
+ )
175
+
176
+ def set_function_breakpoints(self, names: list[str]) -> dict:
177
+ return self.request(
178
+ "setFunctionBreakpoints",
179
+ {
180
+ "breakpoints": [{"name": n} for n in names],
181
+ },
182
+ )
183
+
184
+ def set_breakpoints(self, source_path: str, lines: list[int]) -> dict:
185
+ return self.request(
186
+ "setBreakpoints",
187
+ {
188
+ "source": {"path": source_path},
189
+ "breakpoints": [{"line": ln} for ln in lines],
190
+ },
191
+ )
192
+
193
+ def configuration_done(self) -> dict:
194
+ return self.request("configurationDone")
195
+
196
+ def continue_req(self, thread_id: int = 1) -> dict:
197
+ return self.request("continue", {"threadId": thread_id})
198
+
199
+ def stack_trace(self, thread_id: int, levels: int = 30) -> dict:
200
+ return self.request(
201
+ "stackTrace",
202
+ {
203
+ "threadId": thread_id,
204
+ "levels": levels,
205
+ },
206
+ )
207
+
208
+ def scopes(self, frame_id: int) -> dict:
209
+ return self.request("scopes", {"frameId": frame_id})
210
+
211
+ def variables(self, var_ref: int) -> dict:
212
+ return self.request("variables", {"variablesReference": var_ref})
213
+
214
+ def disconnect(self) -> dict:
215
+ return self.request("disconnect")
216
+
217
+ # ── non-blocking launch flow ───────────────────────────────────────
218
+
219
+ def launch_no_wait(
220
+ self,
221
+ program: str,
222
+ args: list[str] | None = None,
223
+ *,
224
+ type: str = "python",
225
+ console: str = "none",
226
+ sourceLanguages: list[str] | None = None,
227
+ ) -> int:
228
+ """Send ``launch`` + ``configurationDone`` without waiting for the
229
+ launch response.
230
+
231
+ Some adapters (codelldb) defer the ``launch`` response until
232
+ ``configurationDone`` has been processed. Blocking on the response
233
+ before sending ``configurationDone`` deadlocks, so this sends both
234
+ back-to-back and returns the launch request seq.
235
+ """
236
+ body = {
237
+ "type": type,
238
+ "request": "launch",
239
+ "name": "Patchi Debug",
240
+ "program": program,
241
+ "args": args or [],
242
+ "console": console,
243
+ }
244
+ if sourceLanguages is not None:
245
+ body["sourceLanguages"] = sourceLanguages
246
+ launch_seq = self.send_request("launch", body)
247
+ self.send_request("configurationDone", {})
248
+ return launch_seq
249
+
250
+ def wait_for_stopped(self, timeout: float) -> dict:
251
+ """Read messages until a ``stopped`` event arrives (within *timeout*).
252
+
253
+ Other events and responses (including the deferred ``launch``
254
+ response) are consumed and discarded. Raises ``TimeoutError`` if no
255
+ stop happens in time.
256
+ """
257
+ deadline = time.monotonic() + timeout
258
+ while True:
259
+ remaining = deadline - time.monotonic()
260
+ if remaining <= 0:
261
+ raise TimeoutError("timed out waiting for 'stopped' event")
262
+ try:
263
+ self._sock.settimeout(min(max(remaining, 0.5), 5.0))
264
+ msg = self.read_message()
265
+ except (ConnectionError, OSError, TimeoutError) as exc:
266
+ raise TimeoutError("timed out waiting for 'stopped' event") from exc
267
+ if msg.get("type") == "event" and msg.get("event") == "stopped":
268
+ return msg.get("body", {})
File without changes
@@ -0,0 +1,194 @@
1
+ """
2
+ Audit log — hash-chained, append-only event log.
3
+
4
+ Wraps the existing governance SQLite log and hosted JSON-lines log into a single
5
+ interface, receiving Event objects. Every event published to the EventBus with an
6
+ audit handler is written through this layer.
7
+
8
+ Hash chaining: each entry includes the SHA-256 of the previous entry, forming an
9
+ immutable chain. Exporting produces a verifiable lineage.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import logging
17
+ import os
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from patchi.core.detector.event import Event
24
+
25
+ logger = logging.getLogger("patchi.detector.audit")
26
+
27
+
28
+ @dataclass
29
+ class AuditEntry:
30
+ """A single entry in the audit log — wraps an Event with hash chaining."""
31
+
32
+ event: Event
33
+ previous_hash: str = ""
34
+ entry_hash: str = field(default_factory=lambda: "")
35
+ written_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
36
+
37
+ def __post_init__(self) -> None:
38
+ if not self.entry_hash:
39
+ self.entry_hash = self._compute_hash()
40
+
41
+ def _compute_hash(self) -> str:
42
+ raw = json.dumps(self.to_dict(signed=True), sort_keys=True, default=str)
43
+ return hashlib.sha256(raw.encode()).hexdigest()
44
+
45
+ def to_dict(self, signed: bool = False) -> dict:
46
+ d: dict[str, Any] = {
47
+ "event": self.event.to_dict(),
48
+ "written_at": self.written_at,
49
+ }
50
+ if signed:
51
+ d["previous_hash"] = self.previous_hash
52
+ d["entry_hash"] = self.entry_hash
53
+ return d
54
+
55
+
56
+ class AuditLog:
57
+ """
58
+ Event-driven audit log with hash chaining.
59
+
60
+ Writes to a JSON-lines file at `.patchi/detector/audit.log.jsonl`.
61
+ Each line is a JSON object with the event, timestamp, and chain hash.
62
+
63
+ Export produces a full verifiable chain. Governance SQLite log is still
64
+ written for backward compatibility (high-level action tracking).
65
+ """
66
+
67
+ def __init__(self, root: Path | None = None):
68
+ self._root = root
69
+ self._last_hash = ""
70
+ self._chain_length = 0
71
+ self._file: Any = None # open file handle
72
+ if root:
73
+ self._open()
74
+
75
+ def _open(self) -> None:
76
+ if not self._root:
77
+ return
78
+ log_dir = self._root / ".patchi" / "detector"
79
+ log_dir.mkdir(parents=True, exist_ok=True)
80
+ log_path = log_dir / "audit.log.jsonl"
81
+
82
+ # Load the last entry's hash if the file exists
83
+ if log_path.exists() and log_path.stat().st_size > 0:
84
+ try:
85
+ with open(log_path, encoding="utf-8") as f:
86
+ for line in f:
87
+ if line.strip():
88
+ entry = json.loads(line)
89
+ self._last_hash = entry.get("entry_hash", "")
90
+ self._chain_length += 1
91
+ except (json.JSONDecodeError, OSError):
92
+ logger.warning("Corrupt audit log — starting fresh chain")
93
+ self._last_hash = ""
94
+ self._chain_length = 0
95
+
96
+ self._log_path = log_path
97
+ self._file = open(log_path, "a", encoding="utf-8") # noqa: SIM115
98
+
99
+ async def write(self, event: Event) -> None:
100
+ """Write an event to the audit log with hash chaining."""
101
+ entry = AuditEntry(
102
+ event=event,
103
+ previous_hash=self._last_hash,
104
+ )
105
+ self._last_hash = entry.entry_hash
106
+ self._chain_length += 1
107
+ line = json.dumps(entry.to_dict(signed=True), default=str) + "\n"
108
+
109
+ if self._file:
110
+ try:
111
+ self._file.write(line)
112
+ self._file.flush()
113
+ os.fsync(self._file.fileno())
114
+ except OSError as exc:
115
+ logger.error("Failed to write audit log: %s", exc)
116
+
117
+ async def write_batch(self, events: list[Event]) -> None:
118
+ for event in events:
119
+ await self.write(event)
120
+
121
+ def export(self) -> str:
122
+ """Export the full audit log as a JSON array (for reporting/sharing)."""
123
+ if not self._root:
124
+ return "[]"
125
+ log_path = self._root / ".patchi" / "detector" / "audit.log.jsonl"
126
+ if not log_path.exists():
127
+ return "[]"
128
+ entries = []
129
+ with open(log_path, encoding="utf-8") as f:
130
+ for line in f:
131
+ if line.strip():
132
+ entries.append(json.loads(line))
133
+ return json.dumps(entries, indent=2, default=str)
134
+
135
+ def verify_chain(self) -> tuple[bool, int]:
136
+ """Verify the hash chain integrity. Returns (valid, verified_count)."""
137
+ if not self._root:
138
+ return True, 0
139
+ log_path = self._root / ".patchi" / "detector" / "audit.log.jsonl"
140
+ if not log_path.exists():
141
+ return True, 0
142
+ prev_hash = ""
143
+ count = 0
144
+ with open(log_path, encoding="utf-8") as f:
145
+ for line in f:
146
+ if not line.strip():
147
+ continue
148
+ try:
149
+ entry = json.loads(line)
150
+ except json.JSONDecodeError:
151
+ return False, count
152
+ if entry.get("previous_hash", "") != prev_hash:
153
+ return False, count
154
+ prev_hash = entry.get("entry_hash", "")
155
+ count += 1
156
+ return True, count
157
+
158
+ def close(self) -> None:
159
+ if self._file:
160
+ self._file.close()
161
+ self._file = None
162
+
163
+ def __enter__(self) -> AuditLog:
164
+ return self
165
+
166
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
167
+ self.close()
168
+
169
+ @property
170
+ def stats(self) -> dict:
171
+ return {
172
+ "chain_length": self._chain_length,
173
+ "last_hash": self._last_hash[:16] + "...",
174
+ }
175
+
176
+
177
+ # ── Shared bus setup (convenience) ──────────────────────────────────────────
178
+
179
+
180
+ def setup_audit_logger(bus, root: Path) -> AuditLog:
181
+ """
182
+ Create an AuditLog and attach it as the bus's audit handler.
183
+
184
+ Returns the AuditLog instance so callers can export/verify.
185
+ """
186
+
187
+ audit_log = AuditLog(root=root)
188
+
189
+ async def audit_handler(event: Event) -> None:
190
+ await audit_log.write(event)
191
+
192
+ bus._audit_handler = audit_handler # type: ignore[attr-defined]
193
+ logger.info("Audit log attached to bus at .patchi/detector/audit.log.jsonl")
194
+ return audit_log
@@ -0,0 +1,226 @@
1
+ """
2
+ EventBus — in-memory async pub/sub for the detector pipeline.
3
+
4
+ Features:
5
+ - Subscribe by EventSource, TechniqueID, severity threshold, or agent name
6
+ - Rate-limited dispatch (prevents handler resource exhaustion)
7
+ - Batch collection (time/buffer-based for bulk processing)
8
+ - Auditable: every published event is forwarded to the audit log
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from collections import defaultdict
17
+ from collections.abc import Awaitable, Callable
18
+ from dataclasses import dataclass
19
+
20
+ from patchi.core.detector.event import Event, EventSeverity, EventSource, TechniqueID
21
+
22
+ logger = logging.getLogger("patchi.detector.bus")
23
+
24
+ # Type alias for event handlers
25
+ EventHandler = Callable[[Event], Awaitable[None]]
26
+
27
+
28
+ _log = logging.getLogger("patchi.core.bus")
29
+
30
+
31
+ @dataclass
32
+ class Subscription:
33
+ """A single subscription to the event bus."""
34
+
35
+ handler: EventHandler
36
+ source: set[EventSource] | None = None
37
+ technique_filter: set[str] | None = None
38
+ agent_filter: set[str] | None = None
39
+ min_severity: EventSeverity | None = None
40
+ name: str = ""
41
+
42
+
43
+ class RateLimiter:
44
+ """Sliding-window rate limiter per handler."""
45
+
46
+ def __init__(self, max_per_second: float = 100.0):
47
+ self._windows: dict[str, list[float]] = defaultdict(list)
48
+ self._max_per_second = max_per_second
49
+
50
+ def allow(self, key: str) -> bool:
51
+ now = time.monotonic()
52
+ window = self._windows[key]
53
+ cutoff = now - 1.0
54
+ while window and window[0] < cutoff:
55
+ window.pop(0)
56
+ if len(window) >= self._max_per_second:
57
+ return False
58
+ window.append(now)
59
+ return True
60
+
61
+ def reset(self, key: str) -> None:
62
+ self._windows.pop(key, None)
63
+
64
+
65
+ class EventBus:
66
+ """
67
+ In-memory event bus with filtering, rate limiting, and audit forwarding.
68
+
69
+ Usage:
70
+ bus = EventBus()
71
+
72
+ async def on_scan_event(event: Event):
73
+ print(f"Got: {event.summary}")
74
+
75
+ bus.subscribe(on_scan_event, source={EventSource.AGENT})
76
+ await bus.publish(event)
77
+
78
+ # Collect in batches
79
+ async for batch in bus.collect_batch(timeout_s=5.0, max_events=100):
80
+ await process(batch)
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ audit_handler: EventHandler | None = None,
86
+ rate_limit: float = 200.0,
87
+ ):
88
+ self._subscriptions: list[Subscription] = []
89
+ self._lock = asyncio.Lock()
90
+ self._rate_limiter = RateLimiter(max_per_second=rate_limit)
91
+ self._audit_handler = audit_handler
92
+ self._batch_queue: asyncio.Queue[Event] = asyncio.Queue()
93
+ self._running = False
94
+
95
+ # ── Subscription management ────────────────────────────────────────────────
96
+
97
+ def subscribe(
98
+ self,
99
+ handler: EventHandler,
100
+ *,
101
+ source: set[EventSource] | None = None,
102
+ technique_id: set[str] | None = None,
103
+ agent: set[str] | None = None,
104
+ min_severity: EventSeverity | None = None,
105
+ name: str = "",
106
+ ) -> Subscription:
107
+ sub = Subscription(
108
+ handler=handler,
109
+ source=source,
110
+ technique_filter=technique_id,
111
+ agent_filter=agent,
112
+ min_severity=min_severity,
113
+ name=name or getattr(handler, "__name__", str(id(handler))),
114
+ )
115
+ self._subscriptions.append(sub)
116
+ return sub
117
+
118
+ def unsubscribe(self, sub: Subscription) -> None:
119
+ if sub in self._subscriptions:
120
+ self._subscriptions.remove(sub)
121
+
122
+ def clear(self) -> None:
123
+ self._subscriptions.clear()
124
+
125
+ # ── Publishing ─────────────────────────────────────────────────────────────
126
+
127
+ async def publish(self, event: Event) -> None:
128
+ """Publish a single event to matching subscribers."""
129
+ tid = (
130
+ event.technique_id.value
131
+ if isinstance(event.technique_id, TechniqueID)
132
+ else event.technique_id
133
+ )
134
+
135
+ matched_any = False
136
+ async with self._lock:
137
+ for sub in self._subscriptions:
138
+ if not self._matches(event, sub, tid):
139
+ continue
140
+ handler_key = f"{sub.name}:{tid}"
141
+ if not self._rate_limiter.allow(handler_key):
142
+ logger.debug("Rate limit hit for %s on %s", sub.name, tid)
143
+ continue
144
+ matched_any = True
145
+ try:
146
+ await sub.handler(event)
147
+ except Exception as e:
148
+ _log.warning("EventBus.publish failed: %s", e)
149
+ logger.exception("Handler %s failed on event %s", sub.name, event.event_id)
150
+
151
+ # Always queue for batch collection
152
+ await self._batch_queue.put(event)
153
+
154
+ # Forward to audit handler (separate from subscriptions so it always fires)
155
+ if self._audit_handler:
156
+ try:
157
+ await self._audit_handler(event)
158
+ except Exception as e:
159
+ _log.warning("EventBus.publish failed: %s", e)
160
+ logger.exception("Audit handler failed on event %s", event.event_id)
161
+
162
+ if not matched_any:
163
+ logger.debug("Event %s had zero matching subscribers", event.event_id)
164
+
165
+ async def publish_batch(self, events: list[Event]) -> None:
166
+ """Publish multiple events in sequence."""
167
+ for event in events:
168
+ await self.publish(event)
169
+
170
+ # ── Batch collection ──────────────────────────────────────────────────────
171
+
172
+ async def collect_batch(
173
+ self,
174
+ timeout_s: float = 5.0,
175
+ max_events: int = 100,
176
+ ) -> list[Event]:
177
+ """
178
+ Collect events into a batch (blocks until timeout or max_events reached).
179
+
180
+ Usage:
181
+ while True:
182
+ batch = await bus.collect_batch(timeout_s=1.0)
183
+ if batch:
184
+ await save_to_db(batch)
185
+ """
186
+ events: list[Event] = []
187
+ deadline = time.monotonic() + timeout_s
188
+
189
+ while len(events) < max_events:
190
+ remaining = deadline - time.monotonic()
191
+ if remaining <= 0:
192
+ break
193
+ try:
194
+ ev = await asyncio.wait_for(
195
+ self._batch_queue.get(),
196
+ timeout=remaining,
197
+ )
198
+ events.append(ev)
199
+ except TimeoutError:
200
+ break
201
+
202
+ return events
203
+
204
+ # ── Stats ──────────────────────────────────────────────────────────────────
205
+
206
+ def subscriber_count(self) -> int:
207
+ return len(self._subscriptions)
208
+
209
+ def stats(self) -> dict:
210
+ return {
211
+ "subscribers": len(self._subscriptions),
212
+ "queue_depth": self._batch_queue.qsize(),
213
+ }
214
+
215
+ # ── Matching logic ─────────────────────────────────────────────────────────
216
+
217
+ def _matches(self, event: Event, sub: Subscription, tid: str) -> bool:
218
+ if sub.source is not None and event.source not in sub.source:
219
+ return False
220
+ if sub.technique_filter is not None and tid not in sub.technique_filter:
221
+ return False
222
+ if sub.agent_filter is not None and event.agent_name not in sub.agent_filter:
223
+ return False
224
+ if sub.min_severity is not None and event.severity.sort_key() > sub.min_severity.sort_key():
225
+ return False
226
+ return True