{ sha, size, mtime } for every watched file that existed at baseline time. */ const OPTION_BASELINE = 'csdt_persistence_baseline'; /** Summary of the last run: ts, watched, added, changed, removed. */ const OPTION_LAST = 'csdt_persistence_last'; /** * Monotonic count of every added, changed and removed file seen since install. The * out-of-band heartbeat sends it (signal "pw") so the receiving Worker can alert on a * change even when this site's own alert never left it. See CSDT_Uptime_Heartbeat. */ const OPTION_CHANGES = 'csdt_persistence_changes'; /** A run older than this is stale (three missed five-minute ticks). */ const STALE_AFTER = 15 * MINUTE_IN_SECONDS; /** * Drop-in file names core loads by name from wp-content, from wp-admin's * _get_dropins(). Listed here rather than read from that function because it is * only loaded in the admin and this runs from cron. */ const DROPINS = array( 'advanced-cache.php', 'db.php', 'db-error.php', 'install.php', 'maintenance.php', 'object-cache.php', 'php-error.php', 'fatal-error-handler.php', 'sunrise.php', 'blog-deleted.php', 'blog-inactive.php', 'blog-suspended.php', ); /** Whether the watch is switched on. */ public static function is_enabled(): bool { return '1' === (string) get_option( self::OPTION_ENABLED, '1' ); } /** Registers the cron schedule. Idempotent. */ public static function sync_schedule(): void { if ( ! function_exists( 'wp_next_scheduled' ) ) { return; } $hook = self::CRON_HOOK; if ( ! wp_next_scheduled( $hook ) ) { wp_schedule_event( time() + 120, 'csdt_every_5min', $hook ); } } /** * One scheduled run. Never throws. * * @return array */ public static function cron_run(): array { try { if ( ! self::is_enabled() ) { return array( 'skipped' => 'disabled' ); } return self::run_on( self::watched_paths() ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } return array( 'error' => $e->getMessage() ); } } /** * The files worth watching, as label => absolute path. * * Every path comes from CSDT_Paths, the one place allowed to know where the * install lives. A path that does not exist is still listed: its later * appearance is exactly the event to report. * * @return array */ public static function watched_paths(): array { $out = array(); $out['wp-config.php'] = CSDT_Paths::wp_config_path(); $out['.htaccess'] = CSDT_Paths::root_file( '.htaccess' ); $out['index.php'] = CSDT_Paths::root_file( 'index.php' ); // Keyed by the content folder's real name, which is "wp-content" on an ordinary site, so // existing baselines keep matching; a relocated folder is labelled as it really is. $content_label = basename( rtrim( CSDT_Paths::content_dir(), '/' ) ); $out[ $content_label . '/.htaccess' ] = rtrim( CSDT_Paths::content_dir(), '/' ) . '/.htaccess'; if ( function_exists( 'wp_upload_dir' ) ) { $up = wp_upload_dir( null, false ); if ( is_array( $up ) && ! empty( $up['basedir'] ) ) { $out['uploads/.htaccess'] = rtrim( (string) $up['basedir'], '/' ) . '/.htaccess'; } } $content = rtrim( CSDT_Paths::content_dir(), '/' ); foreach ( self::DROPINS as $dropin ) { $out[ $content_label . '/' . $dropin ] = $content . '/' . $dropin; } // Every PHP file at the top of mu-plugins, existing or not-yet: core loads exactly // those (wp_get_mu_plugins() is non-recursive), so a file dropped there runs on the // next request with no activation step. $mu = rtrim( CSDT_Paths::mu_plugins_dir(), '/' ); if ( is_dir( $mu ) ) { $entries = scandir( $mu ); if ( is_array( $entries ) ) { foreach ( $entries as $entry ) { if ( '.' === $entry[0] || 'php' !== strtolower( pathinfo( $entry, PATHINFO_EXTENSION ) ) ) { continue; } $out[ 'mu-plugins/' . $entry ] = $mu . '/' . $entry; } } } return $out; } /** * Hash every listed path that exists. * * @param array $paths label => absolute path. * @return array label => facts, present files only. */ public static function snapshot( array $paths ): array { $out = array(); foreach ( $paths as $label => $path ) { if ( ! is_file( $path ) || ! is_readable( $path ) ) { continue; } clearstatcache( true, $path ); $sha = hash_file( 'sha256', $path ); if ( false === $sha ) { continue; } $out[ (string) $label ] = array( 'sha' => $sha, 'size' => (int) filesize( $path ), 'mtime' => (int) filemtime( $path ), ); } return $out; } /** * What changed between two snapshots. * * Removal is a change: a drop-in that vanishes is as much an event as one that * appears (an attacker cleaning up, or a caching plugin's object-cache.php gone * with the plugin, which the owner still wants to know about). * * @param array $baseline Earlier snapshot. * @param array $current Current snapshot. * @return array{added:array,changed:array,removed:array} */ public static function diff( array $baseline, array $current ): array { $added = array(); $changed = array(); $removed = array(); foreach ( $current as $label => $facts ) { if ( ! isset( $baseline[ $label ] ) ) { $added[] = (string) $label; } elseif ( (string) ( $baseline[ $label ]['sha'] ?? '' ) !== (string) ( $facts['sha'] ?? '' ) ) { $changed[] = (string) $label; } } foreach ( $baseline as $label => $facts ) { if ( ! isset( $current[ $label ] ) ) { $removed[] = (string) $label; } } return array( 'added' => $added, 'changed' => $changed, 'removed' => $removed, ); } /** * Snapshot, compare, report, re-baseline. * * @param array $paths label => absolute path. * @return array Summary, also stored in OPTION_LAST. */ public static function run_on( array $paths ): array { $current = self::snapshot( $paths ); $baseline = get_option( self::OPTION_BASELINE, array() ); $baseline = is_array( $baseline ) ? $baseline : array(); $now = time(); if ( empty( $baseline ) ) { update_option( self::OPTION_BASELINE, $current, false ); $summary = array( 'ts' => $now, 'watched' => count( $paths ), 'present' => count( $current ), 'baselined' => true, 'added' => array(), 'changed' => array(), 'removed' => array(), ); update_option( self::OPTION_LAST, $summary, false ); return $summary; } $d = self::diff( $baseline, $current ); $summary = array( 'ts' => $now, 'watched' => count( $paths ), 'present' => count( $current ), 'baselined' => false, 'added' => $d['added'], 'changed' => $d['changed'], 'removed' => $d['removed'], ); if ( ! empty( $d['added'] ) || ! empty( $d['changed'] ) || ! empty( $d['removed'] ) ) { // Re-baseline FIRST, so a failure to alert cannot cause the same change to // be reported on every tick until somebody notices the log. update_option( self::OPTION_BASELINE, $current, false ); update_option( self::OPTION_CHANGES, self::change_count() + count( $d['added'] ) + count( $d['changed'] ) + count( $d['removed'] ), false ); self::report( $d, $current, $baseline ); // A new or changed must-use plugin is an Emergency Lockdown signal (report only by // default). After the report, so the owner's alert never waits on it. if ( class_exists( 'CSDT_Lockdown' ) ) { CSDT_Lockdown::on_persistence_changed( $d ); } } update_option( self::OPTION_LAST, $summary, false ); return $summary; } /** * Alert and log one set of changes. * * @param array> $d diff() output. * @param array $current Current snapshot. * @param array $baseline Previous snapshot. * @return void */ private static function report( array $d, array $current, array $baseline ): void { $lines = array(); foreach ( $d['added'] as $label ) { $lines[] = sprintf( 'ADDED %s (%d bytes, sha256 %s)', $label, (int) ( $current[ $label ]['size'] ?? 0 ), substr( (string) ( $current[ $label ]['sha'] ?? '' ), 0, 16 ) ); } foreach ( $d['changed'] as $label ) { $lines[] = sprintf( 'CHANGED %s (%d -> %d bytes, sha256 %s -> %s)', $label, (int) ( $baseline[ $label ]['size'] ?? 0 ), (int) ( $current[ $label ]['size'] ?? 0 ), substr( (string) ( $baseline[ $label ]['sha'] ?? '' ), 0, 16 ), substr( (string) ( $current[ $label ]['sha'] ?? '' ), 0, 16 ) ); } foreach ( $d['removed'] as $label ) { $lines[] = sprintf( 'REMOVED %s', $label ); } $object = implode( ', ', array_merge( $d['added'], $d['changed'], $d['removed'] ) ); if ( class_exists( 'CSDT_Activity_Log' ) && method_exists( 'CSDT_Activity_Log', 'record' ) ) { CSDT_Activity_Log::record( 'persistence_changed', $object, sprintf( '%d added, %d changed, %d removed', count( $d['added'] ), count( $d['changed'] ), count( $d['removed'] ) ), 0 ); } if ( ! is_callable( array( 'CSDT_Threat_Monitor', 'send_threat_alert' ) ) ) { return; } $site = function_exists( 'get_bloginfo' ) ? ( wp_specialchars_decode( (string) ( get_bloginfo( 'name' ) ?: home_url() ), ENT_QUOTES ) ) : ''; $url = function_exists( 'admin_url' ) ? admin_url( 'admin.php?page=cloudscale-cyber-devtools&tab=security' ) : ''; CSDT_Threat_Monitor::send_threat_alert( sprintf( 'CSDT: 🚨 Persistence file changed: %s (%s)', $object, $site ), "A file WordPress executes on every request, outside anything a plugin or theme update touches, was added, changed or removed.\n\n" . implode( "\n", $lines ) . "\n\nIf you or your host did not make this change, treat the site as compromised: inspect the file, then rotate the secret keys in wp-config.php and every password. A new mu-plugin or drop-in runs before any plugin can see it.\n\nSecurity tab: " . $url, 'urgent', 'rotating_light,page_facing_up', $url, CSDT_Alert_Events::SECURITY ); } /** OPTION_CHANGES, 0 before the first change. */ public static function change_count(): int { return max( 0, (int) get_option( self::OPTION_CHANGES, 0 ) ); } /** * For the liveness checker. * * @return array{enabled:bool,ran:bool,fresh:bool,baselined:bool,summary:array} */ public static function status(): array { $last = get_option( self::OPTION_LAST, array() ); $last = is_array( $last ) ? $last : array(); $ts = (int) ( $last['ts'] ?? 0 ); $base = get_option( self::OPTION_BASELINE, array() ); return array( 'enabled' => self::is_enabled(), 'ran' => $ts > 0, 'fresh' => $ts > 0 && ( time() - $ts ) < self::STALE_AFTER, 'baselined' => is_array( $base ) && ! empty( $base ), 'summary' => $last, ); } }