<?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 URL of Wikipedia article about MD5 checksums.
	 */
	const WIKIPEDIA_MD5SUM_URL = 'https://en.wikipedia.org/wiki/Md5sum';
	/**
	 * @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() {
		$link_md5     = '<a href="' . esc_url( self::WIKIPEDIA_MD5SUM_URL ) . '" rel="noopener noreferrer">' . esc_html__( 'MD5 checksums', 'security-ninja' ) . '</a>';
		$link_plugins = '<a href="' . esc_url( self::PLUGINS_DIRECTORY_URL ) . '" rel="noopener noreferrer">' . esc_html__( 'Plugins Directory', 'security-ninja' ) . '</a>';
		parent::__construct(
			__( 'Plugin files are untouched', 'security-ninja' ),
			sprintf(
				/* translators: 1: link to MD5 checksums (e.g. Wikipedia), 2: link to Plugins Directory */
				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' ),
				$link_md5,
				$link_plugins
			)
		);
	}





	/**
	 * 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(),
			array( 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 = array();
		$validated_plugins          = array();
		$suspicious_plugins         = array();
		$evaluated_plugin_dirs      = array();

		// 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 = array();
		foreach ( $plugins as $plugin_basename => $plugin_data ) {

			$PluginDir                                        = dirname( $plugin_basename );
			$plugin_dir                                       = trailingslashit( self::getPluginDirPath( $plugin_basename ) );
			$evaluated_plugin_dirs[]                          = $plugin_dir;
			$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( $validated_plugins[ $plugin_basename ] );
				$suspicious = Wf_Sn_Ms_Suspicious_Plugins::evaluate_checksum_failed_plugin(
					$plugin_dir,
					$plugin_data,
					self::getSlug( $plugin_basename )
				);
				if ( $suspicious ) {
					$suspicious_plugins[ $plugin_basename ] = $suspicious;
				}
				continue;
			}

			// Use checksums to find any modified files.
			$modified_response = self::checkDirectoryForModifiedFiles( $plugin_dir, $checksums, array( '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;
		}

		$orphan_results = Wf_Sn_Ms_Suspicious_Plugins::scan_orphan_directories( $evaluated_plugin_dirs );
		if ( ! empty( $orphan_results ) ) {
			$suspicious_plugins = array_merge( $suspicious_plugins, $orphan_results );
		}
		if ( empty( $suspicious_plugins ) ) {
			$suspicious_plugins = false;
		}

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

		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 = array();
		// 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 array();
		}
		$unknown_files = array();
		// 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 = array();
		foreach ( $json->files as $filename => $file_checksums ) {
			$checksums[ $filename ] = $file_checksums->sha256;
		}
		return (object) $checksums;
	}
}
