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.
333 lines
11 KiB
PHP
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 ) );
|
|
}
|
|
}
|