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.
Files changed (393) hide show
  1. wpsecscan/__init__.py +1 -0
  2. wpsecscan/__main__.py +2595 -0
  3. wpsecscan/activity.py +116 -0
  4. wpsecscan/ai_assist.py +291 -0
  5. wpsecscan/ai_safety.py +231 -0
  6. wpsecscan/ai_triage.py +541 -0
  7. wpsecscan/ai_triage_ui.py +197 -0
  8. wpsecscan/analytics.py +417 -0
  9. wpsecscan/api_server.py +254 -0
  10. wpsecscan/attack_checkpoint.py +149 -0
  11. wpsecscan/attack_scripts.py +118 -0
  12. wpsecscan/auth/__init__.py +1 -0
  13. wpsecscan/auth/approval_workflow.py +108 -0
  14. wpsecscan/auth/audit_log.py +97 -0
  15. wpsecscan/auth/rbac.py +92 -0
  16. wpsecscan/auth/sso_oidc.py +73 -0
  17. wpsecscan/auth/sso_saml.py +74 -0
  18. wpsecscan/auto_pr.py +171 -0
  19. wpsecscan/auto_update.py +129 -0
  20. wpsecscan/baseline.py +52 -0
  21. wpsecscan/branding.py +55 -0
  22. wpsecscan/bug_report.py +228 -0
  23. wpsecscan/burp_import.py +76 -0
  24. wpsecscan/cache.py +80 -0
  25. wpsecscan/check_health.py +121 -0
  26. wpsecscan/checks/__init__.py +614 -0
  27. wpsecscan/checks/_template.py +45 -0
  28. wpsecscan/checks/a11y_deep.py +108 -0
  29. wpsecscan/checks/a11y_lite.py +95 -0
  30. wpsecscan/checks/a11y_wcag_aaa.py +109 -0
  31. wpsecscan/checks/abuseipdb_lookup.py +149 -0
  32. wpsecscan/checks/admin_ajax_brute_surface.py +93 -0
  33. wpsecscan/checks/ai_chatbot_endpoint_leak.py +73 -0
  34. wpsecscan/checks/ai_prompt_injection_passive.py +86 -0
  35. wpsecscan/checks/ajax_surface.py +157 -0
  36. wpsecscan/checks/app_passwords.py +104 -0
  37. wpsecscan/checks/auth_modernisation.py +189 -0
  38. wpsecscan/checks/authenticated.py +459 -0
  39. wpsecscan/checks/backup_exposure.py +151 -0
  40. wpsecscan/checks/backup_file_fuzz.py +106 -0
  41. wpsecscan/checks/brand_monitor.py +116 -0
  42. wpsecscan/checks/cache_headers.py +118 -0
  43. wpsecscan/checks/cache_poisoning.py +96 -0
  44. wpsecscan/checks/cache_poisoning_v2.py +46 -0
  45. wpsecscan/checks/cdn_edge_audit.py +174 -0
  46. wpsecscan/checks/cloud_metadata_ssrf.py +116 -0
  47. wpsecscan/checks/cloudflare_origin_leak.py +334 -0
  48. wpsecscan/checks/companion_advanced.py +277 -0
  49. wpsecscan/checks/compliance_frameworks.py +75 -0
  50. wpsecscan/checks/composer_lock_audit.py +101 -0
  51. wpsecscan/checks/cookie_consent.py +159 -0
  52. wpsecscan/checks/cookies.py +125 -0
  53. wpsecscan/checks/core_checksums.py +124 -0
  54. wpsecscan/checks/core_cves.py +90 -0
  55. wpsecscan/checks/core_tampering.py +163 -0
  56. wpsecscan/checks/core_version.py +135 -0
  57. wpsecscan/checks/cors.py +150 -0
  58. wpsecscan/checks/crlf_location_injection.py +127 -0
  59. wpsecscan/checks/crypto_agility.py +146 -0
  60. wpsecscan/checks/crypto_payment_callback_audit.py +67 -0
  61. wpsecscan/checks/cryptominer_js_injection.py +73 -0
  62. wpsecscan/checks/csp.py +126 -0
  63. wpsecscan/checks/csp_report_endpoint.py +102 -0
  64. wpsecscan/checks/csrf_entropy.py +112 -0
  65. wpsecscan/checks/csrf_nonce.py +101 -0
  66. wpsecscan/checks/csv_export_csp.py +104 -0
  67. wpsecscan/checks/ct_log_recent_certs.py +112 -0
  68. wpsecscan/checks/db_admin_login_probe.py +55 -0
  69. wpsecscan/checks/db_trigger_audit.py +106 -0
  70. wpsecscan/checks/debug_leaks.py +117 -0
  71. wpsecscan/checks/debug_log_pii_sniff.py +80 -0
  72. wpsecscan/checks/default_creds.py +204 -0
  73. wpsecscan/checks/dev_params.py +102 -0
  74. wpsecscan/checks/directory_listing.py +49 -0
  75. wpsecscan/checks/dns_deep.py +221 -0
  76. wpsecscan/checks/dns_rebinding.py +106 -0
  77. wpsecscan/checks/dns_security.py +482 -0
  78. wpsecscan/checks/dns_templates.py +196 -0
  79. wpsecscan/checks/dom_xss_headless.py +177 -0
  80. wpsecscan/checks/email_obfuscation_audit.py +77 -0
  81. wpsecscan/checks/email_security_deep.py +222 -0
  82. wpsecscan/checks/env_file_enum.py +96 -0
  83. wpsecscan/checks/error_pages.py +91 -0
  84. wpsecscan/checks/exposed_files.py +124 -0
  85. wpsecscan/checks/favicon_fingerprint.py +126 -0
  86. wpsecscan/checks/file_upload.py +65 -0
  87. wpsecscan/checks/forced_browse.py +121 -0
  88. wpsecscan/checks/gdpr_dsr.py +91 -0
  89. wpsecscan/checks/git_dir_deep_scan.py +92 -0
  90. wpsecscan/checks/github_leak_search.py +125 -0
  91. wpsecscan/checks/graphql_dos.py +174 -0
  92. wpsecscan/checks/graphql_field_authz_deep.py +128 -0
  93. wpsecscan/checks/gtm_inventory.py +56 -0
  94. wpsecscan/checks/gutenberg_blocks.py +49 -0
  95. wpsecscan/checks/header_smuggling_case.py +109 -0
  96. wpsecscan/checks/headless_templates.py +161 -0
  97. wpsecscan/checks/headless_wp_audit.py +159 -0
  98. wpsecscan/checks/heartbeat_abuse.py +42 -0
  99. wpsecscan/checks/heartbeat_frontend.py +57 -0
  100. wpsecscan/checks/helm_compose_leak.py +99 -0
  101. wpsecscan/checks/hibp.py +154 -0
  102. wpsecscan/checks/honeypot_admin.py +69 -0
  103. wpsecscan/checks/host_header_validation.py +106 -0
  104. wpsecscan/checks/host_recon.py +106 -0
  105. wpsecscan/checks/hosting_platform_audit.py +120 -0
  106. wpsecscan/checks/hostname_collision.py +103 -0
  107. wpsecscan/checks/hpp.py +98 -0
  108. wpsecscan/checks/hsts_preload_eligibility.py +118 -0
  109. wpsecscan/checks/http2_settings.py +89 -0
  110. wpsecscan/checks/http2_smuggling.py +42 -0
  111. wpsecscan/checks/http3_fingerprint.py +77 -0
  112. wpsecscan/checks/http_methods.py +70 -0
  113. wpsecscan/checks/js_framework_deep.py +115 -0
  114. wpsecscan/checks/js_libraries.py +257 -0
  115. wpsecscan/checks/js_supply_chain.py +159 -0
  116. wpsecscan/checks/jwt_audit.py +217 -0
  117. wpsecscan/checks/login.py +80 -0
  118. wpsecscan/checks/login_redirect_http_hop.py +77 -0
  119. wpsecscan/checks/login_throttle.py +157 -0
  120. wpsecscan/checks/login_throttle_deep.py +262 -0
  121. wpsecscan/checks/login_timing.py +138 -0
  122. wpsecscan/checks/magecart_skimmer_patterns.py +80 -0
  123. wpsecscan/checks/mfa_priv_account_audit.py +89 -0
  124. wpsecscan/checks/misc_injection_audit.py +52 -0
  125. wpsecscan/checks/mixed_content.py +90 -0
  126. wpsecscan/checks/mobile_app_endpoints.py +41 -0
  127. wpsecscan/checks/multisite.py +105 -0
  128. wpsecscan/checks/nft_mint_pubapi.py +67 -0
  129. wpsecscan/checks/nonce_freshness.py +116 -0
  130. wpsecscan/checks/nosql_injection.py +110 -0
  131. wpsecscan/checks/oauth_oidc.py +147 -0
  132. wpsecscan/checks/oauth_redirect.py +82 -0
  133. wpsecscan/checks/oauth_redirect_misconfig.py +89 -0
  134. wpsecscan/checks/object_cache_dropin.py +76 -0
  135. wpsecscan/checks/open_redirect.py +81 -0
  136. wpsecscan/checks/open_registration.py +79 -0
  137. wpsecscan/checks/openapi_scanner.py +138 -0
  138. wpsecscan/checks/origin_ip_discovery.py +74 -0
  139. wpsecscan/checks/osint_enrich.py +76 -0
  140. wpsecscan/checks/package_lock_audit.py +118 -0
  141. wpsecscan/checks/page_builder_cve.py +106 -0
  142. wpsecscan/checks/path_bypass.py +127 -0
  143. wpsecscan/checks/path_traversal.py +114 -0
  144. wpsecscan/checks/payment_commerce_deep.py +153 -0
  145. wpsecscan/checks/payment_gateway_test_keys.py +79 -0
  146. wpsecscan/checks/perf_budget.py +110 -0
  147. wpsecscan/checks/permissions_policy.py +106 -0
  148. wpsecscan/checks/php_eol.py +132 -0
  149. wpsecscan/checks/phpinfo_dangerous_directives.py +72 -0
  150. wpsecscan/checks/plugin_archive_fuzz.py +77 -0
  151. wpsecscan/checks/plugin_cemetery.py +214 -0
  152. wpsecscan/checks/plugin_cves.py +403 -0
  153. wpsecscan/checks/plugin_hash_fingerprint.py +132 -0
  154. wpsecscan/checks/plugin_route_fuzz.py +147 -0
  155. wpsecscan/checks/plugin_specific_audit.py +122 -0
  156. wpsecscan/checks/plugin_typosquat_detection.py +111 -0
  157. wpsecscan/checks/plugins.py +94 -0
  158. wpsecscan/checks/postmeta_stored_xss_scan.py +84 -0
  159. wpsecscan/checks/premium_license_leak.py +95 -0
  160. wpsecscan/checks/privacy_inventory.py +177 -0
  161. wpsecscan/checks/prototype_pollution.py +90 -0
  162. wpsecscan/checks/race_condition.py +116 -0
  163. wpsecscan/checks/redirect_chain.py +134 -0
  164. wpsecscan/checks/referenced_buckets.py +212 -0
  165. wpsecscan/checks/rest_api.py +133 -0
  166. wpsecscan/checks/rest_app_passwords_enum.py +60 -0
  167. wpsecscan/checks/rest_fields_dos.py +56 -0
  168. wpsecscan/checks/rest_link_header.py +74 -0
  169. wpsecscan/checks/rest_namespace_leak.py +61 -0
  170. wpsecscan/checks/rest_permission_audit.py +83 -0
  171. wpsecscan/checks/robots_sitemap.py +108 -0
  172. wpsecscan/checks/rum_beacons.py +60 -0
  173. wpsecscan/checks/s3_bucket_discovery.py +161 -0
  174. wpsecscan/checks/saml_xsw.py +72 -0
  175. wpsecscan/checks/secret_leak.py +204 -0
  176. wpsecscan/checks/security_txt.py +52 -0
  177. wpsecscan/checks/sendmail_injection.py +92 -0
  178. wpsecscan/checks/server_stack_reveal.py +113 -0
  179. wpsecscan/checks/server_timing.py +107 -0
  180. wpsecscan/checks/service_exposure.py +121 -0
  181. wpsecscan/checks/session_fixation.py +103 -0
  182. wpsecscan/checks/sitemap_cve_probe.py +154 -0
  183. wpsecscan/checks/smuggling_probe.py +150 -0
  184. wpsecscan/checks/solidity_abi_leak.py +84 -0
  185. wpsecscan/checks/source_maps.py +120 -0
  186. wpsecscan/checks/spider_crawl.py +50 -0
  187. wpsecscan/checks/sqli.py +271 -0
  188. wpsecscan/checks/sri_audit.py +98 -0
  189. wpsecscan/checks/sri_pwa_misc.py +152 -0
  190. wpsecscan/checks/ssrf.py +90 -0
  191. wpsecscan/checks/ssti.py +137 -0
  192. wpsecscan/checks/subdomains.py +301 -0
  193. wpsecscan/checks/tailwind_css_comment_leak.py +96 -0
  194. wpsecscan/checks/theme_cves.py +83 -0
  195. wpsecscan/checks/themes.py +52 -0
  196. wpsecscan/checks/timthumb.py +111 -0
  197. wpsecscan/checks/tls_deep.py +241 -0
  198. wpsecscan/checks/tls_headers.py +195 -0
  199. wpsecscan/checks/tls_modern.py +236 -0
  200. wpsecscan/checks/tls_reneg_dos.py +65 -0
  201. wpsecscan/checks/upload_bypass_deep.py +64 -0
  202. wpsecscan/checks/upload_path_predictable.py +87 -0
  203. wpsecscan/checks/uploads_year_listing.py +52 -0
  204. wpsecscan/checks/users.py +164 -0
  205. wpsecscan/checks/users_deep.py +154 -0
  206. wpsecscan/checks/users_me_capability_leak.py +68 -0
  207. wpsecscan/checks/vendor_backdoor_patterns.py +59 -0
  208. wpsecscan/checks/waf.py +141 -0
  209. wpsecscan/checks/waf_brand_deep.py +113 -0
  210. wpsecscan/checks/waf_bypass_probe.py +120 -0
  211. wpsecscan/checks/waf_lockout_guard.py +106 -0
  212. wpsecscan/checks/waf_ruleset.py +114 -0
  213. wpsecscan/checks/wallet_seed_phrase_leak.py +91 -0
  214. wpsecscan/checks/web3_wallet_connector_audit.py +95 -0
  215. wpsecscan/checks/webdav.py +78 -0
  216. wpsecscan/checks/webhook_signing_secrets.py +76 -0
  217. wpsecscan/checks/webhook_url_fingerprint.py +83 -0
  218. wpsecscan/checks/webhooks.py +111 -0
  219. wpsecscan/checks/websocket_audit.py +150 -0
  220. wpsecscan/checks/websocket_fuzz.py +127 -0
  221. wpsecscan/checks/well_known.py +89 -0
  222. wpsecscan/checks/woocommerce_audit.py +194 -0
  223. wpsecscan/checks/woocommerce_deep.py +68 -0
  224. wpsecscan/checks/woocommerce_order_idor.py +63 -0
  225. wpsecscan/checks/woocommerce_storefront.py +181 -0
  226. wpsecscan/checks/wp_builder_audit.py +121 -0
  227. wpsecscan/checks/wp_cli_inject.py +83 -0
  228. wpsecscan/checks/wp_commerce_alt_audit.py +102 -0
  229. wpsecscan/checks/wp_cron_cpu.py +64 -0
  230. wpsecscan/checks/wp_cron_disabled.py +66 -0
  231. wpsecscan/checks/wp_cron_dos.py +49 -0
  232. wpsecscan/checks/wp_debug_display_via_rest.py +68 -0
  233. wpsecscan/checks/wp_engine_misconfig.py +94 -0
  234. wpsecscan/checks/wp_fork_detection.py +107 -0
  235. wpsecscan/checks/wp_form_audit.py +95 -0
  236. wpsecscan/checks/wp_membership_lms_audit.py +99 -0
  237. wpsecscan/checks/wp_multisite_deep.py +86 -0
  238. wpsecscan/checks/wp_plugin_ecosystem_audit.py +161 -0
  239. wpsecscan/checks/wp_query_sqli.py +65 -0
  240. wpsecscan/checks/wp_rest_methods.py +86 -0
  241. wpsecscan/checks/wp_salts_age.py +60 -0
  242. wpsecscan/checks/wpconfig_hardening_audit.py +83 -0
  243. wpsecscan/checks/wpcron_suspicious_jobs.py +96 -0
  244. wpsecscan/checks/wpgraphql.py +186 -0
  245. wpsecscan/checks/xmlrpc_amplification.py +75 -0
  246. wpsecscan/checks/xmlrpc_deep.py +126 -0
  247. wpsecscan/checks/xmlrpc_method_brute.py +126 -0
  248. wpsecscan/checks/xss_dom_sinks.py +97 -0
  249. wpsecscan/checks/xss_reflected.py +95 -0
  250. wpsecscan/checks/xxe_upload.py +155 -0
  251. wpsecscan/checks/yaml_templates.py +71 -0
  252. wpsecscan/checks/yaml_workflows.py +53 -0
  253. wpsecscan/checks/yarn_pnpm_lock_audit.py +92 -0
  254. wpsecscan/completion.py +136 -0
  255. wpsecscan/confidence.py +54 -0
  256. wpsecscan/config.py +147 -0
  257. wpsecscan/console_live.py +219 -0
  258. wpsecscan/continuous_monitor.py +116 -0
  259. wpsecscan/crash_submit.py +69 -0
  260. wpsecscan/daemon/__init__.py +11 -0
  261. wpsecscan/daemon/_legacy.py +154 -0
  262. wpsecscan/daemon/webhook_v2.py +91 -0
  263. wpsecscan/data/check_tags.json +252 -0
  264. wpsecscan/data/common_paths.txt +201 -0
  265. wpsecscan/data/compliance_extra.json +62 -0
  266. wpsecscan/data/compliance_map.json +1037 -0
  267. wpsecscan/data/compliance_v2.json +158 -0
  268. wpsecscan/data/dashboard.html.j2 +167 -0
  269. wpsecscan/data/exploit_playbook.json +750 -0
  270. wpsecscan/data/exploit_signatures.json +787 -0
  271. wpsecscan/data/known_paths.txt +44 -0
  272. wpsecscan/data/marketplace.json +66 -0
  273. wpsecscan/data/payloads.json +269 -0
  274. wpsecscan/data/plugin_cves.json +42 -0
  275. wpsecscan/data/plugin_file_hashes.json +31 -0
  276. wpsecscan/data/quick_fixes.json +245 -0
  277. wpsecscan/data/references.json +149 -0
  278. wpsecscan/data/remediation_videos.json +86 -0
  279. wpsecscan/data/report.html.j2 +482 -0
  280. wpsecscan/data/report.schema.json +60 -0
  281. wpsecscan/data/security_tutorial.json +37 -0
  282. wpsecscan/db.py +764 -0
  283. wpsecscan/demo.py +259 -0
  284. wpsecscan/diff.py +79 -0
  285. wpsecscan/education.py +75 -0
  286. wpsecscan/enterprise/__init__.py +1 -0
  287. wpsecscan/enterprise/billing_stub.py +68 -0
  288. wpsecscan/enterprise/multi_tenant.py +55 -0
  289. wpsecscan/enterprise/quota.py +55 -0
  290. wpsecscan/eta.py +52 -0
  291. wpsecscan/fun/__init__.py +1 -0
  292. wpsecscan/fun/bingo_card.py +98 -0
  293. wpsecscan/gui.py +3478 -0
  294. wpsecscan/gui_payloads.py +466 -0
  295. wpsecscan/gui_windows.py +1444 -0
  296. wpsecscan/har_replay.py +133 -0
  297. wpsecscan/hardware_keys.py +192 -0
  298. wpsecscan/heatmap.py +132 -0
  299. wpsecscan/history.py +381 -0
  300. wpsecscan/http.py +343 -0
  301. wpsecscan/i18n.py +214 -0
  302. wpsecscan/incremental/__init__.py +17 -0
  303. wpsecscan/incremental/_legacy.py +136 -0
  304. wpsecscan/incremental/diff_scan.py +79 -0
  305. wpsecscan/incremental/smart_skip.py +75 -0
  306. wpsecscan/integrations/__init__.py +1 -0
  307. wpsecscan/integrations/cisa_kev.py +80 -0
  308. wpsecscan/integrations/epss.py +107 -0
  309. wpsecscan/integrations/github_issues.py +126 -0
  310. wpsecscan/integrations/osint.py +120 -0
  311. wpsecscan/integrations/sucuri_sitecheck.py +85 -0
  312. wpsecscan/integrations/threat_intel.py +153 -0
  313. wpsecscan/integrations/ticketing.py +120 -0
  314. wpsecscan/integrations/tor_proxy.py +35 -0
  315. wpsecscan/integrations/virustotal.py +80 -0
  316. wpsecscan/integrations/webhooks_chat.py +121 -0
  317. wpsecscan/interactsh.py +118 -0
  318. wpsecscan/issue_push.py +263 -0
  319. wpsecscan/js_plugin.py +101 -0
  320. wpsecscan/licensing.py +143 -0
  321. wpsecscan/log.py +62 -0
  322. wpsecscan/marketplace.py +107 -0
  323. wpsecscan/mobile_app_discovery.py +60 -0
  324. wpsecscan/models.py +92 -0
  325. wpsecscan/monitors.py +540 -0
  326. wpsecscan/notify.py +249 -0
  327. wpsecscan/observability.py +126 -0
  328. wpsecscan/password_audit.py +208 -0
  329. wpsecscan/payloads.py +152 -0
  330. wpsecscan/perf/__init__.py +12 -0
  331. wpsecscan/perf/_legacy.py +80 -0
  332. wpsecscan/perf/connection_pool.py +59 -0
  333. wpsecscan/perf/parallel_sites.py +42 -0
  334. wpsecscan/playbook.py +120 -0
  335. wpsecscan/policy.py +119 -0
  336. wpsecscan/pr_inspector.py +180 -0
  337. wpsecscan/prove.py +332 -0
  338. wpsecscan/py.typed +0 -0
  339. wpsecscan/recommend.py +163 -0
  340. wpsecscan/region_egress.py +59 -0
  341. wpsecscan/remediation_videos.py +48 -0
  342. wpsecscan/report_query.py +134 -0
  343. wpsecscan/reporters/__init__.py +0 -0
  344. wpsecscan/reporters/attestation.py +178 -0
  345. wpsecscan/reporters/badge_svg.py +68 -0
  346. wpsecscan/reporters/bounty_format.py +201 -0
  347. wpsecscan/reporters/burp_export.py +81 -0
  348. wpsecscan/reporters/comparison_two_sites.py +55 -0
  349. wpsecscan/reporters/console.py +278 -0
  350. wpsecscan/reporters/csv_out.py +67 -0
  351. wpsecscan/reporters/dashboard.py +132 -0
  352. wpsecscan/reporters/diff_viewer.py +190 -0
  353. wpsecscan/reporters/docx_report.py +142 -0
  354. wpsecscan/reporters/eli5_toggle.py +87 -0
  355. wpsecscan/reporters/exec_pdf.py +355 -0
  356. wpsecscan/reporters/executive_pack.py +167 -0
  357. wpsecscan/reporters/html.py +163 -0
  358. wpsecscan/reporters/issue_export.py +265 -0
  359. wpsecscan/reporters/json_out.py +114 -0
  360. wpsecscan/reporters/markdown.py +118 -0
  361. wpsecscan/reporters/org_dashboard.py +124 -0
  362. wpsecscan/reporters/pdf_custom_branding.py +58 -0
  363. wpsecscan/reporters/public_page.py +87 -0
  364. wpsecscan/reporters/sarif.py +81 -0
  365. wpsecscan/reporters/snapshot_compare.py +143 -0
  366. wpsecscan/reporters/translated_summary.py +116 -0
  367. wpsecscan/reporters/trend_over_time.py +81 -0
  368. wpsecscan/reporters/xlsx_out.py +177 -0
  369. wpsecscan/risk.py +88 -0
  370. wpsecscan/risk_weights.py +80 -0
  371. wpsecscan/sbom.py +89 -0
  372. wpsecscan/scanner.py +474 -0
  373. wpsecscan/sites.py +558 -0
  374. wpsecscan/spider.py +127 -0
  375. wpsecscan/ssh_audit.py +231 -0
  376. wpsecscan/tags.py +86 -0
  377. wpsecscan/template_engine.py +257 -0
  378. wpsecscan/template_signature.py +108 -0
  379. wpsecscan/threat_intel_v2.py +442 -0
  380. wpsecscan/tray.py +112 -0
  381. wpsecscan/turbo_engine.py +232 -0
  382. wpsecscan/ua_rotation.py +53 -0
  383. wpsecscan/ux_extras.py +370 -0
  384. wpsecscan/waf_rules.py +150 -0
  385. wpsecscan/watchers.py +318 -0
  386. wpsecscan/workflow.py +113 -0
  387. wpsecscan-2.4.0.dist-info/METADATA +1194 -0
  388. wpsecscan-2.4.0.dist-info/RECORD +393 -0
  389. wpsecscan-2.4.0.dist-info/WHEEL +5 -0
  390. wpsecscan-2.4.0.dist-info/entry_points.txt +5 -0
  391. wpsecscan-2.4.0.dist-info/licenses/LICENSE +661 -0
  392. wpsecscan-2.4.0.dist-info/licenses/NOTICE +37 -0
  393. 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>&lt;head&gt;</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()