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,1004 @@
1
+ """
2
+ Domain Loader — loads security domain taxonomies and fix playbooks.
3
+
4
+ Domains are YAML files under domains/ defining control requirements
5
+ (e.g. OWASP ASVS chapters mapped to Patchi controls). Fix playbooks
6
+ under fix-playbooks/ define how to remediate each control.
7
+
8
+ Usage:
9
+ loader = DomainLoader(root)
10
+ domain = loader.get_domain("web-frontend")
11
+ for ctrl in domain.controls:
12
+ playbook = loader.get_playbook(ctrl.control_id)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import logging
19
+ import os
20
+ import pickle
21
+ import re
22
+ import tempfile
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ _log = logging.getLogger("patchi.security.domain_loader")
28
+
29
+ # Bump when the pickled payload shape changes (new dataclass field, schema
30
+ # semantics, etc.) so stale caches from older code are ignored, not mis-read.
31
+ # Content edits to the YAML and edits to this module both invalidate the cache
32
+ # automatically via the fingerprint; this constant is the manual escape hatch.
33
+ _CACHE_VERSION = 1
34
+
35
+ # ── Component-type scoping ────────────────────────────────────────────────────
36
+ # Canonical aliases shared with AppProfileScorer so a scan target's component
37
+ # type (e.g. "frontend-web") matches domain files that declare "frontend" or
38
+ # "web-frontend". Keep in sync with the top-level component_type in the YAMLs.
39
+ _COMPONENT_ALIASES = {
40
+ "infrastructure": "infra",
41
+ "frontend": "frontend-web",
42
+ "web-frontend": "frontend-web",
43
+ }
44
+
45
+
46
+ def normalize_component_type(ct) -> str:
47
+ """Canonicalize a component-type token (case/alias-insensitive)."""
48
+ key = (ct or "").strip().lower()
49
+ return _COMPONENT_ALIASES.get(key, key)
50
+
51
+
52
+ # Cheap top-level key scan: only lines starting at column 0 count, so nested
53
+ # (indented) keys and inline mentions are ignored. Used to build the domain
54
+ # index without paying for a full YAML parse of every file.
55
+ _INDEX_RE = re.compile(r'^(domain_id|component_type):\s*"?([^"#\n]+?)"?\s*$', re.MULTILINE)
56
+
57
+
58
+ def _cache_dir() -> Path:
59
+ """Per-user cache directory shared across processes and projects.
60
+
61
+ The domain taxonomy ships with the installed package, so one cache entry
62
+ serves every scanned project on this machine. Never written into the
63
+ scanned project (which may be read-only or a third-party checkout).
64
+ """
65
+ if os.name == "nt":
66
+ base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
67
+ else:
68
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
69
+ return Path(base) / "patchi" / "domain-cache"
70
+
71
+
72
+ # Module-level stat cache: {dir_path_str: (mtime, file_mtimes_dict)}
73
+ _stat_cache: dict[str, tuple[float, dict[str, float]]] = {}
74
+
75
+
76
+ def _stat_fingerprint(domains_dir: Path, playbooks_dir: Path) -> str:
77
+ """Fast fingerprint with per-file stat caching.
78
+
79
+ First call stats all files (O(n)). Subsequent calls reuse cached stats
80
+ and only re-stat files whose parent directory mtime changed — making
81
+ repeated calls O(1) when nothing changed, and O(changed) otherwise.
82
+ """
83
+ parts: list[str] = []
84
+ for d in (domains_dir, playbooks_dir):
85
+ if not d.is_dir():
86
+ parts.append(f"{d.name}:0:0")
87
+ continue
88
+ try:
89
+ dir_mtime = d.stat().st_mtime
90
+ except OSError:
91
+ parts.append(f"{d.name}:0:0")
92
+ continue
93
+
94
+ cache_key = str(d)
95
+ cached = _stat_cache.get(cache_key)
96
+ if cached and cached[0] == dir_mtime:
97
+ # Directory didn't change — reuse cached file mtimes
98
+ file_mtimes = cached[1]
99
+ else:
100
+ # Directory changed — re-stat all files
101
+ file_mtimes = {}
102
+ for f in d.glob("*.yaml"):
103
+ try:
104
+ file_mtimes[f.name] = f.stat().st_mtime
105
+ except OSError:
106
+ pass
107
+ _stat_cache[cache_key] = (dir_mtime, file_mtimes)
108
+
109
+ count = len(file_mtimes)
110
+ mtime_sum = sum(file_mtimes.values())
111
+ parts.append(f"{d.name}:{count}:{mtime_sum:.6f}")
112
+
113
+ return "|".join(parts) if parts else "empty"
114
+
115
+ def _content_fingerprint(domains_dir: Path, playbooks_dir: Path) -> str:
116
+ """Authoritative cache key: dir paths + name + full bytes of every YAML.
117
+
118
+ Content-hashed so a data edit can never silently serve a stale cache,
119
+ regardless of filesystem timestamp granularity (NTFS stores 100ns ticks,
120
+ so mtime-only keys can miss quick edits). The loader module's own
121
+ mtime/size is mixed in, so code changes that alter how objects are built
122
+ invalidate the cache automatically too.
123
+ """
124
+ h = hashlib.sha256()
125
+ for d in (domains_dir, playbooks_dir):
126
+ try:
127
+ h.update(str(d.resolve()).encode("utf-8", "replace"))
128
+ except OSError:
129
+ pass
130
+ if not d.is_dir():
131
+ continue
132
+ for f in sorted(d.glob("*.yaml")):
133
+ try:
134
+ h.update(f.name.encode("utf-8", "replace"))
135
+ h.update(f.read_bytes())
136
+ except OSError:
137
+ continue
138
+ try:
139
+ st = Path(__file__).stat()
140
+ h.update(b"|loader:")
141
+ h.update(str(st.st_mtime_ns).encode("ascii"))
142
+ h.update(str(st.st_size).encode("ascii"))
143
+ except OSError:
144
+ pass
145
+ return h.hexdigest()[:24]
146
+
147
+
148
+ def _cache_path(fingerprint: str) -> Path:
149
+ return _cache_dir() / f"v{_CACHE_VERSION}_{fingerprint}.pkl"
150
+
151
+
152
+ def _load_cached(fingerprint: str):
153
+ """Load (domains, playbooks) from the on-disk cache, or None on any miss.
154
+
155
+ The pickled payload is the built Domain/FixPlaybook objects, so a cache
156
+ hit skips both YAML parsing AND object construction (~17s -> <0.1s).
157
+ Shape- and type-checked so a truncated, corrupt, or wrong-schema payload
158
+ (e.g. pickled by older code) falls back to a fresh parse instead of
159
+ being served.
160
+ """
161
+ try:
162
+ path = _cache_path(fingerprint)
163
+ if not path.is_file():
164
+ return None
165
+ with open(path, "rb") as fh:
166
+ domains, playbooks = pickle.load(fh)
167
+ if not isinstance(domains, dict) or not isinstance(playbooks, dict):
168
+ return None
169
+ if not all(isinstance(d, Domain) for d in domains.values()):
170
+ return None
171
+ if not all(isinstance(p, FixPlaybook) for p in playbooks.values()):
172
+ return None
173
+ for d in domains.values():
174
+ if not all(isinstance(c, DomainControl) for c in d.controls):
175
+ return None
176
+ return domains, playbooks
177
+ except Exception as e: # noqa: BLE001 — cache is best-effort
178
+ _log.debug("DomainLoader cache load failed: %s", e)
179
+ return None
180
+
181
+
182
+ def _purge_stale_caches(d: Path, keep_fingerprint: str) -> None:
183
+ """Drop cache files older than a week so the dir can't grow unbounded.
184
+
185
+ Age-based only: never delete files with a different (possibly still
186
+ active) fingerprint, which may belong to another project or process.
187
+ """
188
+ cutoff = time.time() - 7 * 24 * 3600
189
+ try:
190
+ target = _cache_path(keep_fingerprint)
191
+ # Final cache files (v<ver>_<fp>.pkl) and orphaned temp files left by
192
+ # writers killed between mkstemp and os.replace (.tmp_<fp>_*.pkl).
193
+ for old in list(d.glob(f"v{_CACHE_VERSION}_*.pkl")) + list(d.glob(".tmp_*.pkl")):
194
+ if old == target:
195
+ continue
196
+ try:
197
+ if old.stat().st_mtime < cutoff:
198
+ old.unlink(missing_ok=True)
199
+ except OSError:
200
+ pass
201
+ except Exception as _exc: # noqa: BLE001
202
+ _log.debug('_purge_stale_caches skipped: %s', _exc)
203
+
204
+
205
+ def _save_cached(fingerprint: str, domains: dict, playbooks: dict) -> None:
206
+ """Atomically write the cache (unique temp file + rename); never raise."""
207
+ try:
208
+ d = _cache_dir()
209
+ d.mkdir(parents=True, exist_ok=True)
210
+ fd, tmp = tempfile.mkstemp(prefix=f".tmp_{fingerprint}_", suffix=".pkl", dir=d)
211
+ try:
212
+ with os.fdopen(fd, "wb") as fh:
213
+ pickle.dump((domains, playbooks), fh, protocol=pickle.HIGHEST_PROTOCOL)
214
+ os.replace(tmp, _cache_path(fingerprint))
215
+ except BaseException:
216
+ try:
217
+ os.unlink(tmp)
218
+ except OSError:
219
+ pass
220
+ raise
221
+ _purge_stale_caches(d, fingerprint)
222
+ except Exception as e: # noqa: BLE001
223
+ _log.debug("DomainLoader cache write failed: %s", e)
224
+
225
+
226
+ @dataclass
227
+ class DomainControl:
228
+ control_id: str
229
+ name: str
230
+ description: str
231
+ source_clause: str
232
+ severity: str
233
+ check_method: str
234
+ detector: str
235
+ remediation_ref: str
236
+ raw: dict = field(default_factory=dict)
237
+
238
+
239
+ @dataclass
240
+ class Domain:
241
+ domain_id: str
242
+ version: str
243
+ display_name: str
244
+ source_standard: str
245
+ component_type: str
246
+ weight: float
247
+ activation_signals: dict
248
+ controls: list[DomainControl]
249
+ raw: dict = field(default_factory=dict)
250
+
251
+
252
+ @dataclass
253
+ class FixPlaybook:
254
+ control_id: str
255
+ playbook_version: str
256
+ fix_strategy: str
257
+ deterministic_tool: str | None
258
+ llm_fix_template: str | None
259
+ verification_checks: list[str]
260
+ blast_radius_notes: str
261
+ human_review_required: bool
262
+ raw: dict = field(default_factory=dict)
263
+
264
+
265
+ def _try_load_yaml(path: Path) -> dict | None:
266
+ if not path.exists():
267
+ return None
268
+ try:
269
+ import yaml
270
+ try:
271
+ Loader = yaml.CSafeLoader # type: ignore[attr-defined]
272
+ except AttributeError:
273
+ Loader = yaml.SafeLoader
274
+ with open(path, encoding="utf-8") as f:
275
+ return yaml.load(f, Loader=Loader)
276
+ except ImportError:
277
+ try:
278
+ import json
279
+ with open(path, encoding="utf-8") as f:
280
+ return json.load(f)
281
+ except Exception as e:
282
+ import logging
283
+ logging.getLogger("patchi.security.domain_loader").warning(
284
+ "Failed to parse %s: %s", path, e
285
+ )
286
+ return None
287
+ except Exception as e:
288
+ import logging
289
+ logging.getLogger("patchi.security.domain_loader").warning(
290
+ "Failed to parse %s: %s", path, e
291
+ )
292
+ return None
293
+
294
+ def _to_float(value, default: float = 0.5) -> float:
295
+ """Coerce a YAML value to float, never raising on malformed data."""
296
+ try:
297
+ return float(value)
298
+ except (TypeError, ValueError):
299
+ return default
300
+
301
+
302
+ def _to_bool(value, default: bool = True) -> bool:
303
+ """Lenient bool coercion: accepts bools and common string spellings.
304
+
305
+ ``bool("false")`` is ``True`` in Python, which would silently invert
306
+ intent for hand-written YAML, so string values are parsed explicitly.
307
+ """
308
+ if isinstance(value, bool):
309
+ return value
310
+ if isinstance(value, (int, float)):
311
+ return bool(value)
312
+ if isinstance(value, str):
313
+ return value.strip().lower() in {"true", "yes", "y", "1", "on"}
314
+ if value is None:
315
+ return default
316
+ return default
317
+
318
+
319
+ def _to_str_list(value) -> list:
320
+ """Coerce to a list of strings; a bare string becomes a single item."""
321
+ if value is None:
322
+ return []
323
+ if isinstance(value, (list, tuple)):
324
+ return [str(v) for v in value]
325
+ return [str(value)]
326
+
327
+
328
+ class DomainLoader:
329
+ """Loads domain taxonomies and fix playbooks from YAML files."""
330
+
331
+ def __init__(self, root: Path, component_types=None, domain_ids=None):
332
+ self._root = root
333
+ self._domains_dir = root / "patchi" / "core" / "security" / "domains"
334
+ self._playbooks_dir = root / "patchi" / "core" / "security" / "fix-playbooks"
335
+ self._fallback_dirs()
336
+ self._domains: dict[str, Domain] = {}
337
+ self._playbooks: dict[str, FixPlaybook] = {}
338
+ self._loaded = False
339
+ self._last_stat: str = ""
340
+ self._last_fingerprint: str = ""
341
+ self._index: dict | None = None
342
+ self._scope_types: set[str] | None = None
343
+ self._scope_ids: set[str] | None = None
344
+ if component_types is not None or domain_ids is not None:
345
+ self.set_component_scope(component_types, domain_ids)
346
+
347
+ def set_component_scope(self, component_types=None, domain_ids=None) -> None:
348
+ """Restrict loading to a subset of the taxonomy.
349
+
350
+ ``component_types`` (str or iterable) loads only domain files whose
351
+ top-level component_type matches (aliases normalized, so "frontend"
352
+ matches a "frontend-web" scope). ``domain_ids`` additionally forces
353
+ specific domains to load regardless of their declared type — used by
354
+ AppProfileScorer for its explicit component->domain map (e.g. a
355
+ backend-api profile always loads the web-frontend domain). Pass
356
+ None/None (or an empty list — treated the same as None) to restore
357
+ full-taxonomy loading (the default).
358
+ """
359
+ if component_types is None and domain_ids is None:
360
+ self._scope_types = None
361
+ self._scope_ids = None
362
+ else:
363
+ types: set[str] = set()
364
+ if component_types:
365
+ if isinstance(component_types, str):
366
+ component_types = [component_types]
367
+ for ct in component_types:
368
+ types.add(normalize_component_type(ct))
369
+ self._scope_types = types
370
+ self._scope_ids = set(domain_ids or [])
371
+ # Any previously loaded subset is stale.
372
+ self._domains = {}
373
+ self._playbooks = {}
374
+ self._loaded = False
375
+
376
+ def _is_scoped(self) -> bool:
377
+ return bool(self._scope_types or self._scope_ids)
378
+
379
+ def _build_index(self) -> dict:
380
+ """Map domain_id -> {path, types} with a regex scan, no YAML parse.
381
+
382
+ ``types`` is the set of normalized component types, or None when the
383
+ top-level component_type could not be extracted (the file is then
384
+ parsed and authoritatively filtered during a scoped load). The index
385
+ is cached in-process, but invalidated whenever the stat signature of
386
+ the data directories changes — including for callers that only ask
387
+ for the index (``index_component_types``) without ever loading.
388
+ """
389
+ stat_fp = _stat_fingerprint(self._domains_dir, self._playbooks_dir)
390
+ if self._index is not None and stat_fp == self._index.get("_stat"):
391
+ return self._index
392
+ index: dict = {}
393
+ if self._domains_dir.exists():
394
+ for fpath in sorted(self._domains_dir.glob("*.yaml")):
395
+ try:
396
+ text = fpath.read_text(encoding="utf-8")
397
+ except OSError:
398
+ continue
399
+ fields: dict[str, str] = {}
400
+ for m in _INDEX_RE.finditer(text):
401
+ key, val = m.group(1), m.group(2).strip()
402
+ if key not in fields:
403
+ fields[key] = val
404
+ did = fields.get("domain_id")
405
+ if not did:
406
+ continue
407
+ raw = fields.get("component_type")
408
+ types = None
409
+ if raw:
410
+ types = {normalize_component_type(t) for t in raw.split(",")}
411
+ index[did] = {"path": fpath, "types": types}
412
+ index["_stat"] = stat_fp
413
+ self._index = index
414
+ return index
415
+
416
+ def index_component_types(self, domain_id: str) -> frozenset:
417
+ """Normalized component types of a domain, from the cheap index.
418
+
419
+ Never parses YAML — safe to call on a loader with no scope. The index
420
+ is refreshed automatically if the data files change.
421
+ """
422
+ entry = self._build_index().get(domain_id)
423
+ if not entry or not entry["types"]:
424
+ return frozenset()
425
+ return frozenset(entry["types"])
426
+
427
+ def _fallback_dirs(self):
428
+ pkg = Path(__file__).parent
429
+ if not self._domains_dir.exists():
430
+ self._domains_dir = pkg / "domains"
431
+ if not self._playbooks_dir.exists():
432
+ self._playbooks_dir = pkg / "fix-playbooks"
433
+
434
+ def _dirs_changed(self, content: bool = True) -> bool:
435
+ """True if the data changed since the last load.
436
+
437
+ The stat-only signature is the cheap trigger; only when it fires do we
438
+ pay for a full content hash, which is immune to timestamp-granularity
439
+ races (NTFS stores 100ns ticks). The content fingerprint doubles as
440
+ the on-disk cache key. ``content=False`` skips the hash entirely —
441
+ used by scoped loads, which bypass the on-disk cache and only need
442
+ in-process change detection. Residual limitation: an edit landing
443
+ within the same stat signature (same 100ns tick AND same size) goes
444
+ unnoticed for the remainder of this process — fresh processes always
445
+ recompute the content hash, so the on-disk cache can never go stale.
446
+ """
447
+ stat_fp = _stat_fingerprint(self._domains_dir, self._playbooks_dir)
448
+ if stat_fp == self._last_stat:
449
+ return False
450
+ if not content:
451
+ self._last_stat = stat_fp
452
+ self._last_fingerprint = ""
453
+ return True
454
+ content_fp = _content_fingerprint(self._domains_dir, self._playbooks_dir)
455
+ changed = content_fp != self._last_fingerprint
456
+ self._last_stat = stat_fp
457
+ self._last_fingerprint = content_fp
458
+ return changed
459
+
460
+ def _load_all(self):
461
+ if self._is_scoped():
462
+ self._load_scoped()
463
+ return
464
+ if self._loaded:
465
+ if not self._dirs_changed():
466
+ return
467
+ else:
468
+ # First load: initialize fingerprint tracking so the cache key is
469
+ # set before we probe the on-disk cache. Force the content hash
470
+ # even if a scoped load already set a stat signature — the scoped
471
+ # path intentionally skips content hashing, and the cache key must
472
+ # never be the empty sentinel.
473
+ stat_fp = _stat_fingerprint(self._domains_dir, self._playbooks_dir)
474
+ if stat_fp != self._last_stat or not self._last_fingerprint:
475
+ self._last_fingerprint = _content_fingerprint(
476
+ self._domains_dir, self._playbooks_dir
477
+ )
478
+ self._last_stat = stat_fp
479
+ self._loaded = True
480
+
481
+ # Fast path: reuse the shared on-disk cache keyed by content fingerprint.
482
+ fingerprint = self._last_fingerprint
483
+ cached = _load_cached(fingerprint)
484
+ if cached is not None:
485
+ self._domains, self._playbooks = cached
486
+ return
487
+
488
+ if self._domains_dir.exists():
489
+ for fpath in sorted(self._domains_dir.glob("*.yaml")):
490
+ domain = self._parse_domain_file(fpath)
491
+ if domain is not None:
492
+ self._domains[domain.domain_id] = domain
493
+
494
+ if self._playbooks_dir.exists():
495
+ for fpath in sorted(self._playbooks_dir.glob("*.yaml")):
496
+ self._playbooks.update(self._parse_playbook_file(fpath))
497
+
498
+ # Persist the freshly built objects so the next process starts fast.
499
+ _save_cached(fingerprint, self._domains, self._playbooks)
500
+
501
+ def _load_scoped(self):
502
+ """Load only the domains relevant to the active component scope.
503
+
504
+ The cheap regex index picks the candidate files (component_type match
505
+ and/or explicit domain ids) before any YAML parse, so a narrow scan
506
+ (e.g. frontend-web: ~13 of 334 files) never builds the full taxonomy.
507
+ The on-disk cache is intentionally bypassed: it stores the whole
508
+ taxonomy, and reading it would defeat the purpose of a scoped load.
509
+ """
510
+ if self._loaded:
511
+ if not self._dirs_changed(content=False):
512
+ return
513
+ # Data changed — the cached index may reference stale paths/types.
514
+ self._index = None
515
+ else:
516
+ self._dirs_changed(content=False)
517
+ self._loaded = True
518
+
519
+ scope_types = self._scope_types or set()
520
+ scope_ids = self._scope_ids or set()
521
+ index = self._build_index()
522
+
523
+ domains: dict[str, Domain] = {}
524
+ playbooks: dict[str, FixPlaybook] = {}
525
+ for did, entry in index.items():
526
+ if did == "_stat":
527
+ continue # internal cache sentinel, not a domain
528
+ if did in scope_ids:
529
+ pass # explicitly requested regardless of declared type
530
+ elif entry["types"] is not None and not (entry["types"] & scope_types):
531
+ continue # cheap index says this file can't match — skip
532
+ # Parse the file; the parsed component_type is authoritative
533
+ # (guards against a regex miss or an unusual file layout).
534
+ domain = self._parse_domain_file(entry["path"])
535
+ if domain is None:
536
+ continue
537
+ if did not in scope_ids:
538
+ parsed_types = {
539
+ normalize_component_type(t)
540
+ for t in str(domain.component_type).split(",")
541
+ if t.strip()
542
+ }
543
+ if not (parsed_types & scope_types):
544
+ continue
545
+ domains[domain.domain_id] = domain
546
+ pb_path = self._playbooks_dir / f"{entry['path'].stem}.playbook.yaml"
547
+ if pb_path.exists():
548
+ playbooks.update(self._parse_playbook_file(pb_path))
549
+
550
+ self._domains = domains
551
+ self._playbooks = playbooks
552
+
553
+ def _parse_domain_file(self, fpath: Path) -> Domain | None:
554
+ """Build a Domain from one YAML file, or None if it isn't one."""
555
+ data = _try_load_yaml(fpath)
556
+ if not data or "domain_id" not in data:
557
+ return None
558
+ controls: list[DomainControl] = []
559
+ for c in data.get("controls", []):
560
+ if not isinstance(c, dict):
561
+ _log.warning(
562
+ "DomainLoader: skipping non-dict control in %s",
563
+ fpath.name,
564
+ )
565
+ continue
566
+ cid = c.get("control_id")
567
+ name = c.get("name")
568
+ if not isinstance(cid, str) or not cid or not isinstance(name, str) or not name:
569
+ _log.warning(
570
+ "DomainLoader: skipping control without control_id/name in %s (keys=%s)",
571
+ fpath.name,
572
+ sorted(c)[:8],
573
+ )
574
+ continue
575
+ controls.append(
576
+ DomainControl(
577
+ control_id=cid,
578
+ name=name,
579
+ description=str(c.get("description", "")),
580
+ source_clause=str(c.get("source_clause", "")),
581
+ severity=str(c.get("severity", "medium")),
582
+ check_method=str(c.get("check_method", "static")),
583
+ detector=str(c.get("detector", "")),
584
+ remediation_ref=str(c.get("remediation_ref", "")),
585
+ raw=c,
586
+ )
587
+ )
588
+ did = data.get("domain_id")
589
+ if not isinstance(did, str) or not did:
590
+ return None
591
+ return Domain(
592
+ domain_id=did,
593
+ version=str(data.get("version", "1.0.0")),
594
+ display_name=str(data.get("display_name", did)),
595
+ source_standard=str(data.get("source_standard", "")),
596
+ component_type=str(data.get("component_type", "")),
597
+ weight=_to_float(data.get("weight"), 0.5),
598
+ activation_signals=data.get("activation_signals", {}) or {},
599
+ controls=controls,
600
+ raw=data,
601
+ )
602
+
603
+ def _parse_playbook_file(self, fpath: Path) -> dict[str, FixPlaybook]:
604
+ """Build {control_id: FixPlaybook} from one fix-playbook YAML file."""
605
+ result: dict[str, FixPlaybook] = {}
606
+ data = _try_load_yaml(fpath)
607
+ if not data or "playbooks" not in data:
608
+ return result
609
+ for pb in data["playbooks"]:
610
+ if not isinstance(pb, dict):
611
+ _log.warning(
612
+ "DomainLoader: skipping non-dict playbook entry in %s",
613
+ fpath.name,
614
+ )
615
+ continue
616
+ cid = pb.get("control_id")
617
+ if not isinstance(cid, str) or not cid:
618
+ # Also accept the legacy playbook_id key so a malformed or
619
+ # older-format file degrades gracefully instead of crashing
620
+ # the whole scan pipeline (see tools/fix_playbooks.py).
621
+ cid = pb.get("playbook_id")
622
+ if not isinstance(cid, str) or not cid:
623
+ _log.warning(
624
+ "DomainLoader: skipping playbook entry without control_id in %s (keys=%s)",
625
+ fpath.name,
626
+ sorted(pb)[:8],
627
+ )
628
+ continue
629
+ # Legacy schema support: remediation_steps/verification map onto
630
+ # the canonical llm_fix_template/verification_checks fields.
631
+ llm_template = pb.get("llm_fix_template")
632
+ legacy_steps = isinstance(pb.get("remediation_steps"), list)
633
+ if not llm_template and legacy_steps:
634
+ steps = pb["remediation_steps"]
635
+ parts = [f"{i + 1}. {s}" for i, s in enumerate(steps) if isinstance(s, str)]
636
+ notes = pb.get("implementation_notes")
637
+ if isinstance(notes, list):
638
+ parts.append("Implementation notes: " + " ".join(str(n) for n in notes))
639
+ if parts:
640
+ llm_template = "\n".join(parts)
641
+ verification = pb.get("verification_checks")
642
+ if not verification and pb.get("verification") is not None:
643
+ verification = pb["verification"]
644
+ fix_strategy = str(pb.get("fix_strategy") or "")
645
+ if not fix_strategy:
646
+ # Entries written in the legacy remediation_steps schema are
647
+ # LLM-fixable by construction — don't fall back to manual-only.
648
+ fix_strategy = "llm-template-fill" if legacy_steps else "manual-only"
649
+ result[cid] = FixPlaybook(
650
+ control_id=cid,
651
+ playbook_version=str(pb.get("playbook_version", "1.0.0")),
652
+ fix_strategy=fix_strategy,
653
+ deterministic_tool=pb.get("deterministic_tool"),
654
+ llm_fix_template=llm_template if isinstance(llm_template, str) else None,
655
+ verification_checks=_to_str_list(verification),
656
+ blast_radius_notes=str(pb.get("blast_radius_notes", "")),
657
+ human_review_required=_to_bool(pb.get("human_review_required"), True),
658
+ raw=pb,
659
+ )
660
+ return result
661
+
662
+ def list_domains(self) -> list[str]:
663
+ self._load_all()
664
+ return list(self._domains.keys())
665
+
666
+ def get_domain(self, domain_id: str) -> Domain | None:
667
+ self._load_all()
668
+ return self._domains.get(domain_id)
669
+
670
+ def find_control(self, control_id: str) -> DomainControl | None:
671
+ """Find a control by id in the loaded domains.
672
+
673
+ On a scoped loader this only searches the loaded (in-scope) domains —
674
+ controls belonging to out-of-scope domains return None, by design, so
675
+ a narrow scan never materializes the rest of the taxonomy.
676
+ """
677
+ self._load_all()
678
+ for domain in self._domains.values():
679
+ for ctrl in domain.controls:
680
+ if ctrl.control_id == control_id:
681
+ return ctrl
682
+ return None
683
+
684
+ def get_playbook(self, control_id: str) -> FixPlaybook | None:
685
+ """Get the fix playbook for a control.
686
+
687
+ On a scoped loader only playbooks for loaded (in-scope) domains are
688
+ available; out-of-scope controls return None by design.
689
+ """
690
+ self._load_all()
691
+ return self._playbooks.get(control_id)
692
+
693
+ def _build_keyword_index(self) -> dict[str, list[tuple[str, DomainControl, Domain]]]:
694
+ """Pre-build inverted index: keyword -> [(domain_id, control, domain)].
695
+
696
+ Called once after loading; makes match_finding_to_controls O(k) per
697
+ finding instead of O(domains * controls).
698
+ """
699
+ if hasattr(self, "_kw_index") and self._kw_index is not None:
700
+ return self._kw_index
701
+ index: dict[str, list[tuple[str, DomainControl, Domain]]] = {}
702
+ for domain in self._domains.values():
703
+ for ctrl in domain.controls:
704
+ # Index keywords from control name + description
705
+ ctrl_text = (ctrl.name + " " + ctrl.description).lower()
706
+ ctrl_domains = self._classify_domains(ctrl_text)
707
+ for kw_set_name in ctrl_domains:
708
+ if kw_set_name not in index:
709
+ index[kw_set_name] = []
710
+ index[kw_set_name].append((domain.domain_id, ctrl, domain))
711
+ self._kw_index = index
712
+ return index
713
+
714
+ @staticmethod
715
+ def _domain_keywords() -> dict[str, set[str]]:
716
+ return {
717
+ # ── Injection & Input ──────────────────────────────────────────
718
+ "injection": {
719
+ "sql", "nosql", "ldap", "command", "orm", "eval",
720
+ "deserialization", "hql", "injection", "template injection",
721
+ "xpath", "csv injection", "log injection", "header injection",
722
+ },
723
+ "xss": {
724
+ "xss", "cross-site", "cross site", "script injection",
725
+ "dom xss", "reflected xss", "stored xss", "self-xss",
726
+ },
727
+ "sqli": {"sql injection", "sqli", "blind sql", "union select", "stacked query"},
728
+ "ssrf": {
729
+ "ssrf", "server-side request forgery",
730
+ "server side request forgery", "url fetch",
731
+ },
732
+ "xxe": {"xxe", "xml external", "xml entity", "xml parser", "dtd"},
733
+ "rce": {"rce", "remote code", "code execution", "code injection", "command injection", "os command", "exec", "passthru", "system("},
734
+ "path_traversal": {
735
+ "path traversal", "directory traversal", "path injection",
736
+ "file inclusion", "local file", "lfi", "rfi", "dot dot slash",
737
+ },
738
+ "open_redirect": {
739
+ "open redirect", "url redirect", "redirect injection",
740
+ "unvalidated redirect", "302 redirect", "location header",
741
+ },
742
+ # ── Authentication & Session ───────────────────────────────────
743
+ "authentication": {
744
+ "auth", "authentication", "login", "password", "oauth",
745
+ "session", "token", "jwt", "sso", "identity",
746
+ "authenticate", "mfa", "totp", "2fa", "biometric",
747
+ "credential stuffing", "brute force", "account lockout",
748
+ },
749
+ "session": {
750
+ "session", "cookie", "session fixation", "session hijack",
751
+ "session timeout", "session token", "httpOnly", "secure flag",
752
+ },
753
+ "authorization": {
754
+ "authorization", "privilege", "role", "permission", "rbac",
755
+ "abac", "access control", "idor", "broken function",
756
+ "privilege escalation", "elevation of privilege",
757
+ },
758
+ # ── Cryptography ──────────────────────────────────────────────
759
+ "cryptography": {
760
+ "crypto", "encryption", "cipher", "hash", "tls", "ssl",
761
+ "certificate", "cryptographic", "key management",
762
+ "key exchange", "digital signature", "hmac", "aes", "rsa",
763
+ "pbkdf2", "bcrypt", "argon2", "scrypt", "nonce", "iv",
764
+ "quantum", "post-quantum", "ecc", "diffie",
765
+ },
766
+ # ── Web & Frontend ────────────────────────────────────────────
767
+ "cors": {"cors", "cross-origin", "cross origin", "wildcard origin", "access-control-allow"},
768
+ "csrf": {"csrf", "xsrf", "cross-site request", "cross site request", "request forgery", "anti-forgery"},
769
+ "clickjacking": {"clickjack", "frame injection", "x-frame-options", "frame-ancestors"},
770
+ "security_headers": {
771
+ "security header", "content-security-policy", "csp",
772
+ "strict-transport", "hsts", "x-content-type", "x-xss-protection",
773
+ "permissions-policy", "referrer-policy", "feature-policy",
774
+ },
775
+ # ── Data & Privacy ────────────────────────────────────────────
776
+ "sensitive_data": {
777
+ "sensitive data", "pii", "personal information",
778
+ "secret exposure", "credential leakage", "data leak",
779
+ "data breach", "data exposure", "data classification",
780
+ },
781
+ "secrets": {
782
+ "secret", "hardcoded", "credential", "api key",
783
+ "token exposure", "password in code", "secret scanning",
784
+ "private key", "secret key", "connection string",
785
+ },
786
+ # ── Dependencies & Supply Chain ───────────────────────────────
787
+ "dependency": {
788
+ "dependency", "cve", "supply chain", "third party",
789
+ "vulnerable package", "sbom", "outdated", "unmaintained",
790
+ },
791
+ "supply_chain": {
792
+ "supply chain", "dependency confusion", "malicious package",
793
+ "typosquatting", "artifact signing", "provenance",
794
+ },
795
+ # ── Infrastructure & Config ───────────────────────────────────
796
+ "misconfiguration": {
797
+ "misconfig", "security header", "hardening",
798
+ "insecure default", "security config", "missing header",
799
+ "debug mode", "verbose error", "default credential",
800
+ },
801
+ # ── Secrets & Hardcoded Credentials ────────────────────────────
802
+ "hardcoded_secret": {
803
+ "hardcoded", "secret", "credential", "api key", "private key",
804
+ "password in code", "token exposure", "connection string",
805
+ "secret key", "auth token", "bearer token", "access key",
806
+ },
807
+ "hardcoded_key": {
808
+ "hardcoded key", "hardcoded secret", "hardcoded password",
809
+ "hardcoded credential", "hardcoded token",
810
+ },
811
+ # ── Authentication & Access ──────────────────────────────────
812
+ "auth_bypass": {
813
+ "authentication bypass", "auth bypass", "login bypass",
814
+ "without authentication", "no auth", "unauthenticated",
815
+ "missing auth", "missing authentication",
816
+ },
817
+ "missing_auth": {
818
+ "without authentication", "no auth", "unauthenticated",
819
+ "missing auth", "missing authentication", "missing authorization",
820
+ },
821
+ # ── Debug & Config ───────────────────────────────────────────
822
+ "debug_mode": {
823
+ "debug mode", "debug=true", "debug on", "verbose error",
824
+ "stack trace", "exposed error", "error details",
825
+ "traceback", "exception detail", "internal error",
826
+ },
827
+ "insecure_config": {
828
+ "insecure default", "default credential", "default password",
829
+ "insecure configuration", "security misconfiguration",
830
+ "missing security header", "open port", "exposed service",
831
+ },
832
+ # ── Type Safety & Quality ────────────────────────────────────
833
+ "type_safety": {
834
+ "type annotation", "type hint", "missing type",
835
+ "untyped", "any type", "type error", "type safety",
836
+ },
837
+ # ── Technical Debt ───────────────────────────────────────────
838
+ "technical_debt": {
839
+ "todo", "fixme", "hack", "workaround", "bug",
840
+ "technical debt", "deprecated", "legacy", "temporary",
841
+ },
842
+ "dos": {"dos", "denial of service", "rate limit", "resource exhaustion", "ddos", "throttle", "backlog"},
843
+ "runtime": {"runtime", "container", "kubernetes", "docker", "orchestration", "falco", "pod", "node"},
844
+ "network": {"network", "firewall", "dns", "tcp", "port", "egress", "ingress", "proxy", "vpn", "tls termination"},
845
+ # ── Cloud ─────────────────────────────────────────────────────
846
+ "cloud_aws": {"aws", "s3", "ec2", "lambda", "iam", "cloudtrail", "kms", "rds", "ecs", "fargate", "sqs", "sns"},
847
+ "cloud_azure": {"azure", "blob storage", "key vault", "active directory", "arm template", "devops"},
848
+ "cloud_gcp": {"gcp", "gce", "gcs", "bigquery", "cloud functions", "gke", "secret manager", "cloud run"},
849
+ # ── Container & Orchestration ──────────────────────────────────
850
+ "container": {
851
+ "container", "dockerfile", "image", "layer", "registry",
852
+ "privileged", "root user", "capabilities", "seccomp",
853
+ "apparmor", "selinux", "read-only fs",
854
+ },
855
+ "kubernetes": {
856
+ "kubernetes", "k8s", "pod", "deployment", "service",
857
+ "ingress", "rbac", "networkpolicy", "podsecurity",
858
+ "etcd", "apiserver", "admission controller", "helm",
859
+ },
860
+ # ── CI/CD & DevOps ────────────────────────────────────────────
861
+ "cicd": {
862
+ "ci/cd", "pipeline", "github actions", "gitlab ci",
863
+ "jenkins", "build", "deploy", "artifact", "workflow",
864
+ "runner", "secret scanning", "cache poisoning",
865
+ },
866
+ # ── API Security ──────────────────────────────────────────────
867
+ "api_security": {
868
+ "api", "rest", "graphql", "grpc", "webhook",
869
+ "rate limit", "pagination", "versioning", "content negotiation",
870
+ "idempotency", "hateoas", "swagger", "openapi",
871
+ },
872
+ # ── Database ──────────────────────────────────────────────────
873
+ "database": {
874
+ "database", "db", "mysql", "postgres", "postgresql",
875
+ "mongodb", "redis", "elasticsearch", "cassandra",
876
+ "sqlite", "oracle", "sql server", "stored procedure",
877
+ "query", "connection pool", "row level security",
878
+ },
879
+ # ── Mobile ────────────────────────────────────────────────────
880
+ "mobile": {
881
+ "mobile", "android", "ios", "swift", "kotlin",
882
+ "react native", "flutter", "xamarin", "ionic",
883
+ "keychain", "keystore", "deep link", "intent",
884
+ "webview", "biometric", "jailbreak", "root detection",
885
+ },
886
+ # ── Desktop ───────────────────────────────────────────────────
887
+ "desktop": {
888
+ "electron", "tauri", "nwjs", "desktop",
889
+ "browser extension", "chrome extension", "firefox extension",
890
+ },
891
+ # ── Languages & Frameworks ────────────────────────────────────
892
+ "python": {"python", "django", "flask", "fastapi", "pylint", "bandit", "pip", "pyproject"},
893
+ "javascript": {"javascript", "nodejs", "node.js", "express", "npm", "package.json", "nextjs", "nuxt", "remix", "astro", "qwik", "svelte"},
894
+ "java": {"java", "spring", "tomcat", "maven", "gradle", "jvm", "jndi", "jsp", "jackson"},
895
+ "dotnet": {".net", "c#", "asp.net", "blazor", "nuget", "razor", "viewstate", "entity framework"},
896
+ "go": {"golang", "goroutine", "net/http", "gin framework", "echo framework", "fiber framework"},
897
+ "rust": {"rust", "cargo", "crate", "unsafe", "rustc", "tokio", "serde"},
898
+ "ruby": {"ruby", "rails", "rubygems", "bundler", "erb", "devise", "activerecord"},
899
+ "php": {"php", "laravel", "symfony", "composer", "wordpres", "drupal", "twig", "blade"},
900
+ "c_cpp": {"c++", "c language", "gcc", "clang", "buffer overflow", "format string", "malloc", "free"},
901
+ # ── Compliance ────────────────────────────────────────────────
902
+ "compliance": {"compliance", "regulatory", "gdpr", "hipaa", "pci", "sox", "audit", "soc2", "iso27001"},
903
+ "privacy": {"privacy", "consent", "data retention", "anonymization", "pseudonymization", "cookie consent", "tracking", "fingerprinting"},
904
+ # ── Fraud & Business Logic ────────────────────────────────────
905
+ "fraud": {"fraud", "bot", "scraping", "account takeover", "credential stuffing", "payment fraud", "coupon abuse", "referral abuse"},
906
+ "business_logic": {"business logic", "price manipulation", "race condition", "workflow bypass", "amount tampering", "state confusion", "toctou"},
907
+ # ── Incident Response & Monitoring ─────────────────────────────
908
+ "incident": {"incident", "breach", "forensic", "containment", "recovery", "escalation", "alert"},
909
+ "monitoring": {"monitoring", "logging", "audit log", "siem", "detection", "alerting", "telemetry"},
910
+ # ── Threat Modeling ───────────────────────────────────────────
911
+ "threat_model": {"threat model", "stride", "attack surface", "abuse case", "risk assessment", "mitigation tracking"},
912
+ # ── IaC & Cloud Config ────────────────────────────────────────
913
+ "terraform": {"terraform", "tf state", "tfvars", "provider", "module", "hcl"},
914
+ "ansible": {"ansible", "playbook", "vault", "role", "galaxy", "inventory"},
915
+ "helm": {"helm", "chart", "values.yaml", "template", "release"},
916
+ # ── IoT & Edge ────────────────────────────────────────────────
917
+ "iot": {"iot", "mqtt", "coap", "embedded", "firmware", "sensor", "actuator", "gateway"},
918
+ "edge": {"edge", "cdn", "worker", "service worker", "cache api", "push notification"},
919
+ # ── AI/ML ─────────────────────────────────────────────────────
920
+ "ai_ml": {"artificial intelligence", "machine learning", "llm", "neural network", "model training", "inference", "prompt injection", "rag", "vector database", "embedding", "fine-tune", "fine-tuning", "transformer"},
921
+ # ── Web3 & Blockchain ─────────────────────────────────────────
922
+ "web3": {"web3", "smart contract", "blockchain", "ethereum", "defi", "nft", "token", "oracle", "flash loan", "mev"},
923
+ # ── Game Security ─────────────────────────────────────────────
924
+ "game": {"game", "cheat", "speed hack", "item duplication", "memory corruption", "anticheat"},
925
+ # ── Post-Quantum ──────────────────────────────────────────────
926
+ "quantum": {"quantum", "post-quantum", "lattice", "kyber", "dilithium", "nist pqc"},
927
+ # ── Zero Trust ────────────────────────────────────────────────
928
+ "zero_trust": {"zero trust", "never trust", "verify every", "microsegment", "least privilege"},
929
+ }
930
+
931
+ def _classify_domains(self, text: str) -> set[str]:
932
+ """Classify text into security domains based on keyword sets."""
933
+ text_lower = text.lower().replace("_", " ").replace("-", " ")
934
+ matched = set()
935
+ for domain, kws in self._domain_keywords().items():
936
+ for kw in kws:
937
+ if kw in text_lower:
938
+ matched.add(domain)
939
+ break
940
+ return matched
941
+
942
+ def match_finding_to_controls(
943
+ self, finding_type: str, file_path: str, message: str
944
+ ) -> list[DomainControl]:
945
+ """Match a finding to domain controls using a pre-built keyword index.
946
+
947
+ O(k) per finding where k = number of matching keywords.
948
+ """
949
+ self._load_all()
950
+ idx = self._build_keyword_index()
951
+
952
+ combined = f"{message} {file_path} {finding_type}"
953
+ finding_domains = self._classify_domains(combined)
954
+ if not finding_domains:
955
+ return []
956
+
957
+ # Collect candidate controls from the inverted index
958
+ candidates = {}
959
+ for kw_set in finding_domains:
960
+ for _domain_id, ctrl, domain in idx.get(kw_set, []):
961
+ key = ctrl.control_id
962
+ if key not in candidates:
963
+ candidates[key] = [0, set(), ctrl, domain]
964
+ candidates[key][0] += 1
965
+ candidates[key][1].add(kw_set)
966
+
967
+ # Score and filter — require message-specific relevance
968
+ scored = []
969
+ msg_lower = (message or "").lower()
970
+ for _key, entry in candidates.items():
971
+ overlap_count, kw_sets, ctrl, domain = entry
972
+
973
+ # Base score from keyword overlap
974
+ score = overlap_count
975
+
976
+ # Strong boost when finding message words appear in the control name
977
+ if ctrl.name:
978
+ ctrl_name_words = {w for w in ctrl.name.lower().split() if len(w) > 3}
979
+ msg_words = {w for w in msg_lower.split() if len(w) > 3}
980
+ name_overlap = ctrl_name_words & msg_words
981
+ score += len(name_overlap) * 3
982
+
983
+ # Boost for exact phrase matches in the message
984
+ if ctrl.name and ctrl.name.lower() in msg_lower:
985
+ score += 5
986
+ elif ctrl.description and ctrl.description.lower()[:40] in msg_lower:
987
+ score += 3
988
+
989
+ # Boost when file path context matches
990
+ if file_path:
991
+ domain_words = domain.domain_id.replace("-", " ").split()
992
+ if any(w in file_path.lower() for w in domain_words if len(w) > 3):
993
+ score += 2
994
+
995
+ # Require at least some message-specific signal
996
+ # (prevents "technical debt" from matching "secrets in code")
997
+ has_msg_signal = bool(name_overlap) or score >= 5
998
+
999
+ if score >= 3 and has_msg_signal:
1000
+ scored.append((score, ctrl))
1001
+
1002
+ scored.sort(key=lambda x: (-x[0], x[1].severity != "critical"))
1003
+ return [ctrl for _, ctrl in scored[:5]]
1004
+