*/ public static function all() { $store = \GemSecurity\OptionStore::get_state( 'quarantine', [] ); return is_array( $store ) ? $store : []; } /** * Entries as a plain list, newest first — what the REST controllers return. * * @return array */ public static function items() { $items = array_values( self::all() ); usort( $items, static function ( $a, $b ) { return strcmp( (string) ( $b['quarantined_at'] ?? '' ), (string) ( $a['quarantined_at'] ?? '' ) ); } ); return $items; } /** * @param string $id * @return array|null */ public static function get( $id ) { $store = self::all(); return isset( $store[ $id ] ) ? $store[ $id ] : null; } /** * @param array $store * @return void */ public static function save( array $store ) { \GemSecurity\OptionStore::set_state( 'quarantine', $store ); } /** * Drop one entry from the index without touching the stored payload. * * @param string $id * @return void */ public static function forget( $id ) { $store = self::all(); unset( $store[ $id ] ); self::save( $store ); } /* ------------------------------------------------------------------ */ /* Files */ /* ------------------------------------------------------------------ */ /** * Move a file into quarantine. * * @param string $path Absolute path, anywhere inside the WordPress install. * @return array|\WP_Error The stored entry. */ public static function add_file( $path ) { // Resolve to the real path first: wp_normalize_path() leaves "../" in // place, so "ABSPATH/../../elsewhere" used to pass the prefix check, // and a symlink could point anywhere. $real = (string) $path !== '' ? realpath( (string) $path ) : false; $abs = $real ? wp_normalize_path( $real ) : ''; $root = wp_normalize_path( (string) realpath( \GemSecurity\Paths::root() ) ); $root = '' === $root ? wp_normalize_path( untrailingslashit( \GemSecurity\Paths::root() ) ) : $root; // Anywhere in the install, not just wp-content: real infections drop // their stubs into wp-admin and wp-includes too. Compared with a // trailing slash so /var/www/site2 is not "inside" /var/www/site. The // plugin's own directories are off limits so a scan can never // quarantine the scanner. $plugins = wp_normalize_path( (string) realpath( \GemSecurity\Paths::plugins() ) ); $own = '' !== $plugins && preg_match( '#^' . preg_quote( trailingslashit( $plugins ), '#' ) . 'gemsecurity(-pro)?/#', $abs ); if ( '' === $abs || 0 !== strpos( $abs, trailingslashit( $root ) ) || $own ) { return new \WP_Error( 'gemsecurity_quarantine_denied', __( 'That file is outside the WordPress installation.', 'gemsecurity' ), [ 'status' => 400 ] ); } if ( in_array( basename( $abs ), self::PROTECTED, true ) && dirname( $abs ) === $root ) { return new \WP_Error( 'gemsecurity_quarantine_protected', sprintf( /* translators: %s: file name. */ __( '%s is required to run WordPress. Replace it with a clean copy instead of quarantining it.', 'gemsecurity' ), basename( $abs ) ), [ 'status' => 400 ] ); } if ( ! is_file( $abs ) ) { return new \WP_Error( 'gemsecurity_quarantine_missing', __( 'File no longer exists.', 'gemsecurity' ), [ 'status' => 404 ] ); } if ( filesize( $abs ) > self::MAX_BYTES ) { return new \WP_Error( 'gemsecurity_quarantine_size', __( 'That file is too large to quarantine. Delete it or replace it with a clean copy.', 'gemsecurity' ), [ 'status' => 400 ] ); } $id = md5( $abs . microtime() ); $body = file_get_contents( $abs ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $body || ! add_option( self::BLOB_PREFIX . $id, base64_encode( $body ), '', false ) ) { return new \WP_Error( 'gemsecurity_quarantine_failed', __( 'Could not quarantine the file.', 'gemsecurity' ), [ 'status' => 500 ] ); } wp_delete_file( $abs ); if ( file_exists( $abs ) ) { delete_option( self::BLOB_PREFIX . $id ); return new \WP_Error( 'gemsecurity_quarantine_failed', __( 'Could not remove the file.', 'gemsecurity' ), [ 'status' => 500 ] ); } $relative = \GemSecurity\Paths::relative( $abs ); $store = self::all(); $store[ $id ] = [ 'id' => $id, 'type' => 'file', 'original' => $abs, 'relative' => $relative, 'label' => $relative, 'quarantined_at' => current_time( 'mysql' ), ]; self::save( $store ); Logger::log( 'quarantine', sprintf( 'Quarantined %s.', $relative ), Logger::SEVERITY_WARNING ); return $store[ $id ]; } /** * Keep a copy of a file that is about to be overwritten, leaving it in place. * * @param string $path Absolute path. * @param string $source Who took the snapshot (shown in the entry). * @return array|\WP_Error The stored entry. */ public static function snapshot( $path, $source ) { $abs = wp_normalize_path( (string) $path ); if ( ! is_file( $abs ) || filesize( $abs ) > self::MAX_BYTES ) { return new \WP_Error( 'gemsecurity_quarantine_failed', __( 'Could not keep a copy of the current file.', 'gemsecurity' ), [ 'status' => 500 ] ); } $id = md5( 'snap:' . $abs . microtime() ); $body = file_get_contents( $abs ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $body || ! add_option( self::BLOB_PREFIX . $id, base64_encode( $body ), '', false ) ) { return new \WP_Error( 'gemsecurity_quarantine_failed', __( 'Could not keep a copy of the current file.', 'gemsecurity' ), [ 'status' => 500 ] ); } $relative = \GemSecurity\Paths::relative( $abs ); $store = self::all(); $store[ $id ] = [ 'id' => $id, 'type' => 'file', 'original' => $abs, 'relative' => $relative, 'label' => $relative, 'source' => (string) $source, 'quarantined_at' => current_time( 'mysql' ), ]; self::save( $store ); return $store[ $id ]; } /** * Record a database snapshot (an injected option or post value that was * excised). The caller has already written the cleaned value. * * @param string $source 'option' | 'post'. * @param string $ref Option name or post ID. * @param string $original The original value, verbatim. * @param string $label Human label for the UI. * @return array The stored entry. */ public static function add_db( $source, $ref, $original, $label = '' ) { $id = md5( 'db:' . $source . ':' . $ref . ':' . microtime() ); $store = self::all(); $store[ $id ] = [ 'id' => $id, 'type' => 'db', 'db_source' => (string) $source, 'db_ref' => (string) $ref, 'original' => (string) $original, 'label' => $label ? (string) $label : sprintf( '%s: %s', $source, $ref ), 'quarantined_at' => current_time( 'mysql' ), ]; self::save( $store ); return $store[ $id ]; } /** * Put a quarantined file back where it came from. * * Database entries are not handled here — restoring one means writing a * value back into an option or a post, which is the caller's job; use * {@see get()} and {@see forget()} for those. * * @param string $id * @return true|\WP_Error */ public static function restore_file( $id ) { $entry = self::get( $id ); if ( ! $entry ) { return new \WP_Error( 'gemsecurity_restore_missing', __( 'Quarantine entry not found.', 'gemsecurity' ), [ 'status' => 404 ] ); } if ( 'file' !== ( $entry['type'] ?? 'file' ) ) { return new \WP_Error( 'gemsecurity_restore_type', __( 'That entry is a database snapshot, not a file.', 'gemsecurity' ), [ 'status' => 400 ] ); } if ( ! self::can_restore_to( $entry['original'] ) ) { return new \WP_Error( 'gemsecurity_restore_location', __( 'GemSecurity only restores non-executable files to the uploads folder. Re-install the plugin, theme or WordPress core file from its original source instead.', 'gemsecurity' ), [ 'status' => 400 ] ); } $body = self::payload( $id ); // A snapshot was taken of a file that was then replaced, so it may // overwrite; a quarantined file was removed and must not. $overwrite = 'core-file' === ( $entry['source'] ?? '' ); if ( null === $body || ! self::write_back( $entry['original'], $body, $overwrite ) ) { return new \WP_Error( 'gemsecurity_restore_failed', __( 'Could not restore the file.', 'gemsecurity' ), [ 'status' => 500 ] ); } self::delete_payload( $id ); self::forget( $id ); Logger::log( 'quarantine_restore', sprintf( 'Restored %s.', $entry['label'] ), Logger::SEVERITY_NOTICE ); return true; } /** * Files are only ever put back inside the uploads folder. Plugin, theme, * must-use and core folders are never written to; a file that came from * one of those is re-installed from its original source instead. * * @param string $path Absolute original path. * @return bool */ public static function can_restore_to( $path ) { $path = (string) $path; if ( '' === $path ) { return false; } // Never put back anything the server could run or that changes how it // treats a folder: a quarantined back-door must stay quarantined. $name = strtolower( basename( $path ) ); $ext = strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) ); if ( in_array( $name, [ '.htaccess', '.user.ini', 'web.config', 'php.ini' ], true ) || in_array( $ext, [ 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phps', 'phar', 'inc', 'shtml', 'cgi', 'pl', 'py', 'sh' ], true ) ) { return false; } $uploads = wp_normalize_path( (string) realpath( \GemSecurity\Paths::uploads() ) ); $dir = wp_normalize_path( (string) realpath( dirname( $path ) ) ); return '' !== $uploads && '' !== $dir && 0 === strpos( trailingslashit( $dir ), trailingslashit( $uploads ) ); } /** * Put a file's bytes back at the path it was quarantined from. * * @param string $path Absolute original path. * @param string $body * @param bool $overwrite Replace an existing file. * @return bool */ public static function write_back( $path, $body, $overwrite = false ) { $path = (string) $path; if ( ! self::can_restore_to( $path ) ) { return false; } if ( '' === $path || ( ! $overwrite && file_exists( $path ) ) || ( ! is_dir( dirname( $path ) ) && ! wp_mkdir_p( dirname( $path ) ) ) ) { return false; } return false !== file_put_contents( $path, $body, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- restores the site's own file to where it came from. } /* ------------------------------------------------------------------ */ /* Upgrade */ /* ------------------------------------------------------------------ */ /** * Fold the two legacy indexes into this one. * * The old Incident Response index was a *list* of * `[ id, orig, stored, time ]`; the old Pro malware index was a keyed map * living inside the Pro scanner's state option. Both wrote their payloads * into the legacy directory, which {@see payload()} still reads — only the * bookkeeping is merged. Idempotent: entries already keyed by id are left * alone, and the legacy Pro map is cleared once folded in. * * @return void */ public static function migrate_legacy() { $store = self::all(); // Legacy Incident Response entries are the numerically-indexed ones. $merged = []; foreach ( $store as $key => $entry ) { if ( ! is_array( $entry ) || empty( $entry['id'] ) ) { continue; } $id = (string) $entry['id']; if ( is_int( $key ) || ! isset( $entry['type'] ) ) { $relative = isset( $entry['orig'] ) ? \GemSecurity\Paths::relative( wp_normalize_path( $entry['orig'] ) ) : $id; $merged[ $id ] = [ 'id' => $id, 'type' => 'file', 'original' => isset( $entry['orig'] ) ? $entry['orig'] : '', 'relative' => $relative, 'label' => $relative, 'quarantined_at' => isset( $entry['time'] ) ? $entry['time'] : current_time( 'mysql' ), ]; continue; } $merged[ $id ] = $entry; } // Legacy Pro malware entries. $pro = get_option( self::LEGACY_PRO_OPTION, [] ); if ( is_array( $pro ) && ! empty( $pro['quarantine'] ) && is_array( $pro['quarantine'] ) ) { foreach ( $pro['quarantine'] as $id => $entry ) { if ( is_array( $entry ) && ! isset( $merged[ $id ] ) ) { $entry['id'] = (string) $id; $merged[ $id ] = $entry; } } $pro['quarantine'] = []; update_option( self::LEGACY_PRO_OPTION, $pro, false ); } self::save( $merged ); } }