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,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IP Reputation database for hosted mode.
|
|
3
|
+
|
|
4
|
+
Maintains a local database of known-bad IPs from public blocklists.
|
|
5
|
+
Supports:
|
|
6
|
+
- Loading public blocklists (abuse.ch, firehol, etc.)
|
|
7
|
+
- Fast O(1) lookup via set
|
|
8
|
+
- Periodic refresh (daily)
|
|
9
|
+
- Auto-blocking for high-threat IPs
|
|
10
|
+
|
|
11
|
+
All data stored locally in .patchi/hosted/ip_reputation.json.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_REPUTATION_FILE = ".patchi/hosted/ip_reputation.json"
|
|
21
|
+
_BLOCKLIST_URLS = [
|
|
22
|
+
"https://raw.githubusercontent.com/stamparm/ipsum/master/ipsum.txt",
|
|
23
|
+
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# IPs from these lists are automatically blocked
|
|
27
|
+
_AUTO_BLOCK_THRESHOLD = 3 # IPs seen on 3+ lists
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_log = logging.getLogger("patchi.core.ip_reputation")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(root: Path) -> dict:
|
|
34
|
+
path = root / _REPUTATION_FILE
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return {"bad_ips": {}, "last_refresh": 0, "blocked": []}
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
except Exception as e:
|
|
40
|
+
_log.warning("_load failed: %s", e)
|
|
41
|
+
return {"bad_ips": {}, "last_refresh": 0, "blocked": []}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save(root: Path, data: dict) -> None:
|
|
45
|
+
path = root / _REPUTATION_FILE
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
tmp = path.with_suffix(".tmp")
|
|
48
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
49
|
+
tmp.replace(path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def lookup(ip: str, root: Path) -> dict:
|
|
53
|
+
"""
|
|
54
|
+
Look up an IP's reputation.
|
|
55
|
+
Returns {"known_bad": bool, "threat_level": str, "lists_hit": int, "blocked": bool}
|
|
56
|
+
"""
|
|
57
|
+
data = _load(root)
|
|
58
|
+
bad_ips = data.get("bad_ips", {})
|
|
59
|
+
blocked = set(data.get("blocked", []))
|
|
60
|
+
|
|
61
|
+
if ip in bad_ips:
|
|
62
|
+
info = bad_ips[ip]
|
|
63
|
+
return {
|
|
64
|
+
"known_bad": True,
|
|
65
|
+
"threat_level": _threat_level(info.get("lists_hit", 0)),
|
|
66
|
+
"lists_hit": info.get("lists_hit", 0),
|
|
67
|
+
"sources": info.get("sources", []),
|
|
68
|
+
"blocked": ip in blocked,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"known_bad": False,
|
|
73
|
+
"threat_level": "clean",
|
|
74
|
+
"lists_hit": 0,
|
|
75
|
+
"sources": [],
|
|
76
|
+
"blocked": ip in blocked,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def record_hit(ip: str, source: str, root: Path) -> None:
|
|
81
|
+
"""Record that an IP was flagged by a detector."""
|
|
82
|
+
data = _load(root)
|
|
83
|
+
bad_ips = data.setdefault("bad_ips", {})
|
|
84
|
+
|
|
85
|
+
if ip not in bad_ips:
|
|
86
|
+
bad_ips[ip] = {"lists_hit": 0, "sources": [], "first_seen": time.time()}
|
|
87
|
+
|
|
88
|
+
entry = bad_ips[ip]
|
|
89
|
+
if source not in entry["sources"]:
|
|
90
|
+
entry["sources"].append(source)
|
|
91
|
+
entry["lists_hit"] = len(entry["sources"])
|
|
92
|
+
entry["last_seen"] = time.time()
|
|
93
|
+
|
|
94
|
+
# Auto-block if threshold crossed
|
|
95
|
+
if entry["lists_hit"] >= _AUTO_BLOCK_THRESHOLD:
|
|
96
|
+
blocked = data.setdefault("blocked", [])
|
|
97
|
+
if ip not in blocked:
|
|
98
|
+
blocked.append(ip)
|
|
99
|
+
|
|
100
|
+
_save(root, data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_blocked(ip: str, root: Path) -> bool:
|
|
104
|
+
"""Check if an IP is auto-blocked."""
|
|
105
|
+
data = _load(root)
|
|
106
|
+
return ip in data.get("blocked", [])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def block(ip: str, root: Path) -> None:
|
|
110
|
+
"""Manually block an IP."""
|
|
111
|
+
data = _load(root)
|
|
112
|
+
blocked = data.setdefault("blocked", [])
|
|
113
|
+
if ip not in blocked:
|
|
114
|
+
blocked.append(ip)
|
|
115
|
+
_save(root, data)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def unblock(ip: str, root: Path) -> bool:
|
|
119
|
+
"""Unblock an IP. Returns True if it was blocked."""
|
|
120
|
+
data = _load(root)
|
|
121
|
+
blocked = data.get("blocked", [])
|
|
122
|
+
if ip in blocked:
|
|
123
|
+
blocked.remove(ip)
|
|
124
|
+
data["blocked"] = blocked
|
|
125
|
+
_save(root, data)
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def needs_refresh(root: Path, max_age_hours: int = 24) -> bool:
|
|
131
|
+
"""Check if the reputation database needs refreshing."""
|
|
132
|
+
data = _load(root)
|
|
133
|
+
last_refresh = data.get("last_refresh", 0)
|
|
134
|
+
age_hours = (time.time() - last_refresh) / 3600
|
|
135
|
+
return age_hours > max_age_hours
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_top_threats(root: Path, limit: int = 20) -> list[dict]:
|
|
139
|
+
"""Get the top threatening IPs sorted by lists_hit."""
|
|
140
|
+
data = _load(root)
|
|
141
|
+
bad_ips = data.get("bad_ips", {})
|
|
142
|
+
|
|
143
|
+
sorted_ips = sorted(
|
|
144
|
+
bad_ips.items(),
|
|
145
|
+
key=lambda x: x[1].get("lists_hit", 0),
|
|
146
|
+
reverse=True,
|
|
147
|
+
)[:limit]
|
|
148
|
+
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
"ip": ip,
|
|
152
|
+
"lists_hit": info.get("lists_hit", 0),
|
|
153
|
+
"sources": info.get("sources", []),
|
|
154
|
+
"threat_level": _threat_level(info.get("lists_hit", 0)),
|
|
155
|
+
"first_seen": info.get("first_seen", 0),
|
|
156
|
+
"last_seen": info.get("last_seen", 0),
|
|
157
|
+
}
|
|
158
|
+
for ip, info in sorted_ips
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _threat_level(lists_hit: int) -> str:
|
|
163
|
+
if lists_hit >= 5:
|
|
164
|
+
return "critical"
|
|
165
|
+
if lists_hit >= 3:
|
|
166
|
+
return "high"
|
|
167
|
+
if lists_hit >= 1:
|
|
168
|
+
return "medium"
|
|
169
|
+
return "clean"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Log parsers for Patchi hosted mode.
|
|
3
|
+
|
|
4
|
+
Parses 7 log formats into a unified LogEntry structure.
|
|
5
|
+
Uses loguru for internal structured logging of parse errors.
|
|
6
|
+
|
|
7
|
+
Supported formats:
|
|
8
|
+
nginx, apache, caddy, uvicorn, gunicorn, cloudflare (JSON), generic JSON
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from loguru import logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LogEntry:
|
|
23
|
+
"""Unified log entry across all formats."""
|
|
24
|
+
|
|
25
|
+
timestamp: float
|
|
26
|
+
level: str # INFO / WARNING / ERROR / CRITICAL
|
|
27
|
+
method: str # HTTP method or ""
|
|
28
|
+
path: str # URL path or ""
|
|
29
|
+
status: int # HTTP status or 0
|
|
30
|
+
ip: str # client IP or ""
|
|
31
|
+
message: str # raw log line or parsed message
|
|
32
|
+
source: str # parser name
|
|
33
|
+
extra: dict = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Shared patterns ────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
_NGINX_RE = re.compile(
|
|
39
|
+
r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "
|
|
40
|
+
r'"(?P<method>\w+) (?P<path>\S+) [^"]*" (?P<status>\d+) (?P<bytes>\d+)'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_APACHE_RE = re.compile(
|
|
44
|
+
r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "
|
|
45
|
+
r'"(?P<method>\w+) (?P<path>\S+) [^"]*" (?P<status>\d+)'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_CADDY_RE = re.compile(
|
|
49
|
+
r"(?P<ts>\d{4}-\d{2}-\d{2}T\S+)\s+(?P<level>\w+)\s+"
|
|
50
|
+
r'http\.log\.access.*?"method":"(?P<method>\w+)".*?"uri":"(?P<path>[^"]+)"'
|
|
51
|
+
r'.*?"status":(?P<status>\d+).*?"remote_ip":"(?P<ip>[^"]+)"'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_UVICORN_RE = re.compile(
|
|
55
|
+
r'(?P<ip>\S+):(?:\d+) - "(?P<method>\w+) (?P<path>\S+) [^"]*" (?P<status>\d+)'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_GUNICORN_RE = re.compile(
|
|
59
|
+
r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "
|
|
60
|
+
r'"(?P<method>\w+) (?P<path>\S+) [^"]*" (?P<status>\d+)'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _status_to_level(status: int) -> str:
|
|
65
|
+
if status >= 500:
|
|
66
|
+
return "ERROR"
|
|
67
|
+
if status >= 400:
|
|
68
|
+
return "WARNING"
|
|
69
|
+
return "INFO"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _now() -> float:
|
|
73
|
+
return time.time()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Individual parsers ─────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_nginx(line: str) -> LogEntry | None:
|
|
80
|
+
m = _NGINX_RE.match(line)
|
|
81
|
+
if not m:
|
|
82
|
+
return None
|
|
83
|
+
status = int(m.group("status"))
|
|
84
|
+
return LogEntry(
|
|
85
|
+
timestamp=_now(),
|
|
86
|
+
level=_status_to_level(status),
|
|
87
|
+
method=m.group("method"),
|
|
88
|
+
path=m.group("path"),
|
|
89
|
+
status=status,
|
|
90
|
+
ip=m.group("ip"),
|
|
91
|
+
message=line,
|
|
92
|
+
source="nginx",
|
|
93
|
+
extra={"bytes": m.group("bytes"), "raw_time": m.group("time")},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_apache(line: str) -> LogEntry | None:
|
|
98
|
+
m = _APACHE_RE.match(line)
|
|
99
|
+
if not m:
|
|
100
|
+
return None
|
|
101
|
+
status = int(m.group("status"))
|
|
102
|
+
return LogEntry(
|
|
103
|
+
timestamp=_now(),
|
|
104
|
+
level=_status_to_level(status),
|
|
105
|
+
method=m.group("method"),
|
|
106
|
+
path=m.group("path"),
|
|
107
|
+
status=status,
|
|
108
|
+
ip=m.group("ip"),
|
|
109
|
+
message=line,
|
|
110
|
+
source="apache",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_caddy(line: str) -> LogEntry | None:
|
|
115
|
+
m = _CADDY_RE.search(line)
|
|
116
|
+
if not m:
|
|
117
|
+
return None
|
|
118
|
+
status = int(m.group("status"))
|
|
119
|
+
return LogEntry(
|
|
120
|
+
timestamp=_now(),
|
|
121
|
+
level=_status_to_level(status),
|
|
122
|
+
method=m.group("method"),
|
|
123
|
+
path=m.group("path"),
|
|
124
|
+
status=status,
|
|
125
|
+
ip=m.group("ip"),
|
|
126
|
+
message=line,
|
|
127
|
+
source="caddy",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_uvicorn(line: str) -> LogEntry | None:
|
|
132
|
+
m = _UVICORN_RE.search(line)
|
|
133
|
+
if not m:
|
|
134
|
+
return None
|
|
135
|
+
status = int(m.group("status"))
|
|
136
|
+
return LogEntry(
|
|
137
|
+
timestamp=_now(),
|
|
138
|
+
level=_status_to_level(status),
|
|
139
|
+
method=m.group("method"),
|
|
140
|
+
path=m.group("path"),
|
|
141
|
+
status=status,
|
|
142
|
+
ip=m.group("ip"),
|
|
143
|
+
message=line,
|
|
144
|
+
source="uvicorn",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_gunicorn(line: str) -> LogEntry | None:
|
|
149
|
+
m = _GUNICORN_RE.match(line)
|
|
150
|
+
if not m:
|
|
151
|
+
return None
|
|
152
|
+
status = int(m.group("status"))
|
|
153
|
+
return LogEntry(
|
|
154
|
+
timestamp=_now(),
|
|
155
|
+
level=_status_to_level(status),
|
|
156
|
+
method=m.group("method"),
|
|
157
|
+
path=m.group("path"),
|
|
158
|
+
status=status,
|
|
159
|
+
ip=m.group("ip"),
|
|
160
|
+
message=line,
|
|
161
|
+
source="gunicorn",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_cloudflare(line: str) -> LogEntry | None:
|
|
166
|
+
"""Cloudflare logs ship as JSON objects."""
|
|
167
|
+
try:
|
|
168
|
+
d = json.loads(line)
|
|
169
|
+
status = int(d.get("EdgeResponseStatus", d.get("CacheResponseStatus", 0)))
|
|
170
|
+
return LogEntry(
|
|
171
|
+
timestamp=_now(),
|
|
172
|
+
level=_status_to_level(status),
|
|
173
|
+
method=d.get("ClientRequestMethod", ""),
|
|
174
|
+
path=d.get("ClientRequestURI", ""),
|
|
175
|
+
status=status,
|
|
176
|
+
ip=d.get("ClientIP", ""),
|
|
177
|
+
message=line,
|
|
178
|
+
source="cloudflare",
|
|
179
|
+
extra={
|
|
180
|
+
k: v
|
|
181
|
+
for k, v in d.items()
|
|
182
|
+
if k
|
|
183
|
+
not in (
|
|
184
|
+
"ClientRequestMethod",
|
|
185
|
+
"ClientRequestURI",
|
|
186
|
+
"ClientIP",
|
|
187
|
+
"EdgeResponseStatus",
|
|
188
|
+
"CacheResponseStatus",
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
except (json.JSONDecodeError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_json(line: str) -> LogEntry | None:
|
|
197
|
+
"""Generic JSON log line — best-effort field extraction."""
|
|
198
|
+
try:
|
|
199
|
+
d = json.loads(line)
|
|
200
|
+
status = int(d.get("status", d.get("status_code", 0)))
|
|
201
|
+
return LogEntry(
|
|
202
|
+
timestamp=float(d.get("timestamp", d.get("ts", _now()))),
|
|
203
|
+
level=d.get("level", d.get("severity", _status_to_level(status))).upper(),
|
|
204
|
+
method=d.get("method", ""),
|
|
205
|
+
path=d.get("path", d.get("url", d.get("uri", ""))),
|
|
206
|
+
status=status,
|
|
207
|
+
ip=d.get("ip", d.get("remote_addr", d.get("client_ip", ""))),
|
|
208
|
+
message=d.get("message", d.get("msg", line)),
|
|
209
|
+
source="json",
|
|
210
|
+
extra=d,
|
|
211
|
+
)
|
|
212
|
+
except (json.JSONDecodeError, ValueError):
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── Auto-detect and parse ──────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
_PARSERS = [
|
|
219
|
+
parse_cloudflare,
|
|
220
|
+
parse_nginx,
|
|
221
|
+
parse_apache,
|
|
222
|
+
parse_caddy,
|
|
223
|
+
parse_uvicorn,
|
|
224
|
+
parse_gunicorn,
|
|
225
|
+
parse_json, # generic JSON last — format-specific parsers take priority
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_line(line: str) -> LogEntry | None:
|
|
230
|
+
"""Try all parsers in order. Returns first successful result or None."""
|
|
231
|
+
line = line.strip()
|
|
232
|
+
if not line:
|
|
233
|
+
return None
|
|
234
|
+
for parser in _PARSERS:
|
|
235
|
+
try:
|
|
236
|
+
entry = parser(line)
|
|
237
|
+
if entry:
|
|
238
|
+
return entry
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.debug(f"Parser {parser.__name__} error: {e}")
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def parse_lines(lines: list[str]) -> list[LogEntry]:
|
|
245
|
+
"""Parse a batch of lines, skipping unparseable ones."""
|
|
246
|
+
results = []
|
|
247
|
+
for line in lines:
|
|
248
|
+
entry = parse_line(line)
|
|
249
|
+
if entry:
|
|
250
|
+
results.append(entry)
|
|
251
|
+
return results
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Admin token management for Patchi hosted mode.
|
|
3
|
+
|
|
4
|
+
Tokens are generated as random 32-byte hex strings.
|
|
5
|
+
Only the HMAC-SHA256 hash is ever stored — plaintext is shown once on generation.
|
|
6
|
+
|
|
7
|
+
Storage: .patchi/hosted/tokens.json
|
|
8
|
+
Format: [{id, name, hash, created_at, last_used}]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
_TOKENS_FILE = ".patchi/hosted/tokens.json"
|
|
22
|
+
_SECRET_FILE = ".patchi/hosted/.secret"
|
|
23
|
+
_HMAC_KEY_LEN = 32
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tokens_path(root: Path) -> Path:
|
|
27
|
+
return root / _TOKENS_FILE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _secret_path(root: Path) -> Path:
|
|
31
|
+
return root / _SECRET_FILE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_hmac_key(root: Path) -> bytes:
|
|
35
|
+
"""Load or generate a per-installation HMAC key."""
|
|
36
|
+
path = _secret_path(root)
|
|
37
|
+
if path.exists():
|
|
38
|
+
try:
|
|
39
|
+
return bytes.fromhex(path.read_text(encoding="utf-8").strip())
|
|
40
|
+
except (ValueError, OSError):
|
|
41
|
+
pass
|
|
42
|
+
# Generate and persist a new key
|
|
43
|
+
key = os.urandom(_HMAC_KEY_LEN)
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
path.write_text(key.hex(), encoding="utf-8")
|
|
46
|
+
return key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load(root: Path) -> list[dict]:
|
|
50
|
+
path = _tokens_path(root)
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return []
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _save(root: Path, tokens: list[dict]) -> None:
|
|
60
|
+
path = _tokens_path(root)
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_text(json.dumps(tokens, indent=2), encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hash(plaintext: str, root: Path) -> str:
|
|
66
|
+
"""HMAC-SHA256 hash of the token using per-installation key."""
|
|
67
|
+
key = _get_hmac_key(root)
|
|
68
|
+
return hmac.new(key, plaintext.encode(), hashlib.sha256).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def generate(root: Path, name: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Generate a new admin token. Stores the hash, returns the plaintext once.
|
|
74
|
+
The caller must display this to the user — it cannot be recovered later.
|
|
75
|
+
"""
|
|
76
|
+
plaintext = os.urandom(32).hex()
|
|
77
|
+
token_id = str(uuid.uuid4())[:8]
|
|
78
|
+
tokens = _load(root)
|
|
79
|
+
tokens.append(
|
|
80
|
+
{
|
|
81
|
+
"id": token_id,
|
|
82
|
+
"name": name,
|
|
83
|
+
"hash": _hash(plaintext, root),
|
|
84
|
+
"created_at": time.time(),
|
|
85
|
+
"last_used": None,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
_save(root, tokens)
|
|
89
|
+
return plaintext
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate(root: Path, plaintext: str) -> dict | None:
|
|
93
|
+
"""
|
|
94
|
+
Check if a plaintext token matches any stored hash.
|
|
95
|
+
Returns the token record (without hash) on match, None otherwise.
|
|
96
|
+
Updates last_used timestamp on success.
|
|
97
|
+
"""
|
|
98
|
+
candidate_hash = _hash(plaintext, root)
|
|
99
|
+
tokens = _load(root)
|
|
100
|
+
|
|
101
|
+
for token in tokens:
|
|
102
|
+
if hmac.compare_digest(token["hash"], candidate_hash):
|
|
103
|
+
token["last_used"] = time.time()
|
|
104
|
+
_save(root, tokens)
|
|
105
|
+
return {k: v for k, v in token.items() if k != "hash"}
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_tokens(root: Path) -> list[dict]:
|
|
111
|
+
"""Return all tokens without hash field."""
|
|
112
|
+
return [{k: v for k, v in t.items() if k != "hash"} for t in _load(root)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def revoke(root: Path, token_id: str) -> bool:
|
|
116
|
+
"""Remove a token by ID. Returns True if found and removed."""
|
|
117
|
+
tokens = _load(root)
|
|
118
|
+
before = len(tokens)
|
|
119
|
+
tokens = [t for t in tokens if t["id"] != token_id]
|
|
120
|
+
if len(tokens) == before:
|
|
121
|
+
return False
|
|
122
|
+
_save(root, tokens)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def count(root: Path) -> int:
|
|
127
|
+
return len(_load(root))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IP watchlist for Patchi hosted mode.
|
|
3
|
+
|
|
4
|
+
Tracks per-IP threat scores over time. Scores decay with TTL.
|
|
5
|
+
Escalates to Notifier when an IP crosses the configured threshold.
|
|
6
|
+
|
|
7
|
+
Storage: .patchi/hosted/watchlist.json
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_WATCHLIST_FILE = ".patchi/hosted/watchlist.json"
|
|
17
|
+
_SCORE_TTL_SECS = 3600 # scores decay to 0 after 1 hour of silence
|
|
18
|
+
_ESCALATE_HIGH = 50 # score threshold → HIGH alert
|
|
19
|
+
_ESCALATE_CRITICAL = 100 # score threshold → CRITICAL alert
|
|
20
|
+
|
|
21
|
+
# Score increments per finding type
|
|
22
|
+
_SCORE_MAP: dict[str, int] = {
|
|
23
|
+
"brute_force": 40,
|
|
24
|
+
"injection_probe": 30,
|
|
25
|
+
"scanner_sweep": 20,
|
|
26
|
+
"rate_spike": 10,
|
|
27
|
+
"error_spike": 5,
|
|
28
|
+
"ml_outlier": 15,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _path(root: Path) -> Path:
|
|
33
|
+
return root / _WATCHLIST_FILE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load(root: Path) -> dict[str, dict]:
|
|
37
|
+
p = _path(root)
|
|
38
|
+
if not p.exists():
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
42
|
+
except (json.JSONDecodeError, OSError):
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _save(root: Path, data: dict[str, dict]) -> None:
|
|
47
|
+
p = _path(root)
|
|
48
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
tmp = p.with_suffix(".tmp")
|
|
50
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
51
|
+
tmp.replace(p)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decay(entry: dict, now: float) -> float:
|
|
55
|
+
"""Linear decay to 0 over TTL since last_seen."""
|
|
56
|
+
age = now - entry.get("last_seen", now)
|
|
57
|
+
ratio = max(0.0, 1.0 - age / _SCORE_TTL_SECS)
|
|
58
|
+
return entry.get("score", 0.0) * ratio
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WatchlistTracker:
|
|
62
|
+
"""
|
|
63
|
+
Tracks IP threat scores and fires escalation callbacks when thresholds cross.
|
|
64
|
+
|
|
65
|
+
escalation_fn(ip, score, severity) is called when an IP crosses a threshold.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, root: Path, escalation_fn=None) -> None:
|
|
69
|
+
self._root = root
|
|
70
|
+
self._escalation_fn = escalation_fn or (lambda *a: None)
|
|
71
|
+
|
|
72
|
+
def record(self, ip: str, detector: str, note: str = "") -> float:
|
|
73
|
+
"""
|
|
74
|
+
Add score for a detector hit on this IP.
|
|
75
|
+
Returns the new effective score after decay + increment.
|
|
76
|
+
"""
|
|
77
|
+
if not ip:
|
|
78
|
+
return 0.0
|
|
79
|
+
|
|
80
|
+
now = time.time()
|
|
81
|
+
data = _load(self._root)
|
|
82
|
+
entry = data.get(
|
|
83
|
+
ip,
|
|
84
|
+
{
|
|
85
|
+
"ip": ip,
|
|
86
|
+
"score": 0.0,
|
|
87
|
+
"last_seen": now,
|
|
88
|
+
"hit_count": 0,
|
|
89
|
+
"escalated_high": False,
|
|
90
|
+
"escalated_critical": False,
|
|
91
|
+
"notes": [],
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Apply decay since last event
|
|
96
|
+
decayed = _decay(entry, now)
|
|
97
|
+
increment = _SCORE_MAP.get(detector, 10)
|
|
98
|
+
new_score = decayed + increment
|
|
99
|
+
|
|
100
|
+
entry["score"] = new_score
|
|
101
|
+
entry["last_seen"] = now
|
|
102
|
+
entry["hit_count"] = entry.get("hit_count", 0) + 1
|
|
103
|
+
if note:
|
|
104
|
+
entry["notes"] = (entry.get("notes", []) + [note])[-20:]
|
|
105
|
+
|
|
106
|
+
data[ip] = entry
|
|
107
|
+
|
|
108
|
+
# Check escalation before saving so we use in-memory data (no re-read race)
|
|
109
|
+
should_escalate_critical = new_score >= _ESCALATE_CRITICAL and not entry.get(
|
|
110
|
+
"escalated_critical"
|
|
111
|
+
)
|
|
112
|
+
should_escalate_high = (
|
|
113
|
+
new_score >= _ESCALATE_HIGH
|
|
114
|
+
and not should_escalate_critical
|
|
115
|
+
and not entry.get("escalated_high")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if should_escalate_critical:
|
|
119
|
+
entry["escalated_critical"] = True
|
|
120
|
+
elif should_escalate_high:
|
|
121
|
+
entry["escalated_high"] = True
|
|
122
|
+
|
|
123
|
+
_save(self._root, data)
|
|
124
|
+
|
|
125
|
+
if should_escalate_critical:
|
|
126
|
+
self._escalation_fn(ip, new_score, "critical")
|
|
127
|
+
elif should_escalate_high:
|
|
128
|
+
self._escalation_fn(ip, new_score, "high")
|
|
129
|
+
|
|
130
|
+
return new_score
|
|
131
|
+
|
|
132
|
+
def score(self, ip: str) -> float:
|
|
133
|
+
"""Current decayed score for an IP."""
|
|
134
|
+
data = _load(self._root)
|
|
135
|
+
entry = data.get(ip)
|
|
136
|
+
if not entry:
|
|
137
|
+
return 0.0
|
|
138
|
+
return _decay(entry, time.time())
|
|
139
|
+
|
|
140
|
+
def top(self, n: int = 10) -> list[dict]:
|
|
141
|
+
"""Return top-n IPs by current decayed score."""
|
|
142
|
+
now = time.time()
|
|
143
|
+
data = _load(self._root)
|
|
144
|
+
scored = [{**entry, "current_score": _decay(entry, now)} for entry in data.values()]
|
|
145
|
+
return sorted(scored, key=lambda e: e["current_score"], reverse=True)[:n]
|
|
146
|
+
|
|
147
|
+
def clear_ip(self, ip: str) -> bool:
|
|
148
|
+
"""Remove an IP from the watchlist. Returns True if found."""
|
|
149
|
+
data = _load(self._root)
|
|
150
|
+
if ip not in data:
|
|
151
|
+
return False
|
|
152
|
+
del data[ip]
|
|
153
|
+
_save(self._root, data)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def purge_expired(self) -> int:
|
|
157
|
+
"""Remove IPs whose score has fully decayed. Returns count removed."""
|
|
158
|
+
now = time.time()
|
|
159
|
+
data = _load(self._root)
|
|
160
|
+
keep = {ip: e for ip, e in data.items() if _decay(e, now) > 0}
|
|
161
|
+
removed = len(data) - len(keep)
|
|
162
|
+
if removed:
|
|
163
|
+
_save(self._root, keep)
|
|
164
|
+
return removed
|