Phase 3 v1: autocheck framework + six step automations

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.
This commit is contained in:
2026-06-11 16:02:34 +01:00
parent 8de7cad0de
commit 0d51fc3b59
11 changed files with 646 additions and 9 deletions

View File

@@ -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 {
<?php if ($status === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
<div class="wph-escalation"><?php echo esc_html($esc); ?></div>
<?php endif; ?>
<?php wph_render_autocheck($session, $step); ?>
<p>
<label>
<strong>Status:</strong>
@@ -257,6 +274,56 @@ function wph_handle_discard(): void {
exit;
}
function wph_render_autocheck(WPH_Session $session, WPH_Step $step): void {
if (!$step->has_autocheck()) return;
$result = $session->get_autocheck($step->id());
?>
<div class="wph-autocheck">
<header>
<h3>Automated checks</h3>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_refresh_checks_' . $step->id()); ?>
<input type="hidden" name="action" value="wph_refresh_checks">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
<button class="button button-small"><?php echo $result ? 'Refresh' : 'Run checks'; ?></button>
</form>
</header>
<?php if (!$result): ?>
<p class="description">No automated checks have been run for this step yet.</p>
<?php else: ?>
<table>
<?php foreach ($result['findings'] as $finding):
$icon = ['ok' => '✓', 'warn' => '⚠', 'bad' => '✗', 'info' => '·'][$finding['level']] ?? '·';
?>
<tr>
<td class="lvl lvl-<?php echo esc_attr($finding['level']); ?>"><?php echo esc_html($icon); ?></td>
<td class="label"><?php echo esc_html($finding['label']); ?></td>
<td class="value"><?php echo esc_html($finding['value']); ?></td>
<td class="detail"><?php echo esc_html($finding['detail']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<p class="wph-checked-at">Checked <?php echo esc_html(human_time_diff((int) $result['checked_at'], time())); ?> ago (<?php echo esc_html(date('Y-m-d H:i', (int) $result['checked_at'])); ?>)</p>
<?php endif; ?>
</div>
<?php
}
function wph_handle_refresh_checks(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
check_admin_referer('wph_refresh_checks_' . $step_id);
$session = WPH_Session::current();
if (!$session || $session->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');

View File

@@ -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);

View File

@@ -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<string,mixed>
* @return array<int,array<string,string>>
*/
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;
}
}

View File

@@ -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;

View File

@@ -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;
}
};

View File

@@ -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('/<img\b[^>]*>/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;
}
};

View File

@@ -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;
}
};

View File

@@ -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;
}
};

View File

@@ -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' => '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>',
]);
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, '<methodResponse>') !== 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;
}
};

View File

@@ -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;
}
};