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.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
<?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 ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user