<?php
/**
 * AVI Plugin Security Scan — détection backdoors / plugins masqués (WP_PLUGIN_DIR uniquement).
 *
 * Règles:
 * - Arrays PHP uniquement via array() (jamais []).
 * - Méthodes hookées (add_action) = public static.
 * - Ne scanne pas wp-includes, wp-admin, ni vendor/.
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'Avision_Plugin_Security_Scan' ) ) {
	class Avision_Plugin_Security_Scan {

		const OPTION_LAST           = 'avision_security_scan_last';
		const OPTION_HISTORY        = 'avision_security_scan_history';
		const OPTION_ALERTS_ENABLED = 'avi_security_alerts_enabled';
		const OPTION_ALERT_EMAIL    = 'avi_security_alert_email';
		const OPTION_WHITELIST      = 'avi_security_scan_whitelist_plugins';

		const HISTORY_MAX = 10;

		/**
		 * Domaines C2 connus (critical si curl_exec les cible).
		 *
		 * @return array
		 */
		private static function known_c2_domains() {
			return array( 'cdnlogstat.com', 'wp-ninjas.dev' );
		}

		/**
		 * Domaines externes autorisés pour curl_exec (évite warn sur APIs légitimes).
		 *
		 * @return array
		 */
		private static function curl_external_whitelist_domains() {
			return array(
				'api.wordpress.org',
				'downloads.wordpress.org',
				'wordpress.org',
				'googleapis.com',
				'google.com',
				'stripe.com',
				'github.com',
				'githubusercontent.com',
				'gravatar.com',
				'w.org',
			);
		}

		/**
		 * Whitelist plugins (critical → warn uniquement).
		 *
		 * @return array
		 */
		private static function get_whitelist_plugins() {
			$default = array(
				'avision-dashboard',
				'litespeed-cache',
				'wp-mail-smtp',
				'wpforms-lite',
				'contact-form-7',
			);
			$stored = get_option( self::OPTION_WHITELIST, null );
			if ( ! is_array( $stored ) || empty( $stored ) ) {
				return $default;
			}
			return array_values( array_unique( array_merge( $default, $stored ) ) );
		}

		/**
		 * Cron daily + hook scan.
		 *
		 * @return void
		 */
		public static function init_crons() {
			if ( ! wp_next_scheduled( 'avision_security_plugin_scan_cron' ) ) {
				wp_schedule_event( time(), 'daily', 'avision_security_plugin_scan_cron' );
			}
			add_action( 'avision_security_plugin_scan_cron', array( __CLASS__, 'run_scheduled_scan' ) );
		}

		/**
		 * @return void
		 */
		public static function run_scheduled_scan() {
			self::run_scan( true );
		}

		/**
		 * Lance le scan complet et persiste le résultat.
		 *
		 * @param bool $send_alert Envoyer un email si findings critiques ou score &lt; 100.
		 * @return array
		 */
		public static function run_scan( $send_alert = false ) {
			if ( ! function_exists( 'get_plugins' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}

			$findings       = array();
			$hidden_plugins = array();

			$hidden_findings = self::detect_hidden_plugins();
			foreach ( $hidden_findings as $hf ) {
				$findings[] = $hf;
				if ( isset( $hf['plugin'] ) && is_string( $hf['plugin'] ) && $hf['plugin'] !== '' ) {
					$hidden_plugins[] = $hf['plugin'];
				}
			}

			$plugin_dirs = self::list_plugin_directories();
			foreach ( $plugin_dirs as $slug => $dir ) {
				$file_findings = self::scan_plugin_files( $slug, $dir );
				if ( ! empty( $file_findings ) ) {
					$findings = array_merge( $findings, $file_findings );
				}
			}

			$score          = self::calculate_score( $findings );
			$critical_count = self::count_findings_by_severity( $findings, 'critical' );

			if ( $critical_count > 0 ) {
				$status = 'fail';
			} elseif ( $score < 100 ) {
				$status = 'warn';
			} else {
				$status = 'ok';
			}

			$result = array(
				'timestamp'       => time(),
				'findings'        => $findings,
				'hidden_plugins'  => array_values( array_unique( $hidden_plugins ) ),
				'score'           => $score,
				'status'          => $status,
				'critical_count'  => $critical_count,
				'plugins_scanned' => count( $plugin_dirs ),
			);

			self::persist_result( $result );

			$alerts_on = (bool) get_option( self::OPTION_ALERTS_ENABLED, 1 );
			if ( $send_alert && $alerts_on ) {
				self::maybe_send_alert( $result );
			}

			return $result;
		}

		/**
		 * Envoie une alerte email si score &lt; 100 ou findings critical.
		 *
		 * @param array $result Résultat de run_scan().
		 * @return bool
		 */
		public static function maybe_send_alert( $result ) {
			if ( ! is_array( $result ) ) {
				return false;
			}

			$score          = isset( $result['score'] ) ? (int) $result['score'] : 100;
			$findings       = isset( $result['findings'] ) && is_array( $result['findings'] ) ? $result['findings'] : array();
			$critical_count = self::count_findings_by_severity( $findings, 'critical' );

			if ( $score >= 100 && $critical_count < 1 ) {
				return false;
			}

			$primary = get_option( self::OPTION_ALERT_EMAIL, 'contact@avision.org' );
			if ( ! is_string( $primary ) || $primary === '' || ! is_email( $primary ) ) {
				$primary = 'contact@avision.org';
			}

			$recipients = array_unique(
				array_filter(
					array( $primary, 'toniosbh@gmail.com' ),
					function ( $email ) {
						return is_string( $email ) && $email !== '' && is_email( $email );
					}
				)
			);

			if ( empty( $recipients ) ) {
				return false;
			}

			$subject = '[AVI Security] Plugin scan — ' . $critical_count . ' critical finding(s)';
			$body    = self::build_alert_email_body( $result );

			$sent = false;
			foreach ( $recipients as $to ) {
				if ( wp_mail( $to, $subject, $body ) ) {
					$sent = true;
				}
			}

			return $sent;
		}

		/**
		 * @return array|null
		 */
		public static function get_last_result() {
			$last = get_option( self::OPTION_LAST, null );
			return is_array( $last ) ? $last : null;
		}

		/**
		 * @param string $plugin_slug
		 * @param string $plugin_dir
		 * @return array
		 */
		private static function scan_plugin_files( $plugin_slug, $plugin_dir ) {
			$findings  = array();
			$whitelist = self::get_whitelist_plugins();
			$is_whitelist = in_array( $plugin_slug, $whitelist, true );

			$php_files = self::collect_php_files( $plugin_dir );
			foreach ( $php_files as $file_path ) {
				$content = @file_get_contents( $file_path );
				if ( ! is_string( $content ) || $content === '' ) {
					continue;
				}

				$relative = self::relative_plugin_path( $plugin_dir, $file_path );
				$lines    = preg_split( '/\r\n|\r|\n/', $content );
				if ( ! is_array( $lines ) ) {
					$lines = array( $content );
				}

				$file_hits = self::match_patterns_in_content( $content, $lines );
				foreach ( $file_hits as $hit ) {
					$severity = isset( $hit['severity'] ) ? $hit['severity'] : 'warn';
					if ( $is_whitelist && $severity === 'critical' ) {
						$severity = 'warn';
					}

					$findings[] = array(
						'type'     => 'pattern',
						'plugin'   => $plugin_slug,
						'file'     => $relative,
						'line'     => isset( $hit['line'] ) ? (int) $hit['line'] : 0,
						'pattern'  => isset( $hit['pattern'] ) ? $hit['pattern'] : '',
						'severity' => $severity,
						'message'  => isset( $hit['message'] ) ? $hit['message'] : '',
					);
				}
			}

			return $findings;
		}

		/**
		 * Plugins présents sur disque mais absents de get_plugins() (hook all_plugins / masquage).
		 *
		 * @return array
		 */
		private static function detect_hidden_plugins() {
			$findings = array();

			if ( ! defined( 'WP_PLUGIN_DIR' ) || ! is_dir( WP_PLUGIN_DIR ) ) {
				return $findings;
			}

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

			$registered_slugs = array();
			$all_plugins      = get_plugins();
			if ( is_array( $all_plugins ) ) {
				foreach ( array_keys( $all_plugins ) as $plugin_file ) {
					$slug = dirname( $plugin_file );
					if ( $slug === '.' || $slug === '' ) {
						$slug = basename( $plugin_file, '.php' );
					}
					$registered_slugs[ strtolower( $slug ) ] = true;
				}
			}

			$skip_slugs = array( 'avision-dashboard' );
			$dirs       = @scandir( WP_PLUGIN_DIR );
			if ( ! is_array( $dirs ) ) {
				return $findings;
			}

			foreach ( $dirs as $entry ) {
				if ( $entry === '.' || $entry === '..' || $entry === 'index.php' ) {
					continue;
				}
				$full = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $entry;
				if ( ! is_dir( $full ) ) {
					continue;
				}
				if ( in_array( $entry, $skip_slugs, true ) ) {
					continue;
				}

				$key = strtolower( $entry );
				if ( isset( $registered_slugs[ $key ] ) ) {
					continue;
				}

				$findings[] = array(
					'type'     => 'hidden',
					'plugin'   => $entry,
					'file'     => '',
					'line'     => 0,
					'pattern'  => 'filesystem_not_in_get_plugins',
					'severity' => 'critical',
					'message'  => 'Plugin directory on disk but missing from get_plugins() (possible all_plugins hide)',
				);
			}

			return $findings;
		}

		/**
		 * @param array $findings
		 * @return int
		 */
		private static function calculate_score( $findings ) {
			$score = 100;
			if ( ! is_array( $findings ) ) {
				return $score;
			}

			$warn_deduction = 0;
			foreach ( $findings as $f ) {
				if ( ! is_array( $f ) ) {
					continue;
				}
				$sev = isset( $f['severity'] ) ? $f['severity'] : '';
				if ( $sev === 'critical' ) {
					$score -= 25;
				} elseif ( $sev === 'warn' ) {
					$warn_deduction++;
				}
			}
			// Plafonner warns à 20pts max
			$score -= min( 20, $warn_deduction );
			return max( 0, $score );
		}

		/**
		 * @param string $content
		 * @param array  $lines
		 * @return array
		 */
		private static function match_patterns_in_content( $content, $lines ) {
			$hits = array();

			$critical_rules = array(
				array(
					'pattern' => 'eval_base64',
					'regex'   => '/eval\s*\(\s*[^;]{0,500}base64_decode\s*\(/is',
					'message' => 'eval() wrapping base64_decode()',
				),
				array(
					'pattern' => 'assert_dynamic',
					'regex'   => '/assert\s*\(\s*(\$|base64_decode\s*\()/i',
					'message' => 'assert() with variable or base64_decode',
				),
				array(
					'pattern' => 'preg_replace_e',
					'regex'   => '/preg_replace\s*\([^)]*\/[^\/\n]*e[\'"]/is',
					'message' => 'preg_replace with /e modifier (code injection)',
				),
				array(
					'pattern' => 'create_function',
					'regex'   => '/create_function\s*\(/i',
					'message' => 'create_function() (deprecated, often malware)',
				),
				array(
					'pattern' => 'shell_exec',
					'regex'   => '/\b(shell_exec|passthru|proc_open)\s*\(/i',
					'message' => 'Dangerous shell/process function',
				),
				array(
					'pattern' => 'goto_obfuscated',
					'regex'   => '/goto\s+[a-z]{6,}\b/i',
					'message' => 'goto with obfuscated label',
				),
				array(
					'pattern' => 'hide_plugin_hook',
					'regex'   => '/hide_plugin_from_list|pre_option_active_plugins/i',
					'message' => 'Plugin list hiding hook pattern',
				),
			);

			foreach ( $critical_rules as $rule ) {
				if ( preg_match( $rule['regex'], $content ) ) {
					$line = self::find_line_for_regex( $lines, $rule['regex'] );
					$hits[] = array(
						'pattern'  => $rule['pattern'],
						'severity' => 'critical',
						'message'  => $rule['message'],
						'line'     => $line,
					);
				}
			}

			// Hex obfuscation: >5 séquences \xHH.
			if ( preg_match_all( '/\\\\x[0-9a-fA-F]{2}/', $content, $hex_m ) ) {
				$hex_count = is_array( $hex_m[0] ) ? count( $hex_m[0] ) : 0;
				if ( $hex_count > 5 ) {
					$hits[] = array(
						'pattern'  => 'hex_obfuscation',
						'severity' => 'critical',
						'message'  => 'Repeated \\xHH hex sequences (' . $hex_count . ' occurrences)',
						'line'     => self::find_line_for_substring( $lines, '\\x' ),
					);
				}
			}

			// curl_exec vers C2.
			$c2 = self::known_c2_domains();
			foreach ( $c2 as $domain ) {
				$domain_esc = preg_quote( $domain, '/' );
				$regex      = '/curl_exec\s*\([^)]*' . $domain_esc . '/is';
				if ( preg_match( $regex, $content ) ) {
					$hits[] = array(
						'pattern'  => 'curl_c2_' . str_replace( '.', '_', $domain ),
						'severity' => 'critical',
						'message'  => 'curl_exec targeting known C2: ' . $domain,
						'line'     => self::find_line_for_substring( $lines, $domain ),
					);
				}
			}

			// Warnings.
			if ( preg_match( '/base64_decode\s*\(/i', $content ) ) {
				$already_eval = false;
				foreach ( $hits as $h ) {
					if ( isset( $h['pattern'] ) && $h['pattern'] === 'eval_base64' ) {
						$already_eval = true;
						break;
					}
				}
				if ( ! $already_eval ) {
					$hits[] = array(
						'pattern'  => 'base64_decode',
						'severity' => 'warn',
						'message'  => 'base64_decode() present (review if expected)',
						'line'     => self::find_line_for_substring( $lines, 'base64_decode' ),
					);
				}
			}

			if ( preg_match_all( '/curl_exec\s*\(\s*([^)]+)\)/is', $content, $curl_m, PREG_SET_ORDER ) ) {
				foreach ( $curl_m as $curl_hit ) {
					$arg = isset( $curl_hit[1] ) ? $curl_hit[1] : '';
					if ( self::curl_argument_is_whitelisted( $arg ) ) {
						continue;
					}
					if ( self::curl_argument_matches_c2( $arg ) ) {
						continue;
					}
					$hits[] = array(
						'pattern'  => 'curl_external',
						'severity' => 'warn',
						'message'  => 'curl_exec() to non-whitelisted external target',
						'line'     => self::find_line_for_substring( $lines, 'curl_exec' ),
					);
					break;
				}
			}

			return $hits;
		}

		/**
		 * @param string $arg_fragment
		 * @return bool
		 */
		private static function curl_argument_is_whitelisted( $arg_fragment ) {
			$arg = strtolower( (string) $arg_fragment );
			if ( $arg === '' ) {
				return true;
			}
			foreach ( self::curl_external_whitelist_domains() as $domain ) {
				if ( strpos( $arg, strtolower( $domain ) ) !== false ) {
					return true;
				}
			}
			// Variables locales / constantes WP — pas d'URL externe détectable.
			if ( preg_match( '/^\s*[\$]/', $arg_fragment ) ) {
				return true;
			}
			return false;
		}

		/**
		 * @param string $arg_fragment
		 * @return bool
		 */
		private static function curl_argument_matches_c2( $arg_fragment ) {
			$arg = strtolower( (string) $arg_fragment );
			foreach ( self::known_c2_domains() as $domain ) {
				if ( strpos( $arg, strtolower( $domain ) ) !== false ) {
					return true;
				}
			}
			return false;
		}

		/**
		 * @param array  $lines
		 * @param string $regex
		 * @return int
		 */
		private static function find_line_for_regex( $lines, $regex ) {
			if ( ! is_array( $lines ) ) {
				return 0;
			}
			$n = 0;
			foreach ( $lines as $line ) {
				$n++;
				if ( is_string( $line ) && preg_match( $regex, $line ) ) {
					return $n;
				}
			}
			return 0;
		}

		/**
		 * @param array  $lines
		 * @param string $needle
		 * @return int
		 */
		private static function find_line_for_substring( $lines, $needle ) {
			if ( ! is_array( $lines ) || $needle === '' ) {
				return 0;
			}
			$n = 0;
			foreach ( $lines as $line ) {
				$n++;
				if ( is_string( $line ) && stripos( $line, $needle ) !== false ) {
					return $n;
				}
			}
			return 0;
		}

		/**
		 * @param string $plugin_dir
		 * @return array
		 */
		private static function collect_php_files( $plugin_dir ) {
			$files = array();
			if ( ! is_dir( $plugin_dir ) ) {
				return $files;
			}

			try {
				$iterator = new RecursiveIteratorIterator(
					new RecursiveDirectoryIterator( $plugin_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
					RecursiveIteratorIterator::SELF_FIRST
				);
			} catch ( Exception $e ) {
				return $files;
			}

			foreach ( $iterator as $file_info ) {
				if ( ! $file_info->isFile() ) {
					continue;
				}
				$path = $file_info->getPathname();
				if ( self::path_contains_vendor( $path ) ) {
					continue;
				}
				if ( substr( $path, -4 ) !== '.php' ) {
					continue;
				}
				$files[] = $path;
			}

			return $files;
		}

		/**
		 * @param string $path
		 * @return bool
		 */
		private static function path_contains_vendor( $path ) {
			$normalized = str_replace( '\\', '/', strtolower( $path ) );
			return (
				strpos( $normalized, '/vendor/' ) !== false ||
				strpos( $normalized, '/vendor_prefixed/' ) !== false ||
				strpos( $normalized, '/third-party/' ) !== false ||
				strpos( $normalized, '/third_party/' ) !== false
			);
		}

		/**
		 * @return array slug => absolute dir
		 */
		private static function list_plugin_directories() {
			$map = array();
			if ( ! defined( 'WP_PLUGIN_DIR' ) || ! is_dir( WP_PLUGIN_DIR ) ) {
				return $map;
			}

			$dirs = @scandir( WP_PLUGIN_DIR );
			if ( ! is_array( $dirs ) ) {
				return $map;
			}

			foreach ( $dirs as $entry ) {
				if ( $entry === '.' || $entry === '..' || $entry === 'index.php' ) {
					continue;
				}
				$full = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $entry;
				if ( is_dir( $full ) ) {
					$map[ $entry ] = $full;
				}
			}

			return $map;
		}

		/**
		 * @param string $plugin_dir
		 * @param string $file_path
		 * @return string
		 */
		private static function relative_plugin_path( $plugin_dir, $file_path ) {
			$dir  = trailingslashit( str_replace( '\\', '/', $plugin_dir ) );
			$path = str_replace( '\\', '/', $file_path );
			if ( strpos( $path, $dir ) === 0 ) {
				return substr( $path, strlen( $dir ) );
			}
			return basename( $file_path );
		}

		/**
		 * @param array  $findings
		 * @param string $severity
		 * @return int
		 */
		private static function count_findings_by_severity( $findings, $severity ) {
			$count = 0;
			if ( ! is_array( $findings ) ) {
				return $count;
			}
			foreach ( $findings as $f ) {
				if ( is_array( $f ) && isset( $f['severity'] ) && $f['severity'] === $severity ) {
					$count++;
				}
			}
			return $count;
		}

		/**
		 * @param array $result
		 * @return void
		 */
		private static function persist_result( $result ) {
			update_option( self::OPTION_LAST, $result, false );

			$history = get_option( self::OPTION_HISTORY, array() );
			if ( ! is_array( $history ) ) {
				$history = array();
			}

			$snapshot = array(
				'timestamp'      => isset( $result['timestamp'] ) ? $result['timestamp'] : time(),
				'score'          => isset( $result['score'] ) ? (int) $result['score'] : 0,
				'status'         => isset( $result['status'] ) ? $result['status'] : '',
				'critical_count' => isset( $result['critical_count'] ) ? (int) $result['critical_count'] : 0,
				'findings_count' => is_array( $result['findings'] ) ? count( $result['findings'] ) : 0,
			);

			array_unshift( $history, $snapshot );
			if ( count( $history ) > self::HISTORY_MAX ) {
				$history = array_slice( $history, 0, self::HISTORY_MAX );
			}

			update_option( self::OPTION_HISTORY, $history, false );
		}

		/**
		 * @param array $result
		 * @return string
		 */
		private static function build_alert_email_body( $result ) {
			$site     = function_exists( 'home_url' ) ? home_url() : '';
			$score    = isset( $result['score'] ) ? (int) $result['score'] : 0;
			$status   = isset( $result['status'] ) ? $result['status'] : '';
			$ts       = isset( $result['timestamp'] ) ? (int) $result['timestamp'] : time();
			$findings = isset( $result['findings'] ) && is_array( $result['findings'] ) ? $result['findings'] : array();

			$lines   = array();
			$lines[] = 'AVI Plugin Security Scan';
			$lines[] = 'Site: ' . $site;
			$lines[] = 'Time: ' . gmdate( 'Y-m-d H:i:s', $ts ) . ' UTC';
			$lines[] = 'Score: ' . $score . '/100';
			$lines[] = 'Status: ' . $status;
			$lines[] = '';
			$lines[] = 'Findings:';

			if ( empty( $findings ) ) {
				$lines[] = '(none)';
			} else {
				foreach ( $findings as $f ) {
					if ( ! is_array( $f ) ) {
						continue;
					}
					$plugin  = isset( $f['plugin'] ) ? $f['plugin'] : '';
					$file    = isset( $f['file'] ) ? $f['file'] : '';
					$line    = isset( $f['line'] ) ? (int) $f['line'] : 0;
					$pattern = isset( $f['pattern'] ) ? $f['pattern'] : '';
					$sev     = isset( $f['severity'] ) ? $f['severity'] : '';
					$msg     = isset( $f['message'] ) ? $f['message'] : '';
					$line_txt = $line > 0 ? (string) $line : 'n/a';
					$lines[] = '- [' . strtoupper( $sev ) . '] ' . $plugin . ' | ' . $file . ':' . $line_txt . ' | ' . $pattern . ' — ' . $msg;
				}
			}

			$lines[] = '';
			$lines[] = 'Run a manual scan from AVI Control Center → Security tab.';

			return implode( "\n", $lines );
		}
	}
}
