] * : How long each batch may run, 1 to 300. * --- * default: 20 * --- * * ## EXAMPLES * * $ wp activity-log integrity scan * 12,481 files checked * Success: Scan 7 finished: 12,481 files checked; new findings: 1 modified, 0 unknown, 0 missing, 0 executable. * * @param array $args Positional arguments; none are taken. * @param array $assoc_args Flags. */ public function scan( array $args, array $assoc_args ): void { self::gate(); $seconds = (float) \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-seconds', (string) self::BATCH_SECONDS ); if ( $seconds < 1.0 || $seconds > 300.0 ) { WP_CLI::error( '--batch-seconds must be between 1 and 300.' ); } $scanner = Scanner::for_site(); if ( 'running' === $scanner->start( 'manual' ) ) { WP_CLI::line( 'A scan is already running; carrying it on.' ); } self::drive( $scanner, $seconds, array( WP_CLI::class, 'line' ), 1 ); WP_CLI::success( self::summary_line( Scanner::last() ) ); } /** * Shows the scan's state, the last finished scan and the open findings' * counts; with --findings, the findings themselves. * * ## OPTIONS * * [--findings] * : List the open findings, most severe first, with the IDs `accept` takes. * * [--limit=] * : With --findings: how many, 1 to 1000. * --- * default: 50 * --- * * [--format=] * : Render the findings in a particular format. * --- * default: table * options: * - table * - csv * - json * - yaml * --- * * ## EXAMPLES * * $ wp activity-log integrity status --findings --format=json * * @param array $args Positional arguments; none are taken. * @param array $assoc_args Flags. */ public function status( array $args, array $assoc_args ): void { self::gate(); foreach ( self::status_lines() as $line ) { WP_CLI::line( $line ); } if ( ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'findings', false ) ) { return; } $limit = (int) \WP_CLI\Utils\get_flag_value( $assoc_args, 'limit', (string) self::DEFAULT_LIMIT ); if ( $limit < 1 || $limit > self::MAX_LIMIT ) { WP_CLI::error( sprintf( '--limit must be between 1 and %d.', self::MAX_LIMIT ) ); } $formatter = new Formatter( $assoc_args, array( 'id', 'severity', 'status', 'path', 'detected_at' ) ); $formatter->display_items( self::findings( $limit ) ); } /** * Accepts findings: each file as it is on the disk NOW becomes what is * expected (a missing file's absence is accepted). Or, with --unit, a * whole plugin or theme is re-baselined on the next scan. * * Every acceptance is a log row naming the file and the md5 accepted. * Nothing on the disk changes. * * ## OPTIONS * * [...] * : The finding IDs, as `status --findings` lists them. * * [--unit=] * : Accept a whole plugin, theme or the plugins directory's loose files instead: plugin:, theme: or plugins-root. Core and uploads cannot be accepted whole. * * ## EXAMPLES * * $ wp activity-log integrity accept 42 43 * Success: Accepted 2 of 2. * * $ wp activity-log integrity accept --unit=plugin:my-plugin * * @param array $args Finding ids. * @param array $assoc_args Flags. */ public function accept( array $args, array $assoc_args ): void { self::gate(); $unit = isset( $assoc_args['unit'] ) ? (string) $assoc_args['unit'] : ''; if ( '' !== $unit ) { if ( array() !== $args ) { WP_CLI::error( 'Give finding IDs or --unit, not both.' ); } if ( 'accepted_unit' !== Review::accept_unit( $unit ) ) { WP_CLI::error( sprintf( '%s cannot be accepted whole: use plugin:, theme: or plugins-root (core and uploads are accepted file by file).', $unit ) ); } WP_CLI::success( sprintf( 'All of %s accepted; it is re-baselined on the next scan.', $unit ) ); return; } if ( array() === $args ) { WP_CLI::error( 'Give at least one finding ID, or --unit.' ); } $roots = Roots::from_wordpress(); $accepted = 0; foreach ( $args as $id ) { if ( ! ctype_digit( (string) $id ) || 'accepted' !== Review::accept( (int) $id, $roots ) ) { WP_CLI::warning( sprintf( 'No open finding %s.', $id ) ); continue; } ++$accepted; } if ( 0 === $accepted ) { WP_CLI::error( 'Nothing was accepted.' ); } WP_CLI::success( sprintf( 'Accepted %d of %d.', $accepted, count( $args ) ) ); } /** * Why these subcommands are refused here, or null when they may run: the * Integrity tab's own two limits (see the class docblock). */ public static function refusal(): ?string { if ( ! IntegrityActions::available() ) { return 'File integrity monitoring runs on the network\'s main site only: add --url=.'; } if ( get_current_user_id() > 0 && ! current_user_can( IntegrityActions::capability() ) ) { return sprintf( 'The --user given cannot manage file integrity monitoring (it needs %s).', IntegrityActions::capability() ); } return null; } /** * Run a scan's batches until it finishes, saying how far it got. * * A batch that makes no progress means another driver (WP-Cron, an open * tab's "Run a batch now") holds the scan's lock and is doing the work; * this waits for it rather than spinning. The wait is bounded: 60 batches * in a row without progress end the command with the scan still running, * which WP-Cron, the tab and the next run carry on. * * @param Scanner $scanner The scanner. * @param float $seconds Budget per batch. * @param callable(string): void $say Receives each progress line. * @param int $pause Seconds to wait while another driver holds the lock; 0 in tests. * @return string Scanner::IDLE when the scan finished, Scanner::RUNNING when it was left to its other driver. */ public static function drive( Scanner $scanner, float $seconds, callable $say, int $pause ): string { $before = -1; $stuck = 0; do { $state = $scanner->run( $seconds ); $files = (int) $state['files']; if ( Scanner::RUNNING === $state['status'] && $files === $before ) { if ( ++$stuck > 60 ) { return Scanner::RUNNING; } if ( $pause > 0 ) { sleep( $pause ); } continue; } $stuck = 0; $before = $files; if ( Scanner::RUNNING === $state['status'] ) { $say( sprintf( '%s files checked', number_format( $files ) ) ); } } while ( Scanner::RUNNING === $state['status'] ); return Scanner::IDLE; } /** * The last finished scan in one line. * * @param array $last Scanner::last(). */ public static function summary_line( array $last ): string { if ( array() === $last ) { return 'No scan has finished yet.'; } $new = (array) ( $last['new'] ?? array() ); $parts = array(); foreach ( Comparison::FINDINGS as $status ) { $parts[] = sprintf( '%d %s', (int) ( $new[ $status ] ?? 0 ), $status ); } $line = sprintf( 'Scan %d finished: %s files checked; new findings: %s.', (int) ( $last['scan_id'] ?? 0 ), number_format( (int) ( $last['files'] ?? 0 ) ), implode( ', ', $parts ) ); $unverified = count( (array) ( $last['errors'] ?? array() ) ); if ( $unverified > 0 ) { $line .= sprintf( ' %d could not be verified against wordpress.org and will be retried.', $unverified ); } if ( (int) ( $last['overflow'] ?? 0 ) > 0 ) { $line .= sprintf( ' %d findings could not be recorded (the findings limit was reached).', (int) $last['overflow'] ); } return $line; } /** * What `status` prints before any findings. * * @return array */ public static function status_lines(): array { $state = Scanner::state(); $counts = Store::severity_counts(); $lines = array(); $lines[] = Scanner::RUNNING === $state['status'] ? sprintf( 'A scan is running: scan %d, %s files checked so far.', (int) $state['scan_id'], number_format( (int) $state['files'] ) ) : 'No scan is running.'; $lines[] = 'Last scan: ' . self::summary_line( Scanner::last() ); $lines[] = sprintf( 'Open findings: %d high, %d medium, %d low.', $counts[ Comparison::HIGH ], $counts[ Comparison::MEDIUM ], $counts[ Comparison::LOW ] ); $lines[] = sprintf( 'Daily scan: %s.', IntegrityActions::auto_enabled() ? 'on' : 'off' ); return $lines; } /** * The open findings as `status --findings` lists them: paths as the tab * shows them (relative to the WordPress directory), never an absolute * path, never contents. * * @param int $limit How many. * @return array */ public static function findings( int $limit ): array { $roots = Roots::from_wordpress(); $severity = array( Comparison::HIGH => 'high', Comparison::MEDIUM => 'medium', Comparison::LOW => 'low', ); return array_map( static fn( array $row ): array => array( 'id' => (int) $row['id'], 'severity' => $severity[ (int) $row['severity'] ] ?? 'low', 'status' => (string) $row['status'], 'path' => $roots->display( Scanner::root_of( (string) $row['unit'] ), (string) $row['path'] ), 'detected_at' => null === $row['detected_at'] ? '' : (string) $row['detected_at'], ), Store::findings( $limit ) ); } /** End the command when refusal() says so. */ private static function gate(): void { $refusal = self::refusal(); if ( null !== $refusal ) { WP_CLI::error( $refusal ); } } }