meta */ public static function items() { $items = get_option( Malsift_Storage::OPT_QUARANTINE, array() ); return is_array( $items ) ? $items : array(); } /** * Files that must never be moved. * * @param string $rel Relative path. * @return bool */ public static function is_protected( $rel ) { $base = basename( $rel ); if ( in_array( $base, array( 'wp-config.php', 'wp-settings.php', 'wp-load.php', 'index.php' ), true ) && false === strpos( $rel, '/' ) ) { return true; } if ( 'wp-config.php' === $base ) { return true; } $own = Malsift_Storage::rel( MALSIFT_DIR ); return 0 === strpos( $rel, $own ); } /** * Check that a path is a real file/dir inside the site and not protected. * * @param string $rel Relative path. * @return string|WP_Error Absolute real path. */ public static function validate( $rel ) { $abs = Malsift_Storage::abs( $rel ); if ( is_link( $abs ) ) { // Act on the link itself, never on whatever it points to. $dir = realpath( dirname( $abs ) ); if ( ! $dir ) { return new WP_Error( 'malsift_missing', __( 'The file no longer exists.', 'malsift' ) ); } $abs = wp_normalize_path( $dir ) . '/' . basename( $abs ); if ( 0 !== strpos( $abs, Malsift_Storage::root() ) || self::is_protected( Malsift_Storage::rel( $abs ) ) ) { return new WP_Error( 'malsift_protected', __( 'This file cannot be quarantined.', 'malsift' ) ); } return $abs; } $real = realpath( $abs ); if ( ! $real ) { return new WP_Error( 'malsift_missing', __( 'The file no longer exists.', 'malsift' ) ); } $real = wp_normalize_path( $real ); $root = Malsift_Storage::root(); $parent = trailingslashit( wp_normalize_path( dirname( $root ) ) ); $inside = 0 === strpos( $real, $root ) || ( 0 === strpos( $real, $parent ) && dirname( $real ) . '/' === $parent ); if ( ! $inside || self::is_protected( Malsift_Storage::rel( $real ) ) ) { return new WP_Error( 'malsift_protected', __( 'This file cannot be quarantined.', 'malsift' ) ); } $data = Malsift_Storage::data_dir(); if ( $data && 0 === strpos( $real, wp_normalize_path( $data ) ) ) { return new WP_Error( 'malsift_protected', __( 'This file cannot be quarantined.', 'malsift' ) ); } return $real; } /** * Quarantine a file or a whole folder. * * @param string $rel Relative path. * @param string $reason Why. * @return true|WP_Error */ public static function add( $rel, $reason ) { $real = self::validate( $rel ); if ( is_wp_error( $real ) ) { return $real; } $fs = Malsift_Storage::fs(); if ( ! $fs || ! Malsift_Storage::data_dir() ) { return new WP_Error( 'malsift_fs', __( 'Malsift needs direct file access to quarantine files.', 'malsift' ) ); } $group = 'g' . time() . wp_rand( 1000, 9999 ); $folder = ! is_link( $real ) && is_dir( $real ) ? Malsift_Storage::rel( $real ) : ''; $files = array(); $links = array(); if ( is_link( $real ) ) { $links[] = $real; } elseif ( is_dir( $real ) ) { // Symlinked folders are not followed: the links themselves are recorded and removed. $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $real, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::SELF_FIRST ); foreach ( $it as $file ) { $path = wp_normalize_path( $file->getPathname() ); if ( $file->isLink() ) { $links[] = $path; } elseif ( $file->isFile() ) { $files[] = $path; } } } else { $files[] = $real; } $items = self::items(); foreach ( $files as $file ) { $stored = self::store( $file, $group, $reason, $folder ); if ( is_wp_error( $stored ) ) { return $stored; } $items[ $stored['id'] ] = $stored; } foreach ( $links as $link ) { $id = 'q' . substr( md5( $link . microtime() . wp_rand() ), 0, 16 ); $items[ $id ] = array( 'id' => $id, 'group' => $group, 'rel' => Malsift_Storage::rel( $link ), 'folder' => $folder, 'size' => 0, 'md5' => '', 'time' => time(), 'reason' => $reason, 'link' => (string) readlink( $link ), ); } update_option( Malsift_Storage::OPT_QUARANTINE, $items, false ); if ( ! self::remove( $real ) ) { return new WP_Error( 'malsift_delete', __( 'A copy was quarantined, but the original could not be removed. Check file permissions.', 'malsift' ) ); } return true; } /** * Delete a file, link or folder without ever following symbolic links. * * @param string $path Absolute path. * @return bool */ private static function remove( $path ) { // phpcs:disable WordPress.WP.AlternativeFunctions -- WP_Filesystem::delete() follows symlinked folders, which could delete files outside the site. if ( is_link( $path ) || is_file( $path ) ) { return unlink( $path ); } if ( ! is_dir( $path ) ) { return true; } $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $it as $file ) { $p = $file->getPathname(); $ok = ( $file->isLink() || ! $file->isDir() ) ? unlink( $p ) : rmdir( $p ); if ( ! $ok ) { return false; } } return rmdir( $path ); // phpcs:enable } /** * Keep a copy of a file that is about to be replaced with a clean official copy. * * @param string $abs Absolute path. * @param string $reason Reason. * @return true|WP_Error */ public static function backup( $abs, $reason ) { if ( ! Malsift_Storage::fs() || ! Malsift_Storage::data_dir() ) { return new WP_Error( 'malsift_fs', __( 'Malsift needs direct file access to repair files.', 'malsift' ) ); } $stored = self::store( $abs, 'g' . time() . wp_rand( 1000, 9999 ), $reason, '' ); if ( is_wp_error( $stored ) ) { return $stored; } $stored['replaced'] = 1; $items = self::items(); $items[ $stored['id'] ] = $stored; update_option( Malsift_Storage::OPT_QUARANTINE, $items, false ); return true; } /** * Back up several files (kept in place) as one quarantine group. * * @param string[] $files Absolute paths. * @param string $reason Reason. * @return int|WP_Error Number of files backed up. */ public static function backup_files( $files, $reason ) { if ( ! $files ) { return 0; } if ( ! Malsift_Storage::fs() || ! Malsift_Storage::data_dir() ) { return new WP_Error( 'malsift_fs', __( 'Malsift needs direct file access to make a backup.', 'malsift' ) ); } $group = 'g' . time() . wp_rand( 1000, 9999 ); $items = self::items(); $count = 0; foreach ( $files as $abs ) { $stored = self::store( $abs, $group, $reason, '' ); if ( is_wp_error( $stored ) ) { return $stored; } $stored['replaced'] = 1; $stored['label'] = $reason; $items[ $stored['id'] ] = $stored; ++$count; } update_option( Malsift_Storage::OPT_QUARANTINE, $items, false ); return $count; } /** * Store one file. * * @param string $abs Absolute path. * @param string $group Group id. * @param string $reason Reason. * @param string $folder Folder that was quarantined (if any). * @return array|WP_Error */ private static function store( $abs, $group, $reason, $folder ) { $fs = Malsift_Storage::fs(); $content = $fs->get_contents( $abs ); if ( false === $content ) { return new WP_Error( 'malsift_read', __( 'Could not read the file.', 'malsift' ) ); } $id = 'q' . substr( md5( $abs . microtime() . wp_rand() ), 0, 16 ); $dest = Malsift_Storage::data_dir() . 'quarantine/' . $id . '.bin'; if ( ! $fs->put_contents( $dest, gzcompress( $content ), FS_CHMOD_FILE ) ) { return new WP_Error( 'malsift_write', __( 'Could not write to the quarantine folder.', 'malsift' ) ); } return array( 'id' => $id, 'group' => $group, 'rel' => Malsift_Storage::rel( $abs ), 'folder' => $folder, 'size' => strlen( $content ), 'md5' => md5( $content ), 'time' => time(), 'reason' => $reason, ); } /** * Restore a quarantined group back to its original place. * * @param string $group Group id. * @return true|WP_Error */ public static function restore( $group ) { $fs = Malsift_Storage::fs(); if ( ! $fs || ! Malsift_Storage::data_dir() ) { return new WP_Error( 'malsift_fs', __( 'Malsift needs direct file access to restore files.', 'malsift' ) ); } $items = self::items(); $found = false; foreach ( $items as $id => $item ) { if ( $item['group'] !== $group ) { continue; } $found = true; $src = Malsift_Storage::data_dir() . 'quarantine/' . $id . '.bin'; $dest = Malsift_Storage::abs( $item['rel'] ); if ( is_link( $dest ) || ( $fs->exists( $dest ) && empty( $item['replaced'] ) ) ) { return new WP_Error( 'malsift_exists', __( 'A file already exists at the original location; it was not overwritten.', 'malsift' ) ); } if ( isset( $item['link'] ) ) { wp_mkdir_p( dirname( $dest ) ); if ( ! symlink( $item['link'], $dest ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- recreate the link exactly as it was. return new WP_Error( 'malsift_write', __( 'Could not recreate a symbolic link.', 'malsift' ) ); } unset( $items[ $id ] ); continue; } $raw = $fs->get_contents( $src ); $raw = false === $raw ? false : gzuncompress( $raw ); if ( false === $raw ) { return new WP_Error( 'malsift_read', __( 'The quarantined copy could not be read.', 'malsift' ) ); } wp_mkdir_p( dirname( $dest ) ); $fs->put_contents( $dest, $raw, FS_CHMOD_FILE ); $fs->delete( $src ); unset( $items[ $id ] ); } if ( ! $found ) { return new WP_Error( 'malsift_missing', __( 'Quarantine item not found.', 'malsift' ) ); } update_option( Malsift_Storage::OPT_QUARANTINE, $items, false ); return true; } /** * Permanently delete a quarantined group. * * @param string $group Group id. * @return true */ public static function purge( $group ) { $fs = Malsift_Storage::fs(); $items = self::items(); foreach ( $items as $id => $item ) { if ( $item['group'] === $group ) { $src = Malsift_Storage::data_dir() . 'quarantine/' . $id . '.bin'; if ( $fs && $fs->exists( $src ) ) { $fs->delete( $src ); } unset( $items[ $id ] ); } } update_option( Malsift_Storage::OPT_QUARANTINE, $items, false ); return true; } /** * Items grouped for display. * * @return array group => [label, time, reason, count, size] */ public static function groups() { $groups = array(); foreach ( self::items() as $item ) { $g = $item['group']; if ( ! isset( $groups[ $g ] ) ) { $groups[ $g ] = array( 'label' => ! empty( $item['label'] ) ? $item['label'] : ( $item['folder'] ? $item['folder'] . '/' : $item['rel'] ), 'time' => $item['time'], 'reason' => $item['reason'], 'count' => 0, 'size' => 0, ); } ++$groups[ $g ]['count']; $groups[ $g ]['size'] += $item['size']; } uasort( $groups, function ( $a, $b ) { return $b['time'] - $a['time']; } ); return $groups; } }