$read['issues'] ? (string) $read['issues'][0] : 'The package could not be read as a plugin ZIP.' ); } return array( 'profile' => ShouldCheck_Capability_Engine::inspect_zip( $path, (string) $read['metadata']['file'], $read['metadata'] ), 'header' => $read['metadata'], 'php_minimum' => $read['php_minimum'], 'hash' => (string) hash_file( 'sha256', $path ), 'bytes' => (int) filesize( $path ), ); } /** * Read an installed plugin folder into the same shape as scan_zip(). Files are only read. * * @param string $dir Absolute path of the plugin folder. * @param string $file Plugin file relative to the plugins folder, such as demo/demo.php. * @return array|array{error:string} */ public static function scan_directory( $dir, $file ) { $dir = rtrim( str_replace( '\\', '/', (string) $dir ), '/' ); if ( '' === $dir || ! is_dir( $dir ) ) { return array( 'error' => 'The installed plugin folder could not be found.' ); } $folder = (string) strtok( (string) $file, '/' ); $main = $dir . '/' . basename( (string) $file ); $header = is_file( $main ) ? ShouldCheck_Requirements::parse_header( (string) file_get_contents( $main, false, null, 0, ShouldCheck_Requirements::HEADER_BYTES ) ) : array(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, read-only. if ( ! $header ) { return array( 'error' => 'The installed plugin header could not be read.' ); } $header['file'] = $folder . '/' . basename( (string) $file ); $candidates = array(); $paths = array(); $count = 0; try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $it as $f ) { if ( ++$count > ShouldCheck_Capability_Engine::MAX_ENTRIES ) { return array( 'error' => 'The installed plugin has too many files to read.' ); } if ( $f->isLink() || ! $f->isFile() ) { continue; } $rel = $folder . '/' . ltrim( substr( str_replace( '\\', '/', $f->getPathname() ), strlen( $dir ) ), '/' ); $paths[] = $rel; if ( preg_match( '/\.php$/i', $rel ) && $f->getSize() >= 12 ) { $candidates[] = array( 'abs' => $f->getPathname(), 'path' => $rel, 'size' => (int) $f->getSize(), ); } } } catch ( Exception $e ) { return array( 'error' => 'The installed plugin folder could not be listed.' ); } $main_rel = $header['file']; usort( $candidates, function ( $a, $b ) use ( $main_rel ) { $ra = ShouldCheck_Capability_Engine::rank_path( $a['path'], $main_rel ); $rb = ShouldCheck_Capability_Engine::rank_path( $b['path'], $main_rel ); return $ra === $rb ? strcmp( $a['path'], $b['path'] ) : ( $ra < $rb ? -1 : 1 ); } ); $sources = array(); $bytes = 0; $skipped = 0; $print = array(); foreach ( $candidates as $c ) { if ( count( $sources ) >= ShouldCheck_Capability_Engine::MAX_FILES || $bytes >= ShouldCheck_Capability_Engine::MAX_TOTAL_BYTES || $c['size'] > ShouldCheck_Capability_Engine::SKIP_FILE_BYTES ) { ++$skipped; continue; } $slice = (int) min( $c['size'], ShouldCheck_Capability_Engine::MAX_FILE_BYTES, ShouldCheck_Capability_Engine::MAX_TOTAL_BYTES - $bytes ); $raw = $slice >= 12 ? file_get_contents( $c['abs'], false, null, 0, $slice ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, read-only. if ( ! is_string( $raw ) ) { ++$skipped; continue; } $bytes += strlen( $raw ); $source = array( 'path' => $c['path'], 'content' => $raw, 'truncated' => $c['size'] > strlen( $raw ), ); if ( $source['truncated'] && $c['size'] <= ShouldCheck_Capability_Engine::SKIP_FILE_BYTES && $bytes + $c['size'] <= ShouldCheck_Capability_Engine::MAX_TOTAL_BYTES ) { $rest = file_get_contents( $c['abs'], false, null, strlen( $raw ), $c['size'] - strlen( $raw ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, read-only. if ( is_string( $rest ) && '' !== $rest ) { $source['tail'] = $rest; $bytes += strlen( $rest ); } } $sources[] = $source; $print[] = $c['path'] . ':' . strlen( $raw ) . ':' . md5( $raw ); } sort( $print ); return array( 'profile' => ShouldCheck_Capability_Engine::scan( $sources, $paths, $header, $skipped, count( $candidates ) ), 'header' => $header, 'php_minimum' => null, 'hash' => hash( 'sha256', implode( "\n", $print ) ), 'bytes' => $bytes, ); } /** * Read a plugin that is a single file in the plugins folder. Only that file is read. * * @param string $abs Absolute path. * @param string $file Plugin file, main.php. * @return array */ public static function scan_file( $abs, $file ) { if ( ! is_file( $abs ) || is_link( $abs ) ) { return array( 'error' => 'The installed plugin file could not be found.' ); } $size = (int) filesize( $abs ); $header = ShouldCheck_Requirements::parse_header( (string) file_get_contents( $abs, false, null, 0, ShouldCheck_Requirements::HEADER_BYTES ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, read-only. if ( ! $header ) { return array( 'error' => 'The installed plugin header could not be read.' ); } $header['file'] = (string) $file; $slice = (int) min( $size, ShouldCheck_Capability_Engine::MAX_FILE_BYTES ); $raw = $size <= ShouldCheck_Capability_Engine::SKIP_FILE_BYTES && $slice >= 12 ? file_get_contents( $abs, false, null, 0, $slice ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, read-only. $sources = array(); if ( is_string( $raw ) ) { $source = array( 'path' => (string) $file, 'content' => $raw, 'truncated' => $size > strlen( $raw ), ); if ( $source['truncated'] ) { $rest = file_get_contents( $abs, false, null, strlen( $raw ), $size - strlen( $raw ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, read-only. if ( is_string( $rest ) && '' !== $rest ) { $source['tail'] = $rest; } } $sources[] = $source; } return array( 'profile' => ShouldCheck_Capability_Engine::scan( $sources, array( (string) $file ), $header, $sources ? 0 : 1, 1 ), 'header' => $header, 'php_minimum' => null, 'hash' => hash( 'sha256', $file . ':' . ( is_string( $raw ) ? md5( $raw ) : '' ) ), 'bytes' => is_string( $raw ) ? strlen( $raw ) : 0, ); } /** * Fetch and read the installed copy of a plugin. * * @param string $file Plugin file, folder/main.php. * @return array */ public static function installed( $file ) { if ( false === strpos( (string) $file, '/' ) ) { return self::scan_file( trailingslashit( WP_PLUGIN_DIR ) . basename( (string) $file ), (string) $file ); } $folder = (string) strtok( (string) $file, '/' ); return self::scan_directory( trailingslashit( WP_PLUGIN_DIR ) . $folder, $file ); } /** * The package for a pending update, if it can be read. Only the WordPress.org package address for this slug and version * is ever fetched, and only with the administrator's consent. * * @param string $file Plugin file. * @param object $candidate Update transient entry. * @return array{scan:?array,why:string,url:string} */ public static function candidate( $file, $candidate ) { $slug = (string) strtok( (string) $file, '/' ); $version = (string) ( $candidate->new_version ?? '' ); $package = (string) ( $candidate->package ?? '' ); $url = 'https://downloads.wordpress.org/plugin/' . rawurlencode( $slug ) . '.' . rawurlencode( $version ) . '.zip'; if ( '' === $package ) { return array( 'scan' => null, 'why' => 'The update has no download address, so only the update details were read. This is normal for paid plugins that update from their own site.', 'url' => '', ); } if ( $package !== $url ) { $host = (string) wp_parse_url( $package, PHP_URL_HOST ); return array( 'scan' => null, 'why' => 'The update comes from ' . ( '' !== $host ? $host : 'an unknown address' ) . ', not WordPress.org. ShouldCheck only reads packages from WordPress.org, so only the update details were read.', 'url' => '', ); } $scan = ShouldCheck_Directory::inspect( (object) array( 'slug' => $slug, 'version' => $version, ) ); if ( ! $scan ) { $why = ShouldCheck_Directory::failure_reason( (object) array( 'slug' => $slug, 'version' => $version, ) ); return array( 'scan' => null, 'why' => '' !== $why ? $why . ' Only the update details were read.' : 'The package was not read. Only the update details were read.', 'url' => $url, ); } return array( 'scan' => $scan, 'why' => '', 'url' => $url, ); } /** * Everything needed to review an update, gathered once: the installed copy and, when allowed, the proposed one. * * @param string $file Plugin file. * @param object $candidate Update transient entry. * @return array{installed:array,candidate:?array,why:string,url:string} */ public static function gather( $file, $candidate ) { $got = self::candidate( $file, $candidate ); $installed = $got['scan'] ? self::installed( $file ) : array(); return array( 'installed' => $installed, 'candidate' => $got['scan'], 'why' => $got['why'], 'url' => $got['url'], ); } /** * Names of the REST routes, logged-out actions and WooCommerce callbacks in a findings set, with whether each shows a security signal. * * @param array $f Findings. * @return array */ private static function entry_map( array $f ) { $map = array(); foreach ( (array) ( $f['routes'] ?? array() ) as $r ) { $map[ 'route:' . $r['path'] ] = array( 'sig' => ! empty( $r['sig'] ) || empty( $r['open'] ), 'file' => (string) $r['file'], 'label' => 'REST route ' . $r['path'], 'row' => 'routes', 'item' => $r, ); } foreach ( (array) ( $f['ajax'] ?? array() ) as $a ) { $map[ 'ajax:' . $a['name'] ] = array( 'sig' => ! empty( $a['sig'] ), 'file' => (string) $a['file'], 'label' => 'Logged-out action ' . $a['name'], 'row' => 'ajax', 'item' => $a, ); } foreach ( (array) ( $f['wc_api'] ?? array() ) as $a ) { $map[ 'wc:' . $a['name'] ] = array( 'sig' => ! empty( $a['sig'] ), 'file' => (string) $a['file'], 'label' => 'WooCommerce callback wc-api=' . $a['name'], 'row' => 'wc_api', 'item' => $a, ); } return $map; } /** * Labels of the code patterns the scanner looks for, by id. * * @return array */ private static function feature_labels() { $out = array(); foreach ( ShouldCheck_Capability_Engine::rules() as $rule ) { $out[ $rule['id'] ] = (string) ( $rule['label'] ?? $rule['id'] ); } return $out; } /** * The checks that describe what changed between the installed version and the proposed one. * * @param array $old Scan of the installed copy. * @param array $next Scan of the proposed package. * @param array $site Site snapshot. * @param string $own_file Plugin file. * @param string $name Plugin name. * @return array> */ public static function checks( array $old, array $next, array $site, $own_file, $name ) { $checks = array(); $op = (array) ( $old['profile'] ?? array() ); $np = (array) ( $next['profile'] ?? array() ); // The proposed package's own coverage note is shown by the package scan. Add one only for the installed copy. if ( 'complete-static-scan' === ( $np['coverage'] ?? '' ) && 'complete-static-scan' !== ( $op['coverage'] ?? '' ) ) { $checks[] = array( 'title' => __( 'The installed version was not fully read', 'shouldcheck' ), 'status' => 'not_checked', 'plan' => 'update_diff', 'source' => 'package-code', 'evidence' => 'The version you run was only partly read: some files were longer than the read limit or too many to read. Anything the new version adds in a file that was cut short on that side could show as a change that is not.', 'action' => '', ); } // Public entry points: new ones, and ones that lost the security signal they had. $of = self::entry_map( (array) ( $op['findings'] ?? ShouldCheck_Findings::none() ) ); $nf = self::entry_map( (array) ( $np['findings'] ?? ShouldCheck_Findings::none() ) ); $added = array_diff_key( $nf, $of ); $removed = array_diff_key( $of, $nf ); $lost = array(); foreach ( array_intersect_key( $nf, $of ) as $key => $entry ) { if ( $of[ $key ]['sig'] && ! $entry['sig'] ) { $lost[ $key ] = $entry; } } $entry_check = array( 'title' => __( 'Public entry points', 'shouldcheck' ), 'plan' => 'findings', 'source' => 'package-code', 'blocking' => false, ); if ( $added || $lost ) { $bits = array(); foreach ( array_slice( $added, 0, 4 ) as $e ) { $bits[] = $e['label'] . ' (' . $e['file'] . ')' . ( $e['sig'] ? ', with a security-related signal nearby' : ', with no security-related signal nearby' ); } foreach ( array_slice( $lost, 0, 3 ) as $e ) { $bits[] = $e['label'] . ' no longer shows a security-related signal nearby (' . $e['file'] . ')'; } $count = count( $added ) + count( $lost ); $checks[] = $entry_check + array( 'explain' => 'entry.update', 'status' => 'review', 'evidence' => 'Compared with the version you run, the new version adds or changes ' . $count . ' public entry point' . ( 1 === $count ? '' : 's' ) . ': ' . implode( '; ', $bits ) . ( $count > count( $bits ) ? '; and more' : '' ) . '. ShouldCheck reads code patterns, so a signal is a lead, not proof.', 'action' => 'Ask the author, or read the changelog, to see why these were added and how each one checks who is calling.', ); } else { $checks[] = $entry_check + array( 'explain' => 'entry.update.ok', 'status' => 'pass', 'evidence' => 'No public entry points were added or changed in the files read' . ( $removed ? ' (' . count( $removed ) . ' removed)' : '' ) . '. This compares code patterns; it does not say the existing ones are safe.', 'action' => '', ); } // Outside hosts named in the code. $new_hosts = array_values( array_diff( (array) ( $np['findings']['hosts'] ?? array() ), (array) ( $op['findings']['hosts'] ?? array() ) ) ); if ( $new_hosts ) { $checks[] = array( 'title' => __( 'New outside hosts', 'shouldcheck' ), 'explain' => 'hosts.new', 'status' => 'review', 'blocking' => false, 'plan' => 'findings', 'source' => 'package-code', 'evidence' => 'The new version names ' . count( $new_hosts ) . ' outside host' . ( 1 === count( $new_hosts ) ? '' : 's' ) . ' the installed version does not: ' . implode( ', ', array_slice( $new_hosts, 0, 6 ) ) . ( count( $new_hosts ) > 6 ? ', and more' : '' ) . '. A name in the code does not mean the plugin contacts it.', 'action' => 'Check the changelog for a new service the plugin talks to.', ); } // New capabilities: scheduled tasks, database changes, outbound requests, and so on. $labels = self::feature_labels(); $gained = array_keys( array_diff_key( (array) ( $np['features'] ?? array() ), (array) ( $op['features'] ?? array() ) ) ); $dropped = array_keys( array_diff_key( (array) ( $op['features'] ?? array() ), (array) ( $np['features'] ?? array() ) ) ); $diff = array( 'title' => __( 'New abilities in the code', 'shouldcheck' ), 'plan' => 'update_diff', 'source' => 'package-code', ); if ( $gained ) { $names = array(); foreach ( $gained as $id ) { $names[] = $labels[ $id ] ?? $id; } $checks[] = $diff + array( 'explain' => 'abilities.new', 'status' => 'review', 'blocking' => false, 'evidence' => 'Code patterns that the installed version does not have: ' . implode( '; ', array_slice( $names, 0, 6 ) ) . ( count( $names ) > 6 ? '; and more' : '' ) . '. These are patterns found in the files, not proof of what the plugin does.', 'action' => 'Read the changelog for these. Try the update on a staging copy and check them.', ); } else { $checks[] = $diff + array( 'explain' => 'abilities.same', 'status' => 'pass', 'evidence' => 'No code pattern was found that the installed version does not already have' . ( $dropped ? ' (' . count( $dropped ) . ' no longer found)' : '' ) . '.', 'action' => '', ); } // Names the new version declares that the installed version did not. The installed version already runs // beside your other plugins, so a name it already declared cannot be a new clash. $fresh = ShouldCheck_Symbols::none(); foreach ( array( 'functions', 'classes', 'constants' ) as $kind ) { $fresh[ $kind ] = array_values( array_diff( (array) ( $np['symbols'][ $kind ] ?? array() ), (array) ( $op['symbols'][ $kind ] ?? array() ) ) ); } $fresh['bundled'] = (array) ( $np['symbols']['bundled'] ?? array() ); if ( ! array_filter( array( $fresh['functions'], $fresh['classes'], $fresh['constants'] ) ) ) { $checks[] = array( 'title' => __( 'Name collisions with active plugins', 'shouldcheck' ), 'explain' => 'collision.none', 'status' => 'pass', 'plan' => 'collisions', 'source' => 'package-code', 'evidence' => 'The new version declares no function, class or constant name that the version you run does not already declare, so nothing new can clash with your other plugins. This is a name check, not a test.', 'action' => '', ); } elseif ( function_exists( 'get_transient' ) ) { foreach ( ShouldCheck_Collisions::checks( $fresh, $site, (string) $own_file, null, (string) $name ) as $c ) { $checks[] = $c; } } return $checks; } /** * Requirement changes between the installed header and the proposed requirements. * * @param array $old_header Installed header. * @param array $new_reqs requires_wp, requires_php, requires_woo. * @param array $site Site snapshot. * @return array> */ public static function requirement_changes( array $old_header, array $new_reqs, array $site ) { $out = array(); $rows = array( array( 'PHP', 'requires_php', 'php' ), array( 'WordPress', 'requires_wp', 'wordpress' ), array( 'WooCommerce', 'requires_woo', 'woocommerce' ), ); foreach ( $rows as $row ) { list( $noun, $key, $site_key ) = $row; $before = trim( (string) ( $old_header[ $key ] ?? '' ), " \n\r\t\v\x00" ); $after = trim( (string) ( $new_reqs[ $key ] ?? '' ), " \n\r\t\v\x00" ); if ( '' === $after || ! preg_match( '/^\d+(?:\.\d+){0,3}$/', $after ) ) { continue; } if ( '' !== $before && preg_match( '/^\d+(?:\.\d+){0,3}$/', $before ) && ! version_compare( $after, $before, '>' ) ) { continue; } if ( '' === $before ) { $text = 'The installed version does not declare a ' . $noun . ' minimum. The new one needs ' . $after . '.'; } else { $text = 'The ' . $noun . ' minimum goes from ' . $before . ' to ' . $after . '.'; } $have = $site[ $site_key ] ?? null; $met = ( null !== $have && version_compare( (string) $have, $after, '>=' ) ); $out[] = array( 'title' => $noun . ' minimum raised', 'status' => $met ? 'pass' : 'warning', 'blocking' => ! $met, 'plan' => 'requirements', 'source' => 'metadata', 'evidence' => $text . ' This site runs ' . ( null === $have ? 'none (not active)' : $have ) . '.' . ( $met ? ' That meets it, but it narrows where this plugin can be moved to later.' : '' ), 'action' => $met ? 'Nothing to fix today. Note it before you change your ' . $noun . ' version.' : 'Do not update until the site meets it.', ); } return $out; } }