array(), 'medium' => array(), 'minor' => array() ); foreach ( $results as $r ) { $p = isset( $grouped[ $r['priority'] ] ) ? $r['priority'] : 'minor'; $grouped[ $p ][] = $r; } return $grouped; } /** * Number of rows at risk. Pure. * * @param array $results Rows with a 'status' key. * @return int */ public static function risk_count( $results ) { $n = 0; foreach ( $results as $r ) { if ( 'risk' === $r['status'] ) { $n++; } } return $n; } /** * Protection meter from result rows: how many one-click (Aegistha) fixes are * active out of the total available. Pure — progress, not a "security score". * * @param array $results Rows from run(). * @return array ['active'=>int,'total'=>int,'label'=>string] */ public static function meter( $results ) { $total = 0; $active = 0; foreach ( $results as $r ) { $is_you = isset( $r['actor'] ) && 'you' === $r['actor']; // Count every one-click fix and every "Your part" item. if ( 'fix' !== $r['action'] && ! $is_you ) { continue; } $total++; // A non-verifiable reminder (e.g. login) is standing advice, so it // counts as handled rather than blocking a full score. $reminder = $is_you && empty( $r['verifiable'] ); if ( 'ok' === $r['status'] || $reminder ) { $active++; } } return array( 'active' => $active, 'total' => $total, 'label' => self::meter_label( $active, $total ), ); } /** * Calm, honest label for the protection meter. Pure. * * @param int $active Active protections. * @param int $total Total available. * @return string */ public static function meter_label( $active, $total ) { if ( $total <= 0 ) { return ''; } if ( $active >= $total ) { return __( 'All set', 'aegistha' ); } $ratio = $active / $total; if ( $ratio >= 0.6 ) { return __( 'Well protected', 'aegistha' ); } if ( $ratio >= 0.3 ) { return __( 'Making progress', 'aegistha' ); } return __( 'Just getting started', 'aegistha' ); } /** * The curated checks. actor: 'aegistha' (one-click safe fix) or 'you' (manual). * verifiable: whether Aegistha can actually detect the state (shows a status pill). * Fixable ones carry benefit + technical for the confirm dialog. * * @return array */ public static function definitions() { return array( // --- Aegistha can handle these (one-click, lockout-zero) --- array( 'id' => 'alerts', 'actor' => 'aegistha', 'priority' => 'high', 'title' => __( 'Nobody is watching', 'aegistha' ), 'explain' => __( 'You are not notified when an administrator signs in, a new admin is added, or a plugin or theme changes — the first signs that something is wrong.', 'aegistha' ), 'benefit' => __( 'You will get an email the moment an admin signs in, a new admin is added, or a plugin or theme changes, so you catch trouble early instead of finding out too late.', 'aegistha' ), 'technical' => __( 'Emails the site admin on the wp_login, set_user_role, activated_plugin and switch_theme events.', 'aegistha' ), 'fix_key' => 'alerts', ), array( 'id' => 'enumeration', 'actor' => 'aegistha', 'priority' => 'medium', 'title' => __( 'Anyone can discover your usernames', 'aegistha' ), 'explain' => __( 'Visitors can list your usernames through author pages and the REST API, which is half of what an attacker needs to guess their way in.', 'aegistha' ), 'benefit' => __( 'Strangers will no longer be able to list your usernames, so attackers lose half of what they need to guess their way in.', 'aegistha' ), 'technical' => __( 'Redirects ?author=N probes and removes the REST /users endpoint for logged-out visitors.', 'aegistha' ), 'fix_key' => 'enumeration', ), array( 'id' => 'file_editor', 'actor' => 'aegistha', 'priority' => 'medium', 'title' => __( 'Code editor open in the dashboard', 'aegistha' ), 'explain' => __( 'The dashboard lets administrators edit plugin and theme code directly. If an attacker gets in, this hands them an easy way to run their own code.', 'aegistha' ), 'benefit' => __( 'Removes the built-in code editor from your dashboard. If someone ever breaks in, they cannot use it to run their own code.', 'aegistha' ), 'technical' => __( 'Defines the DISALLOW_FILE_EDIT constant.', 'aegistha' ), 'fix_key' => 'file_editor', ), array( 'id' => 'xmlrpc', 'actor' => 'aegistha', 'priority' => 'medium', 'title' => __( 'An old remote-access door is open', 'aegistha' ), 'explain' => __( 'XML-RPC is a legacy remote interface often abused to amplify password guessing. Turn it off unless you use the WordPress mobile app or Jetpack.', 'aegistha' ), 'benefit' => __( 'Closes a legacy door that bots abuse to speed up password guessing. Leave it off unless you use the WordPress mobile app or Jetpack.', 'aegistha' ), 'technical' => __( 'Sets the WordPress xmlrpc_enabled filter to false.', 'aegistha' ), 'fix_key' => 'xmlrpc', ), array( 'id' => 'version', 'actor' => 'aegistha', 'priority' => 'minor', 'title' => __( 'Your WordPress version is public', 'aegistha' ), 'explain' => __( 'Your exact version is printed in your pages. Hiding it is minor — it only slows down automated scanners a little.', 'aegistha' ), 'benefit' => __( 'Hides your exact version from your public pages, giving automated scanners a little less to target. Minor, but harmless.', 'aegistha' ), 'technical' => __( 'Removes the wp_generator meta tag from your pages.', 'aegistha' ), 'fix_key' => 'version', ), array( 'id' => 'headers', 'actor' => 'aegistha', 'priority' => 'minor', 'title' => __( 'Missing basic browser protections', 'aegistha' ), 'explain' => __( 'A few standard response headers tell browsers to protect your visitors (against clickjacking and content sniffing). Nice to have.', 'aegistha' ), 'benefit' => __( 'Adds a few standard headers that tell browsers to protect your visitors from clickjacking and content-type tricks.', 'aegistha' ), 'technical' => __( 'Sends the X-Frame-Options, X-Content-Type-Options and Referrer-Policy headers.', 'aegistha' ), 'fix_key' => 'headers', ), // --- Only you can do these (manual / advisory, verifiable where possible) --- array( 'id' => 'abandoned', 'actor' => 'you', 'priority' => 'high', 'verifiable' => true, 'title' => __( 'Outdated or abandoned code', 'aegistha' ), 'explain' => __( 'Outdated or removed plugins and themes are the most common way WordPress sites get hacked. Review anything that is no longer maintained and replace or remove it.', 'aegistha' ), 'guide' => __( 'Open Plugins and Themes, and for each item below decide: update it, replace it with a maintained alternative, or delete it if you no longer use it.', 'aegistha' ), ), array( 'id' => 'wp_core', 'actor' => 'you', 'priority' => 'high', 'verifiable' => true, 'title' => __( 'WordPress core updates', 'aegistha' ), 'explain' => __( 'Core updates almost always include security fixes. Running an old version leaves known holes open.', 'aegistha' ), 'guide' => __( 'Go to Dashboard → Updates and update WordPress. Back up first if you can.', 'aegistha' ), ), array( 'id' => 'php_eol', 'actor' => 'you', 'priority' => 'high', 'verifiable' => true, 'title' => __( 'Your PHP version', 'aegistha' ), 'explain' => __( 'PHP is the language that runs your site. Once a version reaches end of life it stops getting security updates, so newly found holes stay unpatched.', 'aegistha' ), 'guide' => __( 'Ask your host (or use their control panel) to switch to PHP 8.1 or newer. It is usually a one-click change.', 'aegistha' ), ), array( 'id' => 'https', 'actor' => 'you', 'priority' => 'high', 'verifiable' => true, 'title' => __( 'Secure connection (HTTPS)', 'aegistha' ), 'explain' => __( 'Without HTTPS, logins and everything your visitors submit travel unencrypted and can be read or tampered with along the way.', 'aegistha' ), 'guide' => __( 'Enable an SSL certificate — most hosts offer one free and a one-click switch — then set your site address to use https.', 'aegistha' ), ), array( 'id' => 'admin_user', 'actor' => 'you', 'priority' => 'medium', 'verifiable' => true, 'title' => __( 'The "admin" username', 'aegistha' ), 'explain' => __( '"admin" is the first username attackers try. If one of your accounts uses it, they already know half of your login.', 'aegistha' ), 'guide' => __( 'Create a new administrator with a unique username, sign in as that user, then delete the "admin" account and assign its content to the new user.', 'aegistha' ), ), array( 'id' => 'login', 'actor' => 'you', 'priority' => 'high', 'verifiable' => false, 'title' => __( 'Keep your login hard to guess', 'aegistha' ), 'explain' => __( 'Your login page is at the default address and password guessing may be unlimited. We keep this advisory: changing the login URL can lock you out, so we guide you instead of doing it automatically.', 'aegistha' ), 'guide' => __( 'Use a long, unique password for every administrator, and consider a dedicated login-protection plugin if you see repeated failed logins.', 'aegistha' ), ), ); } /** * Run every check and return result rows for the UI. * * @return array */ public static function run() { $rows = array(); foreach ( self::definitions() as $c ) { $action = isset( $c['fix_key'] ) ? 'fix' : ( isset( $c['guide'] ) ? 'guide' : 'none' ); $rows[] = array_merge( $c, array( 'status' => self::detect( $c['id'] ), 'action' => $action, 'detail' => self::detail( $c['id'] ), ) ); } return $rows; } /** * Detect the real state of a check. Returns 'ok' or 'risk'. * * @param string $id Check id. * @return string */ public static function detect( $id ) { switch ( $id ) { case 'abandoned': return Aegistha_Abandoned::get_risky() ? 'risk' : 'ok'; case 'alerts': return Aegistha_Settings::get()['alerts_enabled'] ? 'ok' : 'risk'; case 'file_editor': return ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) ? 'ok' : 'risk'; case 'xmlrpc': return apply_filters( 'xmlrpc_enabled', true ) ? 'risk' : 'ok'; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- reading the WordPress core xmlrpc_enabled filter to detect the current state. case 'version': return has_action( 'wp_head', 'wp_generator' ) ? 'risk' : 'ok'; case 'enumeration': case 'headers': return Aegistha_Settings::fix_on( $id ) ? 'ok' : 'risk'; case 'wp_core': return self::core_update_available() ? 'risk' : 'ok'; case 'php_eol': return version_compare( PHP_VERSION, '8.1', '<' ) ? 'risk' : 'ok'; case 'https': return ( 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME ) ) ? 'ok' : 'risk'; case 'admin_user': return username_exists( 'admin' ) ? 'risk' : 'ok'; case 'login': return 'risk'; // Advisory reminder: never verified, never auto-fixed. default: return 'ok'; } } /** * Whether a WordPress core update is available (reads the cached update data). * * @return bool */ private static function core_update_available() { if ( ! function_exists( 'get_core_updates' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $updates = get_core_updates(); if ( empty( $updates ) || ! is_array( $updates ) ) { return false; } foreach ( $updates as $u ) { if ( isset( $u->response ) && 'upgrade' === $u->response ) { return true; } } return false; } /** * Optional plain-language detail for a check (shown expanded / inline). * * @param string $id Check id. * @return string */ public static function detail( $id ) { if ( 'php_eol' === $id ) { /* translators: %s: PHP version */ return sprintf( __( 'Your site is currently running PHP %s.', 'aegistha' ), PHP_VERSION ); } if ( 'abandoned' !== $id ) { return ''; } $parts = array(); foreach ( Aegistha_Abandoned::get_risky() as $item ) { $reason = ( 'removed' === $item['reason'] ) ? __( 'no longer in the WordPress.org directory', 'aegistha' ) : __( 'not updated in over two years', 'aegistha' ); $parts[] = $item['name'] . ' — ' . $reason; } return implode( "\n", $parts ); } }