From 0d51fc3b5993b9d38791d8971af0ced6bc7faead Mon Sep 17 00:00:00 2001 From: Steve Hanlon Date: Thu, 11 Jun 2026 16:02:34 +0100 Subject: [PATCH] Phase 3 v1: autocheck framework + six step automations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure: - WPH_Step::autocheck($session_state) returns an array of findings shaped {id, level: ok/warn/bad/info, label, value, detail}. - WPH_Session stores results keyed by step id (persisted in the option- backed session). - WPH_Step::has_autocheck() reflection check so the UI only renders the panel for steps that implement automation. - 'Run checks' / 'Refresh' button per step, admin-post handler runs autocheck() and stashes the result on the session. - Findings rendered as a coloured table on the step card; included verbatim in the Markdown report with status icons. Step automations implemented: - Step 1 (Backup): detection of 11 known backup plugins by slug; active/inactive state; UpdraftPlus last-backup timestamp. - Step 2 (Environment): PHP version + EOL, WP version vs latest, disk usage, wp-config flags, file perms on wp-config/wp-content/uploads, error-log sizes. - Step 4 (Plugins): WP.org API enrichment with 24h transient cache — last_updated, active_installs, abandonment flag, removed-from-repo flag, update-available count. Summary line at the top. - Step 8 (Security): SSL cert expiry via stream_socket_client + openssl_x509_parse, administrator audit, xmlrpc reachability, login URL hardening detection. - Step 9 (Database): spam comments, post revisions, autoload size (WP 6.6+ value handling), top 3 largest tables. - Step 11 (Small fixes): deactivated-but-installed plugin list, homepage alt-text scan. Smoke-tested on testsite — all six steps return findings with correctly-classified levels. Report regenerated with automated findings section. --- .beads/issues.jsonl | 12 +-- includes/admin-page.php | 67 +++++++++++++++ includes/class-wph-session.php | 14 ++++ includes/class-wph-step.php | 19 ++++- includes/report.php | 14 ++++ includes/steps/10-backup.php | 77 +++++++++++++++++ includes/steps/110-small-fixes.php | 47 +++++++++++ includes/steps/20-environment.php | 128 +++++++++++++++++++++++++++++ includes/steps/40-plugins.php | 108 ++++++++++++++++++++++++ includes/steps/80-security.php | 107 ++++++++++++++++++++++++ includes/steps/90-database.php | 62 ++++++++++++++ 11 files changed, 646 insertions(+), 9 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 73a85e6..325280c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -11,17 +11,17 @@ {"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.25","title":"Step 12 (Wrap-up): pre-fill report with all automated findings, attach diff vs. previous session, optionally push summary to ManageWP/WP Umbrella","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:26Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:26Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.25","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hc-5ix.24","title":"Step 11 (Small fixes): broken internal link scan (sample first N pages), missing alt-text scan on homepage images, deactivated-but-installed plugin list","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:25Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:25Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.24","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hc-5ix.22","title":"Step 9 (Database): table sizes report, spam comment count, post revision count, autoload option size, recommendation engine (e.g. 'consider revision limit if \u003e5000')","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:24Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.22","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:23Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-5ix.24","title":"Step 11 (Small fixes): broken internal link scan (sample first N pages), missing alt-text scan on homepage images, deactivated-but-installed plugin list","notes":"Deactivated-but-installed plugin list and homepage alt-text scan (fetch home_url, regex over \u003cimg\u003e tags for alt attribute presence). Broken-link scan deferred — too heavy for v1 (would need to crawl every internal link).","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:25Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:34Z","started_at":"2026-06-11T14:55:08Z","closed_at":"2026-06-11T15:02:34Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.24","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-5ix.22","title":"Step 9 (Database): table sizes report, spam comment count, post revision count, autoload option size, recommendation engine (e.g. 'consider revision limit if \u003e5000')","notes":"Spam comments (warn \u003e100), post revisions (warn \u003e5000), autoload options size with WP 6.6+ value handling (on/auto/auto-on alongside legacy yes; warn \u003e5MB), top 3 largest tables from information_schema. MySQL 8 reserved-word fix (TABLE_ROWS AS table_rows).","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:34Z","started_at":"2026-06-11T14:55:07Z","closed_at":"2026-06-11T15:02:34Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.22","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:23Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.23","title":"Step 10 (Uptime): pluggable uptime provider integration (ManageWP, UptimeRobot, BetterStack, Pingdom). At minimum expose 'paste downtime summary' field","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:24Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.23","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:24Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hc-5ix.21","title":"Step 8 (Security): SSL cert expiry check (stream_socket_client to :443 + parse cert), admin user audit (flag unfamiliar accounts vs. snapshot baseline), xmlrpc.php reachability check, custom login URL check","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:23Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:23Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.21","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:23Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-5ix.21","title":"Step 8 (Security): SSL cert expiry check (stream_socket_client to :443 + parse cert), admin user audit (flag unfamiliar accounts vs. snapshot baseline), xmlrpc.php reachability check, custom login URL check","notes":"SSL cert expiry via stream_socket_client + openssl_x509_parse (warn \u003c30 days, bad expired); not-HTTPS flagged bad. Administrator audit lists all admin users (warn if \u003e5). xmlrpc.php reachability via wp_remote_post with system.listMethods. Login URL hardening detection (WPS Hide Login, Rename wp-login.php).","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:23Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:34Z","started_at":"2026-06-11T14:55:07Z","closed_at":"2026-06-11T15:02:34Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.21","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:23Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.19","title":"Step 6 (Visual \u0026 Functional): homepage screenshot via mShots (or local headless if available); checklist with auto-pulled key pages (front page + posts page + WC shop + login)","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:22Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:22Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.19","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:21Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.20","title":"Step 7 (Performance): PageSpeed Insights API integration (server-side fetch), record mobile + desktop scores, flag \u003e10pt drop vs. previous session, image scan for uncompressed \u003e 500KB on homepage","description":"Requires a Google PageSpeed API key (per-installation setting). Fall back to manually-entered scores if no key. Cache results for 12h.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:22Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:22Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.20","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:22Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.18","title":"Step 5 (Theme): detect parent/child relationship; if no child, diff parent theme files vs. WP.org canonical to flag direct customisations that would be lost on update","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:21Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:21Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.18","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:20Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.16","title":"Step 3 (Core): wizard around core update with safe-mode (deactivate plugins first, update, smoke-load /, reactivate) and clear rollback path on failure","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:20Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:20Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.16","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:19Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hc-5ix.17","title":"Step 4 (Plugins): enrich each plugin with WP.org 'last updated' date, 'removed from repo' flag, active install count; flag plugins not updated \u003e12 months; flag plugins missing from repo","description":"Calls api.wordpress.org/plugins/info/1.0/\u003cslug\u003e.json for each plugin. Cache per-site for 24h. 'Removed from repo' = 404 or unsupported response.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:20Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:20Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.17","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:20Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hc-5ix.15","title":"Step 2 (Environment): auto-collect PHP version + EOL flag, disk usage (statvfs), tail of error log (PHP + WP debug.log if enabled), wp-config flags (WP_DEBUG, WP_DEBUG_DISPLAY, DISALLOW_FILE_EDIT), spot-check permissions on wp-config.php / wp-content / uploads","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:19Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:19Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.15","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:18Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"hc-5ix.14","title":"Step 1 (Backup): detect installed backup plugin (UpdraftPlus, BackWPup, BlogVault, Jetpack VaultPress), surface last backup time + size + verification status","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:18Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:18Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.14","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:18Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-5ix.17","title":"Step 4 (Plugins): enrich each plugin with WP.org 'last updated' date, 'removed from repo' flag, active install count; flag plugins not updated \u003e12 months; flag plugins missing from repo","description":"Calls api.wordpress.org/plugins/info/1.0/\u003cslug\u003e.json for each plugin. Cache per-site for 24h. 'Removed from repo' = 404 or unsupported response.","notes":"WP.org API enrichment via api.wordpress.org/plugins/info/1.0/\u003cslug\u003e.json with 24h transient cache. Surfaces last_updated date + active_installs, flags 'not in repo' (404) and 'removed' (error response), flags abandoned (\u003e12 months), flags update available via update_plugins transient. Summary line counts each category. ~1.7s for 4 plugins on first run (uncached); subsequent runs \u003c50ms via cache.","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:20Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:33Z","started_at":"2026-06-11T14:55:06Z","closed_at":"2026-06-11T15:02:33Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.17","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:20Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-5ix.15","title":"Step 2 (Environment): auto-collect PHP version + EOL flag, disk usage (statvfs), tail of error log (PHP + WP debug.log if enabled), wp-config flags (WP_DEBUG, WP_DEBUG_DISPLAY, DISALLOW_FILE_EDIT), spot-check permissions on wp-config.php / wp-content / uploads","notes":"PHP version + EOL table; WP version vs latest; disk usage on ABSPATH; wp-config flags (WP_DEBUG, _DISPLAY, _LOG, DISALLOW_FILE_EDIT, WP_ENVIRONMENT_TYPE); file perms on wp-config.php/wp-content/uploads; PHP error_log + WP debug.log size. 12 findings on testsite, correctly flagged disk 95% (bad) and WP_DEBUG_DISPLAY=true (bad).","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:19Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:33Z","started_at":"2026-06-11T14:55:06Z","closed_at":"2026-06-11T15:02:33Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.15","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:18Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hc-5ix.14","title":"Step 1 (Backup): detect installed backup plugin (UpdraftPlus, BackWPup, BlogVault, Jetpack VaultPress), surface last backup time + size + verification status","notes":"Backup detection by plugin slug (UpdraftPlus, BackWPup, Duplicator, WPvivid, All-in-One WP Migration, BlogVault, Jetpack, Solid Backups/BackupBuddy, WP Time Capsule, Backup Migration). Active/inactive state per plugin. UpdraftPlus last-backup timestamp surfaced via updraft_last_backup option. If none detected → bad-level finding.","status":"closed","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:18Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:02:32Z","started_at":"2026-06-11T14:55:05Z","closed_at":"2026-06-11T15:02:32Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.14","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:18Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.12","title":"Diff against previous session: highlight deltas vs. last completed session for this site","description":"Pre-fill 'before' values for plugin/theme/WP versions from the previous session's 'after' values. Show a Δ column on the report. Lets clients see trajectory across checks.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:17Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.12","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:16Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.13","title":"Polished HTML/PDF report styling + ability to email","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:17Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.13","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:17Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.11","title":"Stop-and-escalate decision support: when a step is marked blocked/escalate, surface the matching guidance from steps.md and link to a quote/escalation template","description":"Drive from the 'When to Stop and Escalate' table at the bottom of steps.md. Each escalation condition becomes a structured rule that fires when its preconditions hold (e.g. step=backup, status=blocked → 'Stop. Do not proceed. Restore backup before retry.'). Render as a banner on the affected step.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:16Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:16Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.11","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:16Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/includes/admin-page.php b/includes/admin-page.php index ed94255..badad7c 100644 --- a/includes/admin-page.php +++ b/includes/admin-page.php @@ -7,6 +7,7 @@ add_action('admin_post_wph_save_step', 'wph_handle_save_step'); add_action('admin_post_wph_finish', 'wph_handle_finish'); add_action('admin_post_wph_discard', 'wph_handle_discard'); add_action('admin_post_wph_download_report', 'wph_handle_download_report'); +add_action('admin_post_wph_refresh_checks', 'wph_handle_refresh_checks'); add_action('admin_enqueue_scripts', 'wph_enqueue_assets'); function wph_register_menu(): void { @@ -52,6 +53,21 @@ function wph_inline_css(): string { .wph-bootstrap-panel { padding:.6rem 1rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; } .wph-bootstrap-panel h3 { margin:0 0 .35rem; font-size:1rem; } .wph-actions { display:flex; gap:.5rem; align-items:center; margin-top:.4rem; } + .wph-autocheck { background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; padding:.6rem 1rem; margin:.6rem 0; } + .wph-autocheck header { display:flex; justify-content:space-between; align-items:center; margin:0 0 .4rem; } + .wph-autocheck header h3 { margin:0; font-size:.95rem; } + .wph-autocheck table { width:100%; border-collapse:collapse; } + .wph-autocheck td { padding:.25rem .4rem; vertical-align:top; border-bottom:1px solid #f0f0f1; } + .wph-autocheck tr:last-child td { border-bottom:0; } + .wph-autocheck .lvl { width:1.4rem; text-align:center; font-weight:600; } + .wph-autocheck .lvl-ok { color:#1a8917; } + .wph-autocheck .lvl-warn { color:#b07a00; } + .wph-autocheck .lvl-bad { color:#c0392b; } + .wph-autocheck .lvl-info { color:#646970; } + .wph-autocheck .label { font-weight:600; } + .wph-autocheck .value { color:#1d1d1f; } + .wph-autocheck .detail { color:#646970; font-size:.9em; } + .wph-checked-at { color:#646970; font-size:.85em; } '; } @@ -164,6 +180,7 @@ function wph_render_step_card(WPH_Session $session, WPH_Step $step): void { escalation())): ?>
+

+
+

Automated checks

+
+ id()); ?> + + + +
+
+ +

No automated checks have been run for this step yet.

+ + + '✓', 'warn' => '⚠', 'bad' => '✗', 'info' => '·'][$finding['level']] ?? '·'; + ?> + + + + + + + +
+

Checked ago ()

+ +
+ is_finished()) wp_die('No active session.'); + $step = WPH_Steps::instance()->get($step_id); + if (!$step) wp_die('Unknown step.'); + @set_time_limit(60); + $findings = $step->autocheck($session->data()); + $session->set_autocheck($step_id, $findings); + wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id))); + exit; +} + function wph_handle_download_report(): void { if (!current_user_can('manage_options')) wp_die('Forbidden'); check_admin_referer('wph_download_report'); diff --git a/includes/class-wph-session.php b/includes/class-wph-session.php index 77a3ea6..0036a47 100644 --- a/includes/class-wph-session.php +++ b/includes/class-wph-session.php @@ -90,6 +90,20 @@ final class WPH_Session { update_option(WPH_OPT_SESSION, $this->data, false); } + /** Store the result of running autocheck() on a step. */ + public function set_autocheck(string $step_id, array $findings): void { + $this->data['autocheck'][$step_id] = [ + 'checked_at' => time(), + 'findings' => $findings, + ]; + update_option(WPH_OPT_SESSION, $this->data, false); + } + + /** Returns ['checked_at'=>int, 'findings'=>array] or null. */ + public function get_autocheck(string $step_id): ?array { + return $this->data['autocheck'][$step_id] ?? null; + } + public function progress(): array { $steps = WPH_Steps::instance()->all(); $total = count($steps); diff --git a/includes/class-wph-step.php b/includes/class-wph-step.php index 254ed82..4c613ee 100644 --- a/includes/class-wph-step.php +++ b/includes/class-wph-step.php @@ -36,10 +36,23 @@ abstract class WPH_Step { public function escalation(): ?string { return null; } /** - * Phase 3 hook — return structured findings (php version, plugin update - * intel, etc.) for the technician to verify. Phase 1 returns nothing. + * Phase 3 hook — return an array of findings for the technician to verify. + * Each finding: ['id'=>str, 'level'=>'ok'|'warn'|'bad'|'info', 'label'=>str, 'value'=>str, 'detail'=>str]. + * Override in subclasses. Default returns nothing. * - * @return array + * @return array> */ public function autocheck(array $session_state): array { return []; } + + /** Convenience finding builder for subclasses. */ + protected function finding(string $id, string $level, string $label, string $value = '', string $detail = ''): array { + if (!in_array($level, ['ok', 'warn', 'bad', 'info'], true)) $level = 'info'; + return compact('id', 'level', 'label', 'value', 'detail'); + } + + /** Returns true if any of this step's automation is implemented. Override or rely on autocheck() returning [] by default. */ + public function has_autocheck(): bool { + $r = new ReflectionMethod($this, 'autocheck'); + return $r->getDeclaringClass()->getName() !== WPH_Step::class; + } } diff --git a/includes/report.php b/includes/report.php index 5a63492..ba8d94a 100644 --- a/includes/report.php +++ b/includes/report.php @@ -66,6 +66,20 @@ function wph_build_markdown_report(WPH_Session $session): string { $lines[] = '> ' . $nl; } } + + $auto = $session->get_autocheck($step->id()); + if ($auto && !empty($auto['findings'])) { + $lines[] = ''; + $lines[] = '**Automated checks** (run ' . date('Y-m-d H:i', (int) ($auto['checked_at'] ?? 0)) . '):'; + $lines[] = ''; + foreach ($auto['findings'] as $finding) { + $icon = ['ok' => '✅', 'warn' => '⚠️', 'bad' => '❌', 'info' => 'ℹ️'][$finding['level']] ?? '·'; + $bits = [$icon, '**' . $finding['label'] . '**']; + if (!empty($finding['value'])) $bits[] = $finding['value']; + if (!empty($finding['detail'])) $bits[] = '— ' . $finding['detail']; + $lines[] = '- ' . implode(' ', $bits); + } + } if ($state['status'] === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())) { $lines[] = ''; $lines[] = '> ⚠ **Escalation:** ' . $esc; diff --git a/includes/steps/10-backup.php b/includes/steps/10-backup.php index daf33c9..851f0c5 100644 --- a/includes/steps/10-backup.php +++ b/includes/steps/10-backup.php @@ -17,4 +17,81 @@ return new class extends WPH_Step { public function escalation(): ?string { return 'If the backup fails or cannot be confirmed, stop. Do not proceed until you have a verified backup.'; } + + public function autocheck(array $session_state): array { + if (!function_exists('get_plugins')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $plugins = get_plugins(); + + // Map known backup plugins: slug → friendly label. + $known = [ + 'updraftplus/updraftplus.php' => 'UpdraftPlus', + 'backwpup/backwpup.php' => 'BackWPup', + 'backup-backup/backup-backup.php' => 'Backup Migration', + 'duplicator/duplicator.php' => 'Duplicator', + 'wpvivid-backuprestore/wpvivid-backuprestore.php' => 'WPvivid', + 'all-in-one-wp-migration/all-in-one-wp-migration.php' => 'All-in-One WP Migration', + 'blogvault-real-time-backup/blogvault.php' => 'BlogVault', + 'jetpack/jetpack.php' => 'Jetpack (VaultPress)', + 'solid-backups/backupbuddy.php' => 'Solid Backups', + 'backupbuddy/backupbuddy.php' => 'BackupBuddy', + 'wp-time-capsule/wp-time-capsule.php' => 'WP Time Capsule', + ]; + + $found = []; + foreach ($known as $file => $label) { + if (isset($plugins[$file])) { + $found[$file] = [ + 'label' => $label, + 'active' => is_plugin_active($file), + ]; + } + } + + $f = []; + if (empty($found)) { + $f[] = $this->finding( + 'no_backup_plugin', 'bad', + 'No recognised backup plugin installed', '', + 'None of the common backup plugins were detected. Confirm host-side backups instead, or install one.' + ); + return $f; + } + + $any_active = false; + foreach ($found as $file => $meta) { + if ($meta['active']) $any_active = true; + $f[] = $this->finding( + 'plugin_' . sanitize_key($file), + $meta['active'] ? 'ok' : 'warn', + $meta['label'], + $meta['active'] ? 'active' : 'installed but inactive', + '' + ); + } + if (!$any_active) { + $f[] = $this->finding( + 'no_active', 'bad', + 'No backup plugin is active', '', + 'A backup plugin is installed but not active — activate before proceeding.' + ); + } + + // UpdraftPlus — surface last backup timestamp if available. + if (isset($found['updraftplus/updraftplus.php'])) { + $last = get_option('updraft_last_backup'); + if (is_array($last) && !empty($last['backup_time'])) { + $age_days = floor((time() - (int) $last['backup_time']) / DAY_IN_SECONDS); + $level = $age_days > 7 ? 'warn' : 'ok'; + $f[] = $this->finding( + 'updraft_last', + $level, + 'UpdraftPlus — last backup', + date('Y-m-d H:i', (int) $last['backup_time']), + $age_days . ' day(s) ago' . (!empty($last['backup_nonce']) ? '. Nonce ' . substr((string) $last['backup_nonce'], 0, 8) : '') + ); + } + } + + return $f; + } }; diff --git a/includes/steps/110-small-fixes.php b/includes/steps/110-small-fixes.php index 749f64e..b6f1ea2 100644 --- a/includes/steps/110-small-fixes.php +++ b/includes/steps/110-small-fixes.php @@ -16,4 +16,51 @@ return new class extends WPH_Step { 'Clearing accumulated spam comments', ]; } + + public function autocheck(array $session_state): array { + $f = []; + + // Deactivated-but-installed plugins + if (!function_exists('get_plugins')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $inactive = []; + foreach (get_plugins() as $file => $meta) { + if (!is_plugin_active($file)) $inactive[] = ($meta['Name'] ?? $file); + } + $f[] = $this->finding( + 'inactive_plugins', + count($inactive) > 0 ? 'info' : 'ok', + 'Deactivated-but-installed plugins', + (string) count($inactive), + $inactive ? implode(', ', array_slice($inactive, 0, 10)) . (count($inactive) > 10 ? ' …' : '') : '' + ); + + // Homepage alt-text scan + $url = home_url('/'); + $resp = wp_remote_get($url, ['timeout' => 6]); + if (is_wp_error($resp)) { + $f[] = $this->finding('homepage_alt', 'warn', 'Homepage alt text', 'fetch failed', $resp->get_error_message()); + } else { + $body = (string) wp_remote_retrieve_body($resp); + $imgs = 0; + $missing = 0; + if (preg_match_all('/]*>/i', $body, $matches)) { + foreach ($matches[0] as $tag) { + $imgs++; + if (!preg_match('/\salt\s*=\s*"[^"]+"/i', $tag) && !preg_match('/\salt\s*=\s*\'[^\']+\'/i', $tag)) { + $missing++; + } + } + } + $level = $missing > 0 ? 'warn' : 'ok'; + $f[] = $this->finding( + 'homepage_alt', + $level, + 'Homepage alt text', + $missing . ' missing of ' . $imgs . ' image(s)', + $missing ? 'Empty/missing alt attributes hurt accessibility and SEO.' : '' + ); + } + + return $f; + } }; diff --git a/includes/steps/20-environment.php b/includes/steps/20-environment.php index b6d5486..645ea99 100644 --- a/includes/steps/20-environment.php +++ b/includes/steps/20-environment.php @@ -16,4 +16,132 @@ return new class extends WPH_Step { 'File permissions — spot check wp-config.php (should be 640 or 600), wp-content (755), uploads (755)', ]; } + + public function autocheck(array $session_state): array { + $f = []; + + // PHP version + EOL + // EOL dates from php.net (Y-m-d). Conservative — bump as new versions ship. + $eol = [ + '7.4' => '2022-11-28', + '8.0' => '2023-11-26', + '8.1' => '2025-12-31', + '8.2' => '2026-12-31', + '8.3' => '2027-12-31', + '8.4' => '2028-12-31', + ]; + $php = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + if (isset($eol[$php])) { + $is_eol = strtotime($eol[$php]) < time(); + $f[] = $this->finding( + 'php_version', + $is_eol ? 'bad' : (version_compare($php, '8.1', '<') ? 'warn' : 'ok'), + 'PHP version', + PHP_VERSION, + $is_eol ? "EOL since {$eol[$php]} — upgrade urgently." : "Supported until {$eol[$php]}." + ); + } else { + $f[] = $this->finding('php_version', 'info', 'PHP version', PHP_VERSION, 'EOL date unknown for this branch.'); + } + + // WP version vs latest + global $wp_version; + if (!function_exists('get_core_updates')) require_once ABSPATH . 'wp-admin/includes/update.php'; + $updates = function_exists('get_core_updates') ? get_core_updates(['dismissed' => true]) : []; + $latest = (!empty($updates) && !empty($updates[0]->current)) ? $updates[0]->current : $wp_version; + $behind = version_compare($wp_version, $latest, '<'); + $f[] = $this->finding( + 'wp_version', + $behind ? 'warn' : 'ok', + 'WordPress version', + $wp_version, + $behind ? "Latest is {$latest} — update available." : 'Up to date.' + ); + + // Disk usage on ABSPATH + $free = @disk_free_space(ABSPATH); + $total = @disk_total_space(ABSPATH); + if ($free !== false && $total !== false && $total > 0) { + $used_pct = round(($total - $free) / $total * 100, 1); + $level = $used_pct > 90 ? 'bad' : ($used_pct > 80 ? 'warn' : 'ok'); + $f[] = $this->finding( + 'disk_usage', + $level, + 'Disk usage', + $used_pct . '% used', + size_format($total - $free) . ' of ' . size_format($total) . ' (free: ' . size_format($free) . ')' + ); + } + + // wp-config flags + $flags = [ + 'WP_DEBUG' => false, + 'WP_DEBUG_DISPLAY' => true, // default true; we want false on production + 'WP_DEBUG_LOG' => false, + 'DISALLOW_FILE_EDIT' => false, // we want this true + 'WP_ENVIRONMENT_TYPE'=> 'production', + ]; + foreach ($flags as $const => $expected_for_prod) { + if (!defined($const)) { + if ($const === 'WP_ENVIRONMENT_TYPE') continue; // optional + $f[] = $this->finding('flag_' . strtolower($const), 'info', $const, 'not defined', 'Default applies.'); + continue; + } + $val = constant($const); + if ($const === 'WP_ENVIRONMENT_TYPE') { + $f[] = $this->finding('flag_env_type', 'info', 'WP_ENVIRONMENT_TYPE', (string) $val, ''); + continue; + } + $bool = (bool) $val; + $ok = ($const === 'DISALLOW_FILE_EDIT') ? ($bool === true) : ($bool === false); + $level = $ok ? 'ok' : ($const === 'WP_DEBUG_DISPLAY' ? 'bad' : 'warn'); + $f[] = $this->finding( + 'flag_' . strtolower($const), + $level, + $const, + $bool ? 'true' : 'false', + $ok ? '' : ($const === 'WP_DEBUG_DISPLAY' ? 'Errors are being shown to visitors — turn this off on production.' : 'Recommended: ' . ($expected_for_prod ? 'true' : 'false') . ' on production.') + ); + } + + // Permissions on key paths + foreach ([ + 'wp-config.php' => ABSPATH . 'wp-config.php', + 'wp-content/' => WP_CONTENT_DIR, + 'uploads/' => wp_get_upload_dir()['basedir'] ?? WP_CONTENT_DIR . '/uploads', + ] as $label => $path) { + if (!file_exists($path)) continue; + $perms = substr(sprintf('%o', fileperms($path)), -4); + // wp-config: 600 or 640; dirs: 755 (loose check) + $is_cfg = $label === 'wp-config.php'; + $ok = $is_cfg ? in_array($perms, ['0600', '0640'], true) + : in_array($perms, ['0755', '0750'], true); + $f[] = $this->finding( + 'perm_' . sanitize_key($label), + $ok ? 'ok' : 'warn', + 'Permissions: ' . $label, + $perms, + $ok ? '' : ($is_cfg ? 'Recommend 600 or 640.' : 'Recommend 755 or 750.') + ); + } + + // Error log size (PHP error_log + WP debug.log) + foreach ([ + 'PHP error_log' => ini_get('error_log'), + 'WP debug.log' => WP_CONTENT_DIR . '/debug.log', + ] as $label => $path) { + if (!$path || !file_exists($path) || !is_readable($path)) continue; + $size = filesize($path); + $level = $size > 10 * MB_IN_BYTES ? 'warn' : 'info'; + $f[] = $this->finding( + 'log_' . sanitize_key($label), + $level, + $label, + size_format($size), + $path . ($level === 'warn' ? ' — large file, consider rotating + reviewing tail.' : '') + ); + } + + return $f; + } }; diff --git a/includes/steps/40-plugins.php b/includes/steps/40-plugins.php index 68f54e8..831161b 100644 --- a/includes/steps/40-plugins.php +++ b/includes/steps/40-plugins.php @@ -22,4 +22,112 @@ return new class extends WPH_Step { 'Plugins showing "Update unavailable" or removed from the WordPress repository — flag immediately, these can indicate abandoned or compromised plugins', ]; } + + public function autocheck(array $session_state): array { + if (!function_exists('get_plugins')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $plugins = get_plugins(); + $f = []; + $abandoned = 0; + $removed = 0; + $update_avail = 0; + + // Refresh update transient (no-op if recent) + if (function_exists('wp_update_plugins')) wp_update_plugins(); + $updates = get_site_transient('update_plugins'); + $update_map = isset($updates->response) && is_array($updates->response) ? $updates->response : []; + + foreach ($plugins as $file => $meta) { + $slug = dirname($file); + if ($slug === '.' || $slug === '') $slug = basename($file, '.php'); + $info = $this->wp_org_info($slug); + $detail_parts = []; + $level = 'ok'; + + if ($info === 'not_in_repo') { + $level = 'warn'; + $detail_parts[] = 'Not in WP.org repo (could be premium, custom, or removed)'; + $removed++; + } elseif ($info === 'removed') { + $level = 'bad'; + $detail_parts[] = '⚠ Removed from WP.org repo — possible abandonment or compromise'; + $removed++; + } elseif (is_array($info)) { + if (!empty($info['last_updated'])) { + $age_days = floor((time() - strtotime($info['last_updated'])) / DAY_IN_SECONDS); + $detail_parts[] = 'Last release ' . $age_days . ' day(s) ago (' . $info['last_updated'] . ')'; + if ($age_days > 365) { + $level = 'warn'; + $abandoned++; + $detail_parts[] = 'no release in >12 months'; + } + } + if (!empty($info['active_installs'])) { + $detail_parts[] = number_format($info['active_installs']) . ' active installs'; + } + } + + if (isset($update_map[$file])) { + $level = 'warn'; + $detail_parts[] = 'Update available → ' . ($update_map[$file]->new_version ?? '?'); + $update_avail++; + } + + $f[] = $this->finding( + 'plugin_' . sanitize_key($file), + $level, + $meta['Name'] ?? $file, + (string) ($meta['Version'] ?? ''), + implode(' · ', $detail_parts) + ); + } + + // Summary at the top + array_unshift($f, $this->finding( + 'summary', 'info', + 'Summary', + count($plugins) . ' plugins', + $update_avail . ' with updates · ' . $abandoned . ' not updated in 12 mo · ' . $removed . ' not in WP.org' + )); + + return $f; + } + + /** + * Returns ['name' => ..., 'last_updated' => ..., 'active_installs' => ...] + * or 'not_in_repo' / 'removed' on failure. Cached 24h per slug via transient. + */ + private function wp_org_info(string $slug) { + $cache_key = 'wph_pi_' . md5($slug); + $cached = get_transient($cache_key); + if ($cached !== false) return $cached; + + $url = 'https://api.wordpress.org/plugins/info/1.0/' . rawurlencode($slug) . '.json'; + $resp = wp_remote_get($url, ['timeout' => 4]); + if (is_wp_error($resp)) { + set_transient($cache_key, 'not_in_repo', HOUR_IN_SECONDS); + return 'not_in_repo'; + } + $code = wp_remote_retrieve_response_code($resp); + $body = wp_remote_retrieve_body($resp); + if ($code === 404) { + set_transient($cache_key, 'not_in_repo', DAY_IN_SECONDS); + return 'not_in_repo'; + } + $data = json_decode((string) $body, true); + if (!is_array($data)) { + set_transient($cache_key, 'not_in_repo', HOUR_IN_SECONDS); + return 'not_in_repo'; + } + if (isset($data['error'])) { + set_transient($cache_key, 'removed', DAY_IN_SECONDS); + return 'removed'; + } + $out = [ + 'name' => $data['name'] ?? $slug, + 'last_updated' => $data['last_updated'] ?? null, + 'active_installs' => $data['active_installs'] ?? null, + ]; + set_transient($cache_key, $out, DAY_IN_SECONDS); + return $out; + } }; diff --git a/includes/steps/80-security.php b/includes/steps/80-security.php index e9fbd26..28db079 100644 --- a/includes/steps/80-security.php +++ b/includes/steps/80-security.php @@ -14,4 +14,111 @@ return new class extends WPH_Step { 'Check that xmlrpc.php is disabled or restricted if not in use', ]; } + + public function autocheck(array $session_state): array { + $f = []; + + // SSL cert expiry — only meaningful for https sites + $host = parse_url(get_site_url(), PHP_URL_HOST); + $scheme = parse_url(get_site_url(), PHP_URL_SCHEME); + if ($scheme === 'https' && $host) { + $cert = $this->fetch_cert($host); + if (is_array($cert) && !empty($cert['validTo_time_t'])) { + $expires = (int) $cert['validTo_time_t']; + $days = floor(($expires - time()) / DAY_IN_SECONDS); + $level = $days < 0 ? 'bad' : ($days < 30 ? 'warn' : 'ok'); + $f[] = $this->finding( + 'ssl_expiry', + $level, + 'SSL certificate', + date('Y-m-d', $expires), + $days < 0 ? abs($days) . ' day(s) EXPIRED' : 'expires in ' . $days . ' day(s)' . (isset($cert['issuer']['O']) ? ' · issuer: ' . $cert['issuer']['O'] : '') + ); + } else { + $f[] = $this->finding('ssl_expiry', 'warn', 'SSL certificate', 'could not fetch', 'TLS handshake to ' . $host . ':443 failed; check manually.'); + } + } else { + $f[] = $this->finding('ssl_expiry', 'bad', 'SSL', 'not HTTPS', 'Site URL is not https — install/configure a certificate.'); + } + + // Administrator audit + $admins = get_users(['role' => 'administrator', 'number' => 50]); + $f[] = $this->finding( + 'admin_count', + count($admins) > 5 ? 'warn' : 'ok', + 'Administrator accounts', + (string) count($admins), + count($admins) > 5 ? 'More than 5 administrators — review whether all are necessary.' : '' + ); + foreach ($admins as $u) { + $last_login = get_user_meta($u->ID, 'last_login', true); // if a plugin tracks it + $f[] = $this->finding( + 'admin_' . $u->ID, + 'info', + 'admin: ' . $u->user_login, + $u->user_email, + 'Registered ' . $u->user_registered . ($last_login ? ' · last login ' . $last_login : '') + ); + } + + // xmlrpc.php reachability + $xmlrpc_url = trailingslashit(get_site_url()) . 'xmlrpc.php'; + $resp = wp_remote_post($xmlrpc_url, [ + 'timeout' => 5, + 'headers' => ['Content-Type' => 'text/xml'], + 'body' => 'system.listMethods', + ]); + if (is_wp_error($resp)) { + $f[] = $this->finding('xmlrpc', 'ok', 'xmlrpc.php', 'unreachable', $resp->get_error_message()); + } else { + $code = wp_remote_retrieve_response_code($resp); + $body = (string) wp_remote_retrieve_body($resp); + $is_open = ($code === 200 && (strpos($body, '') !== false)); + $f[] = $this->finding( + 'xmlrpc', + $is_open ? 'warn' : 'ok', + 'xmlrpc.php', + $is_open ? 'open (responds to system.listMethods)' : 'restricted/disabled (' . $code . ')', + $is_open ? 'If not in use, consider disabling — common brute-force/DDoS target.' : '' + ); + } + + // Login URL — detect known "hide login" plugins + $hide_login_plugins = [ + 'wps-hide-login/wps-hide-login.php' => 'WPS Hide Login', + 'rename-wp-login/rename-wp-login.php' => 'Rename wp-login.php', + ]; + $hidden = false; + foreach ($hide_login_plugins as $file => $label) { + if (is_plugin_active($file)) { $hidden = $label; break; } + } + $f[] = $this->finding( + 'login_url', + $hidden ? 'ok' : 'info', + 'Login URL hardening', + $hidden ? 'custom (' . $hidden . ')' : 'default (/wp-admin, /wp-login.php)', + $hidden ? '' : 'Not necessarily a problem — confirm with client whether hardening was previously applied.' + ); + + return $f; + } + + private function fetch_cert(string $host) { + $ctx = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + 'SNI_enabled' => true, + 'peer_name' => $host, + ], + ]); + $errno = 0; $errstr = ''; + $sock = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx); + if (!$sock) return null; + $params = stream_context_get_params($sock); + fclose($sock); + $cert = $params['options']['ssl']['peer_certificate'] ?? null; + return $cert ? openssl_x509_parse($cert) : null; + } }; diff --git a/includes/steps/90-database.php b/includes/steps/90-database.php index 1349e0b..ae370df 100644 --- a/includes/steps/90-database.php +++ b/includes/steps/90-database.php @@ -11,4 +11,66 @@ return new class extends WPH_Step { 'Check post revisions — if excessive (thousands), note for client discussion on whether a revision limit should be set', ]; } + + public function autocheck(array $session_state): array { + /** @var wpdb $wpdb */ + global $wpdb; + $f = []; + + // Spam comments + $counts = wp_count_comments(); + $f[] = $this->finding( + 'spam_comments', + ($counts->spam ?? 0) > 100 ? 'warn' : 'ok', + 'Spam comments', + number_format((int) ($counts->spam ?? 0)), + ($counts->trash ?? 0) ? number_format((int) $counts->trash) . ' in trash too.' : '' + ); + + // Post revisions + $revisions = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision'"); + $level = $revisions > 5000 ? 'warn' : 'ok'; + $f[] = $this->finding( + 'revisions', + $level, + 'Post revisions', + number_format($revisions), + $level === 'warn' ? 'Consider setting WP_POST_REVISIONS to a sane limit (e.g. 10).' : '' + ); + + // Autoload option size. WP 6.6+ uses 'on'/'auto'/'auto-on' alongside legacy 'yes'. + $autoload_in = "autoload IN ('yes', 'on', 'auto', 'auto-on')"; + $autoload_bytes = (int) $wpdb->get_var("SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE $autoload_in"); + $autoload_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options} WHERE $autoload_in"); + $autoload_level = $autoload_bytes > 5 * MB_IN_BYTES ? 'warn' : 'ok'; + $f[] = $this->finding( + 'autoload', + $autoload_level, + 'Autoload options', + $autoload_count . ' rows · ' . size_format($autoload_bytes), + $autoload_level === 'warn' ? 'Large autoload payload slows every request — investigate which plugins are responsible.' : '' + ); + + // Top 3 largest tables. `rows` is reserved in MySQL 8 — alias as table_rows. + $tables = $wpdb->get_results(" + SELECT TABLE_NAME AS name, + DATA_LENGTH + INDEX_LENGTH AS bytes, + TABLE_ROWS AS table_rows + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY bytes DESC + LIMIT 3 + "); + foreach ($tables as $i => $t) { + $f[] = $this->finding( + 'big_table_' . $i, + 'info', + ($i === 0 ? 'Largest tables — ' : '') . $t->name, + size_format((int) $t->bytes), + number_format((int) $t->table_rows) . ' rows' + ); + } + + return $f; + } };