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,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CDNCacheSecurityAgent — CDN/edge cache security issues.
|
|
3
|
+
|
|
4
|
+
Detects CDN-related security issues:
|
|
5
|
+
- Cache poisoning via unvalidated headers
|
|
6
|
+
- Origin bypass (missing origin access controls)
|
|
7
|
+
- Cache-key normalization flaws
|
|
8
|
+
- Missing cache-control directives
|
|
9
|
+
- CDN configuration misconfigurations (CloudFront, Fastly, etc.)
|
|
10
|
+
|
|
11
|
+
Uses static file analysis and optional external tools (curl).
|
|
12
|
+
Does NOT call AI.
|
|
13
|
+
Does NOT write to disk.
|
|
14
|
+
Does NOT touch the queue.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from ..agents.base import (
|
|
26
|
+
AgentDomain,
|
|
27
|
+
AgentGroup,
|
|
28
|
+
AgentInput,
|
|
29
|
+
AgentResult,
|
|
30
|
+
AgentStatus,
|
|
31
|
+
BaseAgent,
|
|
32
|
+
Finding,
|
|
33
|
+
Severity,
|
|
34
|
+
make_finding,
|
|
35
|
+
register,
|
|
36
|
+
safe_rglob,
|
|
37
|
+
)
|
|
38
|
+
from ..brain.trace_log import trace_agent
|
|
39
|
+
|
|
40
|
+
_SOURCE_EXTENSIONS = {
|
|
41
|
+
"*.py",
|
|
42
|
+
"*.js",
|
|
43
|
+
"*.jsx",
|
|
44
|
+
"*.ts",
|
|
45
|
+
"*.tsx",
|
|
46
|
+
"*.java",
|
|
47
|
+
"*.php",
|
|
48
|
+
"*.rb",
|
|
49
|
+
"*.go",
|
|
50
|
+
"*.rs",
|
|
51
|
+
"*.cs",
|
|
52
|
+
"*.yaml",
|
|
53
|
+
"*.yml",
|
|
54
|
+
"*.toml",
|
|
55
|
+
"*.json",
|
|
56
|
+
"*.conf",
|
|
57
|
+
"*.cfg",
|
|
58
|
+
"*.nginx",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_CDN_CONFIG_NAMES = {
|
|
62
|
+
"cloudfront.yaml",
|
|
63
|
+
"cloudfront.yml",
|
|
64
|
+
"cloudfront.json",
|
|
65
|
+
"fastly.toml",
|
|
66
|
+
"fastly.yaml",
|
|
67
|
+
"fastly.yml",
|
|
68
|
+
"cloudflare.toml",
|
|
69
|
+
"cloudflare.yaml",
|
|
70
|
+
"cloudflare.yml",
|
|
71
|
+
"akamai.json",
|
|
72
|
+
"akamai.yaml",
|
|
73
|
+
"cloudfront-properties.json",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_CACHE_CONTROL_PATTERNS = [
|
|
77
|
+
re.compile(r"cache-control", re.IGNORECASE),
|
|
78
|
+
re.compile(r"surrogate-control", re.IGNORECASE),
|
|
79
|
+
re.compile(r"x-cache", re.IGNORECASE),
|
|
80
|
+
re.compile(r"cdn-cache", re.IGNORECASE),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
_ORIGIN_BYPASS_PATTERNS = [
|
|
84
|
+
(re.compile(r"X-Forwarded-Host", re.IGNORECASE), "X-Forwarded-Host header manipulation"),
|
|
85
|
+
(re.compile(r"X-Original-URL", re.IGNORECASE), "X-Original-URL header injection"),
|
|
86
|
+
(re.compile(r"X-Rewrite-URL", re.IGNORECASE), "X-Rewrite-URL header injection"),
|
|
87
|
+
(re.compile(r"X-Real-IP", re.IGNORECASE), "X-Real-IP header spoofing"),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
_CACHE_KEY_INJECTION_PATTERNS = [
|
|
91
|
+
(
|
|
92
|
+
re.compile(
|
|
93
|
+
r"(?:cache.?key|cacheKey|cache_key)\s*[=:]\s*.*\+|f['\"].*\{.*\}", re.IGNORECASE
|
|
94
|
+
),
|
|
95
|
+
"Dynamic cache key construction via string concatenation",
|
|
96
|
+
),
|
|
97
|
+
(re.compile(r"Vary\s*:\s*\*", re.IGNORECASE), "Wildcard Vary header may cause cache poisoning"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_log = logging.getLogger("patchi.security.cdn_cache_agent")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@register
|
|
105
|
+
class CDNCacheSecurityAgent(BaseAgent):
|
|
106
|
+
"""Agent for detecting CDN and edge cache security issues."""
|
|
107
|
+
|
|
108
|
+
group = AgentGroup.SECURITY
|
|
109
|
+
domain = AgentDomain.SECURITY
|
|
110
|
+
name = "CDNCacheSecurityAgent"
|
|
111
|
+
description = "CDN/edge cache security: cache poisoning, origin bypass, cache-key normalization"
|
|
112
|
+
|
|
113
|
+
def _run(self, inp: AgentInput, result: AgentResult) -> None:
|
|
114
|
+
findings: list[Finding] = []
|
|
115
|
+
files_scanned = 0
|
|
116
|
+
|
|
117
|
+
with trace_agent(self.name, inp.root) as trace:
|
|
118
|
+
# ── Static analysis ────────────────────────────────────────────
|
|
119
|
+
findings.extend(self._scan_cdn_configs(inp))
|
|
120
|
+
findings.extend(self._scan_cache_headers(inp))
|
|
121
|
+
findings.extend(self._scan_origin_bypass(inp))
|
|
122
|
+
findings.extend(self._scan_cache_key_issues(inp))
|
|
123
|
+
|
|
124
|
+
for pattern in _SOURCE_EXTENSIONS:
|
|
125
|
+
for fp in safe_rglob(inp.root, pattern):
|
|
126
|
+
if fp.is_file():
|
|
127
|
+
files_scanned += 1
|
|
128
|
+
findings.extend(self._scan_file(fp, inp.root))
|
|
129
|
+
|
|
130
|
+
# ── External tool: curl ────────────────────────────────────────
|
|
131
|
+
if shutil.which("curl"):
|
|
132
|
+
findings.extend(self._run_curl_check(inp))
|
|
133
|
+
|
|
134
|
+
trace.findings = len(findings)
|
|
135
|
+
trace.files_scanned = files_scanned
|
|
136
|
+
|
|
137
|
+
result.status = AgentStatus.SUCCEEDED
|
|
138
|
+
result.findings = findings
|
|
139
|
+
result.data.update(
|
|
140
|
+
{
|
|
141
|
+
"cdn_findings": len(findings),
|
|
142
|
+
"curl_available": shutil.which("curl") is not None,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# ── CDN config file scan ───────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def _scan_cdn_configs(self, inp: AgentInput) -> list[Finding]:
|
|
150
|
+
findings: list[Finding] = []
|
|
151
|
+
for fp in safe_rglob(inp.root, "*"):
|
|
152
|
+
if fp.name in _CDN_CONFIG_NAMES:
|
|
153
|
+
rel = fp.relative_to(inp.root).as_posix()
|
|
154
|
+
try:
|
|
155
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
156
|
+
if (
|
|
157
|
+
"OriginAccessControl" not in content
|
|
158
|
+
and "origin_access" not in content.lower()
|
|
159
|
+
):
|
|
160
|
+
findings.append(
|
|
161
|
+
make_finding(
|
|
162
|
+
severity=Severity.HIGH,
|
|
163
|
+
file=rel,
|
|
164
|
+
line_start=0,
|
|
165
|
+
title="CDN-01: Missing Origin Access Control",
|
|
166
|
+
description=(
|
|
167
|
+
"CDN configuration file found without origin access control "
|
|
168
|
+
"settings. This may allow direct origin bypass."
|
|
169
|
+
),
|
|
170
|
+
evidence=content[:200],
|
|
171
|
+
suggestion="Enable Origin Access Identity (CloudFront) or Shield Origin (Fastly).",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
if "signed" not in content.lower() and "token" not in content.lower():
|
|
175
|
+
findings.append(
|
|
176
|
+
make_finding(
|
|
177
|
+
severity=Severity.MEDIUM,
|
|
178
|
+
file=rel,
|
|
179
|
+
line_start=0,
|
|
180
|
+
title="CDN-02: No Signed URLs/Cookies Configured",
|
|
181
|
+
description="CDN distribution has no signed URL or signed cookie configuration.",
|
|
182
|
+
suggestion="Enable signed URLs or signed cookies for authenticated content.",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
_log.warning("CDNCacheSecurityAgent._scan_cdn_configs failed: %s", e)
|
|
187
|
+
return findings
|
|
188
|
+
|
|
189
|
+
# ── Cache header analysis ──────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def _scan_cache_headers(self, inp: AgentInput) -> list[Finding]:
|
|
192
|
+
findings: list[Finding] = []
|
|
193
|
+
header_patterns = [
|
|
194
|
+
("*.conf", ["nginx", "apache", "httpd"]),
|
|
195
|
+
("*.cfg", ["nginx", "apache"]),
|
|
196
|
+
("*.yaml", ["proxy", "upstream", "cache"]),
|
|
197
|
+
("*.yml", ["proxy", "upstream", "cache"]),
|
|
198
|
+
]
|
|
199
|
+
for pattern, _context_words in header_patterns:
|
|
200
|
+
for fp in safe_rglob(inp.root, pattern):
|
|
201
|
+
if not fp.is_file():
|
|
202
|
+
continue
|
|
203
|
+
rel = fp.relative_to(inp.root).as_posix()
|
|
204
|
+
try:
|
|
205
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
206
|
+
lines = content.splitlines()
|
|
207
|
+
for i, line in enumerate(lines, 1):
|
|
208
|
+
for rx in _CACHE_CONTROL_PATTERNS:
|
|
209
|
+
if rx.search(line):
|
|
210
|
+
if "no-store" not in line.lower() and "private" not in line.lower():
|
|
211
|
+
findings.append(
|
|
212
|
+
make_finding(
|
|
213
|
+
severity=Severity.MEDIUM,
|
|
214
|
+
file=rel,
|
|
215
|
+
line_start=i,
|
|
216
|
+
title="CDN-03: Cache-Control May Allow Sensitive Data Caching",
|
|
217
|
+
description=(
|
|
218
|
+
"Cache header found without no-store/private directive. "
|
|
219
|
+
"Sensitive data may be cached at the edge."
|
|
220
|
+
),
|
|
221
|
+
evidence=line.strip(),
|
|
222
|
+
suggestion="Add 'no-store' or 'private' for sensitive endpoints.",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
break
|
|
226
|
+
except Exception as e:
|
|
227
|
+
_log.warning("CDNCacheSecurityAgent._scan_cache_headers failed: %s", e)
|
|
228
|
+
return findings
|
|
229
|
+
|
|
230
|
+
# ── Origin bypass patterns ─────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def _scan_origin_bypass(self, inp: AgentInput) -> list[Finding]:
|
|
233
|
+
findings: list[Finding] = []
|
|
234
|
+
for pattern in ("*.py", "*.js", "*.ts", "*.java", "*.go", "*.rb", "*.php"):
|
|
235
|
+
for fp in safe_rglob(inp.root, pattern):
|
|
236
|
+
if not fp.is_file():
|
|
237
|
+
continue
|
|
238
|
+
rel = fp.relative_to(inp.root).as_posix()
|
|
239
|
+
try:
|
|
240
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
241
|
+
lines = content.splitlines()
|
|
242
|
+
for i, line in enumerate(lines, 1):
|
|
243
|
+
for rx, desc in _ORIGIN_BYPASS_PATTERNS:
|
|
244
|
+
if rx.search(line) and "test" not in rel.lower():
|
|
245
|
+
findings.append(
|
|
246
|
+
make_finding(
|
|
247
|
+
severity=Severity.HIGH,
|
|
248
|
+
file=rel,
|
|
249
|
+
line_start=i,
|
|
250
|
+
title="CDN-04: Origin Bypass via Header Injection",
|
|
251
|
+
description=desc,
|
|
252
|
+
evidence=line.strip(),
|
|
253
|
+
suggestion="Validate and strip CDN-injected headers at the origin.",
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
_log.warning("CDNCacheSecurityAgent._scan_origin_bypass failed: %s", e)
|
|
258
|
+
return findings
|
|
259
|
+
|
|
260
|
+
# ── Cache-key normalization ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def _scan_cache_key_issues(self, inp: AgentInput) -> list[Finding]:
|
|
263
|
+
findings: list[Finding] = []
|
|
264
|
+
for pattern in ("*.py", "*.js", "*.ts", "*.go", "*.rb", "*.php", "*.conf"):
|
|
265
|
+
for fp in safe_rglob(inp.root, pattern):
|
|
266
|
+
if not fp.is_file():
|
|
267
|
+
continue
|
|
268
|
+
rel = fp.relative_to(inp.root).as_posix()
|
|
269
|
+
try:
|
|
270
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
271
|
+
lines = content.splitlines()
|
|
272
|
+
for i, line in enumerate(lines, 1):
|
|
273
|
+
for rx, desc in _CACHE_KEY_INJECTION_PATTERNS:
|
|
274
|
+
if rx.search(line):
|
|
275
|
+
findings.append(
|
|
276
|
+
make_finding(
|
|
277
|
+
severity=Severity.MEDIUM,
|
|
278
|
+
file=rel,
|
|
279
|
+
line_start=i,
|
|
280
|
+
title="CDN-05: Cache Key Injection Risk",
|
|
281
|
+
description=desc,
|
|
282
|
+
evidence=line.strip(),
|
|
283
|
+
suggestion="Normalize cache keys to prevent injection via headers.",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
_log.warning("CDNCacheSecurityAgent._scan_cache_key_issues failed: %s", e)
|
|
288
|
+
return findings
|
|
289
|
+
|
|
290
|
+
# ── General source file scan ───────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def _scan_file(self, fp: Path, root: Path) -> list[Finding]:
|
|
293
|
+
findings: list[Finding] = []
|
|
294
|
+
rel = fp.relative_to(root).as_posix()
|
|
295
|
+
try:
|
|
296
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
297
|
+
# CDN-specific secrets
|
|
298
|
+
secret_patterns = [
|
|
299
|
+
(
|
|
300
|
+
re.compile(
|
|
301
|
+
r"(?:cloudfront|fastly|cloudflare)[_-]?(?:key|token|secret)\s*[:=]\s*['\"][^'\"]{8,}['\"]",
|
|
302
|
+
re.IGNORECASE,
|
|
303
|
+
),
|
|
304
|
+
"CDN-06: Hardcoded CDN Credential",
|
|
305
|
+
),
|
|
306
|
+
]
|
|
307
|
+
lines = content.splitlines()
|
|
308
|
+
for i, line in enumerate(lines, 1):
|
|
309
|
+
for rx, title in secret_patterns:
|
|
310
|
+
if rx.search(line):
|
|
311
|
+
findings.append(
|
|
312
|
+
make_finding(
|
|
313
|
+
severity=Severity.CRITICAL,
|
|
314
|
+
file=rel,
|
|
315
|
+
line_start=i,
|
|
316
|
+
title=title,
|
|
317
|
+
description="Hardcoded CDN credential found in source code.",
|
|
318
|
+
evidence=line.strip()[:120],
|
|
319
|
+
suggestion="Move CDN credentials to environment variables or a secrets manager.",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
_log.warning("CDNCacheSecurityAgent._scan_file failed: %s", e)
|
|
324
|
+
return findings
|
|
325
|
+
|
|
326
|
+
# ── External tool: curl ────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
def _run_curl_check(self, inp: AgentInput) -> list[Finding]:
|
|
329
|
+
"""Use curl to probe cache headers if a URL is provided in config."""
|
|
330
|
+
findings: list[Finding] = []
|
|
331
|
+
url = inp.config.get("cdn_probe_url")
|
|
332
|
+
if not url or not isinstance(url, str):
|
|
333
|
+
return findings
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
proc = subprocess.run(
|
|
337
|
+
["curl", "-sI", "-m", "10", url],
|
|
338
|
+
capture_output=True,
|
|
339
|
+
text=True,
|
|
340
|
+
timeout=15,
|
|
341
|
+
)
|
|
342
|
+
if proc.returncode == 0:
|
|
343
|
+
headers_lower = proc.stdout.lower()
|
|
344
|
+
if "x-cache:" in headers_lower and "hit" in headers_lower:
|
|
345
|
+
findings.append(
|
|
346
|
+
make_finding(
|
|
347
|
+
severity=Severity.LOW,
|
|
348
|
+
file="(cdn_probe)",
|
|
349
|
+
line_start=0,
|
|
350
|
+
title="CDN-03: Cache Hit Detected on Probe URL",
|
|
351
|
+
description="The probed URL returned a cache HIT. Verify sensitive data is not cached.",
|
|
352
|
+
evidence=proc.stdout[:300],
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
356
|
+
pass
|
|
357
|
+
return findings
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exploit Chain Engine — turns isolated findings into attack narratives.
|
|
3
|
+
|
|
4
|
+
A SQL injection in a rarely-hit endpoint is Medium. The same SQL injection
|
|
5
|
+
reachable without authentication, chained past a missing rate limit into an
|
|
6
|
+
unencrypted admin cookie, is Critical. Static scanners see the pieces; this
|
|
7
|
+
module assembles the pieces into the story an attacker would tell.
|
|
8
|
+
|
|
9
|
+
How it works (deterministic, zero AI, bounded):
|
|
10
|
+
|
|
11
|
+
1. Each Finding becomes a node classified as an ENTRY (exposes attack
|
|
12
|
+
surface: missing auth, open CORS, debug mode...), an EXPLOIT (injection,
|
|
13
|
+
XSS, SSRF...), or an IMPACT amplifier (secrets, weak crypto, sensitive
|
|
14
|
+
data exposure).
|
|
15
|
+
2. An edge A -> B ("A enables B") is added when the pair matches a chain
|
|
16
|
+
rule AND the findings are co-located (same file) or import-connected
|
|
17
|
+
(B's module imports A's module).
|
|
18
|
+
3. A chain is any path entry -> ... -> impact of length >= 2.
|
|
19
|
+
4. Chains are scored by exploitability and sorted worst-first.
|
|
20
|
+
|
|
21
|
+
Severity escalation: a chain's severity is at least HIGH when it links two
|
|
22
|
+
or more confirmed findings — the compound condition is what makes it
|
|
23
|
+
dangerous, even if each piece alone looked Medium.
|
|
24
|
+
|
|
25
|
+
No AI calls. No network. Pure graph analysis over the findings you already have.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from collections.abc import Iterable
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
_log = logging.getLogger("patchi.security.chain_analyzer")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NodeRole(StrEnum):
|
|
41
|
+
"""What role a finding plays in an attack chain."""
|
|
42
|
+
|
|
43
|
+
ENTRY = "entry" # opens the door
|
|
44
|
+
EXPLOIT = "exploit" # the actual attack step
|
|
45
|
+
IMPACT = "impact" # what the attacker walks away with
|
|
46
|
+
STANDALONE = "standalone" # not part of any known chain pattern
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Chain rules ────────────────────────────────────────────────────────────────
|
|
50
|
+
# Each rule: (source_type_pattern, target_type_pattern, edge_label).
|
|
51
|
+
# Type patterns are matched with substring semantics against finding.type so
|
|
52
|
+
# "sqli", "sql_injection", "injection_sql" all match "sql".
|
|
53
|
+
|
|
54
|
+
_CHAIN_RULES: tuple[tuple[str, str, str], ...] = (
|
|
55
|
+
# Entry -> exploit edges
|
|
56
|
+
("auth", "inject", "unauthenticated input reaches injector"),
|
|
57
|
+
("auth", "xss", "unauthenticated input reaches XSS sink"),
|
|
58
|
+
("auth", "ssrf", "unauthenticated request forges SSRF"),
|
|
59
|
+
("auth", "idor", "no auth boundary protects object access"),
|
|
60
|
+
("open_redirect", "xss", "redirect target smuggles script"),
|
|
61
|
+
("cors", "xss", "permissive CORS lets attacker read XSS payload"),
|
|
62
|
+
("debug", "inject", "debug mode leaks internals that shape payloads"),
|
|
63
|
+
("rate_limit", "brute", "no throttle permits unlimited attempts"),
|
|
64
|
+
("rate_limit", "inject", "no throttle permits payload fuzzing"),
|
|
65
|
+
# Exploit -> impact edges
|
|
66
|
+
("inject", "secret", "injection exfiltrates stored secrets"),
|
|
67
|
+
("inject", "sensitive_data", "injection dumps sensitive tables"),
|
|
68
|
+
("ssrf", "secret", "SSRF reaches internal metadata/credential service"),
|
|
69
|
+
("idor", "sensitive_data", "object access crosses ownership boundary"),
|
|
70
|
+
("xss", "session", "script lifts session cookies/tokens"),
|
|
71
|
+
("xss", "secret", "script scrapes secrets rendered into DOM"),
|
|
72
|
+
("deserialization", "rce", "deserialization gadget yields code execution"),
|
|
73
|
+
("command_injection", "secret", "command execution reads env/key material"),
|
|
74
|
+
("path_traversal", "secret", "traversal reads key/config files"),
|
|
75
|
+
("weak_crypto", "session", "forgeable tokens defeat session integrity"),
|
|
76
|
+
("hardcoded_secret", "secret", "committed key grants direct access"),
|
|
77
|
+
# Amplifier edges
|
|
78
|
+
("jwt", "auth", "token flaw defeats the auth layer itself"),
|
|
79
|
+
("csrf", "state_change", "forged request drives privileged mutation"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _matches(pattern: str, finding_type: str) -> bool:
|
|
84
|
+
return pattern in finding_type.lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify(finding_type: str) -> NodeRole:
|
|
88
|
+
"""Classify a finding type into its chain role."""
|
|
89
|
+
t = finding_type.lower()
|
|
90
|
+
if any(k in t for k in ("auth", "cors", "debug", "rate_limit", "redirect", "open")):
|
|
91
|
+
return NodeRole.ENTRY
|
|
92
|
+
if any(
|
|
93
|
+
k in t
|
|
94
|
+
for k in (
|
|
95
|
+
"inject",
|
|
96
|
+
"xss",
|
|
97
|
+
"ssrf",
|
|
98
|
+
"idor",
|
|
99
|
+
"deserial",
|
|
100
|
+
"traversal",
|
|
101
|
+
"rce",
|
|
102
|
+
"sqli",
|
|
103
|
+
"xxe",
|
|
104
|
+
)
|
|
105
|
+
):
|
|
106
|
+
return NodeRole.EXPLOIT
|
|
107
|
+
if any(
|
|
108
|
+
k in t
|
|
109
|
+
for k in (
|
|
110
|
+
"secret",
|
|
111
|
+
"crypto",
|
|
112
|
+
"sensitive",
|
|
113
|
+
"session",
|
|
114
|
+
"jwt",
|
|
115
|
+
"hardcoded",
|
|
116
|
+
"password",
|
|
117
|
+
"key",
|
|
118
|
+
)
|
|
119
|
+
):
|
|
120
|
+
return NodeRole.IMPACT
|
|
121
|
+
return NodeRole.STANDALONE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ChainNode:
|
|
126
|
+
"""One finding participating in a chain."""
|
|
127
|
+
|
|
128
|
+
finding: Any # patchi.core.agents.base.Finding
|
|
129
|
+
role: NodeRole
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def key(self) -> tuple:
|
|
133
|
+
f = self.finding
|
|
134
|
+
return (f.file, f.line, f.type)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def severity(self) -> str:
|
|
138
|
+
return self.finding.severity.value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Chain:
|
|
143
|
+
"""An ordered attack path: entry -> exploits -> impact."""
|
|
144
|
+
|
|
145
|
+
nodes: list[ChainNode]
|
|
146
|
+
edges: list[str] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def length(self) -> int:
|
|
150
|
+
return len(self.nodes)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def severity(self) -> str:
|
|
154
|
+
"""Escalated severity: multi-step chains are at least HIGH."""
|
|
155
|
+
order = ["info", "low", "medium", "high", "critical"]
|
|
156
|
+
worst = max(
|
|
157
|
+
(order.index(n.severity) for n in self.nodes if n.severity in order),
|
|
158
|
+
default=0,
|
|
159
|
+
)
|
|
160
|
+
if self.length >= 2:
|
|
161
|
+
worst = max(worst, order.index("high"))
|
|
162
|
+
if self.length >= 3:
|
|
163
|
+
worst = max(worst, order.index("critical"))
|
|
164
|
+
return order[worst]
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def score(self) -> float:
|
|
168
|
+
"""Exploitability score 0-100: depth × severity × confirmation spread."""
|
|
169
|
+
sev_weight = {"info": 5, "low": 15, "medium": 35, "high": 70, "critical": 90}
|
|
170
|
+
base = max((sev_weight.get(n.severity, 20) for n in self.nodes), default=20)
|
|
171
|
+
depth_bonus = min((self.length - 1) * 8, 24)
|
|
172
|
+
agents = {n.finding.agent for n in self.nodes}
|
|
173
|
+
confirm_bonus = min(len(agents) * 4, 12)
|
|
174
|
+
return min(base + depth_bonus + confirm_bonus, 100)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def narrative(self) -> str:
|
|
178
|
+
"""Human-readable attack story assembled from the edge labels."""
|
|
179
|
+
parts = [f"[{n.finding.type}] {n.finding.file}:{n.finding.line}" for n in self.nodes]
|
|
180
|
+
story = " -> ".join(parts)
|
|
181
|
+
if self.edges:
|
|
182
|
+
story += " | " + "; ".join(self.edges)
|
|
183
|
+
return story
|
|
184
|
+
|
|
185
|
+
def to_dict(self) -> dict:
|
|
186
|
+
return {
|
|
187
|
+
"severity": self.severity,
|
|
188
|
+
"score": round(self.score, 1),
|
|
189
|
+
"length": self.length,
|
|
190
|
+
"narrative": self.narrative,
|
|
191
|
+
"steps": [
|
|
192
|
+
{
|
|
193
|
+
"role": n.role.value,
|
|
194
|
+
"type": n.finding.type,
|
|
195
|
+
"file": n.finding.file,
|
|
196
|
+
"line": n.finding.line,
|
|
197
|
+
"severity": n.severity,
|
|
198
|
+
"agent": n.finding.agent,
|
|
199
|
+
}
|
|
200
|
+
for n in self.nodes
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ChainAnalyzer:
|
|
206
|
+
"""Builds exploit chains from a set of findings."""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
findings: Iterable[Any],
|
|
211
|
+
import_edges: dict[str, set[str]] | None = None,
|
|
212
|
+
):
|
|
213
|
+
"""
|
|
214
|
+
Args:
|
|
215
|
+
findings: patchi.core.agents.base.Finding objects (or duck-typed:
|
|
216
|
+
need .type/.file/.line/.severity/.agent).
|
|
217
|
+
import_edges: optional {importer_file: {imported_files...}} map.
|
|
218
|
+
Co-location alone is enough to link findings; import edges let
|
|
219
|
+
chains cross module boundaries (route -> service -> repo).
|
|
220
|
+
"""
|
|
221
|
+
self.import_edges = import_edges or {}
|
|
222
|
+
self.nodes: list[ChainNode] = []
|
|
223
|
+
for f in findings:
|
|
224
|
+
role = classify(getattr(f, "type", ""))
|
|
225
|
+
if role is not NodeRole.STANDALONE:
|
|
226
|
+
self.nodes.append(ChainNode(finding=f, role=role))
|
|
227
|
+
self._index()
|
|
228
|
+
|
|
229
|
+
def _index(self) -> None:
|
|
230
|
+
self.by_file: dict[str, list[ChainNode]] = defaultdict(list)
|
|
231
|
+
for n in self.nodes:
|
|
232
|
+
self.by_file[n.finding.file].append(n)
|
|
233
|
+
|
|
234
|
+
def _connected(self, a: ChainNode, b: ChainNode) -> bool:
|
|
235
|
+
"""True when b is reachable from a's location (same file or import path)."""
|
|
236
|
+
fa, fb = a.finding.file, b.finding.file
|
|
237
|
+
if fa == fb:
|
|
238
|
+
return True
|
|
239
|
+
return fb in self.import_edges.get(fa, set())
|
|
240
|
+
|
|
241
|
+
def _edge_label(self, src: ChainNode, dst: ChainNode) -> str | None:
|
|
242
|
+
st, dt = src.finding.type.lower(), dst.finding.type.lower()
|
|
243
|
+
for sp, dp, label in _CHAIN_RULES:
|
|
244
|
+
if _matches(sp, st) and _matches(dp, dt):
|
|
245
|
+
return label
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def _adjacency(self) -> dict[int, list[tuple[int, str]]]:
|
|
249
|
+
adj: dict[int, list[tuple[int, str]]] = defaultdict(list)
|
|
250
|
+
for i, src in enumerate(self.nodes):
|
|
251
|
+
for j, dst in enumerate(self.nodes):
|
|
252
|
+
if i == j:
|
|
253
|
+
continue
|
|
254
|
+
# Enforce direction: entries start chains, impacts end them.
|
|
255
|
+
order = {NodeRole.ENTRY: 0, NodeRole.EXPLOIT: 1, NodeRole.IMPACT: 2}
|
|
256
|
+
if order[src.role] > order[dst.role]:
|
|
257
|
+
continue
|
|
258
|
+
if not self._connected(src, dst):
|
|
259
|
+
continue
|
|
260
|
+
label = self._edge_label(src, dst)
|
|
261
|
+
if label:
|
|
262
|
+
adj[i].append((j, label))
|
|
263
|
+
return adj
|
|
264
|
+
|
|
265
|
+
def find_chains(self, max_chains: int = 50, max_depth: int = 5) -> list[Chain]:
|
|
266
|
+
"""Return all entry->...->impact chains, worst-first, capped."""
|
|
267
|
+
adj = self._adjacency()
|
|
268
|
+
chains: list[Chain] = []
|
|
269
|
+
|
|
270
|
+
def dfs(idx: int, path: list[int], labels: list[str], seen: set[int]) -> None:
|
|
271
|
+
if len(chains) >= max_chains * 4 or len(path) > max_depth:
|
|
272
|
+
return
|
|
273
|
+
node = self.nodes[idx]
|
|
274
|
+
# Entry->exploit pairs are already actionable ("unauthenticated
|
|
275
|
+
# SQL injection"); record them AND keep walking toward impact.
|
|
276
|
+
if len(path) >= 2 and node.role in (NodeRole.EXPLOIT, NodeRole.IMPACT):
|
|
277
|
+
chains.append(Chain(nodes=[self.nodes[i] for i in path], edges=list(labels)))
|
|
278
|
+
if node.role == NodeRole.IMPACT:
|
|
279
|
+
return # impact terminates a chain
|
|
280
|
+
for j, label in adj.get(idx, []):
|
|
281
|
+
if j in seen:
|
|
282
|
+
continue
|
|
283
|
+
dfs(j, path + [j], labels + [label], seen | {j})
|
|
284
|
+
|
|
285
|
+
for i, n in enumerate(self.nodes):
|
|
286
|
+
if n.role == NodeRole.ENTRY:
|
|
287
|
+
dfs(i, [i], [], {i})
|
|
288
|
+
elif n.role == NodeRole.EXPLOIT:
|
|
289
|
+
# Exploit-to-impact pairs also count (2-node chains).
|
|
290
|
+
for j, label in adj.get(i, []):
|
|
291
|
+
if self.nodes[j].role == NodeRole.IMPACT:
|
|
292
|
+
chains.append(
|
|
293
|
+
Chain(
|
|
294
|
+
nodes=[self.nodes[i], self.nodes[j]],
|
|
295
|
+
edges=[label],
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
chains.sort(key=lambda c: c.score, reverse=True)
|
|
300
|
+
# Dedup identical narratives (same steps via different DFS orders)
|
|
301
|
+
seen_narratives: set[str] = set()
|
|
302
|
+
unique: list[Chain] = []
|
|
303
|
+
for c in chains:
|
|
304
|
+
sig = tuple(n.key for n in c.nodes)
|
|
305
|
+
if sig not in seen_narratives:
|
|
306
|
+
seen_narratives.add(sig)
|
|
307
|
+
unique.append(c)
|
|
308
|
+
return unique[:max_chains]
|
|
309
|
+
|
|
310
|
+
def summary(self) -> dict[str, Any]:
|
|
311
|
+
"""Aggregate stats for reporting / web panels."""
|
|
312
|
+
chains = self.find_chains()
|
|
313
|
+
sev_counts: dict[str, int] = defaultdict(int)
|
|
314
|
+
for c in chains:
|
|
315
|
+
sev_counts[c.severity] += 1
|
|
316
|
+
return {
|
|
317
|
+
"findings_analyzed": len(self.nodes),
|
|
318
|
+
"chains_found": len(chains),
|
|
319
|
+
"by_severity": dict(sev_counts),
|
|
320
|
+
"worst": chains[0].to_dict() if chains else None,
|
|
321
|
+
}
|