'idle', 'last_attempt' => 0, 'last_success' => 0, 'last_error' => '', 'offset' => 0, 'file' => '', 'wanted' => array(), 'records' => 0, 'copyright' => '', ), is_array( $state ) ? $state : array() ); } /** * Starts a daily sync when due. * * @return bool Whether a sync was started. */ public static function maybe_schedule() { if ( ! self::is_enabled() ) { return false; } $state = self::sync_state(); if ( time() - $state['last_success'] < DAY_IN_SECONDS - HOUR_IN_SECONDS ) { return false; } return self::start(); } /** * Starts a sync now, if the rate limit allows it. * * @return true|\WP_Error */ public static function start() { if ( ! self::is_enabled() ) { return new \WP_Error( 'disabled', __( 'Enable the vulnerability module and add a Wordfence Intelligence API key first.', 'maintained-log' ) ); } $state = self::sync_state(); // A sync stuck for more than a day (e.g. cron stopped) can be restarted. if ( 'idle' !== $state['state'] && time() - $state['last_attempt'] < DAY_IN_SECONDS ) { return new \WP_Error( 'running', __( 'A vulnerability sync is already in progress.', 'maintained-log' ) ); } if ( time() - $state['last_attempt'] < self::MIN_INTERVAL ) { return new \WP_Error( 'rate_limited', __( 'Wordfence allows one download every 30 minutes. Try again later.', 'maintained-log' ) ); } $state['state'] = 'download'; $state['last_attempt'] = time(); $state['wanted'] = self::wanted_software(); update_option( self::SYNC_OPTION, $state, false ); self::schedule_tick( 0 ); return true; } /** * Advances the sync one step: download, then parse in slices. * * Steps can be driven by cron or by the settings screen at the same time, * so only one runs at once. * * @return bool Whether a step ran (false if another one is in progress). */ public static function tick() { // add_option() fails if the row exists, which makes it a cheap lock. if ( ! add_option( self::LOCK_OPTION, time(), '', false ) ) { if ( time() - (int) get_option( self::LOCK_OPTION ) < 10 * MINUTE_IN_SECONDS ) { return false; } update_option( self::LOCK_OPTION, time(), false ); } try { self::step(); } finally { delete_option( self::LOCK_OPTION ); } return true; } /** * One sync step. */ private static function step() { $state = self::sync_state(); if ( 'download' === $state['state'] ) { $file = self::download(); if ( is_wp_error( $file ) ) { self::fail( $state, $file->get_error_message() ); return; } $state['state'] = 'parse'; $state['file'] = $file; $state['offset'] = 0; $state['format'] = self::detect_format( $file ); update_option( self::PARTIAL_OPTION, array(), false ); update_option( self::SYNC_OPTION, $state, false ); self::schedule_tick( 5 ); return; } if ( 'parse' === $state['state'] ) { if ( ! $state['file'] || ! file_exists( $state['file'] ) ) { self::fail( $state, __( 'The downloaded feed file is missing.', 'maintained-log' ) ); return; } $partial = get_option( self::PARTIAL_OPTION, array() ); $result = self::parse_slice( $state['file'], $state['offset'], array_flip( $state['wanted'] ), is_array( $partial ) ? $partial : array(), 0, isset( $state['format'] ) ? $state['format'] : 'json' ); if ( is_wp_error( $result ) ) { self::fail( $state, $result->get_error_message() ); return; } $state['offset'] = $result['offset']; $state['records'] += $result['records']; if ( '' === $state['copyright'] && '' !== $result['copyright'] ) { $state['copyright'] = $result['copyright']; } if ( ! $result['done'] ) { update_option( self::PARTIAL_OPTION, $result['index'], false ); update_option( self::SYNC_OPTION, $state, false ); self::schedule_tick( 5 ); return; } if ( 0 === $state['records'] ) { self::fail( $state, __( 'The feed did not contain any vulnerability records. Its format may have changed.', 'maintained-log' ) ); return; } update_option( self::INDEX_OPTION, $result['index'], false ); delete_option( self::PARTIAL_OPTION ); wp_delete_file( $state['file'] ); $state['state'] = 'idle'; $state['file'] = ''; $state['last_success'] = time(); $state['last_error'] = ''; $total = $state['records']; $state['records'] = 0; $state['indexed'] = array_sum( array_map( 'count', $result['index'] ) ); $state['total'] = $total; update_option( self::SYNC_OPTION, $state, false ); self::notify_new_exposures(); } } /** * Runs a whole sync in this process (WP-CLI). * * @return true|\WP_Error */ public static function sync_now() { $started = self::start(); if ( is_wp_error( $started ) && 'running' !== $started->get_error_code() ) { return $started; } wp_clear_scheduled_hook( self::TICK_HOOK ); do { self::tick(); wp_clear_scheduled_hook( self::TICK_HOOK ); $state = self::sync_state(); } while ( 'idle' !== $state['state'] ); return '' === $state['last_error'] ? true : new \WP_Error( 'sync_failed', $state['last_error'] ); } /** * Progress of the current sync, 0–100. * * @return int */ public static function progress() { $state = self::sync_state(); if ( 'download' === $state['state'] ) { return 5; } if ( 'parse' === $state['state'] && $state['file'] && file_exists( $state['file'] ) ) { return 10 + (int) floor( 90 * $state['offset'] / max( 1, filesize( $state['file'] ) ) ); } return 'idle' === $state['state'] ? 100 : 0; } /** * Vulnerabilities affecting a version of a component. * * @param string $type plugin, theme or core. * @param string $slug WordPress.org slug ("WordPress" for core). * @param string $version Version. * @return array[] */ public static function affecting( $type, $slug, $version ) { $index = get_option( self::INDEX_OPTION, array() ); $key = $type . ':' . $slug; if ( '' === (string) $version || empty( $index[ $key ] ) ) { return array(); } return array_values( array_filter( $index[ $key ], static function ( $vuln ) use ( $version ) { return self::in_ranges( $version, $vuln['ranges'] ); } ) ); } /** * Vulnerabilities that affected $from and no longer affect $to. * * @param string $type plugin, theme or core. * @param string $slug Slug. * @param string $from Version before the update. * @param string $to Version after the update. * @return array[] Most severe first. */ public static function fixed_by( $type, $slug, $from, $to ) { if ( '' === (string) $from || '' === (string) $to ) { return array(); } $fixed = array_values( array_filter( self::affecting( $type, $slug, $from ), static function ( $vuln ) use ( $to ) { return ! self::in_ranges( $to, $vuln['ranges'] ); } ) ); usort( $fixed, static function ( $a, $b ) { return $b['score'] <=> $a['score']; } ); return $fixed; } /** * Vulnerabilities fixed by a logged update entry. * * @param object $entry Update entry. * @return array[] */ public static function fixed_by_entry( $entry ) { $d = $entry->details; if ( empty( $d['kind'] ) || 'updated' !== $d['kind'] || empty( $d['component'] ) ) { return array(); } $target = self::target( $d['component'], isset( $d['key'] ) ? $d['key'] : '' ); if ( ! $target ) { return array(); } $name = isset( $d['name'] ) ? $d['name'] : ''; // A flaw of another edition (e.g. Premium) must not be presented to the client as fixed. return array_values( array_filter( self::fixed_by( $target[0], $target[1], $d['from'], $d['to'] ), static function ( $vuln ) use ( $name ) { return ! self::edition_mismatch( $vuln, $name ); } ) ); } /** * Installed software currently affected by known vulnerabilities. * * Each exposure says whether WordPress offers an update, the version that * fixes everything, which vulnerabilities may belong to another edition * (e.g. Premium vs free) and which were reviewed by the administrator: * * - state "active": at least one vulnerability needs attention. * - state "inactive": same, but the plugin or theme is deactivated (lower risk). * - state "uncertain": the only pending ones may belong to another edition. * - state "reviewed": every vulnerability was marked as reviewed. * * @return array[] Active first, then uncertain, then reviewed. */ public static function exposures() { $snapshot = Inventory::get_stored(); if ( ! $snapshot ) { return array(); } $items = array( array( 'core', 'wordpress', 'WordPress', $snapshot['core']['wordpress'], '', true ), ); foreach ( $snapshot['plugins'] as $file => $plugin ) { $target = self::target( 'plugin', $file ); if ( $target ) { $items[] = array( 'plugin', $target[1], $plugin['name'], $plugin['version'], $file, ! empty( $plugin['active'] ) ); } } foreach ( $snapshot['themes'] as $slug => $theme ) { // A parent theme in use by the active child theme counts as active. $items[] = array( 'theme', $slug, $theme['name'], $theme['version'], $slug, ! empty( $theme['active'] ) ); } $reviewed = self::reviewed(); $exposures = array(); foreach ( $items as $item ) { $vulns = self::affecting( $item[0], $item[1], $item[3] ); if ( ! $vulns ) { continue; } usort( $vulns, static function ( $a, $b ) { return $b['score'] <=> $a['score']; } ); $key = self::review_key( $item[0], $item[1], $item[3] ); $review = isset( $reviewed[ $key ] ) ? $reviewed[ $key ] : null; $active = 0; $doubt = 0; foreach ( $vulns as $i => $vuln ) { $vulns[ $i ]['mismatch'] = self::edition_mismatch( $vuln, $item[2] ); $vulns[ $i ]['reviewed'] = $review && in_array( $vuln['id'], $review['ids'], true ); if ( ! $vulns[ $i ]['reviewed'] ) { $vulns[ $i ]['mismatch'] ? ++$doubt : ++$active; } } $update = self::available_update( $item[0], $item[4] ); $state = $active ? ( $item[5] ? 'active' : 'inactive' ) : ( $doubt ? 'uncertain' : 'reviewed' ); $exposures[] = array( 'type' => $item[0], 'slug' => $item[1], 'name' => $item[2], 'version' => $item[3], 'vulns' => $vulns, 'fixable' => count( array_filter( wp_list_pluck( $vulns, 'patched' ) ) ) === count( $vulns ), 'fixed_in' => self::fixed_in( $vulns, $item[3] ), 'update_available' => '' !== $update, 'update_version' => $update, 'state' => $state, 'enabled' => $item[5], 'review' => $review, 'review_key' => $key, ); } $order = array( 'active' => 0, 'inactive' => 1, 'uncertain' => 2, 'reviewed' => 3, ); usort( $exposures, static function ( $a, $b ) use ( $order ) { return $order[ $a['state'] ] <=> $order[ $b['state'] ]; } ); return $exposures; } /** * Exposures that need attention (not reviewed, not a likely edition mix-up). * * @return array[] */ public static function active_exposures() { return array_values( array_filter( self::exposures(), static function ( $exposure ) { return 'active' === $exposure['state']; } ) ); } /** * Deactivated plugins and themes with vulnerabilities that need attention. * Counted apart: their code is not loaded, so the risk is lower but not zero. * * @return array[] */ public static function inactive_exposures() { return array_values( wp_list_filter( self::exposures(), array( 'state' => 'inactive' ) ) ); } /** * Whether a plugin or theme is active now, from the last inventory. * * @param string $component plugin, theme or core. * @param string $key Inventory key. * @return bool */ public static function is_enabled_component( $component, $key ) { if ( 'core' === $component ) { return true; } $snapshot = Inventory::get_stored(); $group = 'plugin' === $component ? 'plugins' : 'themes'; // Unknown (e.g. removed since): assume it was in use. return ! isset( $snapshot[ $group ][ $key ] ) || ! empty( $snapshot[ $group ][ $key ]['active'] ); } /** * Whether a vulnerability seems to belong to another edition of the plugin. * * Free and Premium/Pro editions sometimes share the same slug but number * their versions differently, so a Premium flaw would wrongly match the * free plugin. Flagged when the vulnerability names a Premium/Pro edition * and the installed plugin does not. * * @param array $vuln Indexed vulnerability. * @param string $installed_name Installed plugin or theme name. * @return bool */ public static function edition_mismatch( array $vuln, $installed_name ) { $marker = '/\b(premium|pro)\b/i'; $names = ( isset( $vuln['sw_name'] ) ? $vuln['sw_name'] : '' ) . ' ' . preg_replace( '/\s+[<>=].*$/', '', (string) $vuln['title'] ); $result = preg_match( $marker, $names ) && ! preg_match( $marker, (string) $installed_name ); /** * Filters whether a vulnerability is considered to belong to another edition. * * @param bool $result Mismatch. * @param array $vuln Indexed vulnerability. * @param string $installed_name Installed name. */ return (bool) apply_filters( 'maintained_log_vulnerability_edition_mismatch', $result, $vuln, $installed_name ); } /** * Version that fixes every pending vulnerability, or '' if unknown or none. * * @param array[] $vulns Vulnerabilities affecting the installed version. * @param string $installed Installed version. * @return string */ private static function fixed_in( array $vulns, $installed ) { $needed = ''; foreach ( $vulns as $vuln ) { if ( ! empty( $vuln['reviewed'] ) || ! empty( $vuln['mismatch'] ) ) { continue; } $candidates = array_filter( (array) $vuln['fixed'], static function ( $version ) use ( $installed ) { return version_compare( $version, $installed, '>' ); } ); if ( ! $candidates ) { return ''; } usort( $candidates, 'version_compare' ); if ( '' === $needed || version_compare( $candidates[0], $needed, '>' ) ) { $needed = $candidates[0]; } } return $needed; } /** * Version WordPress offers as an update, or '' if it offers none. * * Premium plugins without an active license, or bundled with a theme, * are often outdated without WordPress knowing about a newer version. * * @param string $type plugin, theme or core. * @param string $key Plugin file or theme slug. * @return string */ private static function available_update( $type, $key ) { if ( 'plugin' === $type ) { $updates = get_site_transient( 'update_plugins' ); return isset( $updates->response[ $key ]->new_version ) ? (string) $updates->response[ $key ]->new_version : ''; } if ( 'theme' === $type ) { $updates = get_site_transient( 'update_themes' ); return isset( $updates->response[ $key ]['new_version'] ) ? (string) $updates->response[ $key ]['new_version'] : ''; } if ( ! function_exists( 'get_core_updates' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } foreach ( (array) get_core_updates() as $offer ) { if ( isset( $offer->response ) && 'upgrade' === $offer->response ) { return (string) $offer->current; } } return ''; } /** * Reviews by "type:slug@version". * * @return array */ public static function reviewed() { $reviewed = get_option( self::REVIEWED_OPTION, array() ); return is_array( $reviewed ) ? $reviewed : array(); } /** * Key of a review: it only applies to that exact version. * * @param string $type Type. * @param string $slug Slug. * @param string $version Version. * @return string */ public static function review_key( $type, $slug, $version ) { return $type . ':' . $slug . '@' . $version; } /** * Marks the current vulnerabilities of an installed component as reviewed. * * New vulnerabilities, or another version, will need attention again. * * @param string $key Review key from exposures(). * @param string $note Why it is fine (optional). * @return bool */ public static function mark_reviewed( $key, $note ) { foreach ( self::exposures() as $exposure ) { if ( $exposure['review_key'] !== $key ) { continue; } $reviewed = self::reviewed(); $reviewed[ $key ] = array( 'ids' => wp_list_pluck( $exposure['vulns'], 'id' ), 'note' => sanitize_textarea_field( $note ), 'user_id' => get_current_user_id(), 'at' => current_time( 'mysql', true ), ); update_option( self::REVIEWED_OPTION, $reviewed, false ); Log::add( array( 'type' => Log::TYPE_MANUAL, 'category' => 'security', 'status' => Log::STATUS_SUCCESS, /* translators: 1: software name, 2: version. */ 'title' => sprintf( __( 'Known vulnerabilities of %1$s %2$s reviewed', 'maintained-log' ), $exposure['name'], $exposure['version'] ), 'details' => array( 'notes' => $reviewed[ $key ]['note'], 'titles' => array_slice( wp_list_pluck( $exposure['vulns'], 'title' ), 0, 5 ), ), 'source' => Log::SOURCE_MANUAL, ) ); return true; } return false; } /** * Undoes a review. * * @param string $key Review key. * @return bool */ public static function unmark_reviewed( $key ) { $reviewed = self::reviewed(); if ( ! isset( $reviewed[ $key ] ) ) { return false; } unset( $reviewed[ $key ] ); update_option( self::REVIEWED_OPTION, $reviewed, false ); Log::add( array( 'type' => Log::TYPE_SYSTEM, 'title' => sprintf( /* translators: %s: component and version, e.g. plugin:slug@1.0. */ __( 'Vulnerability review undone: %s is pending again', 'maintained-log' ), $key ), 'source' => Log::SOURCE_MANUAL, ) ); return true; } /** * Short sentence about what to do with an exposure. * * @param array $exposure From exposures(). * @return string */ public static function advice( array $exposure ) { if ( 'inactive' === $exposure['state'] ) { $text = __( 'Deactivated: while it stays deactivated the risk is lower, but not zero, because its files are still on the server. If it is not used, the safest option is to delete it.', 'maintained-log' ); if ( '' !== $exposure['fixed_in'] ) { /* translators: %s: version. */ $text .= ' ' . sprintf( __( 'If it is going to be used again, update it to version %s first.', 'maintained-log' ), $exposure['fixed_in'] ); } return $text; } if ( 'uncertain' === $exposure['state'] ) { return __( 'Possible match with another edition (e.g. Premium) that has the same internal name: check whether it really affects your version, and mark it as reviewed if not.', 'maintained-log' ); } if ( ! $exposure['fixable'] || '' === $exposure['fixed_in'] ) { return __( 'No fix is available yet: consider deactivating or replacing it.', 'maintained-log' ); } if ( 'core' === $exposure['type'] ) { /* translators: %s: version that fixes it. */ return sprintf( __( 'Fixed in WordPress %s: update WordPress from Dashboard → Updates.', 'maintained-log' ), $exposure['fixed_in'] ); } if ( $exposure['update_available'] ) { /* translators: 1: version that fixes it, 2: version WordPress offers. */ return sprintf( __( 'Fixed in version %1$s. WordPress offers version %2$s: update it.', 'maintained-log' ), $exposure['fixed_in'], $exposure['update_version'] ); } /* translators: %s: version that fixes it. */ return sprintf( __( 'Fixed in version %s, but WordPress does not offer the update. Premium plugins and themes without an active license, or bundled with a theme, do not show their updates: get the new version from the developer. If it is free, check again in Dashboard → Updates.', 'maintained-log' ), $exposure['fixed_in'] ); } /** * Plain-language explanation of what a vulnerability allowed. * * @param array $vuln Indexed vulnerability. * @return string */ public static function explain( array $vuln ) { $groups = array( 'xss' => array( 79, 80, 83, 87 ), 'sqli' => array( 89, 564 ), 'csrf' => array( 352 ), 'access' => array( 284, 285, 639, 862, 863, 732, 425 ), 'code' => array( 77, 78, 94, 95, 96, 434, 502, 98 ), 'files' => array( 22, 23, 35, 73, 552 ), 'disclosure' => array( 200, 201, 209, 359, 532, 538, 548 ), 'auth' => array( 287, 288, 290, 294, 306, 620, 640, 798 ), 'privilege' => array( 266, 269, 270 ), 'redirect' => array( 601 ), 'ssrf' => array( 918 ), 'deletion' => array( 24, 36, 1236 ), ); $texts = array( 'xss' => __( 'it could let someone insert malicious content into pages that visitors or administrators see', 'maintained-log' ), 'sqli' => __( 'it could let someone access or alter the information stored in the site\'s database', 'maintained-log' ), 'csrf' => __( 'it could let an attacker trick a logged-in administrator into making changes without realizing it', 'maintained-log' ), 'access' => __( 'it could let users without permission perform restricted actions or see restricted information', 'maintained-log' ), 'code' => __( 'it could let an attacker run their own code on the server and take control of the site', 'maintained-log' ), 'files' => __( 'it could let someone read files on the server that should not be accessible', 'maintained-log' ), 'disclosure' => __( 'it could expose information that should remain private', 'maintained-log' ), 'auth' => __( 'it could let someone log in or take over an account without the right credentials', 'maintained-log' ), 'privilege' => __( 'it could let a user give themselves more permissions than they should have', 'maintained-log' ), 'redirect' => __( 'it could be used to send visitors to a malicious site through a link that looks legitimate', 'maintained-log' ), 'ssrf' => __( 'it could let someone use the site to make requests to other systems', 'maintained-log' ), 'deletion' => __( 'it could let someone delete files from the server', 'maintained-log' ), ); $cwe = (int) $vuln['cwe']; $text = __( 'it was a security weakness that could be exploited by an attacker', 'maintained-log' ); foreach ( $groups as $group => $ids ) { if ( in_array( $cwe, $ids, true ) ) { $text = $texts[ $group ]; break; } } /** * Filters the plain-language explanation of a vulnerability. * * @param string $text Explanation, starting in lowercase ("it could…"). * @param array $vuln Indexed vulnerability. */ return (string) apply_filters( 'maintained_log_vulnerability_explanation', $text, $vuln ); } /** * Who could exploit it, from the title Wordfence gives each record. * * @param array $vuln Indexed vulnerability. * @return string Empty when unknown. */ public static function access_context( array $vuln ) { $title = (string) $vuln['title']; if ( preg_match( '/\bUnauthenticated\b/i', $title ) ) { return __( 'It could be exploited by anyone, without an account on the site.', 'maintained-log' ); } if ( preg_match( '/\bAuthenticated\s*\(\s*([A-Za-z ]+?)\s*\+?\s*\)/i', $title, $m ) ) { $role = ucwords( strtolower( trim( $m[1] ) ) ); if ( 'Custom-level' === $role || 'Custom' === $role ) { return __( 'It required an account on the site with special permissions.', 'maintained-log' ); } if ( ! function_exists( 'translate_user_role' ) ) { require_once ABSPATH . 'wp-admin/includes/user.php'; } /* translators: %s: user role, e.g. Subscriber. */ return sprintf( __( 'It required an account on the site with at least the "%s" role.', 'maintained-log' ), translate_user_role( $role ) ); } return ''; } /** * Severity label. * * @param array $vuln Indexed vulnerability. * @return array{key:string,label:string,color:string} */ public static function severity( array $vuln ) { $score = (float) $vuln['score']; if ( $score >= 9 ) { return array( 'key' => 'critical', 'label' => __( 'Critical severity', 'maintained-log' ), 'color' => '#8a1f11', ); } if ( $score >= 7 ) { return array( 'key' => 'high', 'label' => __( 'High severity', 'maintained-log' ), 'color' => '#b32d2e', ); } if ( $score >= 4 ) { return array( 'key' => 'medium', 'label' => __( 'Medium severity', 'maintained-log' ), 'color' => '#b26200', ); } return array( 'key' => 'low', 'label' => __( 'Low severity', 'maintained-log' ), 'color' => '#50575e', ); } /** * Attribution required when showing Wordfence data. * * @return string */ public static function attribution() { $copyright = trim( (string) self::sync_state()['copyright'] ); return trim( __( 'Vulnerability data provided by Wordfence Intelligence (Defiant, Inc.).', 'maintained-log' ) . ' ' . $copyright ); } /** * Index key parts (type, slug) for a logged component. * * @param string $component core, plugin or theme. * @param string $key Inventory key. * @return array|null */ public static function target( $component, $key ) { if ( 'core' === $component ) { return array( 'core', 'wordpress' ); } if ( 'plugin' === $component ) { $slug = dirname( $key ); return ( '.' === $slug || '' === $slug ) ? array( 'plugin', basename( $key, '.php' ) ) : array( 'plugin', $slug ); } if ( 'theme' === $component && '' !== $key ) { return array( 'theme', $key ); } return null; } /** * Parses part of the feed file, keeping the vulnerabilities of wanted software. * * The feed is a JSON object (id => record) or array of records. Records are * located by tracking nesting depth outside strings, then decoded one by one. * * @param string $file Path to the feed. * @param int $offset Byte offset to resume from (always between records). * @param array $wanted Flipped list of "type:slug" keys. * @param array $index Index built so far. * @param int $budget Bytes to scan in this call. * @param string $format json (object or array) or ndjson, from detect_format(). * @return array{index:array,offset:int,done:bool,records:int,copyright:string}|\WP_Error */ public static function parse_slice( $file, $offset, array $wanted, array $index, $budget = 0, $format = 'json' ) { /** * Filters how many bytes of the feed are parsed per cron event. * * @param int $bytes Default 40 MB. */ $budget = $budget ? $budget : (int) apply_filters( 'maintained_log_vuln_parse_bytes', 40 * MB_IN_BYTES ); $handle = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $handle ) { return new \WP_Error( 'read_failed', __( 'The downloaded feed could not be read.', 'maintained-log' ) ); } $size = filesize( $file ); $records = 0; $copyright = ''; if ( 'ndjson' === $format ) { fseek( $handle, $offset ); while ( false !== ( $line = fgets( $handle ) ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition $line = trim( $line ); if ( '' !== $line && '{' === $line[0] ) { ++$records; self::index_record( $line, $wanted, $index, $copyright ); } if ( ftell( $handle ) - $offset >= $budget ) { break; } } $at = ftell( $handle ); fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return array( 'index' => $index, 'offset' => $at, 'done' => $at >= $size, 'records' => $records, 'copyright' => $copyright, ); } $depth = $offset > 0 ? 1 : 0; $in_string = false; $start = -1; $pos = $offset; $buffer = ''; $base = $offset; $last_safe = $offset; fseek( $handle, $offset ); while ( ! feof( $handle ) ) { $read_at = ftell( $handle ); $chunk = fread( $handle, MB_IN_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( false === $chunk || '' === $chunk ) { break; } // Keep only the unfinished record from the previous chunk. if ( $start >= 0 ) { $buffer = substr( $buffer, $start - $base ) . $chunk; $base = $start; } else { $buffer = $chunk; $base = $read_at; } $len = strlen( $buffer ); $i = $pos - $base; while ( $i < $len ) { if ( $in_string ) { $i += strcspn( $buffer, '"\\', $i ); if ( $i >= $len ) { break; } if ( '\\' === $buffer[ $i ] ) { $i += 2; continue; } $in_string = false; ++$i; continue; } $i += strcspn( $buffer, '{}[]"', $i ); if ( $i >= $len ) { break; } $c = $buffer[ $i ]; if ( '"' === $c ) { $in_string = true; } elseif ( '{' === $c || '[' === $c ) { if ( 1 === $depth && '{' === $c ) { $start = $base + $i; } ++$depth; } else { --$depth; if ( 1 === $depth && $start >= 0 && '}' === $c ) { $json = substr( $buffer, $start - $base, $base + $i + 1 - $start ); ++$records; self::index_record( $json, $wanted, $index, $copyright ); $start = -1; $last_safe = $base + $i + 1; // Stop between records once the budget is used, so the next call resumes cleanly. if ( $last_safe - $offset >= $budget ) { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return array( 'index' => $index, 'offset' => $last_safe, 'done' => false, 'records' => $records, 'copyright' => $copyright, ); } } } ++$i; } $pos = $base + $i; } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return array( 'index' => $index, 'offset' => $size, 'done' => true, 'records' => $records, 'copyright' => $copyright, ); } /** * Whether the feed is one JSON document (object or array of records) or * newline-delimited JSON with one record per line. * * @param string $file Path. * @return string json or ndjson. */ public static function detect_format( $file ) { $handle = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $handle ) { return 'json'; } $first = fgets( $handle, 4 * MB_IN_BYTES ); fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose $first = trim( (string) $first ); if ( '' === $first || '{' !== $first[0] || '}' !== substr( $first, -1 ) ) { return 'json'; } $decoded = json_decode( $first, true ); return ( is_array( $decoded ) && isset( $decoded['software'] ) ) ? 'ndjson' : 'json'; } /** * Adds a record to the index if it concerns wanted software. * * @param string $json Record JSON. * @param array $wanted Flipped list of "type:slug" keys. * @param array $index Index, by reference. * @param string $copyright Attribution message, by reference. */ private static function index_record( $json, array $wanted, array &$index, &$copyright ) { // Cheap pre-filter before decoding: most records are for software not on this site. if ( ! preg_match_all( '/"slug"\s*:\s*"([^"]+)"/', $json, $m ) ) { return; } $hit = false; foreach ( $m[1] as $slug ) { if ( isset( $wanted[ 'plugin:' . $slug ] ) || isset( $wanted[ 'theme:' . $slug ] ) || isset( $wanted[ 'core:' . $slug ] ) ) { $hit = true; break; } } if ( ! $hit && '' !== $copyright ) { return; } $record = json_decode( $json, true ); if ( ! is_array( $record ) || empty( $record['software'] ) ) { return; } if ( '' === $copyright && ! empty( $record['copyrights']['message'] ) ) { $copyright = wp_strip_all_tags( $record['copyrights']['message'] ); } if ( ! $hit || ! empty( $record['informational'] ) ) { return; } foreach ( (array) $record['software'] as $software ) { if ( empty( $software['type'] ) || empty( $software['slug'] ) ) { continue; } $key = $software['type'] . ':' . $software['slug']; if ( ! isset( $wanted[ $key ] ) ) { continue; } $ranges = array(); foreach ( (array) ( isset( $software['affected_versions'] ) ? $software['affected_versions'] : array() ) as $range ) { if ( isset( $range['from_version'], $range['to_version'] ) ) { $ranges[] = array( (string) $range['from_version'], ! empty( $range['from_inclusive'] ), (string) $range['to_version'], ! empty( $range['to_inclusive'] ), ); } } if ( ! $ranges ) { continue; } $index[ $key ][] = array( 'id' => isset( $record['id'] ) ? (string) $record['id'] : md5( $json ), // Raw text, not wp_strip_all_tags(): titles contain "<=" and would be cut. Escaped on output. 'title' => isset( $record['title'] ) ? trim( preg_replace( '/[\x00-\x1F\x7F]+/', ' ', wp_check_invalid_utf8( (string) $record['title'], true ) ) ) : '', 'cwe' => isset( $record['cwe']['id'] ) ? (int) $record['cwe']['id'] : 0, 'score' => isset( $record['cvss']['score'] ) ? (float) $record['cvss']['score'] : 0.0, 'rating' => isset( $record['cvss']['rating'] ) ? (string) $record['cvss']['rating'] : '', 'cve' => isset( $record['cve'] ) ? (string) $record['cve'] : '', 'ranges' => $ranges, 'patched' => ! empty( $software['patched'] ), 'sw_name' => isset( $software['name'] ) ? (string) $software['name'] : '', 'fixed' => isset( $software['patched_versions'] ) ? array_values( array_map( 'strval', (array) $software['patched_versions'] ) ) : array(), ); } } /** * Whether a version falls in any of the ranges. * * @param string $version Version. * @param array $ranges List of [from, from_inclusive, to, to_inclusive]. * @return bool */ public static function in_ranges( $version, array $ranges ) { foreach ( $ranges as $r ) { $above = '*' === $r[0] || ( $r[1] ? version_compare( $version, $r[0], '>=' ) : version_compare( $version, $r[0], '>' ) ); $below = '*' === $r[2] || ( $r[3] ? version_compare( $version, $r[2], '<=' ) : version_compare( $version, $r[2], '<' ) ); if ( $above && $below ) { return true; } } return false; } /** * Software to keep in the index: installed now or updated in the past. * * @return string[] "type:slug" keys. */ private static function wanted_software() { $wanted = array( 'core:wordpress' ); $snapshot = Inventory::get_stored(); if ( $snapshot ) { foreach ( array_keys( $snapshot['plugins'] ) as $file ) { $target = self::target( 'plugin', $file ); $wanted[] = $target[0] . ':' . $target[1]; } foreach ( array_keys( $snapshot['themes'] ) as $slug ) { $wanted[] = 'theme:' . $slug; } } foreach ( Log::query( array( 'type' => Log::TYPE_UPDATE, 'per_page' => 0, ) ) as $entry ) { if ( ! empty( $entry->details['component'] ) ) { $target = self::target( $entry->details['component'], isset( $entry->details['key'] ) ? $entry->details['key'] : '' ); if ( $target ) { $wanted[] = $target[0] . ':' . $target[1]; } } } return array_values( array_unique( $wanted ) ); } /** * Streams the feed to a protected folder. * * @return string|\WP_Error Path to the JSON file. */ private static function download() { $dir = self::storage_dir(); wp_mkdir_p( $dir ); if ( ! file_exists( $dir . '/index.php' ) ) { file_put_contents( $dir . '/index.php', "\nDeny from all\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } $file = $dir . '/feed.json'; /** * Filters the Wordfence Intelligence feed URL. * * @param string $url Production feed URL. */ $url = apply_filters( 'maintained_log_vuln_feed_url', self::FEED_URL ); $response = wp_remote_get( $url, array( 'timeout' => 300, 'stream' => true, 'filename' => $file, 'headers' => array( 'Authorization' => 'Bearer ' . trim( (string) Settings::get( 'vuln_api_key' ) ), ), ) ); if ( is_wp_error( $response ) ) { wp_delete_file( $file ); return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { $body = file_exists( $file ) ? (string) file_get_contents( $file, false, null, 0, 2000 ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents wp_delete_file( $file ); if ( 401 === $code || 403 === $code ) { return new \WP_Error( 'unauthorized', __( 'Wordfence rejected the API key. Check it in your Wordfence account (Integrations).', 'maintained-log' ) ); } if ( 429 === $code ) { return new \WP_Error( 'rate_limited', __( 'Wordfence allows one download every 30 minutes. The next sync will retry.', 'maintained-log' ) ); } $decoded = json_decode( $body, true ); $message = is_array( $decoded ) && isset( $decoded['message'] ) ? $decoded['message'] : sprintf( 'HTTP %d', $code ); return new \WP_Error( 'feed_error', $message ); } return self::maybe_gunzip( $file ); } /** * Decompresses the feed if it arrived gzip-encoded. * * @param string $file Path. * @return string|\WP_Error Path to plain JSON. */ private static function maybe_gunzip( $file ) { $handle = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $magic = $handle ? fread( $handle, 2 ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( $handle ) { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } if ( "\x1f\x8b" !== $magic ) { return $file; } if ( ! function_exists( 'gzopen' ) ) { return new \WP_Error( 'gzip', __( 'The feed is compressed and this server cannot decompress it.', 'maintained-log' ) ); } $plain = $file . '.plain'; $in = gzopen( $file, 'rb' ); $out = fopen( $plain, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen while ( ! gzeof( $in ) ) { fwrite( $out, gzread( $in, MB_IN_BYTES ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite } gzclose( $in ); fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose wp_delete_file( $file ); rename( $plain, $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename return $file; } /** * Emails the administrator about vulnerabilities not reported before. */ private static function notify_new_exposures() { $notified = (array) get_option( self::NOTIFIED_OPTION, array() ); $new = array(); foreach ( self::exposures() as $exposure ) { $fresh = array_filter( $exposure['vulns'], static function ( $vuln ) use ( $notified ) { return empty( $vuln['reviewed'] ) && ! in_array( $vuln['id'], $notified, true ); } ); if ( $fresh ) { $exposure['vulns'] = array_values( $fresh ); $new[] = $exposure; } } if ( ! $new ) { return; } foreach ( $new as $exposure ) { $worst = self::severity( $exposure['vulns'][0] ); foreach ( $exposure['vulns'] as $vuln ) { $notified[] = $vuln['id']; if ( $vuln['score'] > (float) $exposure['vulns'][0]['score'] ) { $worst = self::severity( $vuln ); } } $count = count( $exposure['vulns'] ); $status = Log::STATUS_WARNING; if ( 'inactive' === $exposure['state'] ) { $status = Log::STATUS_INFO; /* translators: 1: software name, 2: version, 3: number of vulnerabilities. */ $title = sprintf( _n( '%1$s %2$s (deactivated) has %3$d known vulnerability: delete it if it is not used', '%1$s %2$s (deactivated) has %3$d known vulnerabilities: delete it if it is not used', $count, 'maintained-log' ), $exposure['name'], $exposure['version'], $count ); } elseif ( 'uncertain' === $exposure['state'] ) { $status = Log::STATUS_INFO; /* translators: 1: software name, 2: version. */ $title = sprintf( __( '%1$s %2$s: possible match with vulnerabilities of another edition, check it', 'maintained-log' ), $exposure['name'], $exposure['version'] ); } elseif ( ! $exposure['fixable'] || '' === $exposure['fixed_in'] ) { $status = Log::STATUS_ERROR; /* translators: 1: software name, 2: version. */ $title = sprintf( __( '%1$s %2$s has a known vulnerability with no fix available yet', 'maintained-log' ), $exposure['name'], $exposure['version'] ); } elseif ( $exposure['update_available'] ) { /* translators: 1: software name, 2: version, 3: number of vulnerabilities. */ $title = sprintf( _n( '%1$s %2$s has %3$d known vulnerability: update it', '%1$s %2$s has %3$d known vulnerabilities: update it', $count, 'maintained-log' ), $exposure['name'], $exposure['version'], $count ); } else { /* translators: 1: software name, 2: version, 3: number of vulnerabilities, 4: fixed version. */ $title = sprintf( _n( '%1$s %2$s has %3$d known vulnerability fixed in %4$s, but WordPress does not offer the update', '%1$s %2$s has %3$d known vulnerabilities fixed in %4$s, but WordPress does not offer the update', $count, 'maintained-log' ), $exposure['name'], $exposure['version'], $count, $exposure['fixed_in'] ); } Log::add( array( 'type' => Log::TYPE_ALERT, 'category' => 'vulnerability', 'status' => $status, 'title' => $title, 'details' => array( 'vulnerability' => true, 'type' => $exposure['type'], 'slug' => $exposure['slug'], 'severity' => $worst['label'], 'titles' => array_slice( wp_list_pluck( $exposure['vulns'], 'title' ), 0, 5 ), 'advice' => self::advice( $exposure ), ), 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); } update_option( self::NOTIFIED_OPTION, array_values( array_unique( $notified ) ), false ); if ( Settings::get( 'vuln_alert' ) ) { Alerts::send_vulnerabilities( $new ); } } /** * Records a failed sync. * * @param array $state Sync state. * @param string $message Error. */ private static function fail( array $state, $message ) { if ( $state['file'] ) { wp_delete_file( $state['file'] ); } delete_option( self::PARTIAL_OPTION ); $state['state'] = 'idle'; $state['file'] = ''; $state['offset'] = 0; $state['records'] = 0; $state['last_error'] = $message; update_option( self::SYNC_OPTION, $state, false ); Log::add( array( 'type' => Log::TYPE_SYSTEM, 'category' => 'vulnerability', 'status' => Log::STATUS_WARNING, /* translators: %s: error message. */ 'title' => sprintf( __( 'Vulnerability data could not be updated: %s', 'maintained-log' ), $message ), 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); } /** * Folder for the temporary feed download. * * @return string */ private static function storage_dir() { $uploads = wp_upload_dir( null, false ); return trailingslashit( $uploads['basedir'] ) . 'maintained-log-data-' . substr( wp_hash( 'maintained-log-data' ), 0, 12 ); } /** * Schedules the next sync step. * * @param int $delay Seconds. */ private static function schedule_tick( $delay ) { if ( ! wp_next_scheduled( self::TICK_HOOK ) ) { wp_schedule_single_event( time() + $delay, self::TICK_HOOK ); } } }