Real changes since 1.0.0, all live-verified before this release: - Firewall: rule corpus expanded 19 -> 43 rules, real OWASP-CRS-equivalent coverage (XXE, SSRF, session fixation, Log4Shell/JNDI, scanner-tool detection, deeper SQL injection/XSS/PHP-injection). - Fixed a real bug: a quarantined file's severity badge and its content analysis score could disagree with no explanation (e.g. a benign file showing CRITICAL next to Score 0); both are now derived consistently and shown together. - ARGUS now always keeps itself updated, and can optionally do the same for every other installed plugin and theme (Settings, on by default) -- uses WordPress's own native update system, nothing custom. - Global Threat Intelligence is now opt-in, not automatic -- a single click on its own page, with an honest, specific description of exactly what's shared (an IP address, a reason code, a confidence score, a country). Previously connected automatically on activation. - New first-run Welcome screen after activation: confirms what's already protecting the site, and surfaces the few real optional choices in one place. - Dashboard: running version now visible in the header; new "IPs Tracked" and "ANIS Protections" metrics. - Full WordPress.org Plugin Directory readiness audit performed against this codebase. Two real compliance issues found and fixed (see above: Global Threat Intelligence's default, and the self-update mechanism, which is excluded from this build entirely -- WordPress.org prohibits a plugin from using any update channel other than its own, even an inert one). This release is still self-distributed, not a WordPress.org submission -- that remains a future step. Verified before publishing: this exact ZIP was installed, activated (14 admin pages loaded clean, zero PHP errors/warnings), and uninstalled (zero leftover database tables or options) in a fresh, disposable WordPress + MySQL environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
340 lines
14 KiB
PHP
340 lines
14 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Malware_Scanner {
|
|
|
|
const MAX_FILE_BYTES = 2 * MB_IN_BYTES;
|
|
|
|
protected static function heuristics() {
|
|
return array(
|
|
array( 'id' => 'eval-dynamic-decode', 'weight' => 40,
|
|
'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|convert_uudecode)\s*\(/i',
|
|
'label' => 'eval() wrapping a decode/decompress function -- the single most common webshell obfuscation pattern' ),
|
|
array( 'id' => 'superglobal-into-exec', 'weight' => 40,
|
|
'pattern' => '/\b(eval|assert|system|exec|shell_exec|passthru|proc_open)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i',
|
|
'label' => 'A request superglobal ($_POST/$_GET/etc.) passed directly into a code-execution function' ),
|
|
array( 'id' => 'assert-as-eval', 'weight' => 25,
|
|
'pattern' => '/\bassert\s*\(\s*[\'"]/i',
|
|
'label' => 'assert() called with a string argument -- a legacy PHP trick equivalent to eval()' ),
|
|
array( 'id' => 'dangerous-function-present', 'weight' => 15,
|
|
'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i',
|
|
'label' => 'A shell-execution function is present in the file' ),
|
|
array( 'id' => 'variable-variable-obfuscation', 'weight' => 15,
|
|
'pattern' => '/\$\{\s*[\'"]\w+[\'"]\s*\}|\$\$\w+/',
|
|
'label' => 'Variable-variable syntax -- a common obfuscation technique to hide function/variable names from simple text search' ),
|
|
array( 'id' => 'large-base64-blob', 'weight' => 20,
|
|
'pattern' => '/[A-Za-z0-9+\/]{800,}={0,2}/',
|
|
'label' => 'A very large base64-like blob is embedded in the file' ),
|
|
array( 'id' => 'create-function', 'weight' => 15,
|
|
'pattern' => '/\bcreate_function\s*\(/i',
|
|
'label' => 'create_function() -- deprecated since PHP 7.2, rarely used except to obscure dynamically-generated code' ),
|
|
array( 'id' => 'file-write-from-request', 'weight' => 20,
|
|
'pattern' => '/\b(file_put_contents|fwrite)\s*\([^,]+,\s*\$_(POST|GET|REQUEST)/i',
|
|
'label' => 'Writes request data directly to a file -- consistent with a dropper/uploader shell' ),
|
|
);
|
|
}
|
|
|
|
const THRESHOLD_HIGH = 55;
|
|
const THRESHOLD_MEDIUM = 30;
|
|
|
|
const QUARANTINE_SUFFIX = '.argus-quarantined';
|
|
const HTACCESS_MARKER = 'ARGUS WordPress Defence -- deny PHP execution (ADR-0053 §9.1)';
|
|
const RESTORED_TRUST_OPTION = 'argus_wpd_malware_restored_trust';
|
|
|
|
const SCAN_WATERMARK_OPTION = 'argus_wpd_malware_watermark';
|
|
|
|
const LOCKDOWN_STATUS_OPTION = 'argus_wpd_uploads_lockdown_status';
|
|
|
|
public static function mark_restored_trusted( $rel_path, $hash ) {
|
|
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
|
if ( ! is_array( $trust ) ) {
|
|
$trust = array();
|
|
}
|
|
$trust[ $rel_path ] = $hash;
|
|
update_option( self::RESTORED_TRUST_OPTION, $trust, false );
|
|
}
|
|
|
|
protected static function is_restored_trusted( $rel_path, $hash ) {
|
|
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
|
return is_array( $trust ) && isset( $trust[ $rel_path ] ) && hash_equals( $trust[ $rel_path ], $hash );
|
|
}
|
|
|
|
public static function incremental_scan() {
|
|
self::ensure_uploads_lockdown();
|
|
self::scan_uploads_for_php();
|
|
self::scan_recently_changed();
|
|
self::rescan_restored_trust();
|
|
}
|
|
|
|
public static function ensure_uploads_lockdown() {
|
|
$uploads = wp_get_upload_dir();
|
|
$base = $uploads['basedir'] ?? null;
|
|
if ( ! $base || ! is_dir( $base ) || ! is_writable( $base ) ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false );
|
|
return false;
|
|
}
|
|
|
|
$path = trailingslashit( $base ) . '.htaccess';
|
|
|
|
$rule = "# " . self::HTACCESS_MARKER . "\n"
|
|
. "<IfModule mod_php.c>\n\tphp_flag engine off\n</IfModule>\n"
|
|
. "<IfModule mod_php7.c>\n\tphp_flag engine off\n</IfModule>\n"
|
|
. "<FilesMatch \"\\.ph(p[3457]?|t|tml)\">\n\tRequire all denied\n</FilesMatch>\n";
|
|
|
|
$existing = file_exists( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
$healthy = is_string( $existing )
|
|
&& false !== strpos( $existing, self::HTACCESS_MARKER )
|
|
&& false !== strpos( $existing, '\\.ph(p[3457]?|t|tml)"' );
|
|
|
|
if ( ! $healthy ) {
|
|
|
|
$preserved = is_string( $existing ) ? $existing : '';
|
|
$preserved = (string) preg_replace(
|
|
'/# ' . preg_quote( self::HTACCESS_MARKER, '/' ) . '.*?<\/FilesMatch>\n?/s',
|
|
'',
|
|
$preserved
|
|
);
|
|
|
|
$written = file_put_contents( $path, $rule . $preserved ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $written ) {
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false );
|
|
return false;
|
|
}
|
|
}
|
|
|
|
update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false );
|
|
return true;
|
|
}
|
|
|
|
public static function lockdown_status() {
|
|
return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' );
|
|
}
|
|
|
|
protected static function scan_uploads_for_php() {
|
|
$uploads = wp_get_upload_dir();
|
|
$base = $uploads['basedir'] ?? null;
|
|
if ( ! $base || ! is_dir( $base ) ) {
|
|
return;
|
|
}
|
|
|
|
$data_dir = wp_normalize_path( trailingslashit( $base ) . 'argus-wpd-data' );
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
|
|
RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
|
|
foreach ( $iterator as $file ) {
|
|
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
|
continue;
|
|
}
|
|
$path = $file->getPathname();
|
|
|
|
if ( 0 === strpos( wp_normalize_path( $path ), $data_dir ) ) {
|
|
continue;
|
|
}
|
|
|
|
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
|
|
|
if ( self::is_restored_trusted( $rel, hash_file( 'sha256', $path ) ) ) {
|
|
continue;
|
|
}
|
|
|
|
$size = filesize( $path );
|
|
$mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) );
|
|
|
|
// The detection rule itself ("a PHP file exists inside uploads") is
|
|
// location-based and unconditional -- WordPress never executes PHP
|
|
// there by design, so any match is quarantined regardless of content.
|
|
// The content heuristic score is a SEPARATE signal layered on top,
|
|
// used only to set an honest severity/verdict -- never to skip
|
|
// quarantining. This keeps "rule matched" (always true here),
|
|
// "analysis score" (score_content()'s real number), and "verdict"
|
|
// (the severity below) from contradicting each other in the UI, e.g.
|
|
// a benign defensive stub scoring 0 must never be labeled CRITICAL.
|
|
$content = is_readable( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
list( $content_score, $matched ) = false !== $content ? self::score_content( $content ) : array( 0, array() );
|
|
|
|
if ( $content_score >= self::THRESHOLD_HIGH ) {
|
|
$final_severity = 'critical';
|
|
$verdict = 'Its content strongly matches known webshell/backdoor patterns.';
|
|
} elseif ( $content_score >= self::THRESHOLD_MEDIUM ) {
|
|
$final_severity = 'high';
|
|
$verdict = 'Its content contains some patterns also seen in malicious files, though less conclusively.';
|
|
} else {
|
|
$final_severity = 'medium';
|
|
$verdict = 'No suspicious code patterns were found in its content -- it has been quarantined as a precaution because a PHP file in uploads is itself unusual, not because of what it contains.';
|
|
}
|
|
|
|
$result = Argus_Quarantine::quarantine_file(
|
|
$path,
|
|
$rel,
|
|
array(
|
|
'engine' => 'Argus_Malware_Scanner',
|
|
'rule' => 'php-in-uploads',
|
|
'type' => 'signature',
|
|
'severity' => $final_severity,
|
|
'content_score' => $content_score,
|
|
'matched_rules' => wp_list_pluck( $matched, 'id' ),
|
|
)
|
|
);
|
|
$neutralized = false !== $result;
|
|
|
|
$finding_id = Argus_Findings::record(
|
|
'malware',
|
|
$final_severity,
|
|
array(
|
|
'what_happened' => 'A PHP file was found inside the uploads directory: ' . $rel,
|
|
'why_it_matters' => 'WordPress does not execute PHP inside wp-content/uploads by design -- a legitimate install should never have PHP files there.',
|
|
'what_argus_found' => sprintf(
|
|
'File: %1$s, size %2$s bytes, last modified %3$s. Detection rule "php-in-uploads" matched (location-based, always fires on any .php file here). Content analysis score: %4$d/100%5$s.',
|
|
$rel,
|
|
$size,
|
|
$mtime,
|
|
$content_score,
|
|
$matched ? ' (matched: ' . implode( ', ', wp_list_pluck( $matched, 'id' ) ) . ')' : ' (no heuristic patterns matched)'
|
|
),
|
|
'when_it_happened' => current_time( 'mysql' ),
|
|
'why_suspicious' => 'This is one of the most common webshell placement techniques -- uploads directories are usually writable by the web application, unlike wp-admin/wp-includes. ' . $verdict,
|
|
'what_could_be_affected' => 'A PHP file here can be executed directly by visiting its URL, potentially giving an attacker full code execution on your server.',
|
|
'what_should_you_do' => $neutralized
|
|
? 'This file has been quarantined -- moved to a protected, non-web-accessible store. Review it on the Quarantine page (its content analysis score is shown there) and decide whether to restore or permanently delete it.'
|
|
: 'ARGUS detected this file but could not quarantine it (the uploads directory is not writable by PHP on this host, or it is a symlink ARGUS refuses to move automatically). Remove it manually and investigate how it got there.',
|
|
),
|
|
array(
|
|
'quarantine_id' => $neutralized ? $result['id'] : null,
|
|
'file_path' => $rel,
|
|
'file_mtime' => $mtime,
|
|
'neutralized' => $neutralized,
|
|
'content_score' => $content_score,
|
|
),
|
|
Argus_Findings::STATUS_OPEN
|
|
);
|
|
|
|
if ( $neutralized ) {
|
|
global $wpdb;
|
|
$wpdb->update(
|
|
Argus_DB::table( 'quarantine' ),
|
|
array( 'finding_id' => $finding_id ),
|
|
array( 'id' => $result['id'] ),
|
|
array( '%d' ),
|
|
array( '%d' )
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected static function scan_recently_changed() {
|
|
$since = (int) get_option( self::SCAN_WATERMARK_OPTION, time() - HOUR_IN_SECONDS );
|
|
$own_dir = wp_normalize_path( ARGUS_WPD_DIR );
|
|
|
|
foreach ( array( WP_PLUGIN_DIR, get_theme_root(), ABSPATH . 'wp-admin', ABSPATH . WPINC ) as $dir ) {
|
|
if ( ! is_dir( $dir ) ) {
|
|
continue;
|
|
}
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
|
|
RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
foreach ( $iterator as $file ) {
|
|
if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
|
|
continue;
|
|
}
|
|
$path = wp_normalize_path( $file->getPathname() );
|
|
|
|
// ARGUS never treats its own installed files as a finding about
|
|
// themselves -- its test fixtures legitimately contain literal
|
|
// attack-pattern strings (e.g. eval(base64_decode(...)) samples),
|
|
// and its own WAF/scanner rule definitions legitimately contain
|
|
// the same keywords as regex source. Genuine tampering with
|
|
// ARGUS's own code is a different threat model (an already-
|
|
// compromised server) that a self-scan wouldn't meaningfully
|
|
// catch anyway. Its own supply-chain integrity is covered by
|
|
// WordPress.org's review/signing and its own forced self-update.
|
|
if ( 0 === strpos( $path, $own_dir ) ) {
|
|
continue;
|
|
}
|
|
|
|
if ( $file->getMTime() < $since ) {
|
|
continue;
|
|
}
|
|
self::scan_file( $file->getPathname() );
|
|
}
|
|
}
|
|
|
|
update_option( self::SCAN_WATERMARK_OPTION, time(), false );
|
|
}
|
|
|
|
protected static function rescan_restored_trust() {
|
|
$trust = get_option( self::RESTORED_TRUST_OPTION, array() );
|
|
if ( ! is_array( $trust ) || empty( $trust ) ) {
|
|
return;
|
|
}
|
|
|
|
foreach ( $trust as $rel_path => $known_hash ) {
|
|
$abs = ABSPATH . ltrim( $rel_path, '/' );
|
|
if ( ! is_readable( $abs ) ) {
|
|
continue;
|
|
}
|
|
if ( hash_file( 'sha256', $abs ) === $known_hash ) {
|
|
continue;
|
|
}
|
|
self::scan_file( $abs );
|
|
}
|
|
}
|
|
|
|
public static function score_content( $content ) {
|
|
$score = 0;
|
|
$matched = array();
|
|
|
|
foreach ( self::heuristics() as $rule ) {
|
|
if ( preg_match( $rule['pattern'], $content ) ) {
|
|
$score += $rule['weight'];
|
|
$matched[] = $rule;
|
|
}
|
|
}
|
|
|
|
return array( $score, $matched );
|
|
}
|
|
|
|
public static function scan_file( $path ) {
|
|
if ( ! is_readable( $path ) || filesize( $path ) > self::MAX_FILE_BYTES ) {
|
|
return;
|
|
}
|
|
|
|
$content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $content ) {
|
|
return;
|
|
}
|
|
|
|
list( $score, $matched ) = self::score_content( $content );
|
|
|
|
if ( $score < self::THRESHOLD_MEDIUM ) {
|
|
return;
|
|
}
|
|
|
|
$severity = $score >= self::THRESHOLD_HIGH ? 'critical' : 'medium';
|
|
$rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) );
|
|
$labels = wp_list_pluck( $matched, 'label' );
|
|
|
|
Argus_Findings::record(
|
|
'malware',
|
|
$severity,
|
|
array(
|
|
'what_happened' => sprintf( 'Suspicious PHP file detected: %s (heuristic score %d)', $rel, $score ),
|
|
'why_it_matters' => 'This file contains patterns strongly associated with webshells or backdoors -- code designed to give an attacker persistent, hidden access to your server.',
|
|
'what_argus_found' => implode( '; ', $labels ),
|
|
'when_it_happened' => current_time( 'mysql' ),
|
|
'why_suspicious' => 'These are static-analysis heuristics, not a claim that this file is definitely malicious -- some are also occasionally present in legitimate advanced plugins. ARGUS shows you exactly what matched so you can judge it yourself.',
|
|
'what_could_be_affected' => 'If this file is genuinely malicious, an attacker could have code execution on your server through it.',
|
|
'what_should_you_do' => 'Review the file manually. If you don\'t recognize it or can\'t explain why it exists, remove it and investigate how it got there (check recent file changes, plugin/theme sources, and user activity).',
|
|
),
|
|
array( 'file_path' => $rel, 'score' => $score, 'matched_rules' => wp_list_pluck( $matched, 'id' ), 'file_mtime' => gmdate( 'Y-m-d H:i:s', filemtime( $path ) ) )
|
|
);
|
|
}
|
|
}
|