Files
argus-wp-defence/includes/class-argus-integrity.php
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
Automatic WordPress security: local firewall, malware and file-integrity
scanning, vulnerability protection, quarantine, scheduled backups, an
optional page cache, and automatic global threat intelligence.

See README.md for installation, update, and uninstall instructions.
2026-08-09 13:40:16 +00:00

333 lines
11 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Argus_Integrity {
const CATEGORY_CORE = 'core';
const CATEGORY_PLUGIN = 'plugin';
const CATEGORY_THEME = 'theme';
const CATEGORY_CONFIG = 'config';
const WATERMARK_OPTION = 'argus_wpd_integrity_watermark';
const TRUSTED_WINDOWS_OPTION = 'argus_wpd_trusted_update_windows';
const TRUSTED_WINDOW_TTL = 600;
const LAST_FULL_SCAN_OPTION = 'argus_wpd_integrity_last_full_scan';
const LAST_FULL_SCAN_FILES_OPTION = 'argus_wpd_integrity_last_full_scan_files';
public static function on_upgrader_complete( $upgrader, $hook_extra ) {
if ( empty( $hook_extra['action'] ) || 'update' !== $hook_extra['action'] ) {
return;
}
$prefixes = array();
switch ( $hook_extra['type'] ?? '' ) {
case 'core':
$prefixes[] = 'wp-admin/';
$prefixes[] = WPINC . '/';
break;
case 'plugin':
foreach ( (array) ( $hook_extra['plugins'] ?? array( $hook_extra['plugin'] ?? '' ) ) as $plugin_file ) {
if ( $plugin_file ) {
$prefixes[] = 'wp-content/plugins/' . strtok( $plugin_file, '/' ) . '/';
}
}
break;
case 'theme':
foreach ( (array) ( $hook_extra['themes'] ?? array( $hook_extra['theme'] ?? '' ) ) as $theme_slug ) {
if ( $theme_slug ) {
$prefixes[] = 'wp-content/themes/' . $theme_slug . '/';
}
}
break;
}
if ( empty( $prefixes ) ) {
return;
}
self::trust_prefixes( $prefixes );
}
protected static function trust_prefixes( array $prefixes ) {
$windows = get_option( self::TRUSTED_WINDOWS_OPTION, array() );
if ( ! is_array( $windows ) ) {
$windows = array();
}
$until = time() + self::TRUSTED_WINDOW_TTL;
foreach ( $prefixes as $prefix ) {
$windows[ $prefix ] = $until;
}
update_option( self::TRUSTED_WINDOWS_OPTION, $windows, false );
}
protected static function is_trusted_change( $rel_path ) {
$windows = get_option( self::TRUSTED_WINDOWS_OPTION, array() );
if ( ! is_array( $windows ) || empty( $windows ) ) {
return false;
}
$now = time();
$trusted = false;
$pruned = array();
foreach ( $windows as $prefix => $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 ) );
}
}