File "class-malware-scan.php"

Full Path: /home/pylifeesyu/www/wp-content/plugins/admin-wp/includes/class-malware-scan.php
File size: 21.42 KB
MIME-type: text/x-php
Charset: utf-8

<?php
if ( ! defined( 'ABSPATH' ) ) exit;

class AWG_Malware_Scan {

    const OPT_QUARANTINE       = '_awg_malware_quarantine';
    const OPT_LAST_SCAN        = '_awg_malware_last';
    const OPT_REMOTE_WHITELIST = '_awg_malware_remote_wl';

    const SCORE_AUTO_DELETE = 80;
    const SCORE_WARNING     = 40;
    const MAX_FILE_SIZE     = 1048576; // 1 MB
    const SCAN_TIMEOUT      = 30;     // seconds

    /* ---- safe slugs: never touch these ---- */
    private static $safe_slugs = [
        'cloudflare',
        'cloudflare-turnstile',
        'advanced-ads',
        'ad-inserter',
        'google-adsense',
        'google-site-kit',
        'wordfence',
        'sucuri-scanner',
        'ithemes-security',
        'better-wp-security',
        'jetpack',
        'akismet',
        'all-in-one-wp-security-and-firewall',
        'really-simple-ssl',
        'wp-super-cache',
        'w3-total-cache',
        'litespeed-cache',
        'cookie-notice',
        'complianz-gdpr',
    ];

    /* ================================================================
     *  METADATA SIGNATURES  (slug / name / author patterns)
     * ================================================================ */

    private static $metadata_rules = [
        /* --- Fake Cloudflare --- */
        [
            'id'      => 'slug_fake_cloudflare',
            'type'    => 'fake_cloudflare',
            'score'   => 80,
            'field'   => 'slug',
            'pattern' => '/^(cloudflare|cf)[\-_]?(cdn|cache|security|waf|protect|optimize|speed|ssl|flex|dns|proxy|turnstile[\-_]?pro|turnstile[\-_]?addon|turnstile[\-_]?plus)/i',
            'safe_if' => [ 'slug' => [ 'cloudflare', 'cloudflare-turnstile' ] ],
        ],
        [
            'id'      => 'name_fake_cloudflare',
            'type'    => 'fake_cloudflare',
            'score'   => 60,
            'field'   => 'Name',
            'pattern' => '/cloudflare/i',
            'safe_if' => [ 'Author' => [ 'Cloudflare, Inc.', 'Cloudflare' ] ],
        ],

        /* --- Fake Chrome / Browser Update --- */
        [
            'id'      => 'slug_fake_chrome',
            'type'    => 'fake_chrome_update',
            'score'   => 90,
            'field'   => 'slug',
            'pattern' => '/^(chrome|browser|font)[\-_]?(update|updater|upgrade|pack|fix|patch)/i',
        ],
        [
            'id'      => 'name_fake_chrome',
            'type'    => 'fake_chrome_update',
            'score'   => 70,
            'field'   => 'Name',
            'pattern' => '/\b(chrome\s*update|browser\s*update|font\s*pack)\b/i',
        ],

        /* --- Suspicious generic names --- */
        [
            'id'      => 'slug_suspicious_helper',
            'type'    => 'suspicious_plugin',
            'score'   => 30,
            'field'   => 'slug',
            'pattern' => '/^(wp[\-_]?helper|site[\-_]?boost|speed[\-_]?fix|core[\-_]?update|index[\-_]?checker)$/i',
        ],
    ];

    /* ================================================================
     *  CODE PATTERNS  (regex on PHP source)
     * ================================================================ */

    private static $code_patterns = [
        /* score 100 — immediate threats */
        [ 'pattern' => '/\beval\s*\(\s*base64_decode\s*\(/i',                                          'score' => 100, 'id' => 'eval_base64_decode' ],
        [ 'pattern' => '/\beval\s*\(\s*gzinflate\s*\(/i',                                              'score' => 100, 'id' => 'eval_gzinflate' ],
        [ 'pattern' => '/\beval\s*\(\s*gzuncompress\s*\(/i',                                           'score' => 100, 'id' => 'eval_gzuncompress' ],
        [ 'pattern' => '/\beval\s*\(\s*str_rot13\s*\(/i',                                              'score' => 100, 'id' => 'eval_str_rot13' ],
        [ 'pattern' => '/\bassert\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i',                              'score' => 100, 'id' => 'assert_user_input' ],
        [ 'pattern' => '/preg_replace\s*\(\s*[\'"].*\/e[\s\'"]/',                                      'score' => 100, 'id' => 'preg_replace_e_modifier' ],
        [ 'pattern' => '/\b(system|exec|passthru|shell_exec|popen|proc_open)\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)/i', 'score' => 100, 'id' => 'cmd_exec_user_input' ],

        /* score 90 */
        [ 'pattern' => '/\$\w+\s*\(\s*\$_(GET|POST|REQUEST|COOKIE)\s*\[/',                             'score' => 90,  'id' => 'variable_func_user_input' ],
        [ 'pattern' => '/\bcreate_function\s*\(\s*[\'"][^\'"]*[\'"]\s*,\s*\$_(GET|POST|REQUEST|COOKIE)/i', 'score' => 90, 'id' => 'create_function_user_input' ],

        /* score 70 */
        [ 'pattern' => '/file_put_contents\s*\(.*wp-(admin|includes)/i',                               'score' => 70,  'id' => 'write_to_wp_core' ],
        [ 'pattern' => '/wp_insert_user\s*\(.*administrator|administrator.*wp_insert_user/is',          'score' => 70,  'id' => 'create_admin_user' ],
        [ 'pattern' => '/wp_create_user\s*\(.*administrator|administrator.*wp_create_user/is',          'score' => 70,  'id' => 'create_admin_user_2' ],

        /* score 50 — chrome / browser update overlay */
        [ 'pattern' => '/your\s+browser\s+is\s+out\s+of\s+date/i',                                    'score' => 50,  'id' => 'fake_browser_outdated' ],
        [ 'pattern' => '/update\s+your\s+(chrome|browser)/i',                                          'score' => 50,  'id' => 'fake_update_prompt' ],
        [ 'pattern' => '/browser\s+update\s+required/i',                                               'score' => 50,  'id' => 'fake_browser_required' ],
        [ 'pattern' => '/font\s+was\s+not\s+found/i',                                                  'score' => 50,  'id' => 'fake_font_missing' ],
        [ 'pattern' => '/HoeflerText/i',                                                               'score' => 50,  'id' => 'hoeflertext_attack' ],

        /* score 40 — banner / ad injection */
        [ 'pattern' => '/document\.write\s*\(\s*unescape/i',                                           'score' => 40,  'id' => 'doc_write_unescape' ],
        [ 'pattern' => '/(\\\\x[0-9a-fA-F]{2}){20,}/',                                                'score' => 40,  'id' => 'long_hex_encoded' ],
        [ 'pattern' => '/createElement\s*\(\s*[\'"]script[\'"]\s*\).*?(\.xyz|\.top|\.click|\.tk|\.ml|\.ga|\.cf|\.gq)/is', 'score' => 60, 'id' => 'script_inject_suspicious_tld' ],
        [ 'pattern' => '/<iframe[^>]+src\s*=\s*["\']https?:\/\/[^"\']*\.(xyz|top|click|tk|ml|ga|gq)\b/i', 'score' => 60, 'id' => 'iframe_suspicious_tld' ],

        /* score 50 — large base64 blobs */
        [ 'pattern' => '/base64_decode\s*\(\s*[\'"][A-Za-z0-9+\/=]{500,}[\'"]\s*\)/i',                 'score' => 50,  'id' => 'large_base64_blob' ],

        /* score 40 — wp_enqueue with suspicious TLDs */
        [ 'pattern' => '/wp_enqueue_script\s*\(.*?(\.xyz|\.top|\.click|\.tk|\.ml|\.ga|\.cf|\.gq)/is',  'score' => 40,  'id' => 'enqueue_suspicious_tld' ],
    ];

    /* ================================================================
     *  INIT
     * ================================================================ */

    public static function init(): void {
        /* cron — every 5 min */
        add_action( 'awg_cron_scan', [ __CLASS__, 'scan' ] );

        /* Remote whitelist push: POST /?awg_whitelist={site_secret} */
        add_action( 'init', [ __CLASS__, 'handle_remote_whitelist' ], 0 );

        /*
         * Late-priority real-time hooks.
         * Malicious plugins often register at priority 9999+ to load AFTER
         * security plugins. We hook at PHP_INT_MAX to always run last,
         * catching anything that tried to sneak in at 9999 or later.
         */
        add_action( 'plugins_loaded', [ __CLASS__, 'scan_late' ], PHP_INT_MAX );
        add_action( 'init',           [ __CLASS__, 'scan_late' ], PHP_INT_MAX );

        /* Also catch plugins that hook into wp_loaded / admin_init late */
        add_action( 'wp_loaded',   [ __CLASS__, 'scan_late' ], PHP_INT_MAX );
        add_action( 'admin_init',  [ __CLASS__, 'scan_late' ], PHP_INT_MAX );
    }

    /**
     * Lightweight real-time scan at late priority.
     * Runs at most once per request to avoid performance hit.
     */
    private static $late_scan_done = false;

    public static function scan_late(): void {
        if ( self::$late_scan_done ) {
            return;
        }
        self::$late_scan_done = true;

        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        /*
         * Only scan ACTIVE plugins in real-time (lighter than full scan).
         * Full code scan runs on cron every 5 min.
         */
        $all_plugins    = get_plugins();
        $active_plugins = (array) get_option( 'active_plugins', [] );
        $findings       = [];

        foreach ( $active_plugins as $plugin_file ) {
            if ( ! isset( $all_plugins[ $plugin_file ] ) ) continue;

            $result = self::scan_plugin( $plugin_file, $all_plugins[ $plugin_file ] );
            if ( $result !== null && $result['threat_score'] >= self::SCORE_AUTO_DELETE ) {
                $findings[] = $result;
            }
        }

        if ( ! empty( $findings ) ) {
            self::handle_findings( $findings );
        }
    }

    /* ================================================================
     *  MAIN SCAN
     * ================================================================ */

    public static function scan(): array {
        if ( ! function_exists( 'get_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $start    = time();
        $plugins  = get_plugins();
        $findings = [];

        foreach ( $plugins as $plugin_file => $plugin_data ) {
            if ( ( time() - $start ) > self::SCAN_TIMEOUT ) {
                break;
            }

            $result = self::scan_plugin( $plugin_file, $plugin_data );
            if ( $result !== null ) {
                $findings[] = $result;
            }
        }

        update_option( self::OPT_LAST_SCAN, time(), false );

        if ( ! empty( $findings ) ) {
            self::handle_findings( $findings );
        }

        return $findings;
    }

    /* ================================================================
     *  SCAN SINGLE PLUGIN
     * ================================================================ */

    private static function scan_plugin( string $plugin_file, array $plugin_data ): ?array {
        $slug = self::extract_slug( $plugin_file );

        /* never touch ourselves */
        if ( defined( 'AWG_PLUGIN_FILE' ) && plugin_basename( AWG_PLUGIN_FILE ) === $plugin_file ) {
            return null;
        }

        /* safe list */
        if ( self::is_safe( $slug, $plugin_data ) ) {
            return null;
        }

        $score         = 0;
        $matched_rules = [];
        $threat_type   = 'obfuscated_malware';

        /* --- metadata check --- */
        foreach ( self::$metadata_rules as $rule ) {
            $value = ( $rule['field'] === 'slug' ) ? $slug : ( $plugin_data[ $rule['field'] ] ?? '' );
            if ( $value === '' ) continue;

            if ( ! preg_match( $rule['pattern'], $value ) ) continue;

            /* check safe_if exceptions */
            if ( ! empty( $rule['safe_if'] ) ) {
                $is_exception = false;
                foreach ( $rule['safe_if'] as $sf_field => $sf_values ) {
                    $sf_actual = ( $sf_field === 'slug' ) ? $slug : ( $plugin_data[ $sf_field ] ?? '' );
                    if ( in_array( $sf_actual, $sf_values, true ) ) {
                        $is_exception = true;
                        break;
                    }
                }
                if ( $is_exception ) continue;
            }

            $score += $rule['score'];
            $matched_rules[] = $rule['id'];
            if ( ! empty( $rule['type'] ) ) {
                $threat_type = $rule['type'];
            }
        }

        /* --- code pattern check --- */
        $dangerous_files = [];
        $plugin_dir      = WP_PLUGIN_DIR . '/' . dirname( $plugin_file );

        if ( is_dir( $plugin_dir ) ) {
            $code_results = self::check_code( $plugin_dir );
            foreach ( $code_results as $file => $matches ) {
                foreach ( $matches as $m ) {
                    $score += $m['score'];
                    $matched_rules[] = $m['id'];
                }
                $dangerous_files[] = $file;
            }
        }

        /* single-file plugin (no subdirectory) */
        if ( dirname( $plugin_file ) === '.' ) {
            $single_file = WP_PLUGIN_DIR . '/' . $plugin_file;
            if ( is_file( $single_file ) && filesize( $single_file ) <= self::MAX_FILE_SIZE ) {
                $content = @file_get_contents( $single_file );
                if ( $content !== false ) {
                    $file_matches = self::match_code_patterns( $content );
                    foreach ( $file_matches as $m ) {
                        $score += $m['score'];
                        $matched_rules[] = $m['id'];
                    }
                    if ( ! empty( $file_matches ) ) {
                        $dangerous_files[] = $plugin_file;
                    }
                }
            }
        }

        /* deduplicate rules */
        $matched_rules = array_unique( $matched_rules );

        if ( $score < self::SCORE_WARNING ) {
            return null;
        }

        return [
            'plugin_file'    => $plugin_file,
            'plugin_name'    => $plugin_data['Name'] ?? $slug,
            'plugin_slug'    => $slug,
            'plugin_version' => $plugin_data['Version'] ?? '',
            'plugin_author'  => $plugin_data['Author'] ?? '',
            'threat_type'    => $threat_type,
            'threat_score'   => $score,
            'matched_rules'  => $matched_rules,
            'dangerous_files' => $dangerous_files,
        ];
    }

    /* ================================================================
     *  CODE SCANNING
     * ================================================================ */

    private static function check_code( string $dir ): array {
        $results = [];

        try {
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
                RecursiveIteratorIterator::LEAVES_ONLY
            );
        } catch ( \Exception $e ) {
            return $results;
        }

        foreach ( $iterator as $file ) {
            if ( $file->getExtension() !== 'php' ) continue;
            if ( $file->getSize() > self::MAX_FILE_SIZE ) continue;

            $path    = $file->getRealPath();
            $content = @file_get_contents( $path );
            if ( $content === false ) continue;

            $matches = self::match_code_patterns( $content );
            if ( ! empty( $matches ) ) {
                $rel = str_replace( WP_PLUGIN_DIR . '/', '', $path );
                $results[ $rel ] = $matches;
            }
        }

        return $results;
    }

    private static function match_code_patterns( string $content ): array {
        $matches = [];
        foreach ( self::$code_patterns as $rule ) {
            if ( preg_match( $rule['pattern'], $content ) ) {
                $matches[] = [
                    'id'    => $rule['id'],
                    'score' => $rule['score'],
                ];
            }
        }
        return $matches;
    }

    /* ================================================================
     *  HANDLE FINDINGS — delete or warn
     * ================================================================ */

    private static function handle_findings( array $findings ): void {
        if ( ! function_exists( 'deactivate_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }
        if ( ! function_exists( 'delete_plugins' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        foreach ( $findings as $finding ) {
            $action = 'warning_only';

            if ( $finding['threat_score'] >= self::SCORE_AUTO_DELETE ) {
                deactivate_plugins( $finding['plugin_file'], true );
                $delete_result = delete_plugins( [ $finding['plugin_file'] ] );
                $action = ( $delete_result === true || ! is_wp_error( $delete_result ) )
                    ? 'deactivated_and_deleted'
                    : 'deactivated_only';
            }

            $finding['action_taken'] = $action;

            /* quarantine log */
            self::quarantine_log( $finding );

            /* incident log */
            $event = ( $action === 'warning_only' ) ? 'malware_warning' : 'malware_detected';
            AWG_Admin_Guardian::log_incident( $event, [
                'plugin'  => $finding['plugin_name'],
                'slug'    => $finding['plugin_slug'],
                'score'   => $finding['threat_score'],
                'type'    => $finding['threat_type'],
                'action'  => $action,
                'rules'   => $finding['matched_rules'],
            ] );

            /* send to server */
            AWG_Secure_Comm::send( $event, $finding );
        }
    }

    /* ================================================================
     *  QUARANTINE LOG
     * ================================================================ */

    private static function quarantine_log( array $finding ): void {
        $log = get_option( self::OPT_QUARANTINE, [] );
        if ( ! is_array( $log ) ) $log = [];

        $log[] = [
            'time'    => current_time( 'mysql' ),
            'plugin'  => $finding['plugin_name'],
            'slug'    => $finding['plugin_slug'],
            'score'   => $finding['threat_score'],
            'type'    => $finding['threat_type'],
            'action'  => $finding['action_taken'],
            'rules'   => $finding['matched_rules'],
        ];

        /* keep last 100 */
        if ( count( $log ) > 100 ) {
            $log = array_slice( $log, -100 );
        }

        update_option( self::OPT_QUARANTINE, $log, false );
    }

    public static function get_quarantine_log(): array {
        $log = get_option( self::OPT_QUARANTINE, [] );
        return is_array( $log ) ? $log : [];
    }

    public static function get_last_scan(): int {
        return (int) get_option( self::OPT_LAST_SCAN, 0 );
    }

    /* ================================================================
     *  HELPERS
     * ================================================================ */

    private static function extract_slug( string $plugin_file ): string {
        $parts = explode( '/', $plugin_file );
        return ( count( $parts ) > 1 ) ? $parts[0] : pathinfo( $plugin_file, PATHINFO_FILENAME );
    }

    private static function is_safe( string $slug, array $plugin_data ): bool {
        /* hardcoded safe list */
        if ( in_array( $slug, self::$safe_slugs, true ) ) {
            return true;
        }

        /* remote whitelist from server */
        $remote = self::get_remote_whitelist();
        if ( in_array( $slug, $remote, true ) ) {
            return true;
        }

        return false;
    }

    /* ---- remote whitelist management ---- */

    public static function get_remote_whitelist(): array {
        $wl = AWG_Crypto::decrypt_option( self::OPT_REMOTE_WHITELIST );
        if ( $wl ) {
            $data = json_decode( $wl, true );
            if ( is_array( $data ) ) {
                return $data;
            }
        }
        return [];
    }

    public static function set_remote_whitelist( array $slugs ): void {
        $slugs = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', $slugs ) ) ) );
        AWG_Crypto::encrypt_option( self::OPT_REMOTE_WHITELIST, wp_json_encode( $slugs ) );
    }

    /**
     * Remote trigger: POST /?awg_whitelist={site_secret}
     * Body: JSON {"slugs": ["plugin-slug-1", "plugin-slug-2"]}
     * Allows the server to push a safe-plugin whitelist to this site.
     */
    public static function handle_remote_whitelist(): void {
        if ( ! isset( $_GET['awg_whitelist'] ) ) return;

        $token  = sanitize_text_field( $_GET['awg_whitelist'] );
        $secret = AWG_Secure_Comm::get_site_secret();

        if ( ! hash_equals( $secret, $token ) ) {
            status_header( 403 );
            header( 'Content-Type: application/json' );
            echo '{"error":"forbidden"}';
            exit;
        }

        $raw  = file_get_contents( 'php://input' );
        $body = json_decode( $raw, true );

        if ( ! is_array( $body ) || ! isset( $body['slugs'] ) || ! is_array( $body['slugs'] ) ) {
            status_header( 400 );
            header( 'Content-Type: application/json' );
            echo '{"error":"invalid payload, expected {slugs:[...]}"}';
            exit;
        }

        self::set_remote_whitelist( $body['slugs'] );

        status_header( 200 );
        header( 'Content-Type: application/json' );
        echo wp_json_encode( [
            'ok'       => true,
            'site'     => home_url(),
            'accepted' => count( self::get_remote_whitelist() ),
        ] );
        exit;
    }

    /* ================================================================
     *  CLEANUP
     * ================================================================ */

    public static function destroy(): void {
        delete_option( self::OPT_QUARANTINE );
        delete_option( self::OPT_LAST_SCAN );
        delete_option( self::OPT_REMOTE_WHITELIST );
    }
}