{ hash, marked_at, marked_by }. * * @return array */ public static function all() { if ( null === self::$cache ) { $stored = get_option( self::OPTION, [] ); self::$cache = is_array( $stored ) ? $stored : []; } return self::$cache; } /** ABSPATH-relative, forward-slashed path for an absolute one. */ public static function relative( $abs ) { return \GemSecurity\Paths::relative( wp_normalize_path( $abs ) ); } /** * Should this file be reported at all? * * @param string $abs Absolute path. * @return bool */ public static function is_safe( $abs ) { if ( self::is_inert_php( $abs ) ) { return true; } return self::is_marked( $abs ); } /** * Has a person marked this exact file — same contents — as reviewed? * * @param string $abs * @return bool */ public static function is_marked( $abs ) { $list = self::all(); $rel = self::relative( $abs ); if ( ! isset( $list[ $rel ] ) ) { return false; } if ( ! is_file( $abs ) ) { return false; } // The mark belongs to the contents that were reviewed, not to the name. return isset( $list[ $rel ]['hash'] ) && hash_equals( (string) $list[ $rel ]['hash'], (string) md5_file( $abs ) ); } /** * A PHP file with nothing in it that can run. * * Decided from the token stream rather than by matching the familiar * "Silence is golden" wording, so every variant a plugin author invents — * a different comment, a stray blank line, an empty file — is covered, and * a file that merely *contains* that comment alongside real code is not. * * @param string $abs * @return bool */ public static function is_inert_php( $abs ) { if ( ! is_file( $abs ) ) { return false; } // Anything of consequence is bigger than this; keeps the check cheap. if ( filesize( $abs ) > 1024 ) { return false; } if ( 'php' !== strtolower( pathinfo( $abs, PATHINFO_EXTENSION ) ) ) { return false; } $source = @file_get_contents( $abs ); // phpcs:ignore if ( false === $source ) { return false; } if ( ! function_exists( 'token_get_all' ) ) { // Fall back to the shape rather than guessing. return (bool) preg_match( '/\A\s*<\?php\s*(\/\/[^\n]*|#[^\n]*|\/\*.*?\*\/|\s)*\s*\z/s', $source ); } $tokens = @token_get_all( $source ); // phpcs:ignore if ( ! is_array( $tokens ) ) { return false; } $harmless = [ T_OPEN_TAG, T_CLOSE_TAG, T_COMMENT, T_DOC_COMMENT, T_WHITESPACE ]; foreach ( $tokens as $token ) { if ( is_string( $token ) ) { return false; // Punctuation means a statement. } if ( in_array( $token[0], $harmless, true ) ) { continue; } // Text outside PHP tags is only harmless when it is blank. if ( T_INLINE_HTML === $token[0] && '' === trim( $token[1] ) ) { continue; } return false; } return true; } /** * Mark a file as reviewed and safe. * * @param string $abs * @return array|\WP_Error The stored entry. */ public static function mark( $abs ) { $abs = wp_normalize_path( $abs ); if ( 0 !== strpos( $abs, \GemSecurity\Paths::root() ) ) { return new \WP_Error( 'gemsecurity_outside_site', __( 'That file is outside this WordPress installation.', 'gemsecurity' ), [ 'status' => 400 ] ); } if ( ! is_file( $abs ) ) { return new \WP_Error( 'gemsecurity_missing_file', __( 'That file no longer exists.', 'gemsecurity' ), [ 'status' => 404 ] ); } $rel = self::relative( $abs ); $list = self::all(); $list[ $rel ] = [ 'hash' => md5_file( $abs ), 'marked_at' => current_time( 'mysql' ), 'marked_by' => get_current_user_id(), ]; update_option( self::OPTION, $list, false ); self::$cache = $list; Logger::log( 'file_marked_safe', sprintf( 'Marked %s as reviewed and safe.', $rel ), Logger::SEVERITY_NOTICE ); return [ 'path' => $rel ] + $list[ $rel ]; } /** * Stop treating a file as safe. * * @param string $rel ABSPATH-relative path. * @return bool */ public static function unmark( $rel ) { $list = self::all(); if ( ! isset( $list[ $rel ] ) ) { return false; } unset( $list[ $rel ] ); update_option( self::OPTION, $list, false ); self::$cache = $list; Logger::log( 'file_unmarked_safe', sprintf( 'Removed the safe mark from %s.', $rel ), Logger::SEVERITY_NOTICE ); return true; } /** * The list for the UI, with a note where the file has changed since it was * reviewed — that entry no longer suppresses anything and should be either * re-reviewed or removed. * * @return array */ public static function for_js() { $out = []; foreach ( self::all() as $rel => $entry ) { $abs = \GemSecurity\Paths::root() . $rel; $exists = is_file( $abs ); $current = $exists ? md5_file( $abs ) : ''; $out[] = [ 'path' => $rel, 'marked_at' => isset( $entry['marked_at'] ) ? $entry['marked_at'] : '', 'missing' => ! $exists, 'changed' => $exists && isset( $entry['hash'] ) && ! hash_equals( (string) $entry['hash'], (string) $current ), ]; } return $out; } }