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,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sigma rule engine — portable detection rule matching.
|
|
3
|
+
|
|
4
|
+
Implements a subset of the Sigma specification sufficient for Patchi's needs:
|
|
5
|
+
- Rule loading from YAML files
|
|
6
|
+
- Field matching (equality, wildcard, regex, CIDR)
|
|
7
|
+
- Aggregation matching (cardinality, count over time windows)
|
|
8
|
+
- Tag-based routing to MITRE ATT&CK technique_ids
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from patchi.core.detector.event import Event, EventSeverity, EventSource, TechniqueID
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("patchi.detector.sigma")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SigmaMatch:
|
|
27
|
+
"""Result of matching a rule against an event."""
|
|
28
|
+
|
|
29
|
+
rule_name: str
|
|
30
|
+
rule_id: str
|
|
31
|
+
technique_id: TechniqueID | str
|
|
32
|
+
confidence: float
|
|
33
|
+
severity: str
|
|
34
|
+
matched_fields: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
description: str = ""
|
|
36
|
+
target_agent: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SigmaRule:
|
|
41
|
+
title: str
|
|
42
|
+
id: str = ""
|
|
43
|
+
description: str = ""
|
|
44
|
+
status: str = "experimental"
|
|
45
|
+
level: str = "medium"
|
|
46
|
+
tags: list[str] = field(default_factory=list)
|
|
47
|
+
logsource: dict[str, str] = field(default_factory=dict)
|
|
48
|
+
detection: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
falsepositives: list[str] = field(default_factory=list)
|
|
50
|
+
author: str = ""
|
|
51
|
+
selections: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
52
|
+
condition_expr: str = ""
|
|
53
|
+
target_agent: str = ""
|
|
54
|
+
min_confidence: float = 0.3
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def technique_id(self) -> TechniqueID:
|
|
58
|
+
for tag in self.tags:
|
|
59
|
+
if tag.startswith("attack."):
|
|
60
|
+
tid = tag.split(".", 1)[1].upper()
|
|
61
|
+
try:
|
|
62
|
+
return TechniqueID(tid)
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue
|
|
65
|
+
if tag.startswith("mitre."):
|
|
66
|
+
try:
|
|
67
|
+
return TechniqueID(tag.split(".", 1)[1])
|
|
68
|
+
except ValueError:
|
|
69
|
+
continue
|
|
70
|
+
return TechniqueID.UNKNOWN
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SigmaRuleSet:
|
|
74
|
+
def __init__(self, rules: list[SigmaRule] | None = None):
|
|
75
|
+
self._rules: list[SigmaRule] = rules or []
|
|
76
|
+
self._rule_index: dict[str, list[SigmaRule]] = {}
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def load_directory(cls, path: Path) -> SigmaRuleSet:
|
|
80
|
+
import yaml
|
|
81
|
+
|
|
82
|
+
rules: list[SigmaRule] = []
|
|
83
|
+
if not path.exists():
|
|
84
|
+
logger.warning("Sigma rules directory not found: %s", path)
|
|
85
|
+
return cls()
|
|
86
|
+
for fpath in sorted(path.glob("*.yml")):
|
|
87
|
+
try:
|
|
88
|
+
raw = yaml.safe_load(fpath.read_text(encoding="utf-8"))
|
|
89
|
+
if not raw or not isinstance(raw, dict):
|
|
90
|
+
continue
|
|
91
|
+
rule = _parse_sigma_rule(raw)
|
|
92
|
+
if rule:
|
|
93
|
+
rules.append(rule)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.warning("Failed to load Sigma rule %s: %s", fpath.name, exc)
|
|
96
|
+
engine = cls(rules)
|
|
97
|
+
engine._build_index()
|
|
98
|
+
logger.info("Loaded %d Sigma rules from %s", len(rules), path)
|
|
99
|
+
return engine
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def load_text(cls, yaml_text: str) -> SigmaRuleSet:
|
|
103
|
+
import yaml
|
|
104
|
+
|
|
105
|
+
rules: list[SigmaRule] = []
|
|
106
|
+
try:
|
|
107
|
+
raw = yaml.safe_load(yaml_text)
|
|
108
|
+
except yaml.YAMLError:
|
|
109
|
+
return cls(rules)
|
|
110
|
+
if isinstance(raw, dict):
|
|
111
|
+
rule = _parse_sigma_rule(raw)
|
|
112
|
+
if rule:
|
|
113
|
+
rules.append(rule)
|
|
114
|
+
elif isinstance(raw, list):
|
|
115
|
+
for entry in raw:
|
|
116
|
+
if isinstance(entry, dict):
|
|
117
|
+
rule = _parse_sigma_rule(entry)
|
|
118
|
+
if rule:
|
|
119
|
+
rules.append(rule)
|
|
120
|
+
return cls(rules)
|
|
121
|
+
|
|
122
|
+
def match(self, event: Event) -> list[SigmaMatch]:
|
|
123
|
+
results: list[SigmaMatch] = []
|
|
124
|
+
for rule in self._rules:
|
|
125
|
+
m = self._match_one(rule, event)
|
|
126
|
+
if m:
|
|
127
|
+
results.append(m)
|
|
128
|
+
return results
|
|
129
|
+
|
|
130
|
+
def _build_index(self) -> None:
|
|
131
|
+
self._rule_index.clear()
|
|
132
|
+
for rule in self._rules:
|
|
133
|
+
tid = (
|
|
134
|
+
rule.technique_id.value
|
|
135
|
+
if isinstance(rule.technique_id, TechniqueID)
|
|
136
|
+
else str(rule.technique_id)
|
|
137
|
+
)
|
|
138
|
+
self._rule_index.setdefault(tid, []).append(rule)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def count(self) -> int:
|
|
142
|
+
return len(self._rules)
|
|
143
|
+
|
|
144
|
+
def _match_one(self, rule: SigmaRule, event: Event) -> SigmaMatch | None:
|
|
145
|
+
if rule.logsource and not self._match_logsource(rule.logsource, event):
|
|
146
|
+
return None
|
|
147
|
+
if not rule.selections:
|
|
148
|
+
return None
|
|
149
|
+
evaluated: dict[str, bool] = {}
|
|
150
|
+
for sel_name, sel_fields in rule.selections.items():
|
|
151
|
+
evaluated[sel_name] = self._match_selection(sel_fields, event)
|
|
152
|
+
if not self._evaluate_condition(rule.condition_expr, evaluated):
|
|
153
|
+
return None
|
|
154
|
+
sev_map = {"informational": 0.1, "low": 0.3, "medium": 0.5, "high": 0.7, "critical": 0.9}
|
|
155
|
+
confidence = max(rule.min_confidence, sev_map.get(rule.level, 0.3))
|
|
156
|
+
return SigmaMatch(
|
|
157
|
+
rule_name=rule.title,
|
|
158
|
+
rule_id=rule.id,
|
|
159
|
+
technique_id=rule.technique_id,
|
|
160
|
+
confidence=confidence,
|
|
161
|
+
severity=rule.level,
|
|
162
|
+
description=rule.description or rule.title,
|
|
163
|
+
target_agent=rule.target_agent,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _match_logsource(self, logsource: dict[str, str], event: Event) -> bool:
|
|
167
|
+
if "category" in logsource:
|
|
168
|
+
if not self._value_match(
|
|
169
|
+
logsource["category"], event.source_details.get("category", event.source.value)
|
|
170
|
+
):
|
|
171
|
+
return False
|
|
172
|
+
if "product" in logsource:
|
|
173
|
+
if not self._value_match(logsource["product"], event.source_details.get("product", "")):
|
|
174
|
+
return False
|
|
175
|
+
if "service" in logsource:
|
|
176
|
+
if not self._value_match(logsource["service"], event.source_details.get("service", "")):
|
|
177
|
+
return False
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
def _match_selection(self, fields: dict[str, Any], event: Event) -> bool:
|
|
181
|
+
for field_key, expected in fields.items():
|
|
182
|
+
field_name, modifier = self._parse_sigma_field(field_key)
|
|
183
|
+
actual = self._get_event_field(event, field_name)
|
|
184
|
+
if actual is None:
|
|
185
|
+
return False
|
|
186
|
+
if modifier:
|
|
187
|
+
result = self._match_with_modifier(modifier, expected, actual)
|
|
188
|
+
else:
|
|
189
|
+
result = self._value_match(expected, actual)
|
|
190
|
+
if not result:
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def _parse_sigma_field(self, field_key: str) -> tuple[str, str | None]:
|
|
195
|
+
if "|" in field_key:
|
|
196
|
+
parts = field_key.rsplit("|", 1)
|
|
197
|
+
return parts[0], parts[1]
|
|
198
|
+
return field_key, None
|
|
199
|
+
|
|
200
|
+
def _match_with_modifier(self, modifier: str, expected: Any, actual: Any) -> bool:
|
|
201
|
+
actual_str = str(actual)
|
|
202
|
+
if modifier == "contains":
|
|
203
|
+
return str(expected) in actual_str
|
|
204
|
+
if modifier == "startswith":
|
|
205
|
+
return actual_str.startswith(str(expected))
|
|
206
|
+
if modifier == "endswith":
|
|
207
|
+
return actual_str.endswith(str(expected))
|
|
208
|
+
if modifier == "re":
|
|
209
|
+
try:
|
|
210
|
+
return bool(re.search(str(expected), actual_str))
|
|
211
|
+
except re.error:
|
|
212
|
+
return False
|
|
213
|
+
if modifier == "base64":
|
|
214
|
+
try:
|
|
215
|
+
import base64
|
|
216
|
+
|
|
217
|
+
decoded = base64.b64decode(actual_str).decode()
|
|
218
|
+
return str(expected) in decoded
|
|
219
|
+
except Exception as e:
|
|
220
|
+
return False
|
|
221
|
+
logger.warning("SigmaRuleSet._match_with_modifier failed: %s", e)
|
|
222
|
+
if modifier == "cidr":
|
|
223
|
+
return self._match_cidr(str(expected), actual_str)
|
|
224
|
+
if modifier == "gt":
|
|
225
|
+
return isinstance(actual, (int, float)) and actual > expected
|
|
226
|
+
if modifier == "lt":
|
|
227
|
+
return isinstance(actual, (int, float)) and actual < expected
|
|
228
|
+
if modifier == "gte":
|
|
229
|
+
return isinstance(actual, (int, float)) and actual >= expected
|
|
230
|
+
if modifier == "lte":
|
|
231
|
+
return isinstance(actual, (int, float)) and actual <= expected
|
|
232
|
+
return self._value_match(expected, actual)
|
|
233
|
+
|
|
234
|
+
def _match_cidr(self, cidr: str, ip: str) -> bool:
|
|
235
|
+
try:
|
|
236
|
+
import ipaddress
|
|
237
|
+
|
|
238
|
+
net = ipaddress.ip_network(cidr, strict=False)
|
|
239
|
+
addr = ipaddress.ip_address(ip)
|
|
240
|
+
return addr in net
|
|
241
|
+
except ImportError:
|
|
242
|
+
return False
|
|
243
|
+
except ValueError:
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def _get_event_field(self, event: Event, field: str) -> Any:
|
|
247
|
+
if field.startswith("event."):
|
|
248
|
+
field = field[6:]
|
|
249
|
+
d = event.to_dict()
|
|
250
|
+
parts = field.split(".")
|
|
251
|
+
obj: Any = d
|
|
252
|
+
for part in parts:
|
|
253
|
+
if isinstance(obj, dict):
|
|
254
|
+
obj = obj.get(part)
|
|
255
|
+
else:
|
|
256
|
+
return None
|
|
257
|
+
return obj
|
|
258
|
+
|
|
259
|
+
def _value_match(self, expected: Any, actual: Any) -> bool:
|
|
260
|
+
if isinstance(expected, str):
|
|
261
|
+
if "*" in expected:
|
|
262
|
+
return fnmatch.fnmatch(str(actual), expected)
|
|
263
|
+
if expected.startswith("re:"):
|
|
264
|
+
try:
|
|
265
|
+
return bool(re.search(expected[3:], str(actual)))
|
|
266
|
+
except re.error:
|
|
267
|
+
return False
|
|
268
|
+
return str(actual) == expected
|
|
269
|
+
if isinstance(expected, list):
|
|
270
|
+
return any(self._value_match(v, actual) for v in expected)
|
|
271
|
+
if isinstance(expected, dict):
|
|
272
|
+
if "contains" in expected:
|
|
273
|
+
return str(expected["contains"]) in str(actual)
|
|
274
|
+
if "startswith" in expected:
|
|
275
|
+
return str(actual).startswith(str(expected["startswith"]))
|
|
276
|
+
if "endswith" in expected:
|
|
277
|
+
return str(actual).endswith(str(expected["endswith"]))
|
|
278
|
+
if "re" in expected:
|
|
279
|
+
try:
|
|
280
|
+
return bool(re.search(expected["re"], str(actual)))
|
|
281
|
+
except re.error:
|
|
282
|
+
return False
|
|
283
|
+
return str(actual) == str(expected)
|
|
284
|
+
|
|
285
|
+
def _evaluate_condition(self, expr: str, results: dict[str, bool]) -> bool:
|
|
286
|
+
expr = expr.strip()
|
|
287
|
+
if not expr:
|
|
288
|
+
return True
|
|
289
|
+
lower = expr.lower()
|
|
290
|
+
if " or " in lower:
|
|
291
|
+
return any(
|
|
292
|
+
self._evaluate_condition(p.strip(), results)
|
|
293
|
+
for p in re.split(r"\s+or\s+", expr, flags=re.IGNORECASE)
|
|
294
|
+
)
|
|
295
|
+
if " and " in lower:
|
|
296
|
+
return all(
|
|
297
|
+
self._evaluate_condition(p.strip(), results)
|
|
298
|
+
for p in re.split(r"\s+and\s+", expr, flags=re.IGNORECASE)
|
|
299
|
+
)
|
|
300
|
+
if lower in ("all of them", "all of the"):
|
|
301
|
+
return all(results.values())
|
|
302
|
+
if lower in ("any of them", "any of the"):
|
|
303
|
+
return any(results.values())
|
|
304
|
+
m = re.match(r"(all|1|any)\s+of\s+(.+?)(?:\*)?$", lower)
|
|
305
|
+
if m:
|
|
306
|
+
prefix = m.group(2).rstrip()
|
|
307
|
+
relevant = {k: v for k, v in results.items() if k.startswith(prefix)}
|
|
308
|
+
if not relevant:
|
|
309
|
+
return False
|
|
310
|
+
return all(relevant.values()) if m.group(1) in ("all", "1") else any(relevant.values())
|
|
311
|
+
if lower.startswith("not "):
|
|
312
|
+
return not results.get(expr[4:].strip(), False)
|
|
313
|
+
if lower in results:
|
|
314
|
+
return results[lower]
|
|
315
|
+
if lower == "1 of them":
|
|
316
|
+
return any(results.values())
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _parse_sigma_rule(raw: dict[str, Any]) -> SigmaRule | None:
|
|
321
|
+
if not raw.get("title"):
|
|
322
|
+
return None
|
|
323
|
+
detection = raw.get("detection", {})
|
|
324
|
+
selections: dict[str, dict[str, Any]] = {}
|
|
325
|
+
condition_expr = ""
|
|
326
|
+
for key, value in detection.items():
|
|
327
|
+
if key == "condition":
|
|
328
|
+
if isinstance(value, str):
|
|
329
|
+
condition_expr = value
|
|
330
|
+
elif isinstance(value, dict):
|
|
331
|
+
condition_expr = value.get("expression", "any of them")
|
|
332
|
+
elif isinstance(value, dict):
|
|
333
|
+
selections[key] = value
|
|
334
|
+
if not selections and not condition_expr:
|
|
335
|
+
logger.debug("No selections found in rule '%s'", raw.get("title", ""))
|
|
336
|
+
return None
|
|
337
|
+
return SigmaRule(
|
|
338
|
+
title=str(raw.get("title", "")),
|
|
339
|
+
id=str(raw.get("id", "")),
|
|
340
|
+
description=str(raw.get("description", "")),
|
|
341
|
+
status=str(raw.get("status", "experimental")),
|
|
342
|
+
level=str(raw.get("level", "low")),
|
|
343
|
+
tags=[str(t) for t in raw.get("tags", [])],
|
|
344
|
+
logsource={k: str(v) for k, v in raw.get("logsource", {}).items()},
|
|
345
|
+
detection=detection,
|
|
346
|
+
falsepositives=[str(f) for f in raw.get("falsepositives", [])],
|
|
347
|
+
author=str(raw.get("author", "")),
|
|
348
|
+
selections=selections,
|
|
349
|
+
condition_expr=condition_expr,
|
|
350
|
+
target_agent=str(raw.get("target_agent", "")),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _sigma_level_to_severity(level: str) -> EventSeverity:
|
|
355
|
+
mapping = {
|
|
356
|
+
"informational": EventSeverity.INFO,
|
|
357
|
+
"low": EventSeverity.LOW,
|
|
358
|
+
"medium": EventSeverity.MEDIUM,
|
|
359
|
+
"high": EventSeverity.HIGH,
|
|
360
|
+
"critical": EventSeverity.CRITICAL,
|
|
361
|
+
}
|
|
362
|
+
return mapping.get(level, EventSeverity.INFO)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def sigma_match_to_event(match: SigmaMatch, parent_event: Event | None = None) -> Event:
|
|
366
|
+
return Event(
|
|
367
|
+
source=parent_event.source if parent_event else EventSource.AGENT,
|
|
368
|
+
technique_id=match.technique_id,
|
|
369
|
+
summary=match.description or match.rule_name,
|
|
370
|
+
severity=_sigma_level_to_severity(match.severity),
|
|
371
|
+
confidence=match.confidence,
|
|
372
|
+
payload={
|
|
373
|
+
"rule_id": match.rule_id,
|
|
374
|
+
"rule_name": match.rule_name,
|
|
375
|
+
"matched_fields": match.matched_fields,
|
|
376
|
+
},
|
|
377
|
+
suggested_agent=match.target_agent or "",
|
|
378
|
+
)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TriageAgent — statistical anomaly detection over the event stream.
|
|
3
|
+
|
|
4
|
+
The Triage Agent is the catch-all for events that don't match any existing
|
|
5
|
+
technique_id with high confidence. It uses lightweight, deterministic statistics
|
|
6
|
+
(z-score, EWMA, rate-of-change) to surface outliers that warrant human or AI review.
|
|
7
|
+
|
|
8
|
+
This is NOT a machine learning model. It's a set of interpretable counters and
|
|
9
|
+
moving averages that flag:
|
|
10
|
+
|
|
11
|
+
1. **Rate anomalies** — a signal source suddenly emitting 10x its normal volume
|
|
12
|
+
2. **Severity anomalies** — a normally-low-severity source producing a HIGH/CRITICAL event
|
|
13
|
+
3. **Novelty anomalies** — a technique_id never seen before from a known source
|
|
14
|
+
4. **Silence anomalies** — a normally-chatty source goes silent (dead-man's switch)
|
|
15
|
+
|
|
16
|
+
Every anomaly produces a Finding with the agent name "TriageAgent" so it enters
|
|
17
|
+
the standard pipeline (orchestration → gating → routing).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
|
|
28
|
+
from patchi.core.agents.base import (
|
|
29
|
+
AgentGroup,
|
|
30
|
+
AgentInput,
|
|
31
|
+
AgentResult,
|
|
32
|
+
BaseAgent,
|
|
33
|
+
Finding,
|
|
34
|
+
Severity,
|
|
35
|
+
register,
|
|
36
|
+
)
|
|
37
|
+
from patchi.core.detector.bus import EventBus, Subscription
|
|
38
|
+
from patchi.core.detector.event import Event, TechniqueID
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("patchi.detector.triage")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Statistical windows ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class EWMAMeter:
|
|
48
|
+
"""
|
|
49
|
+
Exponentially-weighted moving average and z-score computation.
|
|
50
|
+
|
|
51
|
+
Tracks rate and variance per key (source, technique_id, agent).
|
|
52
|
+
Alpha controls how quickly old data decays (lower = smoother).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
alpha: float = 0.1 # Smoothing factor (0.0–1.0)
|
|
56
|
+
min_samples: int = 5 # Min observations before z-score is meaningful
|
|
57
|
+
_mean: float = 0.0
|
|
58
|
+
_m2: float = 0.0 # Sum of squared differences (for variance)
|
|
59
|
+
_count: int = 0
|
|
60
|
+
_last_update: float = 0.0
|
|
61
|
+
_current_value: float = 0.0
|
|
62
|
+
|
|
63
|
+
def update(self, value: float) -> None:
|
|
64
|
+
now = time.monotonic()
|
|
65
|
+
elapsed = now - self._last_update if self._last_update else 0.0
|
|
66
|
+
self._last_update = now
|
|
67
|
+
|
|
68
|
+
rate = value / elapsed if elapsed > 0.0 else value
|
|
69
|
+
|
|
70
|
+
self._count += 1
|
|
71
|
+
if self._count == 1:
|
|
72
|
+
self._mean = rate
|
|
73
|
+
self._m2 = 0.0
|
|
74
|
+
else:
|
|
75
|
+
diff = rate - self._mean
|
|
76
|
+
incr = self.alpha * diff
|
|
77
|
+
self._mean += incr
|
|
78
|
+
self._m2 = (1 - self.alpha) * (self._m2 + self.alpha * diff * diff)
|
|
79
|
+
|
|
80
|
+
self._current_value = rate
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def mean(self) -> float:
|
|
84
|
+
return self._mean
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def variance(self) -> float:
|
|
88
|
+
return self._m2 + 1e-10
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def std(self) -> float:
|
|
92
|
+
return self.variance**0.5
|
|
93
|
+
|
|
94
|
+
def z_score(self, value: float) -> float:
|
|
95
|
+
s = self.std
|
|
96
|
+
if self._count < self.min_samples:
|
|
97
|
+
return 0.0
|
|
98
|
+
return (value - self._mean) / s
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def ready(self) -> bool:
|
|
102
|
+
return self._count >= self.min_samples
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Per-source statistical tracker ───────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class SourceStats:
|
|
110
|
+
"""Per-source statistics for anomaly detection."""
|
|
111
|
+
|
|
112
|
+
rate_meter: EWMAMeter = field(default_factory=lambda: EWMAMeter(alpha=0.05))
|
|
113
|
+
severity_meter: EWMAMeter = field(default_factory=lambda: EWMAMeter(alpha=0.1))
|
|
114
|
+
technique_ids: set[str] = field(default_factory=set)
|
|
115
|
+
last_seen: float = 0.0
|
|
116
|
+
event_count: int = 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── TriageAgent ──────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@register
|
|
123
|
+
class TriageAgent(BaseAgent):
|
|
124
|
+
"""
|
|
125
|
+
Statistical anomaly detection over the event stream.
|
|
126
|
+
|
|
127
|
+
Runs continuously in the background, listening to the EventBus and updating
|
|
128
|
+
per-source statistics. When an anomaly is detected, it creates a Finding.
|
|
129
|
+
|
|
130
|
+
This agent has no `_run()` with a single AgentInput — instead it uses a
|
|
131
|
+
long-lived `run_detached()` coroutine that subscribes to the bus.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
name = "TriageAgent"
|
|
135
|
+
group = AgentGroup.GUARD
|
|
136
|
+
timeout = 300
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
super().__init__()
|
|
140
|
+
self._sources: dict[str, SourceStats] = defaultdict(SourceStats)
|
|
141
|
+
self._subscription: Subscription | None = None
|
|
142
|
+
self._z_score_threshold = 3.0 # Events >3σ are anomalous
|
|
143
|
+
self._silence_timeout_s = 300.0 # 5 min silence = anomaly
|
|
144
|
+
self._rate_spike_threshold = 10.0 # 10x normal rate = spike
|
|
145
|
+
self._last_finding_time: dict[str, float] = {}
|
|
146
|
+
|
|
147
|
+
# ── Detached runner (long-lived coroutine) ────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async def run_detached(self, bus: EventBus, root: str | None = None) -> None:
|
|
150
|
+
"""Subscribe to the EventBus and process events indefinitely."""
|
|
151
|
+
self._subscription = bus.subscribe(
|
|
152
|
+
self._on_event,
|
|
153
|
+
name=self.name,
|
|
154
|
+
)
|
|
155
|
+
logger.info("TriageAgent subscribed to EventBus")
|
|
156
|
+
|
|
157
|
+
def stop(self) -> None:
|
|
158
|
+
if self._subscription:
|
|
159
|
+
self._bus = None
|
|
160
|
+
self._subscription = None
|
|
161
|
+
logger.info("TriageAgent stopped")
|
|
162
|
+
|
|
163
|
+
# ── Event handler ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
async def _on_event(self, event: Event) -> None:
|
|
166
|
+
source_key = f"{event.source.value}:{event.source_details.get('host', 'local')}"
|
|
167
|
+
stats = self._sources[source_key]
|
|
168
|
+
stats.event_count += 1
|
|
169
|
+
stats.last_seen = time.monotonic()
|
|
170
|
+
|
|
171
|
+
# Track seen technique IDs
|
|
172
|
+
tid = (
|
|
173
|
+
event.technique_id.value
|
|
174
|
+
if isinstance(event.technique_id, TechniqueID)
|
|
175
|
+
else event.technique_id
|
|
176
|
+
)
|
|
177
|
+
stats.technique_ids.add(tid)
|
|
178
|
+
|
|
179
|
+
# Update rate meter (increment by 1)
|
|
180
|
+
stats.rate_meter.update(1.0)
|
|
181
|
+
|
|
182
|
+
# Update severity meter (numeric: debug=0 … critical=5)
|
|
183
|
+
sev_score = event.severity.sort_key()
|
|
184
|
+
stats.severity_meter.update(float(5 - sev_score))
|
|
185
|
+
|
|
186
|
+
# Skip anomaly checks if the meter isn't warm yet
|
|
187
|
+
if not stats.rate_meter.ready:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Check: rate spike
|
|
191
|
+
if stats.rate_meter.current_value > 0:
|
|
192
|
+
ratio = stats.rate_meter.current_value / (stats.rate_meter.mean or 1.0)
|
|
193
|
+
if ratio > self._rate_spike_threshold:
|
|
194
|
+
await self._report_anomaly(
|
|
195
|
+
event,
|
|
196
|
+
source_key,
|
|
197
|
+
"rate_spike",
|
|
198
|
+
f"Event rate from {source_key} is {ratio:.1f}x above baseline ({stats.rate_meter.mean:.1f} vs {stats.rate_meter.current_value:.1f})",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Check: z-score severity anomaly
|
|
202
|
+
z = stats.rate_meter.z_score(stats.rate_meter.current_value)
|
|
203
|
+
if z > self._z_score_threshold:
|
|
204
|
+
await self._report_anomaly(
|
|
205
|
+
event,
|
|
206
|
+
source_key,
|
|
207
|
+
"statistical_outlier",
|
|
208
|
+
f"Event from {source_key} is {z:.1f}σ above {tid} baseline (z-score anomaly)",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# ── Silence check (call periodically, e.g. from a timer) ──────────────────
|
|
212
|
+
|
|
213
|
+
def check_silence(self) -> list[Finding]:
|
|
214
|
+
"""Check if any previously-active source has gone silent."""
|
|
215
|
+
now = time.monotonic()
|
|
216
|
+
findings: list[Finding] = []
|
|
217
|
+
for source_key, stats in list(self._sources.items()):
|
|
218
|
+
if stats.event_count < self._ewma.min_samples:
|
|
219
|
+
continue
|
|
220
|
+
elapsed = now - stats.last_seen
|
|
221
|
+
if elapsed > self._silence_timeout_s:
|
|
222
|
+
last_seen_str = datetime.fromtimestamp(stats.last_seen).isoformat()
|
|
223
|
+
findings.append(
|
|
224
|
+
Finding(
|
|
225
|
+
agent=self.name,
|
|
226
|
+
type="source_silent",
|
|
227
|
+
severity=Severity.MEDIUM,
|
|
228
|
+
file="",
|
|
229
|
+
line=0,
|
|
230
|
+
message=f"Source {source_key} has been silent for {elapsed:.0f}s (last seen: {last_seen_str})",
|
|
231
|
+
suggestion="Check if the detector pipeline is healthy or if this source was intentionally removed",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return findings
|
|
235
|
+
|
|
236
|
+
# ── Novelty check ─────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async def _check_novelty(self, event: Event, source_key: str, tid: str) -> None:
|
|
239
|
+
"""Check if this technique_id is new for this source."""
|
|
240
|
+
stats = self._sources[source_key]
|
|
241
|
+
if tid not in stats.technique_ids and tid != TechniqueID.UNKNOWN.value:
|
|
242
|
+
await self._report_anomaly(
|
|
243
|
+
event,
|
|
244
|
+
source_key,
|
|
245
|
+
"novel_technique",
|
|
246
|
+
f"First occurrence of {tid} ({event.technique_display}) from source {source_key}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# ── Anomaly reporting ─────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async def _report_anomaly(
|
|
252
|
+
self,
|
|
253
|
+
event: Event,
|
|
254
|
+
source_key: str,
|
|
255
|
+
anomaly_type: str,
|
|
256
|
+
message: str,
|
|
257
|
+
) -> None:
|
|
258
|
+
# Rate-limit findings: skip if we already reported one in the last 30s
|
|
259
|
+
cooldown_key = f"{source_key}:{anomaly_type}"
|
|
260
|
+
now = time.monotonic()
|
|
261
|
+
last = self._last_finding_time.get(cooldown_key, 0.0)
|
|
262
|
+
if now - last < 30.0:
|
|
263
|
+
return
|
|
264
|
+
self._last_finding_time[cooldown_key] = now
|
|
265
|
+
|
|
266
|
+
logger.info("Triage anomaly [%s] %s", anomaly_type, message)
|
|
267
|
+
|
|
268
|
+
# ── Stats ──────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def get_stats(self) -> dict:
|
|
271
|
+
return {
|
|
272
|
+
"sources_tracked": len(self._sources),
|
|
273
|
+
"total_events": sum(s.event_count for s in self._sources.values()),
|
|
274
|
+
"sources": {
|
|
275
|
+
k: {
|
|
276
|
+
"event_count": s.event_count,
|
|
277
|
+
"techniques": list(s.technique_ids),
|
|
278
|
+
"mean_rate": s.rate_meter.mean,
|
|
279
|
+
"last_seen_ago": time.monotonic() - s.last_seen,
|
|
280
|
+
}
|
|
281
|
+
for k, s in self._sources.items()
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# ── Required BaseAgent method (satisfy ABC) ───────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _run(self, inp: AgentInput, result: AgentResult) -> None:
|
|
288
|
+
"""No-op: TriageAgent is detachment-driven, not input-driven."""
|
|
289
|
+
pass
|