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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user