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,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
_log = logging.getLogger("patchi.debug.dap_client")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DAPError(Exception):
|
|
12
|
+
"""A DAP request returned success=false."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DAPClient:
|
|
16
|
+
"""Minimal Debug Adapter Protocol client over TCP.
|
|
17
|
+
|
|
18
|
+
Drives any DAP-compliant debug adapter (debugpy, dlv dap, codelldb, …)
|
|
19
|
+
through the same code path. This is *not* a full DAP implementation —
|
|
20
|
+
only the subset needed to capture post-exception state.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 5678):
|
|
24
|
+
self._host = host
|
|
25
|
+
self._port = port
|
|
26
|
+
self._sock: socket.socket | None = None
|
|
27
|
+
self._seq = 0
|
|
28
|
+
self._buffer = b""
|
|
29
|
+
|
|
30
|
+
# ── connection lifecycle ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def connect(self, timeout: float = 15) -> None:
|
|
33
|
+
deadline = time.monotonic() + timeout
|
|
34
|
+
last_err = None
|
|
35
|
+
while time.monotonic() < deadline:
|
|
36
|
+
try:
|
|
37
|
+
self._sock = socket.create_connection(
|
|
38
|
+
(self._host, self._port), timeout=min(5.0, deadline - time.monotonic())
|
|
39
|
+
)
|
|
40
|
+
self._sock.settimeout(timeout)
|
|
41
|
+
return
|
|
42
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
43
|
+
last_err = e
|
|
44
|
+
time.sleep(0.5)
|
|
45
|
+
raise ConnectionError(
|
|
46
|
+
f"Could not connect to {self._host}:{self._port} after {timeout}s"
|
|
47
|
+
) from last_err
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
if self._sock:
|
|
51
|
+
try:
|
|
52
|
+
self._sock.close()
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
self._sock = None
|
|
56
|
+
self._buffer = b""
|
|
57
|
+
|
|
58
|
+
# ── low-level I/O ───────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def _send(self, body: str) -> None:
|
|
61
|
+
raw = f"Content-Length: {len(body)}\r\n\r\n{body}".encode()
|
|
62
|
+
self._sock.sendall(raw)
|
|
63
|
+
|
|
64
|
+
def _recv(self, size: int = 8192) -> bytes:
|
|
65
|
+
chunk = self._sock.recv(size)
|
|
66
|
+
if not chunk:
|
|
67
|
+
raise ConnectionError("debug adapter closed the connection")
|
|
68
|
+
return chunk
|
|
69
|
+
|
|
70
|
+
def read_message(self) -> dict:
|
|
71
|
+
self._buffer += self._recv()
|
|
72
|
+
# peel off one complete DAP message
|
|
73
|
+
while True:
|
|
74
|
+
idx = self._buffer.find(b"\r\n\r\n")
|
|
75
|
+
if idx == -1:
|
|
76
|
+
self._buffer += self._recv()
|
|
77
|
+
continue
|
|
78
|
+
header = self._buffer[:idx].decode("ascii", errors="replace")
|
|
79
|
+
length = 0
|
|
80
|
+
for line in header.split("\r\n"):
|
|
81
|
+
if line.lower().startswith("content-length:"):
|
|
82
|
+
length = int(line.split(":", 1)[1].strip())
|
|
83
|
+
break
|
|
84
|
+
body_start = idx + 4
|
|
85
|
+
if len(self._buffer) < body_start + length:
|
|
86
|
+
self._buffer += self._recv()
|
|
87
|
+
continue
|
|
88
|
+
body = self._buffer[body_start : body_start + length]
|
|
89
|
+
self._buffer = self._buffer[body_start + length :]
|
|
90
|
+
return json.loads(body.decode("utf-8"))
|
|
91
|
+
|
|
92
|
+
# ── high-level wire helpers ─────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def send_request(self, command: str, arguments: dict | None = None) -> int:
|
|
95
|
+
self._seq += 1
|
|
96
|
+
body = json.dumps(
|
|
97
|
+
{
|
|
98
|
+
"seq": self._seq,
|
|
99
|
+
"type": "request",
|
|
100
|
+
"command": command,
|
|
101
|
+
"arguments": arguments or {},
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
self._send(body)
|
|
105
|
+
return self._seq
|
|
106
|
+
|
|
107
|
+
def read_response(self, expected_seq: int) -> dict:
|
|
108
|
+
while True:
|
|
109
|
+
msg = self.read_message()
|
|
110
|
+
if msg["type"] == "response" and msg.get("request_seq") == expected_seq:
|
|
111
|
+
if not msg.get("success", False):
|
|
112
|
+
err = msg.get(
|
|
113
|
+
"message", msg.get("body", {}).get("error", {}).get("format", "unknown")
|
|
114
|
+
)
|
|
115
|
+
raise DAPError(f"{msg.get('command', '?')} failed: {err}")
|
|
116
|
+
return msg.get("body", {})
|
|
117
|
+
if msg["type"] == "event":
|
|
118
|
+
_log.debug("DAP event during response wait: %s", msg.get("event"))
|
|
119
|
+
|
|
120
|
+
def request(self, command: str, arguments: dict | None = None) -> dict:
|
|
121
|
+
seq = self.send_request(command, arguments)
|
|
122
|
+
return self.read_response(seq)
|
|
123
|
+
|
|
124
|
+
def wait_for_event(self, event: str) -> dict:
|
|
125
|
+
while True:
|
|
126
|
+
msg = self.read_message()
|
|
127
|
+
if msg["type"] == "event" and msg.get("event") == event:
|
|
128
|
+
return msg.get("body", {})
|
|
129
|
+
|
|
130
|
+
# ── standard DAP requests ──────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def initialize(self) -> dict:
|
|
133
|
+
return self.request(
|
|
134
|
+
"initialize",
|
|
135
|
+
{
|
|
136
|
+
"clientID": "patchi-debug",
|
|
137
|
+
"clientName": "Patchi Debugger",
|
|
138
|
+
"adapterID": "python",
|
|
139
|
+
"pathFormat": "path",
|
|
140
|
+
"linesStartAt1": True,
|
|
141
|
+
"columnsStartAt1": True,
|
|
142
|
+
"supportsVariableType": True,
|
|
143
|
+
"supportsRunInTerminalRequest": False,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def launch(
|
|
148
|
+
self,
|
|
149
|
+
program: str,
|
|
150
|
+
args: list[str] | None = None,
|
|
151
|
+
*,
|
|
152
|
+
type: str = "python",
|
|
153
|
+
console: str = "none",
|
|
154
|
+
sourceLanguages: list[str] | None = None,
|
|
155
|
+
) -> dict:
|
|
156
|
+
args_body = {
|
|
157
|
+
"type": type,
|
|
158
|
+
"request": "launch",
|
|
159
|
+
"name": "Patchi Debug",
|
|
160
|
+
"program": program,
|
|
161
|
+
"args": args or [],
|
|
162
|
+
"console": console,
|
|
163
|
+
}
|
|
164
|
+
if sourceLanguages is not None:
|
|
165
|
+
args_body["sourceLanguages"] = sourceLanguages
|
|
166
|
+
return self.request("launch", args_body)
|
|
167
|
+
|
|
168
|
+
def set_exception_breakpoints(self, filters: list[str] | None = None) -> dict:
|
|
169
|
+
return self.request(
|
|
170
|
+
"setExceptionBreakpoints",
|
|
171
|
+
{
|
|
172
|
+
"filters": filters or ["uncaught"],
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def set_function_breakpoints(self, names: list[str]) -> dict:
|
|
177
|
+
return self.request(
|
|
178
|
+
"setFunctionBreakpoints",
|
|
179
|
+
{
|
|
180
|
+
"breakpoints": [{"name": n} for n in names],
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def set_breakpoints(self, source_path: str, lines: list[int]) -> dict:
|
|
185
|
+
return self.request(
|
|
186
|
+
"setBreakpoints",
|
|
187
|
+
{
|
|
188
|
+
"source": {"path": source_path},
|
|
189
|
+
"breakpoints": [{"line": ln} for ln in lines],
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def configuration_done(self) -> dict:
|
|
194
|
+
return self.request("configurationDone")
|
|
195
|
+
|
|
196
|
+
def continue_req(self, thread_id: int = 1) -> dict:
|
|
197
|
+
return self.request("continue", {"threadId": thread_id})
|
|
198
|
+
|
|
199
|
+
def stack_trace(self, thread_id: int, levels: int = 30) -> dict:
|
|
200
|
+
return self.request(
|
|
201
|
+
"stackTrace",
|
|
202
|
+
{
|
|
203
|
+
"threadId": thread_id,
|
|
204
|
+
"levels": levels,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def scopes(self, frame_id: int) -> dict:
|
|
209
|
+
return self.request("scopes", {"frameId": frame_id})
|
|
210
|
+
|
|
211
|
+
def variables(self, var_ref: int) -> dict:
|
|
212
|
+
return self.request("variables", {"variablesReference": var_ref})
|
|
213
|
+
|
|
214
|
+
def disconnect(self) -> dict:
|
|
215
|
+
return self.request("disconnect")
|
|
216
|
+
|
|
217
|
+
# ── non-blocking launch flow ───────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def launch_no_wait(
|
|
220
|
+
self,
|
|
221
|
+
program: str,
|
|
222
|
+
args: list[str] | None = None,
|
|
223
|
+
*,
|
|
224
|
+
type: str = "python",
|
|
225
|
+
console: str = "none",
|
|
226
|
+
sourceLanguages: list[str] | None = None,
|
|
227
|
+
) -> int:
|
|
228
|
+
"""Send ``launch`` + ``configurationDone`` without waiting for the
|
|
229
|
+
launch response.
|
|
230
|
+
|
|
231
|
+
Some adapters (codelldb) defer the ``launch`` response until
|
|
232
|
+
``configurationDone`` has been processed. Blocking on the response
|
|
233
|
+
before sending ``configurationDone`` deadlocks, so this sends both
|
|
234
|
+
back-to-back and returns the launch request seq.
|
|
235
|
+
"""
|
|
236
|
+
body = {
|
|
237
|
+
"type": type,
|
|
238
|
+
"request": "launch",
|
|
239
|
+
"name": "Patchi Debug",
|
|
240
|
+
"program": program,
|
|
241
|
+
"args": args or [],
|
|
242
|
+
"console": console,
|
|
243
|
+
}
|
|
244
|
+
if sourceLanguages is not None:
|
|
245
|
+
body["sourceLanguages"] = sourceLanguages
|
|
246
|
+
launch_seq = self.send_request("launch", body)
|
|
247
|
+
self.send_request("configurationDone", {})
|
|
248
|
+
return launch_seq
|
|
249
|
+
|
|
250
|
+
def wait_for_stopped(self, timeout: float) -> dict:
|
|
251
|
+
"""Read messages until a ``stopped`` event arrives (within *timeout*).
|
|
252
|
+
|
|
253
|
+
Other events and responses (including the deferred ``launch``
|
|
254
|
+
response) are consumed and discarded. Raises ``TimeoutError`` if no
|
|
255
|
+
stop happens in time.
|
|
256
|
+
"""
|
|
257
|
+
deadline = time.monotonic() + timeout
|
|
258
|
+
while True:
|
|
259
|
+
remaining = deadline - time.monotonic()
|
|
260
|
+
if remaining <= 0:
|
|
261
|
+
raise TimeoutError("timed out waiting for 'stopped' event")
|
|
262
|
+
try:
|
|
263
|
+
self._sock.settimeout(min(max(remaining, 0.5), 5.0))
|
|
264
|
+
msg = self.read_message()
|
|
265
|
+
except (ConnectionError, OSError, TimeoutError) as exc:
|
|
266
|
+
raise TimeoutError("timed out waiting for 'stopped' event") from exc
|
|
267
|
+
if msg.get("type") == "event" and msg.get("event") == "stopped":
|
|
268
|
+
return msg.get("body", {})
|
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit log — hash-chained, append-only event log.
|
|
3
|
+
|
|
4
|
+
Wraps the existing governance SQLite log and hosted JSON-lines log into a single
|
|
5
|
+
interface, receiving Event objects. Every event published to the EventBus with an
|
|
6
|
+
audit handler is written through this layer.
|
|
7
|
+
|
|
8
|
+
Hash chaining: each entry includes the SHA-256 of the previous entry, forming an
|
|
9
|
+
immutable chain. Exporting produces a verifiable lineage.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from patchi.core.detector.event import Event
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("patchi.detector.audit")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AuditEntry:
|
|
30
|
+
"""A single entry in the audit log — wraps an Event with hash chaining."""
|
|
31
|
+
|
|
32
|
+
event: Event
|
|
33
|
+
previous_hash: str = ""
|
|
34
|
+
entry_hash: str = field(default_factory=lambda: "")
|
|
35
|
+
written_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not self.entry_hash:
|
|
39
|
+
self.entry_hash = self._compute_hash()
|
|
40
|
+
|
|
41
|
+
def _compute_hash(self) -> str:
|
|
42
|
+
raw = json.dumps(self.to_dict(signed=True), sort_keys=True, default=str)
|
|
43
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
44
|
+
|
|
45
|
+
def to_dict(self, signed: bool = False) -> dict:
|
|
46
|
+
d: dict[str, Any] = {
|
|
47
|
+
"event": self.event.to_dict(),
|
|
48
|
+
"written_at": self.written_at,
|
|
49
|
+
}
|
|
50
|
+
if signed:
|
|
51
|
+
d["previous_hash"] = self.previous_hash
|
|
52
|
+
d["entry_hash"] = self.entry_hash
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuditLog:
|
|
57
|
+
"""
|
|
58
|
+
Event-driven audit log with hash chaining.
|
|
59
|
+
|
|
60
|
+
Writes to a JSON-lines file at `.patchi/detector/audit.log.jsonl`.
|
|
61
|
+
Each line is a JSON object with the event, timestamp, and chain hash.
|
|
62
|
+
|
|
63
|
+
Export produces a full verifiable chain. Governance SQLite log is still
|
|
64
|
+
written for backward compatibility (high-level action tracking).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, root: Path | None = None):
|
|
68
|
+
self._root = root
|
|
69
|
+
self._last_hash = ""
|
|
70
|
+
self._chain_length = 0
|
|
71
|
+
self._file: Any = None # open file handle
|
|
72
|
+
if root:
|
|
73
|
+
self._open()
|
|
74
|
+
|
|
75
|
+
def _open(self) -> None:
|
|
76
|
+
if not self._root:
|
|
77
|
+
return
|
|
78
|
+
log_dir = self._root / ".patchi" / "detector"
|
|
79
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
log_path = log_dir / "audit.log.jsonl"
|
|
81
|
+
|
|
82
|
+
# Load the last entry's hash if the file exists
|
|
83
|
+
if log_path.exists() and log_path.stat().st_size > 0:
|
|
84
|
+
try:
|
|
85
|
+
with open(log_path, encoding="utf-8") as f:
|
|
86
|
+
for line in f:
|
|
87
|
+
if line.strip():
|
|
88
|
+
entry = json.loads(line)
|
|
89
|
+
self._last_hash = entry.get("entry_hash", "")
|
|
90
|
+
self._chain_length += 1
|
|
91
|
+
except (json.JSONDecodeError, OSError):
|
|
92
|
+
logger.warning("Corrupt audit log — starting fresh chain")
|
|
93
|
+
self._last_hash = ""
|
|
94
|
+
self._chain_length = 0
|
|
95
|
+
|
|
96
|
+
self._log_path = log_path
|
|
97
|
+
self._file = open(log_path, "a", encoding="utf-8") # noqa: SIM115
|
|
98
|
+
|
|
99
|
+
async def write(self, event: Event) -> None:
|
|
100
|
+
"""Write an event to the audit log with hash chaining."""
|
|
101
|
+
entry = AuditEntry(
|
|
102
|
+
event=event,
|
|
103
|
+
previous_hash=self._last_hash,
|
|
104
|
+
)
|
|
105
|
+
self._last_hash = entry.entry_hash
|
|
106
|
+
self._chain_length += 1
|
|
107
|
+
line = json.dumps(entry.to_dict(signed=True), default=str) + "\n"
|
|
108
|
+
|
|
109
|
+
if self._file:
|
|
110
|
+
try:
|
|
111
|
+
self._file.write(line)
|
|
112
|
+
self._file.flush()
|
|
113
|
+
os.fsync(self._file.fileno())
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
logger.error("Failed to write audit log: %s", exc)
|
|
116
|
+
|
|
117
|
+
async def write_batch(self, events: list[Event]) -> None:
|
|
118
|
+
for event in events:
|
|
119
|
+
await self.write(event)
|
|
120
|
+
|
|
121
|
+
def export(self) -> str:
|
|
122
|
+
"""Export the full audit log as a JSON array (for reporting/sharing)."""
|
|
123
|
+
if not self._root:
|
|
124
|
+
return "[]"
|
|
125
|
+
log_path = self._root / ".patchi" / "detector" / "audit.log.jsonl"
|
|
126
|
+
if not log_path.exists():
|
|
127
|
+
return "[]"
|
|
128
|
+
entries = []
|
|
129
|
+
with open(log_path, encoding="utf-8") as f:
|
|
130
|
+
for line in f:
|
|
131
|
+
if line.strip():
|
|
132
|
+
entries.append(json.loads(line))
|
|
133
|
+
return json.dumps(entries, indent=2, default=str)
|
|
134
|
+
|
|
135
|
+
def verify_chain(self) -> tuple[bool, int]:
|
|
136
|
+
"""Verify the hash chain integrity. Returns (valid, verified_count)."""
|
|
137
|
+
if not self._root:
|
|
138
|
+
return True, 0
|
|
139
|
+
log_path = self._root / ".patchi" / "detector" / "audit.log.jsonl"
|
|
140
|
+
if not log_path.exists():
|
|
141
|
+
return True, 0
|
|
142
|
+
prev_hash = ""
|
|
143
|
+
count = 0
|
|
144
|
+
with open(log_path, encoding="utf-8") as f:
|
|
145
|
+
for line in f:
|
|
146
|
+
if not line.strip():
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
entry = json.loads(line)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
return False, count
|
|
152
|
+
if entry.get("previous_hash", "") != prev_hash:
|
|
153
|
+
return False, count
|
|
154
|
+
prev_hash = entry.get("entry_hash", "")
|
|
155
|
+
count += 1
|
|
156
|
+
return True, count
|
|
157
|
+
|
|
158
|
+
def close(self) -> None:
|
|
159
|
+
if self._file:
|
|
160
|
+
self._file.close()
|
|
161
|
+
self._file = None
|
|
162
|
+
|
|
163
|
+
def __enter__(self) -> AuditLog:
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def stats(self) -> dict:
|
|
171
|
+
return {
|
|
172
|
+
"chain_length": self._chain_length,
|
|
173
|
+
"last_hash": self._last_hash[:16] + "...",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── Shared bus setup (convenience) ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def setup_audit_logger(bus, root: Path) -> AuditLog:
|
|
181
|
+
"""
|
|
182
|
+
Create an AuditLog and attach it as the bus's audit handler.
|
|
183
|
+
|
|
184
|
+
Returns the AuditLog instance so callers can export/verify.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
audit_log = AuditLog(root=root)
|
|
188
|
+
|
|
189
|
+
async def audit_handler(event: Event) -> None:
|
|
190
|
+
await audit_log.write(event)
|
|
191
|
+
|
|
192
|
+
bus._audit_handler = audit_handler # type: ignore[attr-defined]
|
|
193
|
+
logger.info("Audit log attached to bus at .patchi/detector/audit.log.jsonl")
|
|
194
|
+
return audit_log
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EventBus — in-memory async pub/sub for the detector pipeline.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- Subscribe by EventSource, TechniqueID, severity threshold, or agent name
|
|
6
|
+
- Rate-limited dispatch (prevents handler resource exhaustion)
|
|
7
|
+
- Batch collection (time/buffer-based for bulk processing)
|
|
8
|
+
- Auditable: every published event is forwarded to the audit log
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from patchi.core.detector.event import Event, EventSeverity, EventSource, TechniqueID
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("patchi.detector.bus")
|
|
23
|
+
|
|
24
|
+
# Type alias for event handlers
|
|
25
|
+
EventHandler = Callable[[Event], Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_log = logging.getLogger("patchi.core.bus")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Subscription:
|
|
33
|
+
"""A single subscription to the event bus."""
|
|
34
|
+
|
|
35
|
+
handler: EventHandler
|
|
36
|
+
source: set[EventSource] | None = None
|
|
37
|
+
technique_filter: set[str] | None = None
|
|
38
|
+
agent_filter: set[str] | None = None
|
|
39
|
+
min_severity: EventSeverity | None = None
|
|
40
|
+
name: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimiter:
|
|
44
|
+
"""Sliding-window rate limiter per handler."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, max_per_second: float = 100.0):
|
|
47
|
+
self._windows: dict[str, list[float]] = defaultdict(list)
|
|
48
|
+
self._max_per_second = max_per_second
|
|
49
|
+
|
|
50
|
+
def allow(self, key: str) -> bool:
|
|
51
|
+
now = time.monotonic()
|
|
52
|
+
window = self._windows[key]
|
|
53
|
+
cutoff = now - 1.0
|
|
54
|
+
while window and window[0] < cutoff:
|
|
55
|
+
window.pop(0)
|
|
56
|
+
if len(window) >= self._max_per_second:
|
|
57
|
+
return False
|
|
58
|
+
window.append(now)
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def reset(self, key: str) -> None:
|
|
62
|
+
self._windows.pop(key, None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EventBus:
|
|
66
|
+
"""
|
|
67
|
+
In-memory event bus with filtering, rate limiting, and audit forwarding.
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
bus = EventBus()
|
|
71
|
+
|
|
72
|
+
async def on_scan_event(event: Event):
|
|
73
|
+
print(f"Got: {event.summary}")
|
|
74
|
+
|
|
75
|
+
bus.subscribe(on_scan_event, source={EventSource.AGENT})
|
|
76
|
+
await bus.publish(event)
|
|
77
|
+
|
|
78
|
+
# Collect in batches
|
|
79
|
+
async for batch in bus.collect_batch(timeout_s=5.0, max_events=100):
|
|
80
|
+
await process(batch)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
audit_handler: EventHandler | None = None,
|
|
86
|
+
rate_limit: float = 200.0,
|
|
87
|
+
):
|
|
88
|
+
self._subscriptions: list[Subscription] = []
|
|
89
|
+
self._lock = asyncio.Lock()
|
|
90
|
+
self._rate_limiter = RateLimiter(max_per_second=rate_limit)
|
|
91
|
+
self._audit_handler = audit_handler
|
|
92
|
+
self._batch_queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
93
|
+
self._running = False
|
|
94
|
+
|
|
95
|
+
# ── Subscription management ────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def subscribe(
|
|
98
|
+
self,
|
|
99
|
+
handler: EventHandler,
|
|
100
|
+
*,
|
|
101
|
+
source: set[EventSource] | None = None,
|
|
102
|
+
technique_id: set[str] | None = None,
|
|
103
|
+
agent: set[str] | None = None,
|
|
104
|
+
min_severity: EventSeverity | None = None,
|
|
105
|
+
name: str = "",
|
|
106
|
+
) -> Subscription:
|
|
107
|
+
sub = Subscription(
|
|
108
|
+
handler=handler,
|
|
109
|
+
source=source,
|
|
110
|
+
technique_filter=technique_id,
|
|
111
|
+
agent_filter=agent,
|
|
112
|
+
min_severity=min_severity,
|
|
113
|
+
name=name or getattr(handler, "__name__", str(id(handler))),
|
|
114
|
+
)
|
|
115
|
+
self._subscriptions.append(sub)
|
|
116
|
+
return sub
|
|
117
|
+
|
|
118
|
+
def unsubscribe(self, sub: Subscription) -> None:
|
|
119
|
+
if sub in self._subscriptions:
|
|
120
|
+
self._subscriptions.remove(sub)
|
|
121
|
+
|
|
122
|
+
def clear(self) -> None:
|
|
123
|
+
self._subscriptions.clear()
|
|
124
|
+
|
|
125
|
+
# ── Publishing ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async def publish(self, event: Event) -> None:
|
|
128
|
+
"""Publish a single event to matching subscribers."""
|
|
129
|
+
tid = (
|
|
130
|
+
event.technique_id.value
|
|
131
|
+
if isinstance(event.technique_id, TechniqueID)
|
|
132
|
+
else event.technique_id
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
matched_any = False
|
|
136
|
+
async with self._lock:
|
|
137
|
+
for sub in self._subscriptions:
|
|
138
|
+
if not self._matches(event, sub, tid):
|
|
139
|
+
continue
|
|
140
|
+
handler_key = f"{sub.name}:{tid}"
|
|
141
|
+
if not self._rate_limiter.allow(handler_key):
|
|
142
|
+
logger.debug("Rate limit hit for %s on %s", sub.name, tid)
|
|
143
|
+
continue
|
|
144
|
+
matched_any = True
|
|
145
|
+
try:
|
|
146
|
+
await sub.handler(event)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
_log.warning("EventBus.publish failed: %s", e)
|
|
149
|
+
logger.exception("Handler %s failed on event %s", sub.name, event.event_id)
|
|
150
|
+
|
|
151
|
+
# Always queue for batch collection
|
|
152
|
+
await self._batch_queue.put(event)
|
|
153
|
+
|
|
154
|
+
# Forward to audit handler (separate from subscriptions so it always fires)
|
|
155
|
+
if self._audit_handler:
|
|
156
|
+
try:
|
|
157
|
+
await self._audit_handler(event)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
_log.warning("EventBus.publish failed: %s", e)
|
|
160
|
+
logger.exception("Audit handler failed on event %s", event.event_id)
|
|
161
|
+
|
|
162
|
+
if not matched_any:
|
|
163
|
+
logger.debug("Event %s had zero matching subscribers", event.event_id)
|
|
164
|
+
|
|
165
|
+
async def publish_batch(self, events: list[Event]) -> None:
|
|
166
|
+
"""Publish multiple events in sequence."""
|
|
167
|
+
for event in events:
|
|
168
|
+
await self.publish(event)
|
|
169
|
+
|
|
170
|
+
# ── Batch collection ──────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
async def collect_batch(
|
|
173
|
+
self,
|
|
174
|
+
timeout_s: float = 5.0,
|
|
175
|
+
max_events: int = 100,
|
|
176
|
+
) -> list[Event]:
|
|
177
|
+
"""
|
|
178
|
+
Collect events into a batch (blocks until timeout or max_events reached).
|
|
179
|
+
|
|
180
|
+
Usage:
|
|
181
|
+
while True:
|
|
182
|
+
batch = await bus.collect_batch(timeout_s=1.0)
|
|
183
|
+
if batch:
|
|
184
|
+
await save_to_db(batch)
|
|
185
|
+
"""
|
|
186
|
+
events: list[Event] = []
|
|
187
|
+
deadline = time.monotonic() + timeout_s
|
|
188
|
+
|
|
189
|
+
while len(events) < max_events:
|
|
190
|
+
remaining = deadline - time.monotonic()
|
|
191
|
+
if remaining <= 0:
|
|
192
|
+
break
|
|
193
|
+
try:
|
|
194
|
+
ev = await asyncio.wait_for(
|
|
195
|
+
self._batch_queue.get(),
|
|
196
|
+
timeout=remaining,
|
|
197
|
+
)
|
|
198
|
+
events.append(ev)
|
|
199
|
+
except TimeoutError:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
return events
|
|
203
|
+
|
|
204
|
+
# ── Stats ──────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def subscriber_count(self) -> int:
|
|
207
|
+
return len(self._subscriptions)
|
|
208
|
+
|
|
209
|
+
def stats(self) -> dict:
|
|
210
|
+
return {
|
|
211
|
+
"subscribers": len(self._subscriptions),
|
|
212
|
+
"queue_depth": self._batch_queue.qsize(),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# ── Matching logic ─────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _matches(self, event: Event, sub: Subscription, tid: str) -> bool:
|
|
218
|
+
if sub.source is not None and event.source not in sub.source:
|
|
219
|
+
return False
|
|
220
|
+
if sub.technique_filter is not None and tid not in sub.technique_filter:
|
|
221
|
+
return False
|
|
222
|
+
if sub.agent_filter is not None and event.agent_name not in sub.agent_filter:
|
|
223
|
+
return False
|
|
224
|
+
if sub.min_severity is not None and event.severity.sort_key() > sub.min_severity.sort_key():
|
|
225
|
+
return False
|
|
226
|
+
return True
|