$inventory Local-only inventory. * @return array */ public function match_file( string $path, array $inventory ): array { if ( ! is_file( $path ) || ! is_readable( $path ) || is_link( $path ) ) { throw new \InvalidArgumentException( 'wordfence_feed_file_invalid' ); } if ( ! isset( $inventory['components'], $inventory['total_count'], $inventory['truncated'] ) || ! is_array( $inventory['components'] ) || ! is_int( $inventory['total_count'] ) || ! is_bool( $inventory['truncated'] ) || $inventory['total_count'] < count( $inventory['components'] ) ) { throw new \InvalidArgumentException( 'wordfence_inventory_invalid' ); } $components = $inventory['components']; $total_count = $inventory['total_count']; $inventory_partial = $inventory['truncated']; $index = array(); $component_count = 0; foreach ( array_slice( $components, 0, 500 ) as $component ) { if ( ! is_array( $component ) || ! $this->valid_component( $component ) ) { continue; } $index[ $component['type'] . ':' . $component['slug'] ][] = $component; ++$component_count; } $records = 0; $relevant_unsupported = 0; $matches = array(); $seen = array(); try { $items = Items::fromFile( $path, array( 'decoder' => new ExtJsonDecoder( true, 64 ) ) ); foreach ( $items as $record_id => $record ) { if ( ++$records > self::MAX_RECORDS ) { throw new ExternalRequestException( 'wordfence_feed_record_limit' ); } if ( ! is_array( $record ) || ! isset( $record['software'] ) || ! is_array( $record['software'] ) ) { continue; } $advisory = $this->advisory_id( $record_id, $record ); foreach ( $record['software'] as $software ) { if ( ! is_array( $software ) || ! isset( $software['type'], $software['slug'] ) || ! is_string( $software['type'] ) || ! is_string( $software['slug'] ) ) { continue; } $key = strtolower( $software['type'] ) . ':' . strtolower( $software['slug'] ); if ( ! isset( $index[ $key ] ) ) { continue; } if ( ! isset( $software['affected_versions'] ) || ! is_array( $software['affected_versions'] ) ) { ++$relevant_unsupported; continue; } foreach ( $index[ $key ] as $component ) { $affected = $this->affected( $component['version'], $software['affected_versions'] ); if ( null === $affected ) { ++$relevant_unsupported; continue; } if ( ! $affected ) { continue; } $identity = $key . '|' . $component['version'] . '|' . $advisory; if ( isset( $seen[ $identity ] ) ) { continue; } $source_url = $this->source_url( $record ); $copyright_notice = $this->copyright_notice( $record ); if ( null === $source_url || null === $copyright_notice ) { ++$relevant_unsupported; continue; } $seen[ $identity ] = true; if ( count( $matches ) < self::MAX_MATCHES ) { $matches[] = array( 'component_type' => $component['type'], 'component_name' => $component['name'], 'component_version' => $component['version'], 'advisory_id' => $advisory, 'informational' => true === ( $record['informational'] ?? false ), 'patched' => true === ( $software['patched'] ?? false ), 'fixed_version' => $this->fixed_version( $software['patched_versions'] ?? array() ), 'source_url' => $source_url, 'copyright_notice' => $copyright_notice, ); } } } } } catch ( ExternalRequestException $exception ) { throw $exception; } catch ( \Throwable $exception ) { throw new ExternalRequestException( 'wordfence_feed_schema_invalid' ); } return array( 'components_count' => $total_count, 'components_observed' => $component_count, 'inventory_truncated' => $inventory_partial || $component_count < count( $components ), 'feed_records' => $records, 'matches' => $matches, 'matches_count' => count( $seen ), 'matches_truncated' => count( $seen ) > self::MAX_MATCHES, 'relevant_unsupported' => $relevant_unsupported, ); } /** * @param array $component Component. */ private function valid_component( array $component ): bool { return isset( $component['type'], $component['slug'], $component['name'], $component['version'] ) && in_array( $component['type'], array( 'core', 'plugin', 'theme' ), true ) && is_string( $component['slug'] ) && is_string( $component['name'] ) && is_string( $component['version'] ) && 1 === preg_match( '/^[a-z0-9][a-z0-9-]{0,99}$/D', $component['slug'] ) && 1 === preg_match( '/^[0-9A-Za-z][0-9A-Za-z._+-]{0,49}$/D', $component['version'] ); } /** * @param mixed $record_id Feed key. * @param array $record Feed record. */ private function advisory_id( $record_id, array $record ): string { $value = isset( $record['id'] ) && is_string( $record['id'] ) ? $record['id'] : (string) $record_id; return 1 === preg_match( '/^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/D', $value ) ? $value : 'unavailable'; } /** * @param array $ranges Wordfence affected-version ranges. */ private function affected( string $version, array $ranges ): ?bool { $valid = 0; foreach ( $ranges as $range ) { if ( ! is_array( $range ) || ! isset( $range['from_version'], $range['from_inclusive'], $range['to_version'], $range['to_inclusive'] ) || ! is_string( $range['from_version'] ) || ! is_bool( $range['from_inclusive'] ) || ! is_string( $range['to_version'] ) || ! is_bool( $range['to_inclusive'] ) ) { continue; } if ( ! $this->valid_range_version( $range['from_version'] ) || ! $this->valid_range_version( $range['to_version'] ) ) { continue; } ++$valid; $from_ok = '*' === $range['from_version'] || version_compare( $version, $range['from_version'], $range['from_inclusive'] ? '>=' : '>' ); $to_ok = '*' === $range['to_version'] || version_compare( $version, $range['to_version'], $range['to_inclusive'] ? '<=' : '<' ); if ( $from_ok && $to_ok ) { return true; } } return 0 === $valid ? null : false; } private function valid_range_version( string $version ): bool { return '*' === $version || 1 === preg_match( '/^[0-9A-Za-z][0-9A-Za-z._+-]{0,49}$/D', $version ); } /** * Retain the provider's required record hyperlink without accepting an * arbitrary feed-controlled destination into the report. * * @param array $record Feed record. */ private function source_url( array $record ): ?string { if ( ! isset( $record['references'] ) || ! is_array( $record['references'] ) ) { return null; } foreach ( array_slice( $record['references'], 0, 20 ) as $reference ) { if ( ! is_string( $reference ) || strlen( $reference ) > 512 ) { continue; } $parts = wp_parse_url( $reference ); if ( ! is_array( $parts ) || 'https' !== ( $parts['scheme'] ?? '' ) || 'www.wordfence.com' !== ( $parts['host'] ?? '' ) || ! isset( $parts['path'] ) || 0 !== strpos( (string) $parts['path'], '/threat-intel/vulnerabilities/' ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['port'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) || 1 !== preg_match( '#^/threat-intel/vulnerabilities/[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*/?$#D', (string) $parts['path'] ) ) { continue; } return 'https://www.wordfence.com' . (string) $parts['path']; } return null; } /** * Preserve the provider's bounded copyright designation for every copied * match, as required by the Wordfence Intelligence license. * * @param array $record Feed record. */ private function copyright_notice( array $record ): ?string { $notice = $record['copyrights']['defiant']['notice'] ?? null; if ( ! is_string( $notice ) || strlen( $notice ) > 96 || 1 !== preg_match( '/^Copyright (?:\xC2\xA9 )?[0-9]{4}(?:-[0-9]{4})? Defiant,? Inc\.$/D', $notice ) ) { return null; } return $notice; } /** * @param mixed $versions Patched versions. */ private function fixed_version( $versions ): string { if ( ! is_array( $versions ) ) { return 'unavailable'; } foreach ( $versions as $version ) { if ( is_string( $version ) && 1 === preg_match( '/^[0-9A-Za-z][0-9A-Za-z._+-]{0,49}$/D', $version ) ) { return $version; } } return 'unavailable'; } }