*/ public static function event_types(): array { return array( self::EVENT_VERIFIED => 'Log Verified', self::EVENT_ALERT => 'Log Tampering Detected', self::EVENT_RESTART => 'Log Chain Restarted', ); } /** Whether a run is under way. */ public static function running(): bool { $state = get_option( self::STATE_OPTION, null ); return is_array( $state ) && isset( $state['walk'] ); } /** * The last finished run's verdict, or an empty array before the first. * * @return array */ public static function last(): array { $last = get_option( self::LAST_OPTION, array() ); return is_array( $last ) ? $last : array(); } /** * Begin a run unless one is under way. * * @return string `started` or `running`. */ public static function start(): string { if ( self::running() ) { return 'running'; } update_option( self::STATE_OPTION, array( 'started' => time(), 'walk' => Walk::start( Key::get(), Ledger::cuts( Key::get() )['floor'] ), ), false ); return 'started'; } /** * Work on the current run (starting one if there is none) for up to * `$seconds`. * * @param float $seconds Time budget. * @return array{status: string, rows: int} `running` with the rows read so far, or `done`. */ public static function run( float $seconds ): array { global $wpdb; self::start(); $key = Key::get(); $began = microtime( true ); $state = get_option( self::STATE_OPTION ); $state = is_array( $state ) ? $state : array( 'started' => time(), 'walk' => Walk::start( $key, Ledger::cuts( $key )['floor'] ), ); $walk = (array) $state['walk']; $head = Ledger::head( $key ); $cuts = Ledger::cuts( $key ); // Rotated salts: nothing before the change can be checked. Said as // that, immediately, instead of hashing a million rows to report // each as forged. if ( Ledger::KEY === $head['status'] || Ledger::KEY === $cuts['status'] ) { return self::finish( $state, $walk, $head, $cuts, true ); } $table = Database::get_table_name(); $after = max( (int) $walk['last_id'], $cuts['floor'] ); do { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table; $table is prefix-composed; a verification must read the rows as they are. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table WHERE id > %d ORDER BY id ASC LIMIT %d", $after, self::BATCH ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = is_array( $rows ) ? $rows : array(); $walk = Walk::step( $key, $walk, $rows, $cuts['cuts'], Ledger::OK === $head['status'] ? array( 'id' => (int) $head['id'], 'hash' => (string) $head['hash'], ) : null, 0 === strpos( strtolower( (string) $wpdb->charset ), 'utf8mb4' ) ); $after = max( $after, (int) $walk['last_id'] ); if ( count( $rows ) < self::BATCH ) { return self::finish( $state, $walk, $head, $cuts, false ); } // Re-read per slice: see the class docblock. $head = Ledger::head( $key ); $cuts = Ledger::cuts( $key ); } while ( ( microtime( true ) - $began ) < $seconds ); $state['walk'] = $walk; update_option( self::STATE_OPTION, $state, false ); return array( 'status' => self::RUNNING, 'rows' => (int) $walk['rows'], ); } /** * Close a run: the whole-run checks, the verdict, the record. * * @param array $state The run's stored state. * @param array $walk The walk. * @param array $head Ledger::head(). * @param array $cuts Ledger::cuts(). * @param bool $key_changed Whether the key no longer matches the stored state. * @return array{status: string, rows: int} */ private static function finish( array $state, array $walk, array $head, array $cuts, bool $key_changed ): array { if ( ! $key_changed ) { if ( Ledger::FORGED === $cuts['status'] ) { $walk = Walk::note( $walk, Walk::LEDGER, 0 ); } if ( Ledger::FORGED === $head['status'] && 0 === (int) $walk['sealed'] ) { $walk = Walk::note( $walk, Walk::HEAD, 0 ); } // The head may name a row logged after the walk read its last // slice (cron verifies while the site logs). Ask the table // directly before calling that a missing tail; a row that really // was deleted is still not there. if ( Ledger::OK === $head['status'] && ! $walk['head_seen'] && (int) $head['id'] > (int) $walk['last_id'] && self::hash_at( (int) $head['id'] ) === $head['hash'] ) { $walk['head_seen'] = true; } $walk = Walk::finish( $walk, Ledger::OK === $head['status'] ? array( 'id' => (int) $head['id'], 'hash' => (string) $head['hash'], ) : null, $cuts['cuts'] ); } $verdict = $key_changed ? 'key_changed' : Walk::verdict( $walk ); $result = array( 'verdict' => $verdict, 'started' => (int) ( $state['started'] ?? time() ), 'finished' => time(), 'rows' => (int) $walk['rows'], 'sealed' => (int) $walk['sealed'], 'redacted' => (int) $walk['redacted'], 'unsealed' => (int) $walk['unsealed'], 'imported' => (int) $walk['imported'], 'counts' => (array) $walk['counts'], 'findings' => (array) $walk['findings'], 'config_backed' => Key::config_backed(), ); update_option( self::LAST_OPTION, $result, false ); delete_option( self::STATE_OPTION ); self::record( $result ); /** * A verification of the log's chain has finished. * * The Pro add-on's seam for alerting on a tampered log and for * anchoring the head off the site. The payload carries counts and * the verdict, never row contents; finding ids are row ids. * * @param array $result The stored result: verdict, started, finished, rows, sealed, redacted, unsealed, imported, counts, findings, config_backed. */ do_action( 'ops404_activity_log_chain_verified', $result ); return array( 'status' => self::DONE, 'rows' => (int) $walk['rows'], ); } /** * The chain hash of one row, or null when there is no such row. * * @param int $id Row id. */ private static function hash_at( int $id ): ?string { global $wpdb; $table = Database::get_table_name(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- as in run(). $hash = $wpdb->get_var( $wpdb->prepare( "SELECT chain_hash FROM $table WHERE id = %d", $id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return is_string( $hash ) ? $hash : null; } /** * Write the run to the log itself: every run as `chain_verified`, a bad * one as `chain_tampering_detected`. * * @param array $result The result. */ private static function record( array $result ): void { $bad = in_array( $result['verdict'], array( 'tampered', 'key_changed' ), true ); EventLogger::log( $bad ? self::EVENT_ALERT : self::EVENT_VERIFIED, $bad ? sprintf( 'Log verification found tampering: %s', (string) $result['verdict'] ) : sprintf( 'Log verified: %d rows checked, verdict %s', (int) $result['rows'], (string) $result['verdict'] ), 'chain', null, array( 'verdict' => $result['verdict'], 'rows' => $result['rows'], 'sealed' => $result['sealed'], 'redacted' => $result['redacted'], 'unsealed' => $result['unsealed'], 'counts' => $result['counts'], ) ); } }