$request_uri, 'request_method' => $request_method, 'block_reason' => $block_info['reason'] ?? 'unknown', ) ); self::send_block_response(); } // Block expired, clean up. self::cache_delete( 'block_' . $ip_hash ); } $now = time(); $window = (int) ( $now / self::WINDOW_SECONDS ); $counter = self::increment_counter( $ip_hash, $window ); if ( null === self::$rate_limit_settings ) { self::$rate_limit_settings = array( 'rate_limit' => (int) ( Settings::get_option( 'security_rate_limit_per_minute' ) ?: self::DEFAULT_RATE_LIMIT ), 'burst_limit' => (int) ( Settings::get_option( 'security_burst_limit' ) ?: self::BURST_LIMIT ), 'block_duration' => (int) ( Settings::get_option( 'security_block_duration' ) ?: self::BLOCK_DURATION ), ); } $rate_limit = self::$rate_limit_settings['rate_limit']; $burst_limit = self::$rate_limit_settings['burst_limit']; // Apply lockdown overrides if active. $limits = \apply_filters( 'advan_security_rate_limit_override', array( 'rate_limit' => $rate_limit, 'burst_limit' => $burst_limit, ) ); if ( is_array( $limits ) ) { $rate_limit = (int) ( $limits['rate_limit'] ?? $rate_limit ); $burst_limit = (int) ( $limits['burst_limit'] ?? $burst_limit ); } if ( $counter > $rate_limit ) { // Block this IP. $block_duration = self::$rate_limit_settings['block_duration']; self::cache_set( 'block_' . $ip_hash, \wp_json_encode( array( 'until' => $now + $block_duration, 'reason' => 'rate_limit', ) ), $block_duration ); self::queue_event( Security_Events_Entity::EVENT_DOS, Security_Events_Entity::SEVERITY_HIGH, $ip_hash, sprintf( 'Rate limit exceeded: %d requests in %d seconds', $counter, self::WINDOW_SECONDS ), array( 'counter' => $counter, 'limit' => $rate_limit, 'window' => self::WINDOW_SECONDS, ) ); self::send_block_response(); } // Burst detection (short window, higher sensitivity). $burst_window = (int) ( $now / self::BURST_WINDOW ); $burst_counter = self::increment_counter( $ip_hash . '_burst', $burst_window ); if ( $burst_counter > $burst_limit ) { $block_duration = self::$rate_limit_settings['block_duration']; self::cache_set( 'block_' . $ip_hash, \wp_json_encode( array( 'until' => $now + $block_duration, 'reason' => 'burst', ) ), $block_duration ); self::queue_event( Security_Events_Entity::EVENT_DOS, Security_Events_Entity::SEVERITY_CRITICAL, $ip_hash, sprintf( 'Burst limit exceeded: %d requests in %d seconds', $burst_counter, self::BURST_WINDOW ), array( 'counter' => $burst_counter, 'limit' => $burst_limit, 'window' => self::BURST_WINDOW, ) ); self::send_block_response(); } } /** * Rate limit login attempts. * * @param \WP_User|\WP_Error|null $user User object or error. * @param string $username Username. * @param string $password Password. * * @return \WP_User|\WP_Error|null * * @since 5.2.0 */ public static function check_login_rate_limit( $user, $username, $password ) { if ( empty( $username ) ) { return $user; } self::$authenticate_called = true; $ip_hash = self::get_ip_hash(); if ( '' === $ip_hash ) { return $user; } $now = time(); $login_window = (int) ( $now / self::LOGIN_WINDOW ); $login_key = 'login_' . $ip_hash; $login_counter = self::get_counter( $login_key, $login_window ); $login_limit = (int) ( Settings::get_option( 'security_login_rate_limit' ) ?: self::LOGIN_RATE_LIMIT ); if ( $login_counter >= $login_limit ) { self::queue_event( Security_Events_Entity::EVENT_BRUTE_FORCE, Security_Events_Entity::SEVERITY_HIGH, $ip_hash, sprintf( 'Login rate limit exceeded: %d attempts in %d seconds for user "%s"', $login_counter, self::LOGIN_WINDOW, sanitize_user( $username ) ), array( 'username' => sanitize_user( $username ), 'counter' => $login_counter, ) ); // Block the IP from further login attempts. $block_duration = (int) ( Settings::get_option( 'security_block_duration' ) ?: self::BLOCK_DURATION ); self::cache_set( 'block_' . $ip_hash, \wp_json_encode( array( 'until' => $now + $block_duration, 'reason' => 'brute_force', ) ), $block_duration ); return new \WP_Error( 'advan_rate_limited', \__( 'Too many login attempts. Please try again later.', '0-day-analytics' ) ); } return $user; } /** * Record a failed login attempt. * * @param string $username The username that failed. * * @return void * * @since 5.2.0 */ public static function record_failed_login( $username ): void { $ip_hash = self::get_ip_hash(); if ( '' === $ip_hash ) { return; } $now = time(); $login_window = (int) ( $now / self::LOGIN_WINDOW ); self::increment_counter( 'login_' . $ip_hash, $login_window ); self::queue_event( Security_Events_Entity::EVENT_BRUTE_FORCE, Security_Events_Entity::SEVERITY_MEDIUM, $ip_hash, sprintf( 'Failed login attempt for user "%s"', sanitize_user( $username ) ), array( 'username' => sanitize_user( $username ) ) ); } /** * Detect logins that bypassed the standard authenticate flow. * * Fires inside wp_set_auth_cookie(). In a normal login flow, * the `authenticate` filter runs first (setting $authenticate_called). * If this action fires without `authenticate` having run, a plugin * called wp_set_auth_cookie() directly — bypassing rate limiting. * * We count the login in the rate limiter and log a security event * so the IP's login counter stays accurate. We cannot retroactively * block the cookie (it's already being set), but subsequent requests * from the same IP will be properly rate-limited. * * @param string $logged_in_cookie The logged-in cookie value. * @param int $expire The time the login grace period expires as a UNIX timestamp. * @param int $expiration The time when the logged-in authentication cookie expires as a UNIX timestamp. * @param int $user_id User ID. * @param string $scheme Authentication scheme. * @param string $token User's session token. * * @return void * * @since 5.3.1 */ public static function on_set_logged_in_cookie( string $logged_in_cookie, int $expire, int $expiration, int $user_id, string $scheme, string $token ): void { if ( self::$authenticate_called ) { return; // Normal login flow — already tracked by check_login_rate_limit(). } $ip_hash = self::get_ip_hash(); if ( '' === $ip_hash ) { return; } // Increment login counter so rate limiting stays accurate for this IP. $now = time(); $login_window = (int) ( $now / self::LOGIN_WINDOW ); $login_counter = self::increment_counter( 'login_' . $ip_hash, $login_window ); $user = \get_userdata( $user_id ); $username = $user instanceof \WP_User ? $user->user_login : 'unknown'; // Check if this IP is already over the login rate limit. $login_limit = (int) ( Settings::get_option( 'security_login_rate_limit' ) ?: self::LOGIN_RATE_LIMIT ); $severity = Security_Events_Entity::SEVERITY_MEDIUM; if ( $login_counter > $login_limit ) { // IP exceeded the login limit through the bypass path — escalate. $severity = Security_Events_Entity::SEVERITY_HIGH; // Block the IP from further requests. $block_duration = (int) ( Settings::get_option( 'security_block_duration' ) ?: self::BLOCK_DURATION ); self::cache_set( 'block_' . $ip_hash, \wp_json_encode( array( 'until' => $now + $block_duration, 'reason' => 'cookie_login_bypass', ) ), $block_duration ); } self::queue_event( Security_Events_Entity::EVENT_USER_LOGIN, $severity, $ip_hash, sprintf( 'Cookie-based login bypassed standard authentication for user "%s" (#%d)', \sanitize_user( $username ), $user_id ), array( 'user_id' => $user_id, 'username' => \sanitize_user( $username ), 'source' => 'set_logged_in_cookie', 'bypass' => true, 'login_counter' => $login_counter, ) ); } /** * Rate limit XML-RPC requests. * * @param bool $enabled Whether XML-RPC is enabled. * * @return bool * * @since 5.2.0 */ public static function check_xmlrpc_rate_limit( $enabled ) { if ( ! $enabled ) { return $enabled; } $ip_hash = self::get_ip_hash(); if ( '' === $ip_hash ) { return $enabled; } $now = time(); $window = (int) ( $now / self::WINDOW_SECONDS ); $xmlrpc_counter = self::get_counter( 'xmlrpc_' . $ip_hash, $window ); // XML-RPC gets a tighter limit (half of normal). $xmlrpc_limit = max( 5, (int) ( ( Settings::get_option( 'security_rate_limit_per_minute' ) ?: self::DEFAULT_RATE_LIMIT ) / 2 ) ); if ( $xmlrpc_counter >= $xmlrpc_limit ) { self::queue_event( Security_Events_Entity::EVENT_DOS, Security_Events_Entity::SEVERITY_HIGH, $ip_hash, sprintf( 'XML-RPC rate limit exceeded: %d requests', $xmlrpc_counter ), array( 'endpoint' => 'xmlrpc' ) ); return false; } self::increment_counter( 'xmlrpc_' . $ip_hash, $window ); return $enabled; } /** * Rate limit REST API requests. * * @param mixed $result Response to replace the result with. * @param \WP_REST_Server $server Server instance. * @param \WP_REST_Request $request Request used to generate the response. * * @return mixed * * @since 5.2.0 */ public static function check_rest_rate_limit( $result, $server, $request ) { if ( null !== $result ) { return $result; } // Skip rate limiting for logged-in users with admin capabilities. if ( \current_user_can( 'manage_options' ) ) { return $result; } $ip_hash = self::get_ip_hash(); if ( '' === $ip_hash ) { return $result; } $now = time(); $window = (int) ( $now / self::WINDOW_SECONDS ); // REST API gets its own counter. $rest_counter = self::increment_counter( 'rest_' . $ip_hash, $window ); $rest_limit = (int) ( Settings::get_option( 'security_rate_limit_per_minute' ) ?: self::DEFAULT_RATE_LIMIT ); if ( $rest_counter > $rest_limit ) { self::queue_event( Security_Events_Entity::EVENT_DOS, Security_Events_Entity::SEVERITY_HIGH, $ip_hash, sprintf( 'REST API rate limit exceeded: %d requests', $rest_counter ), array( 'endpoint' => 'rest_api', 'route' => $request->get_route(), ) ); return new \WP_Error( 'advan_rate_limited', \__( 'Rate limit exceeded. Please slow down.', '0-day-analytics' ), array( 'status' => 429 ) ); } return $result; } /** * Get a securely hashed representation of the client IP. * * Never stores or logs the actual IP address. * * @return string SHA-256 hash of IP + salt, or empty string. * * @since 5.2.0 */ public static function get_ip_hash(): string { if ( null !== self::$cached_ip_hash ) { return self::$cached_ip_hash; } $ip = self::get_client_ip(); if ( '' === $ip ) { self::$cached_ip_hash = ''; return ''; } if ( '' === self::$hash_key ) { // Use WordPress auth salt for HMAC — unique per site, not guessable. self::$hash_key = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'advan-fallback-salt-' . md5( ABSPATH ); } self::$cached_ip_hash = hash_hmac( 'sha256', $ip, self::$hash_key ); return self::$cached_ip_hash; } /** * Get the client's IP address. * * Trusts only REMOTE_ADDR by default. Proxy headers can be spoofed * and are only trusted when explicitly configured. * * @return string IP address or empty string. * * @since 5.2.0 */ private static function get_client_ip(): string { // Only trust REMOTE_ADDR by default — proxy headers are spoofable. if ( Settings::get_option( 'security_trust_proxy_headers' ) ) { // When explicitly configured (e.g., behind Cloudflare/load balancer). $trusted_headers = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP' ); foreach ( $trusted_headers as $header ) { if ( ! empty( $_SERVER[ $header ] ) ) { $ip = \sanitize_text_field( \wp_unslash( $_SERVER[ $header ] ) ); // For X-Forwarded-For, take the first (leftmost = client) IP. if ( str_contains( $ip, ',' ) ) { $ip = trim( explode( ',', $ip )[0] ); } if ( false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return $ip; } } } } return isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } /** * Check if the current request is an internal loopback from the scanner. * * Validates the X-ADVAN-Internal-Check header against the stored token * to ensure only genuine internal requests are exempted. * * @return bool * * @since 5.3.2 */ private static function is_internal_loopback_request(): bool { $header_value = $_SERVER['HTTP_X_ADVAN_INTERNAL_CHECK'] ?? ''; if ( '' === $header_value ) { return false; } // Validate the token against the stored transient. $stored_token = \get_transient( 'advan_loopback_token' ); return ( false !== $stored_token && hash_equals( (string) $stored_token, $header_value ) ); } /** * Check if the current request appears to be from a logged-in admin user. * * Runs early (before full WP bootstrap), so we check for the presence * of a WordPress logged-in auth cookie on wp-admin requests. This is a * lightweight heuristic — the actual cookie is validated later by WordPress. * * @return bool * * @since 5.3.2 */ private static function is_authenticated_admin_request(): bool { // Only exempt wp-admin and admin-ajax requests. $request_uri = $_SERVER['REQUEST_URI'] ?? ''; if ( false === strpos( $request_uri, '/wp-admin' ) && false === strpos( $request_uri, 'admin-ajax.php' ) ) { return false; } // Check for the presence of a WordPress logged-in cookie. // Cookie name format: wordpress_logged_in_{hash}. foreach ( $_COOKIE as $name => $value ) { if ( 0 === strpos( $name, 'wordpress_logged_in_' ) && '' !== $value ) { return true; } } return false; } /** * Normalize a request URI for consistent logging and analysis. * * Handles percent-encoding, UTF-8/UTF-16 encoded characters, overlong * sequences, null bytes, and other encoding tricks used to bypass detection. * * @param string $uri Raw request URI from $_SERVER. * * @return string Normalized URI. * * @since 5.2.1 */ private static function normalize_request_uri( string $uri ): string { // 1. Strip null bytes (common bypass technique). $uri = str_replace( "\0", '', $uri ); // 2. Decode percent-encoded characters (recursively to catch double-encoding). $previous = ''; $max = 3; // Prevent infinite loops on malformed input. while ( $uri !== $previous && $max-- > 0 ) { $previous = $uri; $uri = rawurldecode( $uri ); } // 3. Handle UTF-16 BOM and encoded sequences. // Remove UTF-16 LE BOM (FF FE) and UTF-16 BE BOM (FE FF). $uri = str_replace( array( "\xFF\xFE", "\xFE\xFF" ), '', $uri ); // Convert common UTF-16 percent-encoded sequences to UTF-8. // Match %uXXXX (IIS/non-standard UTF-16 encoding). $uri = preg_replace_callback( '/%u([0-9A-Fa-f]{4})/', static function ( $matches ) { $codepoint = hexdec( $matches[1] ); // Convert Unicode codepoint to UTF-8. if ( $codepoint < 0x80 ) { return chr( $codepoint ); } elseif ( $codepoint < 0x800 ) { return chr( 0xC0 | ( $codepoint >> 6 ) ) . chr( 0x80 | ( $codepoint & 0x3F ) ); } else { return chr( 0xE0 | ( $codepoint >> 12 ) ) . chr( 0x80 | ( ( $codepoint >> 6 ) & 0x3F ) ) . chr( 0x80 | ( $codepoint & 0x3F ) ); } }, $uri ) ?? $uri; // 4. Remove overlong UTF-8 sequences (used for WAF bypass). // Overlong 2-byte encoding of ASCII (C0 80 - C1 BF range). $uri = preg_replace( '/[\xC0-\xC1][\x80-\xBF]/', '', $uri ) ?? $uri; // 5. Strip invalid UTF-8 sequences entirely. if ( function_exists( 'mb_convert_encoding' ) ) { $uri = mb_convert_encoding( $uri, 'UTF-8', 'UTF-8' ); } else { // Fallback: remove non-UTF-8 bytes. $uri = preg_replace( '/[\x80-\xFF](?![\x80-\xBF])/', '', $uri ) ?? $uri; } // 6. Remove control characters (except for path separators and query string markers). $uri = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $uri ) ?? $uri; // 7. Normalize backslashes to forward slashes (Windows path traversal). $uri = str_replace( '\\', '/', $uri ); // 8. Truncate excessively long URIs (prevent log flooding). if ( strlen( $uri ) > 2048 ) { $uri = substr( $uri, 0, 2048 ); } return $uri; } // ─── Cache abstraction (APCu → filesystem fallback) ───────────── /** * Check if APCu is available. * * @return bool * * @since 5.2.0 */ private static function has_apcu(): bool { if ( null === self::$has_apcu ) { self::$has_apcu = function_exists( 'apcu_enabled' ) && apcu_enabled(); } return self::$has_apcu; } /** * Get a value from cache. * * @param string $key Cache key. * * @return string|false Cached value or false. * * @since 5.2.0 */ private static function cache_get( string $key ) { $prefixed = 'advan_rl_' . $key; if ( self::has_apcu() ) { $success = false; $val = apcu_fetch( $prefixed, $success ); return $success ? (string) $val : false; } $file = self::get_storage_path() . DIRECTORY_SEPARATOR . md5( $prefixed ) . '.dat'; if ( ! file_exists( $file ) ) { return false; } $content = @file_get_contents( $file ); if ( false === $content ) { return false; } $data = json_decode( $content, true ); if ( ! is_array( $data ) || ! isset( $data['v'], $data['e'] ) ) { @unlink( $file ); return false; } // Check expiration. if ( $data['e'] > 0 && time() > $data['e'] ) { @unlink( $file ); return false; } return (string) $data['v']; } /** * Set a value in cache. * * @param string $key Cache key. * @param string $value Value to cache. * @param int $ttl Time to live in seconds (0 = no expiry). * * @return void * * @since 5.2.0 */ private static function cache_set( string $key, string $value, int $ttl = 0 ): void { $prefixed = 'advan_rl_' . $key; if ( self::has_apcu() ) { apcu_store( $prefixed, $value, $ttl ); return; } $dir = self::get_storage_path(); if ( ! self::ensure_storage_dir( $dir ) ) { return; } $file = $dir . DIRECTORY_SEPARATOR . md5( $prefixed ) . '.dat'; $expires = $ttl > 0 ? time() + $ttl : 0; $content = \wp_json_encode( array( 'v' => $value, 'e' => $expires, ) ); // Atomic write via temp file + rename. $tmp = $file . '.' . getmypid() . '.tmp'; if ( @file_put_contents( $tmp, $content, LOCK_EX ) !== false ) { @rename( $tmp, $file ); } } /** * Delete a cache key. * * @param string $key Cache key. * * @return void * * @since 5.2.0 */ private static function cache_delete( string $key ): void { $prefixed = 'advan_rl_' . $key; if ( self::has_apcu() ) { apcu_delete( $prefixed ); return; } $file = self::get_storage_path() . DIRECTORY_SEPARATOR . md5( $prefixed ) . '.dat'; if ( file_exists( $file ) ) { @unlink( $file ); } } /** * Increment a counter for rate limiting. * * @param string $key Counter key (IP hash based). * @param int $window Time window identifier. * @param string $directory Optional prepared storage directory; no module initialization needed. * * @return int New counter value. * * @since 5.2.0 */ public static function increment_counter( string $key, int $window, string $directory = '' ): int { $cache_key = $key . '_' . $window; $prefixed = 'advan_rl_' . $cache_key; if ( self::has_apcu() ) { $success = false; $val = apcu_inc( $prefixed, 1, $success ); if ( ! $success ) { // Another worker may have initialized it after our failed increment. if ( apcu_add( $prefixed, 1, self::WINDOW_SECONDS * 2 ) ) { return 1; } $val = apcu_inc( $prefixed, 1, $success ); return $success ? (int) $val : 0; } return (int) $val; } // A supplied directory is already prepared by the full runtime (SHORTINIT safe). $dir = '' !== $directory ? $directory : self::get_storage_path(); if ( '' !== $directory ? ! is_dir( $dir ) : ! self::ensure_storage_dir( $dir ) ) { return 0; } $file = $dir . DIRECTORY_SEPARATOR . md5( $prefixed ) . '.cnt'; $fp = @fopen( $file, 'c+' ); if ( ! $fp ) { return 0; } if ( ! flock( $fp, LOCK_EX ) ) { fclose( $fp ); return 0; } $content = fread( $fp, 64 ); $count = ( false !== $content && '' !== $content ) ? (int) $content + 1 : 1; ftruncate( $fp, 0 ); rewind( $fp ); $written = fwrite( $fp, (string) $count ); fflush( $fp ); flock( $fp, LOCK_UN ); fclose( $fp ); return strlen( (string) $count ) === $written ? $count : 0; } /** * Get the current counter value without incrementing. * * @param string $key Counter key. * @param int $window Time window identifier. * @param string $directory Optional prepared storage directory; no module initialization needed. * * @return int Current counter value. * * @since 5.2.0 */ private static function get_counter( string $key, int $window ): int { $cache_key = $key . '_' . $window; $prefixed = 'advan_rl_' . $cache_key; if ( self::has_apcu() ) { $success = false; $val = apcu_fetch( $prefixed, $success ); return $success ? (int) $val : 0; } $file = self::get_storage_path() . DIRECTORY_SEPARATOR . md5( $prefixed ) . '.cnt'; if ( ! file_exists( $file ) ) { return 0; } $content = @file_get_contents( $file ); return ( false !== $content && '' !== $content ) ? (int) $content : 0; } // ─── Storage management ───────────────────────────────────────── /** * Get storage directory path. * * @return string * * @since 5.2.0 */ private static function get_storage_path(): string { if ( '' === self::$storage_path ) { $upload_dir = \wp_upload_dir(); self::$storage_path = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . self::STORAGE_DIR; } return self::$storage_path; } /** * Ensure storage directory exists and is protected. * * @param string $dir Directory path. * * @return bool * * @since 5.2.0 */ private static function ensure_storage_dir( string $dir ): bool { if ( is_dir( $dir ) ) { return true; } if ( ! \wp_mkdir_p( $dir ) ) { return false; } // Protect directory from web access. $htaccess = $dir . DIRECTORY_SEPARATOR . '.htaccess'; if ( ! file_exists( $htaccess ) ) { @file_put_contents( $htaccess, "Deny from all\n" ); } $index = $dir . DIRECTORY_SEPARATOR . 'index.php'; if ( ! file_exists( $index ) ) { @file_put_contents( $index, " $event_type, 'severity' => $severity, 'ip_hash' => $ip_hash, 'description' => $description, 'metadata' => $metadata, ); } /** * Flush pending events to the database. * * Called at shutdown to avoid blocking the response. * Uses event aggregation to merge repeated events from the same IP * into a single row with an incremented counter. * * @return void * * @since 5.2.0 */ public static function flush_pending_events(): void { if ( empty( self::$pending_events ) ) { return; } $events = self::$pending_events; self::$pending_events = array(); foreach ( $events as $event ) { Security_Events_Entity::log_or_aggregate_event( $event['event_type'], $event['severity'], $event['ip_hash'], $event['description'], $event['metadata'] ); } } // ─── Cron management ──────────────────────────────────────────── /** * Schedule the cleanup cron if not already scheduled. * * @return void * * @since 5.2.0 */ private static function schedule_cron(): void { if ( ! \wp_next_scheduled( self::CRON_CLEANUP_HOOK ) ) { \wp_schedule_event( time(), 'hourly', self::CRON_CLEANUP_HOOK ); } } /** * Add custom cron schedules. * * @param array $schedules Existing schedules. * * @return array * * @since 5.2.0 */ public static function add_cron_schedules( array $schedules ): array { // Reuse WP's built-in hourly schedule. No custom schedule needed. return $schedules; } /** * Unschedule the cleanup cron. * * @return void * * @since 5.2.0 */ public static function unschedule_cron(): void { $timestamp = \wp_next_scheduled( self::CRON_CLEANUP_HOOK ); if ( $timestamp ) { \wp_unschedule_event( $timestamp, self::CRON_CLEANUP_HOOK ); } } /** * Clean up stale rate limit files. * * Files older than 2× the window duration are removed. * * @return void * * @since 5.2.0 */ public static function cleanup_stale_files(): void { $dir = self::get_storage_path(); if ( ! is_dir( $dir ) ) { return; } $cutoff = time() - ( self::WINDOW_SECONDS * 10 ); $files = @scandir( $dir ); if ( ! is_array( $files ) ) { return; } foreach ( $files as $file ) { if ( '.' === $file || '..' === $file || '.htaccess' === $file || 'index.php' === $file ) { continue; } $filepath = $dir . DIRECTORY_SEPARATOR . $file; if ( is_file( $filepath ) && filemtime( $filepath ) < $cutoff ) { @unlink( $filepath ); } } // Also prune old security events. $retention = (int) ( Settings::get_option( 'security_event_retention_days' ) ?: 90 ); Security_Events_Entity::prune( $retention ); } } }