array( '#dc2626', '#fee2e2', '#991b1b' ), 'amber' => array( '#f59e0b', '#fef3c7', '#92400e' ), 'green' => array( '#16a34a', '#dcfce7', '#166534' ), ); /** Rows listed per section before "and N more". */ const LIST_MAX = 15; /** Registers the schedule. The cron action itself is registered by the main class. */ public static function init(): void { add_action( 'init', array( __CLASS__, 'sync_schedule' ), 20 ); } /** Whether the report is switched on. */ public static function is_enabled(): bool { return '1' === (string) CSDT_Options::get( self::OPTION_ENABLED ); } /** The report day, 0 to 6. */ public static function day(): int { $d = (int) CSDT_Options::get( self::OPTION_DAY ); return ( $d >= 0 && $d <= 6 ) ? $d : 1; } /** Whether this install is a standby copy. */ private static function is_standby(): bool { return class_exists( 'CSDT_Site_Role' ) && CSDT_Site_Role::is_standby(); } /** Whether alerts are muted on this install. */ private static function is_muted(): bool { return class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_muted(); } /** Keeps the daily tick booked while enabled, and absent on a standby or when off. */ public static function sync_schedule(): void { if ( ! function_exists( 'wp_next_scheduled' ) ) { return; } $hook = self::CRON_HOOK; if ( ! self::is_enabled() || self::is_standby() ) { if ( wp_next_scheduled( $hook ) ) { wp_clear_scheduled_hook( $hook ); } return; } if ( ! wp_next_scheduled( $hook ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', $hook ); } } /** * Whether a report is due now. * * @param int $weekday Today's weekday in the site's timezone, 0 to 6. * @param int $chosen The configured day. * @param int $last_sent Unix time of the last report, 0 if never. * @param int $now Unix time. * @return bool */ public static function is_due( int $weekday, int $chosen, int $last_sent, int $now ): bool { if ( $weekday !== $chosen ) { return false; } // Six days, not seven, so a tick that drifts later each day still lands once a week. return $last_sent <= 0 || ( $now - $last_sent ) >= 6 * 86400; } /** Daily tick. */ public static function cron_run(): void { if ( ! self::is_enabled() || self::is_standby() ) { return; } $last = get_option( self::OPTION_LAST, array() ); $last = is_array( $last ) ? $last : array(); $now = time(); if ( ! self::is_due( (int) wp_date( 'w', $now ), self::day(), (int) ( $last['sent_at'] ?? 0 ), $now ) ) { return; } self::send( false ); } /** The address the report goes to. */ public static function recipient(): string { $to = sanitize_email( (string) CSDT_Options::get( self::OPTION_TO ) ); if ( '' === $to ) { $to = sanitize_email( (string) CSDT_Options::get( 'csdt_notify_email' ) ); } return '' !== $to ? $to : (string) get_option( 'admin_email' ); } /** * Builds and sends the report by email, and to the webhook when subscribed. * * @param bool $is_test True for the button: sends regardless of day, not recorded as the weekly send. * @return array{ok:bool,msg:string} */ public static function send( bool $is_test ): array { if ( self::is_muted() ) { $r = array( 'ok' => false, 'msg' => __( 'Not sent: alerts are muted on this copy of the site.', 'cloudscale-cyber-devtools' ) ); if ( ! $is_test ) { update_option( self::OPTION_LAST, array( 'ts' => time(), 'sent_at' => 0, 'ok' => false, 'msg' => $r['msg'] ), false ); } return $r; } $report = self::build( self::collect( time() ) ); $to = self::recipient(); $sent = (bool) wp_mail( $to, $report['subject'], $report['html'], array( 'Content-Type: text/html; charset=UTF-8' ) ); if ( is_callable( array( 'CSDT_Webhook', 'enabled_events' ) ) && CSDT_Webhook::is_configured() && in_array( 'weekly', CSDT_Webhook::enabled_events(), true ) ) { CSDT_Webhook::send( 'weekly', $report['subject'], $report['text'], 'info', true ); } $msg = $sent /* translators: %s: email address. */ ? sprintf( __( 'Report sent to %s.', 'cloudscale-cyber-devtools' ), $to ) /* translators: %s: email address. */ : sprintf( __( 'WordPress could not send the email to %s. Check the Mail settings below.', 'cloudscale-cyber-devtools' ), $to ); if ( ! $is_test ) { $now = time(); update_option( self::OPTION_LAST, array( 'ts' => $now, // Recorded even on a mail failure, so a broken mailer is retried next week // rather than every day; the panel shows the failure. 'sent_at' => $now, 'ok' => $sent, 'msg' => $msg, ), false ); } return array( 'ok' => $sent, 'msg' => $msg ); } // ── Data ───────────────────────────────────────────────────────────── /** * Reads everything the report needs. Each source that cannot be read is null, never 0. * * @param int $now Unix time. * @return array */ public static function collect( int $now ): array { $since = $now - self::PERIOD; $fmt = static function ( int $ts ): string { $s = function_exists( 'wp_date' ) ? wp_date( 'j M Y H:i', $ts ) : false; return is_string( $s ) && '' !== $s ? $s : gmdate( 'j M Y H:i', $ts ) . ' UTC'; }; $data = array( 'site' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'site_url' => home_url( '/' ), 'admin_url' => admin_url( 'admin.php?page=' . CSDT_DevTools::TOOLS_SLUG ), 'standby' => self::is_standby(), 'period_from' => $fmt( $since ), 'period_to' => $fmt( $now ), ); // Firewall. $data['firewall_mode'] = class_exists( 'CSDT_Firewall' ) ? CSDT_Firewall::mode() : ''; $data['firewall'] = class_exists( 'CSDT_Storage' ) ? CSDT_Storage::match_counts( $since ) : null; // Auto-blocks this period. $blocks = null; if ( class_exists( 'CSDT_Auto_Block' ) ) { $blocks = array(); foreach ( CSDT_Auto_Block::all_blocks() as $b ) { if ( (int) $b['blocked_at'] >= $since && CSDT_Auto_Block::SOURCE_AUTO === $b['source'] ) { $blocks[] = array( 'ip' => (string) $b['ip'], 'reason' => (string) $b['reason'], 'signal' => (string) $b['signal'], 'when' => $fmt( (int) $b['blocked_at'] ), ); } } } $data['autoblocks'] = $blocks; // Failed sign-ins and persistence changes, from the activity log. $data['failed_logins'] = null; $data['failed_logins_capped'] = false; $data['persistence'] = null; if ( class_exists( 'CSDT_Activity_Log' ) && CSDT_Activity_Log::is_enabled() ) { $failed = CSDT_Activity_Log::entries( array( 'event' => 'login_failed', 'since' => $since, 'limit' => 500 ) ); if ( is_array( $failed ) ) { $data['failed_logins'] = count( $failed ); $data['failed_logins_capped'] = count( $failed ) >= 500; } $changes = CSDT_Activity_Log::entries( array( 'event' => 'persistence_changed', 'since' => $since, 'limit' => 50 ) ); if ( is_array( $changes ) ) { $data['persistence'] = array(); foreach ( $changes as $c ) { $data['persistence'][] = array( 'when' => $fmt( (int) $c['ts'] ), 'object' => (string) $c['object'], 'detail' => (string) $c['detail'], ); } } } // New administrators: accounts registered this period that hold the role, plus any // promotion the threat monitor alerted on. $admins = array(); $rows = get_users( array( 'role' => 'administrator', 'fields' => array( 'user_login', 'user_registered' ), 'date_query' => array( array( 'after' => gmdate( 'Y-m-d H:i:s', $since ), 'column' => 'user_registered', 'inclusive' => true, ), ), ) ); foreach ( (array) $rows as $u ) { $admins[ (string) $u->user_login ] = $fmt( (int) strtotime( (string) $u->user_registered . ' UTC' ) ); } $promo = get_option( 'csdt_threat_last_admin_alert', array() ); if ( is_array( $promo ) && (int) ( $promo['ts'] ?? 0 ) >= $since && '' !== (string) ( $promo['login'] ?? '' ) ) { $admins[ (string) $promo['login'] ] = $fmt( (int) $promo['ts'] ); } $data['new_admins'] = array(); foreach ( $admins as $login => $when ) { $data['new_admins'][] = array( 'login' => $login, 'when' => $when, ); } // Vulnerable components, as the last feed check recorded them, minus any already updated. $data['vulns'] = self::flagged_vulns(); $data['vuln_checked'] = ''; $data['server_vulns'] = 0; if ( class_exists( 'CSDT_Vuln_Recheck' ) ) { $status = CSDT_Vuln_Recheck::status(); if ( ! empty( $status['ran'] ) ) { $data['vuln_checked'] = $fmt( (int) ( $status['summary']['ts'] ?? 0 ) ); $data['server_vulns'] = (int) ( $status['summary']['server_findings'] ?? 0 ); } } // File integrity. $data['integrity'] = array( 'state' => 'off' ); if ( class_exists( 'CSDT_Integrity' ) && CSDT_Integrity::is_enabled() ) { $r = CSDT_Integrity::last_result(); if ( empty( $r['ts'] ) ) { $data['integrity'] = array( 'state' => 'never' ); } elseif ( empty( $r['verified'] ) ) { $data['integrity'] = array( 'state' => 'unverified', 'when' => $fmt( (int) $r['ts'] ) ); } else { $a = CSDT_Integrity::alertable( $r ); $data['integrity'] = array( 'state' => $a['count'] > 0 ? 'findings' : 'clean', 'count' => (int) $a['count'], 'lines' => array_slice( $a['lines'], 0, self::LIST_MAX ), 'when' => $fmt( (int) $r['ts'] ), ); } } // Last scan. $data['last_scan'] = null; $history = get_option( 'csdt_scan_history', array() ); if ( is_array( $history ) && ! empty( $history[0] ) && is_array( $history[0] ) ) { $h = $history[0]; $data['last_scan'] = array( 'score' => isset( $h['score'] ) && is_numeric( $h['score'] ) ? (int) $h['score'] : null, 'label' => (string) ( $h['score_label'] ?? '' ), 'type' => (string) ( $h['type'] ?? '' ), 'when' => $fmt( (int) ( $h['scanned_at'] ?? 0 ) ), 'critical' => (int) ( $h['critical_count'] ?? 0 ), 'high' => (int) ( $h['high_count'] ?? 0 ), ); } $data['exposed'] = self::exposed_folders( $fmt ); // Host software, as the last check stored it. No request: the check runs from its // button and from the daily re-check, never from the report. $data['host_software'] = null; if ( class_exists( 'CSDT_Host_Software' ) ) { $hs = CSDT_Host_Software::last(); if ( null !== $hs ) { $hs['when'] = $fmt( (int) $hs['ts'] ); $hs['stale'] = ( $now - (int) $hs['ts'] ) > 8 * DAY_IN_SECONDS; $data['host_software'] = $hs; } } // PHP security advisories: the Site Audit's own finding, from stored data only. The // report never fetches the feed, with PHP Advisory Alerts on or off. $data['php_advisory'] = null; if ( class_exists( 'CSDT_Site_Audit' ) && class_exists( 'CSDT_Php_Advisory' ) ) { $php = CSDT_Site_Audit::php_advisory_findings(); $data['php_advisory'] = $php[0] ?? null; } // Whether anything is checking for NEW CVEs: the two opt-in switches, read, never a fetch. $data['cve_checks'] = null; if ( class_exists( 'CSDT_Cve_Checks' ) ) { $data['cve_checks'] = CSDT_Cve_Checks::state(); } $data['updates'] = self::pending_updates(); return $data; } /** * The Exposed folders scan as the report shows it: the last finished run, its hits asked * for again now, so a leak closed since the run is not reported as still open. * * Paths only, never URLs: an email should not carry a working link to what it warns about. * * @param callable $fmt Timestamp formatter. * @return array|null Null when the scan cannot be read at all. */ private static function exposed_folders( callable $fmt ): ?array { if ( ! class_exists( 'CSDT_Dir_Probe' ) ) { return null; } $scan = CSDT_Dir_Probe::latest(); $finished = (int) ( $scan['finished'] ?? 0 ); $hits = array(); foreach ( (array) ( $scan['hits'] ?? array() ) as $h ) { $hits[] = array( 'type' => (string) ( $h['type'] ?? '' ), 'path' => (string) ( $h['path'] ?? '' ), 'evidence' => (string) ( $h['evidence'] ?? '' ), ); } return array( 'status' => (string) ( $scan['status'] ?? 'pending' ), 'checked' => (int) ( $scan['checked'] ?? 0 ), 'when' => $finished > 0 ? (string) $fmt( $finished ) : '', 'inconclusive' => ! empty( $scan['inconclusive'] ), 'hits' => $hits, ); } /** * Plain words for a scan hit's type. * * @param string $type Hit type from CSDT_Dir_Probe. * @return string */ private static function exposed_label( string $type ): string { $labels = array( 'vcs' => __( 'source control data', 'cloudscale-cyber-devtools' ), 'secret' => __( 'file of passwords or keys', 'cloudscale-cyber-devtools' ), 'archive' => __( 'backup or database dump', 'cloudscale-cyber-devtools' ), 'db_tool' => __( 'database administration tool', 'cloudscale-cyber-devtools' ), 'rce_file' => __( 'file that can run commands', 'cloudscale-cyber-devtools' ), 'listing' => __( 'folder that lists its files', 'cloudscale-cyber-devtools' ), 'second_install' => __( 'second WordPress install', 'cloudscale-cyber-devtools' ), 'phpinfo' => __( 'PHP configuration page', 'cloudscale-cyber-devtools' ), 'server_status' => __( 'web server status page', 'cloudscale-cyber-devtools' ), 'dev' => __( 'development file', 'cloudscale-cyber-devtools' ), 'info_file' => __( 'file that reveals versions', 'cloudscale-cyber-devtools' ), 'leftover' => __( 'leftover folder', 'cloudscale-cyber-devtools' ), ); return $labels[ $type ] ?? __( 'exposed path', 'cloudscale-cyber-devtools' ); } /** * Components the vulnerability feed flagged that are still installed at a vulnerable version. * * Shared with the Site Health test, so the two can never disagree. A component updated to * its fixed version since the last check is left out: the stored list is only refreshed by * the next check, and reporting a fixed plugin as vulnerable trains the reader to ignore it. * * @return array>|null Null when no check has ever run (unknown, not clean). */ public static function flagged_vulns(): ?array { if ( ! class_exists( 'CSDT_Shield' ) || ! class_exists( 'CSDT_Vuln_Recheck' ) ) { return null; } $status = CSDT_Vuln_Recheck::status(); $cands = CSDT_Shield::candidates(); if ( empty( $status['ran'] ) && empty( $cands ) ) { return null; } $installed = self::installed_versions(); $out = array(); foreach ( $cands as $slug => $c ) { $fixed = (string) ( $c['fixed_in'] ?? '' ); $current = $installed[ (string) $slug ] ?? ''; if ( '' !== $fixed && '' !== $current && version_compare( $current, $fixed, '>=' ) ) { continue; } $out[] = array( 'name' => (string) ( $c['name'] ?? $slug ), 'version' => '' !== $current ? $current : (string) ( $c['version'] ?? '' ), 'severity' => (string) ( $c['severity'] ?? '' ), 'fixed_in' => $fixed, 'cves' => implode( ', ', array_slice( (array) ( $c['cve_ids'] ?? array() ), 0, 3 ) ), ); } return $out; } /** * Installed plugin and theme versions, keyed the way CSDT_Shield keys candidates. * * @return array */ private static function installed_versions(): array { $out = array(); if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( function_exists( 'get_plugins' ) ) { foreach ( get_plugins() as $file => $p ) { $slug = false !== strpos( $file, '/' ) ? dirname( $file ) : basename( $file, '.php' ); $out[ $slug ] = (string) ( $p['Version'] ?? '' ); } } if ( function_exists( 'wp_get_themes' ) ) { foreach ( wp_get_themes() as $stylesheet => $t ) { $out[ 'theme:' . $stylesheet ] = (string) $t->get( 'Version' ); if ( ! isset( $out[ $stylesheet ] ) ) { $out[ $stylesheet ] = (string) $t->get( 'Version' ); } } } return $out; } /** * Updates WordPress already knows about, from its own update transients. No request is made. * * @return array{core:string,plugins:array,themes:array} */ private static function pending_updates(): array { $out = array( 'core' => '', 'plugins' => array(), 'themes' => array(), ); $core = get_site_transient( 'update_core' ); if ( is_object( $core ) && ! empty( $core->updates ) && is_array( $core->updates ) ) { $u = $core->updates[0]; if ( is_object( $u ) && 'upgrade' === ( $u->response ?? '' ) ) { $out['core'] = (string) ( $u->current ?? '' ); } } $plugins = get_site_transient( 'update_plugins' ); if ( is_object( $plugins ) && ! empty( $plugins->response ) && is_array( $plugins->response ) ) { $names = function_exists( 'get_plugins' ) ? get_plugins() : array(); foreach ( $plugins->response as $file => $u ) { $name = (string) ( $names[ $file ]['Name'] ?? $file ); $out['plugins'][] = $name . ' ' . (string) ( is_object( $u ) ? ( $u->new_version ?? '' ) : '' ); } } $themes = get_site_transient( 'update_themes' ); if ( is_object( $themes ) && ! empty( $themes->response ) && is_array( $themes->response ) ) { foreach ( $themes->response as $slug => $u ) { $out['themes'][] = $slug . ' ' . (string) ( is_array( $u ) ? ( $u['new_version'] ?? '' ) : '' ); } } return $out; } // ── Rendering (pure) ───────────────────────────────────────────────── /** * Builds the report from collected data. Reads nothing but $d. * * @param array $d Output of collect(), or a test fixture. * @return array{subject:string,html:string,text:string,counts:array} */ public static function build( array $d ): array { $site = (string) ( $d['site'] ?? '' ); $fw_rules = is_array( $d['firewall'] ?? null ) ? $d['firewall'] : null; $blocks = is_array( $d['autoblocks'] ?? null ) ? $d['autoblocks'] : null; $vulns = is_array( $d['vulns'] ?? null ) ? $d['vulns'] : null; $admins = is_array( $d['new_admins'] ?? null ) ? $d['new_admins'] : array(); $persist = is_array( $d['persistence'] ?? null ) ? $d['persistence'] : null; $integ = is_array( $d['integrity'] ?? null ) ? $d['integrity'] : array( 'state' => 'off' ); $updates = is_array( $d['updates'] ?? null ) ? $d['updates'] : array(); $exposed = is_array( $d['exposed'] ?? null ) ? $d['exposed'] : null; // A count only once a run has finished: off, pending and unreadable are unknown, not 0. $exp_status = null === $exposed ? '' : (string) ( $exposed['status'] ?? '' ); $exp_known = in_array( $exp_status, array( 'complete', 'stale' ), true ); $exp_hits = $exp_known ? array_values( (array) ( $exposed['hits'] ?? array() ) ) : array(); $exp_urgent = 0; $exp_high = 0; foreach ( $exp_hits as $h ) { $risk = self::exposed_risk( (string) ( $h['type'] ?? '' ) ); if ( 'low' !== $risk ) { ++$exp_urgent; } if ( 'high' === $risk ) { ++$exp_high; } } $fw_blocked = null; if ( null !== $fw_rules ) { $fw_blocked = 0; foreach ( $fw_rules as $row ) { $fw_blocked += (int) ( $row['blocked'] ?? 0 ); } } $n_updates = ( '' !== (string) ( $updates['core'] ?? '' ) ? 1 : 0 ) + count( (array) ( $updates['plugins'] ?? array() ) ) + count( (array) ( $updates['themes'] ?? array() ) ); $counts = array( 'firewall_blocked' => $fw_blocked, 'autoblocks' => null === $blocks ? null : count( $blocks ), 'failed_logins' => isset( $d['failed_logins'] ) ? (int) $d['failed_logins'] : null, 'new_admins' => count( $admins ), 'vulns' => null === $vulns ? null : count( $vulns ), 'integrity' => 'findings' === ( $integ['state'] ?? '' ) ? (int) ( $integ['count'] ?? 0 ) : 0, 'persistence' => null === $persist ? null : count( $persist ), 'exposed' => $exp_known ? count( $exp_hits ) : null, 'exposed_urgent' => $exp_known ? $exp_urgent : null, 'updates' => $n_updates, ); $host = self::host_software_section( is_array( $d['host_software'] ?? null ) ? $d['host_software'] : null ); $counts['host'] = $host['red']; $php = self::php_advisory_section( is_array( $d['php_advisory'] ?? null ) ? $d['php_advisory'] : null ); $counts['php_advisory'] = $php['red']; $cve = self::cve_checks_section( is_array( $d['cve_checks'] ?? null ) ? $d['cve_checks'] : null ); $counts['cve_checks'] = $cve['red']; $attention = $counts['new_admins'] + (int) $counts['vulns'] + (int) $host['red'] + (int) $php['red'] + (int) $cve['red'] + $counts['integrity'] + (int) $counts['persistence'] + (int) $counts['exposed_urgent'] + ( 'off' === $exp_status ? 1 : 0 ); // A check switched off is an item to act on: switch it on. $subject = '[' . ( '' !== $site ? $site : 'unknown site' ) . ( ! empty( $d['standby'] ) ? ', STANDBY' : '' ) . '] ' . sprintf( /* translators: 1: number of items needing attention */ __( 'Weekly security report: %1$s', 'cloudscale-cyber-devtools' ), $attention > 0 /* translators: %d: number of items */ ? sprintf( _n( '%d item needs attention', '%d items need attention', $attention, 'cloudscale-cyber-devtools' ), $attention ) : __( 'nothing needs attention', 'cloudscale-cyber-devtools' ) ); $na = __( 'unavailable on this site', 'cloudscale-cyber-devtools' ); // Each section: heading, summary line, optional rows. Built once, rendered to HTML and text. $sections = array(); // Firewall. $fw_mode = (string) ( $d['firewall_mode'] ?? '' ); $fw_rows = array(); if ( null !== $fw_rules ) { uasort( $fw_rules, static function ( $a, $b ) { return ( (int) ( $b['hits'] ?? 0 ) ) <=> ( (int) ( $a['hits'] ?? 0 ) ); } ); foreach ( $fw_rules as $rule => $row ) { $fw_rows[] = sprintf( /* translators: 1: rule id, 2: matches, 3: blocked, 4: addresses */ __( '%1$s: %2$d matches, %3$d blocked, %4$d addresses', 'cloudscale-cyber-devtools' ), (string) $rule, (int) ( $row['hits'] ?? 0 ), (int) ( $row['blocked'] ?? 0 ), (int) ( $row['ips'] ?? 0 ) ); } } $sections[] = array( 'title' => __( 'Firewall', 'cloudscale-cyber-devtools' ), 'summary' => null === $fw_rules ? sprintf( /* translators: 1: firewall mode, 2: "unavailable on this site" */ __( 'Mode: %1$s. Match counts %2$s.', 'cloudscale-cyber-devtools' ), '' !== $fw_mode ? $fw_mode : '?', $na ) : sprintf( /* translators: 1: mode, 2: requests blocked */ __( 'Mode: %1$s. %2$d requests blocked.', 'cloudscale-cyber-devtools' ), '' !== $fw_mode ? $fw_mode : '?', (int) $fw_blocked ), 'rows' => $fw_rows, 'rag' => 'off' === $fw_mode ? 'red' : ( 'block' === $fw_mode ? 'green' : 'amber' ), ); // Auto-blocks. $block_rows = array(); foreach ( (array) $blocks as $b ) { $block_rows[] = trim( (string) ( $b['when'] ?? '' ) . ' ' . (string) ( $b['ip'] ?? '' ) . ' ' . (string) ( $b['reason'] ?? '' ) ); } $login_blocks = 0; foreach ( (array) $blocks as $b ) { if ( in_array( (string) ( $b['signal'] ?? '' ), array( 'login', 'xmlrpc' ), true ) ) { ++$login_blocks; } } $sections[] = array( 'title' => __( 'Auto-blocked addresses', 'cloudscale-cyber-devtools' ), 'summary' => null === $blocks ? $na : sprintf( /* translators: %d: number of addresses */ _n( '%d address blocked automatically.', '%d addresses blocked automatically.', count( $blocks ), 'cloudscale-cyber-devtools' ), count( $blocks ) ), 'rows' => $block_rows, 'rag' => null === $blocks ? 'amber' : 'green', ); // Sign-ins. $failed = $counts['failed_logins']; $sections[] = array( 'title' => __( 'Sign-ins', 'cloudscale-cyber-devtools' ), 'summary' => ( null === $failed /* translators: %s: "unavailable on this site" */ ? sprintf( __( 'Failed sign-ins: %s.', 'cloudscale-cyber-devtools' ), $na ) /* translators: 1: count, 2: "+" when capped */ : sprintf( __( 'Failed sign-ins: %1$d%2$s.', 'cloudscale-cyber-devtools' ), $failed, ! empty( $d['failed_logins_capped'] ) ? '+' : '' ) ) /* translators: %d: number of addresses */ . ' ' . sprintf( __( 'Addresses blocked for sign-in abuse: %d.', 'cloudscale-cyber-devtools' ), $login_blocks ), 'rows' => array(), 'rag' => null === $failed ? 'amber' : 'green', ); // New administrators. $admin_rows = array(); foreach ( $admins as $a ) { $admin_rows[] = (string) ( $a['login'] ?? '' ) . ' ' . (string) ( $a['when'] ?? '' ); } $sections[] = array( 'title' => __( 'New administrators', 'cloudscale-cyber-devtools' ), 'summary' => empty( $admins ) ? __( 'None.', 'cloudscale-cyber-devtools' ) /* translators: %d: number of accounts */ : sprintf( _n( '%d new administrator. If you did not create it, revoke it now.', '%d new administrators. If you did not create them, revoke them now.', count( $admins ), 'cloudscale-cyber-devtools' ), count( $admins ) ), 'rows' => $admin_rows, 'rag' => empty( $admins ) ? 'green' : 'red', ); // Vulnerable components. $vuln_rows = array(); foreach ( (array) $vulns as $v ) { $fixed = (string) ( $v['fixed_in'] ?? '' ); $vuln_rows[] = array( 'text' => sprintf( '%s %s %s. %s', (string) ( $v['name'] ?? '' ), (string) ( $v['version'] ?? '' ), (string) ( $v['cves'] ?? '' ), '' !== $fixed /* translators: %s: version */ ? sprintf( __( 'Fixed in %s.', 'cloudscale-cyber-devtools' ), $fixed ) : __( 'No fixed version published yet.', 'cloudscale-cyber-devtools' ) ), 'risk' => strtolower( (string) ( $v['severity'] ?? '' ) ), ); } $checked = (string) ( $d['vuln_checked'] ?? '' ); if ( null === $vulns ) { $vuln_summary = __( 'Not checked: turn on CVE lookups (AI Security Scan tab, CVE Alerts).', 'cloudscale-cyber-devtools' ); } else { $vuln_summary = empty( $vulns ) ? __( 'None flagged.', 'cloudscale-cyber-devtools' ) /* translators: %d: number of components */ : sprintf( _n( '%d vulnerable component flagged.', '%d vulnerable components flagged.', count( $vulns ), 'cloudscale-cyber-devtools' ), count( $vulns ) ); if ( '' !== $checked ) { /* translators: %s: date */ $vuln_summary .= ' ' . sprintf( __( 'Last checked %s.', 'cloudscale-cyber-devtools' ), $checked ); } if ( (int) ( $d['server_vulns'] ?? 0 ) > 0 ) { /* translators: %d: count */ $vuln_summary .= ' ' . sprintf( __( 'Server software findings: %d.', 'cloudscale-cyber-devtools' ), (int) $d['server_vulns'] ); } } $sections[] = array( 'title' => __( 'Vulnerable components', 'cloudscale-cyber-devtools' ), 'summary' => $vuln_summary, 'rows' => $vuln_rows, 'rag' => null === $vulns ? 'amber' : self::worst_rag( $vuln_rows ), ); $sections[] = $host['section']; $sections[] = $php['section']; if ( null !== $cve['section'] ) { $sections[] = $cve['section']; } // Integrity and persistence. switch ( (string) ( $integ['state'] ?? 'off' ) ) { case 'clean': /* translators: %s: date */ $int_summary = sprintf( __( 'File integrity: no changes found (checked %s).', 'cloudscale-cyber-devtools' ), (string) ( $integ['when'] ?? '' ) ); break; case 'findings': /* translators: 1: count, 2: date */ $int_summary = sprintf( __( 'File integrity: %1$d changed or unexpected files (checked %2$s).', 'cloudscale-cyber-devtools' ), (int) ( $integ['count'] ?? 0 ), (string) ( $integ['when'] ?? '' ) ); break; case 'unverified': $int_summary = __( 'File integrity: the last check could not verify the files.', 'cloudscale-cyber-devtools' ); break; case 'never': $int_summary = __( 'File integrity: switched on, not run yet.', 'cloudscale-cyber-devtools' ); break; default: $int_summary = __( 'File integrity: switched off.', 'cloudscale-cyber-devtools' ); } $int_summary .= ' ' . ( null === $persist /* translators: %s: "unavailable on this site" */ ? sprintf( __( 'Persistence file changes: %s.', 'cloudscale-cyber-devtools' ), $na ) /* translators: %d: count */ : sprintf( __( 'Persistence file changes: %d.', 'cloudscale-cyber-devtools' ), count( $persist ) ) ); $int_rows = array(); foreach ( (array) ( $integ['lines'] ?? array() ) as $line ) { $int_rows[] = (string) $line; } foreach ( (array) $persist as $p ) { $int_rows[] = (string) ( $p['when'] ?? '' ) . ' ' . (string) ( $p['object'] ?? '' ) . ' ' . (string) ( $p['detail'] ?? '' ); } $sections[] = array( 'title' => __( 'File integrity and persistence', 'cloudscale-cyber-devtools' ), 'summary' => $int_summary, 'rows' => $int_rows, 'rag' => $counts['integrity'] > 0 || (int) $counts['persistence'] > 0 ? 'red' : ( 'clean' === ( $integ['state'] ?? '' ) && null !== $persist ? 'green' : 'amber' ), ); // Exposed folders. $exp_rows = array(); foreach ( $exp_hits as $h ) { $evidence = (string) ( $h['evidence'] ?? '' ); $exp_rows[] = array( 'text' => (string) ( $h['path'] ?? '' ) . ' ' . self::exposed_label( (string) ( $h['type'] ?? '' ) ) . ( '' !== $evidence ? ' (' . $evidence . ')' : '' ), 'risk' => self::exposed_risk( (string) ( $h['type'] ?? '' ) ), ); } if ( null === $exposed ) { $exp_summary = $na; } elseif ( 'off' === $exp_status ) { $exp_summary = __( 'Not checked: the exposed folders check is switched off, so nothing is looking for backups, .git or .env files, database dumps or folders that list their files that anyone on the internet can download from this site. We recommend switching it on: Security tab, AI Cyber Audit panel, tick "Exposed folders check". It runs once a day in the background and only ever asks this site.', 'cloudscale-cyber-devtools' ); } elseif ( ! $exp_known ) { $exp_summary = __( 'Not checked yet: the first scan has not finished.', 'cloudscale-cyber-devtools' ); } else { $exp_checked = (int) ( $exposed['checked'] ?? 0 ); $exp_when = (string) ( $exposed['when'] ?? '' ); if ( ! empty( $exp_hits ) ) { /* translators: %d: number of paths */ $exp_summary = sprintf( _n( '%d folder or file is served to visitors who are not signed in.', '%d folders or files are served to visitors who are not signed in.', count( $exp_hits ), 'cloudscale-cyber-devtools' ), count( $exp_hits ) ); if ( 0 === $exp_urgent ) { $exp_summary .= ' ' . __( 'All low risk.', 'cloudscale-cyber-devtools' ); } } elseif ( ! empty( $exposed['inconclusive'] ) ) { $exp_summary = __( 'Nothing found, but the last scan did not finish cleanly, so that is unproven.', 'cloudscale-cyber-devtools' ); } else { $exp_summary = __( 'None found.', 'cloudscale-cyber-devtools' ); } if ( '' !== $exp_when ) { /* translators: 1: number of requests, 2: date */ $exp_summary .= ' ' . sprintf( __( '%1$d paths asked for, last scan finished %2$s.', 'cloudscale-cyber-devtools' ), $exp_checked, $exp_when ); } if ( 'stale' === $exp_status ) { $exp_summary .= ' ' . __( 'That is more than three days ago: the daily scan is not running.', 'cloudscale-cyber-devtools' ); } } $sections[] = array( 'title' => __( 'Exposed folders', 'cloudscale-cyber-devtools' ), 'summary' => $exp_summary, 'rows' => $exp_rows, // Off is red and counted in the subject: the action is to switch it on. A high-risk hit // is red; a medium one, or a stale or unfinished scan, is amber; a finished scan with // nothing or only low-risk hits is green. 'rag' => $exp_high > 0 || 'off' === $exp_status ? 'red' : ( $exp_urgent > 0 || 'complete' !== $exp_status || ! empty( $exposed['inconclusive'] ) ? 'amber' : 'green' ), ); // Last scan. $scan = is_array( $d['last_scan'] ?? null ) ? $d['last_scan'] : null; $sections[] = array( 'title' => __( 'Last security scan', 'cloudscale-cyber-devtools' ), 'summary' => null === $scan ? __( 'No scan has been run. Run one from the Security tab.', 'cloudscale-cyber-devtools' ) : sprintf( /* translators: 1: score, 2: label, 3: date, 4: critical count, 5: high count */ __( 'Score %1$s %2$s on %3$s. Critical: %4$d, high: %5$d.', 'cloudscale-cyber-devtools' ), null === $scan['score'] ? '?' : (string) $scan['score'] . '/100', '' !== (string) $scan['label'] ? '(' . (string) $scan['label'] . ')' : '', (string) $scan['when'], (int) $scan['critical'], (int) $scan['high'] ), 'rows' => array(), 'rag' => null === $scan ? 'amber' : ( (int) $scan['critical'] > 0 ? 'red' : ( (int) $scan['high'] > 0 ? 'amber' : 'green' ) ), ); // Updates. $upd_rows = array(); if ( '' !== (string) ( $updates['core'] ?? '' ) ) { /* translators: %s: version */ $upd_rows[] = sprintf( __( 'WordPress %s', 'cloudscale-cyber-devtools' ), (string) $updates['core'] ); } foreach ( array_merge( (array) ( $updates['plugins'] ?? array() ), (array) ( $updates['themes'] ?? array() ) ) as $u ) { $upd_rows[] = (string) $u; } $sections[] = array( 'title' => __( 'Updates pending', 'cloudscale-cyber-devtools' ), 'summary' => 0 === $n_updates ? __( 'Everything is up to date.', 'cloudscale-cyber-devtools' ) /* translators: %d: count */ : sprintf( _n( '%d update waiting.', '%d updates waiting.', $n_updates, 'cloudscale-cyber-devtools' ), $n_updates ), 'rows' => $upd_rows, 'rag' => 0 === $n_updates ? 'green' : 'amber', ); $period = sprintf( /* translators: 1: start date, 2: end date */ __( 'Period: %1$s to %2$s', 'cloudscale-cyber-devtools' ), (string) ( $d['period_from'] ?? '' ), (string) ( $d['period_to'] ?? '' ) ); $sender = (string) ( $d['site_url'] ?? '' ) . ( ! empty( $d['standby'] ) ? ' (standby copy, not production)' : '' ); return array( 'subject' => $subject, 'html' => self::render_html( $subject, $period, $sections, (string) ( $d['admin_url'] ?? '' ), $sender ), 'text' => self::render_text( $period, $sections, $sender ), 'counts' => $counts, ); } /** * The Host software section: one table row per component, worst first. * * Rows carry text and risk like every other section, so the plain-text render and the * webhook need nothing new; 'table' is what the HTML render draws instead of a list. * * @param array|null $hs CSDT_Host_Software::last() plus when and stale, or null. * @return array{section:array,red:int} */ private static function host_software_section( ?array $hs ): array { $title = __( 'Host software', 'cloudscale-cyber-devtools' ); if ( null === $hs ) { return array( 'section' => array( 'title' => $title, 'summary' => __( 'Not checked yet: click Check host software on the Security tab, or turn on CVE lookups (AI Security Scan tab, CVE Alerts) to keep it current.', 'cloudscale-cyber-devtools' ), 'rows' => array(), 'rag' => 'amber', ), 'red' => 0, ); } $rows = array(); $table = array(); $red = 0; $amber = 0; $risk = array( 'red' => 'high', 'amber' => 'medium', 'green' => 'low', ); foreach ( (array) ( $hs['rows'] ?? array() ) as $r ) { if ( ! is_array( $r ) ) { continue; } $rag = (string) ( $r['rag'] ?? 'amber' ); if ( 'red' === $rag ) { ++$red; } elseif ( 'amber' === $rag ) { ++$amber; } $latest = (string) ( $r['cycle_latest'] ?? '' ); $newest = (string) ( $r['newest'] ?? '' ); $table[] = array( 'name' => (string) ( $r['name'] ?? '' ), 'installed' => (string) ( $r['installed'] ?? '' ), 'latest' => '' !== $latest ? $latest . ( '' !== (string) ( $r['cycle_date'] ?? '' ) ? ' (' . (string) $r['cycle_date'] . ')' : '' ) : '?', 'newest' => '' !== $newest ? $newest : '?', 'support' => '' !== (string) ( $r['eol'] ?? '' ) ? ( 'past' === ( $r['eol_state'] ?? '' ) ? __( 'Ended', 'cloudscale-cyber-devtools' ) . ' ' : __( 'Until', 'cloudscale-cyber-devtools' ) . ' ' ) . (string) $r['eol'] : '?', 'rag' => $rag, 'advice' => (string) ( $r['advice'] ?? '' ), ); $rows[] = array( 'text' => (string) ( $r['name'] ?? '' ) . ' ' . (string) ( $r['installed'] ?? '' ) . ': ' . (string) ( $r['advice'] ?? '' ), 'risk' => $risk[ $rag ] ?? '', ); } $n = count( $table ); /* translators: 1: number of components, 2: number to update now, 3: number to plan, 4: date */ $summary = sprintf( __( '%1$d components checked: %2$d to update now, %3$d to plan. Checked %4$s.', 'cloudscale-cyber-devtools' ), $n, $red, $amber, (string) ( $hs['when'] ?? '' ) ); $unver = (array) ( $hs['unverifiable'] ?? array() ); if ( ! empty( $unver ) ) { /* translators: %s: list of components */ $summary .= ' ' . sprintf( __( 'Could not be checked: %s.', 'cloudscale-cyber-devtools' ), implode( '; ', array_map( 'strval', $unver ) ) ); } if ( ! empty( $hs['stale'] ) ) { $summary .= ' ' . __( 'That is more than a week old: switch on the daily vulnerability re-check, or click Check host software.', 'cloudscale-cyber-devtools' ); } $rag = (string) ( $hs['rag'] ?? 'amber' ); if ( ! empty( $hs['stale'] ) && 'green' === $rag ) { $rag = 'amber'; } return array( 'section' => array( 'title' => $title, 'summary' => $summary, 'rows' => $rows, 'table' => $table, 'rag' => $rag, ), 'red' => $red, ); } /** * The PHP advisories section, from the Site Audit's finding (CSDT_Site_Audit::php_advisory_finding()). * * Critical and high are red and counted as needing attention; a pass is green; unknown is * amber and not counted, because "not read yet" is not a fault on the site. * * @param array|null $f The finding, or null when the report could not build one. * @return array{section:array,red:int} */ private static function php_advisory_section( ?array $f ): array { $title = __( 'PHP security advisories', 'cloudscale-cyber-devtools' ); if ( null === $f ) { return array( 'section' => array( 'title' => $title, 'summary' => __( 'Not checked: open the PHP Advisory Alerts panel on the Security tab.', 'cloudscale-cyber-devtools' ), 'rows' => array(), 'rag' => 'amber', ), 'red' => 0, ); } $sev = strtolower( (string) ( $f['severity'] ?? 'info' ) ); $red = in_array( $sev, array( 'critical', 'high' ), true ); $rag = $red ? 'red' : ( 'php_advisory_ok' === ( $f['key'] ?? '' ) ? 'green' : 'amber' ); $fix = (string) ( $f['fix'] ?? '' ); $rows = array(); if ( '' !== $fix && $red ) { $rows[] = array( 'text' => $fix, 'risk' => $sev, ); } elseif ( '' !== $fix && 'amber' === $rag ) { $rows[] = $fix; } return array( 'section' => array( 'title' => $title, 'summary' => trim( (string) ( $f['title'] ?? '' ) . '. ' . (string) ( $f['detail'] ?? '' ) ), 'rows' => $rows, 'rag' => $rag, ), 'red' => $red ? 1 : 0, ); } /** * The "new CVEs" section: which of the two opt-in checks are off, and what each would send. * * A check switched off is red and counted, the same rule as the Exposed folders check: it is * an item to act on, and on a default install it is the reason a new CVE goes unreported. * * @param array|null $state CSDT_Cve_Checks::state(), or null when the class is absent. * @return array{section:array|null,red:int} */ private static function cve_checks_section( ?array $state ): array { if ( null === $state || ! class_exists( 'CSDT_Cve_Checks' ) ) { return array( 'section' => null, 'red' => 0, ); } $title = __( 'New CVE checks', 'cloudscale-cyber-devtools' ); $off = CSDT_Cve_Checks::off( $state ); if ( array() === $off ) { return array( 'section' => array( 'title' => $title, 'summary' => __( 'On: CVE lookups and PHP advisory alerts check for new vulnerabilities on their own schedule.', 'cloudscale-cyber-devtools' ), 'rows' => array(), 'rag' => 'green', ), 'red' => 0, ); } $rows = array(); foreach ( $off as $which ) { $rows[] = array( /* translators: 1: check name, 2: what turning it on sends. */ 'text' => sprintf( __( '%1$s is off. %2$s', 'cloudscale-cyber-devtools' ), CSDT_Cve_Checks::label( $which ), CSDT_Cve_Checks::sends( $which ) ), 'risk' => 'medium', ); } return array( 'section' => array( 'title' => $title, 'summary' => __( 'New CVEs are not being checked. Turn the checks on from the notice in Cyber Devtools, or on the AI Security Scan tab (CVE Alerts and PHP Advisory Alerts).', 'cloudscale-cyber-devtools' ), 'rows' => $rows, 'rag' => 'red', ), 'red' => 1, ); } /** * A Host software table: component, installed, latest in its branch, newest, support, * importance, and the advice on a line of its own beneath, which keeps it inside 640px. * * @param array $table Rows from host_software_section(). * @return string Escaped HTML. */ private static function host_table_html( array $table ): string { $labels = array( 'red' => __( 'Update now', 'cloudscale-cyber-devtools' ), 'amber' => __( 'Plan it', 'cloudscale-cyber-devtools' ), 'green' => __( 'OK', 'cloudscale-cyber-devtools' ), ); $th = 'text-align:left;padding:4px 6px;border-bottom:1px solid #cbd5e1;color:#0f172a;font-size:12px;'; $td = 'padding:4px 6px;color:#374151;font-size:12px;vertical-align:top;'; $h = '' . '' . '' . '' . '' . '' . ''; foreach ( $table as $r ) { $c = self::RAG[ $r['rag'] ] ?? self::RAG['amber']; $h .= '' . '' . '' . '' . '' . '' . ''; } return $h . '
' . esc_html__( 'Component', 'cloudscale-cyber-devtools' ) . '' . esc_html__( 'Installed', 'cloudscale-cyber-devtools' ) . '' . esc_html__( 'Latest in branch', 'cloudscale-cyber-devtools' ) . '' . esc_html__( 'Newest', 'cloudscale-cyber-devtools' ) . '' . esc_html__( 'Support', 'cloudscale-cyber-devtools' ) . '' . esc_html__( 'Importance', 'cloudscale-cyber-devtools' ) . '
' . esc_html( $r['name'] ) . '' . esc_html( $r['installed'] ) . '' . esc_html( $r['latest'] ) . '' . esc_html( $r['newest'] ) . '' . esc_html( $r['support'] ) . '' . esc_html( $labels[ $r['rag'] ] ?? $labels['amber'] ) . '
' . esc_html( $r['advice'] ) . '
'; } /** * Plain HTML: inline styles only, every value escaped. * * @param string $subject Subject line, used as the heading. * @param string $period Period line. * @param array $sections Sections. * @param string $admin Admin URL. * @param string $sender Site line. * @return string */ private static function render_html( string $subject, string $period, array $sections, string $admin, string $sender ): string { $h = '
'; $h .= '

' . esc_html( $subject ) . '

'; $h .= '

' . esc_html( $period ) . '

'; foreach ( $sections as $s ) { $border = self::RAG[ $s['rag'] ?? '' ][0] ?? '#cbd5e1'; $h .= '
'; $h .= '

' . esc_html( (string) $s['title'] ) . '

'; $h .= '

' . esc_html( (string) $s['summary'] ) . '

'; $rows = (array) $s['rows']; if ( ! empty( $s['table'] ) ) { $h .= self::host_table_html( (array) $s['table'] ); } elseif ( ! empty( $rows ) ) { $h .= '
    '; foreach ( array_slice( $rows, 0, self::LIST_MAX ) as $row ) { $h .= '
  • ' . self::row_html( $row ) . '
  • '; } if ( count( $rows ) > self::LIST_MAX ) { /* translators: %d: count */ $h .= '
  • ' . esc_html( sprintf( __( 'and %d more', 'cloudscale-cyber-devtools' ), count( $rows ) - self::LIST_MAX ) ) . '
  • '; } $h .= '
'; } $h .= '
'; } if ( '' !== $admin ) { $h .= '

' . esc_html__( 'Open Cyber DevTools', 'cloudscale-cyber-devtools' ) . '

'; } $h .= '

' . esc_html( __( 'Sent by:', 'cloudscale-cyber-devtools' ) . ' ' . $sender ) . '. ' . esc_html__( 'Turn this report off or change its day on the Alerts tab.', 'cloudscale-cyber-devtools' ) . '

'; $h .= '
'; return $h; } /** * A risk rating's colour and words. An empty or unknown rating is amber "Unrated", never * green: a rating nobody measured is not a low one. * * @param string $risk low, medium, high or critical. * @return array{0:string,1:string} Colour key into RAG, and label. */ private static function risk( string $risk ): array { switch ( $risk ) { case 'low': return array( 'green', __( 'Low risk', 'cloudscale-cyber-devtools' ) ); case 'medium': return array( 'amber', __( 'Medium risk', 'cloudscale-cyber-devtools' ) ); case 'high': return array( 'red', __( 'High risk', 'cloudscale-cyber-devtools' ) ); case 'critical': return array( 'red', __( 'Critical risk', 'cloudscale-cyber-devtools' ) ); default: return array( 'amber', __( 'Unrated', 'cloudscale-cyber-devtools' ) ); } } /** * An exposed folders hit type's risk: low, medium or high. * * @param string $type Hit type from CSDT_Dir_Probe. * @return string */ private static function exposed_risk( string $type ): string { if ( in_array( $type, self::EXPOSED_LOW, true ) ) { return 'low'; } return in_array( $type, self::EXPOSED_MEDIUM, true ) ? 'medium' : 'high'; } /** * The colour of the worst rated row: red beats amber beats green. No rows is green. * * @param array $rows Rows with a 'risk' key. * @return string red, amber or green. */ private static function worst_rag( array $rows ): string { $worst = 'green'; foreach ( $rows as $row ) { $c = self::risk( (string) ( $row['risk'] ?? '' ) )[0]; if ( 'red' === $c ) { return 'red'; } if ( 'amber' === $c ) { $worst = 'amber'; } } return $worst; } /** * One list row as HTML: a plain string, or text followed by a coloured risk pill. * * @param mixed $row String, or array with 'text' and 'risk'. * @return string Escaped HTML. */ private static function row_html( $row ): string { if ( ! is_array( $row ) ) { return esc_html( (string) $row ); } $risk = self::risk( (string) ( $row['risk'] ?? '' ) ); $c = self::RAG[ $risk[0] ]; return esc_html( (string) ( $row['text'] ?? '' ) ) . ' ' . esc_html( $risk[1] ) . ''; } /** * Plain text version, for the webhook. * * @param string $period Period line. * @param array $sections Sections. * @param string $sender Site line. * @return string */ private static function render_text( string $period, array $sections, string $sender ): string { $lines = array( $period, '' ); foreach ( $sections as $s ) { $lines[] = ( 'red' === ( $s['rag'] ?? '' ) ? '! ' : '' ) . (string) $s['title'] . ': ' . (string) $s['summary']; foreach ( array_slice( (array) $s['rows'], 0, 5 ) as $row ) { $risk = is_array( $row ) ? self::risk( (string) ( $row['risk'] ?? '' ) ) : null; $lines[] = ' - ' . ( is_array( $row ) ? (string) ( $row['text'] ?? '' ) : (string) $row ) . ( null !== $risk ? ' ' . $risk[1] . '.' : '' ); } if ( count( (array) $s['rows'] ) > 5 ) { $lines[] = ' - ...'; } } $lines[] = ''; $lines[] = 'Sent by: ' . $sender; return implode( "\n", $lines ); } // ── Admin ──────────────────────────────────────────────────────────── /** Saves the settings. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $on = '1' === sanitize_key( wp_unslash( $_POST['enabled'] ?? '0' ) ); $day = (int) sanitize_key( wp_unslash( $_POST['day'] ?? '1' ) ); $to = sanitize_email( wp_unslash( $_POST['to'] ?? '' ) ); if ( '' !== sanitize_text_field( wp_unslash( $_POST['to'] ?? '' ) ) && '' === $to ) { wp_send_json_error( __( 'That is not a valid email address.', 'cloudscale-cyber-devtools' ) ); } update_option( self::OPTION_ENABLED, $on ? '1' : '0', false ); update_option( self::OPTION_DAY, (string) ( ( $day >= 0 && $day <= 6 ) ? $day : 1 ), false ); update_option( self::OPTION_TO, $to, false ); self::sync_schedule(); wp_send_json_success( array( 'msg' => $on ? __( 'Saved. The weekly report is on.', 'cloudscale-cyber-devtools' ) : __( 'Saved. The weekly report is off.', 'cloudscale-cyber-devtools' ), ) ); } /** Sends a report now. */ public static function ajax_test(): void { check_ajax_referer( CSDT_DevTools::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $r = self::send( true ); if ( $r['ok'] ) { wp_send_json_success( array( 'msg' => $r['msg'] ) ); } wp_send_json_error( $r['msg'] ); } /** Renders the panel on the Alerts tab. */ public static function render_panel(): void { $on = self::is_enabled(); $day = self::day(); $to = (string) CSDT_Options::get( self::OPTION_TO ); $last = get_option( self::OPTION_LAST, array() ); $last = is_array( $last ) ? $last : array(); $muted = self::is_muted(); $days = array( 0 => __( 'Sunday', 'cloudscale-cyber-devtools' ), 1 => __( 'Monday', 'cloudscale-cyber-devtools' ), 2 => __( 'Tuesday', 'cloudscale-cyber-devtools' ), 3 => __( 'Wednesday', 'cloudscale-cyber-devtools' ), 4 => __( 'Thursday', 'cloudscale-cyber-devtools' ), 5 => __( 'Friday', 'cloudscale-cyber-devtools' ), 6 => __( 'Saturday', 'cloudscale-cyber-devtools' ), ); ?>
📊 'What it contains', 'rec' => 'How it works', 'html' => 'The last seven days: firewall mode and blocks by rule, addresses auto-blocked, failed sign-ins, new administrator accounts, vulnerable plugins and themes still installed (with the version that fixes each), file integrity and persistence-file changes, folders and files the Exposed folders check found served to visitors (when that check is off, the report says so in red and recommends switching it on in the AI Cyber Audit panel), the last scan score and date, and updates waiting. Anything that needs action is marked in red. A source this site cannot read says unavailable, never zero.', ), array( 'name' => 'Send weekly report', 'rec' => 'On by default', 'html' => 'It reads data this plugin already keeps and sends one email to your own address. Nothing leaves the site except that email, and the webhook if you subscribed it to the weekly report.', ), array( 'name' => 'Day', 'rec' => 'How it works', 'html' => 'The report goes out once on this day, in the site timezone, when WordPress cron next runs. A site with little traffic may send it a few hours into the day.', ), array( 'name' => 'Send to', 'rec' => 'Optional', 'html' => 'Leave blank to use the Email address in Notification Channels above, or the WordPress admin email when that is blank too.', ), array( 'name' => 'Send a test report now', 'rec' => 'How it works', 'html' => 'Builds this week\'s report and sends it immediately, whatever the day. It does not move the weekly schedule.', ), array( 'name' => 'Restored copies', 'rec' => 'Honest limits', 'html' => 'A copy with alerts muted sends no report, and a standby copy does not schedule one, because its numbers would describe the live site.', ), ) ); ?>