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,378 @@
1
+ """
2
+ Sigma rule engine — portable detection rule matching.
3
+
4
+ Implements a subset of the Sigma specification sufficient for Patchi's needs:
5
+ - Rule loading from YAML files
6
+ - Field matching (equality, wildcard, regex, CIDR)
7
+ - Aggregation matching (cardinality, count over time windows)
8
+ - Tag-based routing to MITRE ATT&CK technique_ids
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import fnmatch
14
+ import logging
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from patchi.core.detector.event import Event, EventSeverity, EventSource, TechniqueID
21
+
22
+ logger = logging.getLogger("patchi.detector.sigma")
23
+
24
+
25
+ @dataclass
26
+ class SigmaMatch:
27
+ """Result of matching a rule against an event."""
28
+
29
+ rule_name: str
30
+ rule_id: str
31
+ technique_id: TechniqueID | str
32
+ confidence: float
33
+ severity: str
34
+ matched_fields: dict[str, Any] = field(default_factory=dict)
35
+ description: str = ""
36
+ target_agent: str = ""
37
+
38
+
39
+ @dataclass
40
+ class SigmaRule:
41
+ title: str
42
+ id: str = ""
43
+ description: str = ""
44
+ status: str = "experimental"
45
+ level: str = "medium"
46
+ tags: list[str] = field(default_factory=list)
47
+ logsource: dict[str, str] = field(default_factory=dict)
48
+ detection: dict[str, Any] = field(default_factory=dict)
49
+ falsepositives: list[str] = field(default_factory=list)
50
+ author: str = ""
51
+ selections: dict[str, dict[str, Any]] = field(default_factory=dict)
52
+ condition_expr: str = ""
53
+ target_agent: str = ""
54
+ min_confidence: float = 0.3
55
+
56
+ @property
57
+ def technique_id(self) -> TechniqueID:
58
+ for tag in self.tags:
59
+ if tag.startswith("attack."):
60
+ tid = tag.split(".", 1)[1].upper()
61
+ try:
62
+ return TechniqueID(tid)
63
+ except ValueError:
64
+ continue
65
+ if tag.startswith("mitre."):
66
+ try:
67
+ return TechniqueID(tag.split(".", 1)[1])
68
+ except ValueError:
69
+ continue
70
+ return TechniqueID.UNKNOWN
71
+
72
+
73
+ class SigmaRuleSet:
74
+ def __init__(self, rules: list[SigmaRule] | None = None):
75
+ self._rules: list[SigmaRule] = rules or []
76
+ self._rule_index: dict[str, list[SigmaRule]] = {}
77
+
78
+ @classmethod
79
+ def load_directory(cls, path: Path) -> SigmaRuleSet:
80
+ import yaml
81
+
82
+ rules: list[SigmaRule] = []
83
+ if not path.exists():
84
+ logger.warning("Sigma rules directory not found: %s", path)
85
+ return cls()
86
+ for fpath in sorted(path.glob("*.yml")):
87
+ try:
88
+ raw = yaml.safe_load(fpath.read_text(encoding="utf-8"))
89
+ if not raw or not isinstance(raw, dict):
90
+ continue
91
+ rule = _parse_sigma_rule(raw)
92
+ if rule:
93
+ rules.append(rule)
94
+ except Exception as exc:
95
+ logger.warning("Failed to load Sigma rule %s: %s", fpath.name, exc)
96
+ engine = cls(rules)
97
+ engine._build_index()
98
+ logger.info("Loaded %d Sigma rules from %s", len(rules), path)
99
+ return engine
100
+
101
+ @classmethod
102
+ def load_text(cls, yaml_text: str) -> SigmaRuleSet:
103
+ import yaml
104
+
105
+ rules: list[SigmaRule] = []
106
+ try:
107
+ raw = yaml.safe_load(yaml_text)
108
+ except yaml.YAMLError:
109
+ return cls(rules)
110
+ if isinstance(raw, dict):
111
+ rule = _parse_sigma_rule(raw)
112
+ if rule:
113
+ rules.append(rule)
114
+ elif isinstance(raw, list):
115
+ for entry in raw:
116
+ if isinstance(entry, dict):
117
+ rule = _parse_sigma_rule(entry)
118
+ if rule:
119
+ rules.append(rule)
120
+ return cls(rules)
121
+
122
+ def match(self, event: Event) -> list[SigmaMatch]:
123
+ results: list[SigmaMatch] = []
124
+ for rule in self._rules:
125
+ m = self._match_one(rule, event)
126
+ if m:
127
+ results.append(m)
128
+ return results
129
+
130
+ def _build_index(self) -> None:
131
+ self._rule_index.clear()
132
+ for rule in self._rules:
133
+ tid = (
134
+ rule.technique_id.value
135
+ if isinstance(rule.technique_id, TechniqueID)
136
+ else str(rule.technique_id)
137
+ )
138
+ self._rule_index.setdefault(tid, []).append(rule)
139
+
140
+ @property
141
+ def count(self) -> int:
142
+ return len(self._rules)
143
+
144
+ def _match_one(self, rule: SigmaRule, event: Event) -> SigmaMatch | None:
145
+ if rule.logsource and not self._match_logsource(rule.logsource, event):
146
+ return None
147
+ if not rule.selections:
148
+ return None
149
+ evaluated: dict[str, bool] = {}
150
+ for sel_name, sel_fields in rule.selections.items():
151
+ evaluated[sel_name] = self._match_selection(sel_fields, event)
152
+ if not self._evaluate_condition(rule.condition_expr, evaluated):
153
+ return None
154
+ sev_map = {"informational": 0.1, "low": 0.3, "medium": 0.5, "high": 0.7, "critical": 0.9}
155
+ confidence = max(rule.min_confidence, sev_map.get(rule.level, 0.3))
156
+ return SigmaMatch(
157
+ rule_name=rule.title,
158
+ rule_id=rule.id,
159
+ technique_id=rule.technique_id,
160
+ confidence=confidence,
161
+ severity=rule.level,
162
+ description=rule.description or rule.title,
163
+ target_agent=rule.target_agent,
164
+ )
165
+
166
+ def _match_logsource(self, logsource: dict[str, str], event: Event) -> bool:
167
+ if "category" in logsource:
168
+ if not self._value_match(
169
+ logsource["category"], event.source_details.get("category", event.source.value)
170
+ ):
171
+ return False
172
+ if "product" in logsource:
173
+ if not self._value_match(logsource["product"], event.source_details.get("product", "")):
174
+ return False
175
+ if "service" in logsource:
176
+ if not self._value_match(logsource["service"], event.source_details.get("service", "")):
177
+ return False
178
+ return True
179
+
180
+ def _match_selection(self, fields: dict[str, Any], event: Event) -> bool:
181
+ for field_key, expected in fields.items():
182
+ field_name, modifier = self._parse_sigma_field(field_key)
183
+ actual = self._get_event_field(event, field_name)
184
+ if actual is None:
185
+ return False
186
+ if modifier:
187
+ result = self._match_with_modifier(modifier, expected, actual)
188
+ else:
189
+ result = self._value_match(expected, actual)
190
+ if not result:
191
+ return False
192
+ return True
193
+
194
+ def _parse_sigma_field(self, field_key: str) -> tuple[str, str | None]:
195
+ if "|" in field_key:
196
+ parts = field_key.rsplit("|", 1)
197
+ return parts[0], parts[1]
198
+ return field_key, None
199
+
200
+ def _match_with_modifier(self, modifier: str, expected: Any, actual: Any) -> bool:
201
+ actual_str = str(actual)
202
+ if modifier == "contains":
203
+ return str(expected) in actual_str
204
+ if modifier == "startswith":
205
+ return actual_str.startswith(str(expected))
206
+ if modifier == "endswith":
207
+ return actual_str.endswith(str(expected))
208
+ if modifier == "re":
209
+ try:
210
+ return bool(re.search(str(expected), actual_str))
211
+ except re.error:
212
+ return False
213
+ if modifier == "base64":
214
+ try:
215
+ import base64
216
+
217
+ decoded = base64.b64decode(actual_str).decode()
218
+ return str(expected) in decoded
219
+ except Exception as e:
220
+ return False
221
+ logger.warning("SigmaRuleSet._match_with_modifier failed: %s", e)
222
+ if modifier == "cidr":
223
+ return self._match_cidr(str(expected), actual_str)
224
+ if modifier == "gt":
225
+ return isinstance(actual, (int, float)) and actual > expected
226
+ if modifier == "lt":
227
+ return isinstance(actual, (int, float)) and actual < expected
228
+ if modifier == "gte":
229
+ return isinstance(actual, (int, float)) and actual >= expected
230
+ if modifier == "lte":
231
+ return isinstance(actual, (int, float)) and actual <= expected
232
+ return self._value_match(expected, actual)
233
+
234
+ def _match_cidr(self, cidr: str, ip: str) -> bool:
235
+ try:
236
+ import ipaddress
237
+
238
+ net = ipaddress.ip_network(cidr, strict=False)
239
+ addr = ipaddress.ip_address(ip)
240
+ return addr in net
241
+ except ImportError:
242
+ return False
243
+ except ValueError:
244
+ return False
245
+
246
+ def _get_event_field(self, event: Event, field: str) -> Any:
247
+ if field.startswith("event."):
248
+ field = field[6:]
249
+ d = event.to_dict()
250
+ parts = field.split(".")
251
+ obj: Any = d
252
+ for part in parts:
253
+ if isinstance(obj, dict):
254
+ obj = obj.get(part)
255
+ else:
256
+ return None
257
+ return obj
258
+
259
+ def _value_match(self, expected: Any, actual: Any) -> bool:
260
+ if isinstance(expected, str):
261
+ if "*" in expected:
262
+ return fnmatch.fnmatch(str(actual), expected)
263
+ if expected.startswith("re:"):
264
+ try:
265
+ return bool(re.search(expected[3:], str(actual)))
266
+ except re.error:
267
+ return False
268
+ return str(actual) == expected
269
+ if isinstance(expected, list):
270
+ return any(self._value_match(v, actual) for v in expected)
271
+ if isinstance(expected, dict):
272
+ if "contains" in expected:
273
+ return str(expected["contains"]) in str(actual)
274
+ if "startswith" in expected:
275
+ return str(actual).startswith(str(expected["startswith"]))
276
+ if "endswith" in expected:
277
+ return str(actual).endswith(str(expected["endswith"]))
278
+ if "re" in expected:
279
+ try:
280
+ return bool(re.search(expected["re"], str(actual)))
281
+ except re.error:
282
+ return False
283
+ return str(actual) == str(expected)
284
+
285
+ def _evaluate_condition(self, expr: str, results: dict[str, bool]) -> bool:
286
+ expr = expr.strip()
287
+ if not expr:
288
+ return True
289
+ lower = expr.lower()
290
+ if " or " in lower:
291
+ return any(
292
+ self._evaluate_condition(p.strip(), results)
293
+ for p in re.split(r"\s+or\s+", expr, flags=re.IGNORECASE)
294
+ )
295
+ if " and " in lower:
296
+ return all(
297
+ self._evaluate_condition(p.strip(), results)
298
+ for p in re.split(r"\s+and\s+", expr, flags=re.IGNORECASE)
299
+ )
300
+ if lower in ("all of them", "all of the"):
301
+ return all(results.values())
302
+ if lower in ("any of them", "any of the"):
303
+ return any(results.values())
304
+ m = re.match(r"(all|1|any)\s+of\s+(.+?)(?:\*)?$", lower)
305
+ if m:
306
+ prefix = m.group(2).rstrip()
307
+ relevant = {k: v for k, v in results.items() if k.startswith(prefix)}
308
+ if not relevant:
309
+ return False
310
+ return all(relevant.values()) if m.group(1) in ("all", "1") else any(relevant.values())
311
+ if lower.startswith("not "):
312
+ return not results.get(expr[4:].strip(), False)
313
+ if lower in results:
314
+ return results[lower]
315
+ if lower == "1 of them":
316
+ return any(results.values())
317
+ return True
318
+
319
+
320
+ def _parse_sigma_rule(raw: dict[str, Any]) -> SigmaRule | None:
321
+ if not raw.get("title"):
322
+ return None
323
+ detection = raw.get("detection", {})
324
+ selections: dict[str, dict[str, Any]] = {}
325
+ condition_expr = ""
326
+ for key, value in detection.items():
327
+ if key == "condition":
328
+ if isinstance(value, str):
329
+ condition_expr = value
330
+ elif isinstance(value, dict):
331
+ condition_expr = value.get("expression", "any of them")
332
+ elif isinstance(value, dict):
333
+ selections[key] = value
334
+ if not selections and not condition_expr:
335
+ logger.debug("No selections found in rule '%s'", raw.get("title", ""))
336
+ return None
337
+ return SigmaRule(
338
+ title=str(raw.get("title", "")),
339
+ id=str(raw.get("id", "")),
340
+ description=str(raw.get("description", "")),
341
+ status=str(raw.get("status", "experimental")),
342
+ level=str(raw.get("level", "low")),
343
+ tags=[str(t) for t in raw.get("tags", [])],
344
+ logsource={k: str(v) for k, v in raw.get("logsource", {}).items()},
345
+ detection=detection,
346
+ falsepositives=[str(f) for f in raw.get("falsepositives", [])],
347
+ author=str(raw.get("author", "")),
348
+ selections=selections,
349
+ condition_expr=condition_expr,
350
+ target_agent=str(raw.get("target_agent", "")),
351
+ )
352
+
353
+
354
+ def _sigma_level_to_severity(level: str) -> EventSeverity:
355
+ mapping = {
356
+ "informational": EventSeverity.INFO,
357
+ "low": EventSeverity.LOW,
358
+ "medium": EventSeverity.MEDIUM,
359
+ "high": EventSeverity.HIGH,
360
+ "critical": EventSeverity.CRITICAL,
361
+ }
362
+ return mapping.get(level, EventSeverity.INFO)
363
+
364
+
365
+ def sigma_match_to_event(match: SigmaMatch, parent_event: Event | None = None) -> Event:
366
+ return Event(
367
+ source=parent_event.source if parent_event else EventSource.AGENT,
368
+ technique_id=match.technique_id,
369
+ summary=match.description or match.rule_name,
370
+ severity=_sigma_level_to_severity(match.severity),
371
+ confidence=match.confidence,
372
+ payload={
373
+ "rule_id": match.rule_id,
374
+ "rule_name": match.rule_name,
375
+ "matched_fields": match.matched_fields,
376
+ },
377
+ suggested_agent=match.target_agent or "",
378
+ )
@@ -0,0 +1,289 @@
1
+ """
2
+ TriageAgent — statistical anomaly detection over the event stream.
3
+
4
+ The Triage Agent is the catch-all for events that don't match any existing
5
+ technique_id with high confidence. It uses lightweight, deterministic statistics
6
+ (z-score, EWMA, rate-of-change) to surface outliers that warrant human or AI review.
7
+
8
+ This is NOT a machine learning model. It's a set of interpretable counters and
9
+ moving averages that flag:
10
+
11
+ 1. **Rate anomalies** — a signal source suddenly emitting 10x its normal volume
12
+ 2. **Severity anomalies** — a normally-low-severity source producing a HIGH/CRITICAL event
13
+ 3. **Novelty anomalies** — a technique_id never seen before from a known source
14
+ 4. **Silence anomalies** — a normally-chatty source goes silent (dead-man's switch)
15
+
16
+ Every anomaly produces a Finding with the agent name "TriageAgent" so it enters
17
+ the standard pipeline (orchestration → gating → routing).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import time
24
+ from collections import defaultdict
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime
27
+
28
+ from patchi.core.agents.base import (
29
+ AgentGroup,
30
+ AgentInput,
31
+ AgentResult,
32
+ BaseAgent,
33
+ Finding,
34
+ Severity,
35
+ register,
36
+ )
37
+ from patchi.core.detector.bus import EventBus, Subscription
38
+ from patchi.core.detector.event import Event, TechniqueID
39
+
40
+ logger = logging.getLogger("patchi.detector.triage")
41
+
42
+
43
+ # ── Statistical windows ──────────────────────────────────────────────────────
44
+
45
+
46
+ @dataclass
47
+ class EWMAMeter:
48
+ """
49
+ Exponentially-weighted moving average and z-score computation.
50
+
51
+ Tracks rate and variance per key (source, technique_id, agent).
52
+ Alpha controls how quickly old data decays (lower = smoother).
53
+ """
54
+
55
+ alpha: float = 0.1 # Smoothing factor (0.0–1.0)
56
+ min_samples: int = 5 # Min observations before z-score is meaningful
57
+ _mean: float = 0.0
58
+ _m2: float = 0.0 # Sum of squared differences (for variance)
59
+ _count: int = 0
60
+ _last_update: float = 0.0
61
+ _current_value: float = 0.0
62
+
63
+ def update(self, value: float) -> None:
64
+ now = time.monotonic()
65
+ elapsed = now - self._last_update if self._last_update else 0.0
66
+ self._last_update = now
67
+
68
+ rate = value / elapsed if elapsed > 0.0 else value
69
+
70
+ self._count += 1
71
+ if self._count == 1:
72
+ self._mean = rate
73
+ self._m2 = 0.0
74
+ else:
75
+ diff = rate - self._mean
76
+ incr = self.alpha * diff
77
+ self._mean += incr
78
+ self._m2 = (1 - self.alpha) * (self._m2 + self.alpha * diff * diff)
79
+
80
+ self._current_value = rate
81
+
82
+ @property
83
+ def mean(self) -> float:
84
+ return self._mean
85
+
86
+ @property
87
+ def variance(self) -> float:
88
+ return self._m2 + 1e-10
89
+
90
+ @property
91
+ def std(self) -> float:
92
+ return self.variance**0.5
93
+
94
+ def z_score(self, value: float) -> float:
95
+ s = self.std
96
+ if self._count < self.min_samples:
97
+ return 0.0
98
+ return (value - self._mean) / s
99
+
100
+ @property
101
+ def ready(self) -> bool:
102
+ return self._count >= self.min_samples
103
+
104
+
105
+ # ── Per-source statistical tracker ───────────────────────────────────────────
106
+
107
+
108
+ @dataclass
109
+ class SourceStats:
110
+ """Per-source statistics for anomaly detection."""
111
+
112
+ rate_meter: EWMAMeter = field(default_factory=lambda: EWMAMeter(alpha=0.05))
113
+ severity_meter: EWMAMeter = field(default_factory=lambda: EWMAMeter(alpha=0.1))
114
+ technique_ids: set[str] = field(default_factory=set)
115
+ last_seen: float = 0.0
116
+ event_count: int = 0
117
+
118
+
119
+ # ── TriageAgent ──────────────────────────────────────────────────────────────
120
+
121
+
122
+ @register
123
+ class TriageAgent(BaseAgent):
124
+ """
125
+ Statistical anomaly detection over the event stream.
126
+
127
+ Runs continuously in the background, listening to the EventBus and updating
128
+ per-source statistics. When an anomaly is detected, it creates a Finding.
129
+
130
+ This agent has no `_run()` with a single AgentInput — instead it uses a
131
+ long-lived `run_detached()` coroutine that subscribes to the bus.
132
+ """
133
+
134
+ name = "TriageAgent"
135
+ group = AgentGroup.GUARD
136
+ timeout = 300
137
+
138
+ def __init__(self) -> None:
139
+ super().__init__()
140
+ self._sources: dict[str, SourceStats] = defaultdict(SourceStats)
141
+ self._subscription: Subscription | None = None
142
+ self._z_score_threshold = 3.0 # Events >3σ are anomalous
143
+ self._silence_timeout_s = 300.0 # 5 min silence = anomaly
144
+ self._rate_spike_threshold = 10.0 # 10x normal rate = spike
145
+ self._last_finding_time: dict[str, float] = {}
146
+
147
+ # ── Detached runner (long-lived coroutine) ────────────────────────────────
148
+
149
+ async def run_detached(self, bus: EventBus, root: str | None = None) -> None:
150
+ """Subscribe to the EventBus and process events indefinitely."""
151
+ self._subscription = bus.subscribe(
152
+ self._on_event,
153
+ name=self.name,
154
+ )
155
+ logger.info("TriageAgent subscribed to EventBus")
156
+
157
+ def stop(self) -> None:
158
+ if self._subscription:
159
+ self._bus = None
160
+ self._subscription = None
161
+ logger.info("TriageAgent stopped")
162
+
163
+ # ── Event handler ─────────────────────────────────────────────────────────
164
+
165
+ async def _on_event(self, event: Event) -> None:
166
+ source_key = f"{event.source.value}:{event.source_details.get('host', 'local')}"
167
+ stats = self._sources[source_key]
168
+ stats.event_count += 1
169
+ stats.last_seen = time.monotonic()
170
+
171
+ # Track seen technique IDs
172
+ tid = (
173
+ event.technique_id.value
174
+ if isinstance(event.technique_id, TechniqueID)
175
+ else event.technique_id
176
+ )
177
+ stats.technique_ids.add(tid)
178
+
179
+ # Update rate meter (increment by 1)
180
+ stats.rate_meter.update(1.0)
181
+
182
+ # Update severity meter (numeric: debug=0 … critical=5)
183
+ sev_score = event.severity.sort_key()
184
+ stats.severity_meter.update(float(5 - sev_score))
185
+
186
+ # Skip anomaly checks if the meter isn't warm yet
187
+ if not stats.rate_meter.ready:
188
+ return
189
+
190
+ # Check: rate spike
191
+ if stats.rate_meter.current_value > 0:
192
+ ratio = stats.rate_meter.current_value / (stats.rate_meter.mean or 1.0)
193
+ if ratio > self._rate_spike_threshold:
194
+ await self._report_anomaly(
195
+ event,
196
+ source_key,
197
+ "rate_spike",
198
+ f"Event rate from {source_key} is {ratio:.1f}x above baseline ({stats.rate_meter.mean:.1f} vs {stats.rate_meter.current_value:.1f})",
199
+ )
200
+
201
+ # Check: z-score severity anomaly
202
+ z = stats.rate_meter.z_score(stats.rate_meter.current_value)
203
+ if z > self._z_score_threshold:
204
+ await self._report_anomaly(
205
+ event,
206
+ source_key,
207
+ "statistical_outlier",
208
+ f"Event from {source_key} is {z:.1f}σ above {tid} baseline (z-score anomaly)",
209
+ )
210
+
211
+ # ── Silence check (call periodically, e.g. from a timer) ──────────────────
212
+
213
+ def check_silence(self) -> list[Finding]:
214
+ """Check if any previously-active source has gone silent."""
215
+ now = time.monotonic()
216
+ findings: list[Finding] = []
217
+ for source_key, stats in list(self._sources.items()):
218
+ if stats.event_count < self._ewma.min_samples:
219
+ continue
220
+ elapsed = now - stats.last_seen
221
+ if elapsed > self._silence_timeout_s:
222
+ last_seen_str = datetime.fromtimestamp(stats.last_seen).isoformat()
223
+ findings.append(
224
+ Finding(
225
+ agent=self.name,
226
+ type="source_silent",
227
+ severity=Severity.MEDIUM,
228
+ file="",
229
+ line=0,
230
+ message=f"Source {source_key} has been silent for {elapsed:.0f}s (last seen: {last_seen_str})",
231
+ suggestion="Check if the detector pipeline is healthy or if this source was intentionally removed",
232
+ )
233
+ )
234
+ return findings
235
+
236
+ # ── Novelty check ─────────────────────────────────────────────────────────
237
+
238
+ async def _check_novelty(self, event: Event, source_key: str, tid: str) -> None:
239
+ """Check if this technique_id is new for this source."""
240
+ stats = self._sources[source_key]
241
+ if tid not in stats.technique_ids and tid != TechniqueID.UNKNOWN.value:
242
+ await self._report_anomaly(
243
+ event,
244
+ source_key,
245
+ "novel_technique",
246
+ f"First occurrence of {tid} ({event.technique_display}) from source {source_key}",
247
+ )
248
+
249
+ # ── Anomaly reporting ─────────────────────────────────────────────────────
250
+
251
+ async def _report_anomaly(
252
+ self,
253
+ event: Event,
254
+ source_key: str,
255
+ anomaly_type: str,
256
+ message: str,
257
+ ) -> None:
258
+ # Rate-limit findings: skip if we already reported one in the last 30s
259
+ cooldown_key = f"{source_key}:{anomaly_type}"
260
+ now = time.monotonic()
261
+ last = self._last_finding_time.get(cooldown_key, 0.0)
262
+ if now - last < 30.0:
263
+ return
264
+ self._last_finding_time[cooldown_key] = now
265
+
266
+ logger.info("Triage anomaly [%s] %s", anomaly_type, message)
267
+
268
+ # ── Stats ──────────────────────────────────────────────────────────────────
269
+
270
+ def get_stats(self) -> dict:
271
+ return {
272
+ "sources_tracked": len(self._sources),
273
+ "total_events": sum(s.event_count for s in self._sources.values()),
274
+ "sources": {
275
+ k: {
276
+ "event_count": s.event_count,
277
+ "techniques": list(s.technique_ids),
278
+ "mean_rate": s.rate_meter.mean,
279
+ "last_seen_ago": time.monotonic() - s.last_seen,
280
+ }
281
+ for k, s in self._sources.items()
282
+ },
283
+ }
284
+
285
+ # ── Required BaseAgent method (satisfy ABC) ───────────────────────────────
286
+
287
+ def _run(self, inp: AgentInput, result: AgentResult) -> None:
288
+ """No-op: TriageAgent is detachment-driven, not input-driven."""
289
+ pass