0; } public static function baseline_count(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counts the plugin's own baseline table; the count gates every comparison and must be current. $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', NLSSL_Core::table( 'file_baseline' ) ) ); if ( null === $count || ! empty( $wpdb->last_error ) ) { NLSSL_Core::degraded_storage( 'baseline_count_read' ); return -1; } return max( 0, (int) $count ); } public static function mark_stale(): bool { $count = self::baseline_count(); if ( $count < 0 ) { return false; } if ( 0 === $count ) { return true; } return NLSSL_Core::write_option_verified( self::BASELINE_STALE_OPTION, 1 ); } public static function build_baseline(): array { if ( ! NLSSL_Core::acquire_lock( 'nlssl_integrity_action_lock', 30 ) ) { return array( 'ok' => false, 'message' => __( 'Integrity is busy or storage is unavailable. Retry later.', 'neverlast-security-lite' ) ); } try { return self::build_baseline_locked(); } finally { NLSSL_Core::release_lock( 'nlssl_integrity_action_lock' ); } } private static function storage_failure( string $reason ): array { NLSSL_Core::degraded_storage( $reason ); NLSSL_Core::log( 'integrity_storage_failed', 'critical', 'Integrity storage could not be verified. No new verdict was confirmed; the last stored result may be stale.', array( 'reason' => $reason ) ); return array( 'ok' => false, 'storage_error' => true, 'reason' => $reason, 'count' => 0, 'message' => __( 'Integrity storage could not be verified. No new clean or file-change verdict was confirmed. The last stored result may be stale; repair storage before retrying. An uncertain commit/rollback must be reviewed before trusting a baseline.', 'neverlast-security-lite' ) ); } private static function build_baseline_locked(): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resumable baseline build over the plugin's own staging table; each chunk must see the rows the previous chunk wrote. self::clear_upload_scan_state(); $storage = NLSSL_Core::storage_health(); if ( empty( $storage['schema_ok'] ) ) { $repair = NLSSL_Core::repair_storage(); if ( empty( $repair['ok'] ) ) { $reason = 'baseline_schema_' . sanitize_key( (string) ( $repair['reason'] ?? 'repair_failed' ) ); return self::storage_failure( $reason ); } } $state = self::baseline_state(); if ( empty( $state ) ) { self::clear_scan_state( true ); $state = self::start_baseline_state(); if ( empty( $state ) ) { return self::storage_failure( 'baseline_state_start' ); } } if ( ! hash_equals( (string) ( $state['root_hash'] ?? '' ), hash( 'sha256', trailingslashit( wp_normalize_path( ABSPATH ) ) ) ) ) { self::abort_baseline_build( $state, 'baseline_root_changed' ); return array( 'ok' => false, 'reason' => 'baseline_root_changed', 'message' => __( 'The WordPress filesystem root changed while the baseline was being prepared. The staged baseline was discarded and the previous approved baseline remains active.', 'neverlast-security-lite' ) ); } $phase = (string) ( $state['phase'] ?? 'collect' ); if ( 'commit' === $phase ) { return self::commit_staged_baseline( $state ); } if ( ! in_array( $phase, array( 'collect', 'verify' ), true ) ) { self::abort_baseline_build( $state, 'baseline_state_invalid' ); return array( 'ok' => false, 'reason' => 'baseline_state_invalid', 'message' => __( 'The staged baseline state was invalid and was discarded. The previous approved baseline remains active.', 'neverlast-security-lite' ) ); } $step = self::baseline_traversal_chunk( $state, 'verify' === $phase ); if ( ! empty( $step['fatal'] ) ) { $reason = sanitize_key( (string) ( $step['reason'] ?? 'baseline_inventory_failed' ) ); self::abort_baseline_build( $state, $reason ); NLSSL_Core::log( 'integrity_baseline_rejected', 'warning', 'A staged baseline was discarded before approval.', array( 'reason' => $reason, 'phase' => $phase, 'files' => (int) ( $state['file_count'] ?? 0 ) ) ); return array( 'ok' => false, 'reason' => $reason, 'count' => (int) ( $state['file_count'] ?? 0 ), 'message' => 'filesystem_changed_during_baseline' === $reason ? __( 'The filesystem changed while the staged baseline was being verified. Nothing was approved; the previous baseline was preserved. Retry when plugin/theme updates and deployments are idle.', 'neverlast-security-lite' ) : sprintf( /* translators: %s: internal reason code */ __( 'The staged baseline could not continue (%s). Nothing was approved and the previous baseline was preserved.', 'neverlast-security-lite' ), $reason ), ); } if ( ! empty( $step['pending'] ) ) { if ( ! self::persist_baseline_state( $state ) ) { self::abort_baseline_build( $state, 'baseline_state_write' ); return self::storage_failure( 'baseline_state_write' ); } self::schedule_baseline_resume(); return self::baseline_progress_result( $state ); } if ( 'collect' === $phase ) { global $wpdb; $staging = NLSSL_Core::table( 'file_baseline_staging' ); $job_id = (string) $state['job_id']; $stage_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE job_id = %s', $staging, $job_id ) ); if ( null === $stage_count || ! empty( $wpdb->last_error ) || (int) $stage_count !== (int) ( $state['file_count'] ?? 0 ) ) { self::abort_baseline_build( $state, 'baseline_staging_count' ); return self::storage_failure( 'baseline_staging_count' ); } $state['phase'] = 'verify'; $state['stack'] = array( array( 'dir' => '', 'after' => '' ) ); $state['verify_count'] = 0; $state['verify_entries'] = 0; $state['updated_at'] = time(); if ( ! self::persist_baseline_state( $state ) ) { self::abort_baseline_build( $state, 'baseline_state_write' ); return self::storage_failure( 'baseline_state_write' ); } NLSSL_Core::log( 'integrity_baseline_verification_started', 'info', 'The staged baseline inventory completed and verification started.', array( 'files' => (int) $state['file_count'] ) ); self::schedule_baseline_resume(); return self::baseline_progress_result( $state ); } global $wpdb; $staging = NLSSL_Core::table( 'file_baseline_staging' ); $stage_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE job_id = %s', $staging, (string) $state['job_id'] ) ); if ( null === $stage_count || ! empty( $wpdb->last_error ) ) { self::abort_baseline_build( $state, 'baseline_staging_verify_count' ); return self::storage_failure( 'baseline_staging_verify_count' ); } if ( (int) $stage_count !== (int) ( $state['verify_count'] ?? 0 ) || (int) $stage_count !== (int) ( $state['file_count'] ?? 0 ) ) { self::abort_baseline_build( $state, 'filesystem_changed_during_baseline' ); return array( 'ok' => false, 'reason' => 'filesystem_changed_during_baseline', 'count' => (int) $stage_count, 'message' => __( 'The filesystem changed while the staged baseline was being verified. Nothing was approved; the previous baseline was preserved. Retry when plugin/theme updates and deployments are idle.', 'neverlast-security-lite' ) ); } $state['phase'] = 'commit'; $state['updated_at'] = time(); if ( ! self::persist_baseline_state( $state ) ) { self::abort_baseline_build( $state, 'baseline_state_write' ); return self::storage_failure( 'baseline_state_write' ); } return self::commit_staged_baseline( $state ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function start_baseline_state(): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resets the plugin's own staging table before a new baseline build. global $wpdb; try { $job_id = hash( 'sha256', random_bytes( 32 ) . microtime( true ) ); } catch ( Throwable $e ) { return array(); } $staging = NLSSL_Core::table( 'file_baseline_staging' ); if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $staging ) ) ) { return array(); } $state = array( 'job_id' => $job_id, 'phase' => 'collect', 'root_hash' => hash( 'sha256', trailingslashit( wp_normalize_path( ABSPATH ) ) ), 'stack' => array( array( 'dir' => '', 'after' => '' ) ), 'file_count' => 0, 'symlink_count' => 0, 'entry_count' => 0, 'verify_count' => 0, 'verify_entries' => 0, 'started_at' => time(), 'updated_at' => time(), 'actor' => get_current_user_id(), ); if ( ! self::persist_baseline_state( $state ) ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $staging ) ); return array(); } NLSSL_Core::log( 'integrity_baseline_started', 'info', 'A resumable local baseline build started. The previous approved baseline remains active until final verification and commit.', array( 'actor' => get_current_user_id() ) ); return $state; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function baseline_traversal_chunk( array &$state, bool $verify ): array { global $wpdb; $root = trailingslashit( wp_normalize_path( ABSPATH ) ); if ( NLSSL_Firewall::is_link_path( untrailingslashit( $root ) ) ) { return array( 'fatal' => true, 'reason' => 'inventory_root_symlink' ); } $deadline = microtime( true ) + self::BASELINE_CHUNK_SECONDS; $stage_rows = array(); $listings = array(); $reason = ''; while ( ! empty( $state['stack'] ) ) { if ( microtime( true ) >= $deadline || self::memory_pressure() ) { break; } $entry = self::next_traversal_entry( $state, $root, $listings ); if ( is_wp_error( $entry ) ) { $reason = 'unreadable_directory'; break; } if ( null === $entry ) { continue; } $name = $entry['name']; $absolute_dir = $entry['absolute_dir']; $frame_index = $entry['frame_index']; $counter_key = $verify ? 'verify_entries' : 'entry_count'; $state[ $counter_key ] = (int) ( $state[ $counter_key ] ?? 0 ) + 1; if ( $state[ $counter_key ] > self::MAX_ENTRIES ) { $reason = 'baseline_entry_safety_ceiling'; break; } $path = wp_normalize_path( trailingslashit( $absolute_dir ) . $name ); $relative = ltrim( substr( $path, strlen( $root ) ), '/' ); if ( '' === $relative ) { continue; } $is_link = NLSSL_Firewall::is_link_path( $path ); if ( $is_link ) { $target = @readlink( $path ); if ( false === $target ) { $reason = 'unreadable_symlink'; break; } $meta = array( 'hash' => hash( 'sha256', 'symlink|' . $target ), 'size' => 0, 'mtime' => 0, 'link' => true ); if ( $verify ) { if ( ! self::baseline_verify_stage_row( (string) $state['job_id'], $relative, $meta ) ) { $reason = 'filesystem_changed_during_baseline'; break; } $state['verify_count'] = (int) ( $state['verify_count'] ?? 0 ) + 1; } else { $stage_rows[ $relative ] = $meta; $state['file_count'] = (int) ( $state['file_count'] ?? 0 ) + 1; $state['symlink_count'] = (int) ( $state['symlink_count'] ?? 0 ) + 1; } } elseif ( is_dir( $path ) ) { $normalized = strtolower( str_replace( '\\', '/', $relative ) ); if ( ! preg_match( '#(?:^|/)(?:\\.git|\\.svn|node_modules)(?:/|$)#i', $relative ) && ! self::is_uploads_tree( $normalized ) && ! self::is_legacy_quarantine_tree( $normalized ) ) { $state['stack'][] = array( 'dir' => $relative, 'after' => '' ); } } else { if ( self::excluded( $relative ) || ! is_file( $path ) || ! self::tracked( $relative ) ) { continue; } if ( ! is_readable( $path ) ) { $reason = 'unreadable_file'; break; } $hash = self::hash_path( $path, $deadline ); if ( null === $hash ) { $state['stack'][ $frame_index ]['after'] = $entry['previous']; if ( microtime( true ) >= $deadline || self::memory_pressure() ) { break; } $reason = 'hash_failure_or_changed_file'; break; } clearstatcache( true, $path ); $size = @filesize( $path ); $mtime = @filemtime( $path ); if ( false === $size || false === $mtime || NLSSL_Firewall::is_link_path( $path ) ) { $reason = 'hash_failure_or_changed_file'; break; } $meta = array( 'hash' => $hash, 'size' => (int) $size, 'mtime' => (int) $mtime ); if ( $verify ) { if ( ! self::baseline_verify_stage_row( (string) $state['job_id'], $relative, $meta ) ) { $reason = 'filesystem_changed_during_baseline'; break; } $state['verify_count'] = (int) ( $state['verify_count'] ?? 0 ) + 1; } else { $stage_rows[ $relative ] = $meta; $state['file_count'] = (int) ( $state['file_count'] ?? 0 ) + 1; } } if ( ! $verify && (int) ( $state['file_count'] ?? 0 ) > self::MAX_FILES ) { $reason = 'baseline_file_safety_ceiling'; break; } if ( ! $verify && count( $stage_rows ) >= 100 ) { if ( ! self::baseline_stage_rows( (string) $state['job_id'], $stage_rows ) ) { $reason = 'baseline_staging_write'; break; } $stage_rows = array(); } } if ( ! $verify && ! empty( $stage_rows ) && ! self::baseline_stage_rows( (string) $state['job_id'], $stage_rows ) ) { $reason = 'baseline_staging_write'; } $state['updated_at'] = time(); if ( '' !== $reason ) { return array( 'fatal' => true, 'reason' => $reason ); } return array( 'fatal' => false, 'pending' => ! empty( $state['stack'] ), 'reason' => '' ); } /** * Advances the top frame of a resumable traversal (baseline build or comparison) to its next entry. * * A frame resumes after the last name it visited, never at a numeric position. The sorted listing is * read again in every chunk, and an entry added or removed before a saved position between chunks * (WordPress writes .maintenance at the site root during every update; cache plugins add drop-ins) * would shift it, so a position revisits a whole subtree or skips an entry. By name, an entry that * exists throughout is visited exactly once. A listing is read once per directory per chunk. Frames * saved by 1.2.3 and earlier carry 'index' and are converted the first time they are read. * * @return array|null|WP_Error The entry ('name', 'absolute_dir', 'frame_index' and 'previous', the cursor * to restore if the entry must be retried), null when the directory is * finished and its frame was popped, or an error for an unreadable directory. */ private static function next_traversal_entry( array &$state, string $root, array &$listings ) { $frame_index = count( $state['stack'] ) - 1; $frame = $state['stack'][ $frame_index ]; $relative_dir = ltrim( wp_normalize_path( (string) ( $frame['dir'] ?? '' ) ), '/' ); $absolute_dir = $root . $relative_dir; if ( ! isset( $listings[ $relative_dir ] ) ) { $entries = self::baseline_directory_entries( $absolute_dir ); if ( is_wp_error( $entries ) ) { return $entries; } if ( array_key_exists( 'after', $frame ) ) { $after = (string) $frame['after']; $position = 0; while ( isset( $entries[ $position ] ) && strcmp( (string) $entries[ $position ], $after ) <= 0 ) { $position++; } } else { $position = min( count( $entries ), max( 0, (int) ( $frame['index'] ?? 0 ) ) ); } $listings[ $relative_dir ] = array( 'entries' => $entries, 'position' => $position ); } $position = (int) $listings[ $relative_dir ]['position']; $entries = $listings[ $relative_dir ]['entries']; if ( ! isset( $entries[ $position ] ) ) { array_pop( $state['stack'] ); unset( $listings[ $relative_dir ] ); return null; } $name = (string) $entries[ $position ]; $previous = array_key_exists( 'after', $frame ) ? (string) $frame['after'] : ( $position > 0 ? (string) $entries[ $position - 1 ] : '' ); $listings[ $relative_dir ]['position'] = $position + 1; $state['stack'][ $frame_index ]['after'] = $name; unset( $state['stack'][ $frame_index ]['index'] ); return array( 'name' => $name, 'absolute_dir' => $absolute_dir, 'frame_index' => $frame_index, 'previous' => $previous ); } private static function baseline_directory_entries( string $directory ) { try { $names = array(); $iterator = new FilesystemIterator( $directory, FilesystemIterator::SKIP_DOTS ); foreach ( $iterator as $item ) { $names[] = $item->getFilename(); } sort( $names, SORT_STRING ); return $names; } catch ( Throwable $exception ) { return new WP_Error( 'nlssl_baseline_directory', 'unreadable' ); } } private static function baseline_stage_rows( string $job_id, array $rows ): bool { if ( empty( $rows ) ) { return true; } global $wpdb; $table = NLSSL_Core::table( 'file_baseline_staging' ); $placeholders = array(); $values = array(); foreach ( $rows as $path => $file ) { $placeholders[] = '(%s,%s,%s,%s,%d,%d)'; array_push( $values, $job_id, hash( 'sha256', (string) $path ), (string) $path, (string) $file['hash'], (int) $file['size'], (int) $file['mtime'] ); } $sql = 'INSERT INTO %i (job_id,path_hash,file_path,file_hash,file_size,modified_at) VALUES ' . implode( ',', $placeholders ) . ' ON DUPLICATE KEY UPDATE file_path=VALUES(file_path),file_hash=VALUES(file_hash),file_size=VALUES(file_size),modified_at=VALUES(modified_at)'; array_unshift( $values, $table ); return false !== $wpdb->query( $wpdb->prepare( $sql, $values ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql repeats one fixed placeholder tuple per row; every value is bound. Bulk-inserts a bounded chunk of file records into the plugin's own staging table. } private static function baseline_verify_stage_row( string $job_id, string $path, array $file ): bool { global $wpdb; $table = NLSSL_Core::table( 'file_baseline_staging' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Re-reads a staged file record to verify the write; a cached read would verify nothing. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT file_path,file_hash,file_size,modified_at FROM %i WHERE job_id = %s AND path_hash = %s', $table, $job_id, hash( 'sha256', $path ) ), ARRAY_A ); if ( ! is_array( $row ) || ! empty( $wpdb->last_error ) ) { return false; } return hash_equals( (string) $row['file_path'], $path ) && hash_equals( (string) $row['file_hash'], (string) $file['hash'] ) && (int) $row['file_size'] === (int) $file['size'] && (int) $row['modified_at'] === (int) $file['mtime']; } private static function table_engine( string $table ): string { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads the storage engine of the plugin's own baseline table; there is no WordPress API for table metadata. global $wpdb; if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) { return ''; } $wpdb->last_error = ''; $row = $wpdb->get_row( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $wpdb->esc_like( $table ) ), ARRAY_A ); if ( is_array( $row ) && ! empty( $row['Engine'] ) ) { return strtolower( (string) $row['Engine'] ); } $wpdb->last_error = ''; $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) ); return is_string( $engine ) ? strtolower( $engine ) : ''; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function ensure_baseline_transaction_engine( string $baseline ): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Converts the plugin's own baseline table to InnoDB when a transactional commit needs it, verifying the row count before and after. No WordPress API exists for this. global $wpdb; $engine = self::table_engine( $baseline ); if ( 'innodb' === $engine ) { return array( 'ok' => true, 'engine' => 'innodb' ); } if ( '' === $engine ) { return array( 'ok' => false, 'reason' => 'baseline_engine_unknown', 'message' => __( 'NeverLast Security could not determine the approved baseline table engine. The fully verified staging rows were preserved. Check database metadata permissions, then use Continue baseline build.', 'neverlast-security-lite' ) ); } if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $baseline ) ) { return array( 'ok' => false, 'reason' => 'baseline_engine_unknown', 'message' => __( 'The baseline table identifier could not be validated. The staged baseline was preserved.', 'neverlast-security-lite' ) ); } $before_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $baseline ) ); if ( null === $before_count || ! empty( $wpdb->last_error ) ) { return array( 'ok' => false, 'reason' => 'baseline_engine_conversion_unverified', 'message' => __( 'NeverLast Security could not verify the current baseline row count before preparing transactional storage. The staged baseline was preserved.', 'neverlast-security-lite' ) ); } $wpdb->last_error = ''; if ( false === $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ENGINE=InnoDB', $baseline ) ) ) { return array( 'ok' => false, 'reason' => 'baseline_engine_conversion_failed', 'message' => __( 'The baseline table is not InnoDB and NeverLast Security could not convert it. The previous approved baseline and fully verified staging rows were preserved. Grant ALTER permission or convert only the NeverLast Security baseline table to InnoDB, then use Continue baseline build.', 'neverlast-security-lite' ) ); } $after_engine = self::table_engine( $baseline ); $after_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $baseline ) ); if ( 'innodb' !== $after_engine || null === $after_count || ! empty( $wpdb->last_error ) || (int) $before_count !== (int) $after_count ) { return array( 'ok' => false, 'reason' => 'baseline_engine_conversion_unverified', 'message' => __( 'The baseline engine conversion could not be verified without ambiguity. The fully verified staging rows were preserved and were not approved.', 'neverlast-security-lite' ) ); } NLSSL_Core::log( 'integrity_baseline_engine_converted', 'warning', 'NeverLast Security converted only its approved baseline table to InnoDB so final baseline replacement can be transactional.', array( 'from' => $engine, 'rows' => (int) $after_count ) ); return array( 'ok' => true, 'engine' => 'innodb', 'converted_from' => $engine ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange } private static function commit_staged_baseline( array $state ): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One database transaction replaces the approved baseline with the verified staged rows in the plugin's own tables; it has to run directly against the database. global $wpdb; $baseline = NLSSL_Core::table( 'file_baseline' ); $staging = NLSSL_Core::table( 'file_baseline_staging' ); $job_id = (string) ( $state['job_id'] ?? '' ); $stage_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE job_id = %s', $staging, $job_id ) ); if ( null === $stage_count || ! empty( $wpdb->last_error ) || (int) $stage_count <= 0 || (int) $stage_count !== (int) ( $state['file_count'] ?? 0 ) ) { return self::storage_failure( 'baseline_staging_commit_count' ); } $engine = self::ensure_baseline_transaction_engine( $baseline ); if ( empty( $engine['ok'] ) ) { $state['phase'] = 'commit'; $state['commit_reason'] = sanitize_key( (string) ( $engine['reason'] ?? 'baseline_engine_unavailable' ) ); $state['manual_resume_only'] = 1; if ( ! self::persist_baseline_state( $state ) ) { return self::storage_failure( 'baseline_state_write' ); } NLSSL_Core::degraded_storage( (string) $state['commit_reason'] ); NLSSL_Core::log( 'integrity_baseline_commit_paused', 'critical', 'The verified staged baseline could not be committed because transactional baseline storage is unavailable. The staged job was preserved for manual retry.', array( 'reason' => (string) $state['commit_reason'], 'files' => (int) $stage_count ) ); return array( 'ok' => false, 'resume_pending' => true, 'manual_resume_only' => true, 'phase' => 'commit', 'reason' => (string) $state['commit_reason'], 'count' => (int) $stage_count, 'verified' => (int) ( $state['verify_count'] ?? 0 ), 'message' => (string) ( $engine['message'] ?? __( 'The staged baseline is fully verified but transactional commit storage could not be prepared. The staged rows and previous approved baseline were preserved. Repair database engine/ALTER permissions, then use Continue baseline build.', 'neverlast-security-lite' ) ), ); } // The staging table is read-only during commit and does not need to be transactional. // Only the approved baseline table must be InnoDB so DELETE + INSERT + COMMIT is atomic. if ( false === $wpdb->query( 'START TRANSACTION' ) ) { return self::storage_failure( 'baseline_transaction_start' ); } $ok = false !== $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $baseline ) ); if ( $ok ) { $inserted = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (path_hash,file_path,file_hash,file_size,modified_at) SELECT path_hash,file_path,file_hash,file_size,modified_at FROM %i WHERE job_id = %s', $baseline, $staging, $job_id ) ); $ok = false !== $inserted && (int) $inserted === (int) $stage_count; } $committed = $ok && false !== $wpdb->query( 'COMMIT' ); if ( ! $committed ) { $rolled_back = false !== $wpdb->query( 'ROLLBACK' ); return self::storage_failure( $rolled_back ? 'baseline_transaction_failed' : 'baseline_rollback_unconfirmed' ); } NLSSL_Core::clear_degraded_storage( array( 'baseline_transaction_engine', 'baseline_engine_unknown', 'baseline_engine_conversion_failed', 'baseline_engine_conversion_unverified' ) ); $metadata_ok = NLSSL_Core::write_option_verified( self::BASELINE_TIME_OPTION, gmdate( 'c' ) ); $metadata_ok = NLSSL_Core::write_option_verified( self::BASELINE_COMPONENTS_OPTION, self::component_snapshot() ) && $metadata_ok; foreach ( array( self::BASELINE_STALE_OPTION, self::RESULT_OPTION ) as $option ) { delete_option( $option ); $value = NLSSL_Core::persisted_option( $option, null ); $metadata_ok = $metadata_ok && ! is_wp_error( $value ) && null === $value; } delete_option( self::BASELINE_STATE_OPTION ); wp_clear_scheduled_hook( self::BASELINE_RESUME_HOOK ); $state_gone = NLSSL_Core::persisted_option( self::BASELINE_STATE_OPTION, null ); $state_cleared = ! is_wp_error( $state_gone ) && null === $state_gone; if ( $state_cleared ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE job_id = %s', $staging, $job_id ) ); } $metadata_ok = $metadata_ok && $state_cleared; NLSSL_Core::log( 'integrity_baseline_created', $metadata_ok ? 'warning' : 'critical', $metadata_ok ? 'A new resumable local integrity baseline was verified and approved.' : 'A new baseline was committed, but cleanup/display metadata could not be fully verified.', array( 'files' => (int) $stage_count, 'actor' => (int) ( $state['actor'] ?? 0 ), 'completed_by' => get_current_user_id(), 'symlinks' => (int) ( $state['symlink_count'] ?? 0 ) ) ); return array( 'ok' => true, 'count' => (int) $stage_count, 'symlink_count' => (int) ( $state['symlink_count'] ?? 0 ), 'metadata_warning' => ! $metadata_ok, 'message' => $metadata_ok ? __( 'The local baseline was built in bounded chunks, verified in a second pass and committed atomically. It records state, not vendor authenticity.', 'neverlast-security-lite' ) : __( 'The verified baseline rows were committed, but cleanup or WordPress option metadata could not be fully verified. Repair storage before relying on the displayed baseline state.', 'neverlast-security-lite' ), ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function baseline_progress_result( array $state ): array { $phase = (string) ( $state['phase'] ?? 'collect' ); return array( 'ok' => false, 'resume_pending' => true, 'phase' => $phase, 'count' => (int) ( $state['file_count'] ?? 0 ), 'verified' => (int) ( $state['verify_count'] ?? 0 ), 'started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ), 'message' => 'commit' === $phase ? sprintf( /* translators: %d: number of files */ __( 'Baseline collection and second-pass verification are complete for %d file(s). Final atomic commit is pending; the previous approved baseline remains active until commit succeeds.', 'neverlast-security-lite' ), (int) ( $state['file_count'] ?? 0 ) ) : ( 'verify' === $phase ? sprintf( /* translators: %1$d: number of staged files, %2$d: number of files re-verified so far */ __( 'Baseline verification is continuing in bounded chunks. %1$d file(s) were staged and %2$d have been re-verified. The previous approved baseline remains active until atomic commit.', 'neverlast-security-lite' ), (int) ( $state['file_count'] ?? 0 ), (int) ( $state['verify_count'] ?? 0 ) ) : sprintf( /* translators: %d: number of staged files */ __( 'Baseline inventory progress was saved after staging %d file(s). It will continue automatically; the previous approved baseline remains active until a complete verification and atomic commit.', 'neverlast-security-lite' ), (int) ( $state['file_count'] ?? 0 ) ) ), ); } private static function baseline_state(): array { $state = get_option( self::BASELINE_STATE_OPTION, array() ); if ( ! is_array( $state ) || empty( $state['job_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/', (string) $state['job_id'] ) ) { return array(); } if ( (int) ( $state['updated_at'] ?? 0 ) < time() - self::BASELINE_STATE_TTL ) { self::clear_baseline_build_state( true ); return array(); } return $state; } private static function persist_baseline_state( array $state ): bool { $state['updated_at'] = time(); return NLSSL_Core::write_option_verified( self::BASELINE_STATE_OPTION, $state ); } public static function baseline_resume_pending(): bool { $state = self::baseline_state(); return ! empty( $state ) && in_array( (string) ( $state['phase'] ?? '' ), array( 'collect', 'verify', 'commit' ), true ); } public static function baseline_build_status(): array { $state = self::baseline_state(); if ( empty( $state ) ) { return array(); } return array( 'phase' => (string) ( $state['phase'] ?? 'collect' ), 'files' => (int) ( $state['file_count'] ?? 0 ), 'verified' => (int) ( $state['verify_count'] ?? 0 ), 'commit_reason' => sanitize_key( (string) ( $state['commit_reason'] ?? '' ) ), 'manual_resume_only' => ! empty( $state['manual_resume_only'] ), 'started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ), 'max_tracked_files' => self::MAX_FILES, 'max_entries' => self::MAX_ENTRIES, ); } public static function resume_scheduled_baseline(): void { $state = self::baseline_state(); if ( empty( $state ) || ! self::baseline_resume_pending() || ! empty( $state['manual_resume_only'] ) ) { return; } $result = self::build_baseline(); if ( ! empty( $result['resume_pending'] ) && empty( $result['manual_resume_only'] ) ) { self::schedule_baseline_resume(); } } public static function ensure_baseline_resume_schedule(): void { $state = self::baseline_state(); if ( empty( $state ) || ! self::baseline_resume_pending() || ! empty( $state['manual_resume_only'] ) ) { return; } self::schedule_baseline_resume(); } private static function schedule_baseline_resume(): void { if ( ! wp_next_scheduled( self::BASELINE_RESUME_HOOK ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::BASELINE_RESUME_HOOK ); } } private static function abort_baseline_build( array $state, string $reason ): void { global $wpdb; $job_id = (string) ( $state['job_id'] ?? '' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Removes an abandoned build's rows from the plugin's own staging table. if ( preg_match( '/^[a-f0-9]{64}$/', $job_id ) ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE job_id = %s', NLSSL_Core::table( 'file_baseline_staging' ), $job_id ) ); } delete_option( self::BASELINE_STATE_OPTION ); wp_clear_scheduled_hook( self::BASELINE_RESUME_HOOK ); } public static function clear_baseline_build_state( bool $delete_staging = false ): void { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Removes build rows from the plugin's own staging table. global $wpdb; $state = get_option( self::BASELINE_STATE_OPTION, array() ); $job_id = is_array( $state ) ? (string) ( $state['job_id'] ?? '' ) : ''; delete_option( self::BASELINE_STATE_OPTION ); wp_clear_scheduled_hook( self::BASELINE_RESUME_HOOK ); if ( $delete_staging ) { $table = NLSSL_Core::table( 'file_baseline_staging' ); if ( preg_match( '/^[a-f0-9]{64}$/', $job_id ) ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE job_id = %s', $table, $job_id ) ); } else { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $table ) ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } public static function scan(): array { if ( ! NLSSL_Core::acquire_lock( 'nlssl_integrity_action_lock', 30 ) ) { return array( 'ok' => false, 'message' => __( 'Integrity is busy or storage is unavailable. Retry later.', 'neverlast-security-lite' ) ); } try { return self::scan_locked(); } finally { NLSSL_Core::release_lock( 'nlssl_integrity_action_lock' ); } } private static function scan_locked(): array { $count = self::baseline_count(); if ( $count < 0 ) { return self::storage_failure( 'baseline_count_read' ); } if ( 0 === $count ) { return array( 'ok' => false, 'message' => __( 'Create and review a baseline before running integrity comparison.', 'neverlast-security-lite' ) ); } if ( self::baseline_resume_pending() ) { return array( 'ok' => false, 'reason' => 'baseline_build_pending', 'message' => __( 'Finish or abort the in-progress baseline build before starting a local comparison. The staged baseline and current-scan staging area intentionally share one bounded workspace.', 'neverlast-security-lite' ), ); } $state = self::scan_state( $count ); if ( empty( $state ) ) { $state = self::start_scan_state( $count ); if ( empty( $state ) ) { return self::storage_failure( 'integrity_scan_state_start' ); } } $phase = (string) ( $state['phase'] ?? 'collect' ); if ( 'collect' === $phase ) { $step = self::scan_traversal_chunk( $state ); if ( ! empty( $step['fatal'] ) ) { $reason = sanitize_key( (string) ( $step['reason'] ?? 'integrity_inventory_failed' ) ); self::clear_scan_state( true ); $result = array( 'ok' => false, 'generated_at' => gmdate( 'c' ), 'files_checked' => (int) ( $state['file_count'] ?? 0 ), 'truncated' => true, 'resume_pending' => false, 'reason' => $reason, 'message' => sprintf( /* translators: %s: internal reason code */ __( 'The code inventory stopped at a hard safety condition (%s). No clean result was recorded.', 'neverlast-security-lite' ), $reason ), ); if ( ! NLSSL_Core::write_option_verified( self::RESULT_OPTION, $result ) ) { return self::storage_failure( 'integrity_incomplete_result_write' ); } NLSSL_Core::log( 'integrity_scan_incomplete', 'warning', 'The code inventory stopped at a hard safety condition.', array( 'reason' => $reason, 'files' => (int) ( $state['file_count'] ?? 0 ) ) ); return $result; } if ( ! empty( $step['pending'] ) ) { if ( ! self::persist_scan_state( $state ) ) { self::clear_scan_state( true ); return self::storage_failure( 'integrity_scan_state_write' ); } self::schedule_upload_resume(); $result = self::scan_progress_result( $state ); if ( ! NLSSL_Core::write_option_verified( self::RESULT_OPTION, $result ) ) { return self::storage_failure( 'integrity_progress_result_write' ); } NLSSL_Core::log( 'integrity_scan_progress', 'info', 'The code inventory saved progress and will resume.', array( 'files' => (int) ( $state['file_count'] ?? 0 ), 'entries' => (int) ( $state['entry_count'] ?? 0 ) ) ); return $result; } global $wpdb; $staging = NLSSL_Core::table( 'file_baseline_staging' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counts the current comparison's rows in the plugin's own staging table; the count must be current. $stage_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE job_id = %s', $staging, (string) $state['job_id'] ) ); if ( null === $stage_count || ! empty( $wpdb->last_error ) || (int) $stage_count !== (int) ( $state['file_count'] ?? 0 ) ) { self::clear_scan_state( true ); return self::storage_failure( 'integrity_scan_staging_count' ); } $comparison = self::compare_scan_staging( $state ); if ( ! empty( $comparison['storage_error'] ) ) { self::clear_scan_state( true ); return $comparison; } if ( ! empty( $comparison['fatal'] ) ) { $reason = sanitize_key( (string) ( $comparison['reason'] ?? 'integrity_compare_failed' ) ); self::clear_scan_state( true ); return array( 'ok' => false, 'reason' => $reason, 'truncated' => true, 'message' => __( 'The staged code inventory could not be compared safely with the approved baseline. No clean result was recorded.', 'neverlast-security-lite' ) ); } $state['findings'] = (array) ( $comparison['findings'] ?? array() ); $state['finding_limit_reached'] = ! empty( $comparison['finding_limit_reached'] ); $state['phase'] = 'uploads'; $state['updated_at'] = time(); if ( ! self::persist_scan_state( $state ) ) { self::clear_scan_state( true ); return self::storage_failure( 'integrity_scan_state_write' ); } self::schedule_upload_resume(); $result = self::scan_progress_result( $state ); $result['reason'] = 'uploads_pending'; $result['message'] = __( 'The resumable code inventory completed and was compared with the approved baseline. Upload inspection will continue in bounded chunks before a final clean verdict is recorded.', 'neverlast-security-lite' ); if ( ! NLSSL_Core::write_option_verified( self::RESULT_OPTION, $result ) ) { return self::storage_failure( 'integrity_progress_result_write' ); } return $result; } if ( 'uploads' !== $phase ) { self::clear_scan_state( true ); return array( 'ok' => false, 'reason' => 'integrity_scan_state_invalid', 'message' => __( 'The saved integrity scan state was invalid and was discarded. Start the comparison again.', 'neverlast-security-lite' ) ); } return self::finish_scan_upload_phase( $state ); } private static function start_scan_state( int $baseline_count ): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resets the plugin's own staging table before a new comparison. global $wpdb; self::clear_upload_scan_state(); $staging = NLSSL_Core::table( 'file_baseline_staging' ); if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $staging ) ) ) { return array(); } try { $job_id = hash( 'sha256', random_bytes( 32 ) . microtime( true ) . '|scan' ); } catch ( Throwable $e ) { return array(); } $baseline_time = (string) get_option( self::BASELINE_TIME_OPTION, '' ); $state = array( 'version' => 1, 'job_id' => $job_id, 'phase' => 'collect', 'root_hash' => hash( 'sha256', trailingslashit( wp_normalize_path( ABSPATH ) ) ), 'baseline_hash' => hash( 'sha256', $baseline_time . '|' . $baseline_count ), 'stack' => array( array( 'dir' => '', 'after' => '' ) ), 'file_count' => 0, 'entry_count' => 0, 'symlink_count' => 0, 'findings' => array(), 'finding_limit_reached' => false, 'started_at' => time(), 'updated_at' => time(), ); if ( ! self::persist_scan_state( $state ) ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $staging ) ); return array(); } return $state; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function scan_state( int $baseline_count ): array { $state = get_option( self::SCAN_STATE_OPTION, array() ); if ( ! is_array( $state ) || 1 !== (int) ( $state['version'] ?? 0 ) || empty( $state['job_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/', (string) $state['job_id'] ) ) { return array(); } if ( (int) ( $state['updated_at'] ?? 0 ) < time() - self::SCAN_STATE_TTL ) { self::clear_scan_state( true ); return array(); } $root_hash = hash( 'sha256', trailingslashit( wp_normalize_path( ABSPATH ) ) ); $baseline_hash = hash( 'sha256', (string) get_option( self::BASELINE_TIME_OPTION, '' ) . '|' . $baseline_count ); if ( ! hash_equals( $root_hash, (string) ( $state['root_hash'] ?? '' ) ) || ! hash_equals( $baseline_hash, (string) ( $state['baseline_hash'] ?? '' ) ) ) { self::clear_scan_state( true ); return array(); } return $state; } private static function persist_scan_state( array $state ): bool { $state['updated_at'] = time(); return NLSSL_Core::write_option_verified( self::SCAN_STATE_OPTION, $state ); } public static function scan_resume_pending(): bool { $count = self::baseline_count(); if ( $count > 0 && ! empty( self::scan_state( $count ) ) ) { return true; } return self::upload_resume_pending(); } public static function scan_build_status(): array { $count = self::baseline_count(); if ( $count <= 0 ) { return array(); } $state = self::scan_state( $count ); if ( empty( $state ) ) { return array(); } return array( 'phase' => (string) ( $state['phase'] ?? 'collect' ), 'files' => (int) ( $state['file_count'] ?? 0 ), 'entries' => (int) ( $state['entry_count'] ?? 0 ), 'started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ), 'max_tracked_files' => self::MAX_FILES, 'max_entries' => self::MAX_ENTRIES, ); } private static function clear_scan_state( bool $delete_staging = false ): void { global $wpdb; $state = get_option( self::SCAN_STATE_OPTION, array() ); $job_id = is_array( $state ) ? (string) ( $state['job_id'] ?? '' ) : ''; delete_option( self::SCAN_STATE_OPTION ); if ( $delete_staging && preg_match( '/^[a-f0-9]{64}$/', $job_id ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Removes a finished comparison's rows from the plugin's own staging table. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE job_id = %s', NLSSL_Core::table( 'file_baseline_staging' ), $job_id ) ); } } private static function scan_traversal_chunk( array &$state ): array { $root = trailingslashit( wp_normalize_path( ABSPATH ) ); if ( NLSSL_Firewall::is_link_path( untrailingslashit( $root ) ) ) { return array( 'fatal' => true, 'reason' => 'inventory_root_symlink' ); } $deadline = microtime( true ) + self::SCAN_CHUNK_SECONDS; $stage_rows = array(); $listings = array(); $findings = array_values( array_filter( (array) ( $state['findings'] ?? array() ), 'is_array' ) ); $reason = ''; while ( ! empty( $state['stack'] ) ) { if ( microtime( true ) >= $deadline || self::memory_pressure() ) { break; } $entry = self::next_traversal_entry( $state, $root, $listings ); if ( is_wp_error( $entry ) ) { $reason = 'unreadable_directory'; break; } if ( null === $entry ) { continue; } $name = $entry['name']; $absolute_dir = $entry['absolute_dir']; $frame_index = $entry['frame_index']; $state['entry_count'] = (int) ( $state['entry_count'] ?? 0 ) + 1; if ( $state['entry_count'] > self::MAX_ENTRIES ) { $reason = 'scan_entry_safety_ceiling'; break; } $path = wp_normalize_path( trailingslashit( $absolute_dir ) . $name ); $relative = ltrim( substr( $path, strlen( $root ) ), '/' ); if ( '' === $relative ) { continue; } if ( NLSSL_Firewall::is_link_path( $path ) ) { $target = @readlink( $path ); if ( false === $target ) { $reason = 'unreadable_symlink'; break; } $meta = array( 'hash' => hash( 'sha256', 'symlink|' . $target ), 'size' => 0, 'mtime' => 0, 'link' => true ); $stage_rows[ $relative ] = $meta; $state['file_count'] = (int) ( $state['file_count'] ?? 0 ) + 1; $state['symlink_count'] = (int) ( $state['symlink_count'] ?? 0 ) + 1; self::add_change( $findings, $relative, 'symlink_review', 'high', __( 'A symbolic link or junction exists. Its target was not followed; review whether the web server can execute or expose that target.', 'neverlast-security-lite' ), $meta, array( 'classification' => 'filesystem_link_review', 'confidence' => 'high' ) ); } elseif ( is_dir( $path ) ) { $normalized = strtolower( str_replace( '\\', '/', $relative ) ); if ( ! preg_match( '#(?:^|/)(?:\\.git|\\.svn|node_modules)(?:/|$)#i', $relative ) && ! self::is_uploads_tree( $normalized ) && ! self::is_legacy_quarantine_tree( $normalized ) ) { $state['stack'][] = array( 'dir' => $relative, 'after' => '' ); } continue; } else { if ( self::excluded( $relative ) || ! is_file( $path ) || ! self::tracked( $relative ) ) { continue; } if ( ! is_readable( $path ) ) { $reason = 'unreadable_file'; break; } $hash = self::hash_path( $path, $deadline ); if ( null === $hash ) { $state['stack'][ $frame_index ]['after'] = $entry['previous']; if ( microtime( true ) >= $deadline || self::memory_pressure() ) { break; } $reason = 'hash_failure_or_changed_file'; break; } clearstatcache( true, $path ); $size = @filesize( $path ); $mtime = @filemtime( $path ); if ( false === $size || false === $mtime || NLSSL_Firewall::is_link_path( $path ) ) { $reason = 'hash_failure_or_changed_file'; break; } $meta = array( 'hash' => $hash, 'size' => (int) $size, 'mtime' => (int) $mtime ); $stage_rows[ $relative ] = $meta; $state['file_count'] = (int) ( $state['file_count'] ?? 0 ) + 1; if ( self::excluded_tree( $relative ) ) { self::inspect_excluded_security_file( $findings, $relative, $meta ); } } if ( (int) ( $state['file_count'] ?? 0 ) > self::MAX_FILES ) { $reason = 'scan_file_safety_ceiling'; break; } if ( count( $findings ) >= self::MAX_FINDINGS ) { $state['finding_limit_reached'] = true; } if ( count( $stage_rows ) >= 100 ) { if ( ! self::baseline_stage_rows( (string) $state['job_id'], $stage_rows ) ) { $reason = 'integrity_scan_staging_write'; break; } $stage_rows = array(); } } if ( ! empty( $stage_rows ) && ! self::baseline_stage_rows( (string) $state['job_id'], $stage_rows ) ) { $reason = 'integrity_scan_staging_write'; } $state['findings'] = array_slice( $findings, 0, self::MAX_FINDINGS ); $state['updated_at'] = time(); if ( '' !== $reason ) { return array( 'fatal' => true, 'reason' => $reason ); } return array( 'fatal' => false, 'pending' => ! empty( $state['stack'] ) ); } private static function compare_scan_staging( array $state ): array { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Compares the staged file inventory with the approved baseline, both plugin-owned tables, in bounded ordered reads; results must reflect the tables as they are now. global $wpdb; $staging = NLSSL_Core::table( 'file_baseline_staging' ); $baseline_table = NLSSL_Core::table( 'file_baseline' ); $job_id = (string) ( $state['job_id'] ?? '' ); $changes = array_values( array_filter( (array) ( $state['findings'] ?? array() ), 'is_array' ) ); $package_groups = array(); $baseline_components = get_option( self::BASELINE_COMPONENTS_OPTION, array() ); if ( ! is_array( $baseline_components ) ) { $baseline_components = array(); } $current_components = self::component_snapshot(); $remaining = max( 0, self::MAX_FINDINGS - count( $changes ) ); $limit = max( 1, $remaining + 1 ); $current_rows = $wpdb->get_results( $wpdb->prepare( 'SELECT s.file_path,s.file_hash,s.file_size,s.modified_at,b.file_hash AS baseline_hash FROM %i s LEFT JOIN %i b ON b.path_hash=s.path_hash AND b.file_path=s.file_path WHERE s.job_id=%s AND (b.path_hash IS NULL OR b.file_hash<>s.file_hash) ORDER BY s.file_path ASC LIMIT %d', $staging, $baseline_table, $job_id, $limit ), ARRAY_A ); if ( ! is_array( $current_rows ) || ! empty( $wpdb->last_error ) ) { return self::storage_failure( 'integrity_scan_diff_read' ); } $finding_limit = count( $current_rows ) > $remaining; foreach ( array_slice( $current_rows, 0, $remaining ) as $row ) { $path = (string) $row['file_path']; $file = array( 'hash' => (string) $row['file_hash'], 'size' => (int) $row['file_size'], 'mtime' => (int) $row['modified_at'] ); $absolute = trailingslashit( wp_normalize_path( ABSPATH ) ) . $path; if ( NLSSL_Firewall::is_link_path( $absolute ) ) { self::add_change( $changes, $path, 'symlink_review', 'high', __( 'A symbolic link or junction exists. Its target was not followed; review whether the web server can execute or expose that target.', 'neverlast-security-lite' ), $file, array( 'classification' => 'filesystem_link_review', 'confidence' => 'high' ) ); continue; } $type = null === $row['baseline_hash'] ? 'added' : 'modified'; $existing_idx = self::finding_index_for_path( $changes, $path ); $existing_class = $existing_idx >= 0 ? (string) ( $changes[ $existing_idx ]['classification'] ?? '' ) : ''; if ( self::excluded_tree( $path ) && in_array( $existing_class, array( 'defensive_guard_file', 'defensive_server_control' ), true ) ) { self::add_change( $changes, $path, $type, 'low', 'added' === $type ? __( 'The defensive runtime-tree file is not present in the approved local baseline.', 'neverlast-security-lite' ) : __( 'The defensive runtime-tree file differs from the approved local baseline.', 'neverlast-security-lite' ), $file, array( 'classification' => $existing_class, 'confidence' => 'high' ) ); } else { self::record_baseline_change( $changes, $package_groups, $path, $type, 'added' === $type ? __( 'File is not present in the approved local baseline.', 'neverlast-security-lite' ) : __( 'SHA-256 differs from the approved local baseline.', 'neverlast-security-lite' ), $file, $baseline_components, $current_components ); } if ( count( $changes ) >= self::MAX_FINDINGS ) { $finding_limit = true; break; } } $remaining = max( 0, self::MAX_FINDINGS - count( $changes ) ); if ( $remaining > 0 ) { $deleted_rows = $wpdb->get_results( $wpdb->prepare( 'SELECT b.file_path,b.file_hash,b.file_size,b.modified_at FROM %i b LEFT JOIN %i s ON s.job_id=%s AND s.path_hash=b.path_hash AND s.file_path=b.file_path WHERE s.path_hash IS NULL ORDER BY b.file_path ASC LIMIT %d', $baseline_table, $staging, $job_id, $remaining + 1 ), ARRAY_A ); if ( ! is_array( $deleted_rows ) || ! empty( $wpdb->last_error ) ) { return self::storage_failure( 'integrity_scan_deleted_read' ); } if ( count( $deleted_rows ) > $remaining ) { $finding_limit = true; } foreach ( array_slice( $deleted_rows, 0, $remaining ) as $row ) { $path = (string) $row['file_path']; $component_now = self::component_for_path( $path, $current_components ); $component_then = self::component_for_path( $path, $baseline_components ); $version_changed = '' !== (string) $component_then['version'] && (string) $component_then['version'] !== (string) $component_now['version']; if ( $version_changed && in_array( (string) $component_now['kind'], array( 'core', 'plugin', 'theme' ), true ) ) { $prov = array( 'kind' => $component_now['kind'], 'slug' => $component_now['slug'], 'component' => $component_now['label'], 'version' => $component_now['version'], 'baseline_version' => $component_then['version'], 'version_changed' => true, 'source' => 'component_versions', 'status' => 'version_changed_unverified' ); if ( 'theme' !== (string) $component_now['kind'] && ! self::official_checksums_enabled() ) { $prov['lookup'] = 'disabled'; } if ( 'core' === (string) $component_now['kind'] ) { $checks = self::core_checksums( (string) $component_now['version'], (string) ( $current_components['core']['locale'] ?? get_locale() ) ); if ( is_array( $checks ) && isset( $checks[ $path ] ) ) { self::add_change( $changes, $path, 'core_missing_official_file', 'critical', __( 'A file required by the installed official WordPress core package is missing.', 'neverlast-security-lite' ), array(), array( 'classification' => 'package_integrity_failure', 'confidence' => 'high', 'provenance' => array_merge( $prov, array( 'source' => 'wordpress.org-core-checksums', 'status' => 'official_missing' ) ) ) ); continue; } if ( is_array( $checks ) && ! isset( $checks[ $path ] ) ) { $prov['source'] = 'wordpress.org-core-checksums'; $prov['status'] = 'official_match'; } } self::accumulate_package_group( $package_groups, $prov, $path, 'deleted' ); } else { self::add_change( $changes, $path, 'deleted', self::path_severity( $path ), __( 'File from the approved local baseline is missing.', 'neverlast-security-lite' ), array(), array( 'classification' => 'baseline_difference', 'confidence' => 'high' ) ); } if ( count( $changes ) >= self::MAX_FINDINGS ) { $finding_limit = true; break; } } } self::add_package_groups( $changes, $package_groups ); return array( 'findings' => array_slice( $changes, 0, self::MAX_FINDINGS ), 'finding_limit_reached' => $finding_limit || count( $changes ) >= self::MAX_FINDINGS ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } private static function scan_progress_result( array $state ): array { $phase = (string) ( $state['phase'] ?? 'collect' ); $findings = array_values( array_filter( (array) ( $state['findings'] ?? array() ), 'is_array' ) ); $trusted_count = 0; $findings = array_values( array_filter( $findings, static function ( array $finding ) use ( &$trusted_count ): bool { if ( self::is_trusted_finding( $finding ) ) { $trusted_count++; return false; } return true; } ) ); return array( 'ok' => false, 'generated_at' => gmdate( 'c' ), 'files_checked' => (int) ( $state['file_count'] ?? 0 ), 'baseline_created_at' => (string) get_option( self::BASELINE_TIME_OPTION, '' ), 'baseline_stale' => (bool) get_option( self::BASELINE_STALE_OPTION, 0 ), 'critical_count' => count( array_filter( $findings, static fn( array $finding ): bool => 'critical' === (string) ( $finding['severity'] ?? '' ) ) ), 'high_count' => count( array_filter( $findings, static fn( array $finding ): bool => 'high' === (string) ( $finding['severity'] ?? '' ) ) ), 'finding_count' => count( $findings ), 'blocking_count' => count( array_filter( $findings, static fn( array $finding ): bool => ! self::finding_is_nonblocking_information( $finding ) ) ), 'informational_count' => count( array_filter( $findings, static fn( array $finding ): bool => self::finding_is_nonblocking_information( $finding ) ) ), 'finding_limit_reached' => ! empty( $state['finding_limit_reached'] ), 'uploads_files_checked' => 0, 'uploads_truncated' => false, 'resume_pending' => true, 'scan_phase' => $phase, 'scan_started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ), 'truncated' => true, 'reason' => 'collect' === $phase ? 'code_resume_pending' : 'uploads_pending', 'trusted_count' => $trusted_count, 'changes' => $findings, 'message' => 'collect' === $phase ? sprintf( /* translators: %d: number of tracked files staged so far */ __( 'The code inventory is continuing in bounded chunks. %d tracked file(s) have been staged so far; no clean verdict is recorded until code and uploads inspection both complete.', 'neverlast-security-lite' ), (int) ( $state['file_count'] ?? 0 ) ) : __( 'The code comparison completed. Upload inspection remains in progress; no clean verdict is recorded until every upload chunk completes.', 'neverlast-security-lite' ), ); } private static function finish_scan_upload_phase( array $state ): array { $changes = array_values( array_filter( (array) ( $state['findings'] ?? array() ), 'is_array' ) ); $uploads = self::scan_uploads(); foreach ( $uploads['findings'] as $finding ) { if ( count( $changes ) >= self::MAX_FINDINGS && self::finding_index_for_path( $changes, (string) ( $finding['path'] ?? '' ) ) < 0 ) { break; } self::merge_finding( $changes, $finding ); } $finding_limit = ! empty( $state['finding_limit_reached'] ) || count( $changes ) >= self::MAX_FINDINGS; $trusted_count = 0; $changes = array_values( array_filter( $changes, static function ( array $finding ) use ( &$trusted_count ): bool { if ( self::is_trusted_finding( $finding ) ) { $trusted_count++; return false; } return true; } ) ); $critical = count( array_filter( $changes, static fn( array $finding ): bool => 'critical' === (string) ( $finding['severity'] ?? '' ) ) ); $high = count( array_filter( $changes, static fn( array $finding ): bool => 'high' === (string) ( $finding['severity'] ?? '' ) ) ); $blocking_count = count( array_filter( $changes, static fn( array $finding ): bool => ! self::finding_is_nonblocking_information( $finding ) ) ); $informational_count = max( 0, count( $changes ) - $blocking_count ); $result = array( 'ok' => empty( $uploads['truncated'] ) && ! $finding_limit && 0 === $blocking_count, 'generated_at' => gmdate( 'c' ), 'files_checked' => (int) ( $state['file_count'] ?? 0 ), 'baseline_created_at' => (string) get_option( self::BASELINE_TIME_OPTION, '' ), 'baseline_stale' => (bool) get_option( self::BASELINE_STALE_OPTION, 0 ), 'critical_count' => $critical, 'high_count' => $high, 'finding_count' => count( $changes ), 'blocking_count' => $blocking_count, 'informational_count' => $informational_count, 'finding_limit_reached' => $finding_limit, 'uploads_files_checked' => (int) ( $uploads['files_checked'] ?? 0 ), 'uploads_truncated' => ! empty( $uploads['truncated'] ), 'resume_pending' => ! empty( $uploads['resume_pending'] ), 'scan_phase' => ! empty( $uploads['resume_pending'] ) ? 'uploads' : 'complete', 'scan_started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ), 'truncated' => ! empty( $uploads['truncated'] ) || $finding_limit, 'reason' => (string) ( $uploads['reason'] ?? '' ), 'trusted_count' => $trusted_count, 'changes' => $changes, 'message' => ! empty( $uploads['resume_pending'] ) ? __( 'The code comparison is complete and the uploads inspection saved a verified resume cursor. NeverLast Security will continue automatically; no clean verdict is recorded until uploads inspection finishes.', 'neverlast-security-lite' ) : ( ! empty( $uploads['truncated'] ) ? sprintf( /* translators: %s: internal reason code */ __( 'The uploads inspection did not complete (%s). Review collected findings; no clean result was recorded.', 'neverlast-security-lite' ), sanitize_key( (string) ( $uploads['reason'] ?? '' ) ) ) : ( $finding_limit ? __( 'The comparison reached the finding safety ceiling. Review the recorded findings; no clean verdict was recorded.', 'neverlast-security-lite' ) : ( 0 === $blocking_count ? ( 0 === $informational_count ? __( 'No differences were found against the approved local baseline.', 'neverlast-security-lite' ) : sprintf( /* translators: %d: number of informational observations */ _n( 'No blocking differences were found. %d defensive informational observation was reviewed.', 'No blocking differences were found. %d defensive informational observations were reviewed.', $informational_count, 'neverlast-security-lite' ), $informational_count ) ) : __( 'Differences require administrator review. Quarantine is offered only for narrowly eligible local files; no file is changed automatically.', 'neverlast-security-lite' ) ) ) ), ); if ( ! NLSSL_Core::write_option_verified( self::RESULT_OPTION, $result ) ) { return self::storage_failure( 'integrity_result_write' ); } if ( ! empty( $uploads['resume_pending'] ) ) { if ( ! self::persist_scan_state( $state ) ) { return self::storage_failure( 'integrity_scan_state_write' ); } self::schedule_upload_resume(); NLSSL_Core::log( 'integrity_scan_progress', 'info', 'The uploads inspection saved progress and will resume.', array( 'files' => (int) ( $state['file_count'] ?? 0 ), 'uploads_files' => (int) ( $uploads['files_checked'] ?? 0 ), 'findings' => count( $changes ) ) ); return $result; } self::clear_scan_state( true ); NLSSL_Core::log( ! empty( $uploads['truncated'] ) || $finding_limit ? 'integrity_scan_incomplete' : 'integrity_scan_completed', $critical > 0 ? 'critical' : ( ! empty( $uploads['truncated'] ) || $finding_limit || $blocking_count > 0 ? 'warning' : 'info' ), ! empty( $uploads['truncated'] ) || $finding_limit ? 'The integrity scan stopped before a clean verdict.' : 'The read-only integrity scan completed.', array( 'files' => (int) ( $state['file_count'] ?? 0 ), 'findings' => count( $changes ), 'blocking_findings' => $blocking_count, 'informational_findings' => $informational_count, 'uploads_reason' => (string) ( $uploads['reason'] ?? '' ) ) ); return $result; } private static function finding_is_nonblocking_information( array $finding ): bool { $classification = (string) ( $finding['classification'] ?? '' ); return in_array( $classification, array( 'defensive_guard_file', 'defensive_server_control' ), true ) && 'low' === (string) ( $finding['severity'] ?? '' ) && 'high' === (string) ( $finding['confidence'] ?? '' ); } private static function hash_path( string $path, float $deadline ): ?string { if ( NLSSL_Firewall::is_link_path( $path ) || microtime( true ) >= $deadline ) { return null; } $stream = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded chunked streaming with exclusive create and symlink checks; WP_Filesystem cannot provide these. if ( false === $stream ) { return null; } $context = hash_init( 'sha256' ); $before = fstat( $stream ); try { while ( ! feof( $stream ) ) { if ( microtime( true ) >= $deadline || self::memory_pressure() ) { return null; } $chunk = fread( $stream, 65536 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Bounded chunked streaming with exclusive create and symlink checks; WP_Filesystem cannot provide these. if ( false === $chunk || ( '' === $chunk && ! feof( $stream ) ) ) { return null; } hash_update( $context, $chunk ); } $after = fstat( $stream ); clearstatcache( true, $path ); $current = @lstat( $path ); if ( ! is_array( $before ) || ! is_array( $after ) || ! is_array( $current ) || NLSSL_Firewall::is_link_path( $path ) ) { return null; } foreach ( array( 'size', 'mtime', 'ino' ) as $field ) { if ( $before[ $field ] !== $after[ $field ] || $after[ $field ] !== $current[ $field ] ) { return null; } } return hash_final( $context ); } finally { fclose( $stream ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Bounded chunked streaming with exclusive create and symlink checks; WP_Filesystem cannot provide these. } /** Resolve uploads independently of a hardcoded wp-content directory. */ private static function uploads_root(): string { $uploads = wp_get_upload_dir(); if ( ! empty( $uploads['error'] ) ) { return ''; } $root = untrailingslashit( wp_normalize_path( (string) ( $uploads['basedir'] ?? '' ) ) ); return '' !== $root && '/' !== $root ? $root : ''; } private static function is_uploads_tree( string $relative ): bool { $root = self::uploads_root(); $site_root = trailingslashit( wp_normalize_path( ABSPATH ) ); if ( '' === $root || ! str_starts_with( $root . '/', $site_root ) ) { return false; } $prefix = strtolower( ltrim( substr( $root, strlen( $site_root ) ), '/' ) ); $relative = strtolower( trim( wp_normalize_path( $relative ), '/' ) ); return '' !== $prefix && ( $relative === $prefix || str_starts_with( $relative, $prefix . '/' ) ); } private static function is_legacy_quarantine_tree( string $relative ): bool { $root = wp_normalize_path( untrailingslashit( WP_CONTENT_DIR ) . '/' . self::LEGACY_QUARANTINE_DIR ); $site_root = trailingslashit( wp_normalize_path( ABSPATH ) ); if ( ! str_starts_with( $root . '/', $site_root ) ) { return false; } $prefix = strtolower( substr( $root, strlen( $site_root ) ) ); $relative = strtolower( trim( wp_normalize_path( $relative ), '/' ) ); return $relative === $prefix || str_starts_with( $relative, $prefix . '/' ); } private static function excluded( string $relative ): bool { if ( self::is_uploads_tree( $relative ) || self::is_legacy_quarantine_tree( $relative ) ) { return true; } return self::excluded_tree( $relative ) && ! self::security_file( $relative ); } private static function security_file( string $relative ): bool { return in_array( strtolower( wp_basename( $relative ) ), array( '.htaccess', '.user.ini', 'web.config' ), true ) || 1 === preg_match( '/\.(?:php\d*|phtml|phar|phps|inc)(?:\.|$)/i', $relative ); } private static function excluded_tree( string $relative ): bool { $relative = '/' . strtolower( ltrim( str_replace( '\\', '/', $relative ), '/' ) ); // Uploads locations can be customized, including outside the installation root. return self::is_uploads_tree( $relative ) || 1 === preg_match( '#^/(?:\.git|\.svn|node_modules)(?:/|$)#', $relative ) || 1 === preg_match( '#^/wp-content/(?:cache|upgrade|backups?|ai1wm-backups|litespeed)(?:/|$)#', $relative ); } private static function tracked( string $relative ): bool { if ( self::security_file( $relative ) ) { return true; } $name = strtolower( wp_basename( $relative ) ); if ( in_array( $name, array( '.htaccess', '.user.ini', 'web.config', 'wp-config.php', 'index.php' ), true ) ) { return true; } $normalized = '/' . strtolower( str_replace( '\\', '/', $relative ) ); if ( str_contains( $normalized, '/vendor/' ) ) { return 1 === preg_match( '/\.(?:php\d*|phtml|phar|inc)$/i', $name ); } return 1 === preg_match( '/\.(?:php\d*|phtml|phar|inc|js|css|html?|json)$/i', $name ); } private static function memory_pressure(): bool { $raw = trim( (string) ini_get( 'memory_limit' ) ); if ( '' === $raw || '-1' === $raw ) { return false; } $unit = strtolower( substr( $raw, -1 ) ); $value = (int) $raw; if ( 'g' === $unit ) { $value *= 1024 * 1024 * 1024; } elseif ( 'm' === $unit ) { $value *= 1024 * 1024; } elseif ( 'k' === $unit ) { $value *= 1024; } return $value > 0 && memory_get_usage( true ) > (int) ( $value * 0.72 ); } private static function add_change( array &$changes, string $path, string $type, string $severity, string $evidence, array $file = array(), array $details = array() ): void { if ( count( $changes ) >= self::MAX_FINDINGS && self::finding_index_for_path( $changes, $path ) < 0 ) { return; } $fingerprint = (string) ( $file['hash'] ?? '' ); $observation = array( 'type' => $type, 'severity' => $severity, 'evidence' => $evidence, ); foreach ( array( 'signal_id', 'offset', 'line', 'snippet', 'coverage', 'confidence', 'classification' ) as $key ) { if ( array_key_exists( $key, $details ) ) { $observation[ $key ] = $details[ $key ]; } } $idx = self::finding_index_for_path( $changes, $path ); if ( $idx < 0 ) { $finding = array( 'path' => $path, 'type' => $type, 'classification' => (string) ( $details['classification'] ?? $type ), 'severity' => $severity, 'confidence' => (string) ( $details['confidence'] ?? self::default_confidence( $type ) ), 'evidence' => $evidence, 'size' => (int) ( $file['size'] ?? 0 ), 'modified_at' => ! empty( $file['mtime'] ) ? gmdate( 'c', (int) $file['mtime'] ) : '', 'file_hash' => $fingerprint, 'observations' => array( $observation ), ); foreach ( array( 'signal_id', 'offset', 'line', 'snippet', 'coverage', 'provenance', 'package_files', 'component' ) as $key ) { if ( array_key_exists( $key, $details ) ) { $finding[ $key ] = $details[ $key ]; } } $finding['id'] = hash( 'sha256', $path . '|' . $fingerprint ); $finding['quarantine_allowed'] = self::finding_quarantine_allowed( $finding ); $finding['available_actions'] = self::available_actions( $finding ); $changes[] = $finding; return; } $existing = &$changes[ $idx ]; $duplicate = false; foreach ( (array) ( $existing['observations'] ?? array() ) as $prior ) { if ( (string) ( $prior['type'] ?? '' ) === $type && (string) ( $prior['signal_id'] ?? '' ) === (string) ( $observation['signal_id'] ?? '' ) && (string) ( $prior['evidence'] ?? '' ) === $evidence ) { $duplicate = true; break; } } if ( ! $duplicate ) { $existing['observations'][] = $observation; } if ( '' === (string) ( $existing['file_hash'] ?? '' ) && '' !== $fingerprint ) { $existing['file_hash'] = $fingerprint; $existing['size'] = (int) ( $file['size'] ?? 0 ); $existing['modified_at'] = ! empty( $file['mtime'] ) ? gmdate( 'c', (int) $file['mtime'] ) : ''; } $replace_primary = self::severity_rank( $severity ) > self::severity_rank( (string) ( $existing['severity'] ?? 'low' ) ) || ( self::severity_rank( $severity ) === self::severity_rank( (string) ( $existing['severity'] ?? 'low' ) ) && self::type_priority( $type ) > self::type_priority( (string) ( $existing['type'] ?? '' ) ) ); if ( $replace_primary ) { $existing['type'] = $type; $existing['classification'] = (string) ( $details['classification'] ?? $type ); $existing['severity'] = $severity; $existing['confidence'] = (string) ( $details['confidence'] ?? self::default_confidence( $type ) ); $existing['evidence'] = $evidence; foreach ( array( 'signal_id', 'offset', 'line', 'snippet', 'coverage' ) as $key ) { if ( array_key_exists( $key, $details ) ) { $existing[ $key ] = $details[ $key ]; } } } foreach ( array( 'provenance', 'package_files', 'component' ) as $key ) { if ( array_key_exists( $key, $details ) ) { $existing[ $key ] = $details[ $key ]; } } $existing['id'] = hash( 'sha256', $path . '|' . (string) ( $existing['file_hash'] ?? '' ) ); $existing['quarantine_allowed'] = self::finding_quarantine_allowed( $existing ); $existing['available_actions'] = self::available_actions( $existing ); unset( $existing ); } private static function merge_finding( array &$changes, array $finding ): void { $details = array(); foreach ( array( 'signal_id', 'offset', 'line', 'snippet', 'coverage', 'confidence', 'classification', 'provenance', 'package_files', 'component' ) as $key ) { if ( array_key_exists( $key, $finding ) ) { $details[ $key ] = $finding[ $key ]; } } $file = array( 'hash' => (string) ( $finding['file_hash'] ?? '' ), 'size' => (int) ( $finding['size'] ?? 0 ), 'mtime' => ! empty( $finding['modified_at'] ) ? (int) strtotime( (string) $finding['modified_at'] ) : 0 ); self::add_change( $changes, (string) ( $finding['path'] ?? '' ), (string) ( $finding['type'] ?? 'review' ), (string) ( $finding['severity'] ?? 'medium' ), (string) ( $finding['evidence'] ?? '' ), $file, $details ); } private static function available_actions( array $finding ): array { $actions = array(); $type = (string) ( $finding['type'] ?? '' ); $trustable = self::trustable_types(); $trustable_observation = in_array( $type, $trustable, true ); if ( ! $trustable_observation ) { foreach ( (array) ( $finding['observations'] ?? array() ) as $observation ) { if ( is_array( $observation ) && in_array( (string) ( $observation['type'] ?? '' ), $trustable, true ) ) { $trustable_observation = true; break; } } } if ( '' !== (string) ( $finding['file_hash'] ?? '' ) && $trustable_observation ) { $actions[] = 'trust_exact_hash'; } if ( self::finding_quarantine_allowed( $finding ) ) { $actions[] = 'quarantine'; } return $actions; } private static function finding_index_for_path( array $changes, string $path ): int { foreach ( $changes as $i => $finding ) { if ( is_array( $finding ) && (string) ( $finding['path'] ?? '' ) === $path ) { return (int) $i; } } return -1; } private static function severity_rank( string $severity ): int { return array( 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4 )[ $severity ] ?? 1; } private static function type_priority( string $type ): int { if ( in_array( $type, array( 'added', 'modified', 'deleted' ), true ) ) { return 1; } if ( str_contains( $type, 'official_match' ) || str_contains( $type, 'defensive' ) ) { return 2; } if ( str_contains( $type, 'review' ) || str_contains( $type, 'unverified' ) ) { return 3; } return 4; } private static function default_confidence( string $type ): string { if ( in_array( $type, array( 'added', 'modified', 'deleted', 'core_official_match', 'plugin_official_match' ), true ) ) { return 'high'; } if ( str_contains( $type, 'review' ) || str_contains( $type, 'unverified' ) ) { return 'medium'; } return 'high'; } private static function current_file_meta( string $absolute ): array { if ( ! is_file( $absolute ) || ! is_readable( $absolute ) || NLSSL_Firewall::is_link_path( $absolute ) ) { return array(); } $size = @filesize( $absolute ); $mtime = @filemtime( $absolute ); $hash = @hash_file( 'sha256', $absolute ); if ( false === $size || false === $mtime || ! is_string( $hash ) || 64 !== strlen( $hash ) ) { return array(); } return array( 'size' => (int) $size, 'mtime' => (int) $mtime, 'hash' => $hash ); } private static function path_severity( string $path ): string { $path = strtolower( str_replace( '\\', '/', $path ) ); return str_starts_with( $path, 'wp-admin/' ) || str_starts_with( $path, 'wp-includes/' ) || ! str_contains( $path, '/' ) ? 'critical' : 'high'; } private static function component_snapshot(): array { global $wp_version, $wp_local_package; // Core checksum identity follows the installed package locale, not the site's // current UI locale. Language packs can change get_locale() independently. $core_locale = ! empty( $wp_local_package ) ? (string) $wp_local_package : 'en_US'; $snapshot = array( 'core' => array( 'version' => (string) $wp_version, 'locale' => $core_locale ), 'plugins' => array(), 'themes' => array() ); if ( ! function_exists( 'get_plugins' ) && is_file( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( function_exists( 'get_plugins' ) ) { foreach ( (array) get_plugins() as $file => $data ) { $first = str_contains( $file, '/' ) ? strstr( $file, '/', true ) : preg_replace( '/\.php$/i', '', $file ); $slug = sanitize_key( (string) $first ); if ( '' === $slug ) { continue; } $snapshot['plugins'][ $slug ] = array( 'version' => sanitize_text_field( (string) ( $data['Version'] ?? '' ) ), 'file' => sanitize_text_field( (string) $file ) ); } } if ( function_exists( 'wp_get_themes' ) ) { foreach ( wp_get_themes() as $slug => $theme ) { $snapshot['themes'][ sanitize_key( (string) $slug ) ] = array( 'version' => sanitize_text_field( (string) $theme->get( 'Version' ) ) ); } } ksort( $snapshot['plugins'] ); ksort( $snapshot['themes'] ); return $snapshot; } private static function component_for_path( string $path, array $snapshot ): array { $normalized = ltrim( str_replace( '\\', '/', $path ), '/' ); $core_root = ! str_contains( $normalized, '/' ) && ( in_array( strtolower( $normalized ), array( 'index.php', 'xmlrpc.php', 'license.txt', 'readme.html' ), true ) || ( str_starts_with( strtolower( $normalized ), 'wp-' ) && 'wp-config.php' !== strtolower( $normalized ) ) ); if ( str_starts_with( $normalized, 'wp-admin/' ) || str_starts_with( $normalized, 'wp-includes/' ) || $core_root ) { return array( 'kind' => 'core', 'slug' => 'wordpress-core', 'version' => (string) ( $snapshot['core']['version'] ?? '' ), 'label' => 'WordPress core' ); } if ( preg_match( '#^wp-content/plugins/([^/]+)/#i', $normalized, $m ) ) { $slug = sanitize_key( $m[1] ); return array( 'kind' => 'plugin', 'slug' => $slug, 'version' => (string) ( $snapshot['plugins'][ $slug ]['version'] ?? '' ), 'label' => 'Plugin ' . $slug ); } if ( preg_match( '#^wp-content/plugins/([^/]+\.php)$#i', $normalized, $m ) ) { $file = (string) $m[1]; foreach ( (array) ( $snapshot['plugins'] ?? array() ) as $candidate_slug => $meta ) { if ( isset( $meta['file'] ) && 0 === strcasecmp( (string) $meta['file'], $file ) ) { $slug = sanitize_key( (string) $candidate_slug ); return array( 'kind' => 'plugin', 'slug' => $slug, 'version' => (string) ( $meta['version'] ?? '' ), 'label' => 'Plugin ' . $slug, 'main_file' => $file ); } } } if ( preg_match( '#^wp-content/themes/([^/]+)/#i', $normalized, $m ) ) { $slug = sanitize_key( $m[1] ); return array( 'kind' => 'theme', 'slug' => $slug, 'version' => (string) ( $snapshot['themes'][ $slug ]['version'] ?? '' ), 'label' => 'Theme ' . $slug ); } return array( 'kind' => 'site', 'slug' => 'site', 'version' => '', 'label' => 'Site file' ); } private static function provenance_for_file( string $path, array $file, array $baseline_components, array $current_components ): array { $component = self::component_for_path( $path, $current_components ); $old_component = self::component_for_path( $path, $baseline_components ); $base = array( 'kind' => $component['kind'], 'slug' => $component['slug'], 'component' => $component['label'], 'version' => $component['version'], 'baseline_version' => $old_component['version'], 'version_changed' => '' !== $old_component['version'] && $old_component['version'] !== $component['version'], 'source' => 'local_baseline', 'status' => 'unverified', ); if ( ( 'core' === $component['kind'] || ( 'plugin' === $component['kind'] && '' !== $component['version'] ) ) && ! self::official_checksums_enabled() ) { // Comparing with WordPress.org is opt-in, so the local baseline is the only evidence here. // A version change is still grouped as update drift; everything else stays an individual finding. $base['lookup'] = 'disabled'; $base['status'] = $base['version_changed'] ? 'version_changed_unverified' : 'lookup_disabled'; return $base; } if ( 'core' === $component['kind'] ) { $checksums = self::core_checksums( $component['version'], (string) ( $current_components['core']['locale'] ?? 'en_US' ) ); if ( false === $checksums ) { $base['source'] = 'wordpress.org-core-checksums'; $base['status'] = $base['version_changed'] ? 'version_changed_unverified' : 'unavailable'; return $base; } $base['source'] = 'wordpress.org-core-checksums'; if ( ! isset( $checksums[ $path ] ) ) { $base['status'] = 'official_unknown_file'; return $base; } $absolute = self::safe_absolute_path( $path ); $md5 = $absolute ? @md5_file( $absolute ) : false; $base['status'] = is_string( $md5 ) && hash_equals( strtolower( (string) $checksums[ $path ] ), strtolower( $md5 ) ) ? 'official_match' : 'official_mismatch'; return $base; } if ( 'plugin' === $component['kind'] && '' !== $component['version'] ) { $checksums = self::plugin_checksums( $component['slug'], $component['version'] ); $base['source'] = 'wordpress.org-plugin-checksums'; if ( false === $checksums ) { $base['status'] = $base['version_changed'] ? 'version_changed_unverified' : 'unavailable'; return $base; } $prefix = 'wp-content/plugins/' . $component['slug'] . '/'; if ( isset( $component['main_file'] ) && '' !== (string) $component['main_file'] ) { $relative = (string) $component['main_file']; } else { $relative = str_starts_with( $path, $prefix ) ? substr( $path, strlen( $prefix ) ) : $path; } $key = isset( $checksums[ $relative ] ) ? $relative : ( isset( $checksums[ $component['slug'] . '/' . $relative ] ) ? $component['slug'] . '/' . $relative : '' ); if ( '' === $key ) { $base['status'] = 'official_unknown_file'; return $base; } $absolute = self::safe_absolute_path( $path ); $sha256 = (string) ( $file['hash'] ?? '' ); if ( '' === $sha256 && $absolute ) { $sha256 = (string) @hash_file( 'sha256', $absolute ); } $md5 = $absolute ? (string) @md5_file( $absolute ) : ''; $base['status'] = self::checksum_entry_matches( (array) $checksums[ $key ], $sha256, $md5 ) ? 'official_match' : 'official_mismatch'; return $base; } // A plugin reaches this point only when it reports no version. Its files stay individual findings rather than // update drift: a plugin whose version header disappeared is not an update. if ( 'theme' === $component['kind'] && $base['version_changed'] ) { $base['status'] = 'version_changed_unverified'; } return $base; } /** * Whether the administrator opted in to comparing changed core and plugin files with WordPress.org checksums. * Both fetchers below check this first, so no request can leave the site while it is off. */ public static function official_checksums_enabled(): bool { return ! empty( NLSSL_Core::setting( 'wporg_checksums_enabled', 0 ) ); } private static function core_checksums( string $version, string $locale ) { if ( ! self::official_checksums_enabled() ) { return false; } static $cache = array(); $key = $version . '|' . $locale; if ( array_key_exists( $key, $cache ) ) { return $cache[ $key ]; } if ( ! preg_match( '/^[0-9][A-Za-z0-9.+-]*$/D', $version ) || ! preg_match( '/^[A-Za-z0-9_-]{2,32}$/D', $locale ) ) { return $cache[ $key ] = false; } $transient = 'nlssl_core_' . substr( hash( 'sha256', $key ), 0, 32 ); $saved = get_transient( $transient ); if ( is_array( $saved ) && isset( $saved['files'] ) && is_array( $saved['files'] ) ) { return $cache[ $key ] = $saved['files']; } // The same endpoint get_core_checksums() uses, requested over HTTPS only and with a user agent that // does not carry the site address. Only the WordPress version and locale are sent. $url = 'https://api.wordpress.org/core/checksums/1.0/?version=' . rawurlencode( $version ) . '&locale=' . rawurlencode( $locale ); try { $response = wp_safe_remote_get( $url, array( 'timeout' => 3, 'redirection' => 0, 'limit_response_size' => 2 * MB_IN_BYTES, 'user-agent' => 'NeverLast-Security-Lite/' . NLSSL_VERSION ) ); } catch ( Throwable $e ) { return $cache[ $key ] = false; } if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return $cache[ $key ] = false; } $data = json_decode( trim( (string) wp_remote_retrieve_body( $response ) ), true ); if ( ! is_array( $data ) || ! isset( $data['checksums'] ) || ! is_array( $data['checksums'] ) ) { return $cache[ $key ] = false; } $files = array(); foreach ( $data['checksums'] as $name => $md5 ) { if ( is_string( $name ) && is_string( $md5 ) && preg_match( '/^[a-f0-9]{32}$/i', $md5 ) ) { $files[ ltrim( str_replace( '\\', '/', $name ), '/' ) ] = strtolower( $md5 ); } } if ( empty( $files ) ) { return $cache[ $key ] = false; } set_transient( $transient, array( 'files' => $files ), 6 * HOUR_IN_SECONDS ); return $cache[ $key ] = $files; } private static function plugin_checksums( string $slug, string $version ) { if ( ! self::official_checksums_enabled() ) { return false; } static $cache = array(); $key = $slug . '|' . $version; if ( array_key_exists( $key, $cache ) ) { return $cache[ $key ]; } if ( ! preg_match( '/^[a-z0-9_-]+$/D', $slug ) || ! preg_match( '/^[A-Za-z0-9._-]+$/D', $version ) ) { return $cache[ $key ] = false; } $transient = 'nlssl_pkg_' . substr( hash( 'sha256', $key ), 0, 32 ); $saved = get_transient( $transient ); if ( is_array( $saved ) && isset( $saved['files'] ) && is_array( $saved['files'] ) ) { return $cache[ $key ] = $saved['files']; } $url = 'https://downloads.wordpress.org/plugin-checksums/' . rawurlencode( $slug ) . '/' . rawurlencode( $version ) . '.json'; try { $response = wp_safe_remote_get( $url, array( 'timeout' => 2.5, 'redirection' => 0, 'limit_response_size' => 2 * MB_IN_BYTES, 'user-agent' => 'NeverLast-Security-Lite/' . NLSSL_VERSION ) ); } catch ( Throwable $e ) { return $cache[ $key ] = false; } if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return $cache[ $key ] = false; } $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) || ! isset( $data['files'] ) || ! is_array( $data['files'] ) ) { return $cache[ $key ] = false; } // WordPress.org plugin checksums are per-file algorithm maps. A single file // may legitimately have multiple accepted hashes for a named release; match // any published SHA-256, with MD5 only as a compatibility fallback. $files = array(); foreach ( $data['files'] as $name => $entry ) { if ( ! is_string( $name ) || ! is_array( $entry ) ) { continue; } $normalized = array(); foreach ( array( 'sha256' => 64, 'md5' => 32 ) as $algo => $length ) { $values = isset( $entry[ $algo ] ) ? (array) $entry[ $algo ] : array(); $valid = array(); foreach ( $values as $hash ) { if ( is_string( $hash ) && preg_match( '/^[a-f0-9]{' . $length . '}$/i', $hash ) ) { $valid[] = strtolower( $hash ); } } if ( $valid ) { $normalized[ $algo ] = array_values( array_unique( $valid ) ); } } if ( $normalized ) { $files[ ltrim( str_replace( '\\', '/', $name ), '/' ) ] = $normalized; } } if ( ! empty( $files ) ) { set_transient( $transient, array( 'files' => $files ), 6 * HOUR_IN_SECONDS ); } return $cache[ $key ] = ! empty( $files ) ? $files : false; } private static function checksum_entry_matches( array $entry, string $sha256, string $md5 ): bool { if ( preg_match( '/^[a-f0-9]{64}$/i', $sha256 ) && ! empty( $entry['sha256'] ) ) { foreach ( (array) $entry['sha256'] as $expected ) { if ( is_string( $expected ) && hash_equals( strtolower( $expected ), strtolower( $sha256 ) ) ) { return true; } } return false; } if ( preg_match( '/^[a-f0-9]{32}$/i', $md5 ) && ! empty( $entry['md5'] ) ) { foreach ( (array) $entry['md5'] as $expected ) { if ( is_string( $expected ) && hash_equals( strtolower( $expected ), strtolower( $md5 ) ) ) { return true; } } } return false; } private static function accumulate_package_group( array &$groups, array $provenance, string $path, string $change_type ): void { $key = (string) ( $provenance['kind'] ?? 'site' ) . ':' . (string) ( $provenance['slug'] ?? 'site' ) . ':' . (string) ( $provenance['status'] ?? 'unverified' ); if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'provenance' => $provenance, 'files' => array(), 'types' => array() ); } if ( count( $groups[ $key ]['files'] ) < 50 ) { $groups[ $key ]['files'][] = $path; } $groups[ $key ]['types'][ $change_type ] = 1 + (int) ( $groups[ $key ]['types'][ $change_type ] ?? 0 ); } private static function add_package_groups( array &$changes, array $groups ): void { foreach ( $groups as $group ) { $prov = (array) $group['provenance']; $count = array_sum( (array) $group['types'] ); $component = (string) ( $prov['component'] ?? $prov['slug'] ?? 'component' ); if ( 'official_match' === (string) ( $prov['status'] ?? '' ) ) { $type = ( 'core' === (string) ( $prov['kind'] ?? '' ) ) ? 'core_official_match' : 'plugin_official_match'; $evidence = sprintf( /* translators: %1$d: number of differences, %2$s: plugin or theme name, %3$s: version */ __( '%1$d baseline difference(s) belong to %2$s %3$s and match the official WordPress.org checksums. They are grouped as verified package drift instead of separate alerts.', 'neverlast-security-lite' ), $count, $component, (string) ( $prov['version'] ?? '' ) ); self::add_change( $changes, 'package://' . (string) $prov['kind'] . '/' . (string) $prov['slug'], $type, 'low', $evidence, array(), array( 'classification' => 'verified_package_drift', 'confidence' => 'high', 'provenance' => $prov, 'package_files' => array_values( (array) $group['files'] ), 'component' => $component ) ); } elseif ( 'version_changed_unverified' === (string) ( $prov['status'] ?? '' ) ) { $evidence = 'disabled' === (string) ( $prov['lookup'] ?? '' ) ? sprintf( /* translators: %1$d: number of differences, %2$s: WordPress core, plugin or theme name, %3$s: previous version, %4$s: new version */ __( '%1$d file difference(s) were grouped because %2$s changed version from %3$s to %4$s. Comparison with WordPress.org checksums is turned off under Protection, so this is update drift evidence, not a clean verdict.', 'neverlast-security-lite' ), $count, $component, (string) ( $prov['baseline_version'] ?? '?' ), (string) ( $prov['version'] ?? '?' ) ) : sprintf( /* translators: %1$d: number of differences, %2$s: WordPress core, plugin or theme name, %3$s: previous version, %4$s: new version */ __( '%1$d file difference(s) were grouped because %2$s changed version from %3$s to %4$s. Official per-file checksums were unavailable, so this is update drift evidence, not a clean verdict.', 'neverlast-security-lite' ), $count, $component, (string) ( $prov['baseline_version'] ?? '?' ), (string) ( $prov['version'] ?? '?' ) ); self::add_change( $changes, 'package://' . (string) $prov['kind'] . '/' . (string) $prov['slug'], 'component_update_unverified', 'medium', $evidence, array(), array( 'classification' => 'baseline_drift', 'confidence' => 'medium', 'provenance' => $prov, 'package_files' => array_values( (array) $group['files'] ), 'component' => $component ) ); } } } private static function record_baseline_change( array &$changes, array &$package_groups, string $path, string $type, string $evidence, array $file, array $baseline_components, array $current_components ): void { $prov = self::provenance_for_file( $path, $file, $baseline_components, $current_components ); $status = (string) ( $prov['status'] ?? 'unverified' ); if ( 'official_match' === $status || 'version_changed_unverified' === $status ) { self::accumulate_package_group( $package_groups, $prov, $path, $type ); if ( 'version_changed_unverified' === $status ) { self::inspect_changed_file( $changes, $path, $file ); self::inspect_control_file( $changes, $path, $file ); } return; } if ( in_array( $status, array( 'official_mismatch', 'official_unknown_file' ), true ) ) { $kind = (string) ( $prov['kind'] ?? '' ); $finding_type = $kind . ( 'official_unknown_file' === $status ? '_unknown_file' : '_modified_file' ); self::add_change( $changes, $path, $finding_type, 'critical', sprintf( /* translators: %s: verification status code */ __( 'The local file does not match the official package provenance (%s).', 'neverlast-security-lite' ), $status ), $file, array( 'classification' => 'package_integrity_failure', 'confidence' => 'high', 'provenance' => $prov ) ); self::inspect_changed_file( $changes, $path, $file ); self::inspect_control_file( $changes, $path, $file ); return; } $severity = self::path_severity( $path ); $classification = 'baseline_difference'; $confidence = 'high'; $name = strtolower( wp_basename( $path ) ); if ( 'added' === $type && in_array( $name, array( '.user.ini', '.htaccess', 'web.config' ), true ) && self::file_predates_baseline( $file ) ) { $absolute = self::safe_absolute_path( $path ); $control = $absolute ? self::analyze_server_control_file( $absolute, $name ) : array(); if ( ! empty( $control ) && 'server_control_dangerous' !== (string) ( $control['type'] ?? '' ) ) { $severity = 'medium'; $classification = 'baseline_coverage_review'; $confidence = 'high'; $evidence = __( 'This control file is absent from the approved baseline, but its filesystem modification time predates that baseline. Treat it as a historical baseline-coverage gap and review its semantics; this does not prove the file was added after approval.', 'neverlast-security-lite' ); } } if ( 'added' === $type && 'index.php' === $name && self::file_predates_baseline( $file ) && preg_match( '#/(?:backup|backups|cache|debug|tmp)/index\.php$#i', '/' . ltrim( str_replace( '\\', '/', $path ), '/' ) ) ) { $absolute = self::safe_absolute_path( $path ); $guard = $absolute ? self::guard_index_details( $absolute ) : array(); if ( ! empty( $guard ) ) { $severity = 'low'; $classification = 'baseline_coverage_review'; $confidence = 'high'; $evidence = __( 'This small runtime guard file predates the approved baseline and contains no executable behavior beyond an optional immediate exit/comment. It is treated as historical coverage drift, not as evidence that new code was added after approval.', 'neverlast-security-lite' ); } } self::add_change( $changes, $path, $type, $severity, $evidence, $file, array( 'provenance' => $prov, 'classification' => $classification, 'confidence' => $confidence ) ); self::inspect_changed_file( $changes, $path, $file ); self::inspect_control_file( $changes, $path, $file ); } private static function file_predates_baseline( array $file ): bool { $baseline = strtotime( (string) get_option( self::BASELINE_TIME_OPTION, '' ) ); $modified = ! empty( $file['mtime'] ) ? (int) $file['mtime'] : strtotime( (string) ( $file['modified_at'] ?? '' ) ); return false !== $baseline && false !== $modified && $modified > 0 && $modified + 60 < $baseline; } private static function inspect_control_file( array &$changes, string $relative, array $file ): void { $name = strtolower( wp_basename( $relative ) ); if ( ! in_array( $name, array( '.htaccess', '.user.ini', 'web.config' ), true ) ) { return; } $absolute = self::safe_absolute_path( $relative ); if ( ! $absolute ) { return; } $analysis = self::analyze_server_control_file( $absolute, $name ); if ( empty( $analysis ) ) { return; } self::add_change( $changes, $relative, (string) $analysis['type'], (string) $analysis['severity'], (string) $analysis['evidence'], $file, (array) $analysis['details'] ); } private static function analyze_server_control_file( string $path, string $name ): array { if ( ! is_file( $path ) || ! is_readable( $path ) || NLSSL_Firewall::is_link_path( $path ) ) { return array(); } $text = @file_get_contents( $path, false, null, 0, 262144 ); if ( false === $text ) { return array(); } $lines = preg_split( '/\R/', $text ) ?: array(); $danger = array(); $defensive = array(); $first_line = 1; $first_signal = 'SERVER_CONTROL_FILE'; foreach ( $lines as $i => $raw ) { $line = trim( (string) $raw ); if ( '' === $line || str_starts_with( $line, '#' ) || str_starts_with( $line, ';' ) ) { continue; } $lower = strtolower( $line ); if ( '.user.ini' === $name ) { if ( preg_match( '/^auto_prepend_file\s*=\s*(.+)$/i', $line, $m ) && '' !== trim( $m[1], " \"'" ) ) { $danger[] = 'auto_prepend_file'; if ( 1 === count( $danger ) ) { $first_line = $i + 1; $first_signal = 'USER_INI_AUTO_PREPEND'; } } elseif ( preg_match( '/^auto_append_file\s*=\s*(.+)$/i', $line, $m ) && '' !== trim( $m[1], " \"'" ) ) { $danger[] = 'auto_append_file'; if ( 1 === count( $danger ) ) { $first_line = $i + 1; $first_signal = 'USER_INI_AUTO_APPEND'; } } elseif ( preg_match( '/^allow_url_include\s*=\s*(?:1|on|true|yes)$/i', $line ) ) { $danger[] = 'allow_url_include'; if ( 1 === count( $danger ) ) { $first_line = $i + 1; $first_signal = 'USER_INI_ALLOW_URL_INCLUDE'; } } elseif ( preg_match( '/^include_path\s*=\s*(.+)$/i', $line, $m ) && preg_match( '#(?:https?|ftp|phar|data)://#i', $m[1] ) ) { $danger[] = 'remote_include_path'; if ( 1 === count( $danger ) ) { $first_line = $i + 1; $first_signal = 'USER_INI_REMOTE_INCLUDE'; } } continue; } if ( '.htaccess' === $name ) { if ( preg_match( '/^\s*(?:php_value|php_admin_value)\s+auto_(?:prepend|append)_file\s+\S+/i', $line ) ) { $danger[] = 'php_auto_file'; if ( 1 === count( $danger ) ) { $first_line = $i + 1; $first_signal = 'PHP_AUTO_PREPEND'; } } elseif ( preg_match( '/^\s*(?:addhandler|addtype|sethandler|forcetype)\b.*(?:php|x-httpd-php|php-script|fastcgi)/i', $line ) && ! preg_match( '/\b(?:none|default-handler)\b/i', $line ) ) { $danger[] = 'php_handler_enable'; if ( 1 === count( $danger ) ) { $first_line = $i + 1; $first_signal = 'APACHE_PHP_HANDLER_ENABLE'; } } elseif ( preg_match( '/^\s*php_flag\s+engine\s+off\b/i', $line ) || preg_match( '/^\s*(?:removehandler|removetype)\b.*\.(?:php|phtml)\b/i', $line ) || preg_match( '/^\s*sethandler\s+(?:none|default-handler)\b/i', $line ) || preg_match( '/^\s*(?:require\s+all\s+denied|deny\s+from\s+all|options\s+-indexes)\b/i', $lower ) ) { $defensive[] = trim( $line ); } continue; } if ( 'web.config' === $name ) { if ( preg_match( '/scriptprocessor\s*=\s*["\'][^"\']*(?:php|php-cgi)/i', $line ) || ( preg_match( '/ $first_signal, 'line' => $first_line, 'snippet' => $snippet, 'coverage' => strlen( $text ) < 262144 ? 'full_text_file' : 'first_256k', 'confidence' => 'high' ); if ( ! empty( $danger ) ) { $details['classification'] = 'dangerous_server_control'; return array( 'type' => 'server_control_dangerous', 'severity' => 'critical', 'evidence' => sprintf( /* translators: %s: list of the dangerous directives that were found */ __( 'Server-control semantics can enable PHP execution or prepend/append executable code: %s.', 'neverlast-security-lite' ), implode( ', ', array_unique( $danger ) ) ), 'details' => $details ); } if ( ! empty( $defensive ) ) { $details['classification'] = 'defensive_server_control'; $details['signal_id'] = 'SERVER_CONTROL_DEFENSIVE'; return array( 'type' => 'server_control_defensive', 'severity' => 'low', 'evidence' => __( 'Server-control semantics are defensive: they disable execution, deny access or disable directory listing. This is configuration evidence, not malware evidence.', 'neverlast-security-lite' ), 'details' => $details ); } if ( '.user.ini' === $name ) { $generated = 1 === preg_match( '/(?:cpanel-generated php ini directives|multiphp ini editor)/i', $text ); $details['classification'] = 'php_runtime_configuration_review'; $details['confidence'] = $generated ? 'high' : 'medium'; $details['signal_id'] = $generated ? 'USER_INI_MANAGED_CONFIGURATION' : 'SERVER_CONTROL_FILE'; return array( 'type' => 'user_ini_review', 'severity' => $generated ? 'low' : 'medium', 'evidence' => $generated ? __( 'The .user.ini identifies itself as hosting-panel managed configuration and no auto_prepend/auto_append/remote include directive was detected. Review remains appropriate because it changes PHP runtime settings, but this is not executable-code evidence.', 'neverlast-security-lite' ) : __( 'A .user.ini file changes per-directory PHP runtime settings. No auto_prepend/auto_append/remote include directive was detected; review the remaining directives explicitly.', 'neverlast-security-lite' ), 'details' => $details, ); } $details['classification'] = 'server_control_review'; $details['confidence'] = 'medium'; return array( 'type' => 'server_control_review', 'severity' => 'medium', 'evidence' => __( 'Server-control configuration is present, but no execution-enabling or clearly defensive directive was identified.', 'neverlast-security-lite' ), 'details' => $details ); } private static function guard_index_details( string $absolute ): array { if ( ! is_file( $absolute ) || ! is_readable( $absolute ) || NLSSL_Firewall::is_link_path( $absolute ) ) { return array(); } $size = @filesize( $absolute ); if ( false === $size || $size > 4096 ) { return array(); } $text = (string) @file_get_contents( $absolute, false, null, 0, 4096 ); $trim = trim( $text ); if ( '' === $trim ) { return array(); } $simple = ! str_contains( $trim, ' 'defensive_guard_file', 'confidence' => 'high', 'signal_id' => 'GUARD_INDEX_FILE', 'snippet' => substr( $trim, 0, 400 ), 'coverage' => 'full_text_file' ); } private static function inspect_excluded_security_file( array &$changes, string $path, array $file ): void { $name = strtolower( wp_basename( $path ) ); if ( in_array( $name, array( '.htaccess', '.user.ini', 'web.config' ), true ) ) { self::inspect_control_file( $changes, $path, $file ); return; } $absolute = self::safe_absolute_path( $path ); if ( ! $absolute ) { return; } if ( 'index.php' === $name && (int) ( $file['size'] ?? 0 ) <= 4096 ) { $guard = self::guard_index_details( $absolute ); if ( ! empty( $guard ) ) { self::add_change( $changes, $path, 'excluded_tree_guard_file', 'low', __( 'A small guard/index file exists in a runtime tree and contains no executable behavior beyond an optional immediate exit. It is treated as defensive/benign evidence.', 'neverlast-security-lite' ), $file, $guard ); return; } } self::add_change( $changes, $path, 'excluded_tree_security_review', 'high', __( 'An executable file exists in a runtime cache/backup/excluded tree. Its executable semantics could not be verified as a simple guard file; review its origin and code.', 'neverlast-security-lite' ), $file, self::file_evidence_preview( $absolute ) ); } private static function inspect_changed_file( array &$changes, string $relative, array $file ): void { if ( count( $changes ) >= self::MAX_FINDINGS || ! preg_match( '/\.(?:php\d*|phtml|phar|inc|js)$/i', $relative ) ) { return; } $path = wp_normalize_path( ABSPATH . $relative ); $size = @filesize( $path ); if ( ! is_readable( $path ) || false === $size || $size > 2 * MB_IN_BYTES || NLSSL_Firewall::is_link_path( $path ) ) { return; } $sample = @file_get_contents( $path, false, null, 0, 262144 ); if ( false === $sample ) { return; } if ( 'index.php' === strtolower( wp_basename( $relative ) ) && preg_match( '#/(?:backup|backups|cache|debug|tmp)/index\.php$#i', '/' . ltrim( str_replace( '\\', '/', $relative ), '/' ) ) ) { $guard = self::guard_index_details( $path ); if ( ! empty( $guard ) ) { self::add_change( $changes, $relative, 'runtime_guard_file', 'low', __( 'A small index.php in a runtime/backup tree contains only a defensive guard/comment or immediate exit and no executable behavior.', 'neverlast-security-lite' ), $file, $guard ); return; } } $patterns = array( 'ENCODED_EVAL' => '/\beval\s*\(\s*(?:base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(/i', 'REQUEST_COMMAND_EXECUTION' => '/\b(?:system|shell_exec|passthru|exec|popen|proc_open)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)/i', 'VARIABLE_FUNCTION_INPUT' => '/\$_(?:GET|POST|REQUEST|COOKIE)\s*\[[^\]]+\]\s*\(/i', ); foreach ( $patterns as $id => $pattern ) { $match = array(); if ( 1 === preg_match( $pattern, $sample, $match, PREG_OFFSET_CAPTURE ) ) { $offset = (int) $match[0][1]; $line = 1 + substr_count( substr( $sample, 0, $offset ), "\n" ); $snippet = substr( $sample, max( 0, $offset - 120 ), 320 ); $snippet = preg_replace_callback( '/[^\x20-\x7E\r\n ]/', static fn( array $m ): string => sprintf( '\\x%02X', ord( $m[0] ) ), $snippet ); self::add_change( $changes, $relative, 'suspicious_code_shape', 'critical', sprintf( /* translators: %s: signal identifier */ __( 'High-confidence review signal: %s. This is evidence, not a malware verdict.', 'neverlast-security-lite' ), $id ), $file, array( 'signal_id' => $id, 'offset' => $offset, 'line' => $line, 'snippet' => $snippet, 'coverage' => 'first_256k' ) ); } } } private static function scan_uploads(): array { $uploads = wp_get_upload_dir(); $root = wp_normalize_path( (string) ( $uploads['basedir'] ?? '' ) ); if ( '' !== $root && NLSSL_Firewall::is_link_path( untrailingslashit( $root ) ) ) { self::clear_upload_scan_state(); return array( 'findings' => array( array( 'path' => 'uploads', 'type' => 'symlink_review', 'severity' => 'high', 'evidence' => __( 'The uploads root is a symbolic link. Its target was not followed.', 'neverlast-security-lite' ), 'size' => 0, 'modified_at' => '', 'file_hash' => '', 'id' => hash( 'sha256', 'uploads|symlink_review' ), 'quarantine_allowed' => false ) ), 'files_checked' => 0, 'truncated' => true, 'reason' => 'uploads_root_symlink', 'resume_pending' => false ); } if ( '' === $root || ! is_dir( $root ) ) { self::clear_upload_scan_state(); return array( 'findings' => array(), 'files_checked' => 0, 'truncated' => false, 'reason' => '', 'resume_pending' => false ); } $root = untrailingslashit( $root ); $root_hash = hash( 'sha256', $root ); $baseline_time = (string) get_option( self::BASELINE_TIME_OPTION, '' ); $state = get_option( self::UPLOAD_STATE_OPTION, array() ); $state_valid = is_array( $state ) && 1 === (int) ( $state['version'] ?? 0 ) && hash_equals( $root_hash, (string) ( $state['root_hash'] ?? '' ) ) && hash_equals( hash( 'sha256', $baseline_time ), (string) ( $state['baseline_hash'] ?? '' ) ) && (int) ( $state['updated_at'] ?? 0 ) >= time() - self::UPLOAD_STATE_TTL && is_array( $state['stack'] ?? null ) && is_array( $state['findings'] ?? null ); if ( ! $state_valid ) { $state = array( 'version' => 1, 'root_hash' => $root_hash, 'baseline_hash' => hash( 'sha256', $baseline_time ), 'started_at' => time(), 'updated_at' => time(), 'files_checked' => 0, 'findings' => array(), 'stack' => array( array( 'dir' => '', 'after' => '' ) ), ); } $findings = array_values( array_filter( (array) $state['findings'], 'is_array' ) ); $seen = max( 0, (int) ( $state['files_checked'] ?? 0 ) ); $stack = array_values( array_filter( (array) $state['stack'], 'is_array' ) ); if ( empty( $stack ) ) { $stack = array( array( 'dir' => '', 'after' => '' ) ); } $deadline = microtime( true ) + self::MAX_UPLOAD_SECONDS; $reason = ''; $resume_pending = false; $cache = array(); $positions = array(); while ( ! empty( $stack ) ) { if ( microtime( true ) >= $deadline ) { $reason = 'uploads_resume_pending'; $resume_pending = true; break; } if ( self::memory_pressure() ) { $reason = 'uploads_memory'; break; } if ( $seen >= self::MAX_UPLOAD_FILES ) { $reason = 'uploads_file_limit'; break; } $frame_index = count( $stack ) - 1; $dir_rel = trim( str_replace( '\\', '/', (string) ( $stack[ $frame_index ]['dir'] ?? '' ) ), '/' ); $after = (string) ( $stack[ $frame_index ]['after'] ?? '' ); $cache_key = $dir_rel; if ( ! array_key_exists( $cache_key, $cache ) ) { $directory = '' === $dir_rel ? $root : wp_normalize_path( $root . '/' . $dir_rel ); if ( NLSSL_Firewall::is_link_path( $directory ) || ! is_dir( $directory ) ) { $reason = 'uploads_directory_changed'; break; } try { $iterator = new FilesystemIterator( $directory, FilesystemIterator::SKIP_DOTS ); } catch ( Throwable $exception ) { $reason = 'uploads_unreadable_directory'; break; } $entries = array(); try { foreach ( $iterator as $item ) { $name = (string) $item->getFilename(); if ( '' === $name || '.' === $name || '..' === $name ) { continue; } $entries[] = array( 'name' => $name, 'path' => wp_normalize_path( $item->getPathname() ), 'is_dir' => $item->isDir() ); } } catch ( Throwable $exception ) { $reason = 'uploads_filesystem_race'; break; } usort( $entries, static fn( array $a, array $b ): int => strcmp( (string) $a['name'], (string) $b['name'] ) ); $cache[ $cache_key ] = $entries; $pos = 0; while ( isset( $entries[ $pos ] ) && strcmp( (string) $entries[ $pos ]['name'], $after ) <= 0 ) { $pos++; } $positions[ $cache_key ] = $pos; } $entries = $cache[ $cache_key ]; $pos = (int) ( $positions[ $cache_key ] ?? 0 ); if ( ! isset( $entries[ $pos ] ) ) { array_pop( $stack ); unset( $cache[ $cache_key ], $positions[ $cache_key ] ); continue; } $entry = $entries[ $pos ]; $positions[ $cache_key ] = $pos + 1; $previous_after = (string) ( $stack[ $frame_index ]['after'] ?? '' ); $stack[ $frame_index ]['after'] = (string) $entry['name']; $path = (string) $entry['path']; $name = strtolower( (string) $entry['name'] ); $child_rel = '' === $dir_rel ? (string) $entry['name'] : $dir_rel . '/' . (string) $entry['name']; $relative = 'uploads/' . str_replace( '\\', '/', $child_rel ); // Quarantine artifacts are not website uploads; exclude our own evidence from this scan. if ( 'neverlast-security-lite' === $child_rel || str_starts_with( $child_rel, 'neverlast-security-lite/' ) ) { continue; } if ( NLSSL_Firewall::is_link_path( $path ) ) { $seen++; self::add_change( $findings, $relative, 'symlink_review', 'high', __( 'A symbolic link or junction exists under uploads. Its target was not followed; review web-server access and execution rules.', 'neverlast-security-lite' ) ); } elseif ( ! empty( $entry['is_dir'] ) ) { $stack[] = array( 'dir' => $child_rel, 'after' => '' ); continue; } elseif ( in_array( $name, array( '.htaccess', '.user.ini', 'web.config' ), true ) ) { $seen++; $analysis = self::analyze_server_control_file( $path, $name ); if ( ! empty( $analysis ) ) { self::add_change( $findings, $relative, (string) $analysis['type'], (string) $analysis['severity'], (string) $analysis['evidence'], self::current_file_meta( $path ), (array) $analysis['details'] ); } } elseif ( preg_match( '/\.(?:php\d*|phtml|phar|phps|inc)(?:\.|$)/i', $name ) ) { $seen++; $details = self::file_evidence_preview( $path, 'SERVER_EXECUTABLE_EXTENSION' ); if ( empty( $details['signal_id'] ) ) { $details['signal_id'] = 'SERVER_EXECUTABLE_EXTENSION'; } self::add_change( $findings, $relative, 'executable_in_uploads', 'critical', __( 'A server-side executable extension exists under uploads.', 'neverlast-security-lite' ), self::current_file_meta( $path ), $details ); } elseif ( preg_match( '/\.(?:jpe?g|png|gif|webp|svg|ico)$/i', $name ) ) { $inspection = NLSSL_Firewall::inspect_media_file( $path, $deadline, str_ends_with( $name, '.svg' ) ); if ( ! $inspection['complete'] && 'time' === sanitize_key( (string) ( $inspection['reason'] ?? '' ) ) ) { $stack[ $frame_index ]['after'] = $previous_after; $reason = 'uploads_resume_pending'; $resume_pending = true; break; } $seen++; if ( $inspection['code'] ) { $signal = (string) ( $inspection['signal_id'] ?? 'EXECUTABLE_CONTENT' ); self::add_change( $findings, $relative, 'code_in_media', 'critical', sprintf( /* translators: %1$s: signal identifier, %2$d: byte offset within the file */ __( 'Executable content was found outside the validated media structure: %1$s at byte offset %2$d.', 'neverlast-security-lite' ), $signal, (int) ( $inspection['offset'] ?? 0 ) ), self::current_file_meta( $path ), $inspection ); } elseif ( ! $inspection['complete'] ) { $file_reason = sanitize_key( (string) $inspection['reason'] ); self::add_change( $findings, $relative, 'inspection_incomplete', 'medium', sprintf( /* translators: %s: internal reason code */ __( 'Media inspection did not complete for this file (%s); no clean verdict is available for it.', 'neverlast-security-lite' ), $file_reason ), self::current_file_meta( $path ), array( 'signal_id' => 'INSPECTION_INCOMPLETE', 'coverage' => (string) ( $inspection['coverage'] ?? '' ) ) ); } } else { $seen++; } if ( count( $findings ) >= 100 ) { $reason = 'uploads_finding_limit'; break; } } if ( $resume_pending ) { $state['updated_at'] = time(); $state['files_checked'] = $seen; $state['findings'] = $findings; $state['stack'] = $stack; if ( ! NLSSL_Core::write_option_verified( self::UPLOAD_STATE_OPTION, $state ) ) { return array( 'findings' => $findings, 'files_checked' => $seen, 'truncated' => true, 'reason' => 'uploads_resume_state_write', 'resume_pending' => false ); } self::schedule_upload_resume(); return array( 'findings' => $findings, 'files_checked' => $seen, 'truncated' => true, 'reason' => 'uploads_resume_pending', 'resume_pending' => true, 'scan_started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ) ); } self::clear_upload_scan_state(); return array( 'findings' => $findings, 'files_checked' => $seen, 'truncated' => '' !== $reason, 'reason' => $reason, 'resume_pending' => false, 'scan_started_at' => gmdate( 'c', (int) ( $state['started_at'] ?? time() ) ) ); } public static function upload_resume_pending(): bool { $state = get_option( self::UPLOAD_STATE_OPTION, array() ); return is_array( $state ) && ! empty( $state['stack'] ) && (int) ( $state['updated_at'] ?? 0 ) >= time() - self::UPLOAD_STATE_TTL; } public static function resume_scheduled_scan(): void { if ( ! NLSSL_Core::setting( 'integrity_schedule_enabled', 1 ) || ! self::has_baseline() ) { self::clear_upload_scan_state(); self::clear_scan_state( true ); return; } $result = self::scan(); if ( ! empty( $result['resume_pending'] ) ) { self::schedule_upload_resume(); return; } if ( ! empty( $result['critical_count'] ) ) { NLSSL_Core::alert( 'integrity-critical', 'NeverLast Security Lite detected critical file changes', 'Review the Health & Integrity page. NeverLast Security Lite did not modify any file.' ); } } private static function schedule_upload_resume(): void { if ( ! wp_next_scheduled( self::UPLOAD_RESUME_HOOK ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::UPLOAD_RESUME_HOOK ); } } private static function clear_upload_scan_state(): void { delete_option( self::UPLOAD_STATE_OPTION ); wp_clear_scheduled_hook( self::UPLOAD_RESUME_HOOK ); } private static function file_evidence_preview( string $path, string $fallback_signal = '' ): array { if ( '' === $path || ! is_file( $path ) || ! is_readable( $path ) || NLSSL_Firewall::is_link_path( $path ) ) { return array( 'signal_id' => $fallback_signal, 'coverage' => 'unavailable' ); } $sample = @file_get_contents( $path, false, null, 0, 262144 ); if ( false === $sample ) { return array( 'signal_id' => $fallback_signal, 'coverage' => 'unavailable' ); } $patterns = array( 'PHP_OPEN_TAG' => '/<\?(?:php\b|=)/i', 'ENCODED_EVAL' => '/\beval\s*\(\s*(?:base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(/i', 'REQUEST_COMMAND_EXECUTION' => '/\b(?:system|shell_exec|passthru|exec|popen|proc_open)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)/i', 'APACHE_PHP_HANDLER' => '/(?:AddHandler|AddType|SetHandler|ForceType|php_value|php_flag)\b/i', 'PHP_AUTO_PREPEND' => '/\bauto_(?:prepend|append)_file\s*[= ]/i', 'IIS_HANDLER_MAPPING' => '/ $pattern ) { $m = array(); if ( 1 !== preg_match( $pattern, $sample, $m, PREG_OFFSET_CAPTURE ) ) { continue; } $offset = (int) $m[0][1]; if ( null === $best || $offset < $best['offset'] ) { $best = array( 'signal_id' => $id, 'offset' => $offset ); } } $offset = null !== $best ? (int) $best['offset'] : 0; $start = max( 0, $offset - 120 ); $snippet = substr( $sample, $start, 360 ); $snippet = preg_replace_callback( '/[^\x20-\x7E\r\n ]/', static fn( array $m ): string => sprintf( '\\x%02X', ord( $m[0] ) ), $snippet ); return array( 'signal_id' => null !== $best ? (string) $best['signal_id'] : $fallback_signal, 'offset' => $offset, 'line' => 1 + substr_count( substr( $sample, 0, $offset ), "\n" ), 'snippet' => (string) $snippet, 'coverage' => strlen( $sample ) < 262144 ? 'full_text_file' : 'first_256k', ); } private static function trustable_types(): array { return array( 'code_in_media', 'server_control_dangerous', 'server_control_defensive', 'server_control_review', 'user_ini_review', 'excluded_tree_security_review', 'excluded_tree_guard_file', 'runtime_guard_file', 'suspicious_code_shape' ); } private static function trust_key( string $path, string $type ): string { return hash( 'sha256', $path . '|' . $type ); } private static function reconcile_latest_trusted_finding( array $finding ): bool { $latest = self::latest(); if ( empty( $latest ) || empty( $latest['changes'] ) || ! is_array( $latest['changes'] ) ) { return true; } $id = (string) ( $finding['id'] ?? '' ); $path = (string) ( $finding['path'] ?? '' ); $type = (string) ( $finding['type'] ?? '' ); $hash = (string) ( $finding['file_hash'] ?? '' ); $removed = 0; $changes = array_values( array_filter( $latest['changes'], static function ( array $candidate ) use ( $id, $path, $type, $hash, &$removed ): bool { $same_id = '' !== $id && hash_equals( $id, (string) ( $candidate['id'] ?? '' ) ); $same_tuple = $path === (string) ( $candidate['path'] ?? '' ) && $type === (string) ( $candidate['type'] ?? '' ) && '' !== $hash && hash_equals( $hash, (string) ( $candidate['file_hash'] ?? '' ) ); if ( $same_id || $same_tuple ) { $removed++; return false; } return true; } ) ); if ( 0 === $removed ) { return true; } $latest['changes'] = $changes; $latest['trusted_count'] = max( 0, (int) ( $latest['trusted_count'] ?? 0 ) ) + $removed; $latest['finding_count'] = count( $changes ); $latest['critical_count'] = count( array_filter( $changes, static fn( array $item ): bool => 'critical' === (string) ( $item['severity'] ?? '' ) ) ); $latest['high_count'] = count( array_filter( $changes, static fn( array $item ): bool => 'high' === (string) ( $item['severity'] ?? '' ) ) ); $latest['blocking_count'] = count( array_filter( $changes, static fn( array $item ): bool => ! self::finding_is_nonblocking_information( $item ) ) ); $latest['informational_count'] = max( 0, count( $changes ) - (int) $latest['blocking_count'] ); $incomplete = ! empty( $latest['truncated'] ) || ! empty( $latest['uploads_truncated'] ) || ! empty( $latest['resume_pending'] ) || ! empty( $latest['finding_limit_reached'] ); $latest['ok'] = ! $incomplete && 0 === (int) $latest['blocking_count']; if ( ! $incomplete ) { $latest['message'] = 0 === (int) $latest['blocking_count'] ? ( 0 === (int) $latest['informational_count'] ? __( 'No differences were found against the approved local baseline.', 'neverlast-security-lite' ) : sprintf( /* translators: %d: number of informational observations */ _n( 'No blocking differences were found. %d defensive informational observation remains.', 'No blocking differences were found. %d defensive informational observations remain.', (int) $latest['informational_count'], 'neverlast-security-lite' ), (int) $latest['informational_count'] ) ) : __( 'Differences require administrator review. Quarantine is offered only for narrowly eligible local files; no file is changed automatically.', 'neverlast-security-lite' ); } return NLSSL_Core::write_option_verified( self::RESULT_OPTION, $latest ); } public static function is_trusted_finding( array $finding ): bool { $type = (string) ( $finding['type'] ?? '' ); $hash = (string) ( $finding['file_hash'] ?? '' ); if ( '' === $hash || ! in_array( $type, self::trustable_types(), true ) ) { return false; } $trusted = get_option( self::TRUST_OPTION, array() ); if ( ! is_array( $trusted ) ) { return false; } $key = self::trust_key( (string) ( $finding['path'] ?? '' ), $type ); if ( ! isset( $trusted[ $key ] ) ) { return false; } $stored = is_array( $trusted[ $key ] ) ? (string) ( $trusted[ $key ]['hash'] ?? '' ) : ( is_string( $trusted[ $key ] ) ? $trusted[ $key ] : '' ); return '' !== $stored && hash_equals( $stored, $hash ); } public static function trust_finding( string $finding_id ): array { $trusted = get_option( self::TRUST_OPTION, array() ); if ( ! is_array( $trusted ) ) { $trusted = array(); } $finding = self::find_latest_finding( $finding_id ); if ( ! $finding ) { foreach ( $trusted as $entry ) { if ( ! is_array( $entry ) || ! isset( $entry['finding_id'] ) || ! hash_equals( $finding_id, (string) $entry['finding_id'] ) ) { continue; } $absolute = self::safe_absolute_path( (string) ( $entry['path'] ?? '' ) ); $meta = $absolute ? self::current_file_meta( $absolute ) : array(); $stored_hash = (string) ( $entry['hash'] ?? '' ); if ( '' !== $stored_hash && ! empty( $meta['hash'] ) && hash_equals( $stored_hash, (string) $meta['hash'] ) ) { return array( 'ok' => true, 'code' => 'already_trusted', 'path' => (string) ( $entry['path'] ?? '' ), 'hash' => $stored_hash, 'type' => (string) ( $entry['type'] ?? '' ), 'snapshot_reconciled' => true, 'message' => __( 'This exact file hash was already trusted. No duplicate trust event was created.', 'neverlast-security-lite' ) ); } } return array( 'ok' => false, 'code' => 'stale_finding', 'message' => __( 'The finding is stale. Run the scan again before trusting it.', 'neverlast-security-lite' ) ); } if ( ! in_array( (string) $finding['type'], self::trustable_types(), true ) ) { return array( 'ok' => false, 'code' => 'type_not_trustable', 'message' => __( 'This finding type cannot be suppressed individually.', 'neverlast-security-lite' ) ); } $absolute = self::safe_absolute_path( (string) $finding['path'] ); $meta = $absolute ? self::current_file_meta( $absolute ) : array(); if ( empty( $meta['hash'] ) || empty( $finding['file_hash'] ) || ! hash_equals( (string) $finding['file_hash'], (string) $meta['hash'] ) ) { return array( 'ok' => false, 'code' => 'file_changed', 'message' => __( 'The file changed since the scan. Run the scan again.', 'neverlast-security-lite' ) ); } $key = self::trust_key( (string) $finding['path'], (string) $finding['type'] ); $existing = isset( $trusted[ $key ] ) ? $trusted[ $key ] : null; $existing_hash = is_array( $existing ) ? (string) ( $existing['hash'] ?? '' ) : ( is_string( $existing ) ? $existing : '' ); $already_trusted = '' !== $existing_hash && hash_equals( $existing_hash, (string) $meta['hash'] ); if ( ! $already_trusted ) { $trusted[ $key ] = array( 'hash' => (string) $meta['hash'], 'path' => (string) $finding['path'], 'type' => (string) $finding['type'], 'finding_id' => $finding_id, 'trusted_at' => gmdate( 'c' ), 'actor' => get_current_user_id(), ); if ( count( $trusted ) > 500 ) { $trusted = array_slice( $trusted, -500, null, true ); } if ( ! NLSSL_Core::write_option_verified( self::TRUST_OPTION, $trusted ) ) { return array( 'ok' => false, 'code' => 'trust_storage_failed', 'message' => __( 'The trusted-hash decision could not be verified in storage.', 'neverlast-security-lite' ) ); } } $reconciled = self::reconcile_latest_trusted_finding( $finding ); if ( ! $already_trusted ) { NLSSL_Core::log( 'integrity_finding_trusted', 'warning', 'An administrator trusted the exact current hash for an integrity heuristic.', array( 'path' => $finding['path'], 'type' => $finding['type'], 'actor' => get_current_user_id() ) ); } return array( 'ok' => true, 'code' => $already_trusted ? 'already_trusted' : ( $reconciled ? 'trust_saved' : 'trust_saved_snapshot_stale' ), 'path' => (string) $finding['path'], 'hash' => (string) $meta['hash'], 'type' => (string) $finding['type'], 'snapshot_reconciled' => $reconciled, 'message' => $already_trusted ? __( 'This exact file hash was already trusted. The current scan view was reconciled so the duplicate action is no longer shown.', 'neverlast-security-lite' ) : ( $reconciled ? __( 'This exact file hash is now trusted and was removed from the current scan view. Any future content change will make it visible again.', 'neverlast-security-lite' ) : __( 'The exact file hash was trusted, but the current scan snapshot could not be reconciled. Run the comparison again to refresh the view.', 'neverlast-security-lite' ) ), ); } public static function quarantine_finding( string $finding_id ): array { $finding = self::find_latest_finding( $finding_id ); if ( ! $finding || empty( $finding['quarantine_allowed'] ) || ! self::finding_quarantine_allowed( $finding ) ) { return array( 'ok' => false, 'message' => __( 'This finding is not eligible for quarantine. Core/plugin/theme source is never quarantined from this screen.', 'neverlast-security-lite' ) ); } $absolute = self::safe_absolute_path( (string) $finding['path'] ); if ( ! $absolute ) { return array( 'ok' => false, 'message' => __( 'The finding path is no longer safe to access.', 'neverlast-security-lite' ) ); } $meta = self::current_file_meta( $absolute ); if ( empty( $meta['hash'] ) || empty( $finding['file_hash'] ) || ! hash_equals( (string) $finding['file_hash'], (string) $meta['hash'] ) ) { return array( 'ok' => false, 'message' => __( 'The file changed since the scan. Run the scan again before quarantining it.', 'neverlast-security-lite' ) ); } $root = self::ensure_quarantine_root(); if ( '' === $root ) { return array( 'ok' => false, 'message' => __( 'The quarantine directory could not be created securely.', 'neverlast-security-lite' ) ); } try { $id = hash( 'sha256', $finding_id . '|' . bin2hex( random_bytes( 16 ) ) ); } catch ( Throwable $e ) { return array( 'ok' => false, 'message' => __( 'Secure quarantine identifier generation failed.', 'neverlast-security-lite' ) ); } $target = $root . '/' . $id . '.qtn'; $manifest = $root . '/' . $id . '.json'; $written = self::write_protected_copy( $absolute, $target, 'raw' ); // The hash is recomputed from the bytes that were actually read, and the protected copy is // then authenticated and decoded and hashed again: the original is only removed once a faithful, // authenticated encrypted copy has proved itself in both directions. if ( empty( $written['ok'] ) || ! hash_equals( (string) $meta['hash'], (string) $written['hash'] ) ) { wp_delete_file( $target ); return array( 'ok' => false, 'message' => __( 'A verified quarantine copy could not be created. The original file was left untouched.', 'neverlast-security-lite' ) ); } if ( ! self::evidence_chmod( $target, 0600 ) ) { wp_delete_file( $target ); return array( 'ok' => false, 'message' => __( 'Private permissions could not be applied to held evidence. The original file was left untouched.', 'neverlast-security-lite' ) ); } $probe_hash = self::held_copy_hash( $target, self::QUARANTINE_ENCODING ); if ( '' === $probe_hash || ! hash_equals( (string) $meta['hash'], $probe_hash ) ) { wp_delete_file( $target ); return array( 'ok' => false, 'message' => __( 'The quarantine copy could not be verified by decoding it back. The original file was left untouched.', 'neverlast-security-lite' ) ); } $record = array( 'id' => $id, 'original_path' => (string) $finding['path'], 'hash' => (string) $meta['hash'], 'size' => (int) $meta['size'], 'mode' => (int) ( @fileperms( $absolute ) & 0777 ), 'type' => (string) $finding['type'], 'encoding' => self::QUARANTINE_ENCODING, 'quarantined_at' => gmdate( 'c' ), 'actor' => get_current_user_id() ); $json = wp_json_encode( $record, JSON_UNESCAPED_SLASHES ); $written_bytes = is_string( $json ) ? @file_put_contents( $manifest, $json, LOCK_EX ) : false; if ( ! is_string( $json ) || $written_bytes !== strlen( $json ) || (string) @file_get_contents( $manifest ) !== $json ) { wp_delete_file( $target ); wp_delete_file( $manifest ); return array( 'ok' => false, 'message' => __( 'Quarantine metadata could not be verified. The original file was left untouched.', 'neverlast-security-lite' ) ); } if ( ! self::evidence_chmod( $manifest, 0600 ) ) { wp_delete_file( $target ); wp_delete_file( $manifest ); return array( 'ok' => false, 'message' => __( 'Private permissions could not be applied to quarantine metadata. The original file was left untouched.', 'neverlast-security-lite' ) ); } // Revalidate immediately before removal so a file swapped after the copy is not deleted. $final_hash = @hash_file( 'sha256', $absolute ); if ( ! is_string( $final_hash ) || ! hash_equals( (string) $meta['hash'], $final_hash ) || NLSSL_Firewall::is_link_path( $absolute ) ) { wp_delete_file( $target ); wp_delete_file( $manifest ); return array( 'ok' => false, 'message' => __( 'The source changed while quarantine was being prepared. The current file was left untouched; run the scan again.', 'neverlast-security-lite' ) ); } if ( ! self::delete_confirmed( $absolute ) ) { wp_delete_file( $target ); wp_delete_file( $manifest ); return array( 'ok' => false, 'message' => __( 'The verified quarantine copy was prepared, but the original could not be removed. Nothing was quarantined.', 'neverlast-security-lite' ) ); } NLSSL_Core::log( 'integrity_file_quarantined', 'critical', 'An administrator quarantined a scan finding.', array( 'path' => $finding['path'], 'type' => $finding['type'], 'actor' => get_current_user_id(), 'quarantine_id' => $id ) ); return array( 'ok' => true, 'message' => __( 'The file was removed from uploads and is held as an encrypted copy. It stays there until you decide: download it as evidence or delete it permanently.', 'neverlast-security-lite' ) ); } public static function quarantine_items(): array { $items = array(); $legacy = self::legacy_quarantine_root(); // A bounded lazy migration runs only when an administrator requests this queue. if ( is_dir( $legacy ) && ! NLSSL_Firewall::is_link_path( $legacy ) ) { foreach ( array_slice( glob( $legacy . '/*.json' ) ?: array(), 0, 100 ) as $manifest ) { $id = basename( $manifest, '.json' ); if ( preg_match( '/^[a-f0-9]{64}$/D', $id ) ) { self::migrate_quarantine_record( $id ); } } } foreach ( self::quarantine_roots() as $root ) { foreach ( array_slice( glob( $root . '/*.json' ) ?: array(), 0, 100 ) as $manifest ) { if ( is_link( $manifest ) ) { continue; } $data = json_decode( (string) @file_get_contents( $manifest ), true ); $id = (string) ( $data['id'] ?? '' ); if ( ! is_array( $data ) || ! preg_match( '/^[a-f0-9]{64}$/D', $id ) || basename( $manifest ) !== $id . '.json' ) { continue; } if ( isset( $items[ $id ] ) ) { continue; } $data['held_copy_missing'] = ! is_file( $root . '/' . $id . '.qtn' ) || is_link( $root . '/' . $id . '.qtn' ); $items[ $id ] = $data; } } $items = array_values( $items ); usort( $items, static fn( array $a, array $b ): int => strcmp( (string) ( $b['quarantined_at'] ?? '' ), (string) ( $a['quarantined_at'] ?? '' ) ) ); return $items; } /** * Restoration of suspect executable/server-control files directly into uploads is prohibited. * Evidence can be exported by a privileged administrator and reviewed offline instead. */ public static function restore_quarantine( string $id ): array { return array( 'ok' => false, 'message' => __( 'Direct restoration of suspicious executable files is disabled. Export the held evidence for offline review and restore only through your trusted deployment or backup process.', 'neverlast-security-lite' ) ); } /** * Suspicious content is held as authenticated AES-256-GCM chunks, not executable bytes. * Every chunk has an independent nonce and authentication tag. The full original SHA-256 * is verified before deleting the source. Legacy Base64 records are read for migration only. * WordPress authentication salts derive the key: export evidence before salt rotation. */ const QUARANTINE_CHUNK_BYTES = 196608; const QUARANTINE_ENCODING = 'aes-256-gcm-chunks-v1'; const QUARANTINE_LEGACY_ENCODING = 'base64'; const QUARANTINE_MAGIC = "NLSSLQ2\n"; /** Site salts are required to access held evidence. Back up/export before rotating salts. */ private static function quarantine_key(): string { if ( ! function_exists( 'openssl_encrypt' ) || ! function_exists( 'openssl_decrypt' ) || ! function_exists( 'wp_salt' ) ) { return ''; } return hash_hkdf( 'sha256', wp_salt( 'auth' ) . '|' . wp_salt( 'secure_auth' ), 32, 'NeverLast Security Lite quarantine format 2' ); } /** * Bounded evidence streaming must use native PHP streams: WP_Filesystem has no * chunked stream handles, atomic exclusive-create mode, or streaming flush. * Switching to get_contents()/put_contents() would load entire potentially * hostile uploads into PHP memory, breaking the security resource budget. * Restrict the exception to the stream reader/writer below only. */ // phpcs:disable WordPress.WP.AlternativeFunctions -- Bounded authenticated chunk streaming and exclusive creation are unavailable in WP_Filesystem. /** Iterate bounded plaintext chunks; rejects malformed or unauthenticated evidence. */ private static function held_chunks( string $source, string $encoding, callable $consume ): bool { if ( is_link( $source ) ) { return false; } $in = @fopen( $source, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded evidence streaming. if ( false === $in ) { return false; } $ok = true; try { if ( self::QUARANTINE_ENCODING === $encoding ) { $key = self::quarantine_key(); if ( '' === $key || self::QUARANTINE_MAGIC !== fread( $in, strlen( self::QUARANTINE_MAGIC ) ) ) { return false; } while ( ! feof( $in ) ) { $length_bytes = fread( $in, 4 ); if ( '' === $length_bytes && feof( $in ) ) { break; } if ( false === $length_bytes || strlen( $length_bytes ) !== 4 ) { return false; } $size = (int) unpack( 'Nsize', $length_bytes )['size']; if ( $size < 1 || $size > self::QUARANTINE_CHUNK_BYTES ) { return false; } $nonce = self::read_exact( $in, 12 ); $tag = self::read_exact( $in, 16 ); $cipher = self::read_exact( $in, $size ); if ( false === $nonce || false === $tag || false === $cipher ) { return false; } $decoded = openssl_decrypt( $cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag ); if ( false === $decoded || strlen( $decoded ) !== $size || ! $consume( $decoded ) ) { return false; } } } elseif ( self::QUARANTINE_LEGACY_ENCODING === $encoding ) { $read = (int) ( self::QUARANTINE_CHUNK_BYTES / 3 ) * 4; while ( ! feof( $in ) ) { $chunk = fread( $in, $read ); if ( false === $chunk ) { return false; } if ( '' === $chunk ) { continue; } $decoded = base64_decode( $chunk, true ); if ( false === $decoded || ! $consume( $decoded ) ) { return false; } } } elseif ( '' === $encoding ) { while ( ! feof( $in ) ) { $chunk = fread( $in, self::QUARANTINE_CHUNK_BYTES ); if ( false === $chunk || ( '' === $chunk && ! feof( $in ) ) ) { return false; } if ( '' !== $chunk && ! $consume( $chunk ) ) { return false; } } } else { return false; } return $ok; } finally { fclose( $in ); } } private static function read_exact( $stream, int $size ) { $data = ''; while ( strlen( $data ) < $size ) { $chunk = fread( $stream, $size - strlen( $data ) ); if ( false === $chunk || '' === $chunk ) { return false; } $data .= $chunk; } return $data; } private static function held_copy_hash( string $source, string $encoding ): string { $context = hash_init( 'sha256' ); return self::held_chunks( $source, $encoding, static function ( string $chunk ) use ( $context ): bool { hash_update( $context, $chunk ); return true; } ) ? hash_final( $context ) : ''; } /** Stream a source or legacy artifact into authenticated, encrypted held storage. */ private static function write_protected_copy( string $source, string $target, string $source_encoding ): array { $key = self::quarantine_key(); if ( '' === $key || is_link( $target ) ) { return array( 'ok' => false, 'reason' => 'encryption_unavailable' ); } $out = @fopen( $target, 'xb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Exclusive create prevents overwrite. if ( false === $out ) { return array( 'ok' => false, 'reason' => 'target_exists' ); } $ctx = hash_init( 'sha256' ); $total = 0; $write = static function ( string $plain ) use ( $out, $key, $ctx, &$total ): bool { try { $nonce = random_bytes( 12 ); } catch ( Throwable $e ) { return false; } $tag = ''; $cipher = openssl_encrypt( $plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag ); if ( false === $cipher || strlen( $tag ) !== 16 ) { return false; } $record = pack( 'N', strlen( $cipher ) ) . $nonce . $tag . $cipher; $written = fwrite( $out, $record ); if ( false === $written || $written !== strlen( $record ) ) { return false; } hash_update( $ctx, $plain ); $total += strlen( $plain ); return true; }; $header_written = fwrite( $out, self::QUARANTINE_MAGIC ); $ok = $header_written === strlen( self::QUARANTINE_MAGIC ) && self::held_chunks( $source, $source_encoding === 'raw' ? '' : $source_encoding, $write ); $flushed = fflush( $out ); fclose( $out ); if ( ! $ok || ! $flushed ) { wp_delete_file( $target ); return array( 'ok' => false, 'reason' => 'encrypt_failed' ); } return array( 'ok' => true, 'hash' => hash_final( $ctx ), 'bytes' => $total ); } // phpcs:enable WordPress.WP.AlternativeFunctions /** Privileged export emits only Base64 plaintext, not executable files. */ public static function quarantine_export_ready( string $id ): bool { $data = self::quarantine_manifest( $id ); if ( ! $data ) { return false; } $hash = self::held_copy_hash( (string) $data['stored_path'], (string) ( $data['encoding'] ?? '' ) ); return '' !== $hash && hash_equals( (string) $data['hash'], $hash ); } public static function stream_quarantine_base64( string $id ): bool { $data = self::quarantine_manifest( $id ); if ( ! $data ) { return false; } return self::held_chunks( (string) $data['stored_path'], (string) ( $data['encoding'] ?? '' ), static function ( string $chunk ): bool { echo base64_encode( $chunk ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Encoded evidence requested by privileged administrator. return true; } ); } public static function quarantine_manifest( string $id, bool $require_copy = true ): array { if ( ! preg_match( '/^[a-f0-9]{64}$/', $id ) ) { return array(); } $root = self::quarantine_record_root( $id ); if ( '' === $root ) { return array(); } $data = json_decode( (string) @file_get_contents( $root . '/' . $id . '.json' ), true ); if ( ! is_array( $data ) || ! hash_equals( $id, (string) ( $data['id'] ?? '' ) ) || ! preg_match( '/^[a-f0-9]{64}$/D', (string) ( $data['hash'] ?? '' ) ) ) { return array(); } $present = is_file( $root . '/' . $id . '.qtn' ) && ! is_link( $root . '/' . $id . '.qtn' ); if ( $require_copy && ! $present ) { return array(); } $data['held_copy_missing'] = ! $present; $data['stored_path'] = $present ? $root . '/' . $id . '.qtn' : ''; return $data; } /** * Clears the record of a hold whose copy has already been removed by something other than * NeverLast Security. It refuses while the copy is still present, because deleting evidence is a separate * decision with its own confirmation - this only tidies a row that can no longer do anything. */ public static function discard_quarantine_record( string $id ): array { $data = self::quarantine_manifest( $id, false ); if ( empty( $data ) ) { return array( 'ok' => false, 'message' => __( 'The quarantine record is missing.', 'neverlast-security-lite' ) ); } $root = self::quarantine_record_root( $id ); if ( is_file( $root . '/' . $id . '.qtn' ) ) { return array( 'ok' => false, 'message' => __( 'The held copy is still present. Use Delete permanently if you intend to destroy it.', 'neverlast-security-lite' ) ); } if ( ! self::delete_confirmed( $root . '/' . $id . '.json' ) ) { return array( 'ok' => false, 'message' => __( 'The stale quarantine record could not be removed. Check filesystem permissions on the NeverLast Security quarantine directory.', 'neverlast-security-lite' ) ); } NLSSL_Core::log( 'integrity_quarantine_record_cleared', 'warning', 'An administrator cleared a quarantine record whose held copy had already been removed by something outside NeverLast Security.', array( 'path' => (string) ( $data['original_path'] ?? '' ), 'actor' => get_current_user_id(), 'quarantine_id' => $id ) ); return array( 'ok' => true, 'message' => __( 'The stale record was cleared. The file itself was already gone and could not be recovered.', 'neverlast-security-lite' ) ); } /** * The decision the administrator has NOT made yet is the point of the whole workflow, so a held * item is never deleted on a timer: an unattended site keeps being asked rather than quietly * losing the only copy of the evidence. */ public static function purge_quarantine( string $id ): array { $data = self::quarantine_manifest( $id, false ); if ( empty( $data ) ) { return array( 'ok' => false, 'message' => __( 'The quarantine record is missing.', 'neverlast-security-lite' ) ); } if ( ! empty( $data['held_copy_missing'] ) ) { return array( 'ok' => false, 'message' => __( 'The held copy is already gone; there is nothing left to delete. Clear the record instead.', 'neverlast-security-lite' ) ); } $root = self::quarantine_record_root( $id ); $removed = self::delete_confirmed( $root . '/' . $id . '.qtn' ); if ( ! $removed ) { return array( 'ok' => false, 'message' => __( 'The held copy could not be deleted. Check filesystem permissions on the NeverLast Security quarantine directory.', 'neverlast-security-lite' ) ); } if ( ! self::delete_confirmed( $root . '/' . $id . '.json' ) ) { return array( 'ok' => false, 'message' => __( 'The held copy was deleted, but its record could not be removed. Use Clear record after repairing permissions.', 'neverlast-security-lite' ) ); } NLSSL_Core::log( 'integrity_file_purged', 'warning', 'An administrator permanently deleted a quarantined file.', array( 'path' => (string) ( $data['original_path'] ?? '' ), 'actor' => get_current_user_id(), 'quarantine_id' => $id ) ); return array( 'ok' => true, 'message' => __( 'The held copy was permanently deleted. Nothing was restored to the site.', 'neverlast-security-lite' ) ); } private static function find_latest_finding( string $id ): ?array { if ( ! preg_match( '/^[a-f0-9]{64}$/', $id ) ) { return null; } $latest = self::latest(); foreach ( (array) ( $latest['changes'] ?? array() ) as $finding ) { if ( is_array( $finding ) && hash_equals( $id, (string) ( $finding['id'] ?? '' ) ) ) { return $finding; } } return null; } private static function finding_quarantine_allowed( array $finding ): bool { $path = strtolower( str_replace( '\\', '/', (string) ( $finding['path'] ?? '' ) ) ); if ( ! str_starts_with( $path, 'uploads/' ) || in_array( '..', explode( '/', $path ), true ) || in_array( '.', explode( '/', $path ), true ) ) { return false; } $type = (string) ( $finding['type'] ?? '' ); if ( in_array( $type, array( 'code_in_media', 'executable_in_uploads' ), true ) ) { return true; } if ( 'server_control_dangerous' === $type ) { return in_array( (string) ( $finding['signal_id'] ?? '' ), array( 'APACHE_PHP_HANDLER_ENABLE', 'PHP_AUTO_PREPEND', 'USER_INI_AUTO_PREPEND', 'USER_INI_AUTO_APPEND', 'USER_INI_ALLOW_URL_INCLUDE', 'USER_INI_REMOTE_INCLUDE', 'IIS_HANDLER_MAPPING' ), true ); } return false; } private static function safe_absolute_path( string $relative, bool $must_exist = true ): string { $relative = ltrim( wp_normalize_path( $relative ), '/' ); if ( '' === $relative || str_contains( $relative, "\0" ) || in_array( '..', explode( '/', $relative ), true ) || in_array( '.', explode( '/', $relative ), true ) ) { return ''; } if ( str_starts_with( $relative, 'uploads/' ) ) { $subpath = substr( $relative, strlen( 'uploads/' ) ); $root = self::uploads_root(); if ( '' === $subpath || '' === $root || ! is_dir( $root ) || NLSSL_Firewall::is_link_path( $root ) ) { return ''; } $cursor = $root; foreach ( explode( '/', $subpath ) as $component ) { if ( '' === $component || '.' === $component || '..' === $component ) { return ''; } $cursor .= '/' . $component; if ( NLSSL_Firewall::is_link_path( $cursor ) ) { return ''; } } $absolute = wp_normalize_path( $cursor ); return ( ! $must_exist || is_file( $absolute ) ) ? $absolute : ''; } $root = trailingslashit( wp_normalize_path( ABSPATH ) ); $absolute = wp_normalize_path( $root . $relative ); if ( ! str_starts_with( $absolute, $root ) ) { return ''; } $cursor = untrailingslashit( $root ); foreach ( explode( '/', $relative ) as $component ) { $cursor .= '/' . $component; if ( file_exists( $cursor ) && NLSSL_Firewall::is_link_path( $cursor ) ) { return ''; } } if ( $must_exist && ! is_file( $absolute ) ) { return ''; } return $absolute; } /** * Deletes a file and reports whether it is really gone. wp_delete_file() only returns a value since * WordPress 6.7 (null before), and its filter can redirect or cancel the deletion, so success is judged * by what is on disk afterwards, never by the return value. */ private static function delete_confirmed( string $path ): bool { wp_delete_file( $path ); clearstatcache( true, $path ); return ! file_exists( $path ) && ! is_link( $path ); } /** * Evidence streams operate on local paths, therefore use only a direct * WP_Filesystem transport for permissions. An FTP/SSH transport cannot be * mixed safely with the local exclusive-create streaming workflow. * No credential prompt is attempted during an administrator request. * * @return WP_Filesystem_Direct|null */ private static function evidence_filesystem( string $context ) { global $wp_filesystem; if ( $wp_filesystem instanceof WP_Filesystem_Direct ) { return $wp_filesystem; } if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! WP_Filesystem( false, $context, true ) ) { return null; } return $wp_filesystem instanceof WP_Filesystem_Direct ? $wp_filesystem : null; } /** Fail closed when private evidence permissions cannot be enforced. */ private static function evidence_chmod( string $path, int $mode ): bool { $filesystem = self::evidence_filesystem( dirname( $path ) ); return null !== $filesystem && $filesystem->chmod( $path, $mode ); } private static function quarantine_root(): string { $uploads = wp_upload_dir( null, false ); if ( ! empty( $uploads['error'] ) ) { return ''; } $basedir = self::uploads_root(); if ( '' === $basedir || ! is_dir( $basedir ) || NLSSL_Firewall::is_link_path( $basedir ) ) { return ''; } return wp_normalize_path( $basedir . '/' . self::QUARANTINE_DIR ); } private static function legacy_quarantine_root(): string { return defined( 'WP_CONTENT_DIR' ) ? wp_normalize_path( untrailingslashit( WP_CONTENT_DIR ) . '/' . self::LEGACY_QUARANTINE_DIR ) : ''; } private static function quarantine_roots(): array { $roots = array( self::quarantine_root(), self::legacy_quarantine_root() ); return array_values( array_unique( array_filter( $roots, static fn( string $root ): bool => '' !== $root && is_dir( $root ) && ! NLSSL_Firewall::is_link_path( $root ) && ! NLSSL_Firewall::is_link_path( dirname( $root ) ) ) ) ); } /** A record may live in the old directory when migration was interrupted or storage is read-only. */ private static function quarantine_record_root( string $id ): string { if ( ! preg_match( '/^[a-f0-9]{64}$/D', $id ) ) { return ''; } foreach ( self::quarantine_roots() as $root ) { if ( is_file( $root . '/' . $id . '.json' ) && ! is_link( $root . '/' . $id . '.json' ) ) { $record = json_decode( (string) @file_get_contents( $root . '/' . $id . '.json' ), true ); if ( is_array( $record ) && hash_equals( $id, (string) ( $record['id'] ?? '' ) ) ) { return $root; } } } return ''; } /** Return false on any failure: original legacy copies must never be deleted prematurely. */ private static function migrate_quarantine_record( string $id ): bool { $legacy = self::legacy_quarantine_root(); if ( '' === $legacy || NLSSL_Firewall::is_link_path( $legacy ) ) { return false; } $old_manifest = $legacy . '/' . $id . '.json'; $old_copy = $legacy . '/' . $id . '.qtn'; if ( ! is_file( $old_manifest ) || is_link( $old_manifest ) || ( is_file( $old_copy ) && is_link( $old_copy ) ) ) { return false; } $data = json_decode( (string) @file_get_contents( $old_manifest ), true ); if ( ! is_array( $data ) || ! hash_equals( $id, (string) ( $data['id'] ?? '' ) ) || ! preg_match( '/^[a-f0-9]{64}$/D', (string) ( $data['hash'] ?? '' ) ) ) { return false; } $root = self::ensure_quarantine_root(); if ( '' === $root ) { return false; } $new_manifest = $root . '/' . $id . '.json'; $new_copy = $root . '/' . $id . '.qtn'; $expected_hash = (string) $data['hash']; $converted = $data; if ( is_file( $old_copy ) ) { $legacy_encoding = (string) ( $data['encoding'] ?? '' ); $old_hash = self::held_copy_hash( $old_copy, $legacy_encoding ); if ( '' === $old_hash || ! hash_equals( $expected_hash, $old_hash ) ) { return false; } if ( ! is_file( $new_copy ) ) { $converted_copy = self::write_protected_copy( $old_copy, $new_copy, $legacy_encoding ); if ( empty( $converted_copy['ok'] ) || ! hash_equals( $expected_hash, (string) $converted_copy['hash'] ) ) { return false; } } if ( ! self::evidence_chmod( $new_copy, 0600 ) ) { return false; } $new_hash = self::held_copy_hash( $new_copy, self::QUARANTINE_ENCODING ); if ( '' === $new_hash || ! hash_equals( $expected_hash, $new_hash ) ) { return false; } $converted['encoding'] = self::QUARANTINE_ENCODING; } elseif ( is_file( $new_copy ) ) { return false; } // Unexpected artifact: preserve legacy manifest for manual recovery. if ( is_file( $new_manifest ) ) { if ( ! self::evidence_chmod( $new_manifest, 0600 ) ) { return false; } $existing = json_decode( (string) @file_get_contents( $new_manifest ), true ); if ( ! is_array( $existing ) || $existing !== $converted ) { return false; } } else { $json = wp_json_encode( $converted, JSON_UNESCAPED_SLASHES ); $bytes = is_string( $json ) ? @file_put_contents( $new_manifest, $json, LOCK_EX ) : false; if ( ! is_string( $json ) || $bytes !== strlen( $json ) ) { return false; } if ( ! self::evidence_chmod( $new_manifest, 0600 ) ) { self::delete_confirmed( $new_manifest ); return false; } } // Verify persisted metadata and authenticated artifact before deleting either original. $verified = json_decode( (string) @file_get_contents( $new_manifest ), true ); if ( ! is_array( $verified ) || $verified !== $converted ) { return false; } if ( is_file( $old_copy ) && ! self::delete_confirmed( $old_copy ) ) { return false; } return self::delete_confirmed( $old_manifest ); } private static function guard_quarantine_dir( string $root ): bool { if ( NLSSL_Firewall::is_link_path( $root ) ) { return false; } if ( ! is_dir( $root ) && ! wp_mkdir_p( $root ) ) { return false; } $filesystem = self::evidence_filesystem( $root ); if ( NLSSL_Firewall::is_link_path( $root ) || null === $filesystem || ! $filesystem->chmod( $root, 0700 ) || ! $filesystem->is_writable( $root ) ) { return false; } $guards = array( '.htaccess' => "Deny from all\nRequire all denied\n", 'web.config' => '', 'index.php' => " $content ) { $file = $root . '/' . $name; if ( is_link( $file ) ) { return false; } if ( ! is_file( $file ) ) { if ( @file_put_contents( $file, $content, LOCK_EX ) !== strlen( $content ) ) { return false; } } if ( (string) @file_get_contents( $file ) !== $content ) { return false; } if ( ! $filesystem->chmod( $file, 0600 ) ) { return false; } } return true; } private static function ensure_quarantine_root(): string { $root = self::quarantine_root(); if ( '' === $root ) { return ''; } $parent = dirname( $root ); if ( NLSSL_Firewall::is_link_path( dirname( $parent ) ) || NLSSL_Firewall::is_link_path( $parent ) ) { return ''; } if ( ! self::guard_quarantine_dir( $parent ) || ! self::guard_quarantine_dir( $root ) ) { return ''; } return $root; } }