wpsecscan 2.4.0__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.
- wpsecscan/__init__.py +1 -0
- wpsecscan/__main__.py +2595 -0
- wpsecscan/activity.py +116 -0
- wpsecscan/ai_assist.py +291 -0
- wpsecscan/ai_safety.py +231 -0
- wpsecscan/ai_triage.py +541 -0
- wpsecscan/ai_triage_ui.py +197 -0
- wpsecscan/analytics.py +417 -0
- wpsecscan/api_server.py +254 -0
- wpsecscan/attack_checkpoint.py +149 -0
- wpsecscan/attack_scripts.py +118 -0
- wpsecscan/auth/__init__.py +1 -0
- wpsecscan/auth/approval_workflow.py +108 -0
- wpsecscan/auth/audit_log.py +97 -0
- wpsecscan/auth/rbac.py +92 -0
- wpsecscan/auth/sso_oidc.py +73 -0
- wpsecscan/auth/sso_saml.py +74 -0
- wpsecscan/auto_pr.py +171 -0
- wpsecscan/auto_update.py +129 -0
- wpsecscan/baseline.py +52 -0
- wpsecscan/branding.py +55 -0
- wpsecscan/bug_report.py +228 -0
- wpsecscan/burp_import.py +76 -0
- wpsecscan/cache.py +80 -0
- wpsecscan/check_health.py +121 -0
- wpsecscan/checks/__init__.py +614 -0
- wpsecscan/checks/_template.py +45 -0
- wpsecscan/checks/a11y_deep.py +108 -0
- wpsecscan/checks/a11y_lite.py +95 -0
- wpsecscan/checks/a11y_wcag_aaa.py +109 -0
- wpsecscan/checks/abuseipdb_lookup.py +149 -0
- wpsecscan/checks/admin_ajax_brute_surface.py +93 -0
- wpsecscan/checks/ai_chatbot_endpoint_leak.py +73 -0
- wpsecscan/checks/ai_prompt_injection_passive.py +86 -0
- wpsecscan/checks/ajax_surface.py +157 -0
- wpsecscan/checks/app_passwords.py +104 -0
- wpsecscan/checks/auth_modernisation.py +189 -0
- wpsecscan/checks/authenticated.py +459 -0
- wpsecscan/checks/backup_exposure.py +151 -0
- wpsecscan/checks/backup_file_fuzz.py +106 -0
- wpsecscan/checks/brand_monitor.py +116 -0
- wpsecscan/checks/cache_headers.py +118 -0
- wpsecscan/checks/cache_poisoning.py +96 -0
- wpsecscan/checks/cache_poisoning_v2.py +46 -0
- wpsecscan/checks/cdn_edge_audit.py +174 -0
- wpsecscan/checks/cloud_metadata_ssrf.py +116 -0
- wpsecscan/checks/cloudflare_origin_leak.py +334 -0
- wpsecscan/checks/companion_advanced.py +277 -0
- wpsecscan/checks/compliance_frameworks.py +75 -0
- wpsecscan/checks/composer_lock_audit.py +101 -0
- wpsecscan/checks/cookie_consent.py +159 -0
- wpsecscan/checks/cookies.py +125 -0
- wpsecscan/checks/core_checksums.py +124 -0
- wpsecscan/checks/core_cves.py +90 -0
- wpsecscan/checks/core_tampering.py +163 -0
- wpsecscan/checks/core_version.py +135 -0
- wpsecscan/checks/cors.py +150 -0
- wpsecscan/checks/crlf_location_injection.py +127 -0
- wpsecscan/checks/crypto_agility.py +146 -0
- wpsecscan/checks/crypto_payment_callback_audit.py +67 -0
- wpsecscan/checks/cryptominer_js_injection.py +73 -0
- wpsecscan/checks/csp.py +126 -0
- wpsecscan/checks/csp_report_endpoint.py +102 -0
- wpsecscan/checks/csrf_entropy.py +112 -0
- wpsecscan/checks/csrf_nonce.py +101 -0
- wpsecscan/checks/csv_export_csp.py +104 -0
- wpsecscan/checks/ct_log_recent_certs.py +112 -0
- wpsecscan/checks/db_admin_login_probe.py +55 -0
- wpsecscan/checks/db_trigger_audit.py +106 -0
- wpsecscan/checks/debug_leaks.py +117 -0
- wpsecscan/checks/debug_log_pii_sniff.py +80 -0
- wpsecscan/checks/default_creds.py +204 -0
- wpsecscan/checks/dev_params.py +102 -0
- wpsecscan/checks/directory_listing.py +49 -0
- wpsecscan/checks/dns_deep.py +221 -0
- wpsecscan/checks/dns_rebinding.py +106 -0
- wpsecscan/checks/dns_security.py +482 -0
- wpsecscan/checks/dns_templates.py +196 -0
- wpsecscan/checks/dom_xss_headless.py +177 -0
- wpsecscan/checks/email_obfuscation_audit.py +77 -0
- wpsecscan/checks/email_security_deep.py +222 -0
- wpsecscan/checks/env_file_enum.py +96 -0
- wpsecscan/checks/error_pages.py +91 -0
- wpsecscan/checks/exposed_files.py +124 -0
- wpsecscan/checks/favicon_fingerprint.py +126 -0
- wpsecscan/checks/file_upload.py +65 -0
- wpsecscan/checks/forced_browse.py +121 -0
- wpsecscan/checks/gdpr_dsr.py +91 -0
- wpsecscan/checks/git_dir_deep_scan.py +92 -0
- wpsecscan/checks/github_leak_search.py +125 -0
- wpsecscan/checks/graphql_dos.py +174 -0
- wpsecscan/checks/graphql_field_authz_deep.py +128 -0
- wpsecscan/checks/gtm_inventory.py +56 -0
- wpsecscan/checks/gutenberg_blocks.py +49 -0
- wpsecscan/checks/header_smuggling_case.py +109 -0
- wpsecscan/checks/headless_templates.py +161 -0
- wpsecscan/checks/headless_wp_audit.py +159 -0
- wpsecscan/checks/heartbeat_abuse.py +42 -0
- wpsecscan/checks/heartbeat_frontend.py +57 -0
- wpsecscan/checks/helm_compose_leak.py +99 -0
- wpsecscan/checks/hibp.py +154 -0
- wpsecscan/checks/honeypot_admin.py +69 -0
- wpsecscan/checks/host_header_validation.py +106 -0
- wpsecscan/checks/host_recon.py +106 -0
- wpsecscan/checks/hosting_platform_audit.py +120 -0
- wpsecscan/checks/hostname_collision.py +103 -0
- wpsecscan/checks/hpp.py +98 -0
- wpsecscan/checks/hsts_preload_eligibility.py +118 -0
- wpsecscan/checks/http2_settings.py +89 -0
- wpsecscan/checks/http2_smuggling.py +42 -0
- wpsecscan/checks/http3_fingerprint.py +77 -0
- wpsecscan/checks/http_methods.py +70 -0
- wpsecscan/checks/js_framework_deep.py +115 -0
- wpsecscan/checks/js_libraries.py +257 -0
- wpsecscan/checks/js_supply_chain.py +159 -0
- wpsecscan/checks/jwt_audit.py +217 -0
- wpsecscan/checks/login.py +80 -0
- wpsecscan/checks/login_redirect_http_hop.py +77 -0
- wpsecscan/checks/login_throttle.py +157 -0
- wpsecscan/checks/login_throttle_deep.py +262 -0
- wpsecscan/checks/login_timing.py +138 -0
- wpsecscan/checks/magecart_skimmer_patterns.py +80 -0
- wpsecscan/checks/mfa_priv_account_audit.py +89 -0
- wpsecscan/checks/misc_injection_audit.py +52 -0
- wpsecscan/checks/mixed_content.py +90 -0
- wpsecscan/checks/mobile_app_endpoints.py +41 -0
- wpsecscan/checks/multisite.py +105 -0
- wpsecscan/checks/nft_mint_pubapi.py +67 -0
- wpsecscan/checks/nonce_freshness.py +116 -0
- wpsecscan/checks/nosql_injection.py +110 -0
- wpsecscan/checks/oauth_oidc.py +147 -0
- wpsecscan/checks/oauth_redirect.py +82 -0
- wpsecscan/checks/oauth_redirect_misconfig.py +89 -0
- wpsecscan/checks/object_cache_dropin.py +76 -0
- wpsecscan/checks/open_redirect.py +81 -0
- wpsecscan/checks/open_registration.py +79 -0
- wpsecscan/checks/openapi_scanner.py +138 -0
- wpsecscan/checks/origin_ip_discovery.py +74 -0
- wpsecscan/checks/osint_enrich.py +76 -0
- wpsecscan/checks/package_lock_audit.py +118 -0
- wpsecscan/checks/page_builder_cve.py +106 -0
- wpsecscan/checks/path_bypass.py +127 -0
- wpsecscan/checks/path_traversal.py +114 -0
- wpsecscan/checks/payment_commerce_deep.py +153 -0
- wpsecscan/checks/payment_gateway_test_keys.py +79 -0
- wpsecscan/checks/perf_budget.py +110 -0
- wpsecscan/checks/permissions_policy.py +106 -0
- wpsecscan/checks/php_eol.py +132 -0
- wpsecscan/checks/phpinfo_dangerous_directives.py +72 -0
- wpsecscan/checks/plugin_archive_fuzz.py +77 -0
- wpsecscan/checks/plugin_cemetery.py +214 -0
- wpsecscan/checks/plugin_cves.py +403 -0
- wpsecscan/checks/plugin_hash_fingerprint.py +132 -0
- wpsecscan/checks/plugin_route_fuzz.py +147 -0
- wpsecscan/checks/plugin_specific_audit.py +122 -0
- wpsecscan/checks/plugin_typosquat_detection.py +111 -0
- wpsecscan/checks/plugins.py +94 -0
- wpsecscan/checks/postmeta_stored_xss_scan.py +84 -0
- wpsecscan/checks/premium_license_leak.py +95 -0
- wpsecscan/checks/privacy_inventory.py +177 -0
- wpsecscan/checks/prototype_pollution.py +90 -0
- wpsecscan/checks/race_condition.py +116 -0
- wpsecscan/checks/redirect_chain.py +134 -0
- wpsecscan/checks/referenced_buckets.py +212 -0
- wpsecscan/checks/rest_api.py +133 -0
- wpsecscan/checks/rest_app_passwords_enum.py +60 -0
- wpsecscan/checks/rest_fields_dos.py +56 -0
- wpsecscan/checks/rest_link_header.py +74 -0
- wpsecscan/checks/rest_namespace_leak.py +61 -0
- wpsecscan/checks/rest_permission_audit.py +83 -0
- wpsecscan/checks/robots_sitemap.py +108 -0
- wpsecscan/checks/rum_beacons.py +60 -0
- wpsecscan/checks/s3_bucket_discovery.py +161 -0
- wpsecscan/checks/saml_xsw.py +72 -0
- wpsecscan/checks/secret_leak.py +204 -0
- wpsecscan/checks/security_txt.py +52 -0
- wpsecscan/checks/sendmail_injection.py +92 -0
- wpsecscan/checks/server_stack_reveal.py +113 -0
- wpsecscan/checks/server_timing.py +107 -0
- wpsecscan/checks/service_exposure.py +121 -0
- wpsecscan/checks/session_fixation.py +103 -0
- wpsecscan/checks/sitemap_cve_probe.py +154 -0
- wpsecscan/checks/smuggling_probe.py +150 -0
- wpsecscan/checks/solidity_abi_leak.py +84 -0
- wpsecscan/checks/source_maps.py +120 -0
- wpsecscan/checks/spider_crawl.py +50 -0
- wpsecscan/checks/sqli.py +271 -0
- wpsecscan/checks/sri_audit.py +98 -0
- wpsecscan/checks/sri_pwa_misc.py +152 -0
- wpsecscan/checks/ssrf.py +90 -0
- wpsecscan/checks/ssti.py +137 -0
- wpsecscan/checks/subdomains.py +301 -0
- wpsecscan/checks/tailwind_css_comment_leak.py +96 -0
- wpsecscan/checks/theme_cves.py +83 -0
- wpsecscan/checks/themes.py +52 -0
- wpsecscan/checks/timthumb.py +111 -0
- wpsecscan/checks/tls_deep.py +241 -0
- wpsecscan/checks/tls_headers.py +195 -0
- wpsecscan/checks/tls_modern.py +236 -0
- wpsecscan/checks/tls_reneg_dos.py +65 -0
- wpsecscan/checks/upload_bypass_deep.py +64 -0
- wpsecscan/checks/upload_path_predictable.py +87 -0
- wpsecscan/checks/uploads_year_listing.py +52 -0
- wpsecscan/checks/users.py +164 -0
- wpsecscan/checks/users_deep.py +154 -0
- wpsecscan/checks/users_me_capability_leak.py +68 -0
- wpsecscan/checks/vendor_backdoor_patterns.py +59 -0
- wpsecscan/checks/waf.py +141 -0
- wpsecscan/checks/waf_brand_deep.py +113 -0
- wpsecscan/checks/waf_bypass_probe.py +120 -0
- wpsecscan/checks/waf_lockout_guard.py +106 -0
- wpsecscan/checks/waf_ruleset.py +114 -0
- wpsecscan/checks/wallet_seed_phrase_leak.py +91 -0
- wpsecscan/checks/web3_wallet_connector_audit.py +95 -0
- wpsecscan/checks/webdav.py +78 -0
- wpsecscan/checks/webhook_signing_secrets.py +76 -0
- wpsecscan/checks/webhook_url_fingerprint.py +83 -0
- wpsecscan/checks/webhooks.py +111 -0
- wpsecscan/checks/websocket_audit.py +150 -0
- wpsecscan/checks/websocket_fuzz.py +127 -0
- wpsecscan/checks/well_known.py +89 -0
- wpsecscan/checks/woocommerce_audit.py +194 -0
- wpsecscan/checks/woocommerce_deep.py +68 -0
- wpsecscan/checks/woocommerce_order_idor.py +63 -0
- wpsecscan/checks/woocommerce_storefront.py +181 -0
- wpsecscan/checks/wp_builder_audit.py +121 -0
- wpsecscan/checks/wp_cli_inject.py +83 -0
- wpsecscan/checks/wp_commerce_alt_audit.py +102 -0
- wpsecscan/checks/wp_cron_cpu.py +64 -0
- wpsecscan/checks/wp_cron_disabled.py +66 -0
- wpsecscan/checks/wp_cron_dos.py +49 -0
- wpsecscan/checks/wp_debug_display_via_rest.py +68 -0
- wpsecscan/checks/wp_engine_misconfig.py +94 -0
- wpsecscan/checks/wp_fork_detection.py +107 -0
- wpsecscan/checks/wp_form_audit.py +95 -0
- wpsecscan/checks/wp_membership_lms_audit.py +99 -0
- wpsecscan/checks/wp_multisite_deep.py +86 -0
- wpsecscan/checks/wp_plugin_ecosystem_audit.py +161 -0
- wpsecscan/checks/wp_query_sqli.py +65 -0
- wpsecscan/checks/wp_rest_methods.py +86 -0
- wpsecscan/checks/wp_salts_age.py +60 -0
- wpsecscan/checks/wpconfig_hardening_audit.py +83 -0
- wpsecscan/checks/wpcron_suspicious_jobs.py +96 -0
- wpsecscan/checks/wpgraphql.py +186 -0
- wpsecscan/checks/xmlrpc_amplification.py +75 -0
- wpsecscan/checks/xmlrpc_deep.py +126 -0
- wpsecscan/checks/xmlrpc_method_brute.py +126 -0
- wpsecscan/checks/xss_dom_sinks.py +97 -0
- wpsecscan/checks/xss_reflected.py +95 -0
- wpsecscan/checks/xxe_upload.py +155 -0
- wpsecscan/checks/yaml_templates.py +71 -0
- wpsecscan/checks/yaml_workflows.py +53 -0
- wpsecscan/checks/yarn_pnpm_lock_audit.py +92 -0
- wpsecscan/completion.py +136 -0
- wpsecscan/confidence.py +54 -0
- wpsecscan/config.py +147 -0
- wpsecscan/console_live.py +219 -0
- wpsecscan/continuous_monitor.py +116 -0
- wpsecscan/crash_submit.py +69 -0
- wpsecscan/daemon/__init__.py +11 -0
- wpsecscan/daemon/_legacy.py +154 -0
- wpsecscan/daemon/webhook_v2.py +91 -0
- wpsecscan/data/check_tags.json +252 -0
- wpsecscan/data/common_paths.txt +201 -0
- wpsecscan/data/compliance_extra.json +62 -0
- wpsecscan/data/compliance_map.json +1037 -0
- wpsecscan/data/compliance_v2.json +158 -0
- wpsecscan/data/dashboard.html.j2 +167 -0
- wpsecscan/data/exploit_playbook.json +750 -0
- wpsecscan/data/exploit_signatures.json +787 -0
- wpsecscan/data/known_paths.txt +44 -0
- wpsecscan/data/marketplace.json +66 -0
- wpsecscan/data/payloads.json +269 -0
- wpsecscan/data/plugin_cves.json +42 -0
- wpsecscan/data/plugin_file_hashes.json +31 -0
- wpsecscan/data/quick_fixes.json +245 -0
- wpsecscan/data/references.json +149 -0
- wpsecscan/data/remediation_videos.json +86 -0
- wpsecscan/data/report.html.j2 +482 -0
- wpsecscan/data/report.schema.json +60 -0
- wpsecscan/data/security_tutorial.json +37 -0
- wpsecscan/db.py +764 -0
- wpsecscan/demo.py +259 -0
- wpsecscan/diff.py +79 -0
- wpsecscan/education.py +75 -0
- wpsecscan/enterprise/__init__.py +1 -0
- wpsecscan/enterprise/billing_stub.py +68 -0
- wpsecscan/enterprise/multi_tenant.py +55 -0
- wpsecscan/enterprise/quota.py +55 -0
- wpsecscan/eta.py +52 -0
- wpsecscan/fun/__init__.py +1 -0
- wpsecscan/fun/bingo_card.py +98 -0
- wpsecscan/gui.py +3478 -0
- wpsecscan/gui_payloads.py +466 -0
- wpsecscan/gui_windows.py +1444 -0
- wpsecscan/har_replay.py +133 -0
- wpsecscan/hardware_keys.py +192 -0
- wpsecscan/heatmap.py +132 -0
- wpsecscan/history.py +381 -0
- wpsecscan/http.py +343 -0
- wpsecscan/i18n.py +214 -0
- wpsecscan/incremental/__init__.py +17 -0
- wpsecscan/incremental/_legacy.py +136 -0
- wpsecscan/incremental/diff_scan.py +79 -0
- wpsecscan/incremental/smart_skip.py +75 -0
- wpsecscan/integrations/__init__.py +1 -0
- wpsecscan/integrations/cisa_kev.py +80 -0
- wpsecscan/integrations/epss.py +107 -0
- wpsecscan/integrations/github_issues.py +126 -0
- wpsecscan/integrations/osint.py +120 -0
- wpsecscan/integrations/sucuri_sitecheck.py +85 -0
- wpsecscan/integrations/threat_intel.py +153 -0
- wpsecscan/integrations/ticketing.py +120 -0
- wpsecscan/integrations/tor_proxy.py +35 -0
- wpsecscan/integrations/virustotal.py +80 -0
- wpsecscan/integrations/webhooks_chat.py +121 -0
- wpsecscan/interactsh.py +118 -0
- wpsecscan/issue_push.py +263 -0
- wpsecscan/js_plugin.py +101 -0
- wpsecscan/licensing.py +143 -0
- wpsecscan/log.py +62 -0
- wpsecscan/marketplace.py +107 -0
- wpsecscan/mobile_app_discovery.py +60 -0
- wpsecscan/models.py +92 -0
- wpsecscan/monitors.py +540 -0
- wpsecscan/notify.py +249 -0
- wpsecscan/observability.py +126 -0
- wpsecscan/password_audit.py +208 -0
- wpsecscan/payloads.py +152 -0
- wpsecscan/perf/__init__.py +12 -0
- wpsecscan/perf/_legacy.py +80 -0
- wpsecscan/perf/connection_pool.py +59 -0
- wpsecscan/perf/parallel_sites.py +42 -0
- wpsecscan/playbook.py +120 -0
- wpsecscan/policy.py +119 -0
- wpsecscan/pr_inspector.py +180 -0
- wpsecscan/prove.py +332 -0
- wpsecscan/py.typed +0 -0
- wpsecscan/recommend.py +163 -0
- wpsecscan/region_egress.py +59 -0
- wpsecscan/remediation_videos.py +48 -0
- wpsecscan/report_query.py +134 -0
- wpsecscan/reporters/__init__.py +0 -0
- wpsecscan/reporters/attestation.py +178 -0
- wpsecscan/reporters/badge_svg.py +68 -0
- wpsecscan/reporters/bounty_format.py +201 -0
- wpsecscan/reporters/burp_export.py +81 -0
- wpsecscan/reporters/comparison_two_sites.py +55 -0
- wpsecscan/reporters/console.py +278 -0
- wpsecscan/reporters/csv_out.py +67 -0
- wpsecscan/reporters/dashboard.py +132 -0
- wpsecscan/reporters/diff_viewer.py +190 -0
- wpsecscan/reporters/docx_report.py +142 -0
- wpsecscan/reporters/eli5_toggle.py +87 -0
- wpsecscan/reporters/exec_pdf.py +355 -0
- wpsecscan/reporters/executive_pack.py +167 -0
- wpsecscan/reporters/html.py +163 -0
- wpsecscan/reporters/issue_export.py +265 -0
- wpsecscan/reporters/json_out.py +114 -0
- wpsecscan/reporters/markdown.py +118 -0
- wpsecscan/reporters/org_dashboard.py +124 -0
- wpsecscan/reporters/pdf_custom_branding.py +58 -0
- wpsecscan/reporters/public_page.py +87 -0
- wpsecscan/reporters/sarif.py +81 -0
- wpsecscan/reporters/snapshot_compare.py +143 -0
- wpsecscan/reporters/translated_summary.py +116 -0
- wpsecscan/reporters/trend_over_time.py +81 -0
- wpsecscan/reporters/xlsx_out.py +177 -0
- wpsecscan/risk.py +88 -0
- wpsecscan/risk_weights.py +80 -0
- wpsecscan/sbom.py +89 -0
- wpsecscan/scanner.py +474 -0
- wpsecscan/sites.py +558 -0
- wpsecscan/spider.py +127 -0
- wpsecscan/ssh_audit.py +231 -0
- wpsecscan/tags.py +86 -0
- wpsecscan/template_engine.py +257 -0
- wpsecscan/template_signature.py +108 -0
- wpsecscan/threat_intel_v2.py +442 -0
- wpsecscan/tray.py +112 -0
- wpsecscan/turbo_engine.py +232 -0
- wpsecscan/ua_rotation.py +53 -0
- wpsecscan/ux_extras.py +370 -0
- wpsecscan/waf_rules.py +150 -0
- wpsecscan/watchers.py +318 -0
- wpsecscan/workflow.py +113 -0
- wpsecscan-2.4.0.dist-info/METADATA +1194 -0
- wpsecscan-2.4.0.dist-info/RECORD +393 -0
- wpsecscan-2.4.0.dist-info/WHEEL +5 -0
- wpsecscan-2.4.0.dist-info/entry_points.txt +5 -0
- wpsecscan-2.4.0.dist-info/licenses/LICENSE +661 -0
- wpsecscan-2.4.0.dist-info/licenses/NOTICE +37 -0
- wpsecscan-2.4.0.dist-info/top_level.txt +1 -0
wpsecscan/__main__.py
ADDED
|
@@ -0,0 +1,2595 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
# Windows consoles still default to cp1252 / cp437; force UTF-8 so unicode
|
|
13
|
+
# glyphs in evidence strings and scanned-site response bodies don't crash render.
|
|
14
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
15
|
+
try:
|
|
16
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
17
|
+
except (AttributeError, ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from . import db as vulndb
|
|
24
|
+
from . import diff as diff_mod
|
|
25
|
+
from . import log as logmod
|
|
26
|
+
from . import password_audit as pwaudit
|
|
27
|
+
from . import ssh_audit as sshaudit
|
|
28
|
+
from .reporters import console as console_reporter
|
|
29
|
+
from .reporters import csv_out as csv_reporter
|
|
30
|
+
from .reporters import dashboard as dashboard_reporter
|
|
31
|
+
from .reporters import html as html_reporter
|
|
32
|
+
from .reporters import json_out as json_reporter
|
|
33
|
+
from .reporters import markdown as md_reporter
|
|
34
|
+
from .reporters import sarif as sarif_reporter
|
|
35
|
+
from .reporters import xlsx_out as xlsx_reporter
|
|
36
|
+
from .scanner import scan
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_host(target: str) -> str:
|
|
40
|
+
host = urlparse(target).hostname or "site"
|
|
41
|
+
return re.sub(r"[^a-z0-9.-]+", "_", host.lower())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_auth_pass_from_stdin() -> str:
|
|
45
|
+
"""Prompt for the admin password via getpass. Exits 130 on Ctrl+C/EOF.
|
|
46
|
+
|
|
47
|
+
Extracted from main() so the stdin path is directly testable without
|
|
48
|
+
needing to drive the full argparse pipeline.
|
|
49
|
+
"""
|
|
50
|
+
import getpass
|
|
51
|
+
try:
|
|
52
|
+
return getpass.getpass("WordPress admin password: ")
|
|
53
|
+
except (EOFError, KeyboardInterrupt):
|
|
54
|
+
print("aborted: no password provided", file=sys.stderr)
|
|
55
|
+
sys.exit(130)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _outdir(arg: str | None) -> Path:
|
|
59
|
+
if not arg:
|
|
60
|
+
return Path.cwd()
|
|
61
|
+
# Canonicalize so `--out ../../foo` shows up resolved in output messages
|
|
62
|
+
# and prevents subtle directory-confusion bugs downstream.
|
|
63
|
+
p = Path(arg).expanduser()
|
|
64
|
+
resolved = p.resolve() if not p.suffix else p.parent.resolve()
|
|
65
|
+
# Safety: refuse to mkdir outside cwd / home (defence against
|
|
66
|
+
# `--out ../../etc/cron.d/...` and similar misuse, esp. when a caller
|
|
67
|
+
# builds the --out value from config or another scan). Honour an
|
|
68
|
+
# explicit opt-out via WPSECSCAN_ALLOW_ANY_OUT=1 for the rare
|
|
69
|
+
# legitimate case (e.g. /var/log/wpsecscan/).
|
|
70
|
+
allow_any = os.environ.get("WPSECSCAN_ALLOW_ANY_OUT") == "1"
|
|
71
|
+
if not allow_any:
|
|
72
|
+
cwd = Path.cwd().resolve()
|
|
73
|
+
home = Path.home().resolve()
|
|
74
|
+
if not (str(resolved).startswith(str(cwd)) or str(resolved).startswith(str(home))):
|
|
75
|
+
raise SystemExit(
|
|
76
|
+
f"--out {arg} resolves outside cwd ({cwd}) and home ({home}). "
|
|
77
|
+
"Set WPSECSCAN_ALLOW_ANY_OUT=1 to override."
|
|
78
|
+
)
|
|
79
|
+
if p.suffix:
|
|
80
|
+
# Filename-shaped arg: ensure parent directory exists before writes.
|
|
81
|
+
parent = p.parent if str(p.parent) else Path.cwd()
|
|
82
|
+
if str(parent) and parent != Path("."):
|
|
83
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
return parent.resolve()
|
|
85
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
return p.resolve()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stem(target: str, out_arg: str | None) -> str:
|
|
90
|
+
if out_arg:
|
|
91
|
+
p = Path(out_arg)
|
|
92
|
+
if p.suffix:
|
|
93
|
+
return p.with_suffix("").name
|
|
94
|
+
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
95
|
+
return f"wpsecscan-{_safe_host(target)}-{ts}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_since(s: str):
|
|
99
|
+
"""K26: parse `--since YYYY-MM-DD` (or full ISO timestamp) to datetime.
|
|
100
|
+
Returns None on invalid input rather than crashing the scan."""
|
|
101
|
+
from datetime import datetime
|
|
102
|
+
if not s:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00").split("+", 1)[0])
|
|
106
|
+
except (ValueError, AttributeError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _scan_one(target: str, args, console: Console):
|
|
111
|
+
# Round-56: wrap the scan in a live multi-panel dashboard when stdout
|
|
112
|
+
# is a TTY and the user hasn't asked for plain output.
|
|
113
|
+
use_live = (not args.no_console
|
|
114
|
+
and not getattr(args, "no_live", False)
|
|
115
|
+
and bool(getattr(console, "is_terminal", False)))
|
|
116
|
+
dash = None
|
|
117
|
+
on_progress = None
|
|
118
|
+
if use_live:
|
|
119
|
+
try:
|
|
120
|
+
from .console_live import LiveDashboard
|
|
121
|
+
from .checks import select_checks
|
|
122
|
+
total = len(select_checks(args.aggressive,
|
|
123
|
+
authenticated_enabled=bool(
|
|
124
|
+
(args.auth_user and (args.auth_pass or args.auth_app_password))
|
|
125
|
+
or args.companion_token)))
|
|
126
|
+
dash = LiveDashboard(console, target, total)
|
|
127
|
+
dash.__enter__()
|
|
128
|
+
on_progress = dash.on_progress_callback()
|
|
129
|
+
except Exception: # noqa: BLE001
|
|
130
|
+
dash = None
|
|
131
|
+
on_progress = None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
report = await scan(
|
|
135
|
+
target,
|
|
136
|
+
timeout=args.timeout,
|
|
137
|
+
user_agent=args.user_agent,
|
|
138
|
+
concurrency=args.concurrency,
|
|
139
|
+
verify_tls=not args.insecure,
|
|
140
|
+
wpscan_token=args.wpscan_token,
|
|
141
|
+
hibp_token=args.hibp_token,
|
|
142
|
+
aggressive=args.aggressive,
|
|
143
|
+
prove=args.prove,
|
|
144
|
+
deep_throttle=args.deep_throttle,
|
|
145
|
+
deep_throttle_attempts=args.deep_throttle_attempts,
|
|
146
|
+
deep_throttle_pacing_s=args.deep_throttle_pacing,
|
|
147
|
+
auth_user=args.auth_user,
|
|
148
|
+
auth_pass=args.auth_pass,
|
|
149
|
+
auth_app_password=args.auth_app_password,
|
|
150
|
+
auth_totp=args.auth_totp,
|
|
151
|
+
companion_token=args.companion_token,
|
|
152
|
+
proxy=args.proxy,
|
|
153
|
+
proxy_auth=args.proxy_auth,
|
|
154
|
+
har=bool(args.har),
|
|
155
|
+
har_path=Path(args.har) if args.har else None,
|
|
156
|
+
parallel_groups=args.parallel_groups,
|
|
157
|
+
checkpoint=args.checkpoint,
|
|
158
|
+
abuseipdb_token=args.abuseipdb_token,
|
|
159
|
+
vt_token=args.vt_token,
|
|
160
|
+
github_search_token=args.github_search_token,
|
|
161
|
+
since=_parse_since(args.since) if getattr(args, "since", None) else None,
|
|
162
|
+
on_progress=on_progress,
|
|
163
|
+
)
|
|
164
|
+
finally:
|
|
165
|
+
if dash is not None:
|
|
166
|
+
try:
|
|
167
|
+
dash.__exit__(None, None, None)
|
|
168
|
+
except Exception: # noqa: BLE001
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
# Item #39 — fan-out to PagerDuty + Opsgenie via env vars (no CLI flags;
|
|
172
|
+
# only fires when WPSECSCAN_PAGERDUTY_KEY / WPSECSCAN_OPSGENIE_KEY are set).
|
|
173
|
+
try:
|
|
174
|
+
from . import notify as _n_post
|
|
175
|
+
pd_key = os.environ.get("WPSECSCAN_PAGERDUTY_KEY", "")
|
|
176
|
+
if pd_key:
|
|
177
|
+
ok_pd, _ = _n_post.notify_pagerduty(report, routing_key=pd_key)
|
|
178
|
+
if ok_pd and not args.no_console:
|
|
179
|
+
console.print("[green]✓[/green] PagerDuty incident triggered.")
|
|
180
|
+
og_key = os.environ.get("WPSECSCAN_OPSGENIE_KEY", "")
|
|
181
|
+
if og_key:
|
|
182
|
+
og_region = os.environ.get("WPSECSCAN_OPSGENIE_REGION", "us")
|
|
183
|
+
ok_og, _ = _n_post.notify_opsgenie(report, api_key=og_key, region=og_region)
|
|
184
|
+
if ok_og and not args.no_console:
|
|
185
|
+
console.print("[green]✓[/green] Opsgenie alert created.")
|
|
186
|
+
except Exception: # noqa: BLE001
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
# #61 — redact JWTs / session cookies / bearer tokens / PII from
|
|
190
|
+
# evidence + remediation BEFORE any reporter runs.
|
|
191
|
+
if getattr(args, "redact_evidence", False):
|
|
192
|
+
try:
|
|
193
|
+
from . import ai_safety as _safety
|
|
194
|
+
n = _safety.redact_report_in_place(report)
|
|
195
|
+
if n and not args.no_console:
|
|
196
|
+
console.print(f"[yellow]Redacted {n} string(s) in evidence/remediation.[/yellow]")
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# Items #40 + #41 — apply per-site policy (severity overrides + suppressions)
|
|
201
|
+
# BEFORE the console render so what the user sees matches what the
|
|
202
|
+
# reporters write.
|
|
203
|
+
try:
|
|
204
|
+
from . import policy as _policy
|
|
205
|
+
pol = _policy.load()
|
|
206
|
+
if pol and not pol.get("_error"):
|
|
207
|
+
n_overrides = _policy.apply_severity_overrides(report, pol)
|
|
208
|
+
n_suppressed = _policy.apply_suppressions(report, pol)
|
|
209
|
+
if (n_overrides or n_suppressed) and not args.no_console:
|
|
210
|
+
console.print(
|
|
211
|
+
f"[yellow]Policy applied: "
|
|
212
|
+
f"{n_overrides} severity override(s), "
|
|
213
|
+
f"{n_suppressed} finding(s) suppressed.[/yellow]"
|
|
214
|
+
)
|
|
215
|
+
elif pol.get("_error") and not args.no_console:
|
|
216
|
+
console.print(f"[yellow]policy.yml: {pol['_error']}[/yellow]")
|
|
217
|
+
except Exception: # noqa: BLE001 — policy failures must not break the scan
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
if not args.no_console:
|
|
221
|
+
console_reporter.render(report, console)
|
|
222
|
+
|
|
223
|
+
# FEAT-010: --ai-explain-for {client,dev,exec} attaches plain-English
|
|
224
|
+
# rewrites to high+critical findings before any reporter renders.
|
|
225
|
+
if getattr(args, "ai_explain_for", None):
|
|
226
|
+
try:
|
|
227
|
+
from . import ai_assist as _ai
|
|
228
|
+
n = _ai.client_summarize_report(report, audience=args.ai_explain_for)
|
|
229
|
+
if not args.no_console:
|
|
230
|
+
if n:
|
|
231
|
+
console.print(f"[green]✓[/green] AI explainer ({args.ai_explain_for}): "
|
|
232
|
+
f"rewrote {n} finding(s) into plain English")
|
|
233
|
+
else:
|
|
234
|
+
console.print("[yellow]Note: --ai-explain-for produced no summaries "
|
|
235
|
+
"(no LLM backend configured, or no high-severity findings).[/yellow]")
|
|
236
|
+
except Exception: # noqa: BLE001 — AI is opt-in, must never break a scan
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
out_dir = _outdir(args.out)
|
|
240
|
+
stem = _stem(target, args.out)
|
|
241
|
+
html_path: str | None = None
|
|
242
|
+
|
|
243
|
+
# --dashboard needs per-site HTML files to link to. If the user also
|
|
244
|
+
# passed --json-only, prefer the dashboard link over the JSON-only intent.
|
|
245
|
+
if args.json_only and args.dashboard and not args.no_console:
|
|
246
|
+
console.print("[yellow]Note: --dashboard requires per-site HTML files; --json-only is being overridden for HTML output.[/yellow]")
|
|
247
|
+
want_html = (not args.json_only) or args.dashboard
|
|
248
|
+
if want_html:
|
|
249
|
+
html_p = out_dir / f"{stem}.html"
|
|
250
|
+
html_reporter.write(report, html_p)
|
|
251
|
+
html_path = html_p.name
|
|
252
|
+
if not args.no_console:
|
|
253
|
+
console.print(f"[green]✓[/green] HTML report: [bold]{html_p}[/bold]")
|
|
254
|
+
|
|
255
|
+
if not args.html_only:
|
|
256
|
+
json_p = out_dir / f"{stem}.json"
|
|
257
|
+
json_reporter.write(report, json_p)
|
|
258
|
+
if not args.no_console:
|
|
259
|
+
console.print(f"[green]✓[/green] JSON report: [bold]{json_p}[/bold]")
|
|
260
|
+
|
|
261
|
+
# Persist a timestamped snapshot under ~/.wpsecscan/reports/ so
|
|
262
|
+
# `wpsecscan compare URL` and the GUI trend window can find prior scans.
|
|
263
|
+
# Previously only the GUI called this — CLI users found `compare` always
|
|
264
|
+
# reported "0 saved snapshots".
|
|
265
|
+
try:
|
|
266
|
+
from . import history as _history_mod
|
|
267
|
+
_history_mod.save_report_snapshot(target, json_reporter.render(report))
|
|
268
|
+
except Exception: # noqa: BLE001 — snapshot persistence must never break the scan
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
if args.csv:
|
|
272
|
+
csv_p = out_dir / f"{stem}.csv"
|
|
273
|
+
csv_reporter.write(report, csv_p)
|
|
274
|
+
if not args.no_console:
|
|
275
|
+
console.print(f"[green]✓[/green] CSV report: [bold]{csv_p}[/bold]")
|
|
276
|
+
|
|
277
|
+
if args.sarif:
|
|
278
|
+
sf = out_dir / f"{stem}.sarif"
|
|
279
|
+
sarif_reporter.write(report, sf)
|
|
280
|
+
if not args.no_console:
|
|
281
|
+
console.print(f"[green]✓[/green] SARIF report: [bold]{sf}[/bold]")
|
|
282
|
+
|
|
283
|
+
if args.md:
|
|
284
|
+
md_p = out_dir / f"{stem}.md"
|
|
285
|
+
md_reporter.write(report, md_p, top_n=args.md_top)
|
|
286
|
+
if not args.no_console:
|
|
287
|
+
console.print(f"[green]✓[/green] Markdown report: [bold]{md_p}[/bold]")
|
|
288
|
+
|
|
289
|
+
if args.xlsx:
|
|
290
|
+
xlsx_p = out_dir / f"{stem}.xlsx"
|
|
291
|
+
xlsx_reporter.write(report, xlsx_p)
|
|
292
|
+
if not args.no_console:
|
|
293
|
+
console.print(f"[green]✓[/green] Excel report: [bold]{xlsx_p}[/bold]")
|
|
294
|
+
|
|
295
|
+
if getattr(args, "burp_export", False):
|
|
296
|
+
from .reporters import burp_export as _burp
|
|
297
|
+
burp_p = out_dir / f"{stem}-burp-scope.xml"
|
|
298
|
+
_burp.write(report, burp_p)
|
|
299
|
+
if not args.no_console:
|
|
300
|
+
console.print(f"[green]✓[/green] Burp Suite scope: [bold]{burp_p}[/bold]")
|
|
301
|
+
|
|
302
|
+
if getattr(args, "exec_pdf", False):
|
|
303
|
+
from .reporters import exec_pdf as _epdf
|
|
304
|
+
pdf_p = out_dir / f"{stem}-exec.pdf"
|
|
305
|
+
_epdf.write(report, pdf_p)
|
|
306
|
+
# If reportlab wasn't available, exec_pdf falls back to .html
|
|
307
|
+
actual = pdf_p if pdf_p.exists() else pdf_p.with_suffix(".html")
|
|
308
|
+
if not args.no_console:
|
|
309
|
+
console.print(f"[green]✓[/green] Executive summary: [bold]{actual}[/bold]")
|
|
310
|
+
|
|
311
|
+
if getattr(args, "docx", False):
|
|
312
|
+
from .reporters import docx_report as _dx
|
|
313
|
+
docx_p = out_dir / f"{stem}.docx"
|
|
314
|
+
_dx.write(report, docx_p)
|
|
315
|
+
actual = docx_p if docx_p.exists() else docx_p.with_suffix(".rtf")
|
|
316
|
+
if not args.no_console:
|
|
317
|
+
console.print(f"[green]✓[/green] Word-compatible report: [bold]{actual}[/bold]")
|
|
318
|
+
|
|
319
|
+
# N40 attestation
|
|
320
|
+
if getattr(args, "attestation", None):
|
|
321
|
+
from .reporters import attestation as _att
|
|
322
|
+
att_p = out_dir / args.attestation if not Path(args.attestation).is_absolute() else Path(args.attestation)
|
|
323
|
+
_att.write(report, att_p,
|
|
324
|
+
vendor=args.attestation_vendor or "WPSecScan",
|
|
325
|
+
customer=args.attestation_customer)
|
|
326
|
+
actual = att_p if att_p.exists() else att_p.with_suffix(".html")
|
|
327
|
+
if not args.no_console:
|
|
328
|
+
console.print(f"[green]✓[/green] Attestation: [bold]{actual}[/bold]")
|
|
329
|
+
|
|
330
|
+
# N41 auto-PR
|
|
331
|
+
if getattr(args, "auto_pr", False) and getattr(args, "auto_pr_repo", None):
|
|
332
|
+
from . import auto_pr as _ap
|
|
333
|
+
pr_p = out_dir / f"{stem}-auto-pr.sh"
|
|
334
|
+
_ap.write_script(report, pr_p, repo=args.auto_pr_repo)
|
|
335
|
+
if not args.no_console:
|
|
336
|
+
console.print(f"[green]✓[/green] Auto-PR script (review before running): [bold]{pr_p}[/bold]")
|
|
337
|
+
|
|
338
|
+
# #35 — direct issue-tracker push with idempotency cache.
|
|
339
|
+
push_results: list[tuple[str, list[dict]]] = []
|
|
340
|
+
if getattr(args, "push_jira", None):
|
|
341
|
+
try:
|
|
342
|
+
base, project, email = [s.strip() for s in args.push_jira.split(",", 2)]
|
|
343
|
+
from . import issue_push as _ip
|
|
344
|
+
from .reporters.issue_export import jira_payloads
|
|
345
|
+
payloads = jira_payloads(report, project, getattr(args, "push_min_sev", "high"))
|
|
346
|
+
push_results.append(("jira", _ip.push_jira(target, payloads,
|
|
347
|
+
base_url=base, email=email)))
|
|
348
|
+
except (ValueError, Exception) as e: # noqa: BLE001
|
|
349
|
+
console.print(f"[yellow]--push-jira failed: {e}[/yellow]")
|
|
350
|
+
if getattr(args, "push_linear", None):
|
|
351
|
+
try:
|
|
352
|
+
from . import issue_push as _ip
|
|
353
|
+
from .reporters.issue_export import linear_payloads
|
|
354
|
+
payloads = linear_payloads(report, args.push_linear, getattr(args, "push_min_sev", "high"))
|
|
355
|
+
push_results.append(("linear", _ip.push_linear(target, payloads)))
|
|
356
|
+
except Exception as e: # noqa: BLE001
|
|
357
|
+
console.print(f"[yellow]--push-linear failed: {e}[/yellow]")
|
|
358
|
+
if getattr(args, "push_servicenow", None):
|
|
359
|
+
try:
|
|
360
|
+
from . import issue_push as _ip
|
|
361
|
+
payloads = _ip.servicenow_payloads(report, getattr(args, "push_min_sev", "high"))
|
|
362
|
+
push_results.append(("servicenow", _ip.push_servicenow(target, payloads,
|
|
363
|
+
instance=args.push_servicenow)))
|
|
364
|
+
except Exception as e: # noqa: BLE001
|
|
365
|
+
console.print(f"[yellow]--push-servicenow failed: {e}[/yellow]")
|
|
366
|
+
if getattr(args, "push_github", None):
|
|
367
|
+
try:
|
|
368
|
+
from . import issue_push as _ip
|
|
369
|
+
from .reporters.issue_export import github_payloads
|
|
370
|
+
payloads = github_payloads(report, getattr(args, "push_min_sev", "high"))
|
|
371
|
+
push_results.append(("github", _ip.push_github(target, payloads,
|
|
372
|
+
repo=args.push_github)))
|
|
373
|
+
except Exception as e: # noqa: BLE001
|
|
374
|
+
console.print(f"[yellow]--push-github failed: {e}[/yellow]")
|
|
375
|
+
for system, results in push_results:
|
|
376
|
+
ok = sum(1 for r in results if r.get("ok"))
|
|
377
|
+
skipped = sum(1 for r in results if r.get("skipped"))
|
|
378
|
+
if not args.no_console:
|
|
379
|
+
console.print(f"[green]✓[/green] Pushed to {system}: "
|
|
380
|
+
f"{ok - skipped} new / {skipped} cached-dedupe / "
|
|
381
|
+
f"{len(results) - ok} failed")
|
|
382
|
+
# Log first failure body for diagnosis
|
|
383
|
+
for r in results:
|
|
384
|
+
if not r.get("ok"):
|
|
385
|
+
console.print(f" [yellow]{system} error:[/yellow] {r.get('error', r)}")
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
# FEAT-003: --notion-database emits a Notion-API curl script
|
|
389
|
+
if getattr(args, "notion_database", None):
|
|
390
|
+
from .reporters import issue_export as _ix
|
|
391
|
+
notion_p = out_dir / f"{stem}-notion.sh"
|
|
392
|
+
notion_p.write_text(
|
|
393
|
+
"#!/usr/bin/env bash\n"
|
|
394
|
+
f"# WPSecScan → Notion DB ({args.notion_database}). Review before running.\n"
|
|
395
|
+
f"# Required env: NOTION_TOKEN (from notion.so/my-integrations)\n"
|
|
396
|
+
"#\n"
|
|
397
|
+
"# The Notion database must have a title property; default name is 'Name'.\n"
|
|
398
|
+
"# Override with --notion-title-prop if your DB uses a different title column.\n"
|
|
399
|
+
"set -euo pipefail\n\n"
|
|
400
|
+
+ "\n\n".join(_ix.notion_curl_commands(
|
|
401
|
+
report,
|
|
402
|
+
args.notion_database,
|
|
403
|
+
title_property=getattr(args, "notion_title_prop", "Name"),
|
|
404
|
+
min_sev=getattr(args, "notion_min_sev", "medium"),
|
|
405
|
+
))
|
|
406
|
+
+ "\n",
|
|
407
|
+
encoding="utf-8",
|
|
408
|
+
)
|
|
409
|
+
if not args.no_console:
|
|
410
|
+
console.print(f"[green]✓[/green] Notion export script (review before running): [bold]{notion_p}[/bold]")
|
|
411
|
+
|
|
412
|
+
return console_reporter.exit_code(report, fail_on=args.fail_on), report, html_path
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def _amain(args) -> int:
|
|
416
|
+
console = Console(no_color=args.no_color, legacy_windows=False)
|
|
417
|
+
|
|
418
|
+
# J19: opportunistic update check (silent on no-update / on failure)
|
|
419
|
+
if not getattr(args, "no_update_check", False):
|
|
420
|
+
try:
|
|
421
|
+
from . import auto_update as _au
|
|
422
|
+
note = _au.notice(__version__)
|
|
423
|
+
if note and not args.no_console:
|
|
424
|
+
console.print(f"[yellow]Update available:[/yellow] {note}")
|
|
425
|
+
except Exception: # noqa: BLE001
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
# N39: region-egress warning when WPSECSCAN_REGION is set but no proxy is wired
|
|
429
|
+
try:
|
|
430
|
+
from . import region_egress as _re
|
|
431
|
+
if getattr(args, "region", None):
|
|
432
|
+
import os as _os
|
|
433
|
+
_os.environ["WPSECSCAN_REGION"] = args.region
|
|
434
|
+
warn = _re.warn_if_unenforced()
|
|
435
|
+
if warn and not args.no_console:
|
|
436
|
+
console.print(f"[yellow]Region warning:[/yellow] {warn}")
|
|
437
|
+
except Exception: # noqa: BLE001
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
# Validate --prove flags before any file I/O
|
|
441
|
+
if args.prove:
|
|
442
|
+
if args.file:
|
|
443
|
+
console.print("[red]--prove is single-target only (refuses to batch). Drop --file.[/red]")
|
|
444
|
+
return 64
|
|
445
|
+
if not args.aggressive:
|
|
446
|
+
console.print("[red]--prove requires --aggressive (proof needs a confirmed finding to act on).[/red]")
|
|
447
|
+
target_hint = args.target or "<URL>"
|
|
448
|
+
console.print(f"[yellow]Hint: try wpsecscan {target_hint} --aggressive --prove[/yellow]")
|
|
449
|
+
return 64
|
|
450
|
+
|
|
451
|
+
targets: list[str] = []
|
|
452
|
+
if args.target:
|
|
453
|
+
targets.append(args.target)
|
|
454
|
+
if args.file:
|
|
455
|
+
for line in Path(args.file).read_text(encoding="utf-8").splitlines():
|
|
456
|
+
line = line.strip()
|
|
457
|
+
if line and not line.startswith("#"):
|
|
458
|
+
targets.append(line)
|
|
459
|
+
if not targets:
|
|
460
|
+
console.print("[red]No target provided. Pass a URL or --file <list.txt>.[/red]")
|
|
461
|
+
return 64
|
|
462
|
+
|
|
463
|
+
worst = 0
|
|
464
|
+
all_reports: list = [] # list of (report, html_filename)
|
|
465
|
+
site_concurrency = max(1, int(getattr(args, "site_concurrency", 1) or 1))
|
|
466
|
+
if len(targets) > 1 and site_concurrency > 1:
|
|
467
|
+
# Parallel batch mode. Use a Semaphore so we don't accidentally
|
|
468
|
+
# DDoS a CDN by scanning dozens of sites at once.
|
|
469
|
+
import asyncio as _asyncio
|
|
470
|
+
sem = _asyncio.Semaphore(site_concurrency)
|
|
471
|
+
async def _run_one(tgt: str):
|
|
472
|
+
async with sem:
|
|
473
|
+
try:
|
|
474
|
+
from . import check_health as _ch
|
|
475
|
+
_ch.reset_run()
|
|
476
|
+
except ImportError:
|
|
477
|
+
pass
|
|
478
|
+
return await _scan_one(tgt, args, console)
|
|
479
|
+
if not args.no_console:
|
|
480
|
+
console.print(f"[dim]Batch mode: scanning {len(targets)} sites with concurrency {site_concurrency}[/dim]")
|
|
481
|
+
results = await _asyncio.gather(*[_run_one(t) for t in targets])
|
|
482
|
+
for code, report, html_filename in results:
|
|
483
|
+
worst = max(worst, code)
|
|
484
|
+
if report and html_filename:
|
|
485
|
+
all_reports.append((report, html_filename))
|
|
486
|
+
else:
|
|
487
|
+
for t in targets:
|
|
488
|
+
if len(targets) > 1:
|
|
489
|
+
console.rule(f"[bold cyan]{t}")
|
|
490
|
+
# C2: clear J20/J21 per-scan state so a check auto-disabled on
|
|
491
|
+
# target N doesn't stay disabled for target N+1 in a batch.
|
|
492
|
+
try:
|
|
493
|
+
from . import check_health as _ch
|
|
494
|
+
_ch.reset_run()
|
|
495
|
+
except ImportError:
|
|
496
|
+
pass
|
|
497
|
+
code, report, html_filename = await _scan_one(t, args, console)
|
|
498
|
+
worst = max(worst, code)
|
|
499
|
+
if report and html_filename:
|
|
500
|
+
all_reports.append((report, html_filename))
|
|
501
|
+
|
|
502
|
+
if (args.dashboard or args.agency_dashboard) and all_reports:
|
|
503
|
+
out_dir = _outdir(args.out)
|
|
504
|
+
agency_mode = bool(args.agency_dashboard)
|
|
505
|
+
fname = "wpsecscan-agency-dashboard.html" if agency_mode else "wpsecscan-dashboard.html"
|
|
506
|
+
dpath = out_dir / fname
|
|
507
|
+
dashboard_reporter.write(all_reports, dpath, agency=agency_mode)
|
|
508
|
+
if not args.no_console:
|
|
509
|
+
label = "Agency dashboard" if agency_mode else "Batch dashboard"
|
|
510
|
+
console.print(f"[green]✓[/green] {label}: [bold]{dpath}[/bold]")
|
|
511
|
+
|
|
512
|
+
# FEAT-019: --diff-since 7d — automatically pick the right historical
|
|
513
|
+
# snapshot from ~/.wpsecscan/reports/{safe}-*.json and use it as the
|
|
514
|
+
# baseline. Composes with normal scan flow.
|
|
515
|
+
if getattr(args, "diff_since", None) and all_reports:
|
|
516
|
+
try:
|
|
517
|
+
from . import history as _hmod
|
|
518
|
+
from .diff import diff_dicts as _diff_dicts2
|
|
519
|
+
import re as _re_dur, json as _j_dur
|
|
520
|
+
from datetime import datetime as _dt_dur, timedelta as _td_dur, timezone as _tz_dur
|
|
521
|
+
m = _re_dur.match(r"^(\d+)([hdw])$", args.diff_since.strip())
|
|
522
|
+
if not m:
|
|
523
|
+
console.print(f"[red]--diff-since: invalid WINDOW '{args.diff_since}' (use e.g. 7d, 24h, 2w)[/red]")
|
|
524
|
+
else:
|
|
525
|
+
qty, unit = int(m.group(1)), m.group(2)
|
|
526
|
+
hours = qty * (1 if unit == "h" else 24 if unit == "d" else 24 * 7)
|
|
527
|
+
cutoff = _dt_dur.now(_tz_dur.utc) - _td_dur(hours=hours)
|
|
528
|
+
# Pick the most recent snapshot OLDER than the cutoff
|
|
529
|
+
target_url = all_reports[-1][0].target
|
|
530
|
+
snaps = _hmod.snapshot_history(target_url)
|
|
531
|
+
older = [p for p in snaps if _dt_dur.fromtimestamp(
|
|
532
|
+
p.stat().st_mtime, tz=_tz_dur.utc) < cutoff]
|
|
533
|
+
if not older:
|
|
534
|
+
console.print(f"[yellow]--diff-since {args.diff_since}: no snapshots older than that window for {target_url}[/yellow]")
|
|
535
|
+
else:
|
|
536
|
+
baseline_path = older[-1]
|
|
537
|
+
baseline = _j_dur.loads(baseline_path.read_text(encoding="utf-8"))
|
|
538
|
+
current = json_reporter._enrich(all_reports[-1][0])
|
|
539
|
+
delta = _diff_dicts2(baseline, current)
|
|
540
|
+
console.print(f"\n[bold cyan]--- DIFF vs {baseline_path.name} ({args.diff_since} window) ---[/bold cyan]")
|
|
541
|
+
console.print(f"[red]NEW ({len(delta['new'])}):[/red]")
|
|
542
|
+
for f in delta["new"][:30]:
|
|
543
|
+
console.print(f" + [{f.get('severity','?').upper()}] {f.get('title','?')[:100]}")
|
|
544
|
+
console.print(f"[green]RESOLVED ({len(delta['resolved'])}):[/green]")
|
|
545
|
+
for f in delta["resolved"][:30]:
|
|
546
|
+
console.print(f" - [{f.get('severity','?').upper()}] {f.get('title','?')[:100]}")
|
|
547
|
+
except (OSError, ValueError, TypeError) as e:
|
|
548
|
+
console.print(f"[red]--diff-since failed: {e}[/red]")
|
|
549
|
+
|
|
550
|
+
# D4: --diff-against — compare the most recent scan against a saved baseline.
|
|
551
|
+
if args.diff_against and all_reports:
|
|
552
|
+
try:
|
|
553
|
+
import json as _j
|
|
554
|
+
from .diff import diff_dicts as _diff_dicts
|
|
555
|
+
baseline = _j.loads(Path(args.diff_against).read_text(encoding="utf-8"))
|
|
556
|
+
current = json_reporter._enrich(all_reports[-1][0])
|
|
557
|
+
delta = _diff_dicts(baseline, current)
|
|
558
|
+
console.print("\n[bold cyan]--- DIFF vs baseline ---[/bold cyan]")
|
|
559
|
+
console.print(f"[red]NEW ({len(delta['new'])}):[/red]")
|
|
560
|
+
for f in delta["new"][:30]:
|
|
561
|
+
console.print(f" + [{f.get('severity','?').upper()}] {f.get('title','?')[:100]}")
|
|
562
|
+
if len(delta["new"]) > 30:
|
|
563
|
+
console.print(f" [dim]... and {len(delta['new']) - 30} more[/dim]")
|
|
564
|
+
console.print(f"[green]RESOLVED ({len(delta['resolved'])}):[/green]")
|
|
565
|
+
for f in delta["resolved"][:30]:
|
|
566
|
+
console.print(f" - [{f.get('severity','?').upper()}] {f.get('title','?')[:100]}")
|
|
567
|
+
if len(delta["resolved"]) > 30:
|
|
568
|
+
console.print(f" [dim]... and {len(delta['resolved']) - 30} more[/dim]")
|
|
569
|
+
except (OSError, ValueError, TypeError) as e:
|
|
570
|
+
console.print(f"[red]--diff-against failed: {e}[/red]")
|
|
571
|
+
|
|
572
|
+
# L30: --query — print findings matching the filter expression
|
|
573
|
+
if getattr(args, "query", None) and all_reports:
|
|
574
|
+
try:
|
|
575
|
+
from . import report_query as _rq
|
|
576
|
+
results = _rq.query(all_reports[-1][0], args.query)
|
|
577
|
+
console.print(f"\n[bold cyan]--- QUERY '{args.query}' ({len(results)} match) ---[/bold cyan]")
|
|
578
|
+
for r in results[:100]:
|
|
579
|
+
console.print(f" [{r.get('severity','?').upper()}] {r.get('check_id','?')}: {r.get('title','?')[:100]}")
|
|
580
|
+
if len(results) > 100:
|
|
581
|
+
console.print(f" [dim]... and {len(results) - 100} more match(es)[/dim]")
|
|
582
|
+
except (ValueError, Exception) as e: # noqa: BLE001
|
|
583
|
+
console.print(f"[red]--query failed: {e}[/red]")
|
|
584
|
+
|
|
585
|
+
# F1: --shell — drop into a Python REPL with the last scan loaded.
|
|
586
|
+
if args.shell and all_reports:
|
|
587
|
+
import code
|
|
588
|
+
report = all_reports[-1][0]
|
|
589
|
+
banner = (
|
|
590
|
+
"\n=== WPSecScan interactive shell ===\n"
|
|
591
|
+
f"report = ScanReport for {report.target}, {len(report.results)} check results\n"
|
|
592
|
+
"Try: report.summary | report.risk_score | "
|
|
593
|
+
"[f for r in report.results for f in r.findings if f.severity=='high']\n"
|
|
594
|
+
)
|
|
595
|
+
code.interact(banner=banner, local={"report": report, "wpsecscan": __import__("wpsecscan")})
|
|
596
|
+
|
|
597
|
+
return worst
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def main() -> None:
|
|
601
|
+
# ---- Subcommand dispatch (round-60): keep before argparse so existing
|
|
602
|
+
# `wpsecscan <url>` invocations stay backward-compatible.
|
|
603
|
+
if len(sys.argv) >= 2 and sys.argv[1] in (
|
|
604
|
+
"sites", "schedule", "digest", "ai-cost", "db", "ai-options", "analytics",
|
|
605
|
+
"compare", "badge", "paths", "report", "annotate", "check", "config",
|
|
606
|
+
"verify-release", "watch", "portfolio", "refix", "snooze", "diff-tree",
|
|
607
|
+
"pr-comment", "publish",
|
|
608
|
+
):
|
|
609
|
+
_dispatch_subcommand(sys.argv[1], sys.argv[2:])
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
p = argparse.ArgumentParser(
|
|
613
|
+
prog="wpsecscan",
|
|
614
|
+
description=(
|
|
615
|
+
"WPSecScan — defensive WordPress security scanner. "
|
|
616
|
+
"Use only on sites you own or have written permission to test."
|
|
617
|
+
),
|
|
618
|
+
epilog=(
|
|
619
|
+
"Subcommands (use as: wpsecscan <subcommand> <args>):\n"
|
|
620
|
+
" sites manage a list of sites (add | list | scan | remove)\n"
|
|
621
|
+
" schedule install/uninstall scheduled scans (Windows Task Scheduler etc.)\n"
|
|
622
|
+
" digest configure SMTP / webhook digest of new findings\n"
|
|
623
|
+
" ai-cost print AI-triage cost summary\n"
|
|
624
|
+
" ai-options read/set Advanced AI-triage toggles\n"
|
|
625
|
+
" analytics manage opt-in usage analytics\n"
|
|
626
|
+
" db vuln DB management (status | update | signatures | source-stats | subscribe | alert-check)\n"
|
|
627
|
+
" compare URL diff the two most-recent saved snapshots of URL\n"
|
|
628
|
+
" badge URL emit a shields.io-style status-badge SVG\n"
|
|
629
|
+
"\nRun wpsecscan <subcommand> --help for per-subcommand usage.\n"
|
|
630
|
+
),
|
|
631
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
632
|
+
)
|
|
633
|
+
p.add_argument("target", nargs="?", help="URL to scan (e.g. https://example.com)")
|
|
634
|
+
p.add_argument("--file", help="File containing URLs, one per line (# comments OK)")
|
|
635
|
+
p.add_argument("--out", help="Output directory or filename stem")
|
|
636
|
+
p.add_argument("--timeout", type=float, default=15.0, help="Per-request timeout seconds (default 15)")
|
|
637
|
+
p.add_argument("--concurrency", type=int, default=10, help="Concurrent requests per host (default 10)")
|
|
638
|
+
p.add_argument("--user-agent", default=f"WPSecScan/{__version__} (+defensive-recon)", help="HTTP User-Agent")
|
|
639
|
+
|
|
640
|
+
p.add_argument("--wpscan-token", default=os.environ.get("WPSECSCAN_WPSCAN_TOKEN"),
|
|
641
|
+
help="WPScan API token (env: WPSECSCAN_WPSCAN_TOKEN)")
|
|
642
|
+
p.add_argument("--patchstack-token", default=os.environ.get("WPSECSCAN_PATCHSTACK_TOKEN"),
|
|
643
|
+
help="Patchstack API token (env: WPSECSCAN_PATCHSTACK_TOKEN) — merges Patchstack CVE data into the vuln DB on --update-db")
|
|
644
|
+
p.add_argument("--hibp-token", default=os.environ.get("WPSECSCAN_HIBP_TOKEN"),
|
|
645
|
+
help="HaveIBeenPwned API key (env: WPSECSCAN_HIBP_TOKEN). Otherwise we emit manual-check links.")
|
|
646
|
+
p.add_argument("--deep-throttle", action="store_true", help="Run the deep throttle mapping (N wrong-password attempts for a synthetic non-existent user). Reports the actual rate-limit threshold.")
|
|
647
|
+
p.add_argument("--deep-throttle-attempts", type=int, default=120, metavar="N", help="How many wrong-login attempts the deep throttle test sends (10-500, default 120). Multiply by --deep-throttle-pacing for total runtime.")
|
|
648
|
+
p.add_argument("--deep-throttle-pacing", type=float, default=10.0, metavar="SECONDS", help="Seconds between deep-throttle attempts (5-60, default 10). Below 5s tends to trip network-layer fail2ban before HTTP-layer throttling shows.")
|
|
649
|
+
p.add_argument("--aggressive", action="store_true", help="Enable active checks: SQLi, XSS, SSRF, path traversal, open redirect, upload probes, default-credentials probe (≤10 attempts).")
|
|
650
|
+
p.add_argument("--prove", action="store_true", help="For each confirmed aggressive finding, run a read-only proof helper (single-target only; requires --aggressive). Never writes to the target.")
|
|
651
|
+
# Sensitive flags read from env vars when not given on the command line —
|
|
652
|
+
# use env to avoid leaking secrets via `ps aux` / shell history.
|
|
653
|
+
p.add_argument("--auth-user", default=os.environ.get("WPSECSCAN_AUTH_USER"),
|
|
654
|
+
help="Admin username (env: WPSECSCAN_AUTH_USER)")
|
|
655
|
+
p.add_argument("--auth-pass", default=os.environ.get("WPSECSCAN_AUTH_PASS"),
|
|
656
|
+
help="Admin password (env: WPSECSCAN_AUTH_PASS). Use `-` to read from stdin via getpass.")
|
|
657
|
+
p.add_argument("--auth-app-password", default=os.environ.get("WPSECSCAN_AUTH_APP_PASSWORD"),
|
|
658
|
+
help="WP Application Password (env: WPSECSCAN_AUTH_APP_PASSWORD). Spaces are stripped.")
|
|
659
|
+
p.add_argument("--auth-totp", default=os.environ.get("WPSECSCAN_AUTH_TOTP"),
|
|
660
|
+
help="6-digit TOTP code (env: WPSECSCAN_AUTH_TOTP).")
|
|
661
|
+
p.add_argument("--companion-token", default=os.environ.get("WPSECSCAN_COMPANION_TOKEN"),
|
|
662
|
+
help="One-time companion plugin token (env: WPSECSCAN_COMPANION_TOKEN).")
|
|
663
|
+
p.add_argument("--proxy", default=None,
|
|
664
|
+
help="Proxy URL — http://, https://, or socks5:// (also reads WPSECSCAN_PROXY_URL / HTTP_PROXY env vars).")
|
|
665
|
+
p.add_argument("--proxy-auth", default=os.environ.get("WPSECSCAN_PROXY_AUTH"),
|
|
666
|
+
help="Optional 'user:pass' for the proxy (env: WPSECSCAN_PROXY_AUTH). Injected into the URL; password is URL-encoded.")
|
|
667
|
+
p.add_argument("--ssh-audit", default=None, metavar="user@host", help="Connect via ssh and run a read-only wp-cli audit (uses system ssh client, BatchMode=yes).")
|
|
668
|
+
p.add_argument("--password-audit", default=None, metavar="WP_USERS.csv", help="Offline: read a CSV or SQL dump of wp_users and emit a hashcat-ready file. NO network calls.")
|
|
669
|
+
|
|
670
|
+
p.add_argument("--insecure", action="store_true", help="Don't verify TLS certs")
|
|
671
|
+
# --quiet / -q is the conventional name; --no-console is kept for back-compat.
|
|
672
|
+
p.add_argument("--quiet", "-q", "--no-console", dest="no_console", action="store_true",
|
|
673
|
+
help="Suppress console output (still writes report files)")
|
|
674
|
+
p.add_argument("-v", "--verbose", action="count", default=0,
|
|
675
|
+
help="Increase console verbosity (-v shows per-check progress, -vv shows HTTP-level detail). "
|
|
676
|
+
"Independent of --debug (which writes a log file).")
|
|
677
|
+
p.add_argument("--no-color", action="store_true", help="Disable colored console output")
|
|
678
|
+
|
|
679
|
+
p.add_argument("--csv", action="store_true", help="Also write CSV report (formula-injection neutralised)")
|
|
680
|
+
p.add_argument("--sarif", action="store_true", help="Also write SARIF 2.1.0 report")
|
|
681
|
+
p.add_argument("--md", action="store_true", help="Also write a Markdown report (handy for tickets / PRs / Slack)")
|
|
682
|
+
p.add_argument("--md-top", type=int, default=None, metavar="N",
|
|
683
|
+
help="Truncate the Markdown report to the top-N findings by severity "
|
|
684
|
+
"(useful for Slack/Discord's 4000-char message limit).")
|
|
685
|
+
p.add_argument("--xlsx", action="store_true", help="Also write an Excel workbook with per-OWASP-category sheets")
|
|
686
|
+
p.add_argument("--har", default=None, metavar="HAR_FILE", help="Record every HTTP request/response into a HAR file for debugging or replay")
|
|
687
|
+
p.add_argument("--parallel-groups", action="store_true",
|
|
688
|
+
help="Run within-group checks concurrently (~30%% faster on typical scans; default sequential). "
|
|
689
|
+
"Warning: concurrent same-host requests may trigger WAF rate-limits on strict hosts.")
|
|
690
|
+
p.add_argument("--site-concurrency", type=int, default=1, metavar="N",
|
|
691
|
+
help="When scanning multiple sites via --file, run up to N in parallel (default 1 = serial). "
|
|
692
|
+
"Each site still uses --concurrency per-host. Trade off throughput against being a polite neighbour.")
|
|
693
|
+
p.add_argument("--dry-run", action="store_true",
|
|
694
|
+
help="Validate config + print the list of checks that would run against the target, then exit. "
|
|
695
|
+
"Does not perform any HTTP requests; safe to run against any URL.")
|
|
696
|
+
p.add_argument("--continuous", action="store_true",
|
|
697
|
+
help="FEAT-036: poll the companion plugin's /file-monitor endpoint and "
|
|
698
|
+
"report any file changes on plugin/theme directories. Requires "
|
|
699
|
+
"--companion-token. Use --interval N to control polling cadence.")
|
|
700
|
+
p.add_argument("--interval", type=int, default=300, metavar="SECONDS",
|
|
701
|
+
help="Polling interval for --continuous mode (default 300 = 5 minutes).")
|
|
702
|
+
p.add_argument("--checkpoint", action="store_true", help="Save progress to ~/.wpsecscan/checkpoints/ so a Ctrl+C scan can resume on next run")
|
|
703
|
+
p.add_argument("--fail-on", default=None, metavar="LEVEL[,LEVEL]",
|
|
704
|
+
help="Exit with code 2 if any finding is at or above this severity. "
|
|
705
|
+
"Accepts a single value (critical|high|medium|low) or comma-separated list, "
|
|
706
|
+
"e.g. `critical,high`. Overrides the default exit-code logic.")
|
|
707
|
+
p.add_argument("--abuseipdb-token", default=os.environ.get("WPSECSCAN_ABUSEIPDB_TOKEN"),
|
|
708
|
+
help="AbuseIPDB API token (env: WPSECSCAN_ABUSEIPDB_TOKEN). Free tier: 1000/day.")
|
|
709
|
+
p.add_argument("--vt-token", default=os.environ.get("WPSECSCAN_VT_TOKEN"),
|
|
710
|
+
help="VirusTotal API key (env: WPSECSCAN_VT_TOKEN). Free tier: 4 req/min.")
|
|
711
|
+
p.add_argument("--github-search-token", default=os.environ.get("WPSECSCAN_GITHUB_SEARCH_TOKEN"),
|
|
712
|
+
help="GitHub PAT (env: WPSECSCAN_GITHUB_SEARCH_TOKEN, public_repo scope) for the leaked-token search check")
|
|
713
|
+
# --baseline is the clearer name; --diff-against kept for back-compat
|
|
714
|
+
# (and to distinguish from --diff which compares two arbitrary files).
|
|
715
|
+
p.add_argument("--baseline", "--diff-against", dest="diff_against", default=None, metavar="BASELINE.json",
|
|
716
|
+
help="After scan, compute diff vs a saved JSON baseline and emit NEW/RESOLVED to stdout")
|
|
717
|
+
p.add_argument("--shell", action="store_true", help="After scan, drop into an interactive Python REPL with `report`, `client`, `ctx` pre-bound (for power users)")
|
|
718
|
+
p.add_argument("--replay-har", default=None, metavar="HAR_FILE", help="F2: replay every request from a previously-recorded HAR file (use --target to override the origin). Prints per-request status + body-size delta.")
|
|
719
|
+
# ---- Round-55 CLI additions ----
|
|
720
|
+
p.add_argument("--api-server", default=None, metavar="HOST:PORT", help="M34: run the HTTP API server instead of a scan, e.g. 127.0.0.1:8765. Requires --api-token or WPSECSCAN_API_TOKEN.")
|
|
721
|
+
p.add_argument("--api-token", default=None, help="M34: bearer token for the --api-server endpoints. Or set WPSECSCAN_API_TOKEN env.")
|
|
722
|
+
p.add_argument("--region", default=None, help="N39: region tag for compliance-aware egress (resolved via WPSECSCAN_PROXY_<REGION> env).")
|
|
723
|
+
p.add_argument("--sbom", default=None, metavar="OUT.json", help="J23: write a CycloneDX 1.5 SBOM and exit (no scan).")
|
|
724
|
+
p.add_argument("--attestation", default=None, metavar="OUT.pdf", help="N40: write a customer-facing attestation PDF after the scan.")
|
|
725
|
+
p.add_argument("--attestation-vendor", default="WPSecScan", help="Vendor name in the attestation header.")
|
|
726
|
+
p.add_argument("--attestation-customer", default=None, help="Customer name in the attestation header.")
|
|
727
|
+
p.add_argument("--auto-pr", action="store_true", help="N41: after scan, write a shell script of `gh pr create` commands with conservative fixes.")
|
|
728
|
+
p.add_argument("--auto-pr-repo", default=None, metavar="OWNER/NAME", help="Target repo for --auto-pr commands.")
|
|
729
|
+
# FEAT-003: Notion export
|
|
730
|
+
# #35 — direct REST push to issue trackers with idempotency keys.
|
|
731
|
+
p.add_argument("--push-jira", default=None, metavar="BASE_URL,PROJECT,EMAIL",
|
|
732
|
+
help="#35: POST findings to Jira REST. Comma-separated 'https://you.atlassian.net,SEC,you@example.com'. Token via $JIRA_API_TOKEN.")
|
|
733
|
+
p.add_argument("--push-linear", default=None, metavar="TEAM_ID",
|
|
734
|
+
help="#35: POST findings to Linear GraphQL. Token via $LINEAR_API_KEY.")
|
|
735
|
+
p.add_argument("--push-servicenow", default=None, metavar="INSTANCE_HOST",
|
|
736
|
+
help="#35: POST findings to ServiceNow incident table. Auth via $SERVICENOW_USERNAME / $SERVICENOW_PASSWORD.")
|
|
737
|
+
p.add_argument("--push-github", default=None, metavar="OWNER/REPO",
|
|
738
|
+
help="#35: POST findings to a GitHub Issues repo. Token via $GITHUB_TOKEN.")
|
|
739
|
+
p.add_argument("--push-min-sev", default="high", metavar="SEV",
|
|
740
|
+
help="Lowest severity to push to issue trackers (default: high).")
|
|
741
|
+
p.add_argument("--notion-database", default=None, metavar="DATABASE_ID",
|
|
742
|
+
help="FEAT-003: also write a Notion-API curl script that creates one page per "
|
|
743
|
+
"above-threshold finding in the given Notion database. Token via $NOTION_TOKEN.")
|
|
744
|
+
p.add_argument("--notion-title-prop", default="Name", metavar="NAME",
|
|
745
|
+
help="Title column name in the Notion DB (default: 'Name'; Notion's default).")
|
|
746
|
+
p.add_argument("--notion-min-sev", default="medium", metavar="SEV",
|
|
747
|
+
help="Lowest severity to export to Notion (default: medium).")
|
|
748
|
+
p.add_argument("--query", default=None, metavar="EXPR", help="L30: after scan, print only findings matching the GraphQL-style filter expression.")
|
|
749
|
+
p.add_argument("--since", default=None, metavar="YYYY-MM-DD", help="K26: incremental mode; skip low-churn checks for targets whose snapshot is newer than this date.")
|
|
750
|
+
p.add_argument("--completion", default=None, choices=["bash", "zsh", "powershell"], help="O47: print a shell completion script and exit.")
|
|
751
|
+
p.add_argument("--no-update-check", action="store_true", help="J19: skip the GitHub-releases update check at startup.")
|
|
752
|
+
# Round-56 visibility upgrade
|
|
753
|
+
p.add_argument("--demo", action="store_true", help="Round-56: synthetic scan against a fake target so you can see every feature working without scanning a real site. Writes all artifacts to ~/.wpsecscan/demo/.")
|
|
754
|
+
p.add_argument("--no-live", action="store_true", help="Disable the live multi-panel dashboard during scans (falls back to the static console reporter).")
|
|
755
|
+
p.add_argument("--burp-export", action="store_true", help="Also write a Burp Suite scope XML for handoff to manual deep-testing")
|
|
756
|
+
p.add_argument("--exec-pdf", action="store_true", help="Also write a one-page executive summary PDF (uses reportlab if installed; otherwise an HTML print-to-PDF fallback)")
|
|
757
|
+
p.add_argument("--docx", action="store_true", help="#48: also write a Word-compatible report. Uses python-docx when installed; falls back to .rtf otherwise.")
|
|
758
|
+
p.add_argument("--redact-evidence", action="store_true",
|
|
759
|
+
help="#61: mask JWTs / session cookies / bearer tokens / PII in finding evidence before any reporter writes. Recommended when sharing reports externally.")
|
|
760
|
+
p.add_argument("--diff-html", nargs=2, metavar=("OLD.json", "NEW.json"),
|
|
761
|
+
help="#46: render a side-by-side HTML comparison of two snapshots of the SAME site, then exit.")
|
|
762
|
+
p.add_argument("--daemon", default=None, metavar="CONFIG.yml", help="Run as a daemon: schedule scans via cron-style config (see SDK.md)")
|
|
763
|
+
p.add_argument("--dashboard", action="store_true", help="When scanning multiple sites, also write a batch dashboard")
|
|
764
|
+
p.add_argument("--agency-dashboard", action="store_true",
|
|
765
|
+
help="FEAT-015: write an agency-style dashboard with per-site risk-score sparklines + "
|
|
766
|
+
"Δ-vs-prior + brand.json header. Designed to be printed to PDF and handed to a "
|
|
767
|
+
"non-technical client as a monthly posture summary. Implies --dashboard.")
|
|
768
|
+
p.add_argument("--ai-explain-for", default=None, choices=["client", "dev", "exec"],
|
|
769
|
+
metavar="AUDIENCE",
|
|
770
|
+
help="FEAT-010: after the scan, ask the configured LLM (OpenAI/Anthropic/Ollama) "
|
|
771
|
+
"to rewrite each high+critical finding into plain-English text for the given "
|
|
772
|
+
"audience and store it under finding.extra.client_summary. Costs ~25 LLM "
|
|
773
|
+
"calls per scan. Requires WPSECSCAN_OPENAI_API_KEY / ANTHROPIC_API_KEY / "
|
|
774
|
+
"WPSECSCAN_OLLAMA_URL.")
|
|
775
|
+
format_group = p.add_mutually_exclusive_group()
|
|
776
|
+
format_group.add_argument("--json-only", action="store_true", help="Write JSON only (no HTML)")
|
|
777
|
+
format_group.add_argument("--html-only", action="store_true", help="Write HTML only (no JSON)")
|
|
778
|
+
|
|
779
|
+
p.add_argument("--update-db", action="store_true", help="Download the Wordfence Intelligence vulnerability database and exit")
|
|
780
|
+
p.add_argument("--diff", nargs=2, metavar=("OLD.json", "NEW.json"), help="Compare two report JSONs and print the diff, then exit")
|
|
781
|
+
p.add_argument("--diff-since", default=None, metavar="WINDOW",
|
|
782
|
+
help="Diff scan-in-progress against the most recent saved snapshot older than WINDOW "
|
|
783
|
+
"(e.g. `7d`, `24h`, `2w`). Composes with the scan; outputs the delta after the scan completes.")
|
|
784
|
+
p.add_argument("--debug", action="store_true", help="Verbose logging to ~/.wpsecscan/logs/")
|
|
785
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
786
|
+
args = p.parse_args()
|
|
787
|
+
|
|
788
|
+
# O47 --completion is checked FIRST — before logging setup, before any
|
|
789
|
+
# I/O — so the stdout output isn't contaminated by debug-log notices
|
|
790
|
+
# when the user pipes it (`wpsecscan --completion bash > completions/`).
|
|
791
|
+
if getattr(args, "completion", None):
|
|
792
|
+
from .completion import generate
|
|
793
|
+
print(generate(args.completion))
|
|
794
|
+
sys.exit(0)
|
|
795
|
+
|
|
796
|
+
# Read --auth-pass from stdin when the user passes `-`. Stops the
|
|
797
|
+
# password showing up in `ps aux` / shell history. Extracted to a
|
|
798
|
+
# helper so it's directly unit-testable.
|
|
799
|
+
if args.auth_pass == "-":
|
|
800
|
+
args.auth_pass = _read_auth_pass_from_stdin()
|
|
801
|
+
|
|
802
|
+
# --timeout below 5s reliably causes false-positive timeout findings
|
|
803
|
+
# (TLS handshake alone can take 1-2s; a slow plugin can take 3s).
|
|
804
|
+
if args.timeout < 5 and not args.no_console:
|
|
805
|
+
print(f"[warn] --timeout {args.timeout:.1f}s is very short; "
|
|
806
|
+
"expect false-positive timeout findings on real sites.",
|
|
807
|
+
file=sys.stderr)
|
|
808
|
+
|
|
809
|
+
# Validate --since at startup so a typo doesn't silently disable
|
|
810
|
+
# incremental mode and leave the user wondering why their scan ran
|
|
811
|
+
# every check. (_parse_since() returns None on invalid input; we
|
|
812
|
+
# now distinguish "not provided" from "provided but unparseable".)
|
|
813
|
+
if getattr(args, "since", None) and _parse_since(args.since) is None:
|
|
814
|
+
print(f"[warn] --since {args.since!r} could not be parsed as YYYY-MM-DD "
|
|
815
|
+
"or ISO 8601 — incremental mode is OFF for this scan.",
|
|
816
|
+
file=sys.stderr)
|
|
817
|
+
|
|
818
|
+
log_path = logmod.configure(args.debug)
|
|
819
|
+
if log_path:
|
|
820
|
+
print(f"[debug] log: {log_path}", file=sys.stderr)
|
|
821
|
+
|
|
822
|
+
# One-shot modes
|
|
823
|
+
if args.update_db:
|
|
824
|
+
try:
|
|
825
|
+
n, path = vulndb.update_db(verbose=True, patchstack_token=args.patchstack_token or "")
|
|
826
|
+
print(f"OK: {n} vulnerabilities cached at {path}")
|
|
827
|
+
sys.exit(0)
|
|
828
|
+
except Exception as e: # noqa: BLE001
|
|
829
|
+
print(
|
|
830
|
+
f"\n[!] Could not refresh remote DB: {e}\n"
|
|
831
|
+
f" The scanner will continue using the embedded fallback CVE database "
|
|
832
|
+
f"(26 well-known WP plugin CVEs).\n"
|
|
833
|
+
f" As of 2026, the public Wordfence Intelligence endpoint may require an "
|
|
834
|
+
f"account. You can supply --wpscan-token for per-plugin lookups instead.",
|
|
835
|
+
file=sys.stderr,
|
|
836
|
+
)
|
|
837
|
+
# Exit 75 (EX_TEMPFAIL) so CI / update scripts can detect the
|
|
838
|
+
# network-fetch failure. Previously returned 0, hiding the error.
|
|
839
|
+
sys.exit(75)
|
|
840
|
+
|
|
841
|
+
if args.diff:
|
|
842
|
+
old, new = args.diff
|
|
843
|
+
d = diff_mod.diff(Path(old), Path(new))
|
|
844
|
+
print(diff_mod.render_text(d))
|
|
845
|
+
sys.exit(0 if not d["new"] else 1)
|
|
846
|
+
|
|
847
|
+
if getattr(args, "diff_html", None):
|
|
848
|
+
old, new = args.diff_html
|
|
849
|
+
from .reporters import snapshot_compare as _sc
|
|
850
|
+
out_path = Path(args.out or ".") / "wpsecscan-snapshot-diff.html"
|
|
851
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
852
|
+
_sc.write(Path(old), Path(new), out_path)
|
|
853
|
+
print(f"snapshot diff: {out_path}")
|
|
854
|
+
sys.exit(0)
|
|
855
|
+
|
|
856
|
+
if args.password_audit:
|
|
857
|
+
try:
|
|
858
|
+
result = pwaudit.audit(Path(args.password_audit))
|
|
859
|
+
print(result["instructions"])
|
|
860
|
+
sys.exit(0)
|
|
861
|
+
except (FileNotFoundError, ValueError) as e:
|
|
862
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
863
|
+
sys.exit(1)
|
|
864
|
+
|
|
865
|
+
if args.ssh_audit:
|
|
866
|
+
try:
|
|
867
|
+
report = sshaudit.audit(args.ssh_audit)
|
|
868
|
+
except ValueError as e:
|
|
869
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
870
|
+
sys.exit(64)
|
|
871
|
+
# Render to console + write reports under cwd or --out
|
|
872
|
+
console = Console(no_color=args.no_color, legacy_windows=False)
|
|
873
|
+
if not args.no_console:
|
|
874
|
+
console_reporter.render(report, console)
|
|
875
|
+
out_dir = _outdir(args.out)
|
|
876
|
+
stem = _stem(f"ssh-{args.ssh_audit.replace('@', '_at_')}", args.out)
|
|
877
|
+
if not args.json_only:
|
|
878
|
+
html_reporter.write(report, out_dir / f"{stem}.html")
|
|
879
|
+
if not args.html_only:
|
|
880
|
+
json_reporter.write(report, out_dir / f"{stem}.json")
|
|
881
|
+
sys.exit(console_reporter.exit_code(report))
|
|
882
|
+
|
|
883
|
+
# (O47 --completion is handled earlier, before logging setup)
|
|
884
|
+
|
|
885
|
+
# J23 --sbom short-circuit
|
|
886
|
+
if getattr(args, "sbom", None):
|
|
887
|
+
from . import sbom as _sbom
|
|
888
|
+
_sbom.write(Path(args.sbom), scanner_version=__version__)
|
|
889
|
+
print(f"SBOM written to {args.sbom}")
|
|
890
|
+
sys.exit(0)
|
|
891
|
+
|
|
892
|
+
# M34 --api-server short-circuit
|
|
893
|
+
if getattr(args, "api_server", None):
|
|
894
|
+
from .api_server import serve
|
|
895
|
+
try:
|
|
896
|
+
host, port_s = args.api_server.split(":", 1)
|
|
897
|
+
port = int(port_s)
|
|
898
|
+
except ValueError:
|
|
899
|
+
print(f"FATAL: --api-server expects HOST:PORT, got {args.api_server!r}", file=sys.stderr)
|
|
900
|
+
sys.exit(64)
|
|
901
|
+
serve(host=host, port=port, token=getattr(args, "api_token", None))
|
|
902
|
+
sys.exit(0)
|
|
903
|
+
|
|
904
|
+
# --continuous monitor mode — long-running poll loop, no scan.
|
|
905
|
+
if getattr(args, "continuous", False):
|
|
906
|
+
if not args.target:
|
|
907
|
+
print("--continuous requires a target URL.", file=sys.stderr)
|
|
908
|
+
sys.exit(64)
|
|
909
|
+
if not args.companion_token:
|
|
910
|
+
print("--continuous requires --companion-token (or WPSECSCAN_COMPANION_TOKEN env var) "
|
|
911
|
+
"to authenticate against the companion plugin's /file-monitor endpoint.",
|
|
912
|
+
file=sys.stderr)
|
|
913
|
+
sys.exit(64)
|
|
914
|
+
from . import continuous_monitor as _cm
|
|
915
|
+
try:
|
|
916
|
+
sys.exit(asyncio.run(_cm.run(args.target,
|
|
917
|
+
companion_token=args.companion_token,
|
|
918
|
+
interval_s=args.interval)))
|
|
919
|
+
except KeyboardInterrupt:
|
|
920
|
+
print("\nContinuous monitor stopped.", file=sys.stderr)
|
|
921
|
+
sys.exit(130)
|
|
922
|
+
|
|
923
|
+
# --dry-run short-circuit: print what would happen, exit without HTTP.
|
|
924
|
+
if getattr(args, "dry_run", False):
|
|
925
|
+
target = args.target or (args.file and "<first URL in --file>") or "<URL>"
|
|
926
|
+
from .checks import ALL_CHECKS
|
|
927
|
+
passive = [c for c in ALL_CHECKS if not c[3]]
|
|
928
|
+
aggressive = [c for c in ALL_CHECKS if c[3]]
|
|
929
|
+
print(f"WPSecScan dry-run — would scan: {target}")
|
|
930
|
+
print(f"Output dir: {_outdir(args.out)}")
|
|
931
|
+
print(f"Timeout: {args.timeout}s")
|
|
932
|
+
print(f"Per-host concurrency: {args.concurrency}")
|
|
933
|
+
print(f"Site concurrency: {getattr(args, 'site_concurrency', 1)}")
|
|
934
|
+
print(f"Aggressive payloads: {'ON' if args.aggressive else 'OFF'}")
|
|
935
|
+
print(f"Prove-mode: {'ON' if args.prove else 'OFF'}")
|
|
936
|
+
auth_summary = "anonymous"
|
|
937
|
+
if args.companion_token: auth_summary = "companion plugin token"
|
|
938
|
+
elif args.auth_user and (args.auth_pass or args.auth_app_password):
|
|
939
|
+
auth_summary = f"as {args.auth_user}"
|
|
940
|
+
print(f"Auth: {auth_summary}")
|
|
941
|
+
print(f"Proxy: {args.proxy or '(direct)'}")
|
|
942
|
+
print(f"Reports: " + ", ".join(filter(None, [
|
|
943
|
+
"HTML" if not args.json_only else None,
|
|
944
|
+
"JSON" if not args.html_only else None,
|
|
945
|
+
"CSV" if args.csv else None,
|
|
946
|
+
"SARIF" if args.sarif else None,
|
|
947
|
+
"Markdown" if args.md else None,
|
|
948
|
+
"XLSX" if args.xlsx else None,
|
|
949
|
+
"exec-PDF" if args.exec_pdf else None,
|
|
950
|
+
"Burp scope" if args.burp_export else None,
|
|
951
|
+
"Attestation" if args.attestation else None,
|
|
952
|
+
"SBOM" if args.sbom else None,
|
|
953
|
+
])))
|
|
954
|
+
print(f"\nPassive checks ({len(passive)}) would all run:")
|
|
955
|
+
for cid, cname, _fn, _agg in passive[:20]:
|
|
956
|
+
print(f" {cid:30s} {cname}")
|
|
957
|
+
if len(passive) > 20:
|
|
958
|
+
print(f" ... and {len(passive) - 20} more")
|
|
959
|
+
if args.aggressive:
|
|
960
|
+
print(f"\nAggressive checks ({len(aggressive)}) would run:")
|
|
961
|
+
for cid, cname, _fn, _agg in aggressive:
|
|
962
|
+
print(f" {cid:30s} {cname}")
|
|
963
|
+
else:
|
|
964
|
+
print(f"\nAggressive checks ({len(aggressive)}) WOULD NOT run (no --aggressive).")
|
|
965
|
+
print("\nNo HTTP requests have been made. Run without --dry-run to actually scan.")
|
|
966
|
+
sys.exit(0)
|
|
967
|
+
|
|
968
|
+
# Round-56 --demo short-circuit: synthetic scan, no HTTP, all artifacts.
|
|
969
|
+
if getattr(args, "demo", False):
|
|
970
|
+
from . import demo as _demo
|
|
971
|
+
from .reporters import console as console_reporter
|
|
972
|
+
console = Console(no_color=args.no_color, legacy_windows=False)
|
|
973
|
+
use_live = (not args.no_console
|
|
974
|
+
and not getattr(args, "no_live", False)
|
|
975
|
+
and bool(getattr(console, "is_terminal", False)))
|
|
976
|
+
dash = None
|
|
977
|
+
if use_live:
|
|
978
|
+
try:
|
|
979
|
+
from .console_live import LiveDashboard
|
|
980
|
+
dash = LiveDashboard(console, _demo.DEMO_TARGET, total_checks=len(_demo.DEMO_RESULTS))
|
|
981
|
+
dash.__enter__()
|
|
982
|
+
# Drive a fake on_progress so the live dashboard's findings + counter fill in
|
|
983
|
+
on_prog = dash.on_progress_callback()
|
|
984
|
+
report = _demo.build_demo_report()
|
|
985
|
+
import asyncio as _asyncio
|
|
986
|
+
async def _drive():
|
|
987
|
+
for cr in report.results:
|
|
988
|
+
on_prog("start", cr.check_id, cr.check_name, None)
|
|
989
|
+
await _asyncio.sleep(0.05)
|
|
990
|
+
on_prog("done", cr.check_id, cr.check_name, cr)
|
|
991
|
+
# Now drip the activity events
|
|
992
|
+
for cat, msg in _demo.DEMO_ACTIVITY:
|
|
993
|
+
from . import activity as _act
|
|
994
|
+
_act.emit(cat, msg)
|
|
995
|
+
await _asyncio.sleep(0.05)
|
|
996
|
+
_asyncio.run(_drive())
|
|
997
|
+
finally:
|
|
998
|
+
if dash is not None:
|
|
999
|
+
try:
|
|
1000
|
+
dash.__exit__(None, None, None)
|
|
1001
|
+
except Exception: # noqa: BLE001
|
|
1002
|
+
pass
|
|
1003
|
+
else:
|
|
1004
|
+
report = _demo.run_demo(paced=False)
|
|
1005
|
+
|
|
1006
|
+
# Write every reporter's artifact to ~/.wpsecscan/demo/
|
|
1007
|
+
from .history import _home as _h_home
|
|
1008
|
+
out_dir = Path(_h_home()) / "demo"
|
|
1009
|
+
written = _demo.write_artifacts(report, out_dir)
|
|
1010
|
+
if not args.no_console:
|
|
1011
|
+
console_reporter.render(report, console)
|
|
1012
|
+
console.print()
|
|
1013
|
+
console.print(f"[green]✓[/green] Demo artifacts written to: [bold]{out_dir}[/bold]")
|
|
1014
|
+
for fmt, p in written.items():
|
|
1015
|
+
console.print(f" · {fmt:12} {p}")
|
|
1016
|
+
sys.exit(0)
|
|
1017
|
+
|
|
1018
|
+
# F2: --replay-har short-circuits — replay a HAR file and exit.
|
|
1019
|
+
if args.replay_har:
|
|
1020
|
+
from .har_replay import replay as _replay
|
|
1021
|
+
try:
|
|
1022
|
+
results = asyncio.run(_replay(Path(args.replay_har), target_origin=args.target))
|
|
1023
|
+
except (OSError, ValueError) as e:
|
|
1024
|
+
print(f"FATAL (replay-har): {e}", file=sys.stderr)
|
|
1025
|
+
sys.exit(1)
|
|
1026
|
+
print(f"Replayed {len(results)} request(s) from {args.replay_har}")
|
|
1027
|
+
ok = sum(1 for r in results if r.get("ok"))
|
|
1028
|
+
errs = len(results) - ok
|
|
1029
|
+
print(f" {ok} OK, {errs} errors")
|
|
1030
|
+
# Print top-line per-request results
|
|
1031
|
+
for r in results[:50]:
|
|
1032
|
+
req = r.get("request", {})
|
|
1033
|
+
line = f" {req.get('method', '?'):6} {req.get('url', '?')[:80]}"
|
|
1034
|
+
if r.get("ok"):
|
|
1035
|
+
line += f" -> {r.get('status'):3} ({r.get('body_len', 0)} bytes)"
|
|
1036
|
+
else:
|
|
1037
|
+
line += f" -> ERROR: {r.get('error', '?')[:60]}"
|
|
1038
|
+
print(line)
|
|
1039
|
+
if len(results) > 50:
|
|
1040
|
+
print(f" ... and {len(results) - 50} more")
|
|
1041
|
+
sys.exit(0 if errs == 0 else 1)
|
|
1042
|
+
|
|
1043
|
+
# D6: daemon mode short-circuits the normal scan flow
|
|
1044
|
+
if args.daemon:
|
|
1045
|
+
from .daemon import run_daemon
|
|
1046
|
+
try:
|
|
1047
|
+
asyncio.run(run_daemon(Path(args.daemon)))
|
|
1048
|
+
sys.exit(0)
|
|
1049
|
+
except KeyboardInterrupt:
|
|
1050
|
+
print("\nDaemon stopped.", file=sys.stderr)
|
|
1051
|
+
sys.exit(0)
|
|
1052
|
+
except Exception as e: # noqa: BLE001
|
|
1053
|
+
print(f"FATAL (daemon): {e}", file=sys.stderr)
|
|
1054
|
+
sys.exit(1)
|
|
1055
|
+
|
|
1056
|
+
try:
|
|
1057
|
+
code = asyncio.run(_amain(args))
|
|
1058
|
+
except KeyboardInterrupt:
|
|
1059
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
1060
|
+
code = 130
|
|
1061
|
+
except Exception as e: # noqa: BLE001
|
|
1062
|
+
cp = logmod.write_crash_report(e)
|
|
1063
|
+
print(f"FATAL: {e}\nCrash report: {cp}", file=sys.stderr)
|
|
1064
|
+
code = 1
|
|
1065
|
+
sys.exit(code)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _dispatch_subcommand(cmd: str, args: list[str]) -> None:
|
|
1069
|
+
"""Subcommand router (round-60 + round-61)."""
|
|
1070
|
+
if cmd == "sites":
|
|
1071
|
+
_cmd_sites(args)
|
|
1072
|
+
elif cmd == "schedule":
|
|
1073
|
+
_cmd_schedule(args)
|
|
1074
|
+
elif cmd == "digest":
|
|
1075
|
+
_cmd_digest(args)
|
|
1076
|
+
elif cmd == "ai-cost":
|
|
1077
|
+
_cmd_ai_cost(args)
|
|
1078
|
+
elif cmd == "db":
|
|
1079
|
+
_cmd_db(args)
|
|
1080
|
+
elif cmd == "ai-options":
|
|
1081
|
+
_cmd_ai_options(args)
|
|
1082
|
+
elif cmd == "analytics":
|
|
1083
|
+
_cmd_analytics(args)
|
|
1084
|
+
elif cmd == "compare":
|
|
1085
|
+
_cmd_compare(args)
|
|
1086
|
+
elif cmd == "badge":
|
|
1087
|
+
_cmd_badge(args)
|
|
1088
|
+
elif cmd == "paths":
|
|
1089
|
+
_cmd_paths(args)
|
|
1090
|
+
elif cmd == "report":
|
|
1091
|
+
_cmd_report(args)
|
|
1092
|
+
elif cmd == "annotate":
|
|
1093
|
+
_cmd_annotate(args)
|
|
1094
|
+
elif cmd == "check":
|
|
1095
|
+
_cmd_check(args)
|
|
1096
|
+
elif cmd == "config":
|
|
1097
|
+
_cmd_config(args)
|
|
1098
|
+
elif cmd == "verify-release":
|
|
1099
|
+
_cmd_verify_release(args)
|
|
1100
|
+
elif cmd == "watch":
|
|
1101
|
+
_cmd_watch(args)
|
|
1102
|
+
elif cmd == "portfolio":
|
|
1103
|
+
_cmd_portfolio(args)
|
|
1104
|
+
elif cmd == "refix":
|
|
1105
|
+
_cmd_refix(args)
|
|
1106
|
+
elif cmd == "snooze":
|
|
1107
|
+
_cmd_snooze(args)
|
|
1108
|
+
elif cmd == "diff-tree":
|
|
1109
|
+
_cmd_diff_tree(args)
|
|
1110
|
+
elif cmd == "pr-comment":
|
|
1111
|
+
_cmd_pr_comment(args)
|
|
1112
|
+
elif cmd == "publish":
|
|
1113
|
+
_cmd_publish(args)
|
|
1114
|
+
else:
|
|
1115
|
+
print(f"unknown subcommand: {cmd}", file=sys.stderr)
|
|
1116
|
+
sys.exit(2)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _cmd_report(args: list[str]) -> None:
|
|
1120
|
+
"""`wpsecscan report open <URL>` — open the most-recent HTML report for
|
|
1121
|
+
URL in the default browser. Also: `wpsecscan report path <URL>` to
|
|
1122
|
+
just print the path."""
|
|
1123
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1124
|
+
print("usage: wpsecscan report {open|path} <URL>")
|
|
1125
|
+
return
|
|
1126
|
+
if args[0] not in ("open", "path") or len(args) < 2:
|
|
1127
|
+
print("usage: wpsecscan report {open|path} <URL>", file=sys.stderr)
|
|
1128
|
+
sys.exit(64)
|
|
1129
|
+
mode = args[0]
|
|
1130
|
+
url = args[1]
|
|
1131
|
+
if "://" not in url:
|
|
1132
|
+
url = "https://" + url
|
|
1133
|
+
from . import history as _h
|
|
1134
|
+
reports_dir = _h._reports_dir()
|
|
1135
|
+
safe = _h._safe_filename(url)
|
|
1136
|
+
# Look for the most-recently modified HTML for this host. CLI writes
|
|
1137
|
+
# JSON snapshots automatically; HTML reports stay where --out put them.
|
|
1138
|
+
# We try the home reports dir + cwd as fallbacks.
|
|
1139
|
+
candidates: list[Path] = []
|
|
1140
|
+
for d in (reports_dir, Path.cwd()):
|
|
1141
|
+
if d.exists():
|
|
1142
|
+
candidates.extend(d.glob(f"*{safe}*.html"))
|
|
1143
|
+
if not candidates:
|
|
1144
|
+
print(f"No HTML report found for {url}. Looked in:\n {reports_dir}\n {Path.cwd()}",
|
|
1145
|
+
file=sys.stderr)
|
|
1146
|
+
sys.exit(64)
|
|
1147
|
+
latest = max(candidates, key=lambda p: p.stat().st_mtime)
|
|
1148
|
+
if mode == "path":
|
|
1149
|
+
print(latest)
|
|
1150
|
+
return
|
|
1151
|
+
# Open in default browser, cross-platform.
|
|
1152
|
+
import webbrowser
|
|
1153
|
+
if not webbrowser.open(latest.resolve().as_uri()):
|
|
1154
|
+
print(f"Could not open browser; path: {latest}", file=sys.stderr)
|
|
1155
|
+
sys.exit(1)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _cmd_annotate(args: list[str]) -> None:
|
|
1159
|
+
"""Bulk-accept findings from a CSV, or set a single annotation.
|
|
1160
|
+
|
|
1161
|
+
Usage:
|
|
1162
|
+
wpsecscan annotate --bulk-accept FILE.csv
|
|
1163
|
+
wpsecscan annotate URL CHECK_ID TITLE --status STATUS [--note NOTE] [--snooze YYYY-MM-DD]
|
|
1164
|
+
CSV columns: url,check_id,title[,reason]
|
|
1165
|
+
"""
|
|
1166
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1167
|
+
print(
|
|
1168
|
+
"usage:\n"
|
|
1169
|
+
" wpsecscan annotate --bulk-accept FILE.csv\n"
|
|
1170
|
+
" wpsecscan annotate URL CHECK_ID TITLE --status accepted-risk [--note NOTE] [--snooze YYYY-MM-DD]\n"
|
|
1171
|
+
"CSV columns for --bulk-accept: url,check_id,title[,reason]"
|
|
1172
|
+
)
|
|
1173
|
+
return
|
|
1174
|
+
from . import history as _h
|
|
1175
|
+
if args[0] == "--bulk-accept":
|
|
1176
|
+
if len(args) < 2:
|
|
1177
|
+
print("usage: wpsecscan annotate --bulk-accept FILE.csv", file=sys.stderr)
|
|
1178
|
+
sys.exit(64)
|
|
1179
|
+
import csv as _csv
|
|
1180
|
+
applied = 0
|
|
1181
|
+
skipped = 0
|
|
1182
|
+
with open(args[1], "r", encoding="utf-8") as f:
|
|
1183
|
+
reader = _csv.DictReader(f)
|
|
1184
|
+
for row in reader:
|
|
1185
|
+
url = (row.get("url") or "").strip()
|
|
1186
|
+
cid = (row.get("check_id") or "").strip()
|
|
1187
|
+
title = (row.get("title") or "").strip()
|
|
1188
|
+
reason = (row.get("reason") or "").strip()
|
|
1189
|
+
if not (url and cid and title):
|
|
1190
|
+
skipped += 1
|
|
1191
|
+
continue
|
|
1192
|
+
_h.set_annotation(url, cid, title, "accepted-risk",
|
|
1193
|
+
note=reason or "bulk-accepted")
|
|
1194
|
+
applied += 1
|
|
1195
|
+
print(f"Applied {applied} annotation(s); skipped {skipped} incomplete row(s).")
|
|
1196
|
+
return
|
|
1197
|
+
# Single-annotation form
|
|
1198
|
+
if len(args) < 3:
|
|
1199
|
+
print("usage: wpsecscan annotate URL CHECK_ID TITLE --status STATUS [--note NOTE] [--snooze YYYY-MM-DD]",
|
|
1200
|
+
file=sys.stderr)
|
|
1201
|
+
sys.exit(64)
|
|
1202
|
+
url, cid, title = args[0], args[1], args[2]
|
|
1203
|
+
status = ""
|
|
1204
|
+
note = ""
|
|
1205
|
+
snooze = ""
|
|
1206
|
+
i = 3
|
|
1207
|
+
while i < len(args):
|
|
1208
|
+
if args[i] == "--status" and i + 1 < len(args):
|
|
1209
|
+
status = args[i + 1]; i += 2
|
|
1210
|
+
elif args[i] == "--note" and i + 1 < len(args):
|
|
1211
|
+
note = args[i + 1]; i += 2
|
|
1212
|
+
elif args[i] == "--snooze" and i + 1 < len(args):
|
|
1213
|
+
snooze = args[i + 1]; i += 2
|
|
1214
|
+
else:
|
|
1215
|
+
i += 1
|
|
1216
|
+
if not status:
|
|
1217
|
+
print("--status is required (e.g. accepted-risk, false-positive, '')", file=sys.stderr)
|
|
1218
|
+
sys.exit(64)
|
|
1219
|
+
_h.set_annotation(url, cid, title, status, note=note, snooze_until=snooze)
|
|
1220
|
+
msg = f"annotated {cid}::{title!r} on {url} as {status!r}"
|
|
1221
|
+
if snooze:
|
|
1222
|
+
msg += f" until {snooze}"
|
|
1223
|
+
print(msg)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _cmd_check(args: list[str]) -> None:
|
|
1227
|
+
"""`wpsecscan check list [--category CAT]` — print the full check inventory.
|
|
1228
|
+
|
|
1229
|
+
Categories are derived from each check's OWASP tag. Useful for operators
|
|
1230
|
+
auditing what the scanner does before running it on a client.
|
|
1231
|
+
"""
|
|
1232
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1233
|
+
print("usage: wpsecscan check list [--category OWASP-CATEGORY]")
|
|
1234
|
+
return
|
|
1235
|
+
if args[0] != "list":
|
|
1236
|
+
print("usage: wpsecscan check list [--category OWASP-CATEGORY]", file=sys.stderr)
|
|
1237
|
+
sys.exit(64)
|
|
1238
|
+
cat_filter = ""
|
|
1239
|
+
i = 1
|
|
1240
|
+
while i < len(args):
|
|
1241
|
+
if args[i] == "--category" and i + 1 < len(args):
|
|
1242
|
+
cat_filter = args[i + 1].lower()
|
|
1243
|
+
i += 2
|
|
1244
|
+
else:
|
|
1245
|
+
i += 1
|
|
1246
|
+
from .checks import ALL_CHECKS
|
|
1247
|
+
from . import tags as _tags
|
|
1248
|
+
rows = []
|
|
1249
|
+
for cid, cname, _fn, agg in ALL_CHECKS:
|
|
1250
|
+
t = _tags.get_tags(cid) or {}
|
|
1251
|
+
owasp = t.get("owasp", "") or ""
|
|
1252
|
+
owasp_label = t.get("owasp_label", "") or ""
|
|
1253
|
+
if cat_filter and cat_filter not in owasp.lower() and cat_filter not in owasp_label.lower():
|
|
1254
|
+
continue
|
|
1255
|
+
rows.append((cid, cname, owasp, owasp_label, "aggressive" if agg else "passive"))
|
|
1256
|
+
rows.sort(key=lambda r: (r[2], r[0]))
|
|
1257
|
+
print(f"{len(rows)} check(s)" + (f" (filtered: {cat_filter})" if cat_filter else ""))
|
|
1258
|
+
print()
|
|
1259
|
+
for cid, cname, owasp, owasp_label, mode in rows:
|
|
1260
|
+
print(f" [{owasp:8s}] {cid:35s} ({mode:11s}) {cname}")
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _cmd_verify_release(args: list[str]) -> None:
|
|
1264
|
+
"""`wpsecscan verify-release [--exe PATH]` — verify the running binary's
|
|
1265
|
+
Sigstore signature against the WPSecScan project's OIDC identity.
|
|
1266
|
+
|
|
1267
|
+
The release-attestation workflow publishes a detached signature (.sig)
|
|
1268
|
+
and a Sigstore certificate (.pem) alongside every release artifact;
|
|
1269
|
+
this command verifies both against the expected OIDC identity and
|
|
1270
|
+
prints the Rekor transparency-log entry URL.
|
|
1271
|
+
|
|
1272
|
+
Tries `cosign verify-blob` first (binary on PATH), then sigstore-python
|
|
1273
|
+
if importable, otherwise prints the manual cosign command for the user
|
|
1274
|
+
to run.
|
|
1275
|
+
"""
|
|
1276
|
+
if args and args[0] in ("-h", "--help"):
|
|
1277
|
+
print(
|
|
1278
|
+
"usage: wpsecscan verify-release [--exe PATH] [--sig PATH] [--cert PATH]\n"
|
|
1279
|
+
" --exe path to the .exe / .py to verify (default: this binary)\n"
|
|
1280
|
+
" --sig signature file (default: <exe>.sig next to it)\n"
|
|
1281
|
+
" --cert Sigstore certificate (default: <exe>.pem next to it)\n"
|
|
1282
|
+
"\nVerifies against the project's OIDC identity:\n"
|
|
1283
|
+
" certificate-identity-regexp: https://github.com/bryanflowers/wpsecscan\n"
|
|
1284
|
+
" oidc-issuer: https://token.actions.githubusercontent.com"
|
|
1285
|
+
)
|
|
1286
|
+
return
|
|
1287
|
+
|
|
1288
|
+
# Defaults: resolve from the running executable. PyInstaller sets
|
|
1289
|
+
# sys._MEIPASS, but the actual .exe lives at sys.executable.
|
|
1290
|
+
default_exe = Path(sys.executable)
|
|
1291
|
+
if getattr(sys, "frozen", False): # PyInstaller-bundled
|
|
1292
|
+
default_exe = Path(sys.executable).resolve()
|
|
1293
|
+
else:
|
|
1294
|
+
# Source checkout — verify the wpsecscan module's installation root.
|
|
1295
|
+
default_exe = Path(__file__).parent.parent
|
|
1296
|
+
|
|
1297
|
+
exe_path: Path | None = None
|
|
1298
|
+
sig_path: Path | None = None
|
|
1299
|
+
cert_path: Path | None = None
|
|
1300
|
+
i = 0
|
|
1301
|
+
while i < len(args):
|
|
1302
|
+
if args[i] == "--exe" and i + 1 < len(args):
|
|
1303
|
+
exe_path = Path(args[i + 1]); i += 2
|
|
1304
|
+
elif args[i] == "--sig" and i + 1 < len(args):
|
|
1305
|
+
sig_path = Path(args[i + 1]); i += 2
|
|
1306
|
+
elif args[i] == "--cert" and i + 1 < len(args):
|
|
1307
|
+
cert_path = Path(args[i + 1]); i += 2
|
|
1308
|
+
else:
|
|
1309
|
+
i += 1
|
|
1310
|
+
exe_path = exe_path or default_exe
|
|
1311
|
+
sig_path = sig_path or exe_path.with_suffix(exe_path.suffix + ".sig")
|
|
1312
|
+
cert_path = cert_path or exe_path.with_suffix(exe_path.suffix + ".pem")
|
|
1313
|
+
|
|
1314
|
+
print(f"Verifying release artifact:")
|
|
1315
|
+
print(f" exe: {exe_path}")
|
|
1316
|
+
print(f" sig: {sig_path}")
|
|
1317
|
+
print(f" cert: {cert_path}")
|
|
1318
|
+
print()
|
|
1319
|
+
|
|
1320
|
+
if not exe_path.exists():
|
|
1321
|
+
print(f"FAIL: artifact not found at {exe_path}", file=sys.stderr)
|
|
1322
|
+
sys.exit(1)
|
|
1323
|
+
if not sig_path.exists() or not cert_path.exists():
|
|
1324
|
+
print(
|
|
1325
|
+
f"FAIL: signature or certificate missing.\n"
|
|
1326
|
+
f" Expected: {sig_path}\n"
|
|
1327
|
+
f" {cert_path}\n"
|
|
1328
|
+
"Download both from the GitHub release for this version "
|
|
1329
|
+
"(https://github.com/bryanflowers/wpsecscan/releases/latest), "
|
|
1330
|
+
"place them next to the .exe, and re-run.",
|
|
1331
|
+
file=sys.stderr,
|
|
1332
|
+
)
|
|
1333
|
+
sys.exit(1)
|
|
1334
|
+
|
|
1335
|
+
# Try the `cosign` binary first — it's the canonical tool.
|
|
1336
|
+
import shutil as _shutil
|
|
1337
|
+
import subprocess as _subprocess
|
|
1338
|
+
cosign = _shutil.which("cosign")
|
|
1339
|
+
if cosign:
|
|
1340
|
+
print("Using cosign on PATH.")
|
|
1341
|
+
cmd = [
|
|
1342
|
+
cosign, "verify-blob",
|
|
1343
|
+
"--signature", str(sig_path),
|
|
1344
|
+
"--certificate", str(cert_path),
|
|
1345
|
+
"--certificate-identity-regexp", "https://github.com/bryanflowers/wpsecscan",
|
|
1346
|
+
"--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
|
|
1347
|
+
str(exe_path),
|
|
1348
|
+
]
|
|
1349
|
+
try:
|
|
1350
|
+
r = _subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
1351
|
+
except (_subprocess.TimeoutExpired, OSError) as e:
|
|
1352
|
+
print(f"FAIL: cosign invocation failed: {e}", file=sys.stderr)
|
|
1353
|
+
sys.exit(1)
|
|
1354
|
+
if r.returncode == 0:
|
|
1355
|
+
print("✓ Sigstore signature VERIFIED for", exe_path.name)
|
|
1356
|
+
if r.stdout.strip():
|
|
1357
|
+
print(r.stdout.strip())
|
|
1358
|
+
print()
|
|
1359
|
+
print("Rekor transparency log: search https://search.sigstore.dev/ for "
|
|
1360
|
+
"the certificate fingerprint to view the public log entry.")
|
|
1361
|
+
sys.exit(0)
|
|
1362
|
+
print(f"FAIL: cosign exited {r.returncode}", file=sys.stderr)
|
|
1363
|
+
if r.stderr.strip():
|
|
1364
|
+
print(r.stderr.strip(), file=sys.stderr)
|
|
1365
|
+
sys.exit(1)
|
|
1366
|
+
|
|
1367
|
+
# Cosign not available — try sigstore-python.
|
|
1368
|
+
try:
|
|
1369
|
+
import sigstore # noqa: F401 - optional dep
|
|
1370
|
+
from sigstore.verify import Verifier, models # type: ignore
|
|
1371
|
+
print("Using sigstore-python (`cosign` not on PATH).")
|
|
1372
|
+
verifier = Verifier.production()
|
|
1373
|
+
with open(sig_path, "rb") as sf:
|
|
1374
|
+
sig = sf.read()
|
|
1375
|
+
with open(cert_path, "rb") as cf:
|
|
1376
|
+
cert = cf.read()
|
|
1377
|
+
with open(exe_path, "rb") as xf:
|
|
1378
|
+
blob = xf.read()
|
|
1379
|
+
# The exact API of sigstore-python evolves; if it changes shape, fall
|
|
1380
|
+
# back to the printed-instructions path rather than crashing.
|
|
1381
|
+
try:
|
|
1382
|
+
result = verifier.verify(
|
|
1383
|
+
input_=blob,
|
|
1384
|
+
signature=sig,
|
|
1385
|
+
certificate=cert,
|
|
1386
|
+
)
|
|
1387
|
+
ok = bool(getattr(result, "success", True))
|
|
1388
|
+
except Exception as e: # noqa: BLE001
|
|
1389
|
+
print(f"sigstore-python verify() failed: {e}", file=sys.stderr)
|
|
1390
|
+
ok = False
|
|
1391
|
+
if ok:
|
|
1392
|
+
print("✓ Sigstore signature VERIFIED for", exe_path.name)
|
|
1393
|
+
sys.exit(0)
|
|
1394
|
+
print("FAIL: signature did not verify.", file=sys.stderr)
|
|
1395
|
+
sys.exit(1)
|
|
1396
|
+
except ImportError:
|
|
1397
|
+
pass
|
|
1398
|
+
|
|
1399
|
+
# No verifier available — print the manual command and exit non-zero
|
|
1400
|
+
# so CI scripts can detect the no-tools state.
|
|
1401
|
+
print(
|
|
1402
|
+
"No verifier available. Install one of:\n"
|
|
1403
|
+
" - cosign (https://github.com/sigstore/cosign/releases)\n"
|
|
1404
|
+
" - sigstore-python: pip install sigstore\n\n"
|
|
1405
|
+
"Then run manually:\n\n"
|
|
1406
|
+
f" cosign verify-blob \\\n"
|
|
1407
|
+
f" --signature '{sig_path}' \\\n"
|
|
1408
|
+
f" --certificate '{cert_path}' \\\n"
|
|
1409
|
+
f" --certificate-identity-regexp 'https://github.com/bryanflowers/wpsecscan' \\\n"
|
|
1410
|
+
f" --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \\\n"
|
|
1411
|
+
f" '{exe_path}'\n",
|
|
1412
|
+
file=sys.stderr,
|
|
1413
|
+
)
|
|
1414
|
+
sys.exit(2)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _cmd_watch(args: list[str]) -> None:
|
|
1418
|
+
"""`wpsecscan watch URL [--interval N] [--webhook URL] [--exit-on-new]`
|
|
1419
|
+
|
|
1420
|
+
Polling daemon that re-scans URL every N seconds (default 1800 = 30 min),
|
|
1421
|
+
diffs against the previous run's saved snapshot, and posts ONLY on
|
|
1422
|
+
finding-deltas. Quiet by default — no per-run noise. POST a Slack-shaped
|
|
1423
|
+
JSON payload when --webhook is set.
|
|
1424
|
+
|
|
1425
|
+
Use --exit-on-new to break the loop the first time a new finding appears
|
|
1426
|
+
(useful from CI as a tripwire).
|
|
1427
|
+
"""
|
|
1428
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1429
|
+
print(_cmd_watch.__doc__.strip())
|
|
1430
|
+
sys.exit(0)
|
|
1431
|
+
|
|
1432
|
+
target = args[0]
|
|
1433
|
+
if not target.startswith(("http://", "https://")):
|
|
1434
|
+
target = "https://" + target
|
|
1435
|
+
|
|
1436
|
+
interval = 1800
|
|
1437
|
+
webhook: str | None = None
|
|
1438
|
+
exit_on_new = False
|
|
1439
|
+
skip = {0}
|
|
1440
|
+
for i, a in enumerate(args):
|
|
1441
|
+
if i in skip:
|
|
1442
|
+
continue
|
|
1443
|
+
if a == "--interval" and i + 1 < len(args):
|
|
1444
|
+
try:
|
|
1445
|
+
interval = max(60, int(args[i + 1]))
|
|
1446
|
+
except ValueError:
|
|
1447
|
+
pass
|
|
1448
|
+
skip.add(i + 1)
|
|
1449
|
+
elif a == "--webhook" and i + 1 < len(args):
|
|
1450
|
+
webhook = args[i + 1]
|
|
1451
|
+
skip.add(i + 1)
|
|
1452
|
+
elif a == "--exit-on-new":
|
|
1453
|
+
exit_on_new = True
|
|
1454
|
+
|
|
1455
|
+
from . import history as _h
|
|
1456
|
+
from . import json_io as _ji # type: ignore[unused-import] # may be json_out
|
|
1457
|
+
import asyncio as _asyncio
|
|
1458
|
+
import json as _json
|
|
1459
|
+
import time as _time
|
|
1460
|
+
import urllib.request as _ur
|
|
1461
|
+
from datetime import datetime, timezone
|
|
1462
|
+
|
|
1463
|
+
console = Console(no_color=False, legacy_windows=False)
|
|
1464
|
+
console.print(f"[bold]wpsecscan watch[/bold] {target} every {interval}s "
|
|
1465
|
+
f"(webhook={'set' if webhook else 'none'}, "
|
|
1466
|
+
f"exit-on-new={exit_on_new})")
|
|
1467
|
+
|
|
1468
|
+
async def _one_pass() -> tuple[set[str], set[str], int, int]:
|
|
1469
|
+
# Run a passive scan, return (new_titles, fixed_titles, total, score)
|
|
1470
|
+
from .scanner import scan
|
|
1471
|
+
from .reporters import json_out as _jo
|
|
1472
|
+
report = await scan(target, timeout=15.0, aggressive=False, sequential=True)
|
|
1473
|
+
# Persist
|
|
1474
|
+
_h.save_report_snapshot(target, _jo.render(report))
|
|
1475
|
+
# Compare with prior
|
|
1476
|
+
snaps = _h.snapshot_history(target)
|
|
1477
|
+
prev_titles: set[str] = set()
|
|
1478
|
+
if len(snaps) >= 2:
|
|
1479
|
+
try:
|
|
1480
|
+
prev = _json.loads(Path(snaps[-2]).read_text(encoding="utf-8"))
|
|
1481
|
+
for r in prev.get("results", []):
|
|
1482
|
+
for f in r.get("findings", []):
|
|
1483
|
+
if f.get("severity") in ("critical", "high", "medium"):
|
|
1484
|
+
prev_titles.add(f.get("title", ""))
|
|
1485
|
+
except (OSError, ValueError):
|
|
1486
|
+
prev_titles = set()
|
|
1487
|
+
cur_titles: set[str] = set()
|
|
1488
|
+
for r in report.results:
|
|
1489
|
+
for f in r.findings:
|
|
1490
|
+
if f.severity in ("critical", "high", "medium"):
|
|
1491
|
+
cur_titles.add(f.title)
|
|
1492
|
+
return (cur_titles - prev_titles), (prev_titles - cur_titles), len(cur_titles), report.risk_score
|
|
1493
|
+
|
|
1494
|
+
def _post(text: str) -> None:
|
|
1495
|
+
if not webhook:
|
|
1496
|
+
return
|
|
1497
|
+
try:
|
|
1498
|
+
body = _json.dumps({"text": text}).encode("utf-8")
|
|
1499
|
+
req = _ur.Request(webhook, data=body, method="POST",
|
|
1500
|
+
headers={"Content-Type": "application/json",
|
|
1501
|
+
"User-Agent": "WPSecScan/watch"})
|
|
1502
|
+
_ur.urlopen(req, timeout=10.0).close()
|
|
1503
|
+
except Exception as e: # noqa: BLE001 — webhook must never break the loop
|
|
1504
|
+
console.print(f"[yellow]webhook post failed:[/yellow] {e}")
|
|
1505
|
+
|
|
1506
|
+
while True:
|
|
1507
|
+
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
1508
|
+
try:
|
|
1509
|
+
new, fixed, total, score = _asyncio.run(_one_pass())
|
|
1510
|
+
except Exception as e: # noqa: BLE001
|
|
1511
|
+
console.print(f"[{ts}] scan failed: {e}")
|
|
1512
|
+
_time.sleep(interval)
|
|
1513
|
+
continue
|
|
1514
|
+
if new or fixed:
|
|
1515
|
+
console.print(
|
|
1516
|
+
f"[{ts}] {target} — [red]+{len(new)} new[/red] "
|
|
1517
|
+
f"[green]-{len(fixed)} fixed[/green] "
|
|
1518
|
+
f"(total {total}, score {score}/100)"
|
|
1519
|
+
)
|
|
1520
|
+
for title in sorted(new)[:10]:
|
|
1521
|
+
console.print(f" [red]+[/red] {title}")
|
|
1522
|
+
for title in sorted(fixed)[:10]:
|
|
1523
|
+
console.print(f" [green]-[/green] {title}")
|
|
1524
|
+
if webhook:
|
|
1525
|
+
lines = [f"*WPSecScan watch*: {target}",
|
|
1526
|
+
f"+{len(new)} new, -{len(fixed)} fixed, total {total}, score {score}/100"]
|
|
1527
|
+
for title in sorted(new)[:5]:
|
|
1528
|
+
lines.append(f"• NEW: {title}")
|
|
1529
|
+
for title in sorted(fixed)[:5]:
|
|
1530
|
+
lines.append(f"• FIXED: {title}")
|
|
1531
|
+
_post("\n".join(lines))
|
|
1532
|
+
if exit_on_new and new:
|
|
1533
|
+
console.print(f"[red]exit-on-new tripwire fired[/red] — exiting")
|
|
1534
|
+
sys.exit(3)
|
|
1535
|
+
else:
|
|
1536
|
+
console.print(f"[{ts}] {target}: no change (total {total}, score {score}/100)")
|
|
1537
|
+
_time.sleep(interval)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _cmd_portfolio(args: list[str]) -> None:
|
|
1541
|
+
"""`wpsecscan portfolio [--tag FOO] [--out DIR] [--no-pdf]`
|
|
1542
|
+
|
|
1543
|
+
Bulk-scan every site in ~/.wpsecscan/sites.json (filtered by --tag if
|
|
1544
|
+
given) and write a single agency-style dashboard + one exec-PDF per
|
|
1545
|
+
site. Combines `sites scan` + `--dashboard --agency-dashboard` +
|
|
1546
|
+
`--exec-pdf` in one verb.
|
|
1547
|
+
"""
|
|
1548
|
+
if args and args[0] in ("-h", "--help"):
|
|
1549
|
+
print(_cmd_portfolio.__doc__.strip())
|
|
1550
|
+
sys.exit(0)
|
|
1551
|
+
tag_filter: str | None = None
|
|
1552
|
+
out_dir = "wpsecscan-portfolio"
|
|
1553
|
+
want_pdf = True
|
|
1554
|
+
for i, a in enumerate(args):
|
|
1555
|
+
if a == "--tag" and i + 1 < len(args):
|
|
1556
|
+
tag_filter = args[i + 1].lower()
|
|
1557
|
+
elif a == "--out" and i + 1 < len(args):
|
|
1558
|
+
out_dir = args[i + 1]
|
|
1559
|
+
elif a == "--no-pdf":
|
|
1560
|
+
want_pdf = False
|
|
1561
|
+
from . import sites as sites_mod
|
|
1562
|
+
all_sites = sites_mod.list_sites()
|
|
1563
|
+
if tag_filter:
|
|
1564
|
+
sel = [s for s in all_sites if tag_filter in (s.get("tags") or [])]
|
|
1565
|
+
else:
|
|
1566
|
+
sel = all_sites
|
|
1567
|
+
if not sel:
|
|
1568
|
+
msg = "no sites match" + (f" tag {tag_filter!r}" if tag_filter else "")
|
|
1569
|
+
print(msg); sys.exit(2)
|
|
1570
|
+
print(f"portfolio scan: {len(sel)} site(s) → {out_dir}/")
|
|
1571
|
+
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
|
1572
|
+
for s in sel:
|
|
1573
|
+
url = s["url"]
|
|
1574
|
+
print(f" -> {url}")
|
|
1575
|
+
cmd = [sys.executable, "-m", "wpsecscan", url,
|
|
1576
|
+
"--out", out_dir, "--agency-dashboard"]
|
|
1577
|
+
if want_pdf:
|
|
1578
|
+
cmd.append("--exec-pdf")
|
|
1579
|
+
child_env = dict(os.environ)
|
|
1580
|
+
if s.get("auth_user"):
|
|
1581
|
+
child_env["WPSECSCAN_AUTH_USER"] = s["auth_user"]
|
|
1582
|
+
if s.get("proxy_url"):
|
|
1583
|
+
cmd.extend(["--proxy", s["proxy_url"]])
|
|
1584
|
+
for sealed_key, env_name in (
|
|
1585
|
+
("proxy_auth_sealed", "WPSECSCAN_PROXY_AUTH"),
|
|
1586
|
+
("auth_app_password_sealed", "WPSECSCAN_AUTH_APP_PASSWORD"),
|
|
1587
|
+
("companion_token_sealed", "WPSECSCAN_COMPANION_TOKEN"),
|
|
1588
|
+
):
|
|
1589
|
+
if s.get(sealed_key):
|
|
1590
|
+
try:
|
|
1591
|
+
child_env[env_name] = sites_mod._unseal(s[sealed_key])
|
|
1592
|
+
except Exception: # noqa: BLE001
|
|
1593
|
+
pass
|
|
1594
|
+
import subprocess as _sp
|
|
1595
|
+
_sp.run(cmd, env=child_env, check=False)
|
|
1596
|
+
print(f"\nportfolio complete: open {out_dir}/wpsecscan-agency-dashboard.html")
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def _cmd_refix(args: list[str]) -> None:
|
|
1600
|
+
"""`wpsecscan refix CHECK_ID URL` — re-run only one check and write a
|
|
1601
|
+
fix-attested receipt to ~/.wpsecscan/refix/<host>-<check>-<ts>.json.
|
|
1602
|
+
|
|
1603
|
+
Useful after fixing a specific finding: instead of running a full
|
|
1604
|
+
20-minute scan, this re-executes the single check that flagged the
|
|
1605
|
+
issue and tells you whether it's now clean.
|
|
1606
|
+
"""
|
|
1607
|
+
if len(args) < 2 or args[0] in ("-h", "--help"):
|
|
1608
|
+
print("usage: wpsecscan refix CHECK_ID URL\n"
|
|
1609
|
+
" CHECK_ID is the `check_id` from a JSON report.")
|
|
1610
|
+
sys.exit(0 if args and args[0] in ("-h", "--help") else 2)
|
|
1611
|
+
check_id, target = args[0], args[1]
|
|
1612
|
+
if not target.startswith(("http://", "https://")):
|
|
1613
|
+
target = "https://" + target
|
|
1614
|
+
|
|
1615
|
+
import asyncio as _asyncio
|
|
1616
|
+
import json as _json
|
|
1617
|
+
from datetime import datetime, timezone
|
|
1618
|
+
from urllib.parse import urlparse
|
|
1619
|
+
from . import scanner as _sc
|
|
1620
|
+
|
|
1621
|
+
async def _refix_one() -> tuple[bool, list[dict]]:
|
|
1622
|
+
# Find the check function from the registry.
|
|
1623
|
+
from .checks import ALL_CHECKS
|
|
1624
|
+
ours = [c for c in ALL_CHECKS if c[0] == check_id]
|
|
1625
|
+
if not ours:
|
|
1626
|
+
print(f"unknown check_id: {check_id}\n"
|
|
1627
|
+
"see `wpsecscan check list` for valid IDs", file=sys.stderr)
|
|
1628
|
+
return False, []
|
|
1629
|
+
cid, name, fn, aggressive = ours[0]
|
|
1630
|
+
# Build a minimal client + ctx and run the check function directly.
|
|
1631
|
+
from .http import Client
|
|
1632
|
+
client = Client(target, timeout=15.0, user_agent="WPSecScan/refix")
|
|
1633
|
+
try:
|
|
1634
|
+
ctx = {"target": target, "shared": {}, "step": lambda _s: None,
|
|
1635
|
+
"aggressive": False}
|
|
1636
|
+
findings = await fn(client, ctx)
|
|
1637
|
+
return True, [f.to_dict() for f in (findings or [])]
|
|
1638
|
+
finally:
|
|
1639
|
+
try:
|
|
1640
|
+
await client.aclose()
|
|
1641
|
+
except Exception: # noqa: BLE001
|
|
1642
|
+
pass
|
|
1643
|
+
|
|
1644
|
+
ok, findings_out = _asyncio.run(_refix_one())
|
|
1645
|
+
if not ok:
|
|
1646
|
+
sys.exit(2)
|
|
1647
|
+
|
|
1648
|
+
# Classify pass/fail: any finding above 'info' = still failing.
|
|
1649
|
+
fail_findings = [f for f in findings_out
|
|
1650
|
+
if f.get("severity") in ("low", "medium", "high", "critical")]
|
|
1651
|
+
passed = not fail_findings
|
|
1652
|
+
|
|
1653
|
+
home = Path(os.environ.get("WPSECSCAN_HOME") or (Path.home() / ".wpsecscan"))
|
|
1654
|
+
out_dir = home / "refix"
|
|
1655
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1656
|
+
host = (urlparse(target).hostname or "site").replace(".", "-")
|
|
1657
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
1658
|
+
receipt = {
|
|
1659
|
+
"check_id": check_id,
|
|
1660
|
+
"target": target,
|
|
1661
|
+
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
1662
|
+
"passed": passed,
|
|
1663
|
+
"findings": findings_out,
|
|
1664
|
+
}
|
|
1665
|
+
out_path = out_dir / f"{host}-{check_id}-{ts}.json"
|
|
1666
|
+
out_path.write_text(_json.dumps(receipt, indent=2), encoding="utf-8")
|
|
1667
|
+
|
|
1668
|
+
if passed:
|
|
1669
|
+
print(f"PASS: {check_id} on {target} — no actionable findings.")
|
|
1670
|
+
print(f"Receipt: {out_path}")
|
|
1671
|
+
sys.exit(0)
|
|
1672
|
+
else:
|
|
1673
|
+
print(f"FAIL: {check_id} on {target} — {len(fail_findings)} finding(s) still present.")
|
|
1674
|
+
for f in fail_findings[:5]:
|
|
1675
|
+
print(f" [{f.get('severity').upper()}] {f.get('title')}")
|
|
1676
|
+
print(f"Receipt: {out_path}")
|
|
1677
|
+
sys.exit(64)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _cmd_publish(args: list[str]) -> None:
|
|
1681
|
+
"""`wpsecscan publish URL [--out DIR]`
|
|
1682
|
+
|
|
1683
|
+
Generate a small static HTML page declaring "this site was scanned by
|
|
1684
|
+
WPSecScan on YYYY-MM-DD; current risk score is N/100". The page
|
|
1685
|
+
embeds a JSON-LD receipt (target, scanned_at, risk_score) signed
|
|
1686
|
+
with a per-install hardware-key (when available) or HMAC over the
|
|
1687
|
+
bundled ~/.wpsecscan/publish-secret.json otherwise.
|
|
1688
|
+
|
|
1689
|
+
The site owner uploads the page somewhere on their site and links
|
|
1690
|
+
it from their footer. Visitors can manually compare the score on
|
|
1691
|
+
the page to a fresh wpsecscan run to confirm the page hasn't been
|
|
1692
|
+
tampered with.
|
|
1693
|
+
"""
|
|
1694
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1695
|
+
print(_cmd_publish.__doc__.strip()); sys.exit(0 if args else 2)
|
|
1696
|
+
target = args[0]
|
|
1697
|
+
if not target.startswith(("http://", "https://")):
|
|
1698
|
+
target = "https://" + target
|
|
1699
|
+
out_dir = "wpsecscan-publish"
|
|
1700
|
+
for i, a in enumerate(args):
|
|
1701
|
+
if a == "--out" and i + 1 < len(args):
|
|
1702
|
+
out_dir = args[i + 1]
|
|
1703
|
+
|
|
1704
|
+
from . import history as _h
|
|
1705
|
+
import json as _json
|
|
1706
|
+
import hmac as _hmac
|
|
1707
|
+
import hashlib as _hash
|
|
1708
|
+
from datetime import datetime, timezone
|
|
1709
|
+
|
|
1710
|
+
snaps = _h.snapshot_history(target)
|
|
1711
|
+
if not snaps:
|
|
1712
|
+
print(f"no saved snapshots for {target}; run `wpsecscan {target}` first.")
|
|
1713
|
+
sys.exit(64)
|
|
1714
|
+
latest_path = snaps[-1]
|
|
1715
|
+
latest = _json.loads(latest_path.read_text(encoding="utf-8"))
|
|
1716
|
+
risk_score = latest.get("risk_score", "?")
|
|
1717
|
+
scanned_at = latest.get("scanned_at", "")
|
|
1718
|
+
s = latest.get("summary", {})
|
|
1719
|
+
|
|
1720
|
+
# Per-install secret for signing — generated on first publish.
|
|
1721
|
+
home = Path(os.environ.get("WPSECSCAN_HOME") or (Path.home() / ".wpsecscan"))
|
|
1722
|
+
secret_path = home / "publish-secret.json"
|
|
1723
|
+
if not secret_path.exists():
|
|
1724
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
1725
|
+
import secrets as _secrets
|
|
1726
|
+
secret_path.write_text(_json.dumps({"secret": _secrets.token_hex(32)}),
|
|
1727
|
+
encoding="utf-8")
|
|
1728
|
+
secret = _json.loads(secret_path.read_text(encoding="utf-8"))["secret"]
|
|
1729
|
+
|
|
1730
|
+
receipt = {
|
|
1731
|
+
"@context": "https://schema.org",
|
|
1732
|
+
"@type": "ReviewAction",
|
|
1733
|
+
"target": target,
|
|
1734
|
+
"scanned_at": scanned_at,
|
|
1735
|
+
"risk_score": risk_score,
|
|
1736
|
+
"summary": {k: int(s.get(k, 0)) for k in ("critical", "high", "medium", "low", "info")},
|
|
1737
|
+
"scanner": "WPSecScan",
|
|
1738
|
+
"scanner_url": "https://github.com/bryanflowers/wpsecscan",
|
|
1739
|
+
"published": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
1740
|
+
}
|
|
1741
|
+
canonical = _json.dumps(receipt, sort_keys=True).encode("utf-8")
|
|
1742
|
+
sig = _hmac.new(secret.encode("utf-8"), canonical, _hash.sha256).hexdigest()
|
|
1743
|
+
receipt["signature"] = f"sha256={sig}"
|
|
1744
|
+
|
|
1745
|
+
out = Path(out_dir)
|
|
1746
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1747
|
+
|
|
1748
|
+
# Build the HTML page.
|
|
1749
|
+
tier = "green" if isinstance(risk_score, int) and risk_score >= 80 else (
|
|
1750
|
+
"yellow" if isinstance(risk_score, int) and risk_score >= 60 else "orange")
|
|
1751
|
+
color = {"green": "#1f8a3c", "yellow": "#c47700", "orange": "#d35400"}[tier]
|
|
1752
|
+
html = f"""<!doctype html>
|
|
1753
|
+
<html lang="en">
|
|
1754
|
+
<head>
|
|
1755
|
+
<meta charset="utf-8">
|
|
1756
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1757
|
+
<title>Security scan receipt — {target}</title>
|
|
1758
|
+
<style>
|
|
1759
|
+
body{{font:14px/1.5 -apple-system,Segoe UI,sans-serif;color:#222;background:#fafafa;
|
|
1760
|
+
margin:0;padding:40px 20px;display:flex;justify-content:center}}
|
|
1761
|
+
main{{max-width:640px;background:#fff;border:1px solid #ddd;border-radius:10px;
|
|
1762
|
+
padding:32px 36px;box-shadow:0 1px 3px rgba(0,0,0,.04)}}
|
|
1763
|
+
h1{{margin:0 0 6px;font-size:20px}}
|
|
1764
|
+
.meta{{color:#666;font-size:13px;margin-bottom:18px}}
|
|
1765
|
+
.score{{font-size:60px;font-weight:800;color:{color};line-height:1;margin-top:18px}}
|
|
1766
|
+
.grade{{font-size:14px;color:#888;margin-bottom:14px}}
|
|
1767
|
+
table{{width:100%;border-collapse:collapse;margin:14px 0;font-size:13px}}
|
|
1768
|
+
th,td{{border:1px solid #ddd;padding:6px 10px;text-align:center}}
|
|
1769
|
+
th{{background:#f7f7f7;font-weight:600}}
|
|
1770
|
+
.verify{{margin-top:20px;font-size:12px;color:#666;background:#f7f7f7;
|
|
1771
|
+
border:1px solid #eee;border-radius:6px;padding:12px}}
|
|
1772
|
+
code{{background:#f0f0f0;padding:2px 5px;border-radius:3px;font:12px/1.4 ui-monospace,Consolas,monospace}}
|
|
1773
|
+
footer{{text-align:center;margin-top:24px;font-size:11px;color:#999}}
|
|
1774
|
+
</style>
|
|
1775
|
+
<script type="application/ld+json">{_json.dumps(receipt, indent=2)}</script>
|
|
1776
|
+
</head>
|
|
1777
|
+
<body>
|
|
1778
|
+
<main>
|
|
1779
|
+
<h1>Security scan receipt</h1>
|
|
1780
|
+
<div class="meta">Target: <strong>{target}</strong><br>Scanned: {scanned_at}</div>
|
|
1781
|
+
<div class="score">{risk_score}/100</div>
|
|
1782
|
+
<div class="grade">WPSecScan risk score · scanner {receipt['scanner']}</div>
|
|
1783
|
+
<table>
|
|
1784
|
+
<tr><th>Critical</th><th>High</th><th>Medium</th><th>Low</th><th>Info</th></tr>
|
|
1785
|
+
<tr><td>{s.get('critical',0)}</td><td>{s.get('high',0)}</td>
|
|
1786
|
+
<td>{s.get('medium',0)}</td><td>{s.get('low',0)}</td><td>{s.get('info',0)}</td></tr>
|
|
1787
|
+
</table>
|
|
1788
|
+
<div class="verify">
|
|
1789
|
+
<strong>Verify this receipt:</strong> the JSON-LD block in this page's
|
|
1790
|
+
<code><head></code> contains a HMAC-SHA256 signature. To verify,
|
|
1791
|
+
run <code>wpsecscan publish {target} --verify PATH/TO/THIS/FILE</code>
|
|
1792
|
+
on the host that originally signed it.
|
|
1793
|
+
</div>
|
|
1794
|
+
<footer>Generated by <a href="{receipt['scanner_url']}">WPSecScan</a>
|
|
1795
|
+
on {receipt['published']}.</footer>
|
|
1796
|
+
</main>
|
|
1797
|
+
</body>
|
|
1798
|
+
</html>
|
|
1799
|
+
"""
|
|
1800
|
+
page_path = out / "scan-receipt.html"
|
|
1801
|
+
page_path.write_text(html, encoding="utf-8")
|
|
1802
|
+
# Also drop the canonical JSON next to it so the signature can be
|
|
1803
|
+
# re-verified mechanically.
|
|
1804
|
+
(out / "scan-receipt.json").write_text(_json.dumps(receipt, indent=2),
|
|
1805
|
+
encoding="utf-8")
|
|
1806
|
+
print(f"published: {page_path}")
|
|
1807
|
+
print(f" {out / 'scan-receipt.json'}")
|
|
1808
|
+
print(f"upload both files to your site; link the .html from your footer.")
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def _cmd_pr_comment(args: list[str]) -> None:
|
|
1812
|
+
"""`wpsecscan pr-comment PR_URL`
|
|
1813
|
+
|
|
1814
|
+
Inspect a GitHub PR's file list and post (or update) a comment listing
|
|
1815
|
+
currently-open CVEs for any plugin/theme slug under `wp-content/`
|
|
1816
|
+
that the PR touches. The comment is keyed by an HTML marker so
|
|
1817
|
+
repeat runs update the same comment rather than spamming.
|
|
1818
|
+
|
|
1819
|
+
Uses $GITHUB_TOKEN. Pass --dry-run to print the would-be-comment
|
|
1820
|
+
without contacting GitHub.
|
|
1821
|
+
"""
|
|
1822
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1823
|
+
print(_cmd_pr_comment.__doc__.strip()); sys.exit(0 if args else 2)
|
|
1824
|
+
pr_url = args[0]
|
|
1825
|
+
dry = "--dry-run" in args[1:]
|
|
1826
|
+
|
|
1827
|
+
from . import pr_inspector as _pi
|
|
1828
|
+
parsed = _pi._parse_pr_url(pr_url)
|
|
1829
|
+
if not parsed:
|
|
1830
|
+
print(f"not a recognized GitHub PR URL: {pr_url}", file=sys.stderr)
|
|
1831
|
+
sys.exit(2)
|
|
1832
|
+
owner, repo, pr_n = parsed
|
|
1833
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("WPSECSCAN_GITHUB_TOKEN", "")
|
|
1834
|
+
if not token and not dry:
|
|
1835
|
+
print("set $GITHUB_TOKEN to post comments; or pass --dry-run", file=sys.stderr)
|
|
1836
|
+
sys.exit(2)
|
|
1837
|
+
|
|
1838
|
+
touched = _pi.list_changed_slugs(owner, repo, pr_n, token) if token else {"plugins": [], "themes": []}
|
|
1839
|
+
findings = _pi.find_known_cves(touched.get("plugins", []), touched.get("themes", []))
|
|
1840
|
+
body = _pi.build_comment(touched, findings)
|
|
1841
|
+
|
|
1842
|
+
if dry:
|
|
1843
|
+
print("--- DRY RUN ---")
|
|
1844
|
+
print(body)
|
|
1845
|
+
return
|
|
1846
|
+
ok, msg = _pi.post_or_update(owner, repo, pr_n, token, body)
|
|
1847
|
+
if ok:
|
|
1848
|
+
print(f"✓ {msg}")
|
|
1849
|
+
else:
|
|
1850
|
+
print(f"FAILED: {msg}", file=sys.stderr)
|
|
1851
|
+
sys.exit(1)
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
def _cmd_diff_tree(args: list[str]) -> None:
|
|
1855
|
+
"""`wpsecscan diff-tree URL [--limit N]`
|
|
1856
|
+
|
|
1857
|
+
Render a chronological ASCII tree of finding-deltas across the last N
|
|
1858
|
+
snapshots (default 10) for URL. Each snapshot row shows + new, - fixed,
|
|
1859
|
+
and the running risk-score.
|
|
1860
|
+
"""
|
|
1861
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1862
|
+
print(_cmd_diff_tree.__doc__.strip()); sys.exit(0 if args else 2)
|
|
1863
|
+
target = args[0]
|
|
1864
|
+
if not target.startswith(("http://", "https://")):
|
|
1865
|
+
target = "https://" + target
|
|
1866
|
+
limit = 10
|
|
1867
|
+
for i, a in enumerate(args):
|
|
1868
|
+
if a == "--limit" and i + 1 < len(args):
|
|
1869
|
+
try:
|
|
1870
|
+
limit = max(2, int(args[i + 1]))
|
|
1871
|
+
except ValueError:
|
|
1872
|
+
pass
|
|
1873
|
+
|
|
1874
|
+
from . import history as _h
|
|
1875
|
+
import json as _json
|
|
1876
|
+
snaps = _h.snapshot_history(target)
|
|
1877
|
+
if len(snaps) < 2:
|
|
1878
|
+
print(f"need at least 2 snapshots for {target}; found {len(snaps)}.")
|
|
1879
|
+
sys.exit(64)
|
|
1880
|
+
snaps = snaps[-limit:]
|
|
1881
|
+
|
|
1882
|
+
def _key_set(report: dict, min_sev: tuple = ("low", "medium", "high", "critical")) -> set[str]:
|
|
1883
|
+
out = set()
|
|
1884
|
+
for r in report.get("results", []) or []:
|
|
1885
|
+
for f in r.get("findings", []) or []:
|
|
1886
|
+
if f.get("severity") in min_sev:
|
|
1887
|
+
out.add(f"{r.get('check_id','')}::{f.get('title','')}")
|
|
1888
|
+
return out
|
|
1889
|
+
|
|
1890
|
+
prev: set[str] = set()
|
|
1891
|
+
print(f"diff-tree for {target} (last {len(snaps)} snapshots):\n")
|
|
1892
|
+
for i, sp in enumerate(snaps):
|
|
1893
|
+
try:
|
|
1894
|
+
data = _json.loads(sp.read_text(encoding="utf-8"))
|
|
1895
|
+
except (OSError, ValueError):
|
|
1896
|
+
continue
|
|
1897
|
+
ts = data.get("scanned_at") or sp.stem.split("-")[-1]
|
|
1898
|
+
cur = _key_set(data)
|
|
1899
|
+
added = sorted(cur - prev)
|
|
1900
|
+
removed = sorted(prev - cur)
|
|
1901
|
+
score = data.get("risk_score", "?")
|
|
1902
|
+
prefix = "├──" if i < len(snaps) - 1 else "└──"
|
|
1903
|
+
print(f"{prefix} {ts} score={score} total={len(cur)} +{len(added)} -{len(removed)}")
|
|
1904
|
+
bar = "│ " if i < len(snaps) - 1 else " "
|
|
1905
|
+
for t in added[:5]:
|
|
1906
|
+
print(f"{bar} + {t[:90]}")
|
|
1907
|
+
for t in removed[:5]:
|
|
1908
|
+
print(f"{bar} - {t[:90]}")
|
|
1909
|
+
prev = cur
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
def _cmd_snooze(args: list[str]) -> None:
|
|
1913
|
+
"""`wpsecscan snooze {list|import|clear} ...`
|
|
1914
|
+
|
|
1915
|
+
list [--active-only]
|
|
1916
|
+
Print every (URL, check_id, finding_title, status, snooze_until)
|
|
1917
|
+
from ~/.wpsecscan/annotations.json. With --active-only, hide
|
|
1918
|
+
snoozes that have already expired.
|
|
1919
|
+
|
|
1920
|
+
import FILE.csv
|
|
1921
|
+
Bulk-snooze (or bulk-accept-risk) from a CSV with header columns:
|
|
1922
|
+
url, check_id, finding_title, status, snooze_until, note
|
|
1923
|
+
Status is one of: accepted-risk, false-positive. snooze_until
|
|
1924
|
+
is an ISO date YYYY-MM-DD; leave blank for "indefinite".
|
|
1925
|
+
|
|
1926
|
+
clear URL [CHECK_ID [TITLE]]
|
|
1927
|
+
Remove annotation(s). Without CHECK_ID, clears every annotation
|
|
1928
|
+
for the URL. With CHECK_ID, only that check's annotations.
|
|
1929
|
+
"""
|
|
1930
|
+
if not args or args[0] in ("-h", "--help"):
|
|
1931
|
+
print(_cmd_snooze.__doc__.strip()); sys.exit(0)
|
|
1932
|
+
|
|
1933
|
+
from . import history as _h
|
|
1934
|
+
action = args[0]
|
|
1935
|
+
rest = args[1:]
|
|
1936
|
+
|
|
1937
|
+
if action == "list":
|
|
1938
|
+
active_only = "--active-only" in rest
|
|
1939
|
+
ann = _h.load_annotations()
|
|
1940
|
+
rows: list[tuple[str, str, str, str, str]] = []
|
|
1941
|
+
for url, bucket in sorted(ann.items()):
|
|
1942
|
+
for fp, entry in bucket.items():
|
|
1943
|
+
if active_only and not _h.is_active_annotation(entry):
|
|
1944
|
+
continue
|
|
1945
|
+
rows.append((
|
|
1946
|
+
url,
|
|
1947
|
+
str(entry.get("check_id") or fp.split(":", 1)[0]),
|
|
1948
|
+
str(entry.get("title") or fp.split(":", 1)[-1])[:40],
|
|
1949
|
+
str(entry.get("status") or ""),
|
|
1950
|
+
str(entry.get("snooze_until") or ""),
|
|
1951
|
+
))
|
|
1952
|
+
if not rows:
|
|
1953
|
+
print("(no annotations)")
|
|
1954
|
+
return
|
|
1955
|
+
print(f"{'URL':50s} {'CHECK':16s} {'TITLE':40s} {'STATUS':16s} SNOOZE")
|
|
1956
|
+
for url, cid, title, status, snooze in rows:
|
|
1957
|
+
print(f"{url[:50]:50s} {cid[:16]:16s} {title:40s} {status[:16]:16s} {snooze}")
|
|
1958
|
+
return
|
|
1959
|
+
|
|
1960
|
+
if action == "import":
|
|
1961
|
+
if not rest:
|
|
1962
|
+
print("usage: wpsecscan snooze import FILE.csv", file=sys.stderr)
|
|
1963
|
+
sys.exit(2)
|
|
1964
|
+
import csv as _csv
|
|
1965
|
+
path = Path(rest[0])
|
|
1966
|
+
if not path.exists():
|
|
1967
|
+
print(f"file not found: {path}", file=sys.stderr); sys.exit(2)
|
|
1968
|
+
count = 0
|
|
1969
|
+
with path.open(encoding="utf-8") as f:
|
|
1970
|
+
reader = _csv.DictReader(f)
|
|
1971
|
+
for row in reader:
|
|
1972
|
+
url = (row.get("url") or "").strip()
|
|
1973
|
+
cid = (row.get("check_id") or "").strip()
|
|
1974
|
+
title = (row.get("finding_title") or row.get("title") or "").strip()
|
|
1975
|
+
status = (row.get("status") or "accepted-risk").strip()
|
|
1976
|
+
snooze = (row.get("snooze_until") or "").strip()
|
|
1977
|
+
note = (row.get("note") or "").strip()
|
|
1978
|
+
if not (url and cid and title):
|
|
1979
|
+
continue
|
|
1980
|
+
_h.set_annotation(url, cid, title, status,
|
|
1981
|
+
note=note, snooze_until=snooze)
|
|
1982
|
+
count += 1
|
|
1983
|
+
print(f"imported {count} annotation(s) from {path}")
|
|
1984
|
+
return
|
|
1985
|
+
|
|
1986
|
+
if action == "clear":
|
|
1987
|
+
if not rest:
|
|
1988
|
+
print("usage: wpsecscan snooze clear URL [CHECK_ID [TITLE]]", file=sys.stderr)
|
|
1989
|
+
sys.exit(2)
|
|
1990
|
+
url = rest[0]
|
|
1991
|
+
cid_filter = rest[1] if len(rest) > 1 else None
|
|
1992
|
+
title_filter = rest[2] if len(rest) > 2 else None
|
|
1993
|
+
ann = _h.load_annotations()
|
|
1994
|
+
bucket = ann.get(url, {})
|
|
1995
|
+
before = len(bucket)
|
|
1996
|
+
if cid_filter and title_filter:
|
|
1997
|
+
_h.set_annotation(url, cid_filter, title_filter, "") # empty status clears
|
|
1998
|
+
after = len(_h.load_annotations().get(url, {}))
|
|
1999
|
+
print(f"cleared 1 annotation ({cid_filter} / {title_filter})")
|
|
2000
|
+
elif cid_filter:
|
|
2001
|
+
# iterate by fingerprint prefix
|
|
2002
|
+
for fp in list(bucket.keys()):
|
|
2003
|
+
if fp.startswith(cid_filter + ":"):
|
|
2004
|
+
title = bucket[fp].get("title") or fp.split(":", 1)[-1]
|
|
2005
|
+
_h.set_annotation(url, cid_filter, title, "")
|
|
2006
|
+
after = len(_h.load_annotations().get(url, {}))
|
|
2007
|
+
print(f"cleared {before - after} annotation(s) for {cid_filter}")
|
|
2008
|
+
else:
|
|
2009
|
+
# nuke all for this URL
|
|
2010
|
+
ann.pop(url, None)
|
|
2011
|
+
_h._save_annotations(ann)
|
|
2012
|
+
print(f"cleared {before} annotation(s) for {url}")
|
|
2013
|
+
return
|
|
2014
|
+
|
|
2015
|
+
print(f"unknown snooze action: {action}", file=sys.stderr)
|
|
2016
|
+
sys.exit(2)
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def _cmd_config(args: list[str]) -> None:
|
|
2020
|
+
"""`wpsecscan config validate <path>` — lint the daemon YAML config."""
|
|
2021
|
+
if not args or args[0] in ("-h", "--help"):
|
|
2022
|
+
print("usage: wpsecscan config validate <path>")
|
|
2023
|
+
return
|
|
2024
|
+
if args[0] != "validate" or len(args) < 2:
|
|
2025
|
+
print("usage: wpsecscan config validate <path>", file=sys.stderr)
|
|
2026
|
+
sys.exit(64)
|
|
2027
|
+
issues = _validate_yaml_config(Path(args[1]))
|
|
2028
|
+
if not issues:
|
|
2029
|
+
print(f"OK — {args[1]} validated cleanly.")
|
|
2030
|
+
return
|
|
2031
|
+
print(f"FAIL — {len(issues)} issue(s) in {args[1]}:")
|
|
2032
|
+
for i in issues:
|
|
2033
|
+
print(f" - {i}")
|
|
2034
|
+
sys.exit(1)
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
def _validate_yaml_config(path: Path) -> list[str]:
|
|
2038
|
+
"""Lint the daemon YAML config; return a list of human-readable issues
|
|
2039
|
+
(empty list = valid). Used by the new `wpsecscan config validate` cmd."""
|
|
2040
|
+
issues: list[str] = []
|
|
2041
|
+
if not path.exists():
|
|
2042
|
+
return [f"file not found: {path}"]
|
|
2043
|
+
try:
|
|
2044
|
+
import yaml as _yaml # type: ignore
|
|
2045
|
+
except ImportError:
|
|
2046
|
+
return ["PyYAML not installed (pip install pyyaml or pip install wpsecscan[yaml])"]
|
|
2047
|
+
try:
|
|
2048
|
+
doc = _yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
2049
|
+
except _yaml.YAMLError as e:
|
|
2050
|
+
return [f"YAML parse error: {e}"]
|
|
2051
|
+
if not isinstance(doc, dict):
|
|
2052
|
+
return ["top-level must be a mapping (key: value, ...)"]
|
|
2053
|
+
schedule = doc.get("schedule") or {}
|
|
2054
|
+
if not isinstance(schedule, dict):
|
|
2055
|
+
issues.append("`schedule` must be a mapping")
|
|
2056
|
+
targets = doc.get("targets") or doc.get("sites") or []
|
|
2057
|
+
if not isinstance(targets, list):
|
|
2058
|
+
issues.append("`targets` (or `sites`) must be a list")
|
|
2059
|
+
elif not targets:
|
|
2060
|
+
issues.append("`targets` is empty — daemon would do nothing")
|
|
2061
|
+
for i, t in enumerate(targets if isinstance(targets, list) else []):
|
|
2062
|
+
if isinstance(t, str):
|
|
2063
|
+
if not t.startswith(("http://", "https://")):
|
|
2064
|
+
issues.append(f"targets[{i}]: URL must start with http:// or https://")
|
|
2065
|
+
elif isinstance(t, dict):
|
|
2066
|
+
if not t.get("url"):
|
|
2067
|
+
issues.append(f"targets[{i}]: missing `url` key")
|
|
2068
|
+
else:
|
|
2069
|
+
issues.append(f"targets[{i}]: must be string URL or mapping with `url`")
|
|
2070
|
+
cron = doc.get("cron")
|
|
2071
|
+
if cron and not isinstance(cron, str):
|
|
2072
|
+
issues.append("`cron` must be a string in 5-field cron format")
|
|
2073
|
+
return issues
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
def _cmd_paths(args: list[str]) -> None:
|
|
2077
|
+
"""`wpsecscan paths` — print the canonical ~/.wpsecscan/ layout with
|
|
2078
|
+
a short description and the current on-disk size of each entry.
|
|
2079
|
+
Useful for operators who want to know where to back up / clean up
|
|
2080
|
+
state without grepping the source."""
|
|
2081
|
+
if args and args[0] in ("-h", "--help"):
|
|
2082
|
+
print("usage: wpsecscan paths (prints ~/.wpsecscan/ layout + current sizes)")
|
|
2083
|
+
return
|
|
2084
|
+
from . import history as _h
|
|
2085
|
+
home = _h._home()
|
|
2086
|
+
items = [
|
|
2087
|
+
("history.json", "Last 20 scanned URLs (GUI dropdown)"),
|
|
2088
|
+
("profiles.json", "Named scan profiles"),
|
|
2089
|
+
("settings.json", "Tokens from the GUI onboarding wizard"),
|
|
2090
|
+
("sites.json", "Managed sites list (`wpsecscan sites`)"),
|
|
2091
|
+
("schedule_state.json", "Scheduled-scan cron state"),
|
|
2092
|
+
("digest.json", "SMTP / webhook digest config"),
|
|
2093
|
+
("annotations.json", "Per-finding annotations"),
|
|
2094
|
+
("comments.json", "Per-finding free-text comments"),
|
|
2095
|
+
("stars.json", "Starred findings"),
|
|
2096
|
+
("disabled_checks.json", "Persistently disabled check IDs"),
|
|
2097
|
+
("reports/", "Saved JSON / HTML snapshots + timestamped history"),
|
|
2098
|
+
("cache/wporg/", "24h-cached wp.org plugin metadata"),
|
|
2099
|
+
("checkpoints/", "--checkpoint resumable scan state"),
|
|
2100
|
+
("logs/", "--debug log files (rotating)"),
|
|
2101
|
+
("demo/", "--demo synthetic-scan artifacts"),
|
|
2102
|
+
("analytics/events.jsonl", "Opt-in analytics (off by default)"),
|
|
2103
|
+
("wordfence.json", "Aggregated CVE database cache"),
|
|
2104
|
+
]
|
|
2105
|
+
def _size(p: Path) -> str:
|
|
2106
|
+
if not p.exists():
|
|
2107
|
+
return "(absent)"
|
|
2108
|
+
if p.is_dir():
|
|
2109
|
+
total = 0
|
|
2110
|
+
n = 0
|
|
2111
|
+
for f in p.rglob("*"):
|
|
2112
|
+
try:
|
|
2113
|
+
if f.is_file():
|
|
2114
|
+
total += f.stat().st_size
|
|
2115
|
+
n += 1
|
|
2116
|
+
except OSError:
|
|
2117
|
+
pass
|
|
2118
|
+
return f"{total/1024:>8.1f} KB ({n} files)"
|
|
2119
|
+
try:
|
|
2120
|
+
return f"{p.stat().st_size/1024:>8.1f} KB"
|
|
2121
|
+
except OSError:
|
|
2122
|
+
return "(error)"
|
|
2123
|
+
print(f"WPSECSCAN_HOME = {home}\n")
|
|
2124
|
+
for name, desc in items:
|
|
2125
|
+
p = home / name
|
|
2126
|
+
print(f" {name:36s} {_size(p):>22s} {desc}")
|
|
2127
|
+
print(f"\nOverride the base directory with the WPSECSCAN_HOME env var.")
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def _cmd_compare(args: list[str]) -> None:
|
|
2131
|
+
"""`wpsecscan compare URL` — diff the two most recent snapshots of URL.
|
|
2132
|
+
|
|
2133
|
+
Snapshots are auto-saved under ~/.wpsecscan/reports/{safe}-{ts}.json by
|
|
2134
|
+
every scan. Exits 0 if no new findings, 1 if any added since prior scan.
|
|
2135
|
+
"""
|
|
2136
|
+
if not args or args[0] in ("-h", "--help"):
|
|
2137
|
+
print("usage: wpsecscan compare <URL>", file=sys.stderr)
|
|
2138
|
+
sys.exit(64)
|
|
2139
|
+
url = args[0]
|
|
2140
|
+
# Normalise scheme-less URLs (`example.com` → `https://example.com`) so the
|
|
2141
|
+
# snapshot lookup uses a real hostname instead of falling back to "site"
|
|
2142
|
+
# which would match every scheme-less scan.
|
|
2143
|
+
if "://" not in url:
|
|
2144
|
+
url = "https://" + url
|
|
2145
|
+
from . import history as _h
|
|
2146
|
+
snaps = _h.snapshot_history(url)
|
|
2147
|
+
if len(snaps) < 2:
|
|
2148
|
+
msg = ("Need at least 2 saved snapshots to compare; "
|
|
2149
|
+
f"found {len(snaps)} for {url}. "
|
|
2150
|
+
f"Run `wpsecscan {url}` a couple of times first.")
|
|
2151
|
+
print(msg, file=sys.stderr)
|
|
2152
|
+
sys.exit(64)
|
|
2153
|
+
old, new = snaps[-2], snaps[-1]
|
|
2154
|
+
print(f"Comparing:\n before: {old.name}\n after: {new.name}\n", file=sys.stderr)
|
|
2155
|
+
d = diff_mod.diff(old, new)
|
|
2156
|
+
print(diff_mod.render_text(d))
|
|
2157
|
+
sys.exit(0 if not d.get("new") else 1)
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def _cmd_badge(args: list[str]) -> None:
|
|
2161
|
+
"""`wpsecscan badge URL [--out badge.svg]` — emit a shields.io-style SVG
|
|
2162
|
+
of the most recent scan's grade. Reads the canonical
|
|
2163
|
+
`~/.wpsecscan/reports/{safe}.json`."""
|
|
2164
|
+
if not args or args[0] in ("-h", "--help"):
|
|
2165
|
+
print("usage: wpsecscan badge <URL> [--out badge.svg]", file=sys.stderr)
|
|
2166
|
+
sys.exit(64)
|
|
2167
|
+
url = args[0]
|
|
2168
|
+
out_path: Path | None = None
|
|
2169
|
+
# Replaced the previous fragile enumerate(args[1:], 1) + args[i+1] dance
|
|
2170
|
+
# with a plain index walk over the remaining args.
|
|
2171
|
+
i = 1
|
|
2172
|
+
while i < len(args):
|
|
2173
|
+
if args[i] == "--out" and i + 1 < len(args):
|
|
2174
|
+
out_path = Path(args[i + 1])
|
|
2175
|
+
i += 2
|
|
2176
|
+
else:
|
|
2177
|
+
i += 1
|
|
2178
|
+
from . import history as _h
|
|
2179
|
+
from .reporters import badge_svg as _bs
|
|
2180
|
+
snap = _h.previous_report_path(url)
|
|
2181
|
+
if snap is None:
|
|
2182
|
+
print(f"No saved snapshot for {url}. Run `wpsecscan {url}` first.",
|
|
2183
|
+
file=sys.stderr)
|
|
2184
|
+
sys.exit(64)
|
|
2185
|
+
import json as _json
|
|
2186
|
+
try:
|
|
2187
|
+
data = _json.loads(snap.read_text(encoding="utf-8"))
|
|
2188
|
+
except (OSError, ValueError) as e:
|
|
2189
|
+
print(f"Could not read snapshot: {e}", file=sys.stderr)
|
|
2190
|
+
sys.exit(1)
|
|
2191
|
+
summary = data.get("summary") or {}
|
|
2192
|
+
svg = _bs.render_badge_svg(summary)
|
|
2193
|
+
if out_path:
|
|
2194
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2195
|
+
out_path.write_text(svg, encoding="utf-8")
|
|
2196
|
+
print(f"Wrote badge: {out_path}", file=sys.stderr)
|
|
2197
|
+
else:
|
|
2198
|
+
print(svg)
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def _cmd_ai_options(args: list[str]) -> None:
|
|
2202
|
+
"""Round-65 Group C — manage Advanced AI triage settings.
|
|
2203
|
+
|
|
2204
|
+
Usage:
|
|
2205
|
+
wpsecscan ai-options list
|
|
2206
|
+
wpsecscan ai-options get <field>
|
|
2207
|
+
wpsecscan ai-options set <field> <value>
|
|
2208
|
+
"""
|
|
2209
|
+
if args and args[0] in ("-h", "--help"):
|
|
2210
|
+
print("usage: wpsecscan ai-options {list | get <field> | set <field> <value>}")
|
|
2211
|
+
return
|
|
2212
|
+
from . import ai_triage_ui
|
|
2213
|
+
if not args or args[0] == "list":
|
|
2214
|
+
print(ai_triage_ui.cli_list())
|
|
2215
|
+
return
|
|
2216
|
+
if args[0] == "get" and len(args) >= 2:
|
|
2217
|
+
print(ai_triage_ui.cli_get(args[1]))
|
|
2218
|
+
return
|
|
2219
|
+
if args[0] == "set" and len(args) >= 3:
|
|
2220
|
+
print(ai_triage_ui.cli_set(args[1], args[2]))
|
|
2221
|
+
return
|
|
2222
|
+
print(_cmd_ai_options.__doc__)
|
|
2223
|
+
sys.exit(2)
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
def _cmd_analytics(args: list[str]) -> None:
|
|
2227
|
+
"""Round-65 — manage opt-in local usage analytics.
|
|
2228
|
+
|
|
2229
|
+
Usage:
|
|
2230
|
+
wpsecscan analytics status
|
|
2231
|
+
wpsecscan analytics enable
|
|
2232
|
+
wpsecscan analytics disable
|
|
2233
|
+
wpsecscan analytics show
|
|
2234
|
+
wpsecscan analytics export <path>
|
|
2235
|
+
wpsecscan analytics forget
|
|
2236
|
+
"""
|
|
2237
|
+
if args and args[0] in ("-h", "--help"):
|
|
2238
|
+
print("usage: wpsecscan analytics {status | enable | disable | show | export <path> | forget}")
|
|
2239
|
+
return
|
|
2240
|
+
from . import analytics
|
|
2241
|
+
if not args or args[0] == "status":
|
|
2242
|
+
st = analytics.status()
|
|
2243
|
+
print(f"Enabled: {st['enabled']}")
|
|
2244
|
+
print(f"Anonymous ID: {st['anonymous_id']}")
|
|
2245
|
+
print(f"Events recorded: {st['event_count']}")
|
|
2246
|
+
print(f"Storage: {st['storage_path']}")
|
|
2247
|
+
print(f"Upload destination: {st['upload_destination'] or '(local only)'}")
|
|
2248
|
+
return
|
|
2249
|
+
if args[0] == "enable":
|
|
2250
|
+
print(analytics.enable())
|
|
2251
|
+
return
|
|
2252
|
+
if args[0] == "disable":
|
|
2253
|
+
print(analytics.disable())
|
|
2254
|
+
return
|
|
2255
|
+
if args[0] == "show":
|
|
2256
|
+
print(analytics.show_recent(limit=int(args[1]) if len(args) > 1 else 50))
|
|
2257
|
+
return
|
|
2258
|
+
if args[0] == "export" and len(args) >= 2:
|
|
2259
|
+
print(analytics.export(args[1]))
|
|
2260
|
+
return
|
|
2261
|
+
if args[0] == "forget":
|
|
2262
|
+
print(analytics.forget())
|
|
2263
|
+
return
|
|
2264
|
+
print(_cmd_analytics.__doc__)
|
|
2265
|
+
sys.exit(2)
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
def _cmd_sites(args: list[str]) -> None:
|
|
2269
|
+
from . import sites as sites_mod
|
|
2270
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
2271
|
+
print("usage: wpsecscan sites {add|list|remove|scan} ...")
|
|
2272
|
+
return
|
|
2273
|
+
action = args[0]
|
|
2274
|
+
rest = args[1:]
|
|
2275
|
+
if action == "list":
|
|
2276
|
+
# #31: --tag FOO filter
|
|
2277
|
+
tag_filter = None
|
|
2278
|
+
for i, a in enumerate(rest):
|
|
2279
|
+
if a == "--tag" and i + 1 < len(rest):
|
|
2280
|
+
tag_filter = rest[i + 1].lower()
|
|
2281
|
+
break
|
|
2282
|
+
for s in sites_mod.list_sites():
|
|
2283
|
+
tags = s.get("tags") or []
|
|
2284
|
+
if tag_filter and tag_filter not in tags:
|
|
2285
|
+
continue
|
|
2286
|
+
ts = s.get("last_scan_ts") or 0
|
|
2287
|
+
when = "never" if not ts else __import__("time").strftime("%Y-%m-%d", __import__("time").localtime(ts))
|
|
2288
|
+
tag_str = (" [" + ",".join(tags) + "]") if tags else ""
|
|
2289
|
+
print(f" {s['url']:60s} weekly={s.get('weekly', False)} last={when} risk={s.get('last_risk_score', '?')}{tag_str}")
|
|
2290
|
+
return
|
|
2291
|
+
if action == "remove":
|
|
2292
|
+
if not rest:
|
|
2293
|
+
print("usage: wpsecscan sites remove URL"); sys.exit(2)
|
|
2294
|
+
ok = sites_mod.remove(rest[0])
|
|
2295
|
+
print("removed" if ok else "not found")
|
|
2296
|
+
return
|
|
2297
|
+
if action == "add":
|
|
2298
|
+
url = None
|
|
2299
|
+
flags = {"weekly": False, "auth_user": None, "auth_app_password": None,
|
|
2300
|
+
"companion_token": None, "proxy_url": None, "proxy_auth": None,
|
|
2301
|
+
"notes": "", "tags": None}
|
|
2302
|
+
i = 0
|
|
2303
|
+
while i < len(rest):
|
|
2304
|
+
a = rest[i]
|
|
2305
|
+
if a == "--weekly":
|
|
2306
|
+
flags["weekly"] = True; i += 1
|
|
2307
|
+
elif a == "--auth-user" and i + 1 < len(rest):
|
|
2308
|
+
flags["auth_user"] = rest[i + 1]; i += 2
|
|
2309
|
+
elif a == "--auth-app-password" and i + 1 < len(rest):
|
|
2310
|
+
flags["auth_app_password"] = rest[i + 1]; i += 2
|
|
2311
|
+
elif a == "--companion-token" and i + 1 < len(rest):
|
|
2312
|
+
flags["companion_token"] = rest[i + 1]; i += 2
|
|
2313
|
+
elif a == "--proxy" and i + 1 < len(rest):
|
|
2314
|
+
flags["proxy_url"] = rest[i + 1]; i += 2
|
|
2315
|
+
elif a == "--proxy-auth" and i + 1 < len(rest):
|
|
2316
|
+
flags["proxy_auth"] = rest[i + 1]; i += 2
|
|
2317
|
+
elif a == "--notes" and i + 1 < len(rest):
|
|
2318
|
+
flags["notes"] = rest[i + 1]; i += 2
|
|
2319
|
+
elif a == "--tag" and i + 1 < len(rest):
|
|
2320
|
+
if flags["tags"] is None:
|
|
2321
|
+
flags["tags"] = []
|
|
2322
|
+
flags["tags"].append(rest[i + 1])
|
|
2323
|
+
i += 2
|
|
2324
|
+
elif not a.startswith("--") and url is None:
|
|
2325
|
+
url = a; i += 1
|
|
2326
|
+
else:
|
|
2327
|
+
i += 1
|
|
2328
|
+
if not url:
|
|
2329
|
+
print("usage: wpsecscan sites add URL [--weekly] [--auth-user U] [--auth-app-password P] [--proxy URL] [--proxy-auth user:pass] [--tag client:foo]"); sys.exit(2)
|
|
2330
|
+
entry = sites_mod.add(url, **flags)
|
|
2331
|
+
tag_str = (" tags=" + ",".join(entry.get("tags", []))) if entry.get("tags") else ""
|
|
2332
|
+
print(f"added: {entry['url']} (weekly={entry['weekly']}{', proxied' if flags['proxy_url'] else ''}{tag_str})")
|
|
2333
|
+
return
|
|
2334
|
+
if action == "scan":
|
|
2335
|
+
from . import sites as sites_mod
|
|
2336
|
+
targets = [rest[0]] if rest else None
|
|
2337
|
+
sites_to_scan = [sites_mod.get(targets[0])] if targets else sites_mod.due_now()
|
|
2338
|
+
sites_to_scan = [s for s in sites_to_scan if s]
|
|
2339
|
+
if not sites_to_scan:
|
|
2340
|
+
print("nothing due. use `wpsecscan sites scan URL` to force one.")
|
|
2341
|
+
return
|
|
2342
|
+
print(f"scanning {len(sites_to_scan)} site(s)...")
|
|
2343
|
+
for s in sites_to_scan:
|
|
2344
|
+
url = s["url"]
|
|
2345
|
+
print(f" -> {url}")
|
|
2346
|
+
# Shell out to a fresh wpsecscan invocation per site so a crash
|
|
2347
|
+
# in one doesn't kill the batch. Pass per-site secrets via env
|
|
2348
|
+
# vars rather than CLI args so they don't appear in `ps aux` or
|
|
2349
|
+
# shell history; the child process picks them up via argparse
|
|
2350
|
+
# defaults (see argparse setup above).
|
|
2351
|
+
cmd = [sys.executable, "-m", "wpsecscan", url, "--out", "wpsecscan-reports"]
|
|
2352
|
+
child_env = dict(os.environ)
|
|
2353
|
+
if s.get("auth_user"):
|
|
2354
|
+
child_env["WPSECSCAN_AUTH_USER"] = s["auth_user"]
|
|
2355
|
+
if s.get("proxy_url"):
|
|
2356
|
+
cmd.extend(["--proxy", s["proxy_url"]]) # not secret
|
|
2357
|
+
for sealed_key, env_name in (
|
|
2358
|
+
("proxy_auth_sealed", "WPSECSCAN_PROXY_AUTH"),
|
|
2359
|
+
("auth_app_password_sealed", "WPSECSCAN_AUTH_APP_PASSWORD"),
|
|
2360
|
+
("companion_token_sealed", "WPSECSCAN_COMPANION_TOKEN"),
|
|
2361
|
+
):
|
|
2362
|
+
if s.get(sealed_key):
|
|
2363
|
+
try:
|
|
2364
|
+
child_env[env_name] = sites_mod._unseal(s[sealed_key])
|
|
2365
|
+
except Exception: # noqa: BLE001
|
|
2366
|
+
pass
|
|
2367
|
+
# Forward enrichment tokens too (UX-030), again via env not argv.
|
|
2368
|
+
for env_name in ("WPSECSCAN_WPSCAN_TOKEN", "WPSECSCAN_PATCHSTACK_TOKEN",
|
|
2369
|
+
"WPSECSCAN_HIBP_TOKEN", "WPSECSCAN_VT_TOKEN",
|
|
2370
|
+
"WPSECSCAN_ABUSEIPDB_TOKEN", "WPSECSCAN_GITHUB_SEARCH_TOKEN"):
|
|
2371
|
+
v = os.environ.get(env_name)
|
|
2372
|
+
if v:
|
|
2373
|
+
child_env[env_name] = v
|
|
2374
|
+
__import__("subprocess").run(cmd, env=child_env, check=False)
|
|
2375
|
+
return
|
|
2376
|
+
print(f"unknown sites action: {action}", file=sys.stderr); sys.exit(2)
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
def _cmd_schedule(args: list[str]) -> None:
|
|
2380
|
+
from . import sites as sites_mod
|
|
2381
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
2382
|
+
print("usage: wpsecscan schedule {install [--time HH:MM] [--weekly]|uninstall|pause|resume|status}")
|
|
2383
|
+
return
|
|
2384
|
+
action = args[0]
|
|
2385
|
+
if action == "install":
|
|
2386
|
+
time_hhmm = "03:00"
|
|
2387
|
+
for i, a in enumerate(args[1:]):
|
|
2388
|
+
if a == "--time" and i + 2 <= len(args[1:]):
|
|
2389
|
+
time_hhmm = args[i + 2]
|
|
2390
|
+
res = sites_mod.install_schedule(time_hhmm=time_hhmm)
|
|
2391
|
+
print(("OK: " if res["ok"] else "FAIL: ") + f"{res['method']} — {res['detail']}")
|
|
2392
|
+
elif action == "uninstall":
|
|
2393
|
+
res = sites_mod.uninstall_schedule()
|
|
2394
|
+
print(("OK: " if res["ok"] else "FAIL: ") + f"{res['method']} — {res['detail']}")
|
|
2395
|
+
elif action == "pause":
|
|
2396
|
+
sites_mod.pause(); print("scheduler paused")
|
|
2397
|
+
elif action == "resume":
|
|
2398
|
+
sites_mod.resume(); print("scheduler resumed")
|
|
2399
|
+
elif action == "status":
|
|
2400
|
+
print("paused" if sites_mod.is_paused() else "active")
|
|
2401
|
+
else:
|
|
2402
|
+
print(f"unknown schedule action: {action}", file=sys.stderr); sys.exit(2)
|
|
2403
|
+
|
|
2404
|
+
|
|
2405
|
+
def _cmd_digest(args: list[str]) -> None:
|
|
2406
|
+
from . import sites as sites_mod
|
|
2407
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
2408
|
+
print("usage: wpsecscan digest {configure --to ADDR [--smtp HOST] [--slack-webhook URL] | test | send}")
|
|
2409
|
+
return
|
|
2410
|
+
if args[0] == "configure":
|
|
2411
|
+
kv: dict[str, str] = {"to": "", "smtp": "", "smtp_user": "", "smtp_pass": "",
|
|
2412
|
+
"from_addr": "", "slack_webhook": ""}
|
|
2413
|
+
i = 1
|
|
2414
|
+
while i < len(args):
|
|
2415
|
+
a = args[i].lstrip("-").replace("-", "_")
|
|
2416
|
+
if a in kv and i + 1 < len(args):
|
|
2417
|
+
kv[a] = args[i + 1]; i += 2
|
|
2418
|
+
else:
|
|
2419
|
+
i += 1
|
|
2420
|
+
if not kv["to"]:
|
|
2421
|
+
print("usage: wpsecscan digest configure --to ops@example.com [--smtp host:port --smtp-user U --smtp-pass P --from-addr F]"); sys.exit(2)
|
|
2422
|
+
# Either SMTP host or a webhook (notify module reads SLACK_WEBHOOK
|
|
2423
|
+
# etc. from env) must be reachable; otherwise the digest silently
|
|
2424
|
+
# never sends. Warn at configure time, not at send time.
|
|
2425
|
+
import os as _os
|
|
2426
|
+
has_webhook = any(_os.environ.get(k) for k in
|
|
2427
|
+
("WPSECSCAN_SLACK_WEBHOOK", "WPSECSCAN_DISCORD_WEBHOOK",
|
|
2428
|
+
"WPSECSCAN_TEAMS_WEBHOOK", "WPSECSCAN_WEBHOOK_URL"))
|
|
2429
|
+
if not kv.get("smtp") and not has_webhook:
|
|
2430
|
+
print(
|
|
2431
|
+
"[warn] no --smtp host given and no WPSECSCAN_*_WEBHOOK env var set. "
|
|
2432
|
+
"Digest will be configured but `digest send` will silently no-op until "
|
|
2433
|
+
"you set one. Continue? (Ctrl+C to abort)",
|
|
2434
|
+
file=sys.stderr,
|
|
2435
|
+
)
|
|
2436
|
+
sites_mod.configure_digest(**kv)
|
|
2437
|
+
print("digest configured")
|
|
2438
|
+
elif args[0] in ("send", "test"):
|
|
2439
|
+
cfg = sites_mod.load_digest()
|
|
2440
|
+
if not cfg:
|
|
2441
|
+
print("no digest configured. run `wpsecscan digest configure --to ...` first."); sys.exit(2)
|
|
2442
|
+
body = sites_mod.render_digest(sites_mod.list_sites())
|
|
2443
|
+
# Use the existing notify module for actual delivery.
|
|
2444
|
+
from . import notify
|
|
2445
|
+
try:
|
|
2446
|
+
notify.notify("WPSecScan weekly digest", body)
|
|
2447
|
+
print("digest sent")
|
|
2448
|
+
except Exception as e: # noqa: BLE001
|
|
2449
|
+
print(f"send failed: {e}", file=sys.stderr); sys.exit(1)
|
|
2450
|
+
else:
|
|
2451
|
+
print(f"unknown digest action: {args[0]}", file=sys.stderr); sys.exit(2)
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def _cmd_ai_cost(args: list[str]) -> None:
|
|
2455
|
+
if args and args[0] in ("-h", "--help"):
|
|
2456
|
+
print("usage: wpsecscan ai-cost (prints AI-triage cost summary; no arguments)")
|
|
2457
|
+
return
|
|
2458
|
+
from . import ai_safety
|
|
2459
|
+
summary = ai_safety.cost_summary()
|
|
2460
|
+
if not summary:
|
|
2461
|
+
print("no AI cost recorded (or WPSECSCAN_NO_AI=1).")
|
|
2462
|
+
return
|
|
2463
|
+
total = 0.0
|
|
2464
|
+
for backend, entry in summary.items():
|
|
2465
|
+
usd = float(entry.get("usd", 0))
|
|
2466
|
+
total += usd
|
|
2467
|
+
print(f" {backend:12s} {entry.get('calls', 0):5d} calls "
|
|
2468
|
+
f"in={entry.get('in_tokens', 0):>10d} "
|
|
2469
|
+
f"out={entry.get('out_tokens', 0):>10d} "
|
|
2470
|
+
f"${usd:.4f}")
|
|
2471
|
+
print(f" {'TOTAL':12s} ${total:.4f}")
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def _cmd_db(args: list[str]) -> None:
|
|
2475
|
+
"""Round-61: `wpsecscan db {status|update|subscribe|unsubscribe|signatures|alert-check}`."""
|
|
2476
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
2477
|
+
print("usage: wpsecscan db {status|update|source-stats|subscribe|unsubscribe|signatures|alert-check}")
|
|
2478
|
+
return
|
|
2479
|
+
from . import db as _db
|
|
2480
|
+
|
|
2481
|
+
action = args[0]
|
|
2482
|
+
rest = args[1:]
|
|
2483
|
+
|
|
2484
|
+
if action == "status":
|
|
2485
|
+
s = _db.status()
|
|
2486
|
+
age_days = (s["age_seconds"] // 86400) if s["age_seconds"] >= 0 else -1
|
|
2487
|
+
print(f" source: {s['source']}")
|
|
2488
|
+
print(f" cache path: {s['cache_path']}")
|
|
2489
|
+
print(f" cache exists: {s['cache_exists']}")
|
|
2490
|
+
print(f" entries: {s['entry_count']:,}")
|
|
2491
|
+
print(f" age: {age_days} days" if age_days >= 0 else " age: n/a (embedded only)")
|
|
2492
|
+
print(f" stale: {s['stale']} (threshold {s['stale_after_seconds'] // 86400} days)")
|
|
2493
|
+
if s.get("stale"):
|
|
2494
|
+
print()
|
|
2495
|
+
print(" → Run `wpsecscan db update` to refresh.")
|
|
2496
|
+
return
|
|
2497
|
+
|
|
2498
|
+
if action == "update":
|
|
2499
|
+
try:
|
|
2500
|
+
n, p = _db.update_db(verbose=True,
|
|
2501
|
+
patchstack_token=os.environ.get("WPSECSCAN_PATCHSTACK_TOKEN", ""))
|
|
2502
|
+
print(f"OK — {n:,} entries cached at {p}")
|
|
2503
|
+
except Exception as e: # noqa: BLE001
|
|
2504
|
+
print(f"FAIL: {e}", file=sys.stderr); sys.exit(1)
|
|
2505
|
+
return
|
|
2506
|
+
|
|
2507
|
+
if action == "signatures":
|
|
2508
|
+
out = _db.refresh_exploit_signatures()
|
|
2509
|
+
if out.get("ok"):
|
|
2510
|
+
print(f"OK — {out.get('bytes', 0)} bytes cached at {out.get('path')}")
|
|
2511
|
+
else:
|
|
2512
|
+
print(f"FAIL: {out.get('error')}", file=sys.stderr); sys.exit(1)
|
|
2513
|
+
return
|
|
2514
|
+
|
|
2515
|
+
if action == "subscribe":
|
|
2516
|
+
# usage: wpsecscan db subscribe WEBHOOK_URL [--site URL] [--label NAME]
|
|
2517
|
+
if not rest:
|
|
2518
|
+
print("usage: wpsecscan db subscribe WEBHOOK_URL [--site URL] [--label NAME]"); sys.exit(2)
|
|
2519
|
+
webhook = rest[0]
|
|
2520
|
+
site = ""
|
|
2521
|
+
label = "default"
|
|
2522
|
+
i = 1
|
|
2523
|
+
while i < len(rest):
|
|
2524
|
+
a = rest[i]
|
|
2525
|
+
if a == "--site" and i + 1 < len(rest):
|
|
2526
|
+
site = rest[i + 1]; i += 2
|
|
2527
|
+
elif a == "--label" and i + 1 < len(rest):
|
|
2528
|
+
label = rest[i + 1]; i += 2
|
|
2529
|
+
else:
|
|
2530
|
+
i += 1
|
|
2531
|
+
try:
|
|
2532
|
+
entry = _db.subscribe(webhook, site_url=site, label=label)
|
|
2533
|
+
print(f"subscribed: {entry['webhook_url']} for site={entry['site_url']} (label={entry['label']})")
|
|
2534
|
+
except ValueError as e:
|
|
2535
|
+
print(f"FAIL: {e}", file=sys.stderr); sys.exit(2)
|
|
2536
|
+
return
|
|
2537
|
+
|
|
2538
|
+
if action == "unsubscribe":
|
|
2539
|
+
if not rest:
|
|
2540
|
+
print("usage: wpsecscan db unsubscribe WEBHOOK_URL [--site URL]"); sys.exit(2)
|
|
2541
|
+
webhook = rest[0]
|
|
2542
|
+
site = ""
|
|
2543
|
+
i = 1
|
|
2544
|
+
while i < len(rest):
|
|
2545
|
+
if rest[i] == "--site" and i + 1 < len(rest):
|
|
2546
|
+
site = rest[i + 1]; i += 2
|
|
2547
|
+
else:
|
|
2548
|
+
i += 1
|
|
2549
|
+
ok = _db.unsubscribe(webhook, site_url=site)
|
|
2550
|
+
print("removed" if ok else "not found")
|
|
2551
|
+
return
|
|
2552
|
+
|
|
2553
|
+
if action == "alert-check":
|
|
2554
|
+
from . import watchers
|
|
2555
|
+
out = watchers.cve_alert_check()
|
|
2556
|
+
total = len(out["new_alerts"])
|
|
2557
|
+
shown = min(20, total)
|
|
2558
|
+
print(f"checked {out['checked_sites']} site(s), {total} new alert(s)"
|
|
2559
|
+
+ (f" (showing first {shown})" if total > 20 else ""))
|
|
2560
|
+
for a in out["new_alerts"][:20]:
|
|
2561
|
+
print(f" - {a['site_url']}: {a['plugin_slug']} {a['installed_version'] or '?'} "
|
|
2562
|
+
f"[{a['severity']}] {a['cve']} {a['title']}")
|
|
2563
|
+
if total > 20:
|
|
2564
|
+
print(f" ... and {total - 20} more")
|
|
2565
|
+
return
|
|
2566
|
+
|
|
2567
|
+
if action == "source-stats":
|
|
2568
|
+
s = _db.status()
|
|
2569
|
+
sources = _db.cached_sources()
|
|
2570
|
+
if not sources:
|
|
2571
|
+
print("Cache has no per-source breakdown.")
|
|
2572
|
+
print("Either:")
|
|
2573
|
+
print(" - cache is empty (run `wpsecscan db update` first), OR")
|
|
2574
|
+
print(" - cache predates round-63 (the aggregator format)")
|
|
2575
|
+
print(f"Total entries in cache: {s['entry_count']:,}")
|
|
2576
|
+
return
|
|
2577
|
+
total = sum(sources.values())
|
|
2578
|
+
print(f" {'source':<22s} {'count':>9s} share")
|
|
2579
|
+
print(f" {'-' * 22} {'-' * 9} ------")
|
|
2580
|
+
for name, n in sorted(sources.items(), key=lambda kv: -kv[1]):
|
|
2581
|
+
pct = 100.0 * n / total if total else 0.0
|
|
2582
|
+
print(f" {name:<22s} {n:>9,} {pct:>5.1f}%")
|
|
2583
|
+
print(f" {'-' * 22} {'-' * 9}")
|
|
2584
|
+
print(f" {'TOTAL (after dedup)':<22s} {s['entry_count']:>9,}")
|
|
2585
|
+
age_days = (s["age_seconds"] // 86400) if s["age_seconds"] >= 0 else -1
|
|
2586
|
+
print(f"\n cache age: {age_days} days "
|
|
2587
|
+
f"({'STALE' if s['stale'] else 'fresh'})")
|
|
2588
|
+
print(f" cache path: {s['cache_path']}")
|
|
2589
|
+
return
|
|
2590
|
+
|
|
2591
|
+
print(f"unknown db action: {action}", file=sys.stderr); sys.exit(2)
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
if __name__ == "__main__":
|
|
2595
|
+
main()
|