$until ) { if ( $until < $now ) { continue; } $pruned[ $prefix ] = $until; if ( ! $trusted && 0 === strpos( $rel_path, $prefix ) ) { $trusted = true; } } if ( count( $pruned ) !== count( $windows ) ) { update_option( self::TRUSTED_WINDOWS_OPTION, $pruned, false ); } return $trusted; } public static function full_scan() { $file_count = self::scan( null ); self::check_core_checksums(); update_option( self::WATERMARK_OPTION, time(), false ); update_option( self::LAST_FULL_SCAN_OPTION, time(), false ); update_option( self::LAST_FULL_SCAN_FILES_OPTION, $file_count, false ); } public static function incremental_scan() { $since = (int) get_option( self::WATERMARK_OPTION, 0 ); self::scan( $since ); update_option( self::WATERMARK_OPTION, time(), false ); } public static function scan_status() { $next = wp_next_scheduled( 'argus_wpd_daily' ); return array( 'last_full_scan' => (int) get_option( self::LAST_FULL_SCAN_OPTION, 0 ), 'files_scanned' => (int) get_option( self::LAST_FULL_SCAN_FILES_OPTION, 0 ), 'next_scan' => $next ? (int) $next : null, ); } protected static function scan( $mtime_since ) { global $wpdb; $targets = array( self::CATEGORY_CORE => array( ABSPATH . 'wp-admin', ABSPATH . WPINC ), self::CATEGORY_PLUGIN => array( WP_PLUGIN_DIR ), self::CATEGORY_THEME => array( get_theme_root() ), ); $seen_paths = array(); foreach ( $targets as $category => $dirs ) { foreach ( $dirs as $dir ) { if ( ! is_dir( $dir ) ) { continue; } foreach ( self::walk_php_files( $dir ) as $path ) { $mtime = filemtime( $path ); if ( null !== $mtime_since && $mtime < $mtime_since ) { $seen_paths[] = $path; continue; } self::check_file( $path, $category ); $seen_paths[] = $path; } } } if ( file_exists( ABSPATH . 'wp-config.php' ) ) { self::check_file( ABSPATH . 'wp-config.php', self::CATEGORY_CONFIG ); $seen_paths[] = ABSPATH . 'wp-config.php'; } if ( null === $mtime_since ) { self::check_deletions( $seen_paths ); } return count( $seen_paths ); } protected static function walk_php_files( $dir ) { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $iterator as $file ) { if ( $file->isFile() && 'php' === strtolower( $file->getExtension() ) ) { yield $file->getPathname(); } } } protected static function check_file( $path, $category ) { global $wpdb; $table = Argus_DB::table( 'integrity_baseline' ); $hash = hash_file( 'sha256', $path ); $size = filesize( $path ); $mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) ); $now = current_time( 'mysql', true ); $rel = self::relative_path( $path ); $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE file_path = %s", $rel ) ); // phpcs:ignore if ( ! $existing ) { $wpdb->insert( $table, array( 'file_path' => $rel, 'file_hash' => $hash, 'file_size' => $size, 'file_mtime' => $mtime, 'category' => $category, 'first_seen_at' => $now, 'last_checked_at' => $now, ), array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' ) ); if ( self::CATEGORY_CORE === $category ) { self::record_finding( 'New file appeared inside WordPress core: ' . $rel, 'high', $rel, 'WordPress core (wp-admin/wp-includes) does not normally gain new files outside of an actual WordPress update.', 'This can indicate a webshell or backdoor was planted directly into core.', 'Compare this file against a fresh WordPress download of your exact version. If you did not just update WordPress, treat this as a likely compromise and investigate immediately.', $mtime ); } Argus_Events::record( 'integrity_new_file', 'low', 'New file: ' . $rel, array( 'file_path' => $rel, 'category' => $category, 'file_mtime' => $mtime ) ); return; } if ( $existing->file_hash !== $hash ) { $wpdb->update( $table, array( 'file_hash' => $hash, 'file_size' => $size, 'file_mtime' => $mtime, 'last_checked_at' => $now ), array( 'id' => $existing->id ), array( '%s', '%d', '%s', '%s' ), array( '%d' ) ); Argus_Events::record( 'integrity_changed_file', 'low', 'Changed file: ' . $rel, array( 'file_path' => $rel, 'category' => $category, 'file_mtime' => $mtime ) ); $trusted = self::is_trusted_change( $rel ); self::record_finding( sprintf( '%s file changed: %s', ucfirst( $category ), $rel ), self::CATEGORY_CORE === $category ? 'high' : 'medium', $rel, self::CATEGORY_CORE === $category ? 'WordPress core files should only change during an official WordPress update.' : 'Unexpected changes to plugin/theme files can indicate a compromise, a manual edit that will be lost on the next update, or supply-chain tampering.', 'The file\'s content hash no longer matches what ARGUS last recorded for it.', 'If this file is part of an application (theme/plugin), changes here can affect every visitor and every other user of the site.', $trusted ? 'No action needed -- this change was recorded during a WordPress-initiated update of this exact file (core/plugin/theme updater), so ARGUS resolved it automatically.' : 'If you made this change deliberately (a manual edit, a WordPress/plugin update), no action is needed. Otherwise, compare it against the original source and investigate.', null, $trusted ); } else { $wpdb->update( $table, array( 'last_checked_at' => $now ), array( 'id' => $existing->id ), array( '%s' ), array( '%d' ) ); } } protected static function check_deletions( array $seen_paths ) { global $wpdb; $table = Argus_DB::table( 'integrity_baseline' ); $seen_rel = array_map( array( __CLASS__, 'relative_path' ), $seen_paths ); $known = $wpdb->get_results( "SELECT id, file_path, category FROM {$table}" ); // phpcs:ignore foreach ( $known as $row ) { if ( in_array( $row->file_path, $seen_rel, true ) ) { continue; } $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); if ( self::CATEGORY_CORE === $row->category ) { self::record_finding( 'WordPress core file was deleted: ' . $row->file_path, 'high', $row->file_path, 'A missing core file can break site functionality or be a sign of tampering.', 'This file was previously recorded and is no longer present.', 'Restore this file from a fresh WordPress download of your exact version, or reinstall WordPress core files.' ); } } } protected static function check_core_checksums() { global $wp_version; $response = wp_remote_get( sprintf( 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=en_US', rawurlencode( $wp_version ) ), array( 'timeout' => 8 ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $checksums = $body['checksums'][ $wp_version ] ?? null; if ( ! is_array( $checksums ) ) { return; } foreach ( $checksums as $rel_path => $official_hash ) { $abs = ABSPATH . $rel_path; if ( ! file_exists( $abs ) || 0 === strpos( $rel_path, 'wp-content/' ) ) { continue; } if ( md5_file( $abs ) !== $official_hash ) { self::record_finding( 'Core file does not match the official WordPress.org checksum: ' . $rel_path, 'critical', $rel_path, 'This file differs from the exact bytes WordPress.org publishes for your installed version.', sprintf( 'Cross-checked against api.wordpress.org\'s official checksum feed for WordPress %s.', $wp_version ), 'This is a strong tampering signal, not just a local drift. Restore this file from a fresh, official WordPress download immediately.' ); } } } protected static function record_finding( $what_happened, $severity, $path, $why_it_matters, $what_argus_found, $what_should_you_do, $mtime = null, $auto_resolved = false ) { Argus_Findings::record( 'integrity', $severity, array( 'what_happened' => $what_happened, 'why_it_matters' => $why_it_matters, 'what_argus_found' => $what_argus_found, 'when_it_happened' => current_time( 'mysql' ), 'why_suspicious' => 'ARGUS maintains its own baseline of every core, plugin, and theme file and flags anything that changes outside of a WordPress-initiated update.', 'what_could_be_affected' => 'Any visitor or user interacting with the affected file\'s functionality.', 'what_should_you_do' => $what_should_you_do, ), array_filter( array( 'file_path' => $path, 'file_mtime' => $mtime ) ), $auto_resolved ? Argus_Findings::STATUS_RESOLVED : Argus_Findings::STATUS_OPEN ); } protected static function relative_path( $abs_path ) { return str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $abs_path ) ); } }