<?php
/**
 * Plugin Name: WP Engine Security Auditor
 * Description: WP Engine-specific security auditing and logging
 * Author:      wpengine
 * Author URI:  https://wpengine.com
 * Version:     1.1.1
 *
 * @package wpengine-security-auditor
 */

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

if ( defined( 'WPENGINE_SECURITY_AUDITOR_ENABLED' ) && WPENGINE_SECURITY_AUDITOR_ENABLED !== true ) {
	return;
}

WPEngineSecurityAuditor_Events::initialize();
WPEngineSecurityAuditor_Scans::initialize();

/**
 * Class WPEngineSecurityAuditor_Events
 */
// phpcs:disable Squiz.Commenting,Squiz.PHP.CommentedOutCode.Found
#[AllowDynamicProperties]
class WPEngineSecurityAuditor_Events {
// phpcs:enable

	/**
	 * Singleton instance of this class
	 *
	 * @var WPEngineSecurityAuditor_Events
	 */
	public static $instance;

	/**
	 * Initialize
	 *
	 * This is called on plugin start to create a singleton.
	 */
	public static function initialize() {
		if ( defined( 'WP_CLI' ) && WP_CLI && getenv( 'WPENGINE_SECURITY_AUDITOR_LOGGING' ) !== 'enabled' ) {
			// logging events to an interactive session is just noise
			return;
		}
		self::$instance = new self();
		foreach ( self::$instance->events as $event => $nargs ) {
			if ( is_array( $nargs ) ) {
				$nargs = count( $nargs );
			}
			add_action( $event, [ self::$instance, "on_$event" ], 10, $nargs );
		}
	}

	/**
	 * WPEngineSecurityAuditor_Events constructor.
	 */
	public function __construct() {
		$this->events = [
			// code changes
			'activated_plugin'          => [ 'plugin', 'network_activation' ],
			'deactivated_plugin'        => [ 'plugin', 'network_activation' ],
			'switch_theme'              => [ 'new_name' ],
			'upgrader_process_complete' => [ 'upgrader', 'info' ], // special cased
			// access changes
			'add_user_role'             => [ 'user_id', 'role' ],
			'remove_user_role'          => [ 'user_id', 'role' ],
			'set_user_role'             => [ 'user_id', 'role', 'old_roles' ],
			'granted_super_admin'       => [ 'user_id' ],
			'revoked_super_admin'       => [ 'user_id' ],
			// account changes
			'wp_login'                  => [ 'user_login', 'user' ], // special cased
			'user_register'             => [ 'user_id' ],
			'profile_update'            => [ 'user_id' ],
			'deleted_user'              => [ 'user_id', 'reassign' ],
			'retrieve_password_key'     => [ 'user_login' ],
			// option changes
			'updated_option'            => [ 'option', 'old_value', 'value' ],
			'added_option'              => [ 'option', 'value' ],
			'deleted_option'            => [ 'option' ],
		];
		$this->tracked_options = [
			'users_can_register' => 1,
			'default_role'       => 1,
			'siteurl'            => 1,
			'home'               => 1,
		];
	}

	/**
	 * Get current WordPress User
	 *
	 * @return WP_User|null
	 */
	private function get_current_user() {
		// when events fire before all plugins have loaded, unless already overridden
		// the pluggable.php functions will not yet be defined.
		if ( ! function_exists( 'wp_get_current_user' ) ) {
			return null;
		}
		return wp_get_current_user();
	}

	/**
	 * Log event to the error log
	 *
	 * NOTE: This method can be run before much of WordPress has loaded.
	 * Be sure that WP functions used here are imported before mu plugins are loaded.
	 *
	 * @param string $event Name of the event
	 * @param array  $attrs Data associated with the event
	 *
	 * @see https://github.com/WordPress/WordPress/blob/d0c1b77cda3f4ea0b502d2abd686e4ddd45dad7e/wp-settings.php#L292-L305
	 */
	public function log_event( $event, $attrs ) {
		$attrs['blog_id'] = get_current_blog_id();
		$attrs['event']   = $event;
		$u                = $this->get_current_user();
		if ( $u ) {
			$attrs['current_user_id'] = $u->ID;
		}
		if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
			$attrs['remote_addr'] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
		}
		error_log( "auditor:event=$event " . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES ) );
	}

	/**
	 * Log unexpected object context calls to this class
	 *
	 * @param string $name Attempted method call
	 * @param array  $args Args used in method call
	 */
	public function __call( $name, $args ) {
		if ( 'on_' !== substr( $name, 0, 3 ) || ! array_key_exists( substr( $name, 3 ), $this->events ) ) {
			error_log( "unexpected invocation of WPEngineSecurityAuditor_Events::$name" );
			return;
		}
		$event         = substr( $name, 3 );
		$expected_args = $this->events[ $event ];
		// pad arguments with null in case of non-conforming events
		$args = array_pad( $args, count( $expected_args ), null );
		// truncate arguments in case of additional parameters
		$args  = array_slice( $args, 0, count( $expected_args ) );
		$attrs = array_combine( $expected_args, $args );
		$this->log_event( $event, $attrs );
	}

	/**
	 * Log on wp_login hook
	 *
	 * Arguments elided to accomodate non-conforming events
	 *
	 * param $user_login string
	 * param $user WP_User
	 *
	 * @see https://developer.wordpress.org/reference/hooks/wp_login/
	 */
	public function on_wp_login() {
		if ( func_num_args() < 2 ) {
			// ignoring non-conforming event
			return;
		}
		$user = func_get_arg( 1 );
		// again, ignoring non-comforming events
		if ( $user instanceof WP_User ) {
			$this->log_event( 'wp_login', [ 'user_id' => $user->ID ] );
		}
	}

	/**
	 * Log on upgrader_process_complete hook
	 *
	 * @param WP_Upgrader $upgrader WP_Upgrader instance. Might be a Theme_Upgrader, Plugin_Upgrader, Core_Upgrade, or Language_Pack_Upgrader instance
	 * @param array       $info Array of bulk item update data.
	 *
	 * @see https://developer.wordpress.org/reference/hooks/upgrader_process_complete/
	 */
	public function on_upgrader_process_complete( $upgrader, $info ) {
		$type    = $info['type'];
		$plugins = array();
		$themes  = array();
		switch ( $type ) {
			case 'core':
				global $wp_version, $wp_db_version;
				$info['wp_version']    = $wp_version;
				$info['wp_db_version'] = $wp_db_version;
				$this->log_event( 'upgrader_process_complete_core', $info );
				break;
			case 'plugin':
				if ( ! empty( $info['plugin'] ) ) {
					$this->log_event( 'upgrader_process_complete_plugin', $info );
					break;
				}
				if ( isset( $info['plugins'] ) ) {
					$plugins = (array) $info['plugins'];
					unset( $info['plugins'] );
				} elseif ( is_array( $upgrader->result ) && ! empty( $upgrader->result['destination_name'] ) ) {
					$plugins[] = $upgrader->result['destination_name'];
				}
				foreach ( $plugins as $plugin ) {
					$info['plugin'] = $plugin;
					$this->log_event( 'upgrader_process_complete_plugin', $info );
				}
				break;
			case 'theme':
				if ( ! empty( $info['theme'] ) ) {
					$this->log_event( 'upgrader_process_complete_theme', $info );
					break;
				}
				if ( isset( $info['themes'] ) ) {
					$themes = $info['themes'];
					unset( $info['themes'] );
				} else {
					$themes = [ $themes ];
				}
				foreach ( $themes as $theme ) {
					$info['theme'] = $theme;
					$this->log_event( 'upgrader_process_complete_theme', $info );
				}
				break;
			default:
				error_log( "unrecognized upgrade type=$type" );
		}
	}

	/**
	 * Track only certain options
	 *
	 * @param string $option Name of the option
	 * @return bool
	 */
	private function is_tracked_option( $option ) {
		return array_key_exists( $option, $this->tracked_options );
	}

	/**
	 * Log on updated_option hook
	 *
	 * @param string $option Name of the option
	 * @param mixed  $old_value Previous value of the option
	 * @param mixed  $value Value of the option
	 *
	 * @see https://developer.wordpress.org/reference/hooks/updated_option/
	 */
	public function on_updated_option( $option, $old_value, $value ) {
		if ( ! $this->is_tracked_option( $option ) ) {
			return;
		}

		$this->log_event(
			'updated_option',
			[
				'option'    => $option,
				'old_value' => $old_value,
				'value'     => $value,
			]
		);
	}

	/**
	 * Log on added_option hook
	 *
	 * @param string $option Name of the option
	 * @param mixed  $value Value of the option
	 *
	 * @see https://developer.wordpress.org/reference/hooks/added_option/
	 */
	public function on_added_option( $option, $value ) {
		if ( ! $this->is_tracked_option( $option ) ) {
			return;
		}

		$this->log_event(
			'added_option',
			[
				'option' => $option,
				'value'  => $value,
			]
		);
	}

	/**
	 * Log on delete_option hook
	 *
	 * @param string $option Name of the option
	 *
	 * @see https://developer.wordpress.org/reference/hooks/on_deleted_option/
	 */
	public function on_deleted_option( $option ) {
		if ( ! $this->is_tracked_option( $option ) ) {
			return;
		}

		$this->log_event( 'deleted_option', [ 'option' => $option ] );
	}
}

/**
 * Class WPEngineSecurityAuditor_Scans
 */
class WPEngineSecurityAuditor_Scans {

	/**
	 * Singleton instance of this class
	 *
	 * @var WPEngineSecurityAuditor_Scans
	 */
	public static $instance;

	/**
	 * Initialize
	 *
	 * This is called on plugin start to create a singleton.
	 * We also setup WP Crons here.
	 */
	public static function initialize() {
		self::$instance = new self();
		add_action( 'WPEngineSecurityAuditor_Scans_scheduler', [ self::$instance, 'schedule_fingerprint' ] );
		add_action( 'WPEngineSecurityAuditor_Scans_fingerprint', [ self::$instance, 'fingerprint' ] );
		add_action( 'WPEngineSecurityAuditor_Scans_fingerprint_core', [ self::$instance, 'fingerprint_core' ] );
		add_action( 'WPEngineSecurityAuditor_Scans_fingerprint_plugins', [ self::$instance, 'fingerprint_plugins' ] );
		add_action( 'WPEngineSecurityAuditor_Scans_fingerprint_themes', [ self::$instance, 'fingerprint_themes' ] );
		add_action( 'init', [ self::$instance, 'ensure_scheduled' ] );
	}

	/**
	 * WPEngineSecurityAuditor_Scans constructor.
	 */
	public function __construct() {
	}

	/**
	 * Ensure fingerprints are setup in WP Cron
	 */
	public function ensure_scheduled() {
		if ( ! wp_next_scheduled( 'WPEngineSecurityAuditor_Scans_scheduler' ) ) {
			wp_schedule_event( time(), 'twicedaily', 'WPEngineSecurityAuditor_Scans_scheduler' );
		}
	}

	/**
	 * Schedule Fingerprint
	 *
	 * Setup WordPress cron for fingerprinting
	 */
	public function schedule_fingerprint() {
		// do scans at random times in the next 12 hours
		wp_schedule_single_event( time() + random_int( 1, 60 * 60 * 12 ), 'WPEngineSecurityAuditor_Scans_fingerprint_core' );
		wp_schedule_single_event( time() + random_int( 1, 60 * 60 * 12 ), 'WPEngineSecurityAuditor_Scans_fingerprint_plugins' );
		wp_schedule_single_event( time() + random_int( 1, 60 * 60 * 12 ), 'WPEngineSecurityAuditor_Scans_fingerprint_themes' );
	}

	/**
	 * Create tree hash
	 *
	 * @param string $path Location of dir to hash
	 * @return string Hash
	 */
	public static function sig_tree( $path ) {
		return 'v1:nohash';
	}

	/**
	 * Send info to error log
	 *
	 * @param string $kind Kind of scan being recorded (ex. mu-plugins)
	 * @param string $name Name of scanned item (ex. WP Engine Security Auditor)
	 * @param string $slug Slug of scanned item (ex. wpengine-security-auditor)
	 * @param string $ver Version of scanned item (ex. 1.0.4)
	 * @param string $sig Hash of scanned item
	 */
	public function log_info( $kind, $name, $slug, $ver, $sig ) {
		$data = [
			'blog_id' => get_current_blog_id(),
			'kind'    => $kind,
			'name'    => $name,
			'slug'    => $slug,
			'ver'     => $ver,
			'sig'     => $sig,
		];
		error_log( 'auditor:scan=fingerprint ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES ) );
	}

	/**
	 * Get plugin root dir
	 *
	 * @param string $plugin_file Full path of plugin file
	 * @return string
	 */
	private static function plugin_root( $plugin_file ) {
		$dir = dirname( $plugin_file );
		if ( WP_PLUGIN_DIR === $dir || WPMU_PLUGIN_DIR === $dir ) {
			return $plugin_file;
		}
		return $dir;
	}

	/**
	 * Fingerprint factory
	 */
	public function fingerprint() {
		// XXX legacy function to execute stale cron actions
		$this->fingerprint_plugins();
		$this->fingerprint_themes();
		$this->fingerprint_core();
	}

	/**
	 * Fingerprint plugins
	 */
	public function fingerprint_plugins() {
		if ( ! is_main_site() ) {
			// only calculate fingerprints for the main site.
			// XXX this skips per-blog activations states
			// XXX still recalculates fingerprints in a multinetwork
			return;
		}

		$plugin_headers = [
			'name' => 'Plugin Name',
			'ver'  => 'Version',
		];

		foreach ( wp_get_mu_plugins() as $path ) {
			$pd   = get_file_data( $path, $plugin_headers );
			$slug = plugin_basename( $path );
			$this->log_info( 'mu-plugin', $pd['name'], $slug, $pd['ver'], self::sig_tree( $path ) );
		}

		$scanned_path = [];
		foreach ( ( is_multisite() ? wp_get_active_network_plugins() : [] ) as $path ) {
			$pd                    = get_file_data( $path, $plugin_headers );
			$slug                  = plugin_basename( $path );
			$path                  = self::plugin_root( $path );
			$scanned_path[ $path ] = 1;
			$this->log_info( 'active-network-plugin', $pd['name'], $slug, $pd['ver'], self::sig_tree( $path ) );
		}

		foreach ( wp_get_active_and_valid_plugins() as $full_path ) {
			$pd   = get_file_data( $full_path, $plugin_headers );
			$slug = plugin_basename( $full_path );
			$path = self::plugin_root( $full_path );
			if ( array_key_exists( $path, $scanned_path ) ) {
				continue;
			}
			$scanned_path[ $path ] = 1;
			$this->log_info( 'active-plugin', $pd['name'], $slug, $pd['ver'], self::sig_tree( $path ) );
		}

		foreach ( get_plugins() as $plugin_path => $data ) {
			$full_path = WP_PLUGIN_DIR . '/' . $plugin_path;
			$slug      = plugin_basename( $full_path );
			$path      = self::plugin_root( $full_path );
			if ( array_key_exists( $path, $scanned_path ) ) {
				continue;
			}
			$this->log_info( 'installed-plugin', $data['Name'], $slug, $data['Version'], self::sig_tree( $path ) );
		}

	}

	/**
	 * Fingerprint Themes
	 */
	public function fingerprint_themes() {
		if ( ! is_main_site() ) {
			// only calculate fingerprints for the main site.
			// XXX this skips per-blog activations states
			// XXX still recalculates fingerprints in a multinetwork
			return;
		}

		foreach ( wp_get_themes() as $theme_name => $theme ) {
			$this->log_info( 'theme', $theme->name, $theme->get_stylesheet(), $theme->version, self::sig_tree( $theme->get_stylesheet_directory() ) );
		}

	}

	/**
	 * Fingerprint WP Core
	 */
	public function fingerprint_core() {
		if ( ! is_main_site() ) {
			// only calculate fingerprints for the main site.
			// XXX this skips per-blog activations states
			// XXX still recalculates fingerprints in a multinetwork
			return;
		}

		global $wp_version;

		$this->log_info( 'wp-core', 'wp-includes', 'wp-includes', $wp_version, self::sig_tree( ABSPATH . WPINC ) );
		$this->log_info( 'wp-core', 'wp-admin', 'wp-admin', $wp_version, self::sig_tree( ABSPATH . '/wp-admin' ) );
	}
}
