' . esc_html__( 'File integrity monitoring is managed by a network administrator, from the main site.', '404ops-activity-log' ) . '

'; return; } self::render_result_notice(); echo '

' . esc_html__( 'File integrity', '404ops-activity-log' ) . '

'; echo '

' . esc_html__( 'Compares WordPress core and wordpress.org plugins with the checksums wordpress.org publishes, compares other plugins and themes with a baseline recorded on the first scan, and looks for executable files in uploads. It only reads files: nothing is ever changed, moved or deleted, and no file contents are stored or sent anywhere.', '404ops-activity-log' ) . '

'; self::render_status(); self::render_settings(); self::render_findings(); } /** The notice for the result an action redirected back with. */ private static function render_result_notice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. $code = isset( $_GET[ IntegrityActions::RESULT_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ IntegrityActions::RESULT_ARG ] ) ) : ''; if ( ! in_array( $code, IntegrityActions::RESULTS, true ) ) { return; } $messages = array( 'started' => __( 'Scan started. It continues in the background; on a site without WP-Cron, press "Run a batch now" until it finishes.', '404ops-activity-log' ), 'running' => __( 'A scan is already running.', '404ops-activity-log' ), 'batch' => __( 'Batch done. The scan is still running.', '404ops-activity-log' ), 'finished' => __( 'The scan has finished.', '404ops-activity-log' ), 'cancelled' => __( 'Scan cancelled.', '404ops-activity-log' ), 'not_running' => __( 'No scan is running.', '404ops-activity-log' ), 'accepted' => __( 'Finding accepted. The file as it is now is the new baseline, and the acceptance was logged.', '404ops-activity-log' ), 'accepted_unit' => __( 'All findings for that plugin or theme were accepted. It is re-baselined on the next scan, and the acceptance was logged.', '404ops-activity-log' ), 'not_found' => __( 'That finding no longer exists.', '404ops-activity-log' ), 'saved' => __( 'Integrity settings saved.', '404ops-activity-log' ), 'bad_request' => __( 'That request was not understood.', '404ops-activity-log' ), 'bad_nonce' => __( 'The link has expired. Reload the page and try again.', '404ops-activity-log' ), 'forbidden' => __( 'You are not allowed to manage file integrity monitoring.', '404ops-activity-log' ), ); $is_error = in_array( $code, array( 'bad_request', 'bad_nonce', 'forbidden', 'not_found' ), true ); echo '

' . esc_html( $messages[ $code ] ) . '

'; } /** Where the scan stands, and its buttons. */ private static function render_status(): void { $state = Scanner::state(); $last = Scanner::last(); echo '
'; if ( Scanner::RUNNING === $state['status'] ) { $units = (array) $state['units']; $index = min( (int) $state['index'], max( 0, count( $units ) - 1 ) ); $unit = isset( $units[ $index ]['key'] ) ? (string) $units[ $index ]['key'] : ''; echo '

' . esc_html__( 'A scan is running.', '404ops-activity-log' ) . ' ' . esc_html( sprintf( /* translators: 1: current part number, 2: number of parts, 3: the part being scanned, 4: files checked so far. */ __( 'Part %1$d of %2$d (%3$s), %4$d files checked so far.', '404ops-activity-log' ), min( count( $units ), (int) $state['index'] + 1 ), count( $units ), $unit, (int) $state['files'] ) ) . '

'; echo '

'; self::button( IntegrityActions::OP_BATCH, '', __( 'Run a batch now', '404ops-activity-log' ), 'primary' ); self::button( IntegrityActions::OP_CANCEL, '', __( 'Cancel the scan', '404ops-activity-log' ), 'secondary' ); echo '

'; } else { if ( array() === $last ) { echo '

' . esc_html__( 'No scan has run yet. The first scan records the baseline for plugins and themes wordpress.org cannot vouch for; changes are reported from then on.', '404ops-activity-log' ) . '

'; } else { $new = array_sum( array_map( 'intval', (array) ( $last['new'] ?? array() ) ) ); echo '

' . esc_html( sprintf( /* translators: 1: date and time, 2: files checked, 3: new findings. */ __( 'Last scan finished %1$s: %2$d files checked, %3$d new findings.', '404ops-activity-log' ), wp_date( 'Y-m-d H:i', (int) ( $last['finished'] ?? 0 ) ), (int) ( $last['files'] ?? 0 ), $new ) ) . '

'; self::render_scan_caveats( $last ); } echo '

'; self::button( IntegrityActions::OP_START, '', __( 'Start a scan', '404ops-activity-log' ), 'primary' ); echo '

'; } echo '
'; } /** * What the last scan could not do, said plainly. * * @param array $last Scanner::last(). */ private static function render_scan_caveats( array $last ): void { $lines = array(); foreach ( (array) ( $last['errors'] ?? array() ) as $error ) { $unit = is_array( $error ) ? (string) ( $error['unit'] ?? '' ) : ''; $lines[] = Units::CORE === $unit ? __( 'WordPress core could not be verified: wordpress.org\'s checksums could not be fetched. It is tried again on the next scan.', '404ops-activity-log' ) /* translators: %s: plugin identifier. */ : sprintf( __( '%s could not be verified: wordpress.org\'s checksums could not be fetched. It is tried again on the next scan.', '404ops-activity-log' ), $unit ); } if ( (int) ( $last['skipped'] ?? 0 ) > 0 ) { $lines[] = sprintf( /* translators: 1: number of files, 2: size limit in MB. */ __( '%1$d files larger than %2$d MB were not hashed.', '404ops-activity-log' ), (int) $last['skipped'], (int) ( Scanner::MAX_FILE_BYTES / 1048576 ) ); } if ( (int) ( $last['links'] ?? 0 ) > 0 ) { $lines[] = sprintf( /* translators: %d: number of symbolic links. */ __( '%d symbolic links were not followed (linked directories, or links pointing outside their folder).', '404ops-activity-log' ), (int) $last['links'] ); } if ( (int) ( $last['overflow'] ?? 0 ) > 0 ) { $lines[] = sprintf( /* translators: 1: number of files, 2: maximum findings kept. */ __( '%1$d files could not be recorded because the results table is full (at most %2$d findings are kept). Review and accept findings, then scan again.', '404ops-activity-log' ), (int) $last['overflow'], Store::MAX_FINDINGS ); } if ( array() === $lines ) { return; } echo ''; } /** The daily-scan switch. */ private static function render_settings(): void { echo '

' . esc_html__( 'Schedule', '404ops-activity-log' ) . '

'; echo '
'; echo ''; echo ''; echo ''; wp_nonce_field( IntegrityActions::nonce_action( IntegrityActions::OP_SAVE ), '_wpnonce', false ); echo '

'; echo '

' . esc_html__( 'Off by default. Each scan asks wordpress.org for the checksums of this WordPress version and of each wordpress.org plugin, once per version; that is the only request it makes. Updates installed through WordPress reset that plugin\'s or theme\'s baseline automatically; edits made any other way are reported.', '404ops-activity-log' ) . '

'; submit_button( __( 'Save', '404ops-activity-log' ), 'secondary', 'submit', false ); echo '
'; } /** The findings, most severe first. */ private static function render_findings(): void { $counts = Store::severity_counts(); $total = array_sum( $counts ); echo '

' . esc_html__( 'Findings', '404ops-activity-log' ) . '

'; echo '

' . esc_html( sprintf( /* translators: 1: high, 2: medium, 3: low severity counts. */ __( 'High: %1$d · Medium: %2$d · Low: %3$d', '404ops-activity-log' ), $counts[ Comparison::HIGH ], $counts[ Comparison::MEDIUM ], $counts[ Comparison::LOW ] ) ) . '

'; if ( 0 === $total ) { echo '

' . esc_html__( 'Nothing to review.', '404ops-activity-log' ) . '

'; return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- pagination only. $page = isset( $_GET['integrity_page'] ) ? max( 1, absint( $_GET['integrity_page'] ) ) : 1; $rows = Store::findings( self::PER_PAGE, ( $page - 1 ) * self::PER_PAGE ); $roots = Roots::from_wordpress(); $units = array(); echo ''; echo ''; foreach ( array( __( 'Severity', '404ops-activity-log' ), __( 'Finding', '404ops-activity-log' ), __( 'File', '404ops-activity-log' ), __( 'Expected', '404ops-activity-log' ), __( 'Found', '404ops-activity-log' ), __( 'Detected', '404ops-activity-log' ), __( 'Actions', '404ops-activity-log' ), ) as $heading ) { echo ''; } echo ''; foreach ( $rows as $row ) { $unit = (string) $row['unit']; if ( Units::may_baseline( $unit ) ) { $units[ $unit ] = true; } echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( self::severity_label( (int) $row['severity'] ) ) . '' . esc_html( self::status_label( (string) $row['status'] ) ) . '' . esc_html( $roots->display( Scanner::root_of( $unit ), (string) $row['path'] ) ) . '
' . esc_html( self::source_label( (string) $row['source'], $unit ) ) . '
' . esc_html( null === $row['expected'] ? '—' : implode( ', ', Comparison::expected_hashes( (string) $row['expected'] ) ) ) . '' . esc_html( self::found( $row ) ) . '' . esc_html( null === $row['detected_at'] ? '—' : (string) $row['detected_at'] . ' UTC' ) . ''; self::button( IntegrityActions::OP_ACCEPT, (string) (int) $row['id'], __( 'Accept', '404ops-activity-log' ), 'secondary' ); echo '
'; $pages = (int) ceil( $total / self::PER_PAGE ); if ( $pages > 1 ) { echo '

'; for ( $i = 1; $i <= $pages; $i++ ) { $url = add_query_arg( array( 'page' => '404ops-activity-log', 'tab' => 'integrity', 'integrity_page' => $i, ), admin_url( 'admin.php' ) ); echo $i === $page ? '' . esc_html( (string) $i ) . ' ' : '' . esc_html( (string) $i ) . ' '; } echo '

'; } if ( array() !== $units ) { echo '

' . esc_html__( 'Accept a whole plugin or theme', '404ops-activity-log' ) . '

'; echo '

' . esc_html__( 'For a plugin or theme you installed or changed yourself outside WordPress\'s updater: forgets its findings and records it as it is now on the next scan.', '404ops-activity-log' ) . '

'; foreach ( array_keys( $units ) as $unit ) { /* translators: %s: plugin or theme identifier. */ self::button( IntegrityActions::OP_ACCEPT_UNIT, $unit, sprintf( __( 'Accept all of %s', '404ops-activity-log' ), $unit ), 'secondary' ); } echo '

'; } } /** * What is on the disk now. * * @param array $row A finding. */ private static function found( array $row ): string { if ( Comparison::MISSING === $row['status'] ) { return __( 'no file', '404ops-activity-log' ); } $parts = array( null === $row['actual_md5'] ? __( 'not read', '404ops-activity-log' ) : (string) $row['actual_md5'] ); if ( null !== $row['actual_size'] ) { $size = size_format( (int) $row['actual_size'] ); $parts[] = false === $size ? (string) (int) $row['actual_size'] : $size; } if ( null !== $row['actual_mtime'] ) { /* translators: %s: date and time the file was last modified. */ $parts[] = sprintf( __( 'modified %s', '404ops-activity-log' ), wp_date( 'Y-m-d H:i', (int) $row['actual_mtime'] ) ); } return implode( ' · ', $parts ); } /** * @param int $severity Comparison::HIGH etc. */ private static function severity_label( int $severity ): string { switch ( $severity ) { case Comparison::HIGH: return __( 'High', '404ops-activity-log' ); case Comparison::MEDIUM: return __( 'Medium', '404ops-activity-log' ); default: return __( 'Low', '404ops-activity-log' ); } } /** * @param string $status A finding status. */ private static function status_label( string $status ): string { switch ( $status ) { case Comparison::MODIFIED: return __( 'Modified', '404ops-activity-log' ); case Comparison::MISSING: return __( 'Missing', '404ops-activity-log' ); case Comparison::EXECUTABLE: return __( 'Executable file in uploads', '404ops-activity-log' ); default: return __( 'Unknown file', '404ops-activity-log' ); } } /** * @param string $source Store::SOURCE_*. * @param string $unit Unit key. */ private static function source_label( string $source, string $unit ): string { if ( Store::SOURCE_WPORG === $source ) { return __( 'compared with wordpress.org', '404ops-activity-log' ); } if ( Store::SOURCE_BASELINE === $source ) { return __( 'compared with this site\'s baseline', '404ops-activity-log' ); } if ( Store::SOURCE_ACCEPTED === $source ) { return __( 'compared with an accepted version', '404ops-activity-log' ); } return Units::UPLOADS === $unit ? __( 'not expected in uploads', '404ops-activity-log' ) : __( 'not part of the release or baseline', '404ops-activity-log' ); } /** * One POST button to admin-post.php. * * @param string $op Operation. * @param string $target Its target. * @param string $label Button text. * @param string $kind primary or secondary. */ private static function button( string $op, string $target, string $label, string $kind ): void { echo '
'; echo ''; echo ''; echo ''; wp_nonce_field( IntegrityActions::nonce_action( $op, $target ), '_wpnonce', false ); echo ''; echo '
'; } }