<?php
/**
 * Pattern-based suspicious-code detection.
 *
 * This is the "is this PHP file weird?" companion to the file-integrity
 * monitor. It only runs against files that the integrity sweep flagged as
 * NEW or CHANGED, so it isn't a full O(n) malware scanner — it's a
 * triage layer that cheap-scans the deltas.
 *
 * Rules favor *high-precision* signals over breadth: every rule that fires
 * should give an admin a real reason to look at the file. False positives
 * here cost user trust, not CPU.
 *
 * @package Brainwerk Security Suite
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Shieldforge_Pattern_Scanner {

    /**
     * Rule set. Each entry:
     *   - id        : short slug used in event context['rule']
     *   - regex     : PCRE pattern
     *   - severity  : Logger SEV_*
     *   - desc      : human-readable, surfaced in the admin UI
     *
     * Patterns are kept ASCII-only and use \s* for whitespace tolerance so
     * obfuscators that scatter whitespace can't trivially defeat them.
     */
    public static function rules() {
        return array(
            array(
                'id'       => 'eval_b64',
                'regex'    => '/eval\s*\(\s*base64_decode\s*\(/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'eval(base64_decode(...)) — classic obfuscated payload loader.',
            ),
            array(
                'id'       => 'eval_gzinflate',
                'regex'    => '/eval\s*\(\s*gzinflate\s*\(/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'eval(gzinflate(...)) — obfuscated payload loader.',
            ),
            array(
                'id'       => 'eval_gzuncompress',
                'regex'    => '/eval\s*\(\s*gzuncompress\s*\(/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'eval(gzuncompress(...)) — obfuscated payload loader.',
            ),
            array(
                'id'       => 'eval_strrev',
                'regex'    => '/eval\s*\(\s*str(?:_)?rot13\s*\(/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'eval(str_rot13(...)) — obfuscated payload loader.',
            ),
            array(
                'id'       => 'assert_b64',
                'regex'    => '/assert\s*\(\s*base64_decode\s*\(/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'assert(base64_decode(...)) — eval-equivalent backdoor.',
            ),
            array(
                'id'       => 'preg_replace_e',
                'regex'    => '/preg_replace\s*\(\s*[\'"][^\'"]*\/[a-z]*e[a-z]*[\'"]/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'preg_replace() with /e modifier — code execution sink.',
            ),
            array(
                'id'       => 'create_function',
                'regex'    => '/\bcreate_function\s*\(/i',
                'severity' => Shieldforge_Logger::SEV_WARN,
                'desc'     => 'create_function() — deprecated dynamic-eval primitive.',
            ),
            array(
                'id'       => 'concat_eval',
                'regex'    => '/\$[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*[\'"][a-z]{1,3}[\'"]\s*\.\s*[\'"][a-z]{1,3}[\'"]/i',
                'severity' => Shieldforge_Logger::SEV_INFO,
                'desc'     => 'String-concatenation that often hides eval/system. Heuristic.',
            ),
            array(
                'id'       => 'shell_exec',
                'regex'    => '/\b(shell_exec|system|passthru|exec|popen|proc_open)\s*\(\s*\$/i',
                'severity' => Shieldforge_Logger::SEV_WARN,
                'desc'     => 'Shell execution with a variable argument — frequent webshell.',
            ),
            array(
                'id'       => 'webshell_token',
                'regex'    => '/(?:c99shell|r57shell|FilesMan|WSO\s*\d+|b374k|MagicShell|TrojanShell|Symlink_Sa)/i',
                'severity' => Shieldforge_Logger::SEV_CRITICAL,
                'desc'     => 'Known webshell signature.',
            ),
            array(
                'id'       => 'remote_include',
                'regex'    => '/\b(?:include|require)(?:_once)?\s*\(\s*[\'"]https?:\/\//i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'Remote file inclusion — include/require from URL.',
            ),
            array(
                'id'       => 'fopen_remote_post',
                'regex'    => '/file_get_contents\s*\(\s*[\'"]https?:\/\/[^\'"]+[\'"]\s*\)\s*\.\s*\$/i',
                'severity' => Shieldforge_Logger::SEV_INFO,
                'desc'     => 'Remote fetch concatenated with variable — possible exfil.',
            ),
            array(
                'id'       => 'long_b64_blob',
                'regex'    => '/[\'"][A-Za-z0-9+\/=]{300,}[\'"]/',
                'severity' => Shieldforge_Logger::SEV_INFO,
                'desc'     => 'Very long base64-looking literal embedded in PHP.',
            ),
            array(
                'id'       => 'goto_obfuscation',
                'regex'    => '/\bgoto\s+[A-Za-z_][A-Za-z0-9_]{0,30}\s*;/',
                'severity' => Shieldforge_Logger::SEV_INFO,
                'desc'     => 'goto-based control-flow obfuscation (legitimate use is very rare).',
            ),
            array(
                'id'       => 'wp_admin_user_inject',
                'regex'    => '/wp_insert_user\s*\(\s*array\s*\([^)]*[\'"]role[\'"]\s*=>\s*[\'"]administrator[\'"]/i',
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'desc'     => 'Inline wp_insert_user() with administrator role — classic backdoor.',
            ),
            array(
                'id'       => 'auth_cookie_set',
                'regex'    => '/wp_set_auth_cookie\s*\(\s*\d+/i',
                'severity' => Shieldforge_Logger::SEV_WARN,
                'desc'     => 'wp_set_auth_cookie() with a literal user ID — silent login.',
            ),
        );
    }

    /**
     * Scan a single file. Returns a list of hits:
     *   [{ rule, severity, snippet, line, desc }]
     *
     * @param string $abs_path
     * @return array
     */
    public static function scan_file( $abs_path ) {
        if ( ! is_file( $abs_path ) || ! is_readable( $abs_path ) ) {
            return array();
        }
        $cap = (int) Shieldforge_Settings::get( 'scanner_max_file_kb', 4096 ) * 1024;
        $size = @filesize( $abs_path );
        if ( false === $size || $size > $cap ) {
            return array();
        }
        $contents = @file_get_contents( $abs_path );
        if ( false === $contents || '' === $contents ) {
            return array();
        }
        return self::scan_string( $contents );
    }

    /**
     * Run every rule against an in-memory string. Split out so unit tests
     * can drive it directly without touching the filesystem.
     */
    public static function scan_string( $contents ) {
        $hits = array();
        foreach ( self::rules() as $rule ) {
            if ( preg_match( $rule['regex'], $contents, $m, PREG_OFFSET_CAPTURE ) ) {
                $offset  = isset( $m[0][1] ) ? (int) $m[0][1] : 0;
                $line    = self::line_at( $contents, $offset );
                $snippet = self::snippet_at( $contents, $offset, 80 );
                $hits[]  = array(
                    'rule'     => $rule['id'],
                    'severity' => $rule['severity'],
                    'desc'     => $rule['desc'],
                    'snippet'  => $snippet,
                    'line'     => $line,
                );
            }
        }
        return $hits;
    }

    /**
     * 1-based line number of an absolute byte offset.
     */
    private static function line_at( $contents, $offset ) {
        if ( $offset <= 0 ) {
            return 1;
        }
        $head = substr( $contents, 0, $offset );
        return substr_count( $head, "\n" ) + 1;
    }

    /**
     * Single-line excerpt around the offset, with a tiny window on each
     * side. Stripped of newlines so it renders cleanly in the log table.
     */
    private static function snippet_at( $contents, $offset, $max_len ) {
        $start = max( 0, $offset - 20 );
        $slice = substr( $contents, $start, $max_len );
        $slice = preg_replace( '/\s+/', ' ', $slice );
        return mb_substr( trim( (string) $slice ), 0, $max_len );
    }
}
