<?php
/*
 * Imported with love from BC Security
 *
 * https://github.com/chesio/bc-security
 */
namespace WPSecurityNinja\Plugin;

class wf_sn_plugins_integrity extends wf_sn_ms {

   /**
	 * @var string URL of checksum API
	 */
   const CHECKSUMS_API_URL_BASE = 'https://downloads.wordpress.org/plugin-checksums/';
	/**
	 * @var string URL of Plugins Directory.
	 */
	const PLUGINS_DIRECTORY_URL = 'https://wordpress.org/plugins/';
	/**
	 * @var string Path (although not technically) to changelog page relative to URL of plugin homepage at Plugins Directory.
	 */
	const PLUGINS_DIRECTORY_CHANGELOG_PATH = '#developers';



	/**
	 * __construct.
	 *
	 * @author	Lars Koudal
	 * @since	v0.0.1
	 * @version	v1.0.0	Sunday, April 7th, 2024.
	 * @access	public
	 * @return	void
	 */
	public function __construct() {
		parent::__construct(
			__('Plugin files are untouched', 'security-ninja'),
			sprintf(
				/* translators: 1: link to Wikipedia article about md5sum, 2: link to Plugins Directory at WordPress.org */
				esc_html__('By comparing %1$s of local plugin files with checksums provided by WordPress.org it is possible to determine, if any of plugin files have been modified or if there are any unknown files in plugin directories. Note that this check works only with plugins installed from %2$s.', 'security-ninja'),
				'<a href="' . esc_url(__('https://en.wikipedia.org/wiki/Md5sum', 'security-ninja')) . '" rel="noreferrer">' . esc_html__('MD5 checksums', 'security-ninja') . '</a>',
				'<a href="' . esc_url(__('https://wordpress.org/plugins/', 'security-ninja')) . '" rel="noreferrer">' . esc_html__('Plugins Directory', 'security-ninja') . '</a>'
			)
		);
	}





	/**
	 * getChangelogUrl.
	 *
	 * @author	Lars Koudal
	 * @since	v0.0.1
	 * @version	v1.0.0	Sunday, January 3rd, 2021.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @return	mixed
	 */
	public static function getChangelogUrl(string $plugin_basename)
	{
		// By default, changelog URL is unknown.
		$url = '';
		if (self::hasReadmeTxt($plugin_basename)) {
			// Assume that any plugin with readme.txt comes from Plugins Directory.
			$url = self::getDirectoryUrl($plugin_basename) . self::PLUGINS_DIRECTORY_CHANGELOG_PATH;
		}
		// Allow the changelog URL to be filtered.
		return apply_filters(Hooks::PLUGIN_CHANGELOG_URL, $url, $plugin_basename);
	}






	
	/**
	 * getDirectoryUrl.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @return	mixed
	 */
	public static function getDirectoryUrl(string $plugin_basename)
	{
		return trailingslashit(self::PLUGINS_DIRECTORY_URL . self::getSlug($plugin_basename));
	}



	/**
	 * getChecksumsUrl.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @param	array 	$plugin_data    	
	 * @return	mixed
	 */
	public static function getChecksumsUrl(string $plugin_basename, array $plugin_data)
	{
		return self::CHECKSUMS_API_URL_BASE . self::getSlug($plugin_basename) . '/' . $plugin_data['Version'] . '.json';
	}


	/**
	 * Get slug (ie. bc-security) for plugin with given basename (ie. bc-security/bc-security.php).
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @return	mixed
	 */
	public static function getSlug(string $plugin_basename)
	{
		// This is fine most of the time and WPCentral/WP-CLI-Security gets the slug the same way,
		// but it does not seem to be guaranteed that slug is always equal to directory name...
		$slug = dirname($plugin_basename);
		// For single-file plugins, return empty string.
		return $slug === '.' ? '' : $slug;
	}




	/**
	 * hasReadmeTxt.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @return	mixed
	 */
	public static function hasReadmeTxt(string $plugin_basename)
	{
		return is_file(self::getPluginDirPath($plugin_basename) . '/readme.txt');
	}



	/**
	 * isVersionControlled.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @return	mixed
	 */
	public static function isVersionControlled(string $plugin_basename)
	{
		$plugin_dir = self::getPluginDirPath($plugin_basename);
		return is_dir($plugin_dir . '/.git') || is_dir($plugin_dir . '/.svn');
	}


	/**
	 * Get all installed plugins that seems to be hosted at WordPress.org repository (= have readme.txt file).
	 * Method effectively discards any plugins that are not in their own directory (like Hello Dolly) from output.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @return	mixed
	 */
	public static function getPluginsInstalledFromWordPressOrg()
	{
		// We're using some wp-admin stuff here, so make sure it's available.
		if (!function_exists('get_plugins')) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		// There seem to be no easy way to find out if plugin is hosted at WordPress.org repository or not, see:
		// https://core.trac.wordpress.org/ticket/32101
		return array_filter(
			get_plugins(),
			[self::class, 'hasReadmeTxt'],
			ARRAY_FILTER_USE_KEY
		);
	}


	/**
	 * getPluginData.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	
	 * @return	mixed
	 */
	public static function getPluginData(string $plugin_basename)
	{
		// Note: get_plugin_data() function is only defined in admin.
		return get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_basename);
	}




	/**
	 * Get absolute path to plugin directory for given $plugin_basename (ie. "bc-security/bc-security.php").
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$plugin_basename	Basename of plugin installed in its own directory.
	 * @return	mixed
	 */
	public static function getPluginDirPath(string $plugin_basename)
	{
		return wp_normalize_path(WP_PLUGIN_DIR . '/' . dirname($plugin_basename));
	}



	/**
	 * Create comma separated list of plugin names optionally with a link to plugin related URL.
	 *
	 * @param array $plugins
	 * @param string $linkTo [optional] If provided, plugin name will be turned into link to URL under given data key.
	 * @return string
	 */
	public static function implodeList($plugins = array(), $linkTo = '')
	{
		return implode(
			', ',
			array_map(
				function (array $plugin_data) use ($linkTo) {
					$plugin_name = '<em>' . esc_html($plugin_data['Name']) . '</em>';
					return $linkTo
					? '<a href="' . esc_url($plugin_data[$linkTo]) . '" rel="noreferrer">' . $plugin_name . '</a>'
					: $plugin_name
					;
				},
				$plugins
			)
		);
	}






	/**
	 * runInternal.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	static
	 * @return	mixed
	 */
	static function runInternal() {

		$plugins = self::getPluginsInstalledFromWordPressOrg();


		// Do not check plugins that are under version control.
		$plugins = array_filter($plugins, function ($plugin_basename) {
			return !self::isVersionControlled($plugin_basename);
		}, ARRAY_FILTER_USE_KEY);
		// Plugins for which checksums retrieval failed.
		$checksums_retrieval_failed = [];
		$validated_plugins = [];


		// Start pruning of old .json cache files
		$upload_dir = wp_upload_dir();
		$json_cache_folder = $upload_dir['basedir'] . '/security-ninja/checksums/';

		if (file_exists($json_cache_folder)) {
			foreach (new \DirectoryIterator($json_cache_folder) as $fileInfo) {
				if ($fileInfo->isDot()) {
					continue;
				}
				if ($fileInfo->isFile() && time() - $fileInfo->getCTime() >= 14 * 24 * 60 * 60) {
					unlink($fileInfo->getRealPath());
				}
			}
		}



		//
		$checksums_verification_failed = [];
		foreach ($plugins as $plugin_basename => $plugin_data) {

			$PluginDir = dirname($plugin_basename);
			$plugin_dir = trailingslashit(self::getPluginDirPath($plugin_basename));
			$validated_plugins[$plugin_basename]['Path'] = $plugin_dir;
			$validated_plugins[$plugin_basename]['Name'] = $plugin_data['Name'];
			$validated_plugins[$plugin_basename]['Version'] = $plugin_data['Version'];
			$validated_plugins[$plugin_basename]['PluginURI'] = $plugin_data['PluginURI'];
			$validated_plugins[$plugin_basename]['Author'] = $plugin_data['Author'];
			$validated_plugins[$plugin_basename]['AuthorURI'] = $plugin_data['AuthorURI'];

			// Get checksums URL.
			$checksums_url = self::getChecksumsUrl($plugin_basename, $plugin_data);

			// Save checksums URL along with plugin data for later.
			$plugin_data['ChecksumsURL'] = $checksums_url;
			// Get checksums.
			if (empty($checksums = self::getChecksums($checksums_url))) {
				$checksums_retrieval_failed[$plugin_basename] = $plugin_data;
				// Unset from list of validated directories
				unset($validated_plugins[$plugin_basename]);
				continue;
			}

			// Use checksums to find any modified files.
			$modified_response = self::checkDirectoryForModifiedFiles($plugin_dir, $checksums, ['readme.txt']);

			if ($modified_response) {
				$modified_files[$plugin_basename]['Name'] = $plugin_data['Name'];
				$modified_files[$plugin_basename]['Path'] = $plugin_dir;
				$modified_files[$plugin_basename]['files'] = $modified_response;
				// Unset from list of validated directories because files have been modified
				unset($validated_plugins[$plugin_basename]);
			}

			// Use checksums to find any unknown files.
			$unknown_response = self::scanDirectoryForUnknownFiles($plugin_dir, $plugin_dir, $checksums, true);


			if ($unknown_response) {
				$unknown_files = array();
				$unknown_files[$plugin_basename]['Name'] = $plugin_data['Name'];
				$unknown_files[$plugin_basename]['Path'] = $plugin_dir;
				$unknown_files[$plugin_basename]['files'] = $unknown_response;
				// Unset from list of validated directories because it is unknown
				unset($validated_plugins[$plugin_basename]);
			}

		}

		if (!isset($unknown_files)) $unknown_files=false;
		if (!isset($modified_files)) $modified_files=false;

		$collected = array(
			'validated_plugins' => $validated_plugins,
			'modified_files' => $modified_files,
			'unknown_files'	=> $unknown_files,
			'checksums_retrieval_failed' => $checksums_retrieval_failed
		);

		return $collected;

	}



   /**
    * Fetch JSON data from remote $url.
    * Caches files in /uploads/security-ninja/checksums/
    *
    * @author	Unknown
    * @since	v0.0.1
    * @version	v1.0.0	Friday, December 25th, 2020.
    * @access	public static
    * @param	mixed	$url	
    * @return	mixed
    */
   public static function getJson($url) {

   	$re = '#/plugin-checksums\/(.*)\/(.*)\.json#m';
		// extract name and version
   	preg_match_all($re, $url, $match, PREG_SET_ORDER, 0);

   	if ($match) {
   		$json_filename = $match[0][1].'-'.$match[0][2].'.json';
   	}
   	else {
			// could not figure out the filename!
   		return false;
   	}
   	$upload_dir = wp_upload_dir();

   	$json_cache_folder = $upload_dir['basedir'] . '/security-ninja/checksums/';

   	if ( file_exists( $json_cache_folder . $json_filename ) ) {

   		global $wp_filesystem;
   		if ( empty( $wp_filesystem ) ) {
   			require_once( ABSPATH . '/wp-admin/includes/file.php' );
   			WP_Filesystem();
   		}

   		$file_content = $wp_filesystem->get_contents( $json_cache_folder . $json_filename );

   		return json_decode($file_content);
   	}

// Cache folder does not exist - cache it...
   	if ( ! file_exists( $json_cache_folder ) ) {
   		wp_mkdir_p( $json_cache_folder );
   	}

// Make request to URL.
   	$response = wp_remote_get($url);
		// Check response code.
   	if (wp_remote_retrieve_response_code($response) !== 200) {
   		return null;
   	}

	  // Read JSON.
   	$json = json_decode(wp_remote_retrieve_body($response));

   	global $wp_filesystem;
   	if ( empty( $wp_filesystem ) ) {
   		require_once( ABSPATH . '/wp-admin/includes/file.php' );
   		WP_Filesystem();
   	}

   	if ( ! $wp_filesystem->put_contents(  $json_cache_folder . $json_filename, json_encode( $json ) , FS_CHMOD_FILE) ) {
   		// problem saving data
   		return null;

   	}

		// If decoding went fine, return JSON data.
   	return (json_last_error() === JSON_ERROR_NONE) ? $json : null;
   }




	/**
	 * Check md5 hashes of files under $path on local filesystem against $checksums and report any modified files.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @access	public static
	 * @param	mixed	$path         	Absolute path to checksums root directory, must end with slash!
	 * @param	mixed	$checksums    	Dictionary with { filename: checksum } items. All filenames must be relative to $path.
	 * @param	mixed	$ignored_files	List of filenames to ignore [optional].
	 * @return	mixed
	 */
	public static function checkDirectoryForModifiedFiles($path, $checksums, $ignored_files = array())
	{
		// Initialize array for files that do not match.
		$modified_files = [];
		// Loop through all files in list.

		foreach ($checksums as $filename => $checksum) {
			// Skip any ignored files.
			if (in_array($filename, $ignored_files, true)) {
				continue;
			}
			// Get absolute file path.
			$pathname = $path . $filename;
			// Check, if file exists (skip non-existing files).
			if (!file_exists($pathname)) {
				continue;
			}
			// Compare MD5 hashes.
			// Note that there can be multiple checksums provided for a single file (at least in plugin checksums).
			$md5 = md5_file($pathname);

			$md5 = hash_file('sha256', $pathname);

			if ( !is_array( $checksum ) ) {
				if ( ( $md5 <> $checksum ) ) {
					$modified_files[] = $filename;
				}
			}
			else {
				// the return is an array of different values, lets check them all
				$validfile = false;
				foreach ( $checksum as $tinycheck ) {
					if ( $md5 == $tinycheck ) {
						$validfile = true;
					}
				}
				if (!$validfile) {
					$modified_files[] = $filename;
				}
			}


		}

		return $modified_files;
	}


	/**
	 * Scan given $directory ($recursive-ly) and report any files not present in $checksums.
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	mixed  	$directory	Directory to scan, must be ABSPATH or a subdirectory thereof.
	 * @param	mixed  	$path     	Absolute path to checksums root directory, must end with slash!
	 * @param	mixed  	$checksums	
	 * @param	boolean	$recursive	Scan subdirectories too [optional].
	 * @return	mixed
	 */
	public static function scanDirectoryForUnknownFiles( $directory,  $path, $checksums, $recursive = false)
	{
		// Only allow to scan ABSPATH and subdirectories.
		if (strpos($directory, ABSPATH) !== 0) {
			_doing_it_wrong(__METHOD__, sprintf('Directory to scan (%s) is neither ABSPATH (%s) nor subdirectory thereof!', $directory, ABSPATH), '0.5.0');
			return [];
		}
		$unknown_files = [];
		// Get either recursive or normal directory iterator.

		$files = $recursive
		? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory))
		: new \DirectoryIterator($directory)
		;

		$directory_path_length = strlen($path);
		foreach ($files as $fileinfo) {
			// Skip directories as they don't have checksums.
			if ( (is_object($fileinfo)) && ($fileinfo->isDir()) ) {
				continue;
			}
			// Strip directory path from file's pathname.
			$filename = substr($fileinfo->getPathname(), $directory_path_length);
			// Check, whether it is a known file.
			if (!isset($checksums->$filename)) {

				$unknown_files[] = $fileinfo->getPathname();
			}
		}
		return $unknown_files;
	}

	/**
	 * Check, if HTTP request to $url results in 403 forbidden response.
	 * Method returns:
	 * true, if HTTP request to $url returns HTTP status 403.
	 * false, if HTTP request to $url returns HTTP status 200 and response body is equal to $body (if given) or 404
	 * is returned (meaning file does not exist, but access is not forbidden).
	 * null, in all other cases: especially if HTTP request to $url fails or other HTTP status than 200, 403 or 404
	 * is returned. Null is also returned for HTTP status 200 if response body is different than $body (if given).
	 *
	 * @author	Unknown
	 * @since	v0.0.1
	 * @version	v1.0.0	Friday, December 25th, 2020.
	 * @access	public static
	 * @param	string	$url 	URL to check.
	 * @param	mixed 	$body	Default: null
	 * @return	void
	 */
	public static function isAccessToUrlForbidden(string $url, $body = null) {
		// Try to get provided URL. Use HEAD request for simplicity, if response body is of no interest.
		$response = is_string($body) ? wp_remote_get($url) : wp_remote_head($url);
		switch (wp_remote_retrieve_response_code($response)) {
			case 200:
				// Status suggests that URL can be accessed, but check response body too, if given.
			return is_string($body) ? ((wp_remote_retrieve_body($response) === $body) ? false : null) : false;
			case 403:
				// Status suggests that access to URL is forbidden.
			return true;
			case 404:
				// Status suggests that no resource has been found, but access to URL is not forbidden.
			return false;
			default:
				// Otherwise assume nothing.
			return null;
		}
	}



  /**
   * formatLastRunTimestamp.
   *
   * @author	Unknown
   * @since	v0.0.1
   * @version	v1.0.0	Friday, December 25th, 2020.
   * @access	public static
   * @param	mixed	$check	
   * @return	void
   */
  public static function formatLastRunTimestamp( $check) 
  {
  	if (empty($timestamp = $check->getTimeOfLastRun())) {
  		return '--';
  	} else {
  		$format = sprintf('%s %s', get_option('date_format'), get_option('time_format'));
  		return date_i18n($format, $timestamp);
  	}
  }
	/**
	 * @param array $list
	 * @return string
	 */
	public static function formatListOfFiles(array $list)
	{
		return implode(', ', array_map(
			function (string $file) {
				return '<em>' . esc_html($file) . '</em>';
			},
			$list
		));
	}










	/**
	 * Get md5 checksums of plugin files from downloads.wordpress.org.
	 *
	 * @author 
	 * @since	v0.0.1
	 * @version	v1.0.0	Sunday, January 3rd, 2021.
	 * @access	private static
	 * @param	string	$url	
	 * @return	mixed
	 */
	private static function getChecksums(string $url)
	{
		$json = self::getJson($url);
		// Bail on error or if the response body is invalid.
		if (empty($json) || empty($json->files)) {
			return null;
		}
		// Return checksums as hashmap (stdClass): filename -> checksum.
		$checksums = [];
		foreach ($json->files as $filename => $file_checksums) {
			$checksums[$filename] = $file_checksums->sha256;
		}
		return (object) $checksums;
	}

}