($p['enabled'] ?? false) === true,'generation'=>(int)($p['generation'] ?? 0)]; } public static function stop(): void { wp_clear_scheduled_hook('antivirus_ai_exposure_daily'); wp_clear_scheduled_hook('antivirus_ai_exposure_tick'); } public static function setEnabled(bool $enabled): void { $store = new Store(); $token = $store->lock('exposure_permission',30); if (!$token) { throw new \RuntimeException('Exposure preferences are busy. Retry shortly.'); } try { $old = self::permission(); Store::saveOption(self::OPTION,['enabled'=>$enabled,'generation'=>$old['generation']+1]); self::stop(); if ($enabled && !wp_schedule_event(time()+DAY_IN_SECONDS,'daily','antivirus_ai_exposure_daily')) { throw new \RuntimeException('Permission saved, but the daily check could not be scheduled. Use Check now and inspect WP-Cron.'); } } finally { $store->unlock('exposure_permission',$token); } } public static function origin(): string { $url = rtrim(site_url('/'),'/'); $p = PinnedHttp::url($url); if (isset($p['query']) || preg_match('~(?:^|/)(?:\.|\.\.)(?:/|$)~',$p['path'])) { throw new \RuntimeException('The configured WordPress address is not a canonical HTTPS installation URL.'); } return $url; } public static function inventory(string $root): array { $out = []; foreach (self::PATHS as $relative) { $path = rtrim($root,'/') . '/' . $relative; if (!file_exists($path) && !is_link($path)) { continue; } $out[] = $relative; } return $out; } public static function prefix(string $root,string $relative): array { if (!in_array($relative,self::PATHS,true)) { throw new \RuntimeException('This check accepts only its documented candidate filenames.'); } $path = PathGuard::resolve($root,$relative); $before = lstat($path); if (($before['mode'] & 0170000) !== 0100000 || $before['size'] < 64) { throw new \RuntimeException('The local file is too short or unsupported for a reliable comparison.'); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read only a bounded prefix from the validated local inode; preserve the before/after file identity checks. $f = @fopen($path,'rb'); if (!$f) { throw new \RuntimeException('The candidate file could not be read.'); } try { $opened = fstat($f); if ($opened['ino'] !== $before['ino'] || $opened['dev'] !== $before['dev'] || $opened['nlink'] !== 1) { throw new \RuntimeException('The candidate file changed before reading.'); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Read only a bounded prefix from the validated local inode; preserve the before/after file identity checks. $prefix = fread($f,self::PREFIX_BYTES); clearstatcache(true,$path); $after = lstat($path); if (!is_string($prefix) || strlen($prefix) < 64 || !$after || array_intersect_key($opened,array_flip(['ino','dev','size','mtime','ctime'])) !== array_intersect_key($after,array_flip(['ino','dev','size','mtime','ctime']))) { throw new \RuntimeException('The candidate changed during inspection.'); } PathGuard::resolve($root,$relative); return ['prefix'=>$prefix,'identity'=>hash('sha256',json_encode([$opened['ino'],$opened['dev'],$opened['size'],$opened['mtime'],$opened['ctime'],hash('sha256',$prefix)],JSON_THROW_ON_ERROR))]; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Read only a bounded prefix from the validated local inode; preserve the before/after file identity checks. } finally { fclose($f); } } public static function classify(string $prefix,array $response): string { $encoding = strtolower($response['headers']['content-encoding'] ?? 'identity'); // A denial status cannot make returned sensitive bytes private. Misconfigured // error handlers, redirects and caches can still expose the actual file body. $plain = $encoding === '' || $encoding === 'identity'; if ($plain && strlen($prefix) >= 64 && strlen($response['body']) >= strlen($prefix) && hash_equals(hash('sha256',$prefix),hash('sha256',substr($response['body'],0,strlen($prefix))))) { return 'publicly_readable'; } if (!$plain) { return 'unconfirmed'; } return in_array($response['status'],[401,403,404,410],true) ? 'denied_at_check' : 'unconfirmed'; } public static function start(): void { $p = self::permission(); if (!$p['enabled']) { return; } $store = new Store(); $token = $store->lock('exposure_job',45); if (!$token) { return; } try { $old = $store->get(self::JOB); if ($old && $old['state'] === 'running' && ($old['generation'] ?? -1) === $p['generation']) { return; } if ($old && time()-(int)$old['started'] < 300 && ($old['generation'] ?? -1) === $p['generation']) { throw new \RuntimeException('Exposure checks are limited to one new run every five minutes. Resume the existing run.'); } $origin = self::origin(); $p2 = self::permission(); if ($p2 !== $p) { return; } $paths = self::inventory(InstallationPaths::wordpress()); $consent = $store->lock('exposure_permission',30); if (!$consent) { return; } try { if (self::permission() !== $p) { return; } $store->put('exposure_check',self::JOB,['state'=>$paths?'running':'complete','generation'=>$p['generation'],'origin'=>$origin,'paths'=>$paths,'cursor'=>0,'results'=>[],'started'=>time(),'updated'=>time(),'scope'=>'Selected documented filenames under the WordPress installation only. Other directories, names, cache states and authenticated access are not covered.'], $paths?'running':'complete',$old['_revision'] ?? null); if ($paths) { wp_schedule_single_event(time()+1,'antivirus_ai_exposure_tick'); } } finally { $store->unlock('exposure_permission',$consent); } } finally { $store->unlock('exposure_job',$token); } } public static function tick(): void { $store = new Store(); $token = $store->lock('exposure_job',45); if (!$token) { return; } try { $p = self::permission(); $job = $store->get(self::JOB); if (!$p['enabled'] || !$job || $job['state'] !== 'running' || $job['generation'] !== $p['generation']) { return; } if ($job['origin'] !== self::origin()) { throw new \RuntimeException('The configured installation address changed. Start a new check.'); } $root = InstallationPaths::wordpress(); $limit = min(count($job['paths']),$job['cursor']+2); while ($job['cursor'] < $limit) { if (self::permission() !== $p) { return; } $relative = $job['paths'][$job['cursor']]; $state = 'unavailable'; try { $before = self::prefix($root,$relative); $url = $job['origin'] . '/' . implode('/',array_map('rawurlencode',explode('/',$relative))); $response = PinnedHttp::get($url,self::PREFIX_BYTES,['Range'=>'bytes=0-511']); $after = self::prefix($root,$relative); $state = hash_equals($before['identity'],$after['identity']) ? self::classify($before['prefix'],$response) : 'changed_during_check'; } catch (\Throwable $error) { $state = 'unavailable'; } unset($before,$after,$response); $job['results'][] = ['path'=>$relative,'status'=>$state,'checked'=>time()]; $job['cursor']++; } $consent = $store->lock('exposure_permission',30); if (!$consent) { return; } try { if (self::permission() !== $p) { return; } $job['state'] = $job['cursor'] >= count($job['paths']) ? 'complete' : 'running'; $job['updated'] = time(); $store->put('exposure_check',self::JOB,$job,$job['state'],$job['_revision']); if ($job['state'] === 'running') { wp_schedule_single_event(time()+10,'antivirus_ai_exposure_tick'); } } finally { $store->unlock('exposure_permission',$consent); } } catch (\Throwable $error) { // Existing progress remains incomplete; raw transport/file errors are never published. } finally { $store->unlock('exposure_job',$token); } } public static function handle(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) && strlen( $_SERVER['REQUEST_METHOD'] ) <= 16 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( 'POST' !== $method || $method !== wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) { wp_die( 'A POST request is required.', '', array( 'response' => 403 ) ); } if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Administrator access required.', '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only typed, bounded nonce/action inputs are read before the explicit action-bound nonce verification below; no operation is dispatched before it succeeds. if ( ! isset( $_POST['_avai_nonce'] ) || ! is_string( $_POST['_avai_nonce'] ) || strlen( $_POST['_avai_nonce'] ) > 20 ) { wp_die( 'Refresh this form and try again.', '', array( 'response' => 403 ) ); } $nonce = sanitize_text_field( wp_unslash( $_POST['_avai_nonce'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( $nonce !== wp_unslash( $_POST['_avai_nonce'] ) || ! preg_match( '/^[a-zA-Z0-9]{1,20}$/D', $nonce ) ) { wp_die( 'Refresh this form and try again.', '', array( 'response' => 403 ) ); } if ( ! wp_verify_nonce( $nonce, 'antivirus_ai_exposure' ) ) { wp_die( esc_html__( 'Refresh this form and try again.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. $action = isset( $_POST['exposure_action'] ) && is_string( $_POST['exposure_action'] ) && strlen( $_POST['exposure_action'] ) <= 16 ? sanitize_key( wp_unslash( $_POST['exposure_action'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( '' !== $action && $action !== wp_unslash( $_POST['exposure_action'] ) ) { $action = ''; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. $permission = isset( $_POST['permission'] ) && is_string( $_POST['permission'] ) && strlen( $_POST['permission'] ) === 1 ? sanitize_key( wp_unslash( $_POST['permission'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. $complete = isset( $_POST['complete'] ) && is_string( $_POST['complete'] ) && strlen( $_POST['complete'] ) === 3 ? sanitize_key( wp_unslash( $_POST['complete'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( '' !== $complete && $complete !== wp_unslash( $_POST['complete'] ) ) { $complete = ''; } $kind = 'success'; try { if ($action === 'enable') { if ($permission !== '1' || $complete !== 'yes') { throw new \RuntimeException('Review and explicitly allow these same-site checks.'); } self::setEnabled(true); $message = 'Daily exposure checks enabled. Check now to inspect the selected candidate files.'; } elseif ($action === 'disable') { self::setEnabled(false); $message = 'New requests stopped. Previous results remain available; in-flight responses cannot be committed.'; } elseif ($action === 'run' && self::permission()['enabled']) { self::start(); self::tick(); $message = 'Exposure check started or resumed. Incomplete and unavailable checks remain visible.'; } else { throw new \RuntimeException('Choose an available exposure action.'); } } catch (\Throwable $error) { $kind = 'error'; $message = $error->getMessage(); } set_transient('antivirus_ai_notice_'.get_current_user_id(),['kind'=>$kind,'message'=>$message],120); wp_safe_redirect(Admin::url('protection').'#avai-exposure'); exit; } private static function form(string $action,string $label,bool $permission = false): void { echo '
'; } public static function render(): void { $p = self::permission(); $job = (new Store())->get(self::JOB); echo 'Check whether selected local configuration copies, logs and database exports can be read by a visitor. These checks require a public HTTPS WordPress address and PHP cURL. Private staging addresses are intentionally unsupported.
Requests: '.($p['enabled']?'allowed':'off').'
'; if ($p['enabled']) { self::form('run','Check now / resume'); self::form('disable','Stop exposure checks'); } else { self::form('enable','Allow daily exposure checks',true); } if ($job) { $current = $job['generation'] === $p['generation']; echo 'Latest run: '.esc_html($job['state']).' · '.(int)$job['cursor'].' / '.count($job['paths']).' candidates checked'.($current?'':' · retained from an earlier permission state').'
'.esc_html($job['scope']).'
'; if ($job['state'] === 'running' && time()-(int)$job['updated'] > 300) { echo 'This run has not progressed recently. Resume it and review the host scheduler.
'; } if (!$job['paths']) { echo 'No selected candidate files were found locally. This does not establish that the site has no exposed files.
'; } foreach ($job['results'] as $r) { echo ''.esc_html($r['path']).' · '.esc_html(str_replace('_',' ',$r['status'])).'
For publicly readable files, retain a private copy and ask your host to block direct access and clear any cached copy. Recheck the URL afterward. Denied means denied at the recorded check; redirects, different bodies and failed requests remain unconfirmed. No files or host configuration are changed automatically.
'; } echo ''.esc_html(implode(', ',self::PATHS)).'
Custom content directories outside the WordPress installation, arbitrary backup names, authentication-dependent leaks and larger file content are outside this check. Results contain relative paths, status and time only.