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,1005 @@
1
+ """
2
+ Taint analysis and secret detection agents for Patchi.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from patchi.core.constants import is_offline
16
+
17
+ from ..agents.base import (
18
+ AgentGroup,
19
+ AgentInput,
20
+ AgentResult,
21
+ BaseAgent,
22
+ Severity,
23
+ make_finding,
24
+ register,
25
+ )
26
+ from ..brain.ast_utils import track_taint
27
+ from ..brain.languages import EXTENSION_MAP
28
+
29
+ # ── Shared subprocess runner ───────────────────────────────────────────────────
30
+
31
+
32
+ _log = logging.getLogger("patchi.security.security_taint")
33
+
34
+
35
+ def _run(cmd: list[str], cwd: Path, timeout: int = 120, env: dict | None = None) -> dict:
36
+ import os
37
+
38
+ merged = {**os.environ, **(env or {})}
39
+ try:
40
+ proc = subprocess.run(
41
+ cmd,
42
+ capture_output=True,
43
+ text=True,
44
+ cwd=str(cwd),
45
+ timeout=timeout,
46
+ env=merged,
47
+ )
48
+ return {
49
+ "returncode": proc.returncode,
50
+ "stdout": proc.stdout[:10000],
51
+ "stderr": proc.stderr[:3000],
52
+ "timed_out": False,
53
+ }
54
+ except subprocess.TimeoutExpired:
55
+ return {"returncode": -1, "stdout": "", "stderr": "", "timed_out": True}
56
+ except Exception as e:
57
+ return {"returncode": -1, "stdout": "", "stderr": str(e), "timed_out": False}
58
+
59
+
60
+ def _call_ai(prompt: str, config: dict, max_tokens: int = 800) -> str:
61
+ """Call configured AI. Returns "" if unavailable or offline."""
62
+ if is_offline():
63
+ return ""
64
+ ai = config.get("ai", {})
65
+ local = ai.get("local_model_name")
66
+ if local:
67
+ return _call_ollama(local, prompt, max_tokens)
68
+ for key_cfg in ai.get("keys", []):
69
+ if key_cfg.get("status") == "error":
70
+ continue
71
+ env_var = key_cfg.get("env_var", "")
72
+ api_key = os.environ.get(env_var, "")
73
+ if not api_key:
74
+ continue
75
+ result = _call_openai_compat(
76
+ api_key,
77
+ key_cfg.get("base_url", ""),
78
+ key_cfg.get("model", ""),
79
+ key_cfg.get("format", "openai"),
80
+ prompt,
81
+ max_tokens,
82
+ )
83
+ if result:
84
+ return result
85
+ return ""
86
+
87
+
88
+ def _call_ollama(model: str, prompt: str, max_tokens: int) -> str:
89
+ import urllib.request
90
+
91
+ from patchi.core.constants import OLLAMA_GENERATE_URL
92
+
93
+ try:
94
+ payload = json.dumps(
95
+ {
96
+ "model": model,
97
+ "prompt": prompt,
98
+ "stream": False,
99
+ "options": {"num_predict": max_tokens},
100
+ }
101
+ ).encode()
102
+ req = urllib.request.Request(
103
+ OLLAMA_GENERATE_URL,
104
+ data=payload,
105
+ headers={"Content-Type": "application/json"},
106
+ method="POST",
107
+ )
108
+ with urllib.request.urlopen(req, timeout=30) as r:
109
+ return json.loads(r.read()).get("response", "")
110
+ except Exception as e:
111
+ _log.warning("_call_ollama failed: %s", e)
112
+ return ""
113
+
114
+
115
+ def _call_openai_compat(api_key, base_url, model, fmt, prompt, max_tokens) -> str:
116
+ import urllib.request
117
+
118
+ try:
119
+ if fmt == "anthropic":
120
+ payload = json.dumps(
121
+ {
122
+ "model": model,
123
+ "max_tokens": max_tokens,
124
+ "messages": [{"role": "user", "content": prompt}],
125
+ }
126
+ ).encode()
127
+ headers = {
128
+ "x-api-key": api_key,
129
+ "anthropic-version": "2023-06-01",
130
+ "Content-Type": "application/json",
131
+ }
132
+ url = f"{base_url}/messages"
133
+ else:
134
+ payload = json.dumps(
135
+ {
136
+ "model": model,
137
+ "max_tokens": max_tokens,
138
+ "messages": [{"role": "user", "content": prompt}],
139
+ }
140
+ ).encode()
141
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
142
+ url = f"{base_url}/chat/completions"
143
+ req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
144
+ with urllib.request.urlopen(req, timeout=30) as r:
145
+ data = json.loads(r.read())
146
+ if fmt == "anthropic":
147
+ return data.get("content", [{}])[0].get("text", "")
148
+ return data.get("choices", [{}])[0].get("message", {}).get("content", "")
149
+ except Exception as e:
150
+ _log.warning("_call_openai_compat failed: %s", e)
151
+ return ""
152
+
153
+
154
+ # ──────────────────────────────────────────────────────────────────────────────
155
+ # 1. TaintAnalyzer
156
+ # ──────────────────────────────────────────────────────────────────────────────
157
+
158
+ # Sink call names tracked structurally via tree-sitter AST (track_taint).
159
+ _TAINT_SINK_NAMES = {
160
+ "eval",
161
+ "exec",
162
+ "os.system",
163
+ "subprocess.run",
164
+ "subprocess.Popen",
165
+ "subprocess.call",
166
+ "execute",
167
+ "open",
168
+ "redirect",
169
+ "HttpResponseRedirect",
170
+ "render_template_string",
171
+ "Template",
172
+ "pickle.loads",
173
+ "yaml.load",
174
+ }
175
+
176
+ # Maps a sink call name (or its leaf) to (sink_type, severity, cwe).
177
+ _TAINT_SINK_INFO: dict[str, tuple[str, Severity, str]] = {
178
+ "eval": ("code_injection", Severity.CRITICAL, "CWE-94"),
179
+ "exec": ("code_injection", Severity.CRITICAL, "CWE-94"),
180
+ "os.system": ("command_injection", Severity.CRITICAL, "CWE-78"),
181
+ "subprocess.run": ("command_injection", Severity.CRITICAL, "CWE-78"),
182
+ "subprocess.Popen": ("command_injection", Severity.CRITICAL, "CWE-78"),
183
+ "subprocess.call": ("command_injection", Severity.CRITICAL, "CWE-78"),
184
+ "execute": ("sql_injection", Severity.CRITICAL, "CWE-89"),
185
+ "open": ("path_traversal", Severity.HIGH, "CWE-22"),
186
+ "redirect": ("open_redirect", Severity.HIGH, "CWE-601"),
187
+ "HttpResponseRedirect": ("open_redirect", Severity.HIGH, "CWE-601"),
188
+ "render_template_string": ("template_injection", Severity.HIGH, "CWE-94"),
189
+ "Template": ("template_injection", Severity.HIGH, "CWE-94"),
190
+ "pickle.loads": ("deserialization", Severity.HIGH, "CWE-502"),
191
+ "yaml.load": ("deserialization", Severity.HIGH, "CWE-502"),
192
+ }
193
+
194
+
195
+ def _taint_sink_info(name: str) -> tuple[str, Severity, str] | None:
196
+ info = _TAINT_SINK_INFO.get(name)
197
+ if info is not None:
198
+ return info
199
+ leaf = name.split(".")[-1]
200
+ return _TAINT_SINK_INFO.get(leaf)
201
+
202
+
203
+ @register
204
+ class TaintAnalyzer(BaseAgent):
205
+ """
206
+ Data flow taint analysis — maps sources to sinks without sanitization.
207
+
208
+ Sources: HTTP params, query strings, headers, body, cookies, file uploads,
209
+ WebSocket messages, env vars.
210
+ Sinks: eval(), exec(), os.system(), subprocess, SQL string concat,
211
+ file path construction, HTTP redirects, template rendering,
212
+ deserialization calls.
213
+
214
+ Method:
215
+ 1. Static: scan AST for source reads and sink calls, track assignments.
216
+ 2. Build flow graph — flag source→sink paths with no sanitization.
217
+ 3. AI call per flagged path — "Is this actually exploitable?"
218
+ (one call max per path, results cached in findings).
219
+
220
+ No AI = static-only. Still finds ~70% of real taint issues.
221
+ """
222
+
223
+ name = "TaintAnalyzer"
224
+ group = AgentGroup.SECURITY
225
+ timeout = 120
226
+
227
+ # Sources that introduce untrusted data
228
+ # Sources that introduce untrusted data - matched against parsed member
229
+ # paths and call sites (member_paths / find_calls), never raw text.
230
+ _REQUEST_ATTRS = {"args", "form", "json", "data", "files", "cookies", "headers", "values"}
231
+ _REQ_ATTRS = {"query", "body", "params", "headers", "cookies"}
232
+ _EVENT_ATTRS = {"queryStringParameters", "body", "pathParameters"}
233
+ _PHP_VARS = ("$_GET", "$_POST", "$_REQUEST", "$_COOKIE", "$_SERVER")
234
+ _SOURCE_CALLS = {"os.environ.get", "websocket.recv", "ws.receive"}
235
+ _SOURCE_CALL_TYPES = {
236
+ "os.environ.get": "env_var",
237
+ "websocket.recv": "websocket",
238
+ "ws.receive": "websocket",
239
+ }
240
+
241
+ # Sink call names (full dotted or leaf) shared with the track_taint pass.
242
+ _SINK_CALL_NAMES = {
243
+ "eval", "exec", "os.system", "subprocess.run", "subprocess.Popen",
244
+ "subprocess.call", "execute", "open", "redirect", "HttpResponseRedirect",
245
+ "render_template_string", "Template", "pickle.loads", "pickle.load",
246
+ "yaml.load", "document.write", "html",
247
+ }
248
+
249
+ @staticmethod
250
+ def _member_source_kind(dotted: str) -> str | None:
251
+ head, _, rest = dotted.partition(".")
252
+ attr = rest.split(".")[0].split("[")[0]
253
+ if head.lower() == "request" and attr in TaintAnalyzer._REQUEST_ATTRS:
254
+ return "http_param"
255
+ if head.lower() == "req" and attr in TaintAnalyzer._REQ_ATTRS:
256
+ return "http_param"
257
+ if head.lower() == "event" and attr in TaintAnalyzer._EVENT_ATTRS:
258
+ return "http_param"
259
+ if any(dotted.startswith(v) for v in TaintAnalyzer._PHP_VARS):
260
+ return "http_param"
261
+ low = dotted.lower()
262
+ if "flask.request" in low:
263
+ return "http_framework"
264
+ if "fastapi" in low and "request" in low:
265
+ return "http_framework"
266
+ if "django" in low and "request" in low:
267
+ return "http_framework"
268
+ return None
269
+
270
+ def _structural_sources(self, content: str, lang) -> list[dict]:
271
+ """Source reads as (line, type, code) from parsed member paths + calls."""
272
+ from patchi.core.brain.ast_utils import find_calls
273
+ from patchi.core.brain.code_query import member_paths
274
+
275
+ out: list[dict] = []
276
+ for dotted, line in member_paths(content, lang):
277
+ kind = self._member_source_kind(dotted)
278
+ if kind is not None:
279
+ out.append({"line": line, "type": kind, "code": dotted[:100]})
280
+ for call in find_calls(content, lang, set(self._SOURCE_CALLS)):
281
+ name = str(call.get("name", ""))
282
+ kind = self._SOURCE_CALL_TYPES.get(name)
283
+ if kind:
284
+ line_no = int(call.get("line", 0) or 0)
285
+ out.append({"line": line_no, "type": kind, "code": name[:100]})
286
+ return out
287
+
288
+ def _structural_sinks(self, content: str, lang) -> list[dict]:
289
+ """Sink sites as (line, sink_type, severity, cwe) from parsed calls."""
290
+ from patchi.core.brain.ast_utils import find_assignments, find_calls
291
+ from patchi.core.brain.code_query import calls_with_dynamic_arg
292
+
293
+ out: list[dict] = []
294
+ try:
295
+ calls = find_calls(content, lang, set(self._SINK_CALL_NAMES))
296
+ except Exception:
297
+ calls = []
298
+ dynamic_lines = set(calls_with_dynamic_arg(content, lang, {"execute", "open"}))
299
+ for call in calls:
300
+ name = str(call.get("name", ""))
301
+ leaf = name.split(".")[-1]
302
+ line = int(call.get("line", 0) or 0)
303
+ if leaf in ("eval", "exec"):
304
+ out.append((line, "code_injection", Severity.CRITICAL, "CWE-94"))
305
+ elif name == "os.system" or name.startswith("subprocess."):
306
+ out.append((line, "command_injection", Severity.CRITICAL, "CWE-78"))
307
+ elif leaf == "execute":
308
+ if line in dynamic_lines:
309
+ out.append((line, "sql_injection", Severity.CRITICAL, "CWE-89"))
310
+ elif leaf == "open" or "pathlib" in name:
311
+ if line in dynamic_lines:
312
+ out.append((line, "path_traversal", Severity.HIGH, "CWE-22"))
313
+ elif leaf in ("redirect", "HttpResponseRedirect"):
314
+ out.append((line, "open_redirect", Severity.HIGH, "CWE-601"))
315
+ elif leaf in ("render_template_string", "Template"):
316
+ out.append((line, "template_injection", Severity.HIGH, "CWE-94"))
317
+ elif name in ("pickle.loads", "pickle.load", "yaml.load"):
318
+ out.append((line, "deserialization", Severity.HIGH, "CWE-502"))
319
+ elif name == "document.write":
320
+ out.append((line, "xss", Severity.HIGH, "CWE-79"))
321
+ elif leaf == "html":
322
+ if line in dynamic_lines:
323
+ out.append((line, "xss", Severity.HIGH, "CWE-79"))
324
+ # innerHTML assignment targets (parsed, not text search)
325
+ try:
326
+ assignments = find_assignments(content, lang)
327
+ except Exception:
328
+ assignments = []
329
+ for assignment in assignments:
330
+ target = str(assignment.get("target", ""))
331
+ if target.endswith(".innerHTML"):
332
+ out.append((int(assignment.get("line", 0) or 0), "xss", Severity.HIGH, "CWE-79"))
333
+ return out
334
+
335
+ def _run(self, inp: AgentInput, result: AgentResult) -> None:
336
+ from patchi.core.brain.scanner import FileScanner
337
+
338
+ root = inp.root
339
+ paths = FileScanner(root).discover()
340
+
341
+ total_paths = 0
342
+ seen: set[tuple] = set() # dedupe keys (line, sink_type)
343
+
344
+ for path in paths:
345
+ ext = path.suffix.lower()
346
+ lang = EXTENSION_MAP.get(ext)
347
+ if lang is None:
348
+ continue
349
+ try:
350
+ src = path.read_text(encoding="utf-8", errors="replace")
351
+ except OSError:
352
+ continue
353
+
354
+ rel = path.relative_to(root).as_posix()
355
+
356
+ # ── AST-based taint tracking (source → sink argument flow) ──────
357
+ taint_results = track_taint(src, lang, _TAINT_SINK_NAMES)
358
+ for tr in taint_results:
359
+ info = _taint_sink_info(tr["name"])
360
+ if info is None:
361
+ continue
362
+ sink_type, severity, cwe = info
363
+ line = tr.get("line", 0)
364
+ key = (line, sink_type)
365
+ if key in seen:
366
+ continue
367
+ seen.add(key)
368
+ source_code = (tr.get("tainted_via") or [""])[0][:100]
369
+ ai_confirmed = self._confirm_with_ai(
370
+ rel,
371
+ {"line": line, "type": "source", "code": source_code},
372
+ {"line": line, "code": tr["full_text"].strip()[:100], "type": sink_type},
373
+ src,
374
+ inp.config,
375
+ )
376
+ if ai_confirmed is False:
377
+ continue
378
+ result.add_finding(
379
+ make_finding(
380
+ agent=self.name,
381
+ finding_type=f"taint_{sink_type}",
382
+ severity=severity,
383
+ file=rel,
384
+ line=line,
385
+ message=(
386
+ f"Potential {sink_type.replace('_', ' ')}: "
387
+ f"untrusted input reaches {sink_type} sink."
388
+ ),
389
+ code_snippet=tr["full_text"].strip()[:120],
390
+ detail=f"Tainted via: {', '.join(tr.get('tainted_via') or [])[:80]}",
391
+ suggestion=f"Validate and sanitize input before passing to {sink_type} sink.",
392
+ cwe=cwe,
393
+ fix_agent="SecurityFixer",
394
+ )
395
+ )
396
+
397
+ # Structural proximity pass (parsed sources/sinks, 30-line window)
398
+ sources_in_file = self._structural_sources(src, lang)
399
+
400
+ for line, sink_type, severity, cwe in self._structural_sinks(src, lang):
401
+ nearby_sources = [s for s in sources_in_file if abs(s["line"] - line) <= 30]
402
+ if not nearby_sources:
403
+ continue
404
+ key = (line, sink_type)
405
+ if key in seen:
406
+ continue
407
+ seen.add(key)
408
+ source = nearby_sources[0]
409
+ ai_confirmed = self._confirm_with_ai(
410
+ rel,
411
+ source,
412
+ {"line": line, "code": source["code"], "type": sink_type},
413
+ src,
414
+ inp.config,
415
+ )
416
+ if ai_confirmed is False:
417
+ continue
418
+ result.add_finding(
419
+ make_finding(
420
+ agent=self.name,
421
+ finding_type=f"taint_{sink_type}",
422
+ severity=severity,
423
+ file=rel,
424
+ line=line,
425
+ message=(
426
+ f"Potential {sink_type.replace('_', ' ')}: "
427
+ f"user input from {source['type']} reaches {sink_type} sink."
428
+ ),
429
+ code_snippet=source["code"][:120],
430
+ detail=f"Source at line {source['line']}: {source['code'][:80]}",
431
+ suggestion=(
432
+ f"Validate and sanitize input before passing to {sink_type} sink."
433
+ ),
434
+ cwe=cwe,
435
+ fix_agent="SecurityFixer",
436
+ ai_confirmed=ai_confirmed,
437
+ )
438
+ )
439
+
440
+ if taint_results or sources_in_file:
441
+ total_paths += 1
442
+ result.files_scanned += 1
443
+
444
+ result.data["total_paths_checked"] = total_paths
445
+ result.data["taint_findings"] = result.finding_count
446
+
447
+ def _confirm_with_ai(
448
+ self,
449
+ file_path: str,
450
+ source: dict,
451
+ sink: dict,
452
+ file_content: str,
453
+ config: dict,
454
+ ) -> bool | None:
455
+ """Ask AI if this taint path is actually exploitable. Returns True/False/None."""
456
+ # Extract relevant context (source line ± 15 lines)
457
+ lines = file_content.splitlines()
458
+ start = max(0, min(source["line"], sink["line"]) - 15)
459
+ end = min(len(lines), max(source["line"], sink["line"]) + 15)
460
+ context = "\n".join(lines[start:end])
461
+
462
+ prompt = (
463
+ f"Security analysis: Is this code path actually exploitable?\n"
464
+ f"File: {file_path}\n"
465
+ f"Source (user input): line {source['line']} — {source['code']}\n"
466
+ f"Sink: line {sink['line']} — {sink['code']}\n"
467
+ f"Code context:\n```\n{context[:800]}\n```\n"
468
+ f"Answer only: YES (exploitable) or NO (not exploitable) or UNCERTAIN."
469
+ )
470
+ from patchi.core.security import security_agents as compat
471
+
472
+ response = compat._call_ai(prompt, config, max_tokens=10)
473
+ if not response:
474
+ return None # No AI — include as uncertain
475
+ response_upper = response.strip().upper()
476
+ if "YES" in response_upper:
477
+ return True
478
+ if "NO" in response_upper:
479
+ return False
480
+ return None
481
+
482
+ def _cwe(self, sink_type: str) -> str:
483
+ return {
484
+ "code_injection": "CWE-94",
485
+ "command_injection": "CWE-78",
486
+ "sql_injection": "CWE-89",
487
+ "path_traversal": "CWE-22",
488
+ "open_redirect": "CWE-601",
489
+ "template_injection": "CWE-94",
490
+ "deserialization": "CWE-502",
491
+ "xss": "CWE-79",
492
+ }.get(sink_type, "CWE-20")
493
+
494
+
495
+ # ──────────────────────────────────────────────────────────────────────────────
496
+ # 2. SecretScanner
497
+ # ──────────────────────────────────────────────────────────────────────────────
498
+
499
+
500
+ @register
501
+ class SecretScanner(BaseAgent):
502
+ """
503
+ Secret and credential detection.
504
+
505
+ Primary: Gitleaks (MIT) — run as subprocess, parse JSON output.
506
+ Fallback: pattern-based regex scan when Gitleaks not installed.
507
+
508
+ DO NOT use TruffleHog in hosted SaaS mode (AGPL-3.0 — source disclosure required).
509
+ Gitleaks is the correct tool here.
510
+
511
+ Never stores or logs the actual secret value — only file, line, type.
512
+ """
513
+
514
+ name = "SecretScanner"
515
+ group = AgentGroup.SECURITY
516
+ timeout = 120
517
+
518
+ # -- Regex-free fallback detection (AST + entropy + provider prefixes) --
519
+ # Known token formats are matched as EXACT PREFIXES, not regex. Anything
520
+ # without a known prefix must clear a Shannon-entropy + charset-mix bar,
521
+ # and usually also sit in a credential-named assignment, to be flagged.
522
+
523
+ _PROVIDER_PREFIXES: tuple[tuple[str, str], ...] = (
524
+ ("AKIA", "aws_access_key"),
525
+ ("sk-ant-", "anthropic_key"),
526
+ ("sk-proj-", "openai_project_key"),
527
+ ("ghp_", "github_pat"),
528
+ ("gho_", "github_oauth"),
529
+ ("ghs_", "github_app_secret"),
530
+ ("ghr_", "github_refresh_token"),
531
+ ("github_pat_", "github_fine_grained_pat"),
532
+ ("xoxb-", "slack_bot_token"),
533
+ ("xoxp-", "slack_user_token"),
534
+ ("xoxa-", "slack_workspace_token"),
535
+ ("xoxs-", "slack_session_token"),
536
+ ("AIza", "google_api_key"),
537
+ ("SG.", "sendgrid_key"),
538
+ ("sk_live_", "stripe_live_key"),
539
+ ("pk_live_", "stripe_publishable_live_key"),
540
+ ("rk_live_", "stripe_restricted_live_key"),
541
+ ("glpat-", "gitlab_pat"),
542
+ ("dop_v1_", "digitalocean_pat"),
543
+ ("shpat_", "shopify_pat"),
544
+ ("npm_", "npm_token"),
545
+ ("pypi-", "pypi_token"),
546
+ )
547
+
548
+ _CRED_NAME_TERMS: frozenset = frozenset(
549
+ {
550
+ "api_key",
551
+ "apikey",
552
+ "secret",
553
+ "token",
554
+ "password",
555
+ "passwd",
556
+ "pwd",
557
+ "credential",
558
+ "private_key",
559
+ "access_key",
560
+ "auth_key",
561
+ "client_secret",
562
+ "signing_key",
563
+ "encryption_key",
564
+ "session_key",
565
+ "webhook_secret",
566
+ }
567
+ )
568
+
569
+ _PLACEHOLDER_MARKERS: tuple[str, ...] = (
570
+ "changeme",
571
+ "change_me",
572
+ "<your",
573
+ "${",
574
+ "{{",
575
+ "%(",
576
+ "os.environ",
577
+ "getenv",
578
+ "environ[",
579
+ "environ.get",
580
+ "example",
581
+ "placeholder",
582
+ "xxx",
583
+ "...",
584
+ "todo",
585
+ "dummy",
586
+ "sample_",
587
+ "your-",
588
+ "-here",
589
+ "insert_",
590
+ "redacted",
591
+ "[masked]",
592
+ "not_a_real",
593
+ "fake_",
594
+ )
595
+
596
+ _TEXT_SUFFIXES = {
597
+ ".py",
598
+ ".js",
599
+ ".ts",
600
+ ".jsx",
601
+ ".tsx",
602
+ ".go",
603
+ ".rs",
604
+ ".java",
605
+ ".kt",
606
+ ".rb",
607
+ ".php",
608
+ ".swift",
609
+ ".dart",
610
+ ".cs",
611
+ ".c",
612
+ ".cpp",
613
+ ".h",
614
+ ".yaml",
615
+ ".yml",
616
+ ".json",
617
+ ".toml",
618
+ ".ini",
619
+ ".cfg",
620
+ ".conf",
621
+ ".properties",
622
+ ".xml",
623
+ ".envrc",
624
+ ".sh",
625
+ ".bash",
626
+ ".ps1",
627
+ }
628
+
629
+ _SKIP_FILES = {
630
+ "package-lock.json",
631
+ "yarn.lock",
632
+ "pnpm-lock.yaml",
633
+ "CHANGELOG.md",
634
+ "LICENSE",
635
+ ".gitignore",
636
+ }
637
+
638
+ def _run(self, inp: AgentInput, result: AgentResult) -> None:
639
+ root = inp.root
640
+
641
+ if shutil.which("gitleaks"):
642
+ self._run_gitleaks(root, result)
643
+ else:
644
+ self._run_fallback(root, result)
645
+
646
+ result.data["secret_count"] = result.finding_count
647
+ result.data["tool"] = "gitleaks" if shutil.which("gitleaks") else "regex_fallback"
648
+
649
+ def _run_gitleaks(self, root: Path, result: AgentResult) -> None:
650
+ """Run gitleaks detect, parse JSON report."""
651
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
652
+ report_path = tf.name
653
+
654
+ cmd = [
655
+ "gitleaks",
656
+ "detect",
657
+ "--source",
658
+ str(root),
659
+ "--report-format",
660
+ "json",
661
+ "--report-path",
662
+ report_path,
663
+ "--no-git", # scan files without requiring git history
664
+ "--exit-code",
665
+ "0", # don't fail on finds — we handle them
666
+ ]
667
+ _run(cmd, root, timeout=self.timeout - 10)
668
+
669
+ try:
670
+ findings = json.loads(Path(report_path).read_text())
671
+ if not isinstance(findings, list):
672
+ findings = []
673
+ except Exception as e:
674
+ _log.warning("SecretScanner._run_gitleaks failed: %s", e)
675
+ findings = []
676
+ finally:
677
+ try:
678
+ os.unlink(report_path)
679
+ except Exception as e:
680
+ _log.warning("SecretScanner._run_gitleaks failed: %s", e)
681
+
682
+ for f in findings:
683
+ file_path = f.get("File", "")
684
+ line = f.get("StartLine", 0)
685
+ rule_id = f.get("RuleID", "unknown")
686
+ # description never contains the secret value — only metadata
687
+ result.add_finding(
688
+ make_finding(
689
+ agent=self.name,
690
+ finding_type="hardcoded_secret",
691
+ severity=Severity.CRITICAL,
692
+ file=file_path,
693
+ line=line,
694
+ message=f"Secret detected: {rule_id.replace('-', ' ')}",
695
+ detail=f"Match at {file_path}:{line}. Rule: {rule_id}.",
696
+ suggestion="Remove the secret from source. Use environment variables instead.",
697
+ cwe="CWE-312",
698
+ fix_agent="EnvFixer",
699
+ rule_id=rule_id,
700
+ )
701
+ )
702
+ result.files_scanned += 1
703
+
704
+ def _run_fallback(self, root: Path, result: AgentResult) -> None:
705
+ """Regex-free fallback: AST string literals + entropy + provider prefixes."""
706
+ from patchi.core.brain.scanner import FileScanner
707
+
708
+ for path in FileScanner(root).discover():
709
+ if path.suffix.lower() not in self._TEXT_SUFFIXES:
710
+ continue
711
+ if path.name in self._SKIP_FILES:
712
+ continue
713
+ if path.name.startswith(".env"):
714
+ continue # .env files expected to have values — SideFileScanner handles
715
+
716
+ try:
717
+ src = path.read_text(encoding="utf-8", errors="replace")
718
+ rel = path.relative_to(root).as_posix()
719
+ except OSError:
720
+ continue
721
+
722
+ result.files_scanned += 1
723
+
724
+ if path.suffix.lower() == ".py":
725
+ candidates = extract_py_string_candidates(src)
726
+ else:
727
+ candidates = extract_quoted_candidates(src)
728
+
729
+ seen: set[tuple[int, str]] = set()
730
+ for cand in candidates:
731
+ key = (cand.line, cand.value)
732
+ if key in seen:
733
+ continue
734
+ seen.add(key)
735
+
736
+ verdict, label, severity = classify_secret(cand.name, cand.value, self)
737
+ if verdict is None:
738
+ continue
739
+
740
+ redacted = redact(cand.value)
741
+ result.add_finding(
742
+ make_finding(
743
+ agent=self.name,
744
+ finding_type="hardcoded_secret",
745
+ severity=severity,
746
+ file=rel,
747
+ line=cand.line,
748
+ message=f"Potential {label.replace('_', ' ')} ({verdict}) in source code.",
749
+ code_snippet=redacted,
750
+ suggestion="Move to environment variable. Never commit secrets.",
751
+ cwe="CWE-312",
752
+ fix_agent="EnvFixer",
753
+ pattern_type=label,
754
+ detection=verdict,
755
+ )
756
+ )
757
+
758
+ # ── Regex-free classification helpers (pure string/AST analysis) ────────
759
+
760
+ def _match_provider_prefix(self, value: str) -> tuple[str, str] | None:
761
+ lowered = value
762
+ for prefix, label in self._PROVIDER_PREFIXES:
763
+ if lowered.startswith(prefix):
764
+ return prefix, label
765
+ return None
766
+
767
+ def _is_placeholder(self, value: str) -> bool:
768
+ low = value.lower()
769
+ for marker in self._PLACEHOLDER_MARKERS:
770
+ if marker in low:
771
+ return True
772
+ # All same character ("AAAA...", "1111...") is never a real secret
773
+ if len(set(value)) <= 2 and len(value) >= 12:
774
+ return True
775
+ return False
776
+
777
+ def _is_cred_name(self, name: str) -> bool:
778
+ if not name:
779
+ return False
780
+ normalized = name.lower()
781
+ for sep in ("-", ".", " "):
782
+ normalized = normalized.replace(sep, "_")
783
+ terms = set(normalized.split("_"))
784
+ if not terms.isdisjoint(self._CRED_NAME_TERMS):
785
+ return True
786
+ # Compound forms: apikey, authtoken, clientsecret…
787
+ joined = normalized.replace("_", "")
788
+ return any(
789
+ term.replace("_", "") in joined
790
+ for term in self._CRED_NAME_TERMS
791
+ if "_" in term or len(term) >= 5
792
+ )
793
+
794
+
795
+ def shannon_entropy(value: str) -> float:
796
+ """Shannon entropy in bits per character. 0 = single repeated char."""
797
+ import math
798
+
799
+ if not value:
800
+ return 0.0
801
+ counts: dict[str, int] = {}
802
+ for ch in value:
803
+ counts[ch] = counts.get(ch, 0) + 1
804
+ total = float(len(value))
805
+ entropy = 0.0
806
+ for n in counts.values():
807
+ p = n / total
808
+ entropy -= p * math.log2(p)
809
+ return round(entropy, 3)
810
+
811
+
812
+ def charset_mix(value: str) -> int:
813
+ """How many character classes are present: upper, lower, digit, symbol."""
814
+ has_upper = has_lower = has_digit = has_symbol = False
815
+ for ch in value:
816
+ if ch.isupper():
817
+ has_upper = True
818
+ elif ch.islower():
819
+ has_lower = True
820
+ elif ch.isdigit():
821
+ has_digit = True
822
+ else:
823
+ has_symbol = True
824
+ return sum((has_upper, has_lower, has_digit, has_symbol))
825
+
826
+
827
+ def classify_secret(
828
+ name: str, value: str, scanner: SecretScanner
829
+ ) -> tuple[str | None, str, Severity]:
830
+ """Verdict for one candidate literal.
831
+
832
+ Returns (verdict, label, severity); verdict None means clean.
833
+ Order matters: known provider prefixes beat placeholder markers (AWS's own
834
+ documented example keys contain the word "example"), then credential-name
835
+ assignments, then generic blob detection.
836
+ verdict ∈ {"provider-prefix", "credential-assignment", "high-entropy"}
837
+ """
838
+ if not value or len(value) < 8:
839
+ return None, "", Severity.INFO
840
+
841
+ # Known formats win unconditionally — even when they contain words like
842
+ # "example" (AWS docs literally publish AKIAIOSFODNN7EXAMPLE).
843
+ provider = scanner._match_provider_prefix(value)
844
+ if provider:
845
+ return "provider-prefix", provider[1], Severity.CRITICAL
846
+
847
+ entropy = shannon_entropy(value)
848
+ mix = charset_mix(value)
849
+
850
+ # Placeholder markers ("changeme", "<your-...>") only veto weak evidence.
851
+ # A 39-char 3-class string containing the letters "example" is a real
852
+ # credential pattern, not documentation.
853
+ if not (len(value) >= 20 and entropy >= 3.5) and scanner._is_placeholder(value):
854
+ return None, "", Severity.INFO
855
+
856
+ cred_name = scanner._is_cred_name(name)
857
+
858
+ strong = len(value) >= 20 and entropy >= 3.5 and mix >= 3
859
+
860
+ if cred_name and strong:
861
+ return "credential-assignment", name or "credential", Severity.CRITICAL
862
+ if cred_name and len(value) >= 12 and entropy >= 3.0:
863
+ return "credential-assignment", name or "credential", Severity.HIGH
864
+
865
+ # Token blobs: hex / base64url runs are usually 2-3 char classes by
866
+ # nature — judge on LENGTH + entropy, not class count.
867
+ blob_like = (
868
+ len(value) >= 32
869
+ and entropy >= 3.0
870
+ and all(ch.isalnum() or ch in "+/=_-" for ch in value)
871
+ and any(ch.isdigit() for ch in value)
872
+ and any(ch.isalpha() for ch in value)
873
+ )
874
+ if blob_like:
875
+ return "high-entropy", "opaque_token_blob", Severity.MEDIUM
876
+
877
+ if very_strong_general(value, entropy, mix):
878
+ return "high-entropy", "opaque_high_entropy_value", Severity.MEDIUM
879
+ return None, "", Severity.INFO
880
+
881
+
882
+ def very_strong_general(value: str, entropy: float, mix: int) -> bool:
883
+ return len(value) >= 28 and entropy >= 4.0 and mix == 4
884
+
885
+
886
+ def redact(value: str, keep: int = 4) -> str:
887
+ """Show just enough to identify, never the secret itself."""
888
+ if len(value) <= keep:
889
+ return "[REDACTED]"
890
+ return value[:keep] + "…[REDACTED]"
891
+
892
+
893
+ class StringCandidate:
894
+ """A string literal found in source, with optional assignment target."""
895
+
896
+ __slots__ = ("value", "name", "line")
897
+
898
+ def __init__(self, value: str, name: str, line: int):
899
+ self.value = value
900
+ self.name = name
901
+ self.line = line
902
+
903
+
904
+ def extract_py_string_candidates(src: str) -> list[StringCandidate]:
905
+ """AST walk collecting string literals bound to names (assignments,
906
+ dict values under string keys) plus bare high-risk constants."""
907
+ import ast as _ast
908
+
909
+ from patchi.core.brain.ast_utils.helpers import _py_assign_target_name
910
+
911
+ out: list[StringCandidate] = []
912
+ try:
913
+ tree = _ast.parse(src)
914
+ except SyntaxError:
915
+ return out
916
+
917
+ def _const_str(node: _ast.AST) -> str | None:
918
+ return (
919
+ node.value if isinstance(node, _ast.Constant) and isinstance(node.value, str) else None
920
+ )
921
+
922
+ for node in _ast.walk(tree):
923
+ line = getattr(node, "lineno", 0)
924
+ if isinstance(node, _ast.Assign):
925
+ target = ""
926
+ if node.targets:
927
+ target = _py_assign_target_name(node.targets[0])
928
+ val = _const_str(node.value)
929
+ if val:
930
+ out.append(StringCandidate(val, target, line))
931
+ if isinstance(node.value, _ast.Dict):
932
+ for k, v in zip(node.value.keys, node.value.values, strict=False):
933
+ sv = _const_str(v)
934
+ sk = _const_str(k)
935
+ if sv:
936
+ out.append(StringCandidate(sv, sk or target, line))
937
+ elif isinstance(node, _ast.AnnAssign):
938
+ target = _py_assign_target_name(node.target)
939
+ val = _const_str(node.value) if node.value else None
940
+ if val:
941
+ out.append(StringCandidate(val, target, line))
942
+ elif isinstance(node, _ast.keyword):
943
+ val = _const_str(node.value)
944
+ if val and node.arg:
945
+ out.append(StringCandidate(val, node.arg, line))
946
+ return out
947
+
948
+
949
+ def extract_quoted_candidates(src: str) -> list[StringCandidate]:
950
+ """Character-scanner for quoted spans in non-Python text (no regex).
951
+
952
+ Tracks double/single quote runs, honors backslash escapes, and guesses an
953
+ assignment-ish name from the characters immediately before the quote.
954
+ """
955
+ out: list[StringCandidate] = []
956
+ lines = src.splitlines()
957
+ for lineno, raw in enumerate(lines, 1):
958
+ i = 0
959
+ n = len(raw)
960
+ while i < n:
961
+ ch = raw[i]
962
+ if ch in "\"'":
963
+ quote = ch
964
+ j = i + 1
965
+ buf: list[str] = []
966
+ closed = False
967
+ while j < n:
968
+ cj = raw[j]
969
+ if cj == "\\" and j + 1 < n:
970
+ buf.append(raw[j : j + 2])
971
+ j += 2
972
+ continue
973
+ if cj == quote:
974
+ closed = True
975
+ break
976
+ buf.append(cj)
977
+ j += 1
978
+ if closed:
979
+ value = "".join(buf)
980
+ prefix = raw[max(0, i - 40) : i]
981
+ name = _guess_name_from_prefix(prefix)
982
+ out.append(StringCandidate(value, name, lineno))
983
+ i = j + 1
984
+ continue
985
+ i += 1
986
+ return out
987
+
988
+
989
+ def _guess_name_from_prefix(prefix: str) -> str:
990
+ """Best-effort identifier before a quote: `name:` / `name=` / `\"name\":`."""
991
+ tail = prefix.rstrip()
992
+ for sep in (":", "="):
993
+ idx = tail.rfind(sep)
994
+ if idx != -1:
995
+ head = tail[:idx].rstrip()
996
+ word_chars = []
997
+ for ch in reversed(head):
998
+ if ch.isalnum() or ch in "_-":
999
+ word_chars.append(ch)
1000
+ else:
1001
+ break
1002
+ name = "".join(reversed(word_chars)).strip("\"' ")
1003
+ if name:
1004
+ return name
1005
+ return ""