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,980 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`p hosted` — Hosted mode: monitor live applications in production.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
p hosted init — Interactive setup (log path, worker config)
|
|
6
|
+
p hosted worker — Start the background log-watching worker
|
|
7
|
+
p hosted guard — Start live guard with anomaly detection + watchlist
|
|
8
|
+
p hosted status — Show watchlist top threats and audit log summary
|
|
9
|
+
p hosted logs — Stream the audit log in real time
|
|
10
|
+
p hosted token add — Generate a new admin token (shown once)
|
|
11
|
+
p hosted token list — List all active tokens
|
|
12
|
+
p hosted token revoke <id> — Revoke a token by ID
|
|
13
|
+
p hosted disconnect — Clear hosted mode config
|
|
14
|
+
|
|
15
|
+
The hosted worker watches a log file (nginx/apache/uvicorn/gunicorn/caddy/cloudflare)
|
|
16
|
+
and runs statistical + ML anomaly detection on every new line.
|
|
17
|
+
Escalation fires through the notification system when thresholds are crossed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
# ── Dispatch ───────────────────────────────────────────────────────────────────
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from rich.panel import Panel
|
|
30
|
+
from rich.prompt import Confirm, Prompt
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
from rich.text import Text
|
|
33
|
+
|
|
34
|
+
from patchi.cli.console import con
|
|
35
|
+
from patchi.core.config import require_project_root
|
|
36
|
+
|
|
37
|
+
_log = logging.getLogger("patchi.cli.hosted_cmd")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_EXPERIMENTAL_BANNER = (
|
|
41
|
+
"[black on #FACC15] EXPERIMENTAL [/black on #FACC15] Hosted mode is in preview — "
|
|
42
|
+
"feedback appreciated! [dim]Report issues at patchi.dev/feedback[/dim]"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _print_experimental_notice() -> None:
|
|
46
|
+
con.print()
|
|
47
|
+
con.print(_EXPERIMENTAL_BANNER)
|
|
48
|
+
con.print()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(args) -> None:
|
|
52
|
+
"""Main entry from CLI router — dispatches to sub-handlers."""
|
|
53
|
+
_print_experimental_notice()
|
|
54
|
+
hosted_cmd = getattr(args, "hosted_cmd", None)
|
|
55
|
+
token_cmd = getattr(args, "token_cmd", None)
|
|
56
|
+
|
|
57
|
+
if not hosted_cmd or hosted_cmd == "status":
|
|
58
|
+
run_status(json_output=getattr(args, "json", False))
|
|
59
|
+
elif hosted_cmd == "init":
|
|
60
|
+
run_init()
|
|
61
|
+
elif hosted_cmd == "worker":
|
|
62
|
+
run_worker()
|
|
63
|
+
elif hosted_cmd == "daemon":
|
|
64
|
+
run_daemon(guard=getattr(args, "guard", False))
|
|
65
|
+
elif hosted_cmd == "stop":
|
|
66
|
+
run_stop()
|
|
67
|
+
elif hosted_cmd == "guard":
|
|
68
|
+
run_guard()
|
|
69
|
+
elif hosted_cmd == "logs":
|
|
70
|
+
run_logs()
|
|
71
|
+
elif hosted_cmd == "disconnect":
|
|
72
|
+
run_disconnect()
|
|
73
|
+
elif hosted_cmd == "block":
|
|
74
|
+
run_block(getattr(args, "ip", ""))
|
|
75
|
+
elif hosted_cmd == "unblock":
|
|
76
|
+
run_unblock(getattr(args, "ip", ""))
|
|
77
|
+
elif hosted_cmd == "token":
|
|
78
|
+
if not token_cmd or token_cmd == "list":
|
|
79
|
+
run_token_list()
|
|
80
|
+
elif token_cmd == "add":
|
|
81
|
+
run_token_add()
|
|
82
|
+
elif token_cmd == "revoke":
|
|
83
|
+
run_token_revoke(getattr(args, "id", ""))
|
|
84
|
+
else:
|
|
85
|
+
con.print(f"[red]Unknown token subcommand: {token_cmd!r}[/red]")
|
|
86
|
+
else:
|
|
87
|
+
con.print(f"[red]Unknown hosted subcommand: {hosted_cmd!r}[/red]")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── init ───────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_init(root: Path | None = None) -> None:
|
|
94
|
+
"""Interactive hosted mode setup."""
|
|
95
|
+
try:
|
|
96
|
+
r = root or require_project_root()
|
|
97
|
+
except RuntimeError as e:
|
|
98
|
+
con.print(f"[red]{e}[/red]")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
con.print()
|
|
102
|
+
con.print("[bold #C8621A]Hosted Mode Setup[/bold #C8621A]")
|
|
103
|
+
con.print("[dim]Connects Patchi to a live application's log stream.[/dim]")
|
|
104
|
+
con.print()
|
|
105
|
+
|
|
106
|
+
from patchi.core import config as cfg
|
|
107
|
+
|
|
108
|
+
# Log path
|
|
109
|
+
log_path = Prompt.ask(
|
|
110
|
+
" Log file path",
|
|
111
|
+
default="/var/log/nginx/access.log",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Log format
|
|
115
|
+
log_format = Prompt.ask(
|
|
116
|
+
" Log format",
|
|
117
|
+
choices=["nginx", "apache", "caddy", "uvicorn", "gunicorn", "cloudflare", "json"],
|
|
118
|
+
default="nginx",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Notification on escalation
|
|
122
|
+
escalate = Confirm.ask(" Enable notifications on threat escalation?", default=True)
|
|
123
|
+
|
|
124
|
+
# Save to config
|
|
125
|
+
cfg.set_value("hosted.log_path", log_path, r)
|
|
126
|
+
cfg.set_value("hosted.log_format", log_format, r)
|
|
127
|
+
cfg.set_value("hosted.escalate", escalate, r)
|
|
128
|
+
cfg.set_value("hosted.enabled", True, r)
|
|
129
|
+
|
|
130
|
+
# Create hosted dir
|
|
131
|
+
hosted_dir = r / ".patchi" / "hosted"
|
|
132
|
+
hosted_dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
con.print()
|
|
135
|
+
con.print("[#4ADE80]✓[/#4ADE80] Hosted mode configured.")
|
|
136
|
+
con.print(f" [dim]Log: {log_path}[/dim]")
|
|
137
|
+
con.print(f" [dim]Format: {log_format}[/dim]")
|
|
138
|
+
con.print()
|
|
139
|
+
con.print(" Start monitoring: [bold]p hosted worker[/bold]")
|
|
140
|
+
con.print(" Live guard mode: [bold]p hosted guard[/bold]")
|
|
141
|
+
con.print()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── worker ─────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_worker(root: Path | None = None) -> None:
|
|
148
|
+
"""Start the background log-watching worker."""
|
|
149
|
+
try:
|
|
150
|
+
r = root or require_project_root()
|
|
151
|
+
except RuntimeError as e:
|
|
152
|
+
con.print(f"[red]{e}[/red]")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
from patchi.core import config as cfg
|
|
156
|
+
|
|
157
|
+
config = cfg.load(r)
|
|
158
|
+
log_path = config.get("hosted", {}).get("log_path", "")
|
|
159
|
+
|
|
160
|
+
if not log_path:
|
|
161
|
+
con.print("[red]Hosted mode not configured. Run: p hosted init[/red]")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
log_file = Path(log_path)
|
|
165
|
+
if not log_file.exists():
|
|
166
|
+
con.print(f"[#FACC15]⚠ Log file not found: {log_path}[/#FACC15]")
|
|
167
|
+
con.print("[dim]The worker will wait for it to appear.[/dim]")
|
|
168
|
+
|
|
169
|
+
con.print()
|
|
170
|
+
con.print(f"[bold #C8621A]Hosted Worker[/bold #C8621A] [dim]tailing {log_path}[/dim]")
|
|
171
|
+
con.print("[dim]Press Ctrl+C to stop.[/dim]")
|
|
172
|
+
con.print()
|
|
173
|
+
|
|
174
|
+
from patchi.core.hosted.audit_log import write as audit_write
|
|
175
|
+
from patchi.core.hosted.log_parsers import parse_line
|
|
176
|
+
|
|
177
|
+
lines_parsed = 0
|
|
178
|
+
lines_failed = 0
|
|
179
|
+
last_report = time.monotonic()
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with _tail_file(log_file) as lines:
|
|
183
|
+
for raw_line in lines:
|
|
184
|
+
entry = parse_line(raw_line)
|
|
185
|
+
if entry:
|
|
186
|
+
lines_parsed += 1
|
|
187
|
+
audit_write(
|
|
188
|
+
r,
|
|
189
|
+
"log_line",
|
|
190
|
+
data={
|
|
191
|
+
"method": entry.method,
|
|
192
|
+
"path": entry.path,
|
|
193
|
+
"status": entry.status,
|
|
194
|
+
"ip": entry.ip,
|
|
195
|
+
"source": entry.source,
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
lines_failed += 1
|
|
200
|
+
|
|
201
|
+
# Progress every 5s
|
|
202
|
+
now = time.monotonic()
|
|
203
|
+
if now - last_report >= 5:
|
|
204
|
+
con.print(
|
|
205
|
+
f" [dim]parsed {lines_parsed} lines | unrecognised {lines_failed}[/dim]"
|
|
206
|
+
)
|
|
207
|
+
last_report = now
|
|
208
|
+
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
con.print()
|
|
211
|
+
con.print(f"[dim]Worker stopped. {lines_parsed} lines processed.[/dim]")
|
|
212
|
+
con.print()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── guard ──────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_guard(root: Path | None = None) -> None:
|
|
219
|
+
"""Live guard: anomaly detection + watchlist scoring on the log stream."""
|
|
220
|
+
try:
|
|
221
|
+
r = root or require_project_root()
|
|
222
|
+
except RuntimeError as e:
|
|
223
|
+
con.print(f"[red]{e}[/red]")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
from patchi.core import config as cfg
|
|
227
|
+
|
|
228
|
+
config = cfg.load(r)
|
|
229
|
+
log_path = config.get("hosted", {}).get("log_path", "")
|
|
230
|
+
|
|
231
|
+
if not log_path:
|
|
232
|
+
con.print("[red]Hosted mode not configured. Run: p hosted init[/red]")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
from patchi.core.hosted import ip_reputation
|
|
236
|
+
from patchi.core.hosted.anomaly import MLDetector, StatisticalDetector
|
|
237
|
+
from patchi.core.hosted.audit_log import write as audit_write
|
|
238
|
+
from patchi.core.hosted.log_parsers import parse_line
|
|
239
|
+
from patchi.core.hosted.watchlist import WatchlistTracker
|
|
240
|
+
|
|
241
|
+
stat_detector = StatisticalDetector(config)
|
|
242
|
+
ml_detector = MLDetector()
|
|
243
|
+
escalate_fn = _make_escalation_fn(r, config)
|
|
244
|
+
tracker = WatchlistTracker(r, escalation_fn=escalate_fn)
|
|
245
|
+
|
|
246
|
+
con.print()
|
|
247
|
+
con.print(f"[bold #C8621A]Live Guard[/bold #C8621A] [dim]monitoring {log_path}[/dim]")
|
|
248
|
+
con.print("[dim]Statistical + ML anomaly detection active. Press Ctrl+C to stop.[/dim]")
|
|
249
|
+
con.print()
|
|
250
|
+
|
|
251
|
+
sev_colors = {"critical": "#FF4D6D", "high": "#FF8C42", "medium": "#FACC15"}
|
|
252
|
+
log_file = Path(log_path)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
with _tail_file(log_file) as lines:
|
|
256
|
+
for raw_line in lines:
|
|
257
|
+
entry = parse_line(raw_line)
|
|
258
|
+
if not entry:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Check IP reputation — flag blocked IPs
|
|
262
|
+
if entry.ip and ip_reputation.is_blocked(entry.ip, r):
|
|
263
|
+
con.print(
|
|
264
|
+
f" [#FF4D6D]BLOCKED [/#FF4D6D] [dim]{entry.ip}[/dim] — known threat IP"
|
|
265
|
+
)
|
|
266
|
+
audit_write(
|
|
267
|
+
r,
|
|
268
|
+
"blocked_ip_request",
|
|
269
|
+
data={
|
|
270
|
+
"ip": entry.ip,
|
|
271
|
+
"path": entry.path,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
findings = stat_detector.feed(entry) + ml_detector.feed(entry)
|
|
277
|
+
|
|
278
|
+
for finding in findings:
|
|
279
|
+
if finding.severity in sev_colors:
|
|
280
|
+
color = sev_colors[finding.severity]
|
|
281
|
+
ts = time.strftime("%H:%M:%S")
|
|
282
|
+
con.print(
|
|
283
|
+
f" [{color}]{finding.severity.upper():<8}[/{color}] "
|
|
284
|
+
f"[dim]{ts}[/dim] {finding.title}"
|
|
285
|
+
)
|
|
286
|
+
if finding.ip:
|
|
287
|
+
tracker.record(finding.ip, finding.detector, finding.title)
|
|
288
|
+
ip_reputation.record_hit(finding.ip, finding.detector, r)
|
|
289
|
+
audit_write(
|
|
290
|
+
r,
|
|
291
|
+
"anomaly_detected",
|
|
292
|
+
data={
|
|
293
|
+
"detector": finding.detector,
|
|
294
|
+
"severity": finding.severity,
|
|
295
|
+
"title": finding.title,
|
|
296
|
+
"ip": finding.ip,
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
except KeyboardInterrupt:
|
|
301
|
+
con.print()
|
|
302
|
+
con.print("[dim]Guard stopped.[/dim]")
|
|
303
|
+
con.print()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _on_escalation(ip: str, score: float, severity: str) -> None:
|
|
307
|
+
"""Called by WatchlistTracker when an IP crosses a threshold."""
|
|
308
|
+
color = "#FF4D6D" if severity == "critical" else "#FF8C42"
|
|
309
|
+
con.print(
|
|
310
|
+
f"\n [{color}]⚠ ESCALATION[/{color}] IP [bold]{ip}[/bold] "
|
|
311
|
+
f"score={score:.0f} severity={severity.upper()}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _make_escalation_fn(root: Path, config: dict):
|
|
316
|
+
"""Create an escalation callback that respects config and sends notifications."""
|
|
317
|
+
escalate_enabled = config.get("hosted", {}).get("escalate", True)
|
|
318
|
+
|
|
319
|
+
def _escalation(ip: str, score: float, severity: str) -> None:
|
|
320
|
+
color = "#FF4D6D" if severity == "critical" else "#FF8C42"
|
|
321
|
+
con.print(
|
|
322
|
+
f"\n [{color}]⚠ ESCALATION[/{color}] IP [bold]{ip}[/bold] "
|
|
323
|
+
f"score={score:.0f} severity={severity.upper()}"
|
|
324
|
+
)
|
|
325
|
+
if escalate_enabled:
|
|
326
|
+
try:
|
|
327
|
+
from patchi.core.notifications.notifier import Notifier
|
|
328
|
+
|
|
329
|
+
n = Notifier(root)
|
|
330
|
+
n.send(
|
|
331
|
+
severity,
|
|
332
|
+
f"Hosted mode escalation: IP {ip}",
|
|
333
|
+
f"IP {ip} escalated to {severity.upper()} (score={score:.0f})",
|
|
334
|
+
)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
_log.warning("_escalation failed: %s", e)
|
|
337
|
+
|
|
338
|
+
return _escalation
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── status ─────────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def run_status(root: Path | None = None, json_output: bool = False) -> None:
|
|
345
|
+
"""Show watchlist top threats and audit log summary."""
|
|
346
|
+
try:
|
|
347
|
+
r = root or require_project_root()
|
|
348
|
+
except RuntimeError as e:
|
|
349
|
+
con.print(f"[red]{e}[/red]")
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
from patchi.core import config as cfg
|
|
353
|
+
from patchi.core.hosted.audit_log import read_recent
|
|
354
|
+
from patchi.core.hosted.watchlist import WatchlistTracker
|
|
355
|
+
|
|
356
|
+
config = cfg.load(r)
|
|
357
|
+
hosted = config.get("hosted", {})
|
|
358
|
+
enabled = hosted.get("enabled", False)
|
|
359
|
+
log_path = hosted.get("log_path", "")
|
|
360
|
+
|
|
361
|
+
if json_output:
|
|
362
|
+
|
|
363
|
+
tracker = WatchlistTracker(r)
|
|
364
|
+
top_ips = tracker.top(10) if enabled else []
|
|
365
|
+
entries = read_recent(r, 5) if enabled else []
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
con.print()
|
|
369
|
+
con.print("[bold #C8621A]Hosted Mode Status[/bold #C8621A]")
|
|
370
|
+
con.print()
|
|
371
|
+
|
|
372
|
+
status_text = "[#4ADE80]enabled[/#4ADE80]" if enabled else "[#6B7280]not configured[/#6B7280]"
|
|
373
|
+
con.print(f" Mode: {status_text}")
|
|
374
|
+
if log_path:
|
|
375
|
+
con.print(f" Log: [dim]{log_path}[/dim]")
|
|
376
|
+
con.print()
|
|
377
|
+
|
|
378
|
+
if not enabled:
|
|
379
|
+
con.print("[dim] Run 'p hosted init' to configure hosted mode.[/dim]")
|
|
380
|
+
con.print()
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Watchlist
|
|
384
|
+
tracker = WatchlistTracker(r)
|
|
385
|
+
top_ips = tracker.top(10)
|
|
386
|
+
|
|
387
|
+
if top_ips:
|
|
388
|
+
con.print("[bold #F2EDD6]Top Threat IPs[/bold #F2EDD6]")
|
|
389
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
|
|
390
|
+
table.add_column("IP", style="bold", width=18)
|
|
391
|
+
table.add_column("Score", justify="right", width=8)
|
|
392
|
+
table.add_column("Hits", justify="right", width=6)
|
|
393
|
+
table.add_column("Last seen", width=20)
|
|
394
|
+
|
|
395
|
+
for ip_data in top_ips:
|
|
396
|
+
score = ip_data.get("current_score", 0)
|
|
397
|
+
hits = ip_data.get("hit_count", 0)
|
|
398
|
+
last = ip_data.get("last_seen", 0)
|
|
399
|
+
last_s = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else "—"
|
|
400
|
+
color = "#FF4D6D" if score >= 100 else "#FF8C42" if score >= 50 else "#FACC15"
|
|
401
|
+
table.add_row(
|
|
402
|
+
ip_data.get("ip", "?"),
|
|
403
|
+
Text(f"{score:.0f}", style=color),
|
|
404
|
+
str(hits),
|
|
405
|
+
last_s,
|
|
406
|
+
)
|
|
407
|
+
con.print(table)
|
|
408
|
+
con.print()
|
|
409
|
+
|
|
410
|
+
# Audit log
|
|
411
|
+
entries = read_recent(r, 5)
|
|
412
|
+
if entries:
|
|
413
|
+
con.print("[bold #F2EDD6]Recent Audit Events[/bold #F2EDD6]")
|
|
414
|
+
for e in entries:
|
|
415
|
+
ts = time.strftime("%H:%M:%S", time.localtime(e.get("timestamp", 0)))
|
|
416
|
+
event = e.get("event", "?")
|
|
417
|
+
data = e.get("data", {})
|
|
418
|
+
con.print(f" [dim]{ts}[/dim] [bold]{event}[/bold] [dim]{_fmt_data(data)}[/dim]")
|
|
419
|
+
con.print()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ── logs ───────────────────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def run_logs(root: Path | None = None, limit: int = 50) -> None:
|
|
426
|
+
"""Stream the Patchi audit log in real time."""
|
|
427
|
+
try:
|
|
428
|
+
r = root or require_project_root()
|
|
429
|
+
except RuntimeError as e:
|
|
430
|
+
con.print(f"[red]{e}[/red]")
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
from patchi.core.hosted.audit_log import _log_path, read_recent
|
|
434
|
+
|
|
435
|
+
log_file = _log_path(r)
|
|
436
|
+
|
|
437
|
+
con.print()
|
|
438
|
+
con.print(f"[bold #C8621A]Hosted Audit Log[/bold #C8621A] [dim]{log_file}[/dim]")
|
|
439
|
+
con.print("[dim]Press Ctrl+C to stop.[/dim]")
|
|
440
|
+
con.print()
|
|
441
|
+
|
|
442
|
+
# Show last N entries first
|
|
443
|
+
entries = read_recent(r, limit)
|
|
444
|
+
for e in reversed(entries):
|
|
445
|
+
_print_log_entry(e)
|
|
446
|
+
|
|
447
|
+
# Then tail for new ones
|
|
448
|
+
try:
|
|
449
|
+
if log_file.exists():
|
|
450
|
+
with open(log_file, encoding="utf-8") as f:
|
|
451
|
+
f.seek(0, 2) # seek to end
|
|
452
|
+
while True:
|
|
453
|
+
line = f.readline()
|
|
454
|
+
if line:
|
|
455
|
+
try:
|
|
456
|
+
import json as _json
|
|
457
|
+
|
|
458
|
+
entry = _json.loads(line)
|
|
459
|
+
_print_log_entry(entry)
|
|
460
|
+
except Exception as e:
|
|
461
|
+
_log.warning("run_logs failed: %s", e)
|
|
462
|
+
else:
|
|
463
|
+
time.sleep(0.2)
|
|
464
|
+
else:
|
|
465
|
+
con.print("[dim]Audit log does not exist yet. Start the worker to populate it.[/dim]")
|
|
466
|
+
except KeyboardInterrupt:
|
|
467
|
+
con.print()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _print_log_entry(entry: dict) -> None:
|
|
471
|
+
ts = time.strftime("%H:%M:%S", time.localtime(entry.get("timestamp", 0)))
|
|
472
|
+
event = entry.get("event", "?")
|
|
473
|
+
actor = entry.get("actor", "")
|
|
474
|
+
data = entry.get("data", {})
|
|
475
|
+
con.print(
|
|
476
|
+
f" [dim]{ts}[/dim] [bold #F2EDD6]{event:<20}[/bold #F2EDD6]"
|
|
477
|
+
f" [dim]{actor} {_fmt_data(data)}[/dim]"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ── disconnect ─────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def run_disconnect(root: Path | None = None) -> None:
|
|
485
|
+
"""Clear hosted mode config."""
|
|
486
|
+
try:
|
|
487
|
+
r = root or require_project_root()
|
|
488
|
+
except RuntimeError as e:
|
|
489
|
+
con.print(f"[red]{e}[/red]")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
confirmed = Confirm.ask(" Clear hosted mode configuration?", default=False)
|
|
493
|
+
if not confirmed:
|
|
494
|
+
con.print("[dim]Cancelled.[/dim]")
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
from patchi.core import config as cfg
|
|
498
|
+
|
|
499
|
+
config = cfg.load(r)
|
|
500
|
+
config.pop("hosted", None)
|
|
501
|
+
cfg.save(config, r)
|
|
502
|
+
|
|
503
|
+
con.print()
|
|
504
|
+
con.print("[#4ADE80]✓[/#4ADE80] Hosted mode configuration cleared.")
|
|
505
|
+
con.print()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ── token commands ─────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def run_token_add(root: Path | None = None) -> None:
|
|
512
|
+
"""Generate a new admin token and show it once."""
|
|
513
|
+
try:
|
|
514
|
+
r = root or require_project_root()
|
|
515
|
+
except RuntimeError as e:
|
|
516
|
+
con.print(f"[red]{e}[/red]")
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
from patchi.core.hosted.tokens import generate
|
|
520
|
+
|
|
521
|
+
name = Prompt.ask(" Token name (label for identification)", default="admin")
|
|
522
|
+
plaintext = generate(r, name)
|
|
523
|
+
|
|
524
|
+
con.print()
|
|
525
|
+
con.print("[#4ADE80]✓[/#4ADE80] Admin token generated.")
|
|
526
|
+
con.print()
|
|
527
|
+
con.print(
|
|
528
|
+
Panel(
|
|
529
|
+
f"[bold]{plaintext}[/bold]\n",
|
|
530
|
+
title=f"Token: {name}",
|
|
531
|
+
border_style="#C8621A",
|
|
532
|
+
padding=(1, 2),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
con.print()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def run_token_list(root: Path | None = None) -> None:
|
|
539
|
+
"""List all active admin tokens (names only — plaintext is never stored)."""
|
|
540
|
+
try:
|
|
541
|
+
r = root or require_project_root()
|
|
542
|
+
except RuntimeError as e:
|
|
543
|
+
con.print(f"[red]{e}[/red]")
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
from patchi.core.hosted.tokens import list_tokens
|
|
547
|
+
|
|
548
|
+
tokens = list_tokens(r)
|
|
549
|
+
con.print()
|
|
550
|
+
con.print("[bold #C8621A]Admin Tokens[/bold #C8621A]")
|
|
551
|
+
con.print()
|
|
552
|
+
|
|
553
|
+
if not tokens:
|
|
554
|
+
con.print("[dim] No tokens configured. Run: p hosted token add[/dim]")
|
|
555
|
+
con.print()
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
|
|
559
|
+
table.add_column("ID", width=10)
|
|
560
|
+
table.add_column("Name", style="bold #F2EDD6", width=20)
|
|
561
|
+
table.add_column("Created", width=22)
|
|
562
|
+
table.add_column("Last used", width=22)
|
|
563
|
+
|
|
564
|
+
for tok in tokens:
|
|
565
|
+
created = _fmt_ts(tok.get("created_at"))
|
|
566
|
+
last_used = _fmt_ts(tok.get("last_used")) if tok.get("last_used") else "Never"
|
|
567
|
+
table.add_row(tok.get("id", "?"), tok.get("name", "?"), created, last_used)
|
|
568
|
+
|
|
569
|
+
con.print(table)
|
|
570
|
+
con.print()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def run_token_revoke(token_id: str, root: Path | None = None) -> None:
|
|
574
|
+
"""Revoke a token by ID."""
|
|
575
|
+
try:
|
|
576
|
+
r = root or require_project_root()
|
|
577
|
+
except RuntimeError as e:
|
|
578
|
+
con.print(f"[red]{e}[/red]")
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
from patchi.core.hosted.audit_log import write as audit_write
|
|
582
|
+
from patchi.core.hosted.tokens import revoke
|
|
583
|
+
|
|
584
|
+
ok = revoke(r, token_id)
|
|
585
|
+
con.print()
|
|
586
|
+
if ok:
|
|
587
|
+
audit_write(r, "token_revoked", data={"id": token_id})
|
|
588
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] Token [bold]{token_id}[/bold] revoked.")
|
|
589
|
+
else:
|
|
590
|
+
con.print(f"[yellow]Token {token_id!r} not found.[/yellow]")
|
|
591
|
+
con.print()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ── daemon ─────────────────────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def run_daemon(root: Path | None = None, guard: bool = False) -> None:
|
|
598
|
+
"""
|
|
599
|
+
Start the hosted worker as a daemon with auto-restart, PID management,
|
|
600
|
+
and health checks. Runs in the foreground with auto-restart on crash.
|
|
601
|
+
If guard=True, also runs anomaly detection in the worker loop.
|
|
602
|
+
"""
|
|
603
|
+
import signal
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
r = root or require_project_root()
|
|
607
|
+
except RuntimeError as e:
|
|
608
|
+
con.print(f"[red]{e}[/red]")
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
from patchi.core import config as cfg
|
|
612
|
+
|
|
613
|
+
config = cfg.load(r)
|
|
614
|
+
log_path = config.get("hosted", {}).get("log_path", "")
|
|
615
|
+
|
|
616
|
+
if not log_path:
|
|
617
|
+
con.print("[red]Hosted mode not configured. Run: p hosted init[/red]")
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
# PID file management
|
|
621
|
+
hosted_dir = r / ".patchi" / "hosted"
|
|
622
|
+
hosted_dir.mkdir(parents=True, exist_ok=True)
|
|
623
|
+
pid_file = hosted_dir / "worker.pid"
|
|
624
|
+
|
|
625
|
+
# Check if already running
|
|
626
|
+
if pid_file.exists():
|
|
627
|
+
try:
|
|
628
|
+
old_pid = int(pid_file.read_text().strip())
|
|
629
|
+
# Check if process is still alive
|
|
630
|
+
if platform.system() == "Windows":
|
|
631
|
+
import subprocess
|
|
632
|
+
|
|
633
|
+
result = subprocess.run(
|
|
634
|
+
["tasklist", "/FI", f"PID eq {old_pid}"], capture_output=True, text=True
|
|
635
|
+
)
|
|
636
|
+
if str(old_pid) in result.stdout:
|
|
637
|
+
con.print(f"[yellow]Worker already running (PID {old_pid}).[/yellow]")
|
|
638
|
+
con.print(
|
|
639
|
+
"[dim]Run 'p hosted stop' first, or delete .patchi/hosted/worker.pid[/dim]"
|
|
640
|
+
)
|
|
641
|
+
return
|
|
642
|
+
else:
|
|
643
|
+
os.kill(old_pid, 0) # Check if process exists
|
|
644
|
+
con.print(f"[yellow]Worker already running (PID {old_pid}).[/yellow]")
|
|
645
|
+
return
|
|
646
|
+
except (ValueError, ProcessLookupError, OSError):
|
|
647
|
+
pass # Stale PID file, proceed
|
|
648
|
+
|
|
649
|
+
# Write our PID
|
|
650
|
+
pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
|
651
|
+
|
|
652
|
+
con.print()
|
|
653
|
+
con.print(f"[bold #C8621A]Hosted Daemon[/bold #C8621A] [dim]tailing {log_path}[/dim]")
|
|
654
|
+
if guard:
|
|
655
|
+
con.print("[dim]Guard mode: anomaly detection active. Press Ctrl+C to stop.[/dim]")
|
|
656
|
+
else:
|
|
657
|
+
con.print("[dim]Auto-restart enabled. Press Ctrl+C to stop.[/dim]")
|
|
658
|
+
con.print()
|
|
659
|
+
|
|
660
|
+
# Graceful shutdown handler
|
|
661
|
+
_shutdown = False
|
|
662
|
+
|
|
663
|
+
def _handle_signal(signum, frame):
|
|
664
|
+
nonlocal _shutdown
|
|
665
|
+
_shutdown = True
|
|
666
|
+
con.print("\n[yellow]Shutdown signal received...[/yellow]")
|
|
667
|
+
|
|
668
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
669
|
+
if hasattr(signal, "SIGTERM"):
|
|
670
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
671
|
+
|
|
672
|
+
# Track health
|
|
673
|
+
last_health_check = time.monotonic()
|
|
674
|
+
last_restart = time.monotonic()
|
|
675
|
+
restart_count = 0
|
|
676
|
+
max_restarts_per_hour = 10
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
while not _shutdown:
|
|
680
|
+
# Health check every 60s
|
|
681
|
+
now = time.monotonic()
|
|
682
|
+
if now - last_health_check >= 60:
|
|
683
|
+
_daemon_health_check(r, restart_count)
|
|
684
|
+
last_health_check = now
|
|
685
|
+
|
|
686
|
+
# Rate-limit restarts
|
|
687
|
+
if now - last_restart < 3600 and restart_count >= max_restarts_per_hour:
|
|
688
|
+
con.print(
|
|
689
|
+
f"[red]Too many restarts ({restart_count}/hour). Waiting 5 minutes...[/red]"
|
|
690
|
+
)
|
|
691
|
+
time.sleep(300)
|
|
692
|
+
restart_count = 0
|
|
693
|
+
last_restart = time.monotonic()
|
|
694
|
+
continue
|
|
695
|
+
|
|
696
|
+
# Run worker loop
|
|
697
|
+
try:
|
|
698
|
+
_run_worker_loop(r, log_path, guard=guard)
|
|
699
|
+
# If worker exits normally (EOF), wait and restart
|
|
700
|
+
if not _shutdown:
|
|
701
|
+
con.print("[dim]Worker exited (EOF). Restarting in 2s...[/dim]")
|
|
702
|
+
time.sleep(2)
|
|
703
|
+
restart_count += 1
|
|
704
|
+
last_restart = time.monotonic()
|
|
705
|
+
except Exception as e:
|
|
706
|
+
if not _shutdown:
|
|
707
|
+
con.print(f"[red]Worker crashed: {e}[/red]")
|
|
708
|
+
con.print("[dim]Restarting in 5s...[/dim]")
|
|
709
|
+
time.sleep(5)
|
|
710
|
+
restart_count += 1
|
|
711
|
+
last_restart = time.monotonic()
|
|
712
|
+
|
|
713
|
+
finally:
|
|
714
|
+
# Cleanup PID file
|
|
715
|
+
try:
|
|
716
|
+
pid_file.unlink(missing_ok=True)
|
|
717
|
+
except Exception as e:
|
|
718
|
+
_log.warning("run_daemon failed: %s", e)
|
|
719
|
+
con.print(f"[dim]Daemon stopped. {restart_count} restart(s) during this session.[/dim]")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _run_worker_loop(root: Path, log_path: str, guard: bool = False) -> None:
|
|
723
|
+
"""Run the worker's main loop. Returns when log file EOF or shutdown."""
|
|
724
|
+
from patchi.core import config as cfg
|
|
725
|
+
from patchi.core.hosted.audit_log import write as audit_write
|
|
726
|
+
from patchi.core.hosted.log_parsers import parse_line
|
|
727
|
+
|
|
728
|
+
stat_detector = None
|
|
729
|
+
ml_detector = None
|
|
730
|
+
tracker = None
|
|
731
|
+
|
|
732
|
+
if guard:
|
|
733
|
+
from patchi.core.hosted import ip_reputation
|
|
734
|
+
from patchi.core.hosted.anomaly import MLDetector, StatisticalDetector
|
|
735
|
+
from patchi.core.hosted.watchlist import WatchlistTracker
|
|
736
|
+
|
|
737
|
+
config = cfg.load(root)
|
|
738
|
+
stat_detector = StatisticalDetector(config)
|
|
739
|
+
ml_detector = MLDetector()
|
|
740
|
+
escalate_fn = _make_escalation_fn(root, config)
|
|
741
|
+
tracker = WatchlistTracker(root, escalation_fn=escalate_fn)
|
|
742
|
+
|
|
743
|
+
lines_parsed = 0
|
|
744
|
+
lines_failed = 0
|
|
745
|
+
last_report = time.monotonic()
|
|
746
|
+
last_activity = time.monotonic()
|
|
747
|
+
|
|
748
|
+
log_file = Path(log_path)
|
|
749
|
+
with _tail_file(log_file) as lines:
|
|
750
|
+
for raw_line in lines:
|
|
751
|
+
entry = parse_line(raw_line)
|
|
752
|
+
if entry:
|
|
753
|
+
lines_parsed += 1
|
|
754
|
+
last_activity = time.monotonic()
|
|
755
|
+
audit_write(
|
|
756
|
+
root,
|
|
757
|
+
"log_line",
|
|
758
|
+
data={
|
|
759
|
+
"method": entry.method,
|
|
760
|
+
"path": entry.path,
|
|
761
|
+
"status": entry.status,
|
|
762
|
+
"ip": entry.ip,
|
|
763
|
+
"source": entry.source,
|
|
764
|
+
},
|
|
765
|
+
)
|
|
766
|
+
# Guard mode: run anomaly detection
|
|
767
|
+
if guard and stat_detector:
|
|
768
|
+
if entry.ip and ip_reputation.is_blocked(entry.ip, root):
|
|
769
|
+
audit_write(
|
|
770
|
+
root,
|
|
771
|
+
"blocked_ip_request",
|
|
772
|
+
data={
|
|
773
|
+
"ip": entry.ip,
|
|
774
|
+
"path": entry.path,
|
|
775
|
+
},
|
|
776
|
+
)
|
|
777
|
+
else:
|
|
778
|
+
findings = stat_detector.feed(entry) + ml_detector.feed(entry)
|
|
779
|
+
for finding in findings:
|
|
780
|
+
if finding.ip:
|
|
781
|
+
tracker.record(finding.ip, finding.detector, finding.title)
|
|
782
|
+
ip_reputation.record_hit(finding.ip, finding.detector, root)
|
|
783
|
+
audit_write(
|
|
784
|
+
root,
|
|
785
|
+
"anomaly_detected",
|
|
786
|
+
data={
|
|
787
|
+
"detector": finding.detector,
|
|
788
|
+
"severity": finding.severity,
|
|
789
|
+
"title": finding.title,
|
|
790
|
+
"ip": finding.ip,
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
else:
|
|
794
|
+
lines_failed += 1
|
|
795
|
+
|
|
796
|
+
# Progress every 30s (less spam for daemon mode)
|
|
797
|
+
now = time.monotonic()
|
|
798
|
+
if now - last_report >= 30:
|
|
799
|
+
con.print(
|
|
800
|
+
f" [dim]parsed {lines_parsed} lines | "
|
|
801
|
+
f"unrecognised {lines_failed} | "
|
|
802
|
+
f"last activity {int(now - last_activity)}s ago[/dim]"
|
|
803
|
+
)
|
|
804
|
+
last_report = now
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _daemon_health_check(root: Path, restart_count: int) -> None:
|
|
808
|
+
"""Periodic health check for the daemon."""
|
|
809
|
+
try:
|
|
810
|
+
import psutil
|
|
811
|
+
|
|
812
|
+
pid = os.getpid()
|
|
813
|
+
proc = psutil.Process(pid)
|
|
814
|
+
mem_mb = proc.memory_info().rss / (1024 * 1024)
|
|
815
|
+
cpu_pct = proc.cpu_percent(interval=0.1)
|
|
816
|
+
con.print(
|
|
817
|
+
f" [dim]health: mem {mem_mb:.0f}MB cpu {cpu_pct:.1f}% restarts {restart_count}[/dim]"
|
|
818
|
+
)
|
|
819
|
+
except ImportError:
|
|
820
|
+
# psutil not available, basic check
|
|
821
|
+
con.print(f" [dim]health: pid {os.getpid()} restarts {restart_count}[/dim]")
|
|
822
|
+
except Exception as e:
|
|
823
|
+
_log.warning("_daemon_health_check failed: %s", e)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ── stop ───────────────────────────────────────────────────────────────────────
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def run_stop(root: Path | None = None) -> None:
|
|
830
|
+
"""Stop a running daemon by PID file."""
|
|
831
|
+
try:
|
|
832
|
+
r = root or require_project_root()
|
|
833
|
+
except RuntimeError as e:
|
|
834
|
+
con.print(f"[red]{e}[/red]")
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
pid_file = r / ".patchi" / "hosted" / "worker.pid"
|
|
838
|
+
if not pid_file.exists():
|
|
839
|
+
con.print("[dim]No running daemon found.[/dim]")
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
pid = int(pid_file.read_text().strip())
|
|
844
|
+
except (ValueError, OSError):
|
|
845
|
+
con.print("[dim]Invalid PID file. Cleaning up.[/dim]")
|
|
846
|
+
pid_file.unlink(missing_ok=True)
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
try:
|
|
850
|
+
if platform.system() == "Windows":
|
|
851
|
+
import subprocess
|
|
852
|
+
|
|
853
|
+
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
|
|
854
|
+
else:
|
|
855
|
+
import signal
|
|
856
|
+
|
|
857
|
+
os.kill(pid, signal.SIGTERM)
|
|
858
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] Sent stop signal to PID {pid}")
|
|
859
|
+
except ProcessLookupError:
|
|
860
|
+
con.print(f"[dim]Process {pid} not found. Cleaning up PID file.[/dim]")
|
|
861
|
+
except Exception as e:
|
|
862
|
+
con.print(f"[red]Failed to stop: {e}[/red]")
|
|
863
|
+
finally:
|
|
864
|
+
pid_file.unlink(missing_ok=True)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# ── block / unblock ───────────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def run_block(ip: str, root: Path | None = None) -> None:
|
|
871
|
+
"""Manually block an IP address."""
|
|
872
|
+
if not ip:
|
|
873
|
+
con.print("[red]Usage: p hosted block <ip>[/red]")
|
|
874
|
+
return
|
|
875
|
+
try:
|
|
876
|
+
r = root or require_project_root()
|
|
877
|
+
except RuntimeError as e:
|
|
878
|
+
con.print(f"[red]{e}[/red]")
|
|
879
|
+
return
|
|
880
|
+
from patchi.core.hosted import ip_reputation
|
|
881
|
+
from patchi.core.hosted.audit_log import write as audit_write
|
|
882
|
+
|
|
883
|
+
ip_reputation.block(ip, r)
|
|
884
|
+
audit_write(r, "ip_blocked", data={"ip": ip, "reason": "manual"})
|
|
885
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] IP [bold]{ip}[/bold] blocked.")
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def run_unblock(ip: str, root: Path | None = None) -> None:
|
|
889
|
+
"""Unblock an IP address."""
|
|
890
|
+
if not ip:
|
|
891
|
+
con.print("[red]Usage: p hosted unblock <ip>[/red]")
|
|
892
|
+
return
|
|
893
|
+
try:
|
|
894
|
+
r = root or require_project_root()
|
|
895
|
+
except RuntimeError as e:
|
|
896
|
+
con.print(f"[red]{e}[/red]")
|
|
897
|
+
return
|
|
898
|
+
from patchi.core.hosted import ip_reputation
|
|
899
|
+
from patchi.core.hosted.audit_log import write as audit_write
|
|
900
|
+
|
|
901
|
+
ok = ip_reputation.unblock(ip, r)
|
|
902
|
+
if ok:
|
|
903
|
+
audit_write(r, "ip_unblocked", data={"ip": ip})
|
|
904
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] IP [bold]{ip}[/bold] unblocked.")
|
|
905
|
+
else:
|
|
906
|
+
con.print(f"[dim]IP {ip} was not blocked.[/dim]")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
# ── Utilities ──────────────────────────────────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _fmt_ts(ts: float | None) -> str:
|
|
913
|
+
if not ts:
|
|
914
|
+
return "—"
|
|
915
|
+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _fmt_data(data: dict) -> str:
|
|
919
|
+
parts = []
|
|
920
|
+
for k, v in list(data.items())[:3]:
|
|
921
|
+
parts.append(f"{k}={v!r}")
|
|
922
|
+
return " ".join(parts)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _tail_file(path: Path):
|
|
926
|
+
"""
|
|
927
|
+
Context manager that yields lines from a file, blocking when caught up.
|
|
928
|
+
If file does not exist, waits for it to appear (up to 30s).
|
|
929
|
+
Handles log file rotation by detecting inode/size changes.
|
|
930
|
+
"""
|
|
931
|
+
|
|
932
|
+
class _Tailer:
|
|
933
|
+
def __enter__(self_inner):
|
|
934
|
+
waited = 0
|
|
935
|
+
while not path.exists() and waited < 30:
|
|
936
|
+
time.sleep(1)
|
|
937
|
+
waited += 1
|
|
938
|
+
if not path.exists():
|
|
939
|
+
raise FileNotFoundError(f"Log file still not found after 30s: {path}")
|
|
940
|
+
self_inner._f = open(path, encoding="utf-8", errors="replace")
|
|
941
|
+
self_inner._f.seek(0, 2) # jump to end
|
|
942
|
+
try:
|
|
943
|
+
self_inner._inode = path.stat().st_ino
|
|
944
|
+
self_inner._size = path.stat().st_size
|
|
945
|
+
except OSError:
|
|
946
|
+
self_inner._inode = None
|
|
947
|
+
self_inner._size = 0
|
|
948
|
+
return self_inner
|
|
949
|
+
|
|
950
|
+
def __exit__(self_inner, *args):
|
|
951
|
+
self_inner._f.close()
|
|
952
|
+
|
|
953
|
+
def _check_rotation(self_inner):
|
|
954
|
+
try:
|
|
955
|
+
stat = path.stat()
|
|
956
|
+
if self_inner._inode is not None and stat.st_ino != self_inner._inode:
|
|
957
|
+
self_inner._f.close()
|
|
958
|
+
self_inner._f = open(path, encoding="utf-8", errors="replace")
|
|
959
|
+
self_inner._inode = stat.st_ino
|
|
960
|
+
self_inner._size = stat.st_size
|
|
961
|
+
elif stat.st_size < self_inner._size:
|
|
962
|
+
self_inner._f.close()
|
|
963
|
+
self_inner._f = open(path, encoding="utf-8", errors="replace")
|
|
964
|
+
self_inner._inode = stat.st_ino
|
|
965
|
+
self_inner._size = stat.st_size
|
|
966
|
+
else:
|
|
967
|
+
self_inner._size = stat.st_size
|
|
968
|
+
except OSError:
|
|
969
|
+
pass
|
|
970
|
+
|
|
971
|
+
def __iter__(self_inner):
|
|
972
|
+
while True:
|
|
973
|
+
line = self_inner._f.readline()
|
|
974
|
+
if line:
|
|
975
|
+
yield line
|
|
976
|
+
else:
|
|
977
|
+
self_inner._check_rotation()
|
|
978
|
+
time.sleep(0.1)
|
|
979
|
+
|
|
980
|
+
return _Tailer()
|