*/ public static function expected_hashes( string $expected ): array { $hashes = array(); foreach ( explode( ',', strtolower( $expected ) ) as $hash ) { $hash = trim( $hash ); if ( self::is_md5( $hash ) ) { $hashes[] = $hash; } } return array_slice( array_values( array_unique( $hashes ) ), 0, self::MAX_ALTERNATIVES ); } /** * A checksum value from wordpress.org, which is an md5 string or (for a * plugin file that differed between builds of one version) a list of * them, as the stored comma-separated list. Null when nothing valid. * * @param mixed $value The decoded JSON value. */ public static function expected_from( $value ): ?string { $values = is_array( $value ) ? $value : array( $value ); $hashes = array(); foreach ( $values as $hash ) { if ( is_string( $hash ) && self::is_md5( strtolower( $hash ) ) ) { $hashes[] = strtolower( $hash ); } } $hashes = array_slice( array_values( array_unique( $hashes ) ), 0, self::MAX_ALTERNATIVES ); return array() === $hashes ? null : implode( ',', $hashes ); } /** * @param string $value Candidate. */ public static function is_md5( string $value ): bool { return 1 === preg_match( '/^[a-f0-9]{32}$/', $value ); } /** * How serious a finding is. * * High: anything that can run -- an executable in uploads, a changed or * unexpected PHP-family file anywhere, and any changed core file (core * is never edited by a legitimate workflow). Medium: other changed or * unexpected files (JavaScript and CSS can still deface or skim), and a * core file gone missing. Low: a plugin or theme file gone missing, which * breaks things but plants nothing. * * @param string $unit Unit key (`core`, `plugin:x`, `theme:x`, `plugins-root`, `uploads`). * @param string $path Path within the unit's root. * @param string $status A finding status. */ public static function severity( string $unit, string $path, string $status ): int { if ( self::OK === $status ) { return 0; } if ( self::EXECUTABLE === $status ) { return self::HIGH; } $core = 'core' === $unit; if ( self::MISSING === $status ) { return $core ? self::MEDIUM : self::LOW; } if ( $core || self::is_executable( $path ) ) { return self::HIGH; } return self::MEDIUM; } /** * Whether a web server might execute this file: its final extension is * executable, or ANY inner one is (`shell.php.jpg` runs on an Apache that * maps `.php` with AddHandler, the classic upload-filter bypass). * * @param string $path File path or name. */ public static function is_executable( string $path ): bool { $name = strtolower( basename( str_replace( '\\', '/', $path ) ) ); $parts = explode( '.', $name ); array_shift( $parts ); // The name before the first dot is never an extension. foreach ( $parts as $part ) { if ( in_array( $part, self::EXECUTABLE_EXTENSIONS, true ) ) { return true; } } return false; } /** * Whether a file is skipped when nothing vouches for it (see * UNHASHED_EXTENSIONS). An executable name is never skipped, whatever * else it ends in. * * @param string $path File path or name. */ public static function is_unhashed( string $path ): bool { if ( self::is_executable( $path ) ) { return false; } $extension = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ); return in_array( $extension, self::UNHASHED_EXTENSIONS, true ); } /** * Whether a PHP file can do nothing when requested: an open tag, comments * and whitespace, optionally a close tag -- the "Silence is golden" * `index.php` that core, this plugin's own geolocation directory and many * other plugins drop into upload folders to stop directory listings. * Anything else, including a single statement, is not inert. * * Read by the scanner from at most INERT_MAX_BYTES of the file, and * never stored. * * @param string $contents The file's contents. */ public static function is_inert_php( string $contents ): bool { $body = preg_replace( '/^\xEF\xBB\xBF/', '', $contents ); if ( ! is_string( $body ) ) { return false; } // Opening tag, then comments/whitespace only, then an optional close tag. $comment = '(?:\/\/[^\n]*(?:\n|$)|#[^\n]*(?:\n|$)|\/\*.*?\*\/|\s+)*'; return 1 === preg_match( '/^\s*<\?php(?=\s)' . $comment . '(?:\?>\s*)?$/s', $body ); } /** The most of a file read to decide is_inert_php(). */ public const INERT_MAX_BYTES = 1024; /** * Whether a path relative to a root is safe to join onto it: no parent * segments, not absolute, no NUL byte, not empty -- and no backslash on * a server where a backslash separates directories. Applied to every * path a checksum response names, and to every path an action names, * before the disk is touched. (On POSIX a backslash is an ordinary * filename character, and refusing it there would hide a file named * with one from review.) * * @param string $path Relative path. * @param string $separator The server's directory separator. */ public static function is_safe_relative( string $path, string $separator = DIRECTORY_SEPARATOR ): bool { if ( '' === $path || strlen( $path ) > 1024 || false !== strpos( $path, "\0" ) ) { return false; } if ( '\\' === $separator && false !== strpos( $path, '\\' ) ) { return false; } if ( '/' === $path[0] || 1 === preg_match( '/^[a-zA-Z]:/', $path ) ) { return false; } foreach ( explode( '/', $path ) as $segment ) { if ( '' === $segment || '.' === $segment || '..' === $segment ) { return false; } } return true; } }