*/ private array $state = array(); /** * Rows of the directory being walked, keyed by path (this request only). * * @var array> */ private array $rows = array(); /** Which directory $rows (and $names) hold. */ private ?string $rows_dir = null; /** * The sorted listing of that directory (this request only). * * @var array */ private array $names = array(); /** This run's spend. */ private int $run_files = 0; private int $run_bytes = 0; private float $deadline = 0.0; /** This scanner's per-run file cap (MAX_FILES_PER_RUN unless a test lowers it). */ private int $max_files_per_run; /** This scanner's per-file size cap (MAX_FILE_BYTES unless a test lowers it). */ private int $max_file_bytes; /** * @param Roots $roots The roots to read. * @param string|null $wp_version The WordPress version to verify core against (default: running). * @param string|null $locale The core package locale (default: the install's). * @param array $limits `files_per_run` and `file_bytes`, lower than the defaults only (the tests' resumability and size fixtures). */ public function __construct( Roots $roots, ?string $wp_version = null, ?string $locale = null, array $limits = array() ) { $package = $GLOBALS['wp_local_package'] ?? null; $this->max_files_per_run = max( 1, min( self::MAX_FILES_PER_RUN, (int) ( $limits['files_per_run'] ?? self::MAX_FILES_PER_RUN ) ) ); $this->max_file_bytes = max( 1, min( self::MAX_FILE_BYTES, (int) ( $limits['file_bytes'] ?? self::MAX_FILE_BYTES ) ) ); $this->roots = $roots; $this->wp_version = $wp_version ?? (string) ( $GLOBALS['wp_version'] ?? '' ); $this->locale = $locale ?? ( is_string( $package ) && '' !== $package ? $package : 'en_US' ); } /** The site's own scanner. */ public static function for_site(): self { return new self( Roots::from_wordpress() ); } /** * @return array Event type => label for the log's filter. */ public static function event_types(): array { return array( self::EVENT_COMPLETED => 'Integrity Scan Completed', self::EVENT_CHANGES => 'File Changes Detected', self::EVENT_ACCEPTED => 'Integrity Finding Accepted', self::EVENT_REBASELINED => 'Integrity Baseline Reset', self::EVENT_SETTINGS => 'Integrity Settings Changed', ); } /** * The stored state, with defaults. * * @return array */ public static function state(): array { $state = get_option( self::STATE_OPTION, array() ); $state = is_array( $state ) ? $state : array(); return array_merge( array( 'status' => self::IDLE, 'scan_id' => 0, 'trigger' => '', 'started' => 0, 'units' => array(), 'index' => 0, 'prepared' => false, 'mode' => self::COMPARE, 'stack' => array(), 'dir' => null, 'offset' => 0, 'files' => 0, 'bytes' => 0, 'skipped' => 0, 'links' => 0, 'inert' => 0, 'overflow' => 0, 'baselined' => array(), 'errors' => array(), 'new' => array(), 'paths' => array(), ), $state ); } /** Whether a scan is in progress. */ public static function is_running(): bool { return self::RUNNING === self::state()['status']; } /** * The last finished scan's summary, or an empty array. * * @return array */ public static function last(): array { $last = get_option( self::LAST_OPTION, array() ); return is_array( $last ) ? $last : array(); } /** * Begin a scan. Does no work itself: run() does. * * @param string $trigger What started it: manual, schedule. * @return string started, or running when one already is. */ public function start( string $trigger ): string { if ( self::is_running() ) { return 'running'; } $scan_id = (int) get_option( self::SEQUENCE_OPTION, 0 ) + 1; update_option( self::SEQUENCE_OPTION, $scan_id, false ); $this->state = array_merge( self::state(), array( 'status' => self::RUNNING, 'scan_id' => $scan_id, 'trigger' => sanitize_key( $trigger ), 'started' => time(), 'units' => Units::discover( $this->roots, $this->wp_version ), 'index' => 0, 'prepared' => false, 'mode' => self::COMPARE, 'stack' => array(), 'dir' => null, 'offset' => 0, 'files' => 0, 'bytes' => 0, 'skipped' => 0, 'links' => 0, 'inert' => 0, 'overflow' => 0, 'baselined' => array(), 'errors' => array(), 'new' => array(), 'paths' => array(), ) ); $this->save(); return 'started'; } /** * Stop a running scan. Rows already written stay: they are true. * * @return bool Whether one was running. */ public static function cancel(): bool { if ( ! self::is_running() ) { return false; } $state = self::state(); $state['status'] = self::IDLE; update_option( self::STATE_OPTION, $state, false ); return true; } /** * Work for up to $seconds, then save the position. * * @param float $seconds Time budget. * @return array The state after. */ public function run( float $seconds ): array { if ( ! self::is_running() || ! self::lock() ) { return self::state(); } try { // Re-read under the lock: whoever held it last moved the cursor. $this->state = self::state(); $this->rows = array(); $this->rows_dir = null; $this->run_files = 0; $this->run_bytes = 0; $this->deadline = microtime( true ) + max( 0.0, $seconds ); Store::refresh(); // A unit the updater replaced while this scan was inside it is // re-baselined, not compared (see Rebaseline). if ( true === $this->state['prepared'] && isset( self::pending()[ $this->current_key() ] ) && self::ESTABLISH !== $this->state['mode'] ) { Store::forget_unit( $this->current_key() ); $this->state['mode'] = self::ESTABLISH; } while ( self::RUNNING === $this->state['status'] && ! $this->spent() ) { $this->step(); } $this->save(); } finally { self::unlock(); } return $this->state; } /** Whether this run's budget is gone. */ private function spent(): bool { return microtime( true ) >= $this->deadline || $this->run_files >= $this->max_files_per_run || $this->run_bytes >= self::MAX_BYTES_PER_RUN; } /** One unit of progress. */ private function step(): void { $units = (array) $this->state['units']; $index = (int) $this->state['index']; if ( $index >= count( $units ) ) { $this->finish(); return; } if ( true !== $this->state['prepared'] ) { $this->prepare( $units[ $index ] ); return; } if ( null === $this->state['dir'] ) { $stack = (array) $this->state['stack']; if ( array() === $stack ) { $this->finish_unit( $units[ $index ] ); return; } $this->state['dir'] = array_pop( $stack ); $this->state['stack'] = $stack; $this->state['offset'] = 0; } $this->walk_directory( $units[ $index ] ); } /** * Decide how a unit is compared, fetching wordpress.org's checksums * when its version is new. * * @param array $unit The unit. */ private function prepare( array $unit ): void { $key = (string) $unit['key']; $version = (string) $unit['version']; $meta = self::meta()[ $key ] ?? null; $pending = isset( self::pending()[ $key ] ); $current = is_array( $meta ) && ( $meta['version'] ?? null ) === $version; $mode = self::COMPARE; if ( Units::CORE === $key ) { if ( ! $current || ! Store::has_unit( $key ) ) { $sums = Checksums::core( $version, $this->locale ); if ( Checksums::FOUND !== $sums['status'] ) { $this->skip_unit( $key, 'core_checksums_unavailable' ); return; } $this->expect_from_wporg( $key, '', $sums['files'], true ); } } elseif ( Units::UPLOADS === $key ) { $mode = self::COMPARE; } else { $wporg = Units::is_plugin( $key ) && ( ! $current || $pending ); $sums = $wporg ? Checksums::plugin( (string) $unit['slug'], $version ) : null; if ( null !== $sums && Checksums::FOUND === $sums['status'] ) { $this->expect_from_wporg( $key, (string) $unit['slug'] . '/', $sums['files'], false ); } elseif ( null !== $sums && Checksums::ERROR === $sums['status'] ) { // Unreachable: try again next scan rather than baselining a // unit wordpress.org may well vouch for. $this->skip_unit( $key, 'plugin_checksums_unavailable' ); return; } elseif ( $pending || ( ! is_array( $meta ) && ! self::established() ) ) { // The first scan (begun afresh if an earlier first scan was // cancelled part-way), or a known-good replacement: record // what is there. Store::forget_unit( $key ); $mode = self::ESTABLISH; } elseif ( is_array( $meta ) && Store::SOURCE_WPORG === ( $meta['source'] ?? '' ) && ! $current ) { // Was verified by wordpress.org, and now carries a version // it publishes nothing for, without the updater: the old // release's checksums stay the yardstick, so every file the // change touched is reported. $mode = self::COMPARE; } // Otherwise: the baseline as it stands, or -- for a unit that // appeared outside the updater after the first scan -- no rows // at all, so every file in it is reported as unknown. } $this->state['mode'] = $mode; $this->state['prepared'] = true; $this->state['dir'] = null; $this->state['offset'] = 0; $this->state['stack'] = array_reverse( (array) $unit['start'] ); if ( self::ESTABLISH === $mode ) { $baselined = (array) $this->state['baselined']; $baselined[] = $key; $this->state['baselined'] = array_slice( $baselined, -100 ); } } /** * Replace a unit's rows with wordpress.org's checksums. * * @param string $key Unit key. * @param string $prefix Path prefix within the root (a plugin's directory). * @param array $files Path => expected list. * @param bool $core Whether these are core's (wp-content/ entries are skipped). */ private function expect_from_wporg( string $key, string $prefix, array $files, bool $core ): void { Store::forget_unit( $key ); foreach ( $files as $path => $expected ) { // Core's list includes the bundled plugins and themes and the // wp-content index files: those belong to their own units (or // to the site), exactly as WP-CLI leaves them out. if ( $core && 0 === strpos( $path, 'wp-content/' ) ) { continue; } $written = Store::insert( array( 'unit' => $key, 'path' => $prefix . $path, 'source' => Store::SOURCE_WPORG, 'expected' => $expected, 'status' => Comparison::OK, ) ); if ( ! $written ) { ++$this->state['overflow']; } } $meta = self::meta(); $meta[ $key ] = array( 'version' => $this->unit_version( $key ), 'source' => Store::SOURCE_WPORG, ); update_option( self::META_OPTION, $meta, false ); } /** * Note a unit that could not be verified this scan, and move on. * * @param string $key Unit key. * @param string $reason Reason code. */ private function skip_unit( string $key, string $reason ): void { $errors = (array) $this->state['errors']; $errors[] = array( 'unit' => $key, 'reason' => $reason, ); $this->state['errors'] = array_slice( $errors, 0, self::MAX_ERRORS ); $this->state['index'] = (int) $this->state['index'] + 1; $this->state['prepared'] = false; $this->state['dir'] = null; $this->state['stack'] = array(); } /** * Walk (part of) the current directory. * * @param array $unit The unit. */ private function walk_directory( array $unit ): void { $dir = (array) $this->state['dir']; $relative = (string) ( $dir[0] ?? '' ); $recurse = (bool) ( $dir[1] ?? false ); $report_unknown = (bool) ( $dir[2] ?? false ); $root_key = (string) $unit['root']; $root = $this->roots->path( $root_key ); $absolute = '' === $relative ? $root : $root . $relative . '/'; $key = (string) $unit['key']; if ( $this->rows_dir !== $key . '|' . $relative ) { $listing = '' !== $root && is_dir( $absolute ) && ! is_link( rtrim( $absolute, '/' ) ) ? scandir( $absolute ) : false; if ( false === $listing ) { // Gone, or never there (a core install without wp-admin/): // its rows are swept as missing when the unit finishes. $this->state['dir'] = null; return; } $this->names = array_values( array_diff( $listing, array( '.', '..' ) ) ); $this->rows = Store::rows_in( $key, $relative ); $this->rows_dir = $key . '|' . $relative; } $offset = (int) $this->state['offset']; $names = $this->names; $count = count( $names ); $end = min( $count, $offset + self::ENTRIES_PER_STEP ); for ( $i = $offset; $i < $end; $i++ ) { $name = (string) $names[ $i ]; $path = '' === $relative ? $name : $relative . '/' . $name; $file = $root . $path; $this->state['offset'] = $i + 1; if ( is_link( $file ) ) { if ( is_dir( $file ) ) { ++$this->state['links']; // Never followed. continue; } $real = realpath( $file ); if ( ! is_string( $real ) || ! $this->roots->inside( $root_key, $real ) ) { ++$this->state['links']; $this->file_outside( $key, $path, $report_unknown ); continue; } } if ( is_dir( $file ) ) { if ( $recurse && ! in_array( $name, Comparison::SKIPPED_DIRECTORIES, true ) ) { $this->push( array( $path, true, $report_unknown ) ); } continue; } if ( is_file( $file ) ) { if ( Units::UPLOADS === $key ) { $this->upload( $path, $file ); } else { $this->file( $key, $path, $file, $report_unknown ); } } if ( $this->spent() ) { return; } } if ( (int) $this->state['offset'] >= $count ) { $this->state['dir'] = null; $this->state['offset'] = 0; $this->save(); } } /** * @param array{0:string,1:bool,2:bool} $dir A walk entry. */ private function push( array $dir ): void { $stack = (array) $this->state['stack']; if ( count( $stack ) >= self::MAX_STACK ) { ++$this->state['overflow']; return; } $stack[] = $dir; $this->state['stack'] = $stack; } /** * Compare one file of a core, plugin or theme unit. * * @param string $key Unit key. * @param string $path Path within the root. * @param string $file Absolute path (confined). * @param bool $report_unknown Whether a file without a row is reported. */ private function file( string $key, string $path, string $file, bool $report_unknown ): void { $row = $this->rows[ $path ] ?? null; $size = (int) filesize( $file ); if ( $size > $this->max_file_bytes ) { ++$this->state['skipped']; if ( null !== $row ) { Store::update( (int) $row['id'], array( 'seen_scan' => (int) $this->state['scan_id'] ) ); } return; } if ( null === $row ) { if ( Comparison::is_unhashed( $path ) ) { return; } $establish = self::ESTABLISH === $this->state['mode']; if ( ! $establish && ! $report_unknown ) { return; } $md5 = $this->hash( $file, $size ); if ( null === $md5 ) { return; } $status = $establish ? Comparison::OK : Comparison::UNKNOWN; $this->record_new_row( $key, $path, $status, $establish ? $md5 : null, $establish ? Store::SOURCE_BASELINE : Store::SOURCE_NONE, $md5, $size, $file ); return; } $md5 = $this->hash( $file, $size ); if ( null === $md5 ) { return; } $this->compare_row( $key, $path, $row, Comparison::verdict( null === $row['expected'] ? null : (string) $row['expected'], true, $md5 ), $md5, $size, $file ); } /** * A file of the uploads unit: only an executable one matters. * * @param string $path Path within uploads. * @param string $file Absolute path (confined). */ private function upload( string $path, string $file ): void { if ( ! Comparison::is_executable( $path ) ) { return; } $row = $this->rows[ $path ] ?? null; $size = (int) filesize( $file ); if ( $size <= Comparison::INERT_MAX_BYTES && 1 === preg_match( '/\.php$/i', $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a bounded local read of a confined path, never stored. $head = file_get_contents( $file, false, null, 0, Comparison::INERT_MAX_BYTES ); if ( is_string( $head ) && Comparison::is_inert_php( $head ) ) { ++$this->state['inert']; if ( null !== $row && null === $row['expected'] ) { Store::delete( (int) $row['id'] ); } return; } } $md5 = $size > $this->max_file_bytes ? null : $this->hash( $file, $size ); if ( $size > $this->max_file_bytes ) { ++$this->state['skipped']; } if ( null === $row ) { $this->record_new_row( Units::UPLOADS, $path, Comparison::EXECUTABLE, null, Store::SOURCE_NONE, $md5, $size, $file ); return; } $accepted = null !== $row['expected'] && null !== $md5 && Comparison::OK === Comparison::verdict( (string) $row['expected'], true, $md5 ); $this->compare_row( Units::UPLOADS, $path, $row, $accepted ? Comparison::OK : Comparison::EXECUTABLE, $md5, $size, $file ); } /** * A symlinked file resolving outside its root: never read. A file * wordpress.org or the baseline vouched for that is now such a link has * been replaced, so its row says modified; a new one is unknown. * * @param string $key Unit key. * @param string $path Path within the root. * @param bool $report_unknown Whether a file without a row is reported. */ private function file_outside( string $key, string $path, bool $report_unknown ): void { $row = $this->rows[ $path ] ?? null; $uploads = Units::UPLOADS === $key; if ( $uploads && ! Comparison::is_executable( $path ) ) { return; } if ( null !== $row ) { $this->compare_row( $key, $path, $row, $uploads ? Comparison::EXECUTABLE : Comparison::MODIFIED, null, 0, '' ); } elseif ( $report_unknown ) { $this->record_new_row( $key, $path, $uploads ? Comparison::EXECUTABLE : Comparison::UNKNOWN, null, Store::SOURCE_NONE, null, 0, '' ); } } /** * Write a row for a file that had none. * * @param string $key Unit key. * @param string $path Path within the root. * @param string $status Its status. * @param string|null $expected Expected list (a baseline's own md5). * @param string $source Store::SOURCE_*. * @param string|null $md5 Its md5. * @param int $size Size. * @param string $file Absolute path, '' when not read. */ private function record_new_row( string $key, string $path, string $status, ?string $expected, string $source, ?string $md5, int $size, string $file ): void { $finding = Comparison::OK !== $status; $scan_id = (int) $this->state['scan_id']; $written = Store::insert( array( 'unit' => $key, 'path' => $path, 'source' => $source, 'expected' => $expected, 'status' => $status, 'severity' => Comparison::severity( $key, $path, $status ), 'actual_md5' => $md5, 'actual_size' => '' === $file ? null : $size, 'actual_mtime' => '' === $file ? null : (int) filemtime( $file ), 'seen_scan' => $scan_id, 'reported_scan' => $finding ? $scan_id : 0, 'detected_at' => $finding ? gmdate( 'Y-m-d H:i:s' ) : null, ) ); if ( ! $written ) { ++$this->state['overflow']; return; } if ( $finding ) { $this->note_new( $key, $path, $status ); } } /** * Bring an existing row up to date with what the file is now. * * @param string $key Unit key. * @param string $path Path within the root. * @param array $row The row. * @param string $status The verdict. * @param string|null $md5 The file's md5 (null when not read). * @param int $size Size. * @param string $file Absolute path, '' when not read. */ private function compare_row( string $key, string $path, array $row, string $status, ?string $md5, int $size, string $file ): void { $scan_id = (int) $this->state['scan_id']; $mtime = '' === $file ? null : (int) filemtime( $file ); $was = (string) $row['status']; if ( $was === $status && $md5 === $row['actual_md5'] && (string) $size === (string) $row['actual_size'] && (string) $mtime === (string) $row['actual_mtime'] ) { Store::update( (int) $row['id'], array( 'seen_scan' => $scan_id ) ); return; } $fields = array( 'status' => $status, 'severity' => Comparison::severity( $key, $path, $status ), 'actual_md5' => $md5, 'actual_size' => '' === $file ? null : $size, 'actual_mtime' => $mtime, 'seen_scan' => $scan_id, ); if ( Comparison::OK !== $status && $was !== $status ) { $fields['reported_scan'] = $scan_id; $fields['detected_at'] = gmdate( 'Y-m-d H:i:s' ); $this->note_new( $key, $path, $status ); } Store::update( (int) $row['id'], $fields ); } /** * md5 of a confined file, counting the run's spend. * * @param string $file Absolute path. * @param int $size Its size. */ private function hash( string $file, int $size ): ?string { $md5 = md5_file( $file ); ++$this->run_files; ++$this->state['files']; $this->run_bytes += $size; $this->state['bytes'] = (int) $this->state['bytes'] + $size; return is_string( $md5 ) ? $md5 : null; } /** * Count a new finding for the scan's log row. * * @param string $key Unit key. * @param string $path Path within the root. * @param string $status Its status. */ private function note_new( string $key, string $path, string $status ): void { $new = (array) $this->state['new']; $new[ $status ] = (int) ( $new[ $status ] ?? 0 ) + 1; $severity = Comparison::severity( $key, $path, $status ); $new[ 'sev' . $severity ] = (int) ( $new[ 'sev' . $severity ] ?? 0 ) + 1; $this->state['new'] = $new; $paths = (array) $this->state['paths']; if ( count( $paths ) < self::MAX_REPORTED_PATHS ) { $paths[] = array( 'path' => $this->roots->display( self::root_of( $key ), $path ), 'status' => $status, 'severity' => $severity, ); $this->state['paths'] = $paths; } } /** * Close a unit: rows the walk did not see are missing files (or, for a * finding nothing vouched for, simply gone). * * @param array $unit The unit. */ private function finish_unit( array $unit ): void { $key = (string) $unit['key']; $scan_id = (int) $this->state['scan_id']; do { $rows = Store::unseen( $key, $scan_id, self::SWEEP_PAGE ); $page = count( $rows ); foreach ( $rows as $row ) { $id = (int) $row['id']; if ( Units::UPLOADS === $key || null === $row['expected'] || self::ESTABLISH === $this->state['mode'] ) { Store::delete( $id ); continue; } // A directory that changed between two runs of one scan can // shift a file past the saved offset; one that is there is // not missing, whatever the walk managed to see. $file = $this->roots->resolve( self::root_of( $key ), (string) $row['path'] ); if ( null !== $file && is_file( $file ) ) { Store::update( $id, array( 'seen_scan' => $scan_id ) ); continue; } $this->compare_row( $key, (string) $row['path'], $row, Comparison::MISSING, null, 0, '' ); } } while ( self::SWEEP_PAGE === $page ); if ( Units::UPLOADS !== $key ) { $meta = self::meta(); $prev = $meta[ $key ] ?? array(); $meta[ $key ] = array( 'version' => (string) $unit['version'], 'source' => Units::CORE === $key || ( Store::SOURCE_WPORG === ( $prev['source'] ?? '' ) && self::ESTABLISH !== $this->state['mode'] ) ? Store::SOURCE_WPORG : Store::SOURCE_BASELINE, ); update_option( self::META_OPTION, $meta, false ); } $pending = self::pending(); if ( isset( $pending[ $key ] ) ) { unset( $pending[ $key ] ); update_option( self::PENDING_OPTION, $pending, false ); } $this->state['index'] = (int) $this->state['index'] + 1; $this->state['prepared'] = false; $this->state['dir'] = null; $this->state['stack'] = array(); $this->rows = array(); $this->names = array(); $this->rows_dir = null; $this->save(); } /** Close the scan: forget vanished units, record the summary, log it. */ private function finish(): void { $keys = array_map( static fn( $unit ): string => (string) $unit['key'], (array) $this->state['units'] ); $meta = self::meta(); foreach ( Store::units() as $unit ) { // A plugin or theme directory that is gone took its files with // it; the deletion itself is the updater's or the owner's // business, and the log already records plugin deletions. if ( ! in_array( $unit, $keys, true ) ) { Store::forget_unit( $unit ); unset( $meta[ $unit ] ); } } update_option( self::META_OPTION, array_intersect_key( $meta, array_flip( $keys ) ), false ); update_option( self::ESTABLISHED_OPTION, '1', false ); $this->state['status'] = self::IDLE; $this->save(); $new = (array) $this->state['new']; $total = 0; $by_kind = array(); foreach ( Comparison::FINDINGS as $status ) { $by_kind[ $status ] = (int) ( $new[ $status ] ?? 0 ); $total += $by_kind[ $status ]; } $summary = array( 'scan_id' => (int) $this->state['scan_id'], 'trigger' => (string) $this->state['trigger'], 'started' => (int) $this->state['started'], 'finished' => time(), 'files' => (int) $this->state['files'], 'skipped' => (int) $this->state['skipped'], 'links' => (int) $this->state['links'], 'inert' => (int) $this->state['inert'], 'overflow' => (int) $this->state['overflow'], 'errors' => (array) $this->state['errors'], 'baselined' => count( (array) $this->state['baselined'] ), 'new' => $by_kind, ); update_option( self::LAST_OPTION, $summary, false ); $details = array( 'scan_id' => $summary['scan_id'], 'trigger' => $summary['trigger'], 'files_checked' => $summary['files'], 'new' => $by_kind, 'new_by_severity' => array( 'high' => (int) ( $new[ 'sev' . Comparison::HIGH ] ?? 0 ), 'medium' => (int) ( $new[ 'sev' . Comparison::MEDIUM ] ?? 0 ), 'low' => (int) ( $new[ 'sev' . Comparison::LOW ] ?? 0 ), ), 'paths' => (array) $this->state['paths'], 'units_baselined' => $summary['baselined'], 'unverified' => array_values( array_map( static fn( $e ): string => (string) ( $e['unit'] ?? '' ), $summary['errors'] ) ), 'not_recorded' => $summary['overflow'], ); if ( $total > 0 ) { EventLogger::log( self::EVENT_CHANGES, sprintf( 'File integrity scan found %1$d new finding%2$s', $total, 1 === $total ? '' : 's' ), 'integrity', $summary['scan_id'], $details ); return; } EventLogger::log( self::EVENT_COMPLETED, sprintf( 'File integrity scan finished: %d files checked, no new findings', $summary['files'] ), 'integrity', $summary['scan_id'], $details ); } /** The current unit's key. */ private function current_key(): string { $units = (array) $this->state['units']; $index = (int) $this->state['index']; return isset( $units[ $index ]['key'] ) ? (string) $units[ $index ]['key'] : ''; } /** * The version a unit had when this scan started. * * @param string $key Unit key. */ private function unit_version( string $key ): string { foreach ( (array) $this->state['units'] as $unit ) { if ( $key === $unit['key'] ) { return (string) $unit['version']; } } return ''; } /** * The root a unit's paths are relative to. * * @param string $key Unit key. */ public static function root_of( string $key ): string { if ( Units::CORE === $key ) { return Roots::CORE; } if ( Units::UPLOADS === $key ) { return Roots::UPLOADS; } return 0 === strpos( $key, 'theme:' ) ? Roots::THEMES : Roots::PLUGINS; } /** * Unit key => {version, source} for every unit with rows. * * @return array> */ public static function meta(): array { $meta = get_option( self::META_OPTION, array() ); return is_array( $meta ) ? $meta : array(); } /** * Unit key => time, for units to re-baseline on their next scan. * * @return array */ public static function pending(): array { $pending = get_option( self::PENDING_OPTION, array() ); return is_array( $pending ) ? $pending : array(); } /** * Mark a unit to be re-baselined, forgetting its rows. Used by the * updater hook and by accepting a unit whole. * * @param string $key Unit key. * @return int Rows forgotten. */ public static function rebaseline( string $key ): int { $pending = self::pending(); $pending[ $key ] = time(); if ( count( $pending ) > Units::MAX_PACKAGES ) { $pending = array_slice( $pending, -Units::MAX_PACKAGES, null, true ); } update_option( self::PENDING_OPTION, $pending, false ); return Store::forget_unit( $key ); } /** Whether the site's first scan has finished. */ public static function established(): bool { return '1' === (string) get_option( self::ESTABLISHED_OPTION, '' ); } /** Persist the state. */ private function save(): void { update_option( self::STATE_OPTION, $this->state, false ); } /** Every option this feature writes, for uninstall. */ public const OPTIONS = array( self::STATE_OPTION, self::META_OPTION, self::PENDING_OPTION, self::ESTABLISHED_OPTION, self::LAST_OPTION, self::SEQUENCE_OPTION, Checksums::MISSES_OPTION, ); private static function lock(): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a named lock has no WordPress API. return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK( %s, 0 )', self::lock_name() ) ); } private static function unlock(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a named lock has no WordPress API. $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', self::lock_name() ) ); } /** Per table, so two installs sharing one database server never share it. */ private static function lock_name(): string { return substr( 'ops404al_integrity_' . md5( Store::table() ), 0, 64 ); } }