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,1005 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Taint analysis and secret detection agents for Patchi.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from patchi.core.constants import is_offline
|
|
16
|
+
|
|
17
|
+
from ..agents.base import (
|
|
18
|
+
AgentGroup,
|
|
19
|
+
AgentInput,
|
|
20
|
+
AgentResult,
|
|
21
|
+
BaseAgent,
|
|
22
|
+
Severity,
|
|
23
|
+
make_finding,
|
|
24
|
+
register,
|
|
25
|
+
)
|
|
26
|
+
from ..brain.ast_utils import track_taint
|
|
27
|
+
from ..brain.languages import EXTENSION_MAP
|
|
28
|
+
|
|
29
|
+
# ── Shared subprocess runner ───────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_log = logging.getLogger("patchi.security.security_taint")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(cmd: list[str], cwd: Path, timeout: int = 120, env: dict | None = None) -> dict:
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
merged = {**os.environ, **(env or {})}
|
|
39
|
+
try:
|
|
40
|
+
proc = subprocess.run(
|
|
41
|
+
cmd,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
cwd=str(cwd),
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
env=merged,
|
|
47
|
+
)
|
|
48
|
+
return {
|
|
49
|
+
"returncode": proc.returncode,
|
|
50
|
+
"stdout": proc.stdout[:10000],
|
|
51
|
+
"stderr": proc.stderr[:3000],
|
|
52
|
+
"timed_out": False,
|
|
53
|
+
}
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
return {"returncode": -1, "stdout": "", "stderr": "", "timed_out": True}
|
|
56
|
+
except Exception as e:
|
|
57
|
+
return {"returncode": -1, "stdout": "", "stderr": str(e), "timed_out": False}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _call_ai(prompt: str, config: dict, max_tokens: int = 800) -> str:
|
|
61
|
+
"""Call configured AI. Returns "" if unavailable or offline."""
|
|
62
|
+
if is_offline():
|
|
63
|
+
return ""
|
|
64
|
+
ai = config.get("ai", {})
|
|
65
|
+
local = ai.get("local_model_name")
|
|
66
|
+
if local:
|
|
67
|
+
return _call_ollama(local, prompt, max_tokens)
|
|
68
|
+
for key_cfg in ai.get("keys", []):
|
|
69
|
+
if key_cfg.get("status") == "error":
|
|
70
|
+
continue
|
|
71
|
+
env_var = key_cfg.get("env_var", "")
|
|
72
|
+
api_key = os.environ.get(env_var, "")
|
|
73
|
+
if not api_key:
|
|
74
|
+
continue
|
|
75
|
+
result = _call_openai_compat(
|
|
76
|
+
api_key,
|
|
77
|
+
key_cfg.get("base_url", ""),
|
|
78
|
+
key_cfg.get("model", ""),
|
|
79
|
+
key_cfg.get("format", "openai"),
|
|
80
|
+
prompt,
|
|
81
|
+
max_tokens,
|
|
82
|
+
)
|
|
83
|
+
if result:
|
|
84
|
+
return result
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _call_ollama(model: str, prompt: str, max_tokens: int) -> str:
|
|
89
|
+
import urllib.request
|
|
90
|
+
|
|
91
|
+
from patchi.core.constants import OLLAMA_GENERATE_URL
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
payload = json.dumps(
|
|
95
|
+
{
|
|
96
|
+
"model": model,
|
|
97
|
+
"prompt": prompt,
|
|
98
|
+
"stream": False,
|
|
99
|
+
"options": {"num_predict": max_tokens},
|
|
100
|
+
}
|
|
101
|
+
).encode()
|
|
102
|
+
req = urllib.request.Request(
|
|
103
|
+
OLLAMA_GENERATE_URL,
|
|
104
|
+
data=payload,
|
|
105
|
+
headers={"Content-Type": "application/json"},
|
|
106
|
+
method="POST",
|
|
107
|
+
)
|
|
108
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
109
|
+
return json.loads(r.read()).get("response", "")
|
|
110
|
+
except Exception as e:
|
|
111
|
+
_log.warning("_call_ollama failed: %s", e)
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _call_openai_compat(api_key, base_url, model, fmt, prompt, max_tokens) -> str:
|
|
116
|
+
import urllib.request
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if fmt == "anthropic":
|
|
120
|
+
payload = json.dumps(
|
|
121
|
+
{
|
|
122
|
+
"model": model,
|
|
123
|
+
"max_tokens": max_tokens,
|
|
124
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
125
|
+
}
|
|
126
|
+
).encode()
|
|
127
|
+
headers = {
|
|
128
|
+
"x-api-key": api_key,
|
|
129
|
+
"anthropic-version": "2023-06-01",
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
}
|
|
132
|
+
url = f"{base_url}/messages"
|
|
133
|
+
else:
|
|
134
|
+
payload = json.dumps(
|
|
135
|
+
{
|
|
136
|
+
"model": model,
|
|
137
|
+
"max_tokens": max_tokens,
|
|
138
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
139
|
+
}
|
|
140
|
+
).encode()
|
|
141
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
142
|
+
url = f"{base_url}/chat/completions"
|
|
143
|
+
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
|
144
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
145
|
+
data = json.loads(r.read())
|
|
146
|
+
if fmt == "anthropic":
|
|
147
|
+
return data.get("content", [{}])[0].get("text", "")
|
|
148
|
+
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
_log.warning("_call_openai_compat failed: %s", e)
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
# 1. TaintAnalyzer
|
|
156
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
# Sink call names tracked structurally via tree-sitter AST (track_taint).
|
|
159
|
+
_TAINT_SINK_NAMES = {
|
|
160
|
+
"eval",
|
|
161
|
+
"exec",
|
|
162
|
+
"os.system",
|
|
163
|
+
"subprocess.run",
|
|
164
|
+
"subprocess.Popen",
|
|
165
|
+
"subprocess.call",
|
|
166
|
+
"execute",
|
|
167
|
+
"open",
|
|
168
|
+
"redirect",
|
|
169
|
+
"HttpResponseRedirect",
|
|
170
|
+
"render_template_string",
|
|
171
|
+
"Template",
|
|
172
|
+
"pickle.loads",
|
|
173
|
+
"yaml.load",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Maps a sink call name (or its leaf) to (sink_type, severity, cwe).
|
|
177
|
+
_TAINT_SINK_INFO: dict[str, tuple[str, Severity, str]] = {
|
|
178
|
+
"eval": ("code_injection", Severity.CRITICAL, "CWE-94"),
|
|
179
|
+
"exec": ("code_injection", Severity.CRITICAL, "CWE-94"),
|
|
180
|
+
"os.system": ("command_injection", Severity.CRITICAL, "CWE-78"),
|
|
181
|
+
"subprocess.run": ("command_injection", Severity.CRITICAL, "CWE-78"),
|
|
182
|
+
"subprocess.Popen": ("command_injection", Severity.CRITICAL, "CWE-78"),
|
|
183
|
+
"subprocess.call": ("command_injection", Severity.CRITICAL, "CWE-78"),
|
|
184
|
+
"execute": ("sql_injection", Severity.CRITICAL, "CWE-89"),
|
|
185
|
+
"open": ("path_traversal", Severity.HIGH, "CWE-22"),
|
|
186
|
+
"redirect": ("open_redirect", Severity.HIGH, "CWE-601"),
|
|
187
|
+
"HttpResponseRedirect": ("open_redirect", Severity.HIGH, "CWE-601"),
|
|
188
|
+
"render_template_string": ("template_injection", Severity.HIGH, "CWE-94"),
|
|
189
|
+
"Template": ("template_injection", Severity.HIGH, "CWE-94"),
|
|
190
|
+
"pickle.loads": ("deserialization", Severity.HIGH, "CWE-502"),
|
|
191
|
+
"yaml.load": ("deserialization", Severity.HIGH, "CWE-502"),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _taint_sink_info(name: str) -> tuple[str, Severity, str] | None:
|
|
196
|
+
info = _TAINT_SINK_INFO.get(name)
|
|
197
|
+
if info is not None:
|
|
198
|
+
return info
|
|
199
|
+
leaf = name.split(".")[-1]
|
|
200
|
+
return _TAINT_SINK_INFO.get(leaf)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@register
|
|
204
|
+
class TaintAnalyzer(BaseAgent):
|
|
205
|
+
"""
|
|
206
|
+
Data flow taint analysis — maps sources to sinks without sanitization.
|
|
207
|
+
|
|
208
|
+
Sources: HTTP params, query strings, headers, body, cookies, file uploads,
|
|
209
|
+
WebSocket messages, env vars.
|
|
210
|
+
Sinks: eval(), exec(), os.system(), subprocess, SQL string concat,
|
|
211
|
+
file path construction, HTTP redirects, template rendering,
|
|
212
|
+
deserialization calls.
|
|
213
|
+
|
|
214
|
+
Method:
|
|
215
|
+
1. Static: scan AST for source reads and sink calls, track assignments.
|
|
216
|
+
2. Build flow graph — flag source→sink paths with no sanitization.
|
|
217
|
+
3. AI call per flagged path — "Is this actually exploitable?"
|
|
218
|
+
(one call max per path, results cached in findings).
|
|
219
|
+
|
|
220
|
+
No AI = static-only. Still finds ~70% of real taint issues.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
name = "TaintAnalyzer"
|
|
224
|
+
group = AgentGroup.SECURITY
|
|
225
|
+
timeout = 120
|
|
226
|
+
|
|
227
|
+
# Sources that introduce untrusted data
|
|
228
|
+
# Sources that introduce untrusted data - matched against parsed member
|
|
229
|
+
# paths and call sites (member_paths / find_calls), never raw text.
|
|
230
|
+
_REQUEST_ATTRS = {"args", "form", "json", "data", "files", "cookies", "headers", "values"}
|
|
231
|
+
_REQ_ATTRS = {"query", "body", "params", "headers", "cookies"}
|
|
232
|
+
_EVENT_ATTRS = {"queryStringParameters", "body", "pathParameters"}
|
|
233
|
+
_PHP_VARS = ("$_GET", "$_POST", "$_REQUEST", "$_COOKIE", "$_SERVER")
|
|
234
|
+
_SOURCE_CALLS = {"os.environ.get", "websocket.recv", "ws.receive"}
|
|
235
|
+
_SOURCE_CALL_TYPES = {
|
|
236
|
+
"os.environ.get": "env_var",
|
|
237
|
+
"websocket.recv": "websocket",
|
|
238
|
+
"ws.receive": "websocket",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Sink call names (full dotted or leaf) shared with the track_taint pass.
|
|
242
|
+
_SINK_CALL_NAMES = {
|
|
243
|
+
"eval", "exec", "os.system", "subprocess.run", "subprocess.Popen",
|
|
244
|
+
"subprocess.call", "execute", "open", "redirect", "HttpResponseRedirect",
|
|
245
|
+
"render_template_string", "Template", "pickle.loads", "pickle.load",
|
|
246
|
+
"yaml.load", "document.write", "html",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _member_source_kind(dotted: str) -> str | None:
|
|
251
|
+
head, _, rest = dotted.partition(".")
|
|
252
|
+
attr = rest.split(".")[0].split("[")[0]
|
|
253
|
+
if head.lower() == "request" and attr in TaintAnalyzer._REQUEST_ATTRS:
|
|
254
|
+
return "http_param"
|
|
255
|
+
if head.lower() == "req" and attr in TaintAnalyzer._REQ_ATTRS:
|
|
256
|
+
return "http_param"
|
|
257
|
+
if head.lower() == "event" and attr in TaintAnalyzer._EVENT_ATTRS:
|
|
258
|
+
return "http_param"
|
|
259
|
+
if any(dotted.startswith(v) for v in TaintAnalyzer._PHP_VARS):
|
|
260
|
+
return "http_param"
|
|
261
|
+
low = dotted.lower()
|
|
262
|
+
if "flask.request" in low:
|
|
263
|
+
return "http_framework"
|
|
264
|
+
if "fastapi" in low and "request" in low:
|
|
265
|
+
return "http_framework"
|
|
266
|
+
if "django" in low and "request" in low:
|
|
267
|
+
return "http_framework"
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
def _structural_sources(self, content: str, lang) -> list[dict]:
|
|
271
|
+
"""Source reads as (line, type, code) from parsed member paths + calls."""
|
|
272
|
+
from patchi.core.brain.ast_utils import find_calls
|
|
273
|
+
from patchi.core.brain.code_query import member_paths
|
|
274
|
+
|
|
275
|
+
out: list[dict] = []
|
|
276
|
+
for dotted, line in member_paths(content, lang):
|
|
277
|
+
kind = self._member_source_kind(dotted)
|
|
278
|
+
if kind is not None:
|
|
279
|
+
out.append({"line": line, "type": kind, "code": dotted[:100]})
|
|
280
|
+
for call in find_calls(content, lang, set(self._SOURCE_CALLS)):
|
|
281
|
+
name = str(call.get("name", ""))
|
|
282
|
+
kind = self._SOURCE_CALL_TYPES.get(name)
|
|
283
|
+
if kind:
|
|
284
|
+
line_no = int(call.get("line", 0) or 0)
|
|
285
|
+
out.append({"line": line_no, "type": kind, "code": name[:100]})
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
def _structural_sinks(self, content: str, lang) -> list[dict]:
|
|
289
|
+
"""Sink sites as (line, sink_type, severity, cwe) from parsed calls."""
|
|
290
|
+
from patchi.core.brain.ast_utils import find_assignments, find_calls
|
|
291
|
+
from patchi.core.brain.code_query import calls_with_dynamic_arg
|
|
292
|
+
|
|
293
|
+
out: list[dict] = []
|
|
294
|
+
try:
|
|
295
|
+
calls = find_calls(content, lang, set(self._SINK_CALL_NAMES))
|
|
296
|
+
except Exception:
|
|
297
|
+
calls = []
|
|
298
|
+
dynamic_lines = set(calls_with_dynamic_arg(content, lang, {"execute", "open"}))
|
|
299
|
+
for call in calls:
|
|
300
|
+
name = str(call.get("name", ""))
|
|
301
|
+
leaf = name.split(".")[-1]
|
|
302
|
+
line = int(call.get("line", 0) or 0)
|
|
303
|
+
if leaf in ("eval", "exec"):
|
|
304
|
+
out.append((line, "code_injection", Severity.CRITICAL, "CWE-94"))
|
|
305
|
+
elif name == "os.system" or name.startswith("subprocess."):
|
|
306
|
+
out.append((line, "command_injection", Severity.CRITICAL, "CWE-78"))
|
|
307
|
+
elif leaf == "execute":
|
|
308
|
+
if line in dynamic_lines:
|
|
309
|
+
out.append((line, "sql_injection", Severity.CRITICAL, "CWE-89"))
|
|
310
|
+
elif leaf == "open" or "pathlib" in name:
|
|
311
|
+
if line in dynamic_lines:
|
|
312
|
+
out.append((line, "path_traversal", Severity.HIGH, "CWE-22"))
|
|
313
|
+
elif leaf in ("redirect", "HttpResponseRedirect"):
|
|
314
|
+
out.append((line, "open_redirect", Severity.HIGH, "CWE-601"))
|
|
315
|
+
elif leaf in ("render_template_string", "Template"):
|
|
316
|
+
out.append((line, "template_injection", Severity.HIGH, "CWE-94"))
|
|
317
|
+
elif name in ("pickle.loads", "pickle.load", "yaml.load"):
|
|
318
|
+
out.append((line, "deserialization", Severity.HIGH, "CWE-502"))
|
|
319
|
+
elif name == "document.write":
|
|
320
|
+
out.append((line, "xss", Severity.HIGH, "CWE-79"))
|
|
321
|
+
elif leaf == "html":
|
|
322
|
+
if line in dynamic_lines:
|
|
323
|
+
out.append((line, "xss", Severity.HIGH, "CWE-79"))
|
|
324
|
+
# innerHTML assignment targets (parsed, not text search)
|
|
325
|
+
try:
|
|
326
|
+
assignments = find_assignments(content, lang)
|
|
327
|
+
except Exception:
|
|
328
|
+
assignments = []
|
|
329
|
+
for assignment in assignments:
|
|
330
|
+
target = str(assignment.get("target", ""))
|
|
331
|
+
if target.endswith(".innerHTML"):
|
|
332
|
+
out.append((int(assignment.get("line", 0) or 0), "xss", Severity.HIGH, "CWE-79"))
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
def _run(self, inp: AgentInput, result: AgentResult) -> None:
|
|
336
|
+
from patchi.core.brain.scanner import FileScanner
|
|
337
|
+
|
|
338
|
+
root = inp.root
|
|
339
|
+
paths = FileScanner(root).discover()
|
|
340
|
+
|
|
341
|
+
total_paths = 0
|
|
342
|
+
seen: set[tuple] = set() # dedupe keys (line, sink_type)
|
|
343
|
+
|
|
344
|
+
for path in paths:
|
|
345
|
+
ext = path.suffix.lower()
|
|
346
|
+
lang = EXTENSION_MAP.get(ext)
|
|
347
|
+
if lang is None:
|
|
348
|
+
continue
|
|
349
|
+
try:
|
|
350
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
351
|
+
except OSError:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
rel = path.relative_to(root).as_posix()
|
|
355
|
+
|
|
356
|
+
# ── AST-based taint tracking (source → sink argument flow) ──────
|
|
357
|
+
taint_results = track_taint(src, lang, _TAINT_SINK_NAMES)
|
|
358
|
+
for tr in taint_results:
|
|
359
|
+
info = _taint_sink_info(tr["name"])
|
|
360
|
+
if info is None:
|
|
361
|
+
continue
|
|
362
|
+
sink_type, severity, cwe = info
|
|
363
|
+
line = tr.get("line", 0)
|
|
364
|
+
key = (line, sink_type)
|
|
365
|
+
if key in seen:
|
|
366
|
+
continue
|
|
367
|
+
seen.add(key)
|
|
368
|
+
source_code = (tr.get("tainted_via") or [""])[0][:100]
|
|
369
|
+
ai_confirmed = self._confirm_with_ai(
|
|
370
|
+
rel,
|
|
371
|
+
{"line": line, "type": "source", "code": source_code},
|
|
372
|
+
{"line": line, "code": tr["full_text"].strip()[:100], "type": sink_type},
|
|
373
|
+
src,
|
|
374
|
+
inp.config,
|
|
375
|
+
)
|
|
376
|
+
if ai_confirmed is False:
|
|
377
|
+
continue
|
|
378
|
+
result.add_finding(
|
|
379
|
+
make_finding(
|
|
380
|
+
agent=self.name,
|
|
381
|
+
finding_type=f"taint_{sink_type}",
|
|
382
|
+
severity=severity,
|
|
383
|
+
file=rel,
|
|
384
|
+
line=line,
|
|
385
|
+
message=(
|
|
386
|
+
f"Potential {sink_type.replace('_', ' ')}: "
|
|
387
|
+
f"untrusted input reaches {sink_type} sink."
|
|
388
|
+
),
|
|
389
|
+
code_snippet=tr["full_text"].strip()[:120],
|
|
390
|
+
detail=f"Tainted via: {', '.join(tr.get('tainted_via') or [])[:80]}",
|
|
391
|
+
suggestion=f"Validate and sanitize input before passing to {sink_type} sink.",
|
|
392
|
+
cwe=cwe,
|
|
393
|
+
fix_agent="SecurityFixer",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Structural proximity pass (parsed sources/sinks, 30-line window)
|
|
398
|
+
sources_in_file = self._structural_sources(src, lang)
|
|
399
|
+
|
|
400
|
+
for line, sink_type, severity, cwe in self._structural_sinks(src, lang):
|
|
401
|
+
nearby_sources = [s for s in sources_in_file if abs(s["line"] - line) <= 30]
|
|
402
|
+
if not nearby_sources:
|
|
403
|
+
continue
|
|
404
|
+
key = (line, sink_type)
|
|
405
|
+
if key in seen:
|
|
406
|
+
continue
|
|
407
|
+
seen.add(key)
|
|
408
|
+
source = nearby_sources[0]
|
|
409
|
+
ai_confirmed = self._confirm_with_ai(
|
|
410
|
+
rel,
|
|
411
|
+
source,
|
|
412
|
+
{"line": line, "code": source["code"], "type": sink_type},
|
|
413
|
+
src,
|
|
414
|
+
inp.config,
|
|
415
|
+
)
|
|
416
|
+
if ai_confirmed is False:
|
|
417
|
+
continue
|
|
418
|
+
result.add_finding(
|
|
419
|
+
make_finding(
|
|
420
|
+
agent=self.name,
|
|
421
|
+
finding_type=f"taint_{sink_type}",
|
|
422
|
+
severity=severity,
|
|
423
|
+
file=rel,
|
|
424
|
+
line=line,
|
|
425
|
+
message=(
|
|
426
|
+
f"Potential {sink_type.replace('_', ' ')}: "
|
|
427
|
+
f"user input from {source['type']} reaches {sink_type} sink."
|
|
428
|
+
),
|
|
429
|
+
code_snippet=source["code"][:120],
|
|
430
|
+
detail=f"Source at line {source['line']}: {source['code'][:80]}",
|
|
431
|
+
suggestion=(
|
|
432
|
+
f"Validate and sanitize input before passing to {sink_type} sink."
|
|
433
|
+
),
|
|
434
|
+
cwe=cwe,
|
|
435
|
+
fix_agent="SecurityFixer",
|
|
436
|
+
ai_confirmed=ai_confirmed,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if taint_results or sources_in_file:
|
|
441
|
+
total_paths += 1
|
|
442
|
+
result.files_scanned += 1
|
|
443
|
+
|
|
444
|
+
result.data["total_paths_checked"] = total_paths
|
|
445
|
+
result.data["taint_findings"] = result.finding_count
|
|
446
|
+
|
|
447
|
+
def _confirm_with_ai(
|
|
448
|
+
self,
|
|
449
|
+
file_path: str,
|
|
450
|
+
source: dict,
|
|
451
|
+
sink: dict,
|
|
452
|
+
file_content: str,
|
|
453
|
+
config: dict,
|
|
454
|
+
) -> bool | None:
|
|
455
|
+
"""Ask AI if this taint path is actually exploitable. Returns True/False/None."""
|
|
456
|
+
# Extract relevant context (source line ± 15 lines)
|
|
457
|
+
lines = file_content.splitlines()
|
|
458
|
+
start = max(0, min(source["line"], sink["line"]) - 15)
|
|
459
|
+
end = min(len(lines), max(source["line"], sink["line"]) + 15)
|
|
460
|
+
context = "\n".join(lines[start:end])
|
|
461
|
+
|
|
462
|
+
prompt = (
|
|
463
|
+
f"Security analysis: Is this code path actually exploitable?\n"
|
|
464
|
+
f"File: {file_path}\n"
|
|
465
|
+
f"Source (user input): line {source['line']} — {source['code']}\n"
|
|
466
|
+
f"Sink: line {sink['line']} — {sink['code']}\n"
|
|
467
|
+
f"Code context:\n```\n{context[:800]}\n```\n"
|
|
468
|
+
f"Answer only: YES (exploitable) or NO (not exploitable) or UNCERTAIN."
|
|
469
|
+
)
|
|
470
|
+
from patchi.core.security import security_agents as compat
|
|
471
|
+
|
|
472
|
+
response = compat._call_ai(prompt, config, max_tokens=10)
|
|
473
|
+
if not response:
|
|
474
|
+
return None # No AI — include as uncertain
|
|
475
|
+
response_upper = response.strip().upper()
|
|
476
|
+
if "YES" in response_upper:
|
|
477
|
+
return True
|
|
478
|
+
if "NO" in response_upper:
|
|
479
|
+
return False
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
def _cwe(self, sink_type: str) -> str:
|
|
483
|
+
return {
|
|
484
|
+
"code_injection": "CWE-94",
|
|
485
|
+
"command_injection": "CWE-78",
|
|
486
|
+
"sql_injection": "CWE-89",
|
|
487
|
+
"path_traversal": "CWE-22",
|
|
488
|
+
"open_redirect": "CWE-601",
|
|
489
|
+
"template_injection": "CWE-94",
|
|
490
|
+
"deserialization": "CWE-502",
|
|
491
|
+
"xss": "CWE-79",
|
|
492
|
+
}.get(sink_type, "CWE-20")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
496
|
+
# 2. SecretScanner
|
|
497
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@register
|
|
501
|
+
class SecretScanner(BaseAgent):
|
|
502
|
+
"""
|
|
503
|
+
Secret and credential detection.
|
|
504
|
+
|
|
505
|
+
Primary: Gitleaks (MIT) — run as subprocess, parse JSON output.
|
|
506
|
+
Fallback: pattern-based regex scan when Gitleaks not installed.
|
|
507
|
+
|
|
508
|
+
DO NOT use TruffleHog in hosted SaaS mode (AGPL-3.0 — source disclosure required).
|
|
509
|
+
Gitleaks is the correct tool here.
|
|
510
|
+
|
|
511
|
+
Never stores or logs the actual secret value — only file, line, type.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
name = "SecretScanner"
|
|
515
|
+
group = AgentGroup.SECURITY
|
|
516
|
+
timeout = 120
|
|
517
|
+
|
|
518
|
+
# -- Regex-free fallback detection (AST + entropy + provider prefixes) --
|
|
519
|
+
# Known token formats are matched as EXACT PREFIXES, not regex. Anything
|
|
520
|
+
# without a known prefix must clear a Shannon-entropy + charset-mix bar,
|
|
521
|
+
# and usually also sit in a credential-named assignment, to be flagged.
|
|
522
|
+
|
|
523
|
+
_PROVIDER_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
524
|
+
("AKIA", "aws_access_key"),
|
|
525
|
+
("sk-ant-", "anthropic_key"),
|
|
526
|
+
("sk-proj-", "openai_project_key"),
|
|
527
|
+
("ghp_", "github_pat"),
|
|
528
|
+
("gho_", "github_oauth"),
|
|
529
|
+
("ghs_", "github_app_secret"),
|
|
530
|
+
("ghr_", "github_refresh_token"),
|
|
531
|
+
("github_pat_", "github_fine_grained_pat"),
|
|
532
|
+
("xoxb-", "slack_bot_token"),
|
|
533
|
+
("xoxp-", "slack_user_token"),
|
|
534
|
+
("xoxa-", "slack_workspace_token"),
|
|
535
|
+
("xoxs-", "slack_session_token"),
|
|
536
|
+
("AIza", "google_api_key"),
|
|
537
|
+
("SG.", "sendgrid_key"),
|
|
538
|
+
("sk_live_", "stripe_live_key"),
|
|
539
|
+
("pk_live_", "stripe_publishable_live_key"),
|
|
540
|
+
("rk_live_", "stripe_restricted_live_key"),
|
|
541
|
+
("glpat-", "gitlab_pat"),
|
|
542
|
+
("dop_v1_", "digitalocean_pat"),
|
|
543
|
+
("shpat_", "shopify_pat"),
|
|
544
|
+
("npm_", "npm_token"),
|
|
545
|
+
("pypi-", "pypi_token"),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
_CRED_NAME_TERMS: frozenset = frozenset(
|
|
549
|
+
{
|
|
550
|
+
"api_key",
|
|
551
|
+
"apikey",
|
|
552
|
+
"secret",
|
|
553
|
+
"token",
|
|
554
|
+
"password",
|
|
555
|
+
"passwd",
|
|
556
|
+
"pwd",
|
|
557
|
+
"credential",
|
|
558
|
+
"private_key",
|
|
559
|
+
"access_key",
|
|
560
|
+
"auth_key",
|
|
561
|
+
"client_secret",
|
|
562
|
+
"signing_key",
|
|
563
|
+
"encryption_key",
|
|
564
|
+
"session_key",
|
|
565
|
+
"webhook_secret",
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
_PLACEHOLDER_MARKERS: tuple[str, ...] = (
|
|
570
|
+
"changeme",
|
|
571
|
+
"change_me",
|
|
572
|
+
"<your",
|
|
573
|
+
"${",
|
|
574
|
+
"{{",
|
|
575
|
+
"%(",
|
|
576
|
+
"os.environ",
|
|
577
|
+
"getenv",
|
|
578
|
+
"environ[",
|
|
579
|
+
"environ.get",
|
|
580
|
+
"example",
|
|
581
|
+
"placeholder",
|
|
582
|
+
"xxx",
|
|
583
|
+
"...",
|
|
584
|
+
"todo",
|
|
585
|
+
"dummy",
|
|
586
|
+
"sample_",
|
|
587
|
+
"your-",
|
|
588
|
+
"-here",
|
|
589
|
+
"insert_",
|
|
590
|
+
"redacted",
|
|
591
|
+
"[masked]",
|
|
592
|
+
"not_a_real",
|
|
593
|
+
"fake_",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
_TEXT_SUFFIXES = {
|
|
597
|
+
".py",
|
|
598
|
+
".js",
|
|
599
|
+
".ts",
|
|
600
|
+
".jsx",
|
|
601
|
+
".tsx",
|
|
602
|
+
".go",
|
|
603
|
+
".rs",
|
|
604
|
+
".java",
|
|
605
|
+
".kt",
|
|
606
|
+
".rb",
|
|
607
|
+
".php",
|
|
608
|
+
".swift",
|
|
609
|
+
".dart",
|
|
610
|
+
".cs",
|
|
611
|
+
".c",
|
|
612
|
+
".cpp",
|
|
613
|
+
".h",
|
|
614
|
+
".yaml",
|
|
615
|
+
".yml",
|
|
616
|
+
".json",
|
|
617
|
+
".toml",
|
|
618
|
+
".ini",
|
|
619
|
+
".cfg",
|
|
620
|
+
".conf",
|
|
621
|
+
".properties",
|
|
622
|
+
".xml",
|
|
623
|
+
".envrc",
|
|
624
|
+
".sh",
|
|
625
|
+
".bash",
|
|
626
|
+
".ps1",
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_SKIP_FILES = {
|
|
630
|
+
"package-lock.json",
|
|
631
|
+
"yarn.lock",
|
|
632
|
+
"pnpm-lock.yaml",
|
|
633
|
+
"CHANGELOG.md",
|
|
634
|
+
"LICENSE",
|
|
635
|
+
".gitignore",
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
def _run(self, inp: AgentInput, result: AgentResult) -> None:
|
|
639
|
+
root = inp.root
|
|
640
|
+
|
|
641
|
+
if shutil.which("gitleaks"):
|
|
642
|
+
self._run_gitleaks(root, result)
|
|
643
|
+
else:
|
|
644
|
+
self._run_fallback(root, result)
|
|
645
|
+
|
|
646
|
+
result.data["secret_count"] = result.finding_count
|
|
647
|
+
result.data["tool"] = "gitleaks" if shutil.which("gitleaks") else "regex_fallback"
|
|
648
|
+
|
|
649
|
+
def _run_gitleaks(self, root: Path, result: AgentResult) -> None:
|
|
650
|
+
"""Run gitleaks detect, parse JSON report."""
|
|
651
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
|
|
652
|
+
report_path = tf.name
|
|
653
|
+
|
|
654
|
+
cmd = [
|
|
655
|
+
"gitleaks",
|
|
656
|
+
"detect",
|
|
657
|
+
"--source",
|
|
658
|
+
str(root),
|
|
659
|
+
"--report-format",
|
|
660
|
+
"json",
|
|
661
|
+
"--report-path",
|
|
662
|
+
report_path,
|
|
663
|
+
"--no-git", # scan files without requiring git history
|
|
664
|
+
"--exit-code",
|
|
665
|
+
"0", # don't fail on finds — we handle them
|
|
666
|
+
]
|
|
667
|
+
_run(cmd, root, timeout=self.timeout - 10)
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
findings = json.loads(Path(report_path).read_text())
|
|
671
|
+
if not isinstance(findings, list):
|
|
672
|
+
findings = []
|
|
673
|
+
except Exception as e:
|
|
674
|
+
_log.warning("SecretScanner._run_gitleaks failed: %s", e)
|
|
675
|
+
findings = []
|
|
676
|
+
finally:
|
|
677
|
+
try:
|
|
678
|
+
os.unlink(report_path)
|
|
679
|
+
except Exception as e:
|
|
680
|
+
_log.warning("SecretScanner._run_gitleaks failed: %s", e)
|
|
681
|
+
|
|
682
|
+
for f in findings:
|
|
683
|
+
file_path = f.get("File", "")
|
|
684
|
+
line = f.get("StartLine", 0)
|
|
685
|
+
rule_id = f.get("RuleID", "unknown")
|
|
686
|
+
# description never contains the secret value — only metadata
|
|
687
|
+
result.add_finding(
|
|
688
|
+
make_finding(
|
|
689
|
+
agent=self.name,
|
|
690
|
+
finding_type="hardcoded_secret",
|
|
691
|
+
severity=Severity.CRITICAL,
|
|
692
|
+
file=file_path,
|
|
693
|
+
line=line,
|
|
694
|
+
message=f"Secret detected: {rule_id.replace('-', ' ')}",
|
|
695
|
+
detail=f"Match at {file_path}:{line}. Rule: {rule_id}.",
|
|
696
|
+
suggestion="Remove the secret from source. Use environment variables instead.",
|
|
697
|
+
cwe="CWE-312",
|
|
698
|
+
fix_agent="EnvFixer",
|
|
699
|
+
rule_id=rule_id,
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
result.files_scanned += 1
|
|
703
|
+
|
|
704
|
+
def _run_fallback(self, root: Path, result: AgentResult) -> None:
|
|
705
|
+
"""Regex-free fallback: AST string literals + entropy + provider prefixes."""
|
|
706
|
+
from patchi.core.brain.scanner import FileScanner
|
|
707
|
+
|
|
708
|
+
for path in FileScanner(root).discover():
|
|
709
|
+
if path.suffix.lower() not in self._TEXT_SUFFIXES:
|
|
710
|
+
continue
|
|
711
|
+
if path.name in self._SKIP_FILES:
|
|
712
|
+
continue
|
|
713
|
+
if path.name.startswith(".env"):
|
|
714
|
+
continue # .env files expected to have values — SideFileScanner handles
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
718
|
+
rel = path.relative_to(root).as_posix()
|
|
719
|
+
except OSError:
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
result.files_scanned += 1
|
|
723
|
+
|
|
724
|
+
if path.suffix.lower() == ".py":
|
|
725
|
+
candidates = extract_py_string_candidates(src)
|
|
726
|
+
else:
|
|
727
|
+
candidates = extract_quoted_candidates(src)
|
|
728
|
+
|
|
729
|
+
seen: set[tuple[int, str]] = set()
|
|
730
|
+
for cand in candidates:
|
|
731
|
+
key = (cand.line, cand.value)
|
|
732
|
+
if key in seen:
|
|
733
|
+
continue
|
|
734
|
+
seen.add(key)
|
|
735
|
+
|
|
736
|
+
verdict, label, severity = classify_secret(cand.name, cand.value, self)
|
|
737
|
+
if verdict is None:
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
redacted = redact(cand.value)
|
|
741
|
+
result.add_finding(
|
|
742
|
+
make_finding(
|
|
743
|
+
agent=self.name,
|
|
744
|
+
finding_type="hardcoded_secret",
|
|
745
|
+
severity=severity,
|
|
746
|
+
file=rel,
|
|
747
|
+
line=cand.line,
|
|
748
|
+
message=f"Potential {label.replace('_', ' ')} ({verdict}) in source code.",
|
|
749
|
+
code_snippet=redacted,
|
|
750
|
+
suggestion="Move to environment variable. Never commit secrets.",
|
|
751
|
+
cwe="CWE-312",
|
|
752
|
+
fix_agent="EnvFixer",
|
|
753
|
+
pattern_type=label,
|
|
754
|
+
detection=verdict,
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# ── Regex-free classification helpers (pure string/AST analysis) ────────
|
|
759
|
+
|
|
760
|
+
def _match_provider_prefix(self, value: str) -> tuple[str, str] | None:
|
|
761
|
+
lowered = value
|
|
762
|
+
for prefix, label in self._PROVIDER_PREFIXES:
|
|
763
|
+
if lowered.startswith(prefix):
|
|
764
|
+
return prefix, label
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
def _is_placeholder(self, value: str) -> bool:
|
|
768
|
+
low = value.lower()
|
|
769
|
+
for marker in self._PLACEHOLDER_MARKERS:
|
|
770
|
+
if marker in low:
|
|
771
|
+
return True
|
|
772
|
+
# All same character ("AAAA...", "1111...") is never a real secret
|
|
773
|
+
if len(set(value)) <= 2 and len(value) >= 12:
|
|
774
|
+
return True
|
|
775
|
+
return False
|
|
776
|
+
|
|
777
|
+
def _is_cred_name(self, name: str) -> bool:
|
|
778
|
+
if not name:
|
|
779
|
+
return False
|
|
780
|
+
normalized = name.lower()
|
|
781
|
+
for sep in ("-", ".", " "):
|
|
782
|
+
normalized = normalized.replace(sep, "_")
|
|
783
|
+
terms = set(normalized.split("_"))
|
|
784
|
+
if not terms.isdisjoint(self._CRED_NAME_TERMS):
|
|
785
|
+
return True
|
|
786
|
+
# Compound forms: apikey, authtoken, clientsecret…
|
|
787
|
+
joined = normalized.replace("_", "")
|
|
788
|
+
return any(
|
|
789
|
+
term.replace("_", "") in joined
|
|
790
|
+
for term in self._CRED_NAME_TERMS
|
|
791
|
+
if "_" in term or len(term) >= 5
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def shannon_entropy(value: str) -> float:
|
|
796
|
+
"""Shannon entropy in bits per character. 0 = single repeated char."""
|
|
797
|
+
import math
|
|
798
|
+
|
|
799
|
+
if not value:
|
|
800
|
+
return 0.0
|
|
801
|
+
counts: dict[str, int] = {}
|
|
802
|
+
for ch in value:
|
|
803
|
+
counts[ch] = counts.get(ch, 0) + 1
|
|
804
|
+
total = float(len(value))
|
|
805
|
+
entropy = 0.0
|
|
806
|
+
for n in counts.values():
|
|
807
|
+
p = n / total
|
|
808
|
+
entropy -= p * math.log2(p)
|
|
809
|
+
return round(entropy, 3)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def charset_mix(value: str) -> int:
|
|
813
|
+
"""How many character classes are present: upper, lower, digit, symbol."""
|
|
814
|
+
has_upper = has_lower = has_digit = has_symbol = False
|
|
815
|
+
for ch in value:
|
|
816
|
+
if ch.isupper():
|
|
817
|
+
has_upper = True
|
|
818
|
+
elif ch.islower():
|
|
819
|
+
has_lower = True
|
|
820
|
+
elif ch.isdigit():
|
|
821
|
+
has_digit = True
|
|
822
|
+
else:
|
|
823
|
+
has_symbol = True
|
|
824
|
+
return sum((has_upper, has_lower, has_digit, has_symbol))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def classify_secret(
|
|
828
|
+
name: str, value: str, scanner: SecretScanner
|
|
829
|
+
) -> tuple[str | None, str, Severity]:
|
|
830
|
+
"""Verdict for one candidate literal.
|
|
831
|
+
|
|
832
|
+
Returns (verdict, label, severity); verdict None means clean.
|
|
833
|
+
Order matters: known provider prefixes beat placeholder markers (AWS's own
|
|
834
|
+
documented example keys contain the word "example"), then credential-name
|
|
835
|
+
assignments, then generic blob detection.
|
|
836
|
+
verdict ∈ {"provider-prefix", "credential-assignment", "high-entropy"}
|
|
837
|
+
"""
|
|
838
|
+
if not value or len(value) < 8:
|
|
839
|
+
return None, "", Severity.INFO
|
|
840
|
+
|
|
841
|
+
# Known formats win unconditionally — even when they contain words like
|
|
842
|
+
# "example" (AWS docs literally publish AKIAIOSFODNN7EXAMPLE).
|
|
843
|
+
provider = scanner._match_provider_prefix(value)
|
|
844
|
+
if provider:
|
|
845
|
+
return "provider-prefix", provider[1], Severity.CRITICAL
|
|
846
|
+
|
|
847
|
+
entropy = shannon_entropy(value)
|
|
848
|
+
mix = charset_mix(value)
|
|
849
|
+
|
|
850
|
+
# Placeholder markers ("changeme", "<your-...>") only veto weak evidence.
|
|
851
|
+
# A 39-char 3-class string containing the letters "example" is a real
|
|
852
|
+
# credential pattern, not documentation.
|
|
853
|
+
if not (len(value) >= 20 and entropy >= 3.5) and scanner._is_placeholder(value):
|
|
854
|
+
return None, "", Severity.INFO
|
|
855
|
+
|
|
856
|
+
cred_name = scanner._is_cred_name(name)
|
|
857
|
+
|
|
858
|
+
strong = len(value) >= 20 and entropy >= 3.5 and mix >= 3
|
|
859
|
+
|
|
860
|
+
if cred_name and strong:
|
|
861
|
+
return "credential-assignment", name or "credential", Severity.CRITICAL
|
|
862
|
+
if cred_name and len(value) >= 12 and entropy >= 3.0:
|
|
863
|
+
return "credential-assignment", name or "credential", Severity.HIGH
|
|
864
|
+
|
|
865
|
+
# Token blobs: hex / base64url runs are usually 2-3 char classes by
|
|
866
|
+
# nature — judge on LENGTH + entropy, not class count.
|
|
867
|
+
blob_like = (
|
|
868
|
+
len(value) >= 32
|
|
869
|
+
and entropy >= 3.0
|
|
870
|
+
and all(ch.isalnum() or ch in "+/=_-" for ch in value)
|
|
871
|
+
and any(ch.isdigit() for ch in value)
|
|
872
|
+
and any(ch.isalpha() for ch in value)
|
|
873
|
+
)
|
|
874
|
+
if blob_like:
|
|
875
|
+
return "high-entropy", "opaque_token_blob", Severity.MEDIUM
|
|
876
|
+
|
|
877
|
+
if very_strong_general(value, entropy, mix):
|
|
878
|
+
return "high-entropy", "opaque_high_entropy_value", Severity.MEDIUM
|
|
879
|
+
return None, "", Severity.INFO
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def very_strong_general(value: str, entropy: float, mix: int) -> bool:
|
|
883
|
+
return len(value) >= 28 and entropy >= 4.0 and mix == 4
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def redact(value: str, keep: int = 4) -> str:
|
|
887
|
+
"""Show just enough to identify, never the secret itself."""
|
|
888
|
+
if len(value) <= keep:
|
|
889
|
+
return "[REDACTED]"
|
|
890
|
+
return value[:keep] + "…[REDACTED]"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class StringCandidate:
|
|
894
|
+
"""A string literal found in source, with optional assignment target."""
|
|
895
|
+
|
|
896
|
+
__slots__ = ("value", "name", "line")
|
|
897
|
+
|
|
898
|
+
def __init__(self, value: str, name: str, line: int):
|
|
899
|
+
self.value = value
|
|
900
|
+
self.name = name
|
|
901
|
+
self.line = line
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def extract_py_string_candidates(src: str) -> list[StringCandidate]:
|
|
905
|
+
"""AST walk collecting string literals bound to names (assignments,
|
|
906
|
+
dict values under string keys) plus bare high-risk constants."""
|
|
907
|
+
import ast as _ast
|
|
908
|
+
|
|
909
|
+
from patchi.core.brain.ast_utils.helpers import _py_assign_target_name
|
|
910
|
+
|
|
911
|
+
out: list[StringCandidate] = []
|
|
912
|
+
try:
|
|
913
|
+
tree = _ast.parse(src)
|
|
914
|
+
except SyntaxError:
|
|
915
|
+
return out
|
|
916
|
+
|
|
917
|
+
def _const_str(node: _ast.AST) -> str | None:
|
|
918
|
+
return (
|
|
919
|
+
node.value if isinstance(node, _ast.Constant) and isinstance(node.value, str) else None
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
for node in _ast.walk(tree):
|
|
923
|
+
line = getattr(node, "lineno", 0)
|
|
924
|
+
if isinstance(node, _ast.Assign):
|
|
925
|
+
target = ""
|
|
926
|
+
if node.targets:
|
|
927
|
+
target = _py_assign_target_name(node.targets[0])
|
|
928
|
+
val = _const_str(node.value)
|
|
929
|
+
if val:
|
|
930
|
+
out.append(StringCandidate(val, target, line))
|
|
931
|
+
if isinstance(node.value, _ast.Dict):
|
|
932
|
+
for k, v in zip(node.value.keys, node.value.values, strict=False):
|
|
933
|
+
sv = _const_str(v)
|
|
934
|
+
sk = _const_str(k)
|
|
935
|
+
if sv:
|
|
936
|
+
out.append(StringCandidate(sv, sk or target, line))
|
|
937
|
+
elif isinstance(node, _ast.AnnAssign):
|
|
938
|
+
target = _py_assign_target_name(node.target)
|
|
939
|
+
val = _const_str(node.value) if node.value else None
|
|
940
|
+
if val:
|
|
941
|
+
out.append(StringCandidate(val, target, line))
|
|
942
|
+
elif isinstance(node, _ast.keyword):
|
|
943
|
+
val = _const_str(node.value)
|
|
944
|
+
if val and node.arg:
|
|
945
|
+
out.append(StringCandidate(val, node.arg, line))
|
|
946
|
+
return out
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def extract_quoted_candidates(src: str) -> list[StringCandidate]:
|
|
950
|
+
"""Character-scanner for quoted spans in non-Python text (no regex).
|
|
951
|
+
|
|
952
|
+
Tracks double/single quote runs, honors backslash escapes, and guesses an
|
|
953
|
+
assignment-ish name from the characters immediately before the quote.
|
|
954
|
+
"""
|
|
955
|
+
out: list[StringCandidate] = []
|
|
956
|
+
lines = src.splitlines()
|
|
957
|
+
for lineno, raw in enumerate(lines, 1):
|
|
958
|
+
i = 0
|
|
959
|
+
n = len(raw)
|
|
960
|
+
while i < n:
|
|
961
|
+
ch = raw[i]
|
|
962
|
+
if ch in "\"'":
|
|
963
|
+
quote = ch
|
|
964
|
+
j = i + 1
|
|
965
|
+
buf: list[str] = []
|
|
966
|
+
closed = False
|
|
967
|
+
while j < n:
|
|
968
|
+
cj = raw[j]
|
|
969
|
+
if cj == "\\" and j + 1 < n:
|
|
970
|
+
buf.append(raw[j : j + 2])
|
|
971
|
+
j += 2
|
|
972
|
+
continue
|
|
973
|
+
if cj == quote:
|
|
974
|
+
closed = True
|
|
975
|
+
break
|
|
976
|
+
buf.append(cj)
|
|
977
|
+
j += 1
|
|
978
|
+
if closed:
|
|
979
|
+
value = "".join(buf)
|
|
980
|
+
prefix = raw[max(0, i - 40) : i]
|
|
981
|
+
name = _guess_name_from_prefix(prefix)
|
|
982
|
+
out.append(StringCandidate(value, name, lineno))
|
|
983
|
+
i = j + 1
|
|
984
|
+
continue
|
|
985
|
+
i += 1
|
|
986
|
+
return out
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _guess_name_from_prefix(prefix: str) -> str:
|
|
990
|
+
"""Best-effort identifier before a quote: `name:` / `name=` / `\"name\":`."""
|
|
991
|
+
tail = prefix.rstrip()
|
|
992
|
+
for sep in (":", "="):
|
|
993
|
+
idx = tail.rfind(sep)
|
|
994
|
+
if idx != -1:
|
|
995
|
+
head = tail[:idx].rstrip()
|
|
996
|
+
word_chars = []
|
|
997
|
+
for ch in reversed(head):
|
|
998
|
+
if ch.isalnum() or ch in "_-":
|
|
999
|
+
word_chars.append(ch)
|
|
1000
|
+
else:
|
|
1001
|
+
break
|
|
1002
|
+
name = "".join(reversed(word_chars)).strip("\"' ")
|
|
1003
|
+
if name:
|
|
1004
|
+
return name
|
|
1005
|
+
return ""
|