*/ private array $cache = array(); /** * Creates the handler. * * @param string $app App id the findings belong to. * @param FindingStore $store Findings. * @param ScanQueue $queue Inventory and target cache. * @param Http $http Outbound gateway. * @param Check[] $checks Checks to run on every page. * @param string[] $hosts Hosts of the site. * @param array $settings max_urls, include_noindex, pause (seconds between pages). * @param array $stored Checks of stored content, run after the pages. * @phpstan-param array{max_urls: int, include_noindex: bool, pause?: float} $settings * @phpstan-param StoredContentCheck[] $stored */ public function __construct( private string $app, private FindingStore $store, private ScanQueue $queue, private Http $http, private array $checks, private array $hosts, private array $settings, private array $stored = array() ) {} /** * One step. * * @param JobContext $context Job state. */ public function step( JobContext $context ): StepResult { $scan_id = (int) ( $context->args['scan_id'] ?? 0 ); if ( $scan_id <= 0 ) { return StepResult::failed( 'The scan record is missing.' ); } switch ( $context->state['phase'] ?? 'start' ) { case 'start': return $this->start( $context, $scan_id ); case 'inventory': return $this->inventory( $context, $scan_id ); case 'pages': return $this->pages( $context, $scan_id ); case 'database': return $this->database( $context, $scan_id ); default: return $this->finish( $context, $scan_id ); } } /** * Clears old scan rows and finds a way to reach the site. * * @param JobContext $context Job state. * @param int $scan_id Scan id. */ private function start( JobContext $context, int $scan_id ): StepResult { $this->queue->purge_before( $scan_id ); $mode = ( new Fetcher( $this->http ) )->probe( home_url( '/' ) ); if ( null === $mode ) { return StepResult::failed( __( 'The site could not load its own pages. Your host may block requests from the site to itself (loopback requests). Ask your host to allow them.', 'forensix-digital' ) ); } $context->state = array( 'phase' => 'inventory', 'mode' => $mode, 'cursor' => array(), 'capped' => false, 'errors' => 0, 'checked' => 0, 'page' => null, ); return StepResult::more(); } /** * Adds one batch of pages to the inventory. * * @param JobContext $context Job state. * @param int $scan_id Scan id. */ private function inventory( JobContext $context, int $scan_id ): StepResult { $cursor = ( new Inventory( $this->queue, $this->hosts ) )->step( $scan_id, (array) ( $context->state['cursor'] ?? array() ), $this->settings['max_urls'] ); $context->state['cursor'] = $cursor; $context->state['capped'] = $cursor['capped']; $context->total = $this->queue->count( $scan_id ); if ( Inventory::STAGE_DONE === $cursor['stage'] ) { $context->state['phase'] = 'pages'; // Pages listed as skipped (password-protected) count as handled. $coverage = $this->queue->coverage( $scan_id ); $context->progress = $coverage['skipped']; } return StepResult::more(); } /** * Fetches the next page, or checks a few URLs of the current page. * * @param JobContext $context Job state. * @param int $scan_id Scan id. */ private function pages( JobContext $context, int $scan_id ): StepResult { $page = $context->state['page'] ?? null; if ( is_array( $page ) ) { return $this->continue_page( $context, $scan_id, $page ); } $next = $this->queue->next( $scan_id ); if ( null === $next ) { $context->state['phase'] = 'database'; $context->state['db'] = array( 'index' => 0, 'cursor' => array(), ); return StepResult::more(); } $fetcher = new Fetcher( $this->http, (string) ( $context->state['mode'] ?? Fetcher::DIRECT ) ); $response = $fetcher->fetch( $next['url'] ); $status = $response['status']; $this->pause(); if ( 0 === $status || $status >= 500 ) { $this->queue->mark( $next['id'], ScanQueue::FAILED, 0 === $status ? 'no_response' : 'server_error', $status ); $context->advance(); $context->state['errors'] = (int) ( $context->state['errors'] ?? 0 ) + 1; if ( $context->state['errors'] >= self::MAX_SERVER_ERRORS ) { return StepResult::failed( /* translators: %d: number of pages. */ sprintf( __( 'The scan stopped because %d pages in a row failed to load. Your server may be overloaded; try again later.', 'forensix-digital' ), self::MAX_SERVER_ERRORS ) ); } return StepResult::more(); } $context->state['errors'] = 0; $skip = self::skip_reason( $status, $response['headers']['content-type'] ?? '' ); if ( null !== $skip ) { $this->queue->mark( $next['id'], in_array( $skip, array( 'not_found', 'client_error' ), true ) ? ScanQueue::FAILED : ScanQueue::SKIPPED, $skip, $status ); $context->advance(); return StepResult::more(); } $parsed = ( new HtmlParser() )->parse( $response['body'], $next['url'], $response['headers'] ); if ( $parsed->noindex && ! $this->settings['include_noindex'] ) { $this->queue->mark( $next['id'], ScanQueue::SKIPPED, 'noindex', $status ); $context->advance(); return StepResult::more(); } $targets = array(); foreach ( $this->checks as $check ) { foreach ( $check->targets( $parsed ) as $url ) { $targets[ $url ] = true; } } $pending = array(); foreach ( array_keys( $targets ) as $url ) { if ( null === $this->lookup( $scan_id, $url ) ) { $pending[] = $url; } } $context->state['page'] = array( 'id' => $next['id'], 'url' => $next['url'], 'importance' => $next['importance'], 'status' => $status, 'refs' => self::pack( $parsed ), 'title' => $parsed->title, 'facts' => $parsed->facts->to_state(), 'targets' => array_keys( $targets ), 'pending' => $pending, ); return StepResult::more(); } /** * Checks a few pending URLs; once none are left, records the page's findings. * * @param JobContext $context Job state. * @param int $scan_id Scan id. * @param array $page Page in progress. * @phpstan-param array $page */ private function continue_page( JobContext $context, int $scan_id, array $page ): StepResult { $pending = array_values( (array) ( $page['pending'] ?? array() ) ); $checker = new TargetChecker( $this->http, $this->hosts ); $done = 0; while ( array() !== $pending && $done < self::CHECKS_PER_STEP ) { $url = (string) array_shift( $pending ); if ( null !== $this->lookup( $scan_id, $url ) ) { continue; } $result = $checker->check( $url ); $this->queue->save_target( $scan_id, $url, $result ); $this->remember( $url, $result ); ++$done; } $context->state['checked'] = (int) ( $context->state['checked'] ?? 0 ) + $done; if ( array() !== $pending ) { $page['pending'] = $pending; $context->state['page'] = $page; return StepResult::more(); } $this->record_page( $scan_id, $page ); $this->queue->mark( (int) $page['id'], ScanQueue::SCANNED, '', (int) $page['status'] ); $context->state['page'] = null; $context->advance(); return StepResult::more(); } /** * Runs the checks on a page whose URLs are all checked and stores what they report. * * @param int $scan_id Scan id. * @param array $page Page state. * @phpstan-param array $page */ private function record_page( int $scan_id, array $page ): void { $parsed = self::unpack( (string) $page['url'], (array) $page['refs'], (string) ( $page['title'] ?? '' ), (array) ( $page['facts'] ?? array() ) ); $results = array(); foreach ( (array) $page['targets'] as $url ) { $result = $this->lookup( $scan_id, (string) $url ); if ( null !== $result ) { $results[ (string) $url ] = $result; } } $labels = new SourceLabels(); foreach ( $this->checks as $check ) { foreach ( $check->inspect( $parsed, $results ) as $hit ) { $o = $hit['observation']; $this->store->record( $scan_id, $this->app, $o->with_label( $labels->label( new Source( $o->source_type, $o->source_ref ) ) ), $parsed->url, $hit['context'], (int) $page['importance'] ); } } } /** * Runs one batch of the current stored-content check. * * @param JobContext $context Job state. * @param int $scan_id Scan id. */ private function database( JobContext $context, int $scan_id ): StepResult { $db = (array) ( $context->state['db'] ?? array() ); $index = (int) ( $db['index'] ?? 0 ); if ( ! isset( $this->stored[ $index ] ) ) { $context->state['phase'] = 'finish'; return StepResult::more(); } $next = $this->stored[ $index ]->step( $scan_id, (array) ( $db['cursor'] ?? array() ) ); $context->state['db'] = null === $next ? array( 'index' => $index + 1, 'cursor' => array(), ) : array( 'index' => $index, 'cursor' => $next, ); return StepResult::more(); } /** * Ends the scan: resolves what was not seen again and stores coverage. * * @param JobContext $context Job state. * @param int $scan_id Scan id. */ private function finish( JobContext $context, int $scan_id ): StepResult { $this->store->finish_scan( $scan_id, FindingStore::SCAN_COMPLETED, $this->stats( $context, $scan_id ) ); return StepResult::done(); } /** * Coverage and counters for the scan record. * * @param JobContext $context Job state. * @param int $scan_id Scan id. * @return array */ public function stats( JobContext $context, int $scan_id ): array { return array( 'coverage' => $this->queue->coverage( $scan_id ), 'capped' => (bool) ( $context->state['capped'] ?? false ), 'checked' => (int) ( $context->state['checked'] ?? 0 ), 'mode' => (string) ( $context->state['mode'] ?? '' ), ); } /** * Why a fetched page is not parsed, or null when it is. * * @param int $status HTTP status. * @param string $content_type Content-Type header. */ public static function skip_reason( int $status, string $content_type ): ?string { if ( $status >= 300 && $status < 400 ) { return 'redirect'; } if ( in_array( $status, array( 401, 403 ), true ) ) { return 'protected'; } if ( in_array( $status, array( 404, 410 ), true ) ) { return 'not_found'; } if ( $status >= 400 ) { return 'client_error'; } if ( '' !== $content_type && ! str_contains( strtolower( $content_type ), 'html' ) ) { return 'not_html'; } return null; } /** * A checked URL from this request's memory or the scan's table. * * @param int $scan_id Scan id. * @param string $url URL. */ private function lookup( int $scan_id, string $url ): ?TargetResult { if ( isset( $this->cache[ $url ] ) ) { return $this->cache[ $url ]; } $result = $this->queue->target( $scan_id, $url ); if ( null !== $result ) { $this->remember( $url, $result ); } return $result; } /** * Keeps a result in memory, within a fixed size. * * @param string $url URL. * @param TargetResult $result Result. */ private function remember( string $url, TargetResult $result ): void { if ( count( $this->cache ) >= self::CACHE_SIZE ) { $this->cache = array(); } $this->cache[ $url ] = $result; } /** * Short pause between page requests, so the scan never floods the server. */ private function pause(): void { $seconds = (float) ( $this->settings['pause'] ?? 0.2 ); if ( $seconds > 0 ) { usleep( (int) ( $seconds * 1000000 ) ); } } /** * References as plain arrays for the job state. * * @param ParsedPage $page Parsed page. * @return array> */ private static function pack( ParsedPage $page ): array { $out = array(); foreach ( $page->references as $ref ) { $out[] = array( $ref->kind, $ref->url, $ref->raw, $ref->tag, $ref->text, $ref->source->type, $ref->source->ref ); } return $out; } /** * Rebuilds a parsed page from the job state. * * @param string $url Page URL. * @param array $refs Packed references. * @param string $title Page title. * @param array $facts Packed page facts. * @phpstan-param array $refs * @phpstan-param array $facts */ private static function unpack( string $url, array $refs, string $title = '', array $facts = array() ): ParsedPage { $references = array(); foreach ( $refs as $r ) { if ( is_array( $r ) && 7 === count( $r ) ) { $r = array_map( 'strval', array_values( $r ) ); $references[] = new Reference( $r[0], $r[1], $r[2], $r[3], $r[4], new Source( $r[5], $r[6] ) ); } } $page_facts = PageFacts::from_state( $facts ); return new ParsedPage( $url, $title, $page_facts->noindex(), $references, $page_facts ); } }