*/ public function collect(): array { $theme = wp_get_theme(); $plugins = []; $active = (array) get_option('active_plugins', []); $active_lookup = array_fill_keys(array_map('strval', $active), true); $network_active = []; if (function_exists('is_multisite') && is_multisite() && function_exists('get_site_option')) { $network_active = (array) get_site_option('active_sitewide_plugins', []); } $all = function_exists('aetos_seo_get_plugins') ? aetos_seo_get_plugins() : []; $installed_plugins = []; foreach ($all as $pf => $plugin_data) { $pf = (string) $pf; $is_active = isset($active_lookup[$pf]) || isset($network_active[$pf]); $plugin_row = [ 'file' => $pf, 'name' => (string) ($plugin_data['Name'] ?? $pf), 'version' => (string) ($plugin_data['Version'] ?? ''), 'author' => (string) ($plugin_data['AuthorName'] ?? $plugin_data['Author'] ?? ''), 'plugin_uri' => (string) ($plugin_data['PluginURI'] ?? ''), 'network_active' => isset($network_active[$pf]), 'active' => $is_active, ]; $installed_plugins[] = $plugin_row; if (!$is_active) { continue; } $plugins[] = [ 'file' => $pf, 'name' => $plugin_row['name'], 'version' => $plugin_row['version'], 'network_active' => $plugin_row['network_active'], ]; } $constants = [ 'WP_DEBUG' => defined('WP_DEBUG') ? (bool) WP_DEBUG : null, 'WP_DEBUG_LOG' => defined('WP_DEBUG_LOG') ? (bool) WP_DEBUG_LOG : null, 'WP_DEBUG_DISPLAY' => defined('WP_DEBUG_DISPLAY') ? (bool) WP_DEBUG_DISPLAY : null, 'WP_CACHE' => defined('WP_CACHE') ? (bool) WP_CACHE : null, 'SCRIPT_DEBUG' => defined('SCRIPT_DEBUG') ? (bool) SCRIPT_DEBUG : null, 'DISABLE_WP_CRON' => defined('DISABLE_WP_CRON') ? (bool) DISABLE_WP_CRON : null, ]; // Bug fix v1.0.55: parse wp-config.php as a fallback so SecurityCheck can // detect WP_DEBUG / DISALLOW_FILE_EDIT / etc. even when the constant is // defined late, redefined elsewhere, or overridden by a drop-in. Runtime // values still win when present (the wp-config parse only fills in nulls). $wp_config_state = $this->parse_wp_config(); foreach ($wp_config_state['constants'] as $name => $value) { if (!array_key_exists($name, $constants) || $constants[$name] === null) { $constants[$name] = $value; } } // v1.0.57: detect server software + CDN by issuing a HEAD request to home_url // and inspecting response headers. This makes the bot context export aware // of the underlying infrastructure (Apache vs Nginx vs LiteSpeed, Cloudflare // vs SiteGround CDN, etc.). $infra = $this->detect_infrastructure(); // PHP EOL status (mirrors EnvironmentCheck logic so it's available in // site_snapshot.environment for the bot context bundle). $php_eol = $this->compute_php_eol_status(PHP_VERSION); return [ 'site' => [ 'home_url' => home_url('/'), 'site_url' => site_url('/'), 'name' => get_bloginfo('name'), 'description' => get_bloginfo('description'), 'language' => get_locale(), 'is_multisite' => is_multisite(), 'permalink_structure' => (string) get_option('permalink_structure'), ], 'env' => [ 'wp_version' => get_bloginfo('version'), 'php_version' => PHP_VERSION, 'php_eol' => $php_eol, 'memory_limit' => (string) ini_get('memory_limit'), ], 'infrastructure' => $infra, 'theme' => [ 'name' => $theme->get('Name'), 'version' => $theme->get('Version'), 'stylesheet' => $theme->get_stylesheet(), 'template' => $theme->get_template(), 'is_child' => $theme->parent() ? true : false, ], 'plugins' => $plugins, 'installed_plugins' => $installed_plugins, 'plugins_inventory' => [ 'installed_total' => count($installed_plugins), 'active_total' => count($plugins), 'inactive_total' => max(0, count($installed_plugins) - count($plugins)), ], 'constants' => $constants, 'empty_terms' => $this->empty_public_terms(), 'wp_config' => [ 'path' => $wp_config_state['path'], 'parsed' => $wp_config_state['path'] !== '', 'extra_constants' => $wp_config_state['extra_constants'], ], ]; } /** * 4.0.18: public category and tag terms that have no published posts but * still resolve to a public archive page. The default category is skipped * because WordPress requires it to exist. * * @return array */ private function empty_public_terms(): array { if (!function_exists('get_terms') || !function_exists('get_taxonomies')) { return []; } $taxonomies = get_taxonomies(['public' => true, 'publicly_queryable' => true], 'names'); $taxonomies = array_values(array_intersect((array) $taxonomies, ['category', 'post_tag', 'product_cat', 'product_tag'])); if (empty($taxonomies)) { return []; } $terms = get_terms([ 'taxonomy' => $taxonomies, 'hide_empty' => false, 'number' => 200, 'fields' => 'all', ]); if (!is_array($terms)) { return []; } $default_category = (int) get_option('default_category'); $out = []; foreach ($terms as $term) { if (!($term instanceof \WP_Term) || (int) $term->count > 0) { continue; } if ($term->taxonomy === 'category' && (int) $term->term_id === $default_category) { continue; } $link = get_term_link($term); $out[] = [ 'taxonomy' => (string) $term->taxonomy, 'name' => (string) $term->name, 'url' => is_string($link) ? $link : '', ]; if (count($out) >= 50) { break; } } return $out; } /** * Detect server software (Apache/Nginx/LiteSpeed/etc.) and CDN (Cloudflare/ * SG-CDN/BunnyCDN/etc.) by issuing a HEAD request to the home URL and reading * response headers. Best-effort and silent on failure. * * @return array */ private function detect_infrastructure(): array { $result = [ 'server_software' => '', 'powered_by' => '', 'cdn_detected' => '', // v3.3.52 P1.3 — split true edge CDN from origin cache/web server. 'edge_cdn_detected' => '', 'origin_cache_or_server_detected' => '', 'cdn_signals' => [], 'http_protocol' => '', 'detected_at' => function_exists('current_time') ? current_time('mysql') : gmdate('Y-m-d H:i:s'), ]; if (!function_exists('wp_remote_head') || !function_exists('home_url')) { return $result; } $url = home_url('/'); $resp = wp_remote_head($url, [ 'timeout' => 6, 'sslverify' => false, // Same-site only: this is a loopback HEAD to our own home URL for // origin/CDN header detection. Do not auto-follow redirects off-site. 'redirection' => 0, 'user-agent' => \AetosSeo\Utils\Http::user_agent(), 'aetos_seo' => 1, // mark as an internal first-party request ]); if (is_wp_error($resp)) { return $result; } $headers = wp_remote_retrieve_headers($resp); if (!is_object($headers) && !is_array($headers)) { return $result; } // Normalize header access (Requests Response_Headers vs assoc array) $get_header = static function ($key) use ($headers): string { $key_l = strtolower((string) $key); if (is_object($headers) && method_exists($headers, 'offsetGet')) { $v = $headers->offsetGet($key_l); if (is_array($v)) { return (string) end($v); } return (string) $v; } if (is_array($headers)) { $lower = array_change_key_case($headers, CASE_LOWER); if (isset($lower[$key_l])) { $v = $lower[$key_l]; return is_array($v) ? (string) end($v) : (string) $v; } } return ''; }; // Server software $server = $get_header('server'); if ($server !== '') { $result['server_software'] = $server; } // X-Powered-By (often shows PHP version + sometimes the CMS or accelerator) $powered = $get_header('x-powered-by'); if ($powered !== '') { $result['powered_by'] = $powered; } // CDN / cache detection by header presence. // v3.3.52 P1.3 — distinguish a true EDGE CDN (the response actually // went through an off-origin proxy POP) from an ORIGIN cache / web // server (LiteSpeed, Nginx FastCGI cache, Varnish at origin). The // security/CSP checks apply a "loopback bypassed the edge" guard ONLY // for a real edge CDN — an origin server like LiteSpeed is hit directly // by the loopback, so a security header it omits IS a genuine finding, // not a bypass artefact. Treating LiteSpeed Server as a CDN used to // silently suppress real security findings. $cdn_signals = []; $cdn_name = ''; $edge_name = ''; $origin_name = ''; // header => [label, type]: 'edge' = off-origin CDN POP, // 'origin' = origin cache/server, // '' = generic/ambiguous (recorded only). $checks = [ 'cf-ray' => ['Cloudflare', 'edge'], 'cf-cache-status' => ['Cloudflare', 'edge'], 'x-sg-cdn' => ['SiteGround CDN', 'edge'], 'x-served-by' => ['Fastly', 'edge'], 'x-bunnycdn-cache' => ['BunnyCDN', 'edge'], 'x-bunnycdn-pop' => ['BunnyCDN', 'edge'], 'x-quic-cloud' => ['QUIC.cloud', 'edge'], 'x-pantheon-styx-hostname' => ['Pantheon', 'edge'], 'x-kinsta-cache' => ['Kinsta CDN', 'edge'], 'x-litespeed-cache' => ['LiteSpeed Cache', 'origin'], 'x-nginx-cache' => ['Nginx FastCGI Cache', 'origin'], 'x-cache-handler' => ['', 'origin'], // Cloudways origin cache 'x-cache' => ['', ''], // generic — ambiguous ]; foreach ($checks as $hdr => $meta) { $value = $get_header($hdr); if ($value === '') { continue; } $label = (string) $meta[0]; $type = (string) $meta[1]; $cdn_signals[$hdr] = substr($value, 0, 80); if ($cdn_name === '' && $label !== '') { $cdn_name = $label; } if ($type === 'edge' && $edge_name === '' && $label !== '') { $edge_name = $label; } if ($type === 'origin' && $origin_name === '' && $label !== '') { $origin_name = $label; } } // Server-header heuristics. LiteSpeed here = origin web server (NOT a // CDN). Cloudflare in the Server header = response proxied through the // edge. if (stripos($server, 'litespeed') !== false) { if ($cdn_name === '') { $cdn_name = 'LiteSpeed Server'; } if ($origin_name === '') { $origin_name = 'LiteSpeed Server'; } } if (stripos($server, 'cloudflare') !== false) { if ($cdn_name === '') { $cdn_name = 'Cloudflare'; } if ($edge_name === '') { $edge_name = 'Cloudflare'; } } $result['cdn_detected'] = $cdn_name; // backward-compat (display). $result['edge_cdn_detected'] = $edge_name; // real off-origin CDN only. $result['origin_cache_or_server_detected'] = $origin_name; // origin cache / web server. $result['cdn_signals'] = $cdn_signals; // HTTP protocol version (HTTP/1.1, HTTP/2, HTTP/3) // wp_remote_head doesn't expose this directly, but it's in the raw response // headers sometimes via 'alt-svc' for HTTP/3 hints. $alt_svc = $get_header('alt-svc'); if ($alt_svc !== '') { if (stripos($alt_svc, 'h3') !== false) { $result['http_protocol'] = 'HTTP/3 (advertised via alt-svc)'; } elseif (stripos($alt_svc, 'h2') !== false) { $result['http_protocol'] = 'HTTP/2'; } } return $result; } /** * @return array{version:string,major_minor:string,eol_date:string,is_eol:bool,years_since_eol:int} */ private function compute_php_eol_status(string $php_version): array { $eol_dates = [ '5.6' => '2018-12-31', '7.0' => '2019-01-10', '7.1' => '2019-12-01', '7.2' => '2020-11-30', '7.3' => '2021-12-06', '7.4' => '2022-11-28', '8.0' => '2023-11-26', '8.1' => '2025-12-31', '8.2' => '2026-12-31', '8.3' => '2027-12-31', '8.4' => '2028-12-31', ]; $major_minor = ''; if (preg_match('/^(\d+\.\d+)/', $php_version, $m)) { $major_minor = $m[1]; } $eol_date = $eol_dates[$major_minor] ?? ''; $is_eol = false; $years_since_eol = 0; if ($eol_date !== '') { $eol_ts = strtotime($eol_date); if ($eol_ts && time() > $eol_ts) { $is_eol = true; $years_since_eol = (int) floor((time() - $eol_ts) / (365 * 86400)); } } return [ 'version' => $php_version, 'major_minor' => $major_minor, 'eol_date' => $eol_date, 'is_eol' => $is_eol, 'years_since_eol' => $years_since_eol, ]; } /** * Locate wp-config.php and parse selected define() / const statements. * * Reads file content with file_get_contents (no eval) and uses safe regex. * Used as a fallback for runtime constants and to surface deployment-time * security flags such as DISALLOW_FILE_EDIT / WP_AUTO_UPDATE_CORE. * * @return array{path:string, constants:array, extra_constants:array} */ private function parse_wp_config(): array { $result = ['path' => '', 'constants' => [], 'extra_constants' => []]; // Locate wp-config.php — usually one level above the site root or directly in it. $candidates = []; $root = function_exists('aetos_seo_site_root_path') ? aetos_seo_site_root_path() : ''; if ($root !== '') { $candidates[] = rtrim($root, '\\/') . '/wp-config.php'; $candidates[] = dirname(rtrim($root, '\\/')) . '/wp-config.php'; } $path = ''; foreach ($candidates as $candidate) { if (is_string($candidate) && $candidate !== '' && @is_readable($candidate)) { $path = $candidate; break; } } if ($path === '') { return $result; } $contents = @file_get_contents($path); if (!is_string($contents) || $contents === '') { return $result; } // Strip line comments and block comments to avoid false positives. $stripped = preg_replace('!/\*.*?\*/!s', '', $contents); if (!is_string($stripped)) { $stripped = $contents; } $stripped = preg_replace('!(^|[^:])//.*$!m', '$1', $stripped); if (!is_string($stripped)) { $stripped = $contents; } $result['path'] = $path; // Constants we care about for SecurityCheck / EnvironmentCheck. $watched = [ 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'WP_CACHE', 'SCRIPT_DEBUG', 'DISABLE_WP_CRON', 'DISALLOW_FILE_EDIT', 'DISALLOW_FILE_MODS', 'WP_AUTO_UPDATE_CORE', 'FORCE_SSL_ADMIN', 'AUTOMATIC_UPDATER_DISABLED', 'WP_POST_REVISIONS', 'EMPTY_TRASH_DAYS', 'WP_MEMORY_LIMIT', 'WP_MAX_MEMORY_LIMIT', ]; foreach ($watched as $name) { $value = $this->extract_define_value($stripped, $name); if ($value === null) { continue; } $result['constants'][$name] = $value; } // Detect WP_DEBUG_LOG path (string form points at custom debug log location). $log_path = $this->extract_define_value($stripped, 'WP_DEBUG_LOG'); if (is_string($log_path) && $log_path !== '') { $result['extra_constants']['WP_DEBUG_LOG_path'] = $log_path; } return $result; } /** * Extract a defined constant value from a wp-config snippet. * * Supports `define( 'NAME', true|false|123|'string' );` and * `const NAME = ...;`. Returns null when not present so callers can * distinguish "absent" from "explicitly false". * * @return mixed */ private function extract_define_value(string $contents, string $name) { $name_q = preg_quote($name, '/'); // define( 'NAME', value ); $pattern = '/define\s*\(\s*[\'"]' . $name_q . '[\'"]\s*,\s*([^)]+?)\s*\)\s*;/i'; if (preg_match($pattern, $contents, $m)) { return $this->normalize_define_value($m[1]); } // const NAME = value; $pattern = '/\bconst\s+' . $name_q . '\s*=\s*([^;]+?)\s*;/i'; if (preg_match($pattern, $contents, $m)) { return $this->normalize_define_value($m[1]); } return null; } /** * Normalize a raw PHP literal captured from wp-config into a PHP value. * * @return mixed */ private function normalize_define_value(string $raw) { $raw = trim($raw); if ($raw === '') { return ''; } $lower = strtolower($raw); if ($lower === 'true') return true; if ($lower === 'false') return false; if ($lower === 'null') return null; // Quoted string. if ((strlen($raw) >= 2) && (($raw[0] === "'" && substr($raw, -1) === "'") || ($raw[0] === '"' && substr($raw, -1) === '"')) ) { return substr($raw, 1, -1); } // Numeric (handle 1024*1024*32 style memory limits by leaving raw). if (preg_match('/^-?\d+$/', $raw)) { return (int) $raw; } if (preg_match('/^-?\d+\.\d+$/', $raw)) { return (float) $raw; } // Anything else (math, function calls) — return raw text so the bot can read it. return $raw; } }