<?php
/**
 * Malware heuristic audit.
 *
 * scan_code() is the pure signature matcher (unit-tested). run() performs the
 * bounded file walk (verified live). Read-only. Never returns full file
 * contents — only a path:line and a length-capped snippet.
 *
 * @package EMCP_Tools
 * @since   3.0.0
 */

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

/**
 * @since 3.0.0
 */
class EMCP_Tools_Security_Malware_Audit {

	const MAX_FILE_BYTES        = 2097152; // 2 MB — skip larger files.
	const MAX_LINE_BYTES        = 65536;   // Cap the slice fed to regexes (ReDoS / backtrack-limit guard).
	const MAX_FILES             = 2000;
	const MAX_FILES_CEILING     = 20000;
	const TIME_BUDGET           = 20;      // seconds.
	const TIME_BUDGET_CEILING   = 120;
	const SNIPPET_LEN           = 120;
	const MAX_FINDINGS_PER_FILE = 5;

	/**
	 * Pure: scan one file's source for malware signatures.
	 *
	 * @param string $code       File contents.
	 * @param string $relpath    ABSPATH-relative path (used for path:line value).
	 * @param bool   $in_uploads When true, command-exec/backtick rules escalate to critical.
	 * @return array Finding[]
	 */
	public function scan_code( string $code, string $relpath, bool $in_uploads = false ): array {
		$lines    = preg_split( '/\r\n|\r|\n/', $code );
		$rules    = $this->rules();
		$findings = array();
		$seen     = array();

		foreach ( $lines as $i => $line ) {
			// Long lines defeat .* backtracking: PCRE hits pcre.backtrack_limit and
			// preg_match() returns false ("no match"), silently missing a real hit.
			// Cap the slice the regex sees; the snippet still comes from the full line.
			$scan = strlen( $line ) > self::MAX_LINE_BYTES ? substr( $line, 0, self::MAX_LINE_BYTES ) : $line;

			foreach ( $rules as $rule ) {
				if ( isset( $seen[ $rule['id'] ] ) ) {
					continue;
				}
				if ( 1 !== preg_match( $rule['pattern'], $scan ) ) {
					continue;
				}
				$status = $rule['status'];
				if ( ! empty( $rule['uploads_critical'] ) && $in_uploads ) {
					$status = 'critical';
				}
				$findings[] = EMCP_Tools_Security_Finding::make(
					$rule['id'],
					'malware',
					$rule['label'],
					$status,
					array(
						'location' => $relpath . ':' . ( $i + 1 ),
						'snippet'  => $this->snippet( $line ),
					),
					$rule['message'],
					$rule['recommendation']
				);
				$seen[ $rule['id'] ] = true;
				if ( count( $findings ) >= self::MAX_FINDINGS_PER_FILE ) {
					return $findings;
				}
			}
		}
		return $findings;
	}

	/**
	 * Pure: is this ABSPATH-relative path an executable PHP file under uploads?
	 *
	 * @param string $relpath
	 * @return bool
	 */
	public function is_misplaced_php( string $relpath ): bool {
		$rel = strtolower( str_replace( '\\', '/', $relpath ) );
		if ( false === strpos( $rel, 'uploads/' ) ) {
			return false;
		}
		return (bool) preg_match( '/\.(php|phtml|php3|php4|php5|php7|phps|pht)$/', $rel );
	}

	/**
	 * Pure: should the walk stop now? Returns the reason or null to continue.
	 *
	 * @param int $files_scanned
	 * @param int $started_at    Unix seconds.
	 * @param int $now           Unix seconds.
	 * @param int $max_files
	 * @param int $max_seconds
	 * @return string|null 'max_files' | 'time_budget' | null
	 */
	public function should_stop( int $files_scanned, int $started_at, int $now, int $max_files, int $max_seconds ): ?string {
		if ( $files_scanned >= $max_files ) {
			return 'max_files';
		}
		if ( ( $now - $started_at ) >= $max_seconds ) {
			return 'time_budget';
		}
		return null;
	}

	/**
	 * Pure: does this PHP source contain NO executable code? True for empty files
	 * and files that are only an open tag + comments/whitespace (the ubiquitous
	 * "Silence is golden" index.php directory guards). Such files cannot execute
	 * anything, so they are not a malware signal.
	 *
	 * @param string $code
	 * @return bool
	 */
	public function is_trivial_php( string $code ): bool {
		if ( '' === trim( $code ) ) {
			return true;
		}
		$tokens = @token_get_all( $code );
		if ( ! is_array( $tokens ) ) {
			return false;
		}
		$benign = array( T_OPEN_TAG, T_CLOSE_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT );
		foreach ( $tokens as $tok ) {
			if ( is_array( $tok ) ) {
				if ( T_INLINE_HTML === $tok[0] ) {
					if ( '' !== trim( $tok[1] ) ) {
						return false; // Real output outside PHP tags.
					}
					continue;
				}
				if ( ! in_array( $tok[0], $benign, true ) ) {
					return false;
				}
			} else {
				return false; // Single-char token like ';' '{' '=' → real code.
			}
		}
		return true;
	}

	/**
	 * Pure: is this ABSPATH-relative path under one of the excluded prefixes
	 * (the plugin's own install dir + its managed uploads sandbox)? Boundary-safe
	 * (a sibling like "emcp-widgets-evil" is NOT excluded).
	 *
	 * @param string   $relpath
	 * @param string[] $prefixes ABSPATH-relative dir prefixes (no trailing slash).
	 * @return bool
	 */
	public function is_excluded( string $relpath, array $prefixes ): bool {
		$rel = strtolower( str_replace( '\\', '/', $relpath ) );
		foreach ( $prefixes as $p ) {
			$p = strtolower( trim( str_replace( '\\', '/', $p ), '/' ) );
			if ( '' !== $p && ( $rel === $p || 0 === strpos( $rel, $p . '/' ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Bounded recursive scan of PHP files. Stops on the file-count or time cap and
	 * reports it via stats.truncated. Never follows symlinks that escape ABSPATH.
	 *
	 * @param bool $deep        false → uploads + active plugins/themes; true → whole tree.
	 * @param int  $max_files
	 * @param int  $max_seconds
	 * @return array { findings: Finding[], stats: array }
	 */
	public function run( bool $deep, int $max_files, int $max_seconds ): array {
		$abspath  = $this->realpath_slashed( ABSPATH );
		$roots    = $this->scan_roots( $deep );
		$excluded = $this->excluded_prefixes();
		$started  = time();
		$findings = array();
		$scanned  = 0;
		$skipped  = 0;
		$truncated_reason = null;

		foreach ( $roots as $root ) {
			if ( null !== $truncated_reason ) {
				break;
			}
			if ( ! is_dir( $root ) ) {
				continue;
			}
			$it = $this->iterator( $root );
			if ( null === $it ) {
				continue;
			}
			foreach ( $it as $file ) {
				$stop = $this->should_stop( $scanned, $started, time(), $max_files, $max_seconds );
				if ( null !== $stop ) {
					$truncated_reason = $stop;
					break;
				}
				if ( ! $file->isFile() ) {
					continue;
				}
				$path = $this->realpath_slashed( $file->getPathname() );
				if ( '' === $path || ( $path !== $abspath && 0 !== strpos( $path, $abspath . '/' ) ) ) {
					continue; // Symlink escape, prefix-collision, or unreadable.
				}
				$relpath = ltrim( substr( $path, strlen( $abspath ) ), '/' );

				if ( $this->is_excluded( $relpath, $excluded ) ) {
					continue; // Plugin's own code / managed sandbox.
				}

				if ( ! preg_match( '/\.(php|phtml|php3|php4|php5|php7|phps|pht)$/i', $relpath ) ) {
					continue;
				}
				if ( $file->getSize() > self::MAX_FILE_BYTES ) {
					$skipped++;
					$scanned++;
					continue;
				}
				$code = @file_get_contents( $path );
				$scanned++;
				if ( false === $code ) {
					continue;
				}

				$in_uploads = false !== strpos( strtolower( $relpath ), 'uploads/' );
				if ( $in_uploads && $this->is_misplaced_php( $relpath ) ) {
					// Benign directory guards (empty / comment-only index.php) are
					// everywhere; only flag uploads PHP that actually runs code.
					if ( $this->is_trivial_php( $code ) ) {
						continue;
					}
					$findings[] = EMCP_Tools_Security_Finding::make(
						'malware_uploads_php', 'malware', 'Executable PHP under uploads', 'critical',
						array( 'location' => $relpath, 'snippet' => '' ),
						'A PHP file containing executable code exists under wp-content/uploads, where uploaded media should never run code.',
						'Review and remove this file, and block PHP execution in uploads (an uploads .htaccess / nginx rule).'
					);
				}

				$findings = array_merge( $findings, $this->scan_code( $code, $relpath, $in_uploads ) );
			}
		}

		return array(
			'findings' => $findings,
			'stats'    => array(
				'files_scanned'      => $scanned,
				'files_skipped_size' => $skipped,
				'truncated'          => null !== $truncated_reason,
				'truncated_reason'   => $truncated_reason,
			),
		);
	}

	/**
	 * Dirs the scan never flags: the plugin's own install directory (so the
	 * scanner doesn't match its own rule strings) and the plugin's managed
	 * PHP-snippet sandbox under uploads. ABSPATH-relative, no trailing slash.
	 *
	 * @return string[]
	 */
	private function excluded_prefixes(): array {
		$prefixes = array( 'wp-content/uploads/emcp-widgets' );
		if ( defined( 'EMCP_TOOLS_DIR' ) ) {
			$abspath = $this->realpath_slashed( ABSPATH );
			$self    = $this->realpath_slashed( EMCP_TOOLS_DIR );
			if ( '' !== $abspath && '' !== $self && 0 === strpos( $self, $abspath . '/' ) ) {
				$prefixes[] = ltrim( substr( $self, strlen( $abspath ) ), '/' );
			}
		}
		return $prefixes;
	}

	/** @return string[] Absolute roots to scan. */
	private function scan_roots( bool $deep ): array {
		if ( $deep ) {
			return array( rtrim( WP_CONTENT_DIR, '/\\' ) );
		}
		$content = rtrim( WP_CONTENT_DIR, '/\\' );
		$roots   = array( $content . '/uploads' );
		foreach ( (array) get_option( 'active_plugins', array() ) as $plugin ) {
			$dir = $content . '/plugins/' . strtok( (string) $plugin, '/' );
			$roots[] = $dir;
		}
		$roots[] = $content . '/themes/' . get_stylesheet();
		$roots[] = $content . '/themes/' . get_template();
		return array_values( array_unique( $roots ) );
	}

	private function iterator( string $root ): ?\RecursiveIteratorIterator {
		try {
			return new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS ),
				\RecursiveIteratorIterator::LEAVES_ONLY
			);
		} catch ( \Throwable $e ) {
			return null;
		}
	}

	private function realpath_slashed( string $path ): string {
		$real = realpath( $path );
		if ( false === $real ) {
			return '';
		}
		return rtrim( str_replace( '\\', '/', $real ), '/' );
	}

	// ---- Pure helpers --------------------------------------------------

	private function snippet( string $line ): string {
		$line = trim( preg_replace( '/\s+/', ' ', $line ) );
		return mb_substr( $line, 0, self::SNIPPET_LEN );
	}

	/**
	 * @return array<int, array{id:string,label:string,pattern:string,status:string,message:string,recommendation:string,uploads_critical?:bool}>
	 */
	private function rules(): array {
		return array(
			array(
				'id'             => 'malware_eval_obfuscation',
				'label'          => 'Obfuscated code execution',
				'pattern'        => '/\b(eval|assert|create_function)\s*\(.*\b(base64_decode|gzinflate|gzuncompress|str_rot13|strrev|convert_uudecode)\s*\(/i',
				'status'         => 'critical',
				'message'        => 'A code-execution call wrapping a decoder (e.g. eval(base64_decode(...))) — a classic malware pattern.',
				'recommendation' => 'Treat this file as compromised: review it, restore a clean copy from source control or a fresh download, and rotate credentials.',
			),
			array(
				'id'             => 'malware_request_eval',
				'label'          => 'Request-driven code execution',
				'pattern'        => '/\b(eval|assert|system|exec|passthru|shell_exec|popen|proc_open)\s*\(.*\$_(GET|POST|REQUEST|COOKIE|SERVER)\b/i',
				'status'         => 'critical',
				'message'        => 'User-controlled input flows directly into a code/command execution call.',
				'recommendation' => 'This is a remote-code-execution backdoor. Remove the file or the offending call immediately.',
			),
			array(
				'id'               => 'malware_command_exec',
				'label'            => 'Command execution',
				'pattern'          => '/\b(shell_exec|passthru|proc_open|popen|system|exec)\s*\(/i',
				'status'           => 'warning',
				'uploads_critical' => true,
				'message'          => 'A shell/command execution function is used.',
				'recommendation'   => 'Confirm this is legitimate; command execution is rare in theme/plugin code and never belongs under uploads.',
			),
			array(
				'id'             => 'malware_webshell_marker',
				'label'          => 'Known webshell marker',
				'pattern'        => '/(FilesMan|c99shell|r57shell|b374k|phpspy|WSO\s*[0-9.]*\s*shell)/i',
				'status'         => 'critical',
				'message'        => 'A string associated with known PHP webshells was found.',
				'recommendation' => 'Treat this file as a webshell: remove it and audit the whole site for further compromise.',
			),
			array(
				'id'             => 'malware_long_base64',
				'label'          => 'Large encoded blob',
				'pattern'        => '/[A-Za-z0-9+\/]{260,}={0,2}/',
				'status'         => 'warning',
				'message'        => 'A very long base64-like string was found, often used to hide a payload.',
				'recommendation' => 'Review the encoded content; large inline encoded blobs are uncommon in legitimate code.',
			),
		);
	}
}
