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.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- patchi-0.7.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-Remediation Suggestions — maps finding types to actionable fix guidance.
|
|
3
|
+
|
|
4
|
+
Each finding type gets:
|
|
5
|
+
- A short fix action (one-liner for the UI)
|
|
6
|
+
- A playbook reference (if available)
|
|
7
|
+
- Code-level fix patterns (for the chain explorer)
|
|
8
|
+
- Severity-adjusted urgency (critical → immediate, info → optional)
|
|
9
|
+
|
|
10
|
+
This data feeds into:
|
|
11
|
+
1. Chain explorer step cards (inline fix suggestion)
|
|
12
|
+
2. Assurance chain claims (remediation field)
|
|
13
|
+
3. CLI chain output (p chains --fix-hints)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_log = logging.getLogger("patchi.security.remediation")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Remediation:
|
|
27
|
+
"""One fix suggestion for a finding type."""
|
|
28
|
+
|
|
29
|
+
finding_type: str
|
|
30
|
+
action: str
|
|
31
|
+
code_pattern: str = ""
|
|
32
|
+
playbook_id: str = ""
|
|
33
|
+
reference_url: str = ""
|
|
34
|
+
auto_fixable: bool = False
|
|
35
|
+
risk_level: str = "low" # low / medium / high (risk of the fix itself)
|
|
36
|
+
lang_patterns: dict[str, str] | None = None # lang -> code_pattern override
|
|
37
|
+
|
|
38
|
+
def for_language(self, lang: str) -> str:
|
|
39
|
+
"""Get the code pattern for a specific language, falling back to default."""
|
|
40
|
+
if self.lang_patterns and lang in self.lang_patterns:
|
|
41
|
+
return self.lang_patterns[lang]
|
|
42
|
+
return self.code_pattern
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Remediation Database ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
_REMEDIATIONS: dict[str, Remediation] = {
|
|
48
|
+
# Secrets
|
|
49
|
+
"hardcoded_secret": Remediation(
|
|
50
|
+
finding_type="hardcoded_secret",
|
|
51
|
+
action="Move secret to environment variable or secrets manager",
|
|
52
|
+
code_pattern='Use os.environ.get("SECRET_KEY") instead of hardcoded string',
|
|
53
|
+
playbook_id="secrets-runtime-management",
|
|
54
|
+
auto_fixable=True,
|
|
55
|
+
),
|
|
56
|
+
"weak_hash": Remediation(
|
|
57
|
+
finding_type="weak_hash",
|
|
58
|
+
action="Replace MD5/SHA1 with bcrypt, argon2, or SHA-256+",
|
|
59
|
+
code_pattern="Use bcrypt.checkpw() or hashlib.sha256() instead",
|
|
60
|
+
auto_fixable=True,
|
|
61
|
+
),
|
|
62
|
+
"hardcoded_password": Remediation(
|
|
63
|
+
finding_type="hardcoded_password",
|
|
64
|
+
action="Remove hardcoded password, use environment variable or vault",
|
|
65
|
+
code_pattern='Replace with os.environ.get("DB_PASSWORD")',
|
|
66
|
+
playbook_id="secrets-runtime-management",
|
|
67
|
+
auto_fixable=True,
|
|
68
|
+
),
|
|
69
|
+
# Injection
|
|
70
|
+
"sql_injection": Remediation(
|
|
71
|
+
finding_type="sql_injection",
|
|
72
|
+
action="Use parameterized queries or ORM instead of string formatting",
|
|
73
|
+
code_pattern='cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))',
|
|
74
|
+
playbook_id="data-layer",
|
|
75
|
+
auto_fixable=True,
|
|
76
|
+
lang_patterns={
|
|
77
|
+
"go": 'db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", userID)',
|
|
78
|
+
"java": 'PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?"); ps.setInt(1, userId);',
|
|
79
|
+
"rust": 'sqlx::query!("SELECT * FROM users WHERE id = $1", user_id).fetch_one(&pool)',
|
|
80
|
+
"php": '$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id"); $stmt->execute(["id" => $userId]);',
|
|
81
|
+
"ruby": "User.where(id: user_id).first # ActiveRecord sanitizes automatically",
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
"command_injection": Remediation(
|
|
85
|
+
finding_type="command_injection",
|
|
86
|
+
action="Use subprocess.run with list args instead of shell=True",
|
|
87
|
+
code_pattern='subprocess.run(["ls", path], check=False)',
|
|
88
|
+
auto_fixable=True,
|
|
89
|
+
lang_patterns={
|
|
90
|
+
"go": 'exec.Command("ls", path).Output() # avoid exec.Command("sh", "-c", ...)',
|
|
91
|
+
"java": 'ProcessBuilder pb = new ProcessBuilder("ls", path); pb.start();',
|
|
92
|
+
"rust": 'std::process::Command::new("ls").arg(path).output()',
|
|
93
|
+
"php": '$output = shell_exec("ls " . escapeshellarg($path));',
|
|
94
|
+
"ruby": 'Open3.capture3("ls", path) # array form avoids shell interpretation',
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
"xss_reflected": Remediation(
|
|
98
|
+
finding_type="xss_reflected",
|
|
99
|
+
action="Escape user input before rendering, use template auto-escaping",
|
|
100
|
+
code_pattern="Use {{ variable | e }} in Jinja2 templates",
|
|
101
|
+
auto_fixable=False,
|
|
102
|
+
lang_patterns={
|
|
103
|
+
"go": "template.HTML(template.HTMLEscapeString(userInput)) // or use html/template auto-escaping",
|
|
104
|
+
"java": '<%= request.getParameter("name") %> // JSP auto-escapes; use OWASP Encoder for raw',
|
|
105
|
+
"rust": "askama or tera templates auto-escape; avoid Markup::new()",
|
|
106
|
+
"php": 'htmlspecialchars($input, ENT_QUOTES, "UTF-8")',
|
|
107
|
+
"ruby": "ERB::Util.html_escape(user_input) // or Rails auto-escaping",
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
"path_traversal": Remediation(
|
|
111
|
+
finding_type="path_traversal",
|
|
112
|
+
action="Validate and sanitize file paths, use pathlib.resolve()",
|
|
113
|
+
code_pattern="Path(user_input).resolve().is_relative_to(base_dir)",
|
|
114
|
+
auto_fixable=True,
|
|
115
|
+
lang_patterns={
|
|
116
|
+
"go": "filepath.Clean(path) + must be inside baseDir; check with strings.HasPrefix",
|
|
117
|
+
"java": "Path resolved = Paths.get(baseDir, userInput).normalize(); if (!resolved.startsWith(baseDir)) throw;",
|
|
118
|
+
"rust": "let resolved = std::fs::canonicalize(base_dir.join(&user_input))?; if !resolved.starts_with(&base_dir) { return Err(...); }",
|
|
119
|
+
"php": 'realpath($baseDir . "/" . $userInput) must start with realpath($baseDir)',
|
|
120
|
+
"ruby": "File.expand_path(user_input, base_dir).start_with?(base_dir)",
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
# Auth
|
|
124
|
+
"missing_auth": Remediation(
|
|
125
|
+
finding_type="missing_auth",
|
|
126
|
+
action="Add authentication decorator or middleware to route",
|
|
127
|
+
code_pattern="@require_auth or @login_required",
|
|
128
|
+
playbook_id="auth-session",
|
|
129
|
+
auto_fixable=False,
|
|
130
|
+
),
|
|
131
|
+
"session_fixation": Remediation(
|
|
132
|
+
finding_type="session_fixation",
|
|
133
|
+
action="Regenerate session ID after login",
|
|
134
|
+
code_pattern="request.session.regenerate() after successful auth",
|
|
135
|
+
playbook_id="auth-session",
|
|
136
|
+
),
|
|
137
|
+
"weak_password_policy": Remediation(
|
|
138
|
+
finding_type="weak_password_policy",
|
|
139
|
+
action="Enforce minimum length, complexity, and breach-check",
|
|
140
|
+
code_pattern="Use password validation: minlength=12, complexity=True",
|
|
141
|
+
playbook_id="auth-session",
|
|
142
|
+
),
|
|
143
|
+
# Config
|
|
144
|
+
"debug_enabled": Remediation(
|
|
145
|
+
finding_type="debug_enabled",
|
|
146
|
+
action="Disable debug mode in production, use environment-based config",
|
|
147
|
+
code_pattern='DEBUG = os.environ.get("DEBUG", "false").lower() == "true"',
|
|
148
|
+
auto_fixable=True,
|
|
149
|
+
),
|
|
150
|
+
"cors_wildcard": Remediation(
|
|
151
|
+
finding_type="cors_wildcard",
|
|
152
|
+
action="Replace CORS wildcard with explicit allowed origins",
|
|
153
|
+
code_pattern="Access-Control-Allow-Origin: https://yourdomain.com",
|
|
154
|
+
playbook_id="cdn-cache-security",
|
|
155
|
+
auto_fixable=True,
|
|
156
|
+
),
|
|
157
|
+
"missing_security_header": Remediation(
|
|
158
|
+
finding_type="missing_security_header",
|
|
159
|
+
action="Add security headers (HSTS, CSP, X-Frame-Options)",
|
|
160
|
+
code_pattern="Add middleware that sets security response headers",
|
|
161
|
+
playbook_id="cdn-cache-security",
|
|
162
|
+
auto_fixable=True,
|
|
163
|
+
),
|
|
164
|
+
"missing_hsts": Remediation(
|
|
165
|
+
finding_type="missing_hsts",
|
|
166
|
+
action="Add Strict-Transport-Security header with long max-age",
|
|
167
|
+
code_pattern="Strict-Transport-Security: max-age=31536000; includeSubDomains",
|
|
168
|
+
playbook_id="cdn-cache-security",
|
|
169
|
+
),
|
|
170
|
+
"missing_csp": Remediation(
|
|
171
|
+
finding_type="missing_csp",
|
|
172
|
+
action="Add Content-Security-Policy header restricting sources",
|
|
173
|
+
code_pattern="Content-Security-Policy: default-src 'self'; script-src 'self'",
|
|
174
|
+
playbook_id="cdn-cache-security",
|
|
175
|
+
),
|
|
176
|
+
# Crypto
|
|
177
|
+
"weak_crypto": Remediation(
|
|
178
|
+
finding_type="weak_crypto",
|
|
179
|
+
action="Replace deprecated cipher with AES-256-GCM or ChaCha20",
|
|
180
|
+
code_pattern="Use cryptography.hazmat.primitives.ciphers.aead.AESGCM",
|
|
181
|
+
playbook_id="secrets-runtime-management",
|
|
182
|
+
lang_patterns={
|
|
183
|
+
"go": "crypto/aes + crypto/cipher.NewGCM() // AES-256-GCM",
|
|
184
|
+
"java": 'javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")',
|
|
185
|
+
"rust": "aes_gcm::Aes256Gcm::new(key) // use aes-gcm crate",
|
|
186
|
+
"php": 'openssl_encrypt($data, "aes-256-gcm", $key, 0, $iv, $tag)',
|
|
187
|
+
"ruby": 'AES-256-GCM via OpenSSL::Cipher.new("aes-256-gcm")',
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
"insecure_random": Remediation(
|
|
191
|
+
finding_type="insecure_random",
|
|
192
|
+
action="Replace random module with secrets for security-sensitive values",
|
|
193
|
+
code_pattern="Use secrets.token_hex(32) instead of random.random()",
|
|
194
|
+
auto_fixable=True,
|
|
195
|
+
lang_patterns={
|
|
196
|
+
"go": "crypto/rand.Read(buf) // never math/rand for security",
|
|
197
|
+
"java": "java.security.SecureRandom.getInstanceStrong().nextBytes(buf)",
|
|
198
|
+
"rust": "use rand::rngs::OsRng; rand::RngCore::fill_bytes(&mut OsRng, &mut buf)",
|
|
199
|
+
"php": "random_bytes(32) // never mt_rand or rand()",
|
|
200
|
+
"ruby": "SecureRandom.hex(32) // never rand() for security",
|
|
201
|
+
},
|
|
202
|
+
),
|
|
203
|
+
# SSRF / Network
|
|
204
|
+
"ssrf": Remediation(
|
|
205
|
+
finding_type="ssrf",
|
|
206
|
+
action="Validate and allowlist target URLs before making requests",
|
|
207
|
+
code_pattern="Check URL against allowlist before urllib.request.urlopen()",
|
|
208
|
+
),
|
|
209
|
+
"open_redirect": Remediation(
|
|
210
|
+
finding_type="open_redirect",
|
|
211
|
+
action="Validate redirect target is internal or on allowlist",
|
|
212
|
+
code_pattern="if redirect_url.startswith('/'): return redirect(redirect_url)",
|
|
213
|
+
),
|
|
214
|
+
# Business logic
|
|
215
|
+
"idor": Remediation(
|
|
216
|
+
finding_type="idor",
|
|
217
|
+
action="Verify requesting user owns the resource (authorization check)",
|
|
218
|
+
code_pattern="if resource.owner_id != current_user.id: abort(403)",
|
|
219
|
+
playbook_id="auth-session",
|
|
220
|
+
),
|
|
221
|
+
"race_condition": Remediation(
|
|
222
|
+
finding_type="race_condition",
|
|
223
|
+
action="Use database-level locking or atomic operations",
|
|
224
|
+
code_pattern="Use SELECT FOR UPDATE or atomic increment",
|
|
225
|
+
),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Finding Type → Remediation Lookup ───────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _normalize_type(finding_type: str) -> str:
|
|
233
|
+
"""Normalize a finding type to a known remediation key."""
|
|
234
|
+
ft = finding_type.lower().strip()
|
|
235
|
+
# Direct match
|
|
236
|
+
if ft in _REMEDIATIONS:
|
|
237
|
+
return ft
|
|
238
|
+
# Substring match
|
|
239
|
+
for key in _REMEDIATIONS:
|
|
240
|
+
if key in ft or ft in key:
|
|
241
|
+
return key
|
|
242
|
+
# Fuzzy: extract core concept
|
|
243
|
+
mappings = {
|
|
244
|
+
"secret": "hardcoded_secret",
|
|
245
|
+
"credential": "hardcoded_secret",
|
|
246
|
+
"password": "hardcoded_password",
|
|
247
|
+
"hash": "weak_hash",
|
|
248
|
+
"sql": "sql_injection",
|
|
249
|
+
"inject": "command_injection",
|
|
250
|
+
"xss": "xss_reflected",
|
|
251
|
+
"traversal": "path_traversal",
|
|
252
|
+
"auth": "missing_auth",
|
|
253
|
+
"session": "session_fixation",
|
|
254
|
+
"debug": "debug_enabled",
|
|
255
|
+
"cors": "cors_wildcard",
|
|
256
|
+
"header": "missing_security_header",
|
|
257
|
+
"hsts": "missing_hsts",
|
|
258
|
+
"csp": "missing_csp",
|
|
259
|
+
"crypto": "weak_crypto",
|
|
260
|
+
"random": "insecure_random",
|
|
261
|
+
"ssrf": "ssrf",
|
|
262
|
+
"redirect": "open_redirect",
|
|
263
|
+
"idor": "idor",
|
|
264
|
+
"race": "race_condition",
|
|
265
|
+
}
|
|
266
|
+
for keyword, mapped in mappings.items():
|
|
267
|
+
if keyword in ft:
|
|
268
|
+
return mapped
|
|
269
|
+
return ft
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_remediation(finding_type: str) -> Remediation | None:
|
|
273
|
+
"""Look up remediation suggestion for a finding type."""
|
|
274
|
+
key = _normalize_type(finding_type)
|
|
275
|
+
return _REMEDIATIONS.get(key)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_remediation_confidence(finding_type: str, root: Path | None = None) -> float:
|
|
279
|
+
"""Return confidence 0.0-1.0 that this remediation will fix the issue.
|
|
280
|
+
|
|
281
|
+
Factors:
|
|
282
|
+
- Historical fix acceptance rate (from attack_feedback learning)
|
|
283
|
+
- Whether the fix is auto_fixable (higher base confidence)
|
|
284
|
+
- Whether we have a language-specific pattern (more precise)
|
|
285
|
+
- Risk level of the fix itself (high risk = lower confidence)
|
|
286
|
+
"""
|
|
287
|
+
rem = get_remediation(finding_type)
|
|
288
|
+
if not rem:
|
|
289
|
+
return 0.3 # unknown type = low confidence
|
|
290
|
+
|
|
291
|
+
# Base confidence from fix properties
|
|
292
|
+
base = 0.5
|
|
293
|
+
if rem.auto_fixable:
|
|
294
|
+
base += 0.15
|
|
295
|
+
if rem.code_pattern:
|
|
296
|
+
base += 0.05
|
|
297
|
+
if rem.lang_patterns:
|
|
298
|
+
base += 0.05
|
|
299
|
+
risk_penalty = {"low": 0, "medium": 0.1, "high": 0.2}.get(rem.risk_level, 0)
|
|
300
|
+
base -= risk_penalty
|
|
301
|
+
|
|
302
|
+
# Historical acceptance rate (if root provided)
|
|
303
|
+
if root:
|
|
304
|
+
try:
|
|
305
|
+
from patchi.core.security.attack_feedback import get_learning_summary
|
|
306
|
+
|
|
307
|
+
summary = get_learning_summary(root)
|
|
308
|
+
accepted = summary.get("accepted_fixes", {}).get(finding_type, 0)
|
|
309
|
+
rejected = summary.get("rejected_fixes", {}).get(finding_type, 0)
|
|
310
|
+
total = accepted + rejected
|
|
311
|
+
if total >= 3:
|
|
312
|
+
acceptance_rate = accepted / total
|
|
313
|
+
# Blend: 60% base + 40% historical
|
|
314
|
+
base = 0.6 * base + 0.4 * acceptance_rate
|
|
315
|
+
except Exception as _exc:
|
|
316
|
+
_log.warning('get_remediation_confidence failed: %s', _exc)
|
|
317
|
+
|
|
318
|
+
return max(0.1, min(0.95, base))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_remediation_for_step(step: dict) -> dict | None:
|
|
322
|
+
"""Get remediation for a chain step (dict form from chain.to_dict())."""
|
|
323
|
+
ftype = step.get("type", "")
|
|
324
|
+
rem = get_remediation(ftype)
|
|
325
|
+
if not rem:
|
|
326
|
+
return None
|
|
327
|
+
return {
|
|
328
|
+
"action": rem.action,
|
|
329
|
+
"code_pattern": rem.code_pattern,
|
|
330
|
+
"playbook_id": rem.playbook_id,
|
|
331
|
+
"auto_fixable": rem.auto_fixable,
|
|
332
|
+
"risk_level": rem.risk_level,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_remediation_for_chain(chain: dict) -> list[dict | None]:
|
|
337
|
+
"""Get remediation for each step in a chain."""
|
|
338
|
+
steps = chain.get("steps", [])
|
|
339
|
+
return [get_remediation_for_step(s) for s in steps]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def load_playbook_fix(playbook_id: str, control_id: str = "") -> str | None:
|
|
343
|
+
"""Load the fix strategy from a playbook YAML file."""
|
|
344
|
+
if not playbook_id:
|
|
345
|
+
return None
|
|
346
|
+
pb_path = Path(__file__).parent / "fix-playbooks" / f"{playbook_id}.playbook.yaml"
|
|
347
|
+
if not pb_path.is_file():
|
|
348
|
+
return None
|
|
349
|
+
try:
|
|
350
|
+
import yaml
|
|
351
|
+
|
|
352
|
+
data = yaml.safe_load(pb_path.read_text(encoding="utf-8"))
|
|
353
|
+
for pb in data.get("playbooks", []):
|
|
354
|
+
if control_id and pb.get("control_id") != control_id:
|
|
355
|
+
continue
|
|
356
|
+
return pb.get("fix_strategy", "")
|
|
357
|
+
return None
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
_log.debug("Playbook load failed: %s", exc)
|
|
360
|
+
return None
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime Request Interceptor — real-time request/response defense for hosted mode.
|
|
3
|
+
|
|
4
|
+
Intercepts HTTP requests, feeds them through the DetectionPipeline, and blocks
|
|
5
|
+
or modifies responses based on confidence-gated findings.
|
|
6
|
+
|
|
7
|
+
Three integration modes:
|
|
8
|
+
1. ASGI middleware (FastAPI/Starlette) — recommended for Patchi's web UI
|
|
9
|
+
2. WSGI middleware (Flask/Django) — fallback
|
|
10
|
+
3. Reverse proxy sidecar (standalone) — separate process intercepting traffic
|
|
11
|
+
|
|
12
|
+
All modes are optional, opt-in via config:
|
|
13
|
+
{
|
|
14
|
+
"pipeline": {
|
|
15
|
+
"interceptor": {
|
|
16
|
+
"enabled": false,
|
|
17
|
+
"mode": "asgi", // asgi | wsgi | sidecar
|
|
18
|
+
"block_threshold": 0.7, // confidence score threshold for blocking
|
|
19
|
+
"rate_limit": 100, // max inspected requests per minute
|
|
20
|
+
"exclude_paths": ["/health", "/metrics", "/static"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from loguru import logger
|
|
34
|
+
|
|
35
|
+
_log = logging.getLogger("patchi.security.request_interceptor")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RequestInterceptor:
|
|
39
|
+
"""
|
|
40
|
+
Runtime request interceptor for hosted mode.
|
|
41
|
+
|
|
42
|
+
Usage (ASGI):
|
|
43
|
+
from patchi.core.security.request_interceptor import RequestInterceptor
|
|
44
|
+
interceptor = RequestInterceptor(root, config)
|
|
45
|
+
app.add_middleware(interceptor.asgi_middleware)
|
|
46
|
+
|
|
47
|
+
Usage (standalone inspection):
|
|
48
|
+
result = interceptor.inspect_request(method, path, headers, body)
|
|
49
|
+
if result.should_block:
|
|
50
|
+
return 403
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, root: Path, config: dict | None = None):
|
|
54
|
+
self.root = root
|
|
55
|
+
self.config = config or {}
|
|
56
|
+
interceptor_cfg = self.config.get("pipeline", {}).get("interceptor", {})
|
|
57
|
+
self.enabled = interceptor_cfg.get("enabled", False)
|
|
58
|
+
self.block_threshold = interceptor_cfg.get("block_threshold", 0.7)
|
|
59
|
+
self.rate_limit = interceptor_cfg.get("rate_limit", 100) # per minute
|
|
60
|
+
self.exclude_paths = set(
|
|
61
|
+
interceptor_cfg.get("exclude_paths", ["/health", "/metrics", "/static"])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Rate limit tracking
|
|
65
|
+
self._window_start = time.monotonic()
|
|
66
|
+
self._window_count = 0
|
|
67
|
+
|
|
68
|
+
# Threat intelligence cache (IP → threat score)
|
|
69
|
+
self._threat_ips: dict[str, dict] = {}
|
|
70
|
+
self._injection_cache: dict[str, float] = {} # normalized payload → confidence
|
|
71
|
+
# Bound the caches: keys are client-controlled, so unbounded growth is
|
|
72
|
+
# a memory DoS on a long-running hosted process.
|
|
73
|
+
self._max_threat_ips = 10_000
|
|
74
|
+
self._max_injection_cache = 10_000
|
|
75
|
+
|
|
76
|
+
def should_inspect(self, path: str) -> bool:
|
|
77
|
+
"""Check if path should be inspected (not excluded, not static)."""
|
|
78
|
+
if not self.enabled:
|
|
79
|
+
return False
|
|
80
|
+
for excluded in self.exclude_paths:
|
|
81
|
+
if path.startswith(excluded):
|
|
82
|
+
return False
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def _check_rate_limit(self) -> bool:
|
|
86
|
+
"""Check if we're within rate limit. Returns True if allowed."""
|
|
87
|
+
now = time.monotonic()
|
|
88
|
+
if now - self._window_start > 60:
|
|
89
|
+
self._window_start = now
|
|
90
|
+
self._window_count = 0
|
|
91
|
+
if self._window_count >= self.rate_limit:
|
|
92
|
+
return False
|
|
93
|
+
self._window_count += 1
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
# ── Request inspection ────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def inspect_request(self, method: str, path: str, headers: dict, body: str = "") -> dict:
|
|
99
|
+
"""
|
|
100
|
+
Inspect a single HTTP request for threats.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
{
|
|
104
|
+
"should_block": bool,
|
|
105
|
+
"confidence": float,
|
|
106
|
+
"threat_type": str | None,
|
|
107
|
+
"reason": str | None,
|
|
108
|
+
"findings": list[dict],
|
|
109
|
+
}
|
|
110
|
+
"""
|
|
111
|
+
if not self._check_rate_limit():
|
|
112
|
+
return {
|
|
113
|
+
"should_block": False,
|
|
114
|
+
"confidence": 0.0,
|
|
115
|
+
"threat_type": None,
|
|
116
|
+
"reason": "Rate limited (inspector)",
|
|
117
|
+
"findings": [],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
findings = []
|
|
121
|
+
ip = (
|
|
122
|
+
headers.get("x-forwarded-for", headers.get("remote-addr", "unknown"))
|
|
123
|
+
.split(",")[0]
|
|
124
|
+
.strip()
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# 1. Known malicious IP check
|
|
128
|
+
if ip in self._threat_ips:
|
|
129
|
+
ti = self._threat_ips[ip]
|
|
130
|
+
if ti["score"] >= self.block_threshold:
|
|
131
|
+
return {
|
|
132
|
+
"should_block": True,
|
|
133
|
+
"confidence": ti["score"],
|
|
134
|
+
"threat_type": "known_malicious_ip",
|
|
135
|
+
"reason": f"Known malicious IP (score: {ti['score']})",
|
|
136
|
+
"findings": [],
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# 2. Injection detection in path/body
|
|
140
|
+
body_lower = body.lower()
|
|
141
|
+
query = path.lower()
|
|
142
|
+
|
|
143
|
+
injection_patterns = [
|
|
144
|
+
("sqli", ["'", '"', "union select", "or 1=1", "drop table", ";--", "admin'--"]),
|
|
145
|
+
("xss", ["<script", "onerror=", "onload=", "javascript:", "alert("]),
|
|
146
|
+
("path_traversal", ["../", "..\\", "%2e%2e%2f", "....//"]),
|
|
147
|
+
("command_injection", ["; ", "| ", "`", "$(", "&& ", "|| "]),
|
|
148
|
+
("ssti", ["{{", "{%", "${", "#{"]),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
for threat_type, patterns in injection_patterns:
|
|
152
|
+
for pat in patterns:
|
|
153
|
+
if pat in body_lower or pat in query:
|
|
154
|
+
confidence = 0.8 if pat in body_lower else 0.6
|
|
155
|
+
if len(self._injection_cache) < self._max_injection_cache:
|
|
156
|
+
cache_key = f"{threat_type}:{hash(body)}"
|
|
157
|
+
self._injection_cache[cache_key] = confidence
|
|
158
|
+
findings.append(
|
|
159
|
+
{
|
|
160
|
+
"type": threat_type,
|
|
161
|
+
"confidence": confidence,
|
|
162
|
+
"pattern": pat,
|
|
163
|
+
"location": "body" if pat in body_lower else "path",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 3. Rate spike detection (same IP, multiple requests)
|
|
168
|
+
if ip not in self._threat_ips:
|
|
169
|
+
if len(self._threat_ips) >= self._max_threat_ips:
|
|
170
|
+
self.clear_stale_threats()
|
|
171
|
+
if len(self._threat_ips) >= self._max_threat_ips:
|
|
172
|
+
# Still at the cap — drop the oldest entry instead of growing
|
|
173
|
+
# without bound.
|
|
174
|
+
self._threat_ips.pop(next(iter(self._threat_ips)))
|
|
175
|
+
self._threat_ips[ip] = {"score": 0.0, "count": 0, "first_seen": time.time()}
|
|
176
|
+
self._threat_ips[ip]["count"] += 1
|
|
177
|
+
|
|
178
|
+
ip_data = self._threat_ips[ip]
|
|
179
|
+
elapsed = time.time() - ip_data["first_seen"]
|
|
180
|
+
# Only judge sustained request rates over a real window. Measuring
|
|
181
|
+
# bursts against a sub-second elapsed time makes any two quick
|
|
182
|
+
# requests look like thousands per second (false positive).
|
|
183
|
+
if elapsed >= 1.0:
|
|
184
|
+
rps = ip_data["count"] / elapsed
|
|
185
|
+
if rps > 50: # more than 50 req/s from same IP
|
|
186
|
+
ip_data["score"] = min(1.0, ip_data["score"] + 0.2)
|
|
187
|
+
findings.append(
|
|
188
|
+
{
|
|
189
|
+
"type": "rate_spike",
|
|
190
|
+
"confidence": min(0.9, rps / 100),
|
|
191
|
+
"pattern": f"{ip_data['count']} requests in {elapsed:.0f}s",
|
|
192
|
+
"location": "ip",
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not findings:
|
|
197
|
+
return {
|
|
198
|
+
"should_block": False,
|
|
199
|
+
"confidence": 0.0,
|
|
200
|
+
"threat_type": None,
|
|
201
|
+
"reason": "No threats detected",
|
|
202
|
+
"findings": [],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Compute max confidence
|
|
206
|
+
max_conf = max(f["confidence"] for f in findings)
|
|
207
|
+
top_threat = max(findings, key=lambda f: f["confidence"])
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"should_block": max_conf >= self.block_threshold,
|
|
211
|
+
"confidence": max_conf,
|
|
212
|
+
"threat_type": top_threat["type"],
|
|
213
|
+
"reason": f"Detected {top_threat['type']} (confidence: {max_conf:.2f})",
|
|
214
|
+
"findings": findings,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# ── ASGI middleware ───────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
async def asgi_middleware(self, scope: dict, receive: callable, send: callable) -> None:
|
|
220
|
+
"""ASGI middleware for FastAPI/Starlette."""
|
|
221
|
+
if scope["type"] != "http":
|
|
222
|
+
# Non-HTTP scopes (lifespan, websockets) must pass through
|
|
223
|
+
# untouched — `send` takes a message, not the scope tuple.
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
path = scope.get("path", "/")
|
|
227
|
+
if not self.should_inspect(path):
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
method = scope.get("method", "GET")
|
|
231
|
+
headers = {k.decode(): v.decode() for k, v in scope.get("headers", [])}
|
|
232
|
+
|
|
233
|
+
# Read request body
|
|
234
|
+
body = ""
|
|
235
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
236
|
+
try:
|
|
237
|
+
more = True
|
|
238
|
+
while more:
|
|
239
|
+
message = await receive()
|
|
240
|
+
if message["type"] == "http.request":
|
|
241
|
+
body += message.get("body", b"").decode("utf-8", errors="ignore")
|
|
242
|
+
more = message.get("more_body", False)
|
|
243
|
+
else:
|
|
244
|
+
break
|
|
245
|
+
except Exception as e:
|
|
246
|
+
_log.warning("RequestInterceptor.asgi_middleware failed: %s", e)
|
|
247
|
+
|
|
248
|
+
result = self.inspect_request(method, path, headers, body)
|
|
249
|
+
if result["should_block"]:
|
|
250
|
+
logger.warning(f"Blocked request: {method} {path} — {result['reason']}")
|
|
251
|
+
await send(
|
|
252
|
+
{
|
|
253
|
+
"type": "http.response.start",
|
|
254
|
+
"status": 403,
|
|
255
|
+
"headers": [(b"content-type", b"application/json")],
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
await send(
|
|
259
|
+
{
|
|
260
|
+
"type": "http.response.body",
|
|
261
|
+
"body": json.dumps(
|
|
262
|
+
{"error": "Request blocked", "reason": result["reason"]}
|
|
263
|
+
).encode(),
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# ── Threat intelligence maintenance ───────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def add_threat_ip(self, ip: str, score: float = 1.0, reason: str = "") -> None:
|
|
271
|
+
"""Manually add an IP to the threat list (from DefenseLayer block_ip)."""
|
|
272
|
+
self._threat_ips[ip] = {
|
|
273
|
+
"score": score,
|
|
274
|
+
"count": 0,
|
|
275
|
+
"first_seen": time.time(),
|
|
276
|
+
"reason": reason,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def clear_stale_threats(self, max_age: int = 3600) -> int:
|
|
280
|
+
"""Remove threat IPs older than max_age seconds. Returns count removed."""
|
|
281
|
+
now = time.time()
|
|
282
|
+
stale = [ip for ip, data in self._threat_ips.items() if now - data["first_seen"] > max_age]
|
|
283
|
+
for ip in stale:
|
|
284
|
+
del self._threat_ips[ip]
|
|
285
|
+
return len(stale)
|
|
286
|
+
|
|
287
|
+
def export_threats(self) -> dict:
|
|
288
|
+
"""Export current threat intelligence for dashboard."""
|
|
289
|
+
return {
|
|
290
|
+
"threat_ips": len(self._threat_ips),
|
|
291
|
+
"injection_cache": len(self._injection_cache),
|
|
292
|
+
"block_threshold": self.block_threshold,
|
|
293
|
+
"rate_limit_per_min": self.rate_limit,
|
|
294
|
+
}
|