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,1004 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain Loader — loads security domain taxonomies and fix playbooks.
|
|
3
|
+
|
|
4
|
+
Domains are YAML files under domains/ defining control requirements
|
|
5
|
+
(e.g. OWASP ASVS chapters mapped to Patchi controls). Fix playbooks
|
|
6
|
+
under fix-playbooks/ define how to remediate each control.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
loader = DomainLoader(root)
|
|
10
|
+
domain = loader.get_domain("web-frontend")
|
|
11
|
+
for ctrl in domain.controls:
|
|
12
|
+
playbook = loader.get_playbook(ctrl.control_id)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import pickle
|
|
21
|
+
import re
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger("patchi.security.domain_loader")
|
|
28
|
+
|
|
29
|
+
# Bump when the pickled payload shape changes (new dataclass field, schema
|
|
30
|
+
# semantics, etc.) so stale caches from older code are ignored, not mis-read.
|
|
31
|
+
# Content edits to the YAML and edits to this module both invalidate the cache
|
|
32
|
+
# automatically via the fingerprint; this constant is the manual escape hatch.
|
|
33
|
+
_CACHE_VERSION = 1
|
|
34
|
+
|
|
35
|
+
# ── Component-type scoping ────────────────────────────────────────────────────
|
|
36
|
+
# Canonical aliases shared with AppProfileScorer so a scan target's component
|
|
37
|
+
# type (e.g. "frontend-web") matches domain files that declare "frontend" or
|
|
38
|
+
# "web-frontend". Keep in sync with the top-level component_type in the YAMLs.
|
|
39
|
+
_COMPONENT_ALIASES = {
|
|
40
|
+
"infrastructure": "infra",
|
|
41
|
+
"frontend": "frontend-web",
|
|
42
|
+
"web-frontend": "frontend-web",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_component_type(ct) -> str:
|
|
47
|
+
"""Canonicalize a component-type token (case/alias-insensitive)."""
|
|
48
|
+
key = (ct or "").strip().lower()
|
|
49
|
+
return _COMPONENT_ALIASES.get(key, key)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Cheap top-level key scan: only lines starting at column 0 count, so nested
|
|
53
|
+
# (indented) keys and inline mentions are ignored. Used to build the domain
|
|
54
|
+
# index without paying for a full YAML parse of every file.
|
|
55
|
+
_INDEX_RE = re.compile(r'^(domain_id|component_type):\s*"?([^"#\n]+?)"?\s*$', re.MULTILINE)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cache_dir() -> Path:
|
|
59
|
+
"""Per-user cache directory shared across processes and projects.
|
|
60
|
+
|
|
61
|
+
The domain taxonomy ships with the installed package, so one cache entry
|
|
62
|
+
serves every scanned project on this machine. Never written into the
|
|
63
|
+
scanned project (which may be read-only or a third-party checkout).
|
|
64
|
+
"""
|
|
65
|
+
if os.name == "nt":
|
|
66
|
+
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
|
|
67
|
+
else:
|
|
68
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
69
|
+
return Path(base) / "patchi" / "domain-cache"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Module-level stat cache: {dir_path_str: (mtime, file_mtimes_dict)}
|
|
73
|
+
_stat_cache: dict[str, tuple[float, dict[str, float]]] = {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _stat_fingerprint(domains_dir: Path, playbooks_dir: Path) -> str:
|
|
77
|
+
"""Fast fingerprint with per-file stat caching.
|
|
78
|
+
|
|
79
|
+
First call stats all files (O(n)). Subsequent calls reuse cached stats
|
|
80
|
+
and only re-stat files whose parent directory mtime changed — making
|
|
81
|
+
repeated calls O(1) when nothing changed, and O(changed) otherwise.
|
|
82
|
+
"""
|
|
83
|
+
parts: list[str] = []
|
|
84
|
+
for d in (domains_dir, playbooks_dir):
|
|
85
|
+
if not d.is_dir():
|
|
86
|
+
parts.append(f"{d.name}:0:0")
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
dir_mtime = d.stat().st_mtime
|
|
90
|
+
except OSError:
|
|
91
|
+
parts.append(f"{d.name}:0:0")
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
cache_key = str(d)
|
|
95
|
+
cached = _stat_cache.get(cache_key)
|
|
96
|
+
if cached and cached[0] == dir_mtime:
|
|
97
|
+
# Directory didn't change — reuse cached file mtimes
|
|
98
|
+
file_mtimes = cached[1]
|
|
99
|
+
else:
|
|
100
|
+
# Directory changed — re-stat all files
|
|
101
|
+
file_mtimes = {}
|
|
102
|
+
for f in d.glob("*.yaml"):
|
|
103
|
+
try:
|
|
104
|
+
file_mtimes[f.name] = f.stat().st_mtime
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
_stat_cache[cache_key] = (dir_mtime, file_mtimes)
|
|
108
|
+
|
|
109
|
+
count = len(file_mtimes)
|
|
110
|
+
mtime_sum = sum(file_mtimes.values())
|
|
111
|
+
parts.append(f"{d.name}:{count}:{mtime_sum:.6f}")
|
|
112
|
+
|
|
113
|
+
return "|".join(parts) if parts else "empty"
|
|
114
|
+
|
|
115
|
+
def _content_fingerprint(domains_dir: Path, playbooks_dir: Path) -> str:
|
|
116
|
+
"""Authoritative cache key: dir paths + name + full bytes of every YAML.
|
|
117
|
+
|
|
118
|
+
Content-hashed so a data edit can never silently serve a stale cache,
|
|
119
|
+
regardless of filesystem timestamp granularity (NTFS stores 100ns ticks,
|
|
120
|
+
so mtime-only keys can miss quick edits). The loader module's own
|
|
121
|
+
mtime/size is mixed in, so code changes that alter how objects are built
|
|
122
|
+
invalidate the cache automatically too.
|
|
123
|
+
"""
|
|
124
|
+
h = hashlib.sha256()
|
|
125
|
+
for d in (domains_dir, playbooks_dir):
|
|
126
|
+
try:
|
|
127
|
+
h.update(str(d.resolve()).encode("utf-8", "replace"))
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
if not d.is_dir():
|
|
131
|
+
continue
|
|
132
|
+
for f in sorted(d.glob("*.yaml")):
|
|
133
|
+
try:
|
|
134
|
+
h.update(f.name.encode("utf-8", "replace"))
|
|
135
|
+
h.update(f.read_bytes())
|
|
136
|
+
except OSError:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
st = Path(__file__).stat()
|
|
140
|
+
h.update(b"|loader:")
|
|
141
|
+
h.update(str(st.st_mtime_ns).encode("ascii"))
|
|
142
|
+
h.update(str(st.st_size).encode("ascii"))
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
return h.hexdigest()[:24]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cache_path(fingerprint: str) -> Path:
|
|
149
|
+
return _cache_dir() / f"v{_CACHE_VERSION}_{fingerprint}.pkl"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load_cached(fingerprint: str):
|
|
153
|
+
"""Load (domains, playbooks) from the on-disk cache, or None on any miss.
|
|
154
|
+
|
|
155
|
+
The pickled payload is the built Domain/FixPlaybook objects, so a cache
|
|
156
|
+
hit skips both YAML parsing AND object construction (~17s -> <0.1s).
|
|
157
|
+
Shape- and type-checked so a truncated, corrupt, or wrong-schema payload
|
|
158
|
+
(e.g. pickled by older code) falls back to a fresh parse instead of
|
|
159
|
+
being served.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
path = _cache_path(fingerprint)
|
|
163
|
+
if not path.is_file():
|
|
164
|
+
return None
|
|
165
|
+
with open(path, "rb") as fh:
|
|
166
|
+
domains, playbooks = pickle.load(fh)
|
|
167
|
+
if not isinstance(domains, dict) or not isinstance(playbooks, dict):
|
|
168
|
+
return None
|
|
169
|
+
if not all(isinstance(d, Domain) for d in domains.values()):
|
|
170
|
+
return None
|
|
171
|
+
if not all(isinstance(p, FixPlaybook) for p in playbooks.values()):
|
|
172
|
+
return None
|
|
173
|
+
for d in domains.values():
|
|
174
|
+
if not all(isinstance(c, DomainControl) for c in d.controls):
|
|
175
|
+
return None
|
|
176
|
+
return domains, playbooks
|
|
177
|
+
except Exception as e: # noqa: BLE001 — cache is best-effort
|
|
178
|
+
_log.debug("DomainLoader cache load failed: %s", e)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _purge_stale_caches(d: Path, keep_fingerprint: str) -> None:
|
|
183
|
+
"""Drop cache files older than a week so the dir can't grow unbounded.
|
|
184
|
+
|
|
185
|
+
Age-based only: never delete files with a different (possibly still
|
|
186
|
+
active) fingerprint, which may belong to another project or process.
|
|
187
|
+
"""
|
|
188
|
+
cutoff = time.time() - 7 * 24 * 3600
|
|
189
|
+
try:
|
|
190
|
+
target = _cache_path(keep_fingerprint)
|
|
191
|
+
# Final cache files (v<ver>_<fp>.pkl) and orphaned temp files left by
|
|
192
|
+
# writers killed between mkstemp and os.replace (.tmp_<fp>_*.pkl).
|
|
193
|
+
for old in list(d.glob(f"v{_CACHE_VERSION}_*.pkl")) + list(d.glob(".tmp_*.pkl")):
|
|
194
|
+
if old == target:
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
if old.stat().st_mtime < cutoff:
|
|
198
|
+
old.unlink(missing_ok=True)
|
|
199
|
+
except OSError:
|
|
200
|
+
pass
|
|
201
|
+
except Exception as _exc: # noqa: BLE001
|
|
202
|
+
_log.debug('_purge_stale_caches skipped: %s', _exc)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _save_cached(fingerprint: str, domains: dict, playbooks: dict) -> None:
|
|
206
|
+
"""Atomically write the cache (unique temp file + rename); never raise."""
|
|
207
|
+
try:
|
|
208
|
+
d = _cache_dir()
|
|
209
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
fd, tmp = tempfile.mkstemp(prefix=f".tmp_{fingerprint}_", suffix=".pkl", dir=d)
|
|
211
|
+
try:
|
|
212
|
+
with os.fdopen(fd, "wb") as fh:
|
|
213
|
+
pickle.dump((domains, playbooks), fh, protocol=pickle.HIGHEST_PROTOCOL)
|
|
214
|
+
os.replace(tmp, _cache_path(fingerprint))
|
|
215
|
+
except BaseException:
|
|
216
|
+
try:
|
|
217
|
+
os.unlink(tmp)
|
|
218
|
+
except OSError:
|
|
219
|
+
pass
|
|
220
|
+
raise
|
|
221
|
+
_purge_stale_caches(d, fingerprint)
|
|
222
|
+
except Exception as e: # noqa: BLE001
|
|
223
|
+
_log.debug("DomainLoader cache write failed: %s", e)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class DomainControl:
|
|
228
|
+
control_id: str
|
|
229
|
+
name: str
|
|
230
|
+
description: str
|
|
231
|
+
source_clause: str
|
|
232
|
+
severity: str
|
|
233
|
+
check_method: str
|
|
234
|
+
detector: str
|
|
235
|
+
remediation_ref: str
|
|
236
|
+
raw: dict = field(default_factory=dict)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class Domain:
|
|
241
|
+
domain_id: str
|
|
242
|
+
version: str
|
|
243
|
+
display_name: str
|
|
244
|
+
source_standard: str
|
|
245
|
+
component_type: str
|
|
246
|
+
weight: float
|
|
247
|
+
activation_signals: dict
|
|
248
|
+
controls: list[DomainControl]
|
|
249
|
+
raw: dict = field(default_factory=dict)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class FixPlaybook:
|
|
254
|
+
control_id: str
|
|
255
|
+
playbook_version: str
|
|
256
|
+
fix_strategy: str
|
|
257
|
+
deterministic_tool: str | None
|
|
258
|
+
llm_fix_template: str | None
|
|
259
|
+
verification_checks: list[str]
|
|
260
|
+
blast_radius_notes: str
|
|
261
|
+
human_review_required: bool
|
|
262
|
+
raw: dict = field(default_factory=dict)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _try_load_yaml(path: Path) -> dict | None:
|
|
266
|
+
if not path.exists():
|
|
267
|
+
return None
|
|
268
|
+
try:
|
|
269
|
+
import yaml
|
|
270
|
+
try:
|
|
271
|
+
Loader = yaml.CSafeLoader # type: ignore[attr-defined]
|
|
272
|
+
except AttributeError:
|
|
273
|
+
Loader = yaml.SafeLoader
|
|
274
|
+
with open(path, encoding="utf-8") as f:
|
|
275
|
+
return yaml.load(f, Loader=Loader)
|
|
276
|
+
except ImportError:
|
|
277
|
+
try:
|
|
278
|
+
import json
|
|
279
|
+
with open(path, encoding="utf-8") as f:
|
|
280
|
+
return json.load(f)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
import logging
|
|
283
|
+
logging.getLogger("patchi.security.domain_loader").warning(
|
|
284
|
+
"Failed to parse %s: %s", path, e
|
|
285
|
+
)
|
|
286
|
+
return None
|
|
287
|
+
except Exception as e:
|
|
288
|
+
import logging
|
|
289
|
+
logging.getLogger("patchi.security.domain_loader").warning(
|
|
290
|
+
"Failed to parse %s: %s", path, e
|
|
291
|
+
)
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
def _to_float(value, default: float = 0.5) -> float:
|
|
295
|
+
"""Coerce a YAML value to float, never raising on malformed data."""
|
|
296
|
+
try:
|
|
297
|
+
return float(value)
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
return default
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _to_bool(value, default: bool = True) -> bool:
|
|
303
|
+
"""Lenient bool coercion: accepts bools and common string spellings.
|
|
304
|
+
|
|
305
|
+
``bool("false")`` is ``True`` in Python, which would silently invert
|
|
306
|
+
intent for hand-written YAML, so string values are parsed explicitly.
|
|
307
|
+
"""
|
|
308
|
+
if isinstance(value, bool):
|
|
309
|
+
return value
|
|
310
|
+
if isinstance(value, (int, float)):
|
|
311
|
+
return bool(value)
|
|
312
|
+
if isinstance(value, str):
|
|
313
|
+
return value.strip().lower() in {"true", "yes", "y", "1", "on"}
|
|
314
|
+
if value is None:
|
|
315
|
+
return default
|
|
316
|
+
return default
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _to_str_list(value) -> list:
|
|
320
|
+
"""Coerce to a list of strings; a bare string becomes a single item."""
|
|
321
|
+
if value is None:
|
|
322
|
+
return []
|
|
323
|
+
if isinstance(value, (list, tuple)):
|
|
324
|
+
return [str(v) for v in value]
|
|
325
|
+
return [str(value)]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class DomainLoader:
|
|
329
|
+
"""Loads domain taxonomies and fix playbooks from YAML files."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, root: Path, component_types=None, domain_ids=None):
|
|
332
|
+
self._root = root
|
|
333
|
+
self._domains_dir = root / "patchi" / "core" / "security" / "domains"
|
|
334
|
+
self._playbooks_dir = root / "patchi" / "core" / "security" / "fix-playbooks"
|
|
335
|
+
self._fallback_dirs()
|
|
336
|
+
self._domains: dict[str, Domain] = {}
|
|
337
|
+
self._playbooks: dict[str, FixPlaybook] = {}
|
|
338
|
+
self._loaded = False
|
|
339
|
+
self._last_stat: str = ""
|
|
340
|
+
self._last_fingerprint: str = ""
|
|
341
|
+
self._index: dict | None = None
|
|
342
|
+
self._scope_types: set[str] | None = None
|
|
343
|
+
self._scope_ids: set[str] | None = None
|
|
344
|
+
if component_types is not None or domain_ids is not None:
|
|
345
|
+
self.set_component_scope(component_types, domain_ids)
|
|
346
|
+
|
|
347
|
+
def set_component_scope(self, component_types=None, domain_ids=None) -> None:
|
|
348
|
+
"""Restrict loading to a subset of the taxonomy.
|
|
349
|
+
|
|
350
|
+
``component_types`` (str or iterable) loads only domain files whose
|
|
351
|
+
top-level component_type matches (aliases normalized, so "frontend"
|
|
352
|
+
matches a "frontend-web" scope). ``domain_ids`` additionally forces
|
|
353
|
+
specific domains to load regardless of their declared type — used by
|
|
354
|
+
AppProfileScorer for its explicit component->domain map (e.g. a
|
|
355
|
+
backend-api profile always loads the web-frontend domain). Pass
|
|
356
|
+
None/None (or an empty list — treated the same as None) to restore
|
|
357
|
+
full-taxonomy loading (the default).
|
|
358
|
+
"""
|
|
359
|
+
if component_types is None and domain_ids is None:
|
|
360
|
+
self._scope_types = None
|
|
361
|
+
self._scope_ids = None
|
|
362
|
+
else:
|
|
363
|
+
types: set[str] = set()
|
|
364
|
+
if component_types:
|
|
365
|
+
if isinstance(component_types, str):
|
|
366
|
+
component_types = [component_types]
|
|
367
|
+
for ct in component_types:
|
|
368
|
+
types.add(normalize_component_type(ct))
|
|
369
|
+
self._scope_types = types
|
|
370
|
+
self._scope_ids = set(domain_ids or [])
|
|
371
|
+
# Any previously loaded subset is stale.
|
|
372
|
+
self._domains = {}
|
|
373
|
+
self._playbooks = {}
|
|
374
|
+
self._loaded = False
|
|
375
|
+
|
|
376
|
+
def _is_scoped(self) -> bool:
|
|
377
|
+
return bool(self._scope_types or self._scope_ids)
|
|
378
|
+
|
|
379
|
+
def _build_index(self) -> dict:
|
|
380
|
+
"""Map domain_id -> {path, types} with a regex scan, no YAML parse.
|
|
381
|
+
|
|
382
|
+
``types`` is the set of normalized component types, or None when the
|
|
383
|
+
top-level component_type could not be extracted (the file is then
|
|
384
|
+
parsed and authoritatively filtered during a scoped load). The index
|
|
385
|
+
is cached in-process, but invalidated whenever the stat signature of
|
|
386
|
+
the data directories changes — including for callers that only ask
|
|
387
|
+
for the index (``index_component_types``) without ever loading.
|
|
388
|
+
"""
|
|
389
|
+
stat_fp = _stat_fingerprint(self._domains_dir, self._playbooks_dir)
|
|
390
|
+
if self._index is not None and stat_fp == self._index.get("_stat"):
|
|
391
|
+
return self._index
|
|
392
|
+
index: dict = {}
|
|
393
|
+
if self._domains_dir.exists():
|
|
394
|
+
for fpath in sorted(self._domains_dir.glob("*.yaml")):
|
|
395
|
+
try:
|
|
396
|
+
text = fpath.read_text(encoding="utf-8")
|
|
397
|
+
except OSError:
|
|
398
|
+
continue
|
|
399
|
+
fields: dict[str, str] = {}
|
|
400
|
+
for m in _INDEX_RE.finditer(text):
|
|
401
|
+
key, val = m.group(1), m.group(2).strip()
|
|
402
|
+
if key not in fields:
|
|
403
|
+
fields[key] = val
|
|
404
|
+
did = fields.get("domain_id")
|
|
405
|
+
if not did:
|
|
406
|
+
continue
|
|
407
|
+
raw = fields.get("component_type")
|
|
408
|
+
types = None
|
|
409
|
+
if raw:
|
|
410
|
+
types = {normalize_component_type(t) for t in raw.split(",")}
|
|
411
|
+
index[did] = {"path": fpath, "types": types}
|
|
412
|
+
index["_stat"] = stat_fp
|
|
413
|
+
self._index = index
|
|
414
|
+
return index
|
|
415
|
+
|
|
416
|
+
def index_component_types(self, domain_id: str) -> frozenset:
|
|
417
|
+
"""Normalized component types of a domain, from the cheap index.
|
|
418
|
+
|
|
419
|
+
Never parses YAML — safe to call on a loader with no scope. The index
|
|
420
|
+
is refreshed automatically if the data files change.
|
|
421
|
+
"""
|
|
422
|
+
entry = self._build_index().get(domain_id)
|
|
423
|
+
if not entry or not entry["types"]:
|
|
424
|
+
return frozenset()
|
|
425
|
+
return frozenset(entry["types"])
|
|
426
|
+
|
|
427
|
+
def _fallback_dirs(self):
|
|
428
|
+
pkg = Path(__file__).parent
|
|
429
|
+
if not self._domains_dir.exists():
|
|
430
|
+
self._domains_dir = pkg / "domains"
|
|
431
|
+
if not self._playbooks_dir.exists():
|
|
432
|
+
self._playbooks_dir = pkg / "fix-playbooks"
|
|
433
|
+
|
|
434
|
+
def _dirs_changed(self, content: bool = True) -> bool:
|
|
435
|
+
"""True if the data changed since the last load.
|
|
436
|
+
|
|
437
|
+
The stat-only signature is the cheap trigger; only when it fires do we
|
|
438
|
+
pay for a full content hash, which is immune to timestamp-granularity
|
|
439
|
+
races (NTFS stores 100ns ticks). The content fingerprint doubles as
|
|
440
|
+
the on-disk cache key. ``content=False`` skips the hash entirely —
|
|
441
|
+
used by scoped loads, which bypass the on-disk cache and only need
|
|
442
|
+
in-process change detection. Residual limitation: an edit landing
|
|
443
|
+
within the same stat signature (same 100ns tick AND same size) goes
|
|
444
|
+
unnoticed for the remainder of this process — fresh processes always
|
|
445
|
+
recompute the content hash, so the on-disk cache can never go stale.
|
|
446
|
+
"""
|
|
447
|
+
stat_fp = _stat_fingerprint(self._domains_dir, self._playbooks_dir)
|
|
448
|
+
if stat_fp == self._last_stat:
|
|
449
|
+
return False
|
|
450
|
+
if not content:
|
|
451
|
+
self._last_stat = stat_fp
|
|
452
|
+
self._last_fingerprint = ""
|
|
453
|
+
return True
|
|
454
|
+
content_fp = _content_fingerprint(self._domains_dir, self._playbooks_dir)
|
|
455
|
+
changed = content_fp != self._last_fingerprint
|
|
456
|
+
self._last_stat = stat_fp
|
|
457
|
+
self._last_fingerprint = content_fp
|
|
458
|
+
return changed
|
|
459
|
+
|
|
460
|
+
def _load_all(self):
|
|
461
|
+
if self._is_scoped():
|
|
462
|
+
self._load_scoped()
|
|
463
|
+
return
|
|
464
|
+
if self._loaded:
|
|
465
|
+
if not self._dirs_changed():
|
|
466
|
+
return
|
|
467
|
+
else:
|
|
468
|
+
# First load: initialize fingerprint tracking so the cache key is
|
|
469
|
+
# set before we probe the on-disk cache. Force the content hash
|
|
470
|
+
# even if a scoped load already set a stat signature — the scoped
|
|
471
|
+
# path intentionally skips content hashing, and the cache key must
|
|
472
|
+
# never be the empty sentinel.
|
|
473
|
+
stat_fp = _stat_fingerprint(self._domains_dir, self._playbooks_dir)
|
|
474
|
+
if stat_fp != self._last_stat or not self._last_fingerprint:
|
|
475
|
+
self._last_fingerprint = _content_fingerprint(
|
|
476
|
+
self._domains_dir, self._playbooks_dir
|
|
477
|
+
)
|
|
478
|
+
self._last_stat = stat_fp
|
|
479
|
+
self._loaded = True
|
|
480
|
+
|
|
481
|
+
# Fast path: reuse the shared on-disk cache keyed by content fingerprint.
|
|
482
|
+
fingerprint = self._last_fingerprint
|
|
483
|
+
cached = _load_cached(fingerprint)
|
|
484
|
+
if cached is not None:
|
|
485
|
+
self._domains, self._playbooks = cached
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
if self._domains_dir.exists():
|
|
489
|
+
for fpath in sorted(self._domains_dir.glob("*.yaml")):
|
|
490
|
+
domain = self._parse_domain_file(fpath)
|
|
491
|
+
if domain is not None:
|
|
492
|
+
self._domains[domain.domain_id] = domain
|
|
493
|
+
|
|
494
|
+
if self._playbooks_dir.exists():
|
|
495
|
+
for fpath in sorted(self._playbooks_dir.glob("*.yaml")):
|
|
496
|
+
self._playbooks.update(self._parse_playbook_file(fpath))
|
|
497
|
+
|
|
498
|
+
# Persist the freshly built objects so the next process starts fast.
|
|
499
|
+
_save_cached(fingerprint, self._domains, self._playbooks)
|
|
500
|
+
|
|
501
|
+
def _load_scoped(self):
|
|
502
|
+
"""Load only the domains relevant to the active component scope.
|
|
503
|
+
|
|
504
|
+
The cheap regex index picks the candidate files (component_type match
|
|
505
|
+
and/or explicit domain ids) before any YAML parse, so a narrow scan
|
|
506
|
+
(e.g. frontend-web: ~13 of 334 files) never builds the full taxonomy.
|
|
507
|
+
The on-disk cache is intentionally bypassed: it stores the whole
|
|
508
|
+
taxonomy, and reading it would defeat the purpose of a scoped load.
|
|
509
|
+
"""
|
|
510
|
+
if self._loaded:
|
|
511
|
+
if not self._dirs_changed(content=False):
|
|
512
|
+
return
|
|
513
|
+
# Data changed — the cached index may reference stale paths/types.
|
|
514
|
+
self._index = None
|
|
515
|
+
else:
|
|
516
|
+
self._dirs_changed(content=False)
|
|
517
|
+
self._loaded = True
|
|
518
|
+
|
|
519
|
+
scope_types = self._scope_types or set()
|
|
520
|
+
scope_ids = self._scope_ids or set()
|
|
521
|
+
index = self._build_index()
|
|
522
|
+
|
|
523
|
+
domains: dict[str, Domain] = {}
|
|
524
|
+
playbooks: dict[str, FixPlaybook] = {}
|
|
525
|
+
for did, entry in index.items():
|
|
526
|
+
if did == "_stat":
|
|
527
|
+
continue # internal cache sentinel, not a domain
|
|
528
|
+
if did in scope_ids:
|
|
529
|
+
pass # explicitly requested regardless of declared type
|
|
530
|
+
elif entry["types"] is not None and not (entry["types"] & scope_types):
|
|
531
|
+
continue # cheap index says this file can't match — skip
|
|
532
|
+
# Parse the file; the parsed component_type is authoritative
|
|
533
|
+
# (guards against a regex miss or an unusual file layout).
|
|
534
|
+
domain = self._parse_domain_file(entry["path"])
|
|
535
|
+
if domain is None:
|
|
536
|
+
continue
|
|
537
|
+
if did not in scope_ids:
|
|
538
|
+
parsed_types = {
|
|
539
|
+
normalize_component_type(t)
|
|
540
|
+
for t in str(domain.component_type).split(",")
|
|
541
|
+
if t.strip()
|
|
542
|
+
}
|
|
543
|
+
if not (parsed_types & scope_types):
|
|
544
|
+
continue
|
|
545
|
+
domains[domain.domain_id] = domain
|
|
546
|
+
pb_path = self._playbooks_dir / f"{entry['path'].stem}.playbook.yaml"
|
|
547
|
+
if pb_path.exists():
|
|
548
|
+
playbooks.update(self._parse_playbook_file(pb_path))
|
|
549
|
+
|
|
550
|
+
self._domains = domains
|
|
551
|
+
self._playbooks = playbooks
|
|
552
|
+
|
|
553
|
+
def _parse_domain_file(self, fpath: Path) -> Domain | None:
|
|
554
|
+
"""Build a Domain from one YAML file, or None if it isn't one."""
|
|
555
|
+
data = _try_load_yaml(fpath)
|
|
556
|
+
if not data or "domain_id" not in data:
|
|
557
|
+
return None
|
|
558
|
+
controls: list[DomainControl] = []
|
|
559
|
+
for c in data.get("controls", []):
|
|
560
|
+
if not isinstance(c, dict):
|
|
561
|
+
_log.warning(
|
|
562
|
+
"DomainLoader: skipping non-dict control in %s",
|
|
563
|
+
fpath.name,
|
|
564
|
+
)
|
|
565
|
+
continue
|
|
566
|
+
cid = c.get("control_id")
|
|
567
|
+
name = c.get("name")
|
|
568
|
+
if not isinstance(cid, str) or not cid or not isinstance(name, str) or not name:
|
|
569
|
+
_log.warning(
|
|
570
|
+
"DomainLoader: skipping control without control_id/name in %s (keys=%s)",
|
|
571
|
+
fpath.name,
|
|
572
|
+
sorted(c)[:8],
|
|
573
|
+
)
|
|
574
|
+
continue
|
|
575
|
+
controls.append(
|
|
576
|
+
DomainControl(
|
|
577
|
+
control_id=cid,
|
|
578
|
+
name=name,
|
|
579
|
+
description=str(c.get("description", "")),
|
|
580
|
+
source_clause=str(c.get("source_clause", "")),
|
|
581
|
+
severity=str(c.get("severity", "medium")),
|
|
582
|
+
check_method=str(c.get("check_method", "static")),
|
|
583
|
+
detector=str(c.get("detector", "")),
|
|
584
|
+
remediation_ref=str(c.get("remediation_ref", "")),
|
|
585
|
+
raw=c,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
did = data.get("domain_id")
|
|
589
|
+
if not isinstance(did, str) or not did:
|
|
590
|
+
return None
|
|
591
|
+
return Domain(
|
|
592
|
+
domain_id=did,
|
|
593
|
+
version=str(data.get("version", "1.0.0")),
|
|
594
|
+
display_name=str(data.get("display_name", did)),
|
|
595
|
+
source_standard=str(data.get("source_standard", "")),
|
|
596
|
+
component_type=str(data.get("component_type", "")),
|
|
597
|
+
weight=_to_float(data.get("weight"), 0.5),
|
|
598
|
+
activation_signals=data.get("activation_signals", {}) or {},
|
|
599
|
+
controls=controls,
|
|
600
|
+
raw=data,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def _parse_playbook_file(self, fpath: Path) -> dict[str, FixPlaybook]:
|
|
604
|
+
"""Build {control_id: FixPlaybook} from one fix-playbook YAML file."""
|
|
605
|
+
result: dict[str, FixPlaybook] = {}
|
|
606
|
+
data = _try_load_yaml(fpath)
|
|
607
|
+
if not data or "playbooks" not in data:
|
|
608
|
+
return result
|
|
609
|
+
for pb in data["playbooks"]:
|
|
610
|
+
if not isinstance(pb, dict):
|
|
611
|
+
_log.warning(
|
|
612
|
+
"DomainLoader: skipping non-dict playbook entry in %s",
|
|
613
|
+
fpath.name,
|
|
614
|
+
)
|
|
615
|
+
continue
|
|
616
|
+
cid = pb.get("control_id")
|
|
617
|
+
if not isinstance(cid, str) or not cid:
|
|
618
|
+
# Also accept the legacy playbook_id key so a malformed or
|
|
619
|
+
# older-format file degrades gracefully instead of crashing
|
|
620
|
+
# the whole scan pipeline (see tools/fix_playbooks.py).
|
|
621
|
+
cid = pb.get("playbook_id")
|
|
622
|
+
if not isinstance(cid, str) or not cid:
|
|
623
|
+
_log.warning(
|
|
624
|
+
"DomainLoader: skipping playbook entry without control_id in %s (keys=%s)",
|
|
625
|
+
fpath.name,
|
|
626
|
+
sorted(pb)[:8],
|
|
627
|
+
)
|
|
628
|
+
continue
|
|
629
|
+
# Legacy schema support: remediation_steps/verification map onto
|
|
630
|
+
# the canonical llm_fix_template/verification_checks fields.
|
|
631
|
+
llm_template = pb.get("llm_fix_template")
|
|
632
|
+
legacy_steps = isinstance(pb.get("remediation_steps"), list)
|
|
633
|
+
if not llm_template and legacy_steps:
|
|
634
|
+
steps = pb["remediation_steps"]
|
|
635
|
+
parts = [f"{i + 1}. {s}" for i, s in enumerate(steps) if isinstance(s, str)]
|
|
636
|
+
notes = pb.get("implementation_notes")
|
|
637
|
+
if isinstance(notes, list):
|
|
638
|
+
parts.append("Implementation notes: " + " ".join(str(n) for n in notes))
|
|
639
|
+
if parts:
|
|
640
|
+
llm_template = "\n".join(parts)
|
|
641
|
+
verification = pb.get("verification_checks")
|
|
642
|
+
if not verification and pb.get("verification") is not None:
|
|
643
|
+
verification = pb["verification"]
|
|
644
|
+
fix_strategy = str(pb.get("fix_strategy") or "")
|
|
645
|
+
if not fix_strategy:
|
|
646
|
+
# Entries written in the legacy remediation_steps schema are
|
|
647
|
+
# LLM-fixable by construction — don't fall back to manual-only.
|
|
648
|
+
fix_strategy = "llm-template-fill" if legacy_steps else "manual-only"
|
|
649
|
+
result[cid] = FixPlaybook(
|
|
650
|
+
control_id=cid,
|
|
651
|
+
playbook_version=str(pb.get("playbook_version", "1.0.0")),
|
|
652
|
+
fix_strategy=fix_strategy,
|
|
653
|
+
deterministic_tool=pb.get("deterministic_tool"),
|
|
654
|
+
llm_fix_template=llm_template if isinstance(llm_template, str) else None,
|
|
655
|
+
verification_checks=_to_str_list(verification),
|
|
656
|
+
blast_radius_notes=str(pb.get("blast_radius_notes", "")),
|
|
657
|
+
human_review_required=_to_bool(pb.get("human_review_required"), True),
|
|
658
|
+
raw=pb,
|
|
659
|
+
)
|
|
660
|
+
return result
|
|
661
|
+
|
|
662
|
+
def list_domains(self) -> list[str]:
|
|
663
|
+
self._load_all()
|
|
664
|
+
return list(self._domains.keys())
|
|
665
|
+
|
|
666
|
+
def get_domain(self, domain_id: str) -> Domain | None:
|
|
667
|
+
self._load_all()
|
|
668
|
+
return self._domains.get(domain_id)
|
|
669
|
+
|
|
670
|
+
def find_control(self, control_id: str) -> DomainControl | None:
|
|
671
|
+
"""Find a control by id in the loaded domains.
|
|
672
|
+
|
|
673
|
+
On a scoped loader this only searches the loaded (in-scope) domains —
|
|
674
|
+
controls belonging to out-of-scope domains return None, by design, so
|
|
675
|
+
a narrow scan never materializes the rest of the taxonomy.
|
|
676
|
+
"""
|
|
677
|
+
self._load_all()
|
|
678
|
+
for domain in self._domains.values():
|
|
679
|
+
for ctrl in domain.controls:
|
|
680
|
+
if ctrl.control_id == control_id:
|
|
681
|
+
return ctrl
|
|
682
|
+
return None
|
|
683
|
+
|
|
684
|
+
def get_playbook(self, control_id: str) -> FixPlaybook | None:
|
|
685
|
+
"""Get the fix playbook for a control.
|
|
686
|
+
|
|
687
|
+
On a scoped loader only playbooks for loaded (in-scope) domains are
|
|
688
|
+
available; out-of-scope controls return None by design.
|
|
689
|
+
"""
|
|
690
|
+
self._load_all()
|
|
691
|
+
return self._playbooks.get(control_id)
|
|
692
|
+
|
|
693
|
+
def _build_keyword_index(self) -> dict[str, list[tuple[str, DomainControl, Domain]]]:
|
|
694
|
+
"""Pre-build inverted index: keyword -> [(domain_id, control, domain)].
|
|
695
|
+
|
|
696
|
+
Called once after loading; makes match_finding_to_controls O(k) per
|
|
697
|
+
finding instead of O(domains * controls).
|
|
698
|
+
"""
|
|
699
|
+
if hasattr(self, "_kw_index") and self._kw_index is not None:
|
|
700
|
+
return self._kw_index
|
|
701
|
+
index: dict[str, list[tuple[str, DomainControl, Domain]]] = {}
|
|
702
|
+
for domain in self._domains.values():
|
|
703
|
+
for ctrl in domain.controls:
|
|
704
|
+
# Index keywords from control name + description
|
|
705
|
+
ctrl_text = (ctrl.name + " " + ctrl.description).lower()
|
|
706
|
+
ctrl_domains = self._classify_domains(ctrl_text)
|
|
707
|
+
for kw_set_name in ctrl_domains:
|
|
708
|
+
if kw_set_name not in index:
|
|
709
|
+
index[kw_set_name] = []
|
|
710
|
+
index[kw_set_name].append((domain.domain_id, ctrl, domain))
|
|
711
|
+
self._kw_index = index
|
|
712
|
+
return index
|
|
713
|
+
|
|
714
|
+
@staticmethod
|
|
715
|
+
def _domain_keywords() -> dict[str, set[str]]:
|
|
716
|
+
return {
|
|
717
|
+
# ── Injection & Input ──────────────────────────────────────────
|
|
718
|
+
"injection": {
|
|
719
|
+
"sql", "nosql", "ldap", "command", "orm", "eval",
|
|
720
|
+
"deserialization", "hql", "injection", "template injection",
|
|
721
|
+
"xpath", "csv injection", "log injection", "header injection",
|
|
722
|
+
},
|
|
723
|
+
"xss": {
|
|
724
|
+
"xss", "cross-site", "cross site", "script injection",
|
|
725
|
+
"dom xss", "reflected xss", "stored xss", "self-xss",
|
|
726
|
+
},
|
|
727
|
+
"sqli": {"sql injection", "sqli", "blind sql", "union select", "stacked query"},
|
|
728
|
+
"ssrf": {
|
|
729
|
+
"ssrf", "server-side request forgery",
|
|
730
|
+
"server side request forgery", "url fetch",
|
|
731
|
+
},
|
|
732
|
+
"xxe": {"xxe", "xml external", "xml entity", "xml parser", "dtd"},
|
|
733
|
+
"rce": {"rce", "remote code", "code execution", "code injection", "command injection", "os command", "exec", "passthru", "system("},
|
|
734
|
+
"path_traversal": {
|
|
735
|
+
"path traversal", "directory traversal", "path injection",
|
|
736
|
+
"file inclusion", "local file", "lfi", "rfi", "dot dot slash",
|
|
737
|
+
},
|
|
738
|
+
"open_redirect": {
|
|
739
|
+
"open redirect", "url redirect", "redirect injection",
|
|
740
|
+
"unvalidated redirect", "302 redirect", "location header",
|
|
741
|
+
},
|
|
742
|
+
# ── Authentication & Session ───────────────────────────────────
|
|
743
|
+
"authentication": {
|
|
744
|
+
"auth", "authentication", "login", "password", "oauth",
|
|
745
|
+
"session", "token", "jwt", "sso", "identity",
|
|
746
|
+
"authenticate", "mfa", "totp", "2fa", "biometric",
|
|
747
|
+
"credential stuffing", "brute force", "account lockout",
|
|
748
|
+
},
|
|
749
|
+
"session": {
|
|
750
|
+
"session", "cookie", "session fixation", "session hijack",
|
|
751
|
+
"session timeout", "session token", "httpOnly", "secure flag",
|
|
752
|
+
},
|
|
753
|
+
"authorization": {
|
|
754
|
+
"authorization", "privilege", "role", "permission", "rbac",
|
|
755
|
+
"abac", "access control", "idor", "broken function",
|
|
756
|
+
"privilege escalation", "elevation of privilege",
|
|
757
|
+
},
|
|
758
|
+
# ── Cryptography ──────────────────────────────────────────────
|
|
759
|
+
"cryptography": {
|
|
760
|
+
"crypto", "encryption", "cipher", "hash", "tls", "ssl",
|
|
761
|
+
"certificate", "cryptographic", "key management",
|
|
762
|
+
"key exchange", "digital signature", "hmac", "aes", "rsa",
|
|
763
|
+
"pbkdf2", "bcrypt", "argon2", "scrypt", "nonce", "iv",
|
|
764
|
+
"quantum", "post-quantum", "ecc", "diffie",
|
|
765
|
+
},
|
|
766
|
+
# ── Web & Frontend ────────────────────────────────────────────
|
|
767
|
+
"cors": {"cors", "cross-origin", "cross origin", "wildcard origin", "access-control-allow"},
|
|
768
|
+
"csrf": {"csrf", "xsrf", "cross-site request", "cross site request", "request forgery", "anti-forgery"},
|
|
769
|
+
"clickjacking": {"clickjack", "frame injection", "x-frame-options", "frame-ancestors"},
|
|
770
|
+
"security_headers": {
|
|
771
|
+
"security header", "content-security-policy", "csp",
|
|
772
|
+
"strict-transport", "hsts", "x-content-type", "x-xss-protection",
|
|
773
|
+
"permissions-policy", "referrer-policy", "feature-policy",
|
|
774
|
+
},
|
|
775
|
+
# ── Data & Privacy ────────────────────────────────────────────
|
|
776
|
+
"sensitive_data": {
|
|
777
|
+
"sensitive data", "pii", "personal information",
|
|
778
|
+
"secret exposure", "credential leakage", "data leak",
|
|
779
|
+
"data breach", "data exposure", "data classification",
|
|
780
|
+
},
|
|
781
|
+
"secrets": {
|
|
782
|
+
"secret", "hardcoded", "credential", "api key",
|
|
783
|
+
"token exposure", "password in code", "secret scanning",
|
|
784
|
+
"private key", "secret key", "connection string",
|
|
785
|
+
},
|
|
786
|
+
# ── Dependencies & Supply Chain ───────────────────────────────
|
|
787
|
+
"dependency": {
|
|
788
|
+
"dependency", "cve", "supply chain", "third party",
|
|
789
|
+
"vulnerable package", "sbom", "outdated", "unmaintained",
|
|
790
|
+
},
|
|
791
|
+
"supply_chain": {
|
|
792
|
+
"supply chain", "dependency confusion", "malicious package",
|
|
793
|
+
"typosquatting", "artifact signing", "provenance",
|
|
794
|
+
},
|
|
795
|
+
# ── Infrastructure & Config ───────────────────────────────────
|
|
796
|
+
"misconfiguration": {
|
|
797
|
+
"misconfig", "security header", "hardening",
|
|
798
|
+
"insecure default", "security config", "missing header",
|
|
799
|
+
"debug mode", "verbose error", "default credential",
|
|
800
|
+
},
|
|
801
|
+
# ── Secrets & Hardcoded Credentials ────────────────────────────
|
|
802
|
+
"hardcoded_secret": {
|
|
803
|
+
"hardcoded", "secret", "credential", "api key", "private key",
|
|
804
|
+
"password in code", "token exposure", "connection string",
|
|
805
|
+
"secret key", "auth token", "bearer token", "access key",
|
|
806
|
+
},
|
|
807
|
+
"hardcoded_key": {
|
|
808
|
+
"hardcoded key", "hardcoded secret", "hardcoded password",
|
|
809
|
+
"hardcoded credential", "hardcoded token",
|
|
810
|
+
},
|
|
811
|
+
# ── Authentication & Access ──────────────────────────────────
|
|
812
|
+
"auth_bypass": {
|
|
813
|
+
"authentication bypass", "auth bypass", "login bypass",
|
|
814
|
+
"without authentication", "no auth", "unauthenticated",
|
|
815
|
+
"missing auth", "missing authentication",
|
|
816
|
+
},
|
|
817
|
+
"missing_auth": {
|
|
818
|
+
"without authentication", "no auth", "unauthenticated",
|
|
819
|
+
"missing auth", "missing authentication", "missing authorization",
|
|
820
|
+
},
|
|
821
|
+
# ── Debug & Config ───────────────────────────────────────────
|
|
822
|
+
"debug_mode": {
|
|
823
|
+
"debug mode", "debug=true", "debug on", "verbose error",
|
|
824
|
+
"stack trace", "exposed error", "error details",
|
|
825
|
+
"traceback", "exception detail", "internal error",
|
|
826
|
+
},
|
|
827
|
+
"insecure_config": {
|
|
828
|
+
"insecure default", "default credential", "default password",
|
|
829
|
+
"insecure configuration", "security misconfiguration",
|
|
830
|
+
"missing security header", "open port", "exposed service",
|
|
831
|
+
},
|
|
832
|
+
# ── Type Safety & Quality ────────────────────────────────────
|
|
833
|
+
"type_safety": {
|
|
834
|
+
"type annotation", "type hint", "missing type",
|
|
835
|
+
"untyped", "any type", "type error", "type safety",
|
|
836
|
+
},
|
|
837
|
+
# ── Technical Debt ───────────────────────────────────────────
|
|
838
|
+
"technical_debt": {
|
|
839
|
+
"todo", "fixme", "hack", "workaround", "bug",
|
|
840
|
+
"technical debt", "deprecated", "legacy", "temporary",
|
|
841
|
+
},
|
|
842
|
+
"dos": {"dos", "denial of service", "rate limit", "resource exhaustion", "ddos", "throttle", "backlog"},
|
|
843
|
+
"runtime": {"runtime", "container", "kubernetes", "docker", "orchestration", "falco", "pod", "node"},
|
|
844
|
+
"network": {"network", "firewall", "dns", "tcp", "port", "egress", "ingress", "proxy", "vpn", "tls termination"},
|
|
845
|
+
# ── Cloud ─────────────────────────────────────────────────────
|
|
846
|
+
"cloud_aws": {"aws", "s3", "ec2", "lambda", "iam", "cloudtrail", "kms", "rds", "ecs", "fargate", "sqs", "sns"},
|
|
847
|
+
"cloud_azure": {"azure", "blob storage", "key vault", "active directory", "arm template", "devops"},
|
|
848
|
+
"cloud_gcp": {"gcp", "gce", "gcs", "bigquery", "cloud functions", "gke", "secret manager", "cloud run"},
|
|
849
|
+
# ── Container & Orchestration ──────────────────────────────────
|
|
850
|
+
"container": {
|
|
851
|
+
"container", "dockerfile", "image", "layer", "registry",
|
|
852
|
+
"privileged", "root user", "capabilities", "seccomp",
|
|
853
|
+
"apparmor", "selinux", "read-only fs",
|
|
854
|
+
},
|
|
855
|
+
"kubernetes": {
|
|
856
|
+
"kubernetes", "k8s", "pod", "deployment", "service",
|
|
857
|
+
"ingress", "rbac", "networkpolicy", "podsecurity",
|
|
858
|
+
"etcd", "apiserver", "admission controller", "helm",
|
|
859
|
+
},
|
|
860
|
+
# ── CI/CD & DevOps ────────────────────────────────────────────
|
|
861
|
+
"cicd": {
|
|
862
|
+
"ci/cd", "pipeline", "github actions", "gitlab ci",
|
|
863
|
+
"jenkins", "build", "deploy", "artifact", "workflow",
|
|
864
|
+
"runner", "secret scanning", "cache poisoning",
|
|
865
|
+
},
|
|
866
|
+
# ── API Security ──────────────────────────────────────────────
|
|
867
|
+
"api_security": {
|
|
868
|
+
"api", "rest", "graphql", "grpc", "webhook",
|
|
869
|
+
"rate limit", "pagination", "versioning", "content negotiation",
|
|
870
|
+
"idempotency", "hateoas", "swagger", "openapi",
|
|
871
|
+
},
|
|
872
|
+
# ── Database ──────────────────────────────────────────────────
|
|
873
|
+
"database": {
|
|
874
|
+
"database", "db", "mysql", "postgres", "postgresql",
|
|
875
|
+
"mongodb", "redis", "elasticsearch", "cassandra",
|
|
876
|
+
"sqlite", "oracle", "sql server", "stored procedure",
|
|
877
|
+
"query", "connection pool", "row level security",
|
|
878
|
+
},
|
|
879
|
+
# ── Mobile ────────────────────────────────────────────────────
|
|
880
|
+
"mobile": {
|
|
881
|
+
"mobile", "android", "ios", "swift", "kotlin",
|
|
882
|
+
"react native", "flutter", "xamarin", "ionic",
|
|
883
|
+
"keychain", "keystore", "deep link", "intent",
|
|
884
|
+
"webview", "biometric", "jailbreak", "root detection",
|
|
885
|
+
},
|
|
886
|
+
# ── Desktop ───────────────────────────────────────────────────
|
|
887
|
+
"desktop": {
|
|
888
|
+
"electron", "tauri", "nwjs", "desktop",
|
|
889
|
+
"browser extension", "chrome extension", "firefox extension",
|
|
890
|
+
},
|
|
891
|
+
# ── Languages & Frameworks ────────────────────────────────────
|
|
892
|
+
"python": {"python", "django", "flask", "fastapi", "pylint", "bandit", "pip", "pyproject"},
|
|
893
|
+
"javascript": {"javascript", "nodejs", "node.js", "express", "npm", "package.json", "nextjs", "nuxt", "remix", "astro", "qwik", "svelte"},
|
|
894
|
+
"java": {"java", "spring", "tomcat", "maven", "gradle", "jvm", "jndi", "jsp", "jackson"},
|
|
895
|
+
"dotnet": {".net", "c#", "asp.net", "blazor", "nuget", "razor", "viewstate", "entity framework"},
|
|
896
|
+
"go": {"golang", "goroutine", "net/http", "gin framework", "echo framework", "fiber framework"},
|
|
897
|
+
"rust": {"rust", "cargo", "crate", "unsafe", "rustc", "tokio", "serde"},
|
|
898
|
+
"ruby": {"ruby", "rails", "rubygems", "bundler", "erb", "devise", "activerecord"},
|
|
899
|
+
"php": {"php", "laravel", "symfony", "composer", "wordpres", "drupal", "twig", "blade"},
|
|
900
|
+
"c_cpp": {"c++", "c language", "gcc", "clang", "buffer overflow", "format string", "malloc", "free"},
|
|
901
|
+
# ── Compliance ────────────────────────────────────────────────
|
|
902
|
+
"compliance": {"compliance", "regulatory", "gdpr", "hipaa", "pci", "sox", "audit", "soc2", "iso27001"},
|
|
903
|
+
"privacy": {"privacy", "consent", "data retention", "anonymization", "pseudonymization", "cookie consent", "tracking", "fingerprinting"},
|
|
904
|
+
# ── Fraud & Business Logic ────────────────────────────────────
|
|
905
|
+
"fraud": {"fraud", "bot", "scraping", "account takeover", "credential stuffing", "payment fraud", "coupon abuse", "referral abuse"},
|
|
906
|
+
"business_logic": {"business logic", "price manipulation", "race condition", "workflow bypass", "amount tampering", "state confusion", "toctou"},
|
|
907
|
+
# ── Incident Response & Monitoring ─────────────────────────────
|
|
908
|
+
"incident": {"incident", "breach", "forensic", "containment", "recovery", "escalation", "alert"},
|
|
909
|
+
"monitoring": {"monitoring", "logging", "audit log", "siem", "detection", "alerting", "telemetry"},
|
|
910
|
+
# ── Threat Modeling ───────────────────────────────────────────
|
|
911
|
+
"threat_model": {"threat model", "stride", "attack surface", "abuse case", "risk assessment", "mitigation tracking"},
|
|
912
|
+
# ── IaC & Cloud Config ────────────────────────────────────────
|
|
913
|
+
"terraform": {"terraform", "tf state", "tfvars", "provider", "module", "hcl"},
|
|
914
|
+
"ansible": {"ansible", "playbook", "vault", "role", "galaxy", "inventory"},
|
|
915
|
+
"helm": {"helm", "chart", "values.yaml", "template", "release"},
|
|
916
|
+
# ── IoT & Edge ────────────────────────────────────────────────
|
|
917
|
+
"iot": {"iot", "mqtt", "coap", "embedded", "firmware", "sensor", "actuator", "gateway"},
|
|
918
|
+
"edge": {"edge", "cdn", "worker", "service worker", "cache api", "push notification"},
|
|
919
|
+
# ── AI/ML ─────────────────────────────────────────────────────
|
|
920
|
+
"ai_ml": {"artificial intelligence", "machine learning", "llm", "neural network", "model training", "inference", "prompt injection", "rag", "vector database", "embedding", "fine-tune", "fine-tuning", "transformer"},
|
|
921
|
+
# ── Web3 & Blockchain ─────────────────────────────────────────
|
|
922
|
+
"web3": {"web3", "smart contract", "blockchain", "ethereum", "defi", "nft", "token", "oracle", "flash loan", "mev"},
|
|
923
|
+
# ── Game Security ─────────────────────────────────────────────
|
|
924
|
+
"game": {"game", "cheat", "speed hack", "item duplication", "memory corruption", "anticheat"},
|
|
925
|
+
# ── Post-Quantum ──────────────────────────────────────────────
|
|
926
|
+
"quantum": {"quantum", "post-quantum", "lattice", "kyber", "dilithium", "nist pqc"},
|
|
927
|
+
# ── Zero Trust ────────────────────────────────────────────────
|
|
928
|
+
"zero_trust": {"zero trust", "never trust", "verify every", "microsegment", "least privilege"},
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
def _classify_domains(self, text: str) -> set[str]:
|
|
932
|
+
"""Classify text into security domains based on keyword sets."""
|
|
933
|
+
text_lower = text.lower().replace("_", " ").replace("-", " ")
|
|
934
|
+
matched = set()
|
|
935
|
+
for domain, kws in self._domain_keywords().items():
|
|
936
|
+
for kw in kws:
|
|
937
|
+
if kw in text_lower:
|
|
938
|
+
matched.add(domain)
|
|
939
|
+
break
|
|
940
|
+
return matched
|
|
941
|
+
|
|
942
|
+
def match_finding_to_controls(
|
|
943
|
+
self, finding_type: str, file_path: str, message: str
|
|
944
|
+
) -> list[DomainControl]:
|
|
945
|
+
"""Match a finding to domain controls using a pre-built keyword index.
|
|
946
|
+
|
|
947
|
+
O(k) per finding where k = number of matching keywords.
|
|
948
|
+
"""
|
|
949
|
+
self._load_all()
|
|
950
|
+
idx = self._build_keyword_index()
|
|
951
|
+
|
|
952
|
+
combined = f"{message} {file_path} {finding_type}"
|
|
953
|
+
finding_domains = self._classify_domains(combined)
|
|
954
|
+
if not finding_domains:
|
|
955
|
+
return []
|
|
956
|
+
|
|
957
|
+
# Collect candidate controls from the inverted index
|
|
958
|
+
candidates = {}
|
|
959
|
+
for kw_set in finding_domains:
|
|
960
|
+
for _domain_id, ctrl, domain in idx.get(kw_set, []):
|
|
961
|
+
key = ctrl.control_id
|
|
962
|
+
if key not in candidates:
|
|
963
|
+
candidates[key] = [0, set(), ctrl, domain]
|
|
964
|
+
candidates[key][0] += 1
|
|
965
|
+
candidates[key][1].add(kw_set)
|
|
966
|
+
|
|
967
|
+
# Score and filter — require message-specific relevance
|
|
968
|
+
scored = []
|
|
969
|
+
msg_lower = (message or "").lower()
|
|
970
|
+
for _key, entry in candidates.items():
|
|
971
|
+
overlap_count, kw_sets, ctrl, domain = entry
|
|
972
|
+
|
|
973
|
+
# Base score from keyword overlap
|
|
974
|
+
score = overlap_count
|
|
975
|
+
|
|
976
|
+
# Strong boost when finding message words appear in the control name
|
|
977
|
+
if ctrl.name:
|
|
978
|
+
ctrl_name_words = {w for w in ctrl.name.lower().split() if len(w) > 3}
|
|
979
|
+
msg_words = {w for w in msg_lower.split() if len(w) > 3}
|
|
980
|
+
name_overlap = ctrl_name_words & msg_words
|
|
981
|
+
score += len(name_overlap) * 3
|
|
982
|
+
|
|
983
|
+
# Boost for exact phrase matches in the message
|
|
984
|
+
if ctrl.name and ctrl.name.lower() in msg_lower:
|
|
985
|
+
score += 5
|
|
986
|
+
elif ctrl.description and ctrl.description.lower()[:40] in msg_lower:
|
|
987
|
+
score += 3
|
|
988
|
+
|
|
989
|
+
# Boost when file path context matches
|
|
990
|
+
if file_path:
|
|
991
|
+
domain_words = domain.domain_id.replace("-", " ").split()
|
|
992
|
+
if any(w in file_path.lower() for w in domain_words if len(w) > 3):
|
|
993
|
+
score += 2
|
|
994
|
+
|
|
995
|
+
# Require at least some message-specific signal
|
|
996
|
+
# (prevents "technical debt" from matching "secrets in code")
|
|
997
|
+
has_msg_signal = bool(name_overlap) or score >= 5
|
|
998
|
+
|
|
999
|
+
if score >= 3 and has_msg_signal:
|
|
1000
|
+
scored.append((score, ctrl))
|
|
1001
|
+
|
|
1002
|
+
scored.sort(key=lambda x: (-x[0], x[1].severity != "critical"))
|
|
1003
|
+
return [ctrl for _, ctrl in scored[:5]]
|
|
1004
|
+
|