$settings * @return array */ public function collect(array $settings): array { $max_files = isset($settings['scan_limits']['max_files']) ? (int) $settings['scan_limits']['max_files'] : 5000; $max_files = max(200, min(200000, $max_files)); $max_seconds = isset($settings['scan_limits']['max_seconds']) ? (float) $settings['scan_limits']['max_seconds'] : 4.0; $max_seconds = max(0.5, min(20.0, $max_seconds)); // By default we skip uploads in filesystem scan to preserve budget for code/config files. $include_uploads = !empty($settings['scan_limits']['fs_include_uploads']); // Scanning the full site root on shared hosting can be very heavy. // Prefer scanning content-related roots (+ uploads/plugins/themes) by default. $roots = []; $plugins_root = function_exists('aetos_seo_plugins_root') ? aetos_seo_plugins_root() : ''; if ($plugins_root !== '' && is_dir($plugins_root)) { $roots['PLUGINS'] = $plugins_root; $content_root = dirname($plugins_root); if (basename($plugins_root) === 'plugins' && is_dir($content_root)) { $roots['CONTENT'] = $content_root; } } if (function_exists('get_theme_root')) { $tr = (string) get_theme_root(); if ($tr !== '') { $roots['THEME_ROOT'] = $tr; } } if ($include_uploads && function_exists('wp_upload_dir')) { $u = (array) wp_upload_dir(null, false); $basedir = isset($u['basedir']) ? (string) $u['basedir'] : ''; if ($basedir !== '') { $roots['UPLOADS'] = $basedir; } } // Allow integrators to override scan roots. /** @var array $roots */ $roots = (array) apply_filters('aetos_seo_fs_scan_roots', $roots); // Fallback if filters removed everything. if (empty($roots) && function_exists('aetos_seo_site_root_path')) { $site_root = aetos_seo_site_root_path(); if ($site_root !== '') { $roots = ['SITE_ROOT' => $site_root]; } } // Deduplicate + validate roots. $roots_clean = []; foreach ($roots as $label => $path) { $p = is_string($path) ? $path : ''; if ($p === '' || !is_dir($p)) { continue; } $rp = realpath($p); if (!is_string($rp) || $rp === '') { continue; } $roots_clean[(string) $label] = $rp; } if (empty($roots_clean) && function_exists('aetos_seo_site_root_path')) { $site_root = aetos_seo_site_root_path(); if ($site_root !== '') { $roots_clean = ['SITE_ROOT' => $site_root]; } } $root = reset($roots_clean); if (!is_string($root) || $root === '') { $root = function_exists('aetos_seo_site_root_path') ? aetos_seo_site_root_path() : ''; } $scanned = 0; $total_size = 0; $ext_counts = []; $risky = []; $large = []; $risky_total = 0; // v3.3.52 P2.5 — true count; $risky display-capped at 80. $large_total = 0; // v3.3.52 P2.5 — true count; $large display-capped at 30. // Bug fix v1.0.55: separate bucket for active debug logs (debug.log, debug.log.old, // debug.log.1, etc). Previously these were misclassified as "backup artifacts" by // the .old extension rule, producing a high-severity false positive even though // they're a normal WordPress operational concern (and the real issue is size). $log_files = []; $start = microtime(true); $skip_dir_names = [ '.git', '.svn', 'node_modules', 'vendor', 'cache', 'caches', 'wflogs', 'wordfence-waf', 'ai1wm-backups', 'updraft', 'updraftplus', 'backups', ]; if (!$include_uploads) { $skip_dir_names[] = 'uploads'; } /** @var array $skip_dir_names */ $skip_dir_names = (array) apply_filters('aetos_seo_fs_skip_dir_names', $skip_dir_names); $skip_dir_names_l = array_map('strtolower', $skip_dir_names); foreach ($roots_clean as $label => $scan_root) { try { $dirIt = new \RecursiveDirectoryIterator($scan_root, \FilesystemIterator::SKIP_DOTS); $filter = new \RecursiveCallbackFilterIterator($dirIt, function ($current, $key, $iterator) use ($skip_dir_names_l) { if (!$current instanceof \SplFileInfo) { return false; } // Avoid symlink loops. if ($current->isLink()) { return false; } if ($current->isDir()) { $name = strtolower((string) $current->getFilename()); if (in_array($name, $skip_dir_names_l, true)) { return false; } } return true; }); $it = new \RecursiveIteratorIterator($filter, \RecursiveIteratorIterator::SELF_FIRST); foreach ($it as $file) { if (!$file instanceof \SplFileInfo) { continue; } if (!$file->isFile()) { continue; } // Time budget (avoid long scans on shared hosting). if ((microtime(true) - $start) > $max_seconds) { break 2; } $scanned++; $path = $file->getPathname(); $rel = ltrim(str_replace($scan_root, '', $path), '/\\'); $rel = $label . ':' . $rel; $size = (int) $file->getSize(); $total_size += $size; $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($ext === '') { $ext = '(none)'; } if (!isset($ext_counts[$ext])) { $ext_counts[$ext] = 0; } $ext_counts[$ext]++; $lower = strtolower($rel); // Risky artifact heuristic (filename/ext only; no content read). // Goal: detect real backup/dump artifacts while avoiding false positives // from normal plugin/theme source files (e.g. class-import-export.php). $risky_exts = ['sql', 'bak', 'bkp', 'old', 'backup', 'dump']; $archive_exts = ['zip', 'tar', 'tgz', 'gz', 'bz2', '7z', 'rar']; $safe_exts = [ 'php', 'phtml', 'phar', 'inc', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp', 'css', 'js', 'map', 'json', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'mp4', 'mp3', 'avi', 'mov', 'wav', 'ogg', 'webm', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'log', 'xml', 'csv' ]; $whitelist_patterns = [ '/imagify-tmp/', '/cache/', '/tmp/', '/temp/', '/wflogs/', // Never flag Aetos's OWN bundled reference doc. It ships as // docs/database-optimizations.sql inside the plugin folder // (Lite 'aetos-seo' OR Pro 'aetos-seo'). Match the bare // filename — a substring match that holds for either edition, // either path separator (the FsCollector emits `PLUGINS:slug/…` // with a colon, not a slash), and any future rename. Before // this, only the Lite `/plugins/aetos-seo/…` slash-path was // excluded, so every Pro install flagged its own shipped file // as the #1 high-severity "move your backups out of webroot" // security risk (a self-accusing false positive). 'database-optimizations.sql', 'copyright.png', 'readme.txt', 'license.txt', ]; $is_risky = false; $ext_raw = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $base = strtolower(pathinfo($path, PATHINFO_BASENAME)); $lower_norm = str_replace('\\', '/', $lower); // Bug fix v1.0.55 + v1.0.56: detect WordPress active debug logs // (debug.log, debug.log.old, debug.log.1, error_log, // php_errors.log, ...) and route them to the dedicated // $log_files bucket instead of treating them as backup artifacts. // // v1.0.56: the previous regex only accepted `/` or BOL as a // separator, but FsCollector emits paths in the form // `CONTENT:debug.log.old` (label + colon). Match either // separator or simple basename via $base. $is_active_debug_log = (bool) preg_match( '~(?:^|[/:])(debug\.log(?:\.[a-z0-9]+)?|error_log|php_errors?\.log|wp-errors?\.log)$~i', $lower_norm ) || (bool) preg_match( '~^(debug\.log(?:\.[a-z0-9]+)?|error_log|php_errors?\.log|wp-errors?\.log)$~i', $base ); if ($is_active_debug_log) { if (count($log_files) < 30) { $log_files[] = ['path' => $rel, 'size_bytes' => $size]; } // Still track size in extension counts / total below, but skip // the risky/large evaluation to prevent backup false positives. if ($scanned >= $max_files) { break 2; } continue; } $is_whitelisted = false; foreach ($whitelist_patterns as $pattern) { if (stripos($lower_norm, $pattern) !== false) { $is_whitelisted = true; break; } } // v3.3.54 — recognise the label-prefixed path format the // collector actually emits. Files scanned under the PLUGINS or // THEME_ROOT roots arrive as `plugins:slug/…` / `theme_root:theme/…` // (label + colon, NOT `:plugins/`), so the substring checks below // alone never matched a direct plugin/theme scan. That made // `$is_code_path` always false for those roots, and every plugin // or theme that ships a `.sql`/`.bak`/`.dump` got mislabeled as a // backup artifact (the same class of self-accusing false positive // that flagged Aetos's own database-optimizations.sql). Use the // in-scope $label so code roots are identified reliably. $is_code_path = in_array($label, ['PLUGINS', 'THEME_ROOT'], true) || (strpos($lower_norm, '/plugins/') !== false) || (strpos($lower_norm, ':plugins/') !== false) || (strpos($lower_norm, '/themes/') !== false) || (strpos($lower_norm, ':themes/') !== false) || (strpos($lower_norm, '/wp-includes/') !== false) || (strpos($lower_norm, ':wp-includes/') !== false) || (strpos($lower_norm, '/wp-admin/') !== false) || (strpos($lower_norm, ':wp-admin/') !== false); $is_backup_dir_hint = preg_match('~/(?:backups?|ai1wm-backups|updraft(?:plus)?|snapshots?|dumps?)/~i', $lower_norm) === 1; $size_large_enough = $size >= (128 * 1024); if (!$is_whitelisted) { if ($ext_raw !== '' && in_array($ext_raw, $safe_exts, true)) { $is_risky = false; } elseif ($ext_raw !== '' && in_array($ext_raw, $risky_exts, true)) { if ($ext_raw === 'old') { $is_risky = (preg_match('~(?:\.log\.old|backup|dump|sql)~i', $base) === 1) || (!$is_code_path && $size_large_enough); } elseif ($ext_raw === 'sql' || $ext_raw === 'dump') { $is_risky = $is_backup_dir_hint || !$is_code_path || $size_large_enough; } else { $is_risky = $is_backup_dir_hint || !$is_code_path || $size_large_enough; } } elseif ($ext_raw !== '' && in_array($ext_raw, $archive_exts, true)) { $has_backup_keyword = preg_match('~(?:^|[._-])(backup|dump|database|db(?:[-_]?backup)?|sql(?:[-_]?dump|[-_]?backup)|snapshot)(?:[._-]|$)~i', $base) === 1; if ($has_backup_keyword && ($size_large_enough || $is_backup_dir_hint || !$is_code_path)) { $is_risky = true; } } elseif ($ext_raw === '') { $has_backup_keyword = preg_match('~(?:^|[._-])(backup|dump|database|snapshot)(?:[._-]|$)~i', $base) === 1; if ($has_backup_keyword && ($size_large_enough || $is_backup_dir_hint)) { $is_risky = true; } } } if ($is_risky) { $risky_total++; if (count($risky) < 80) { $risky[] = ['path' => $rel, 'size_bytes' => $size]; } } if ($size > 50 * 1024 * 1024) { $large_total++; if (count($large) < 30) { $large[] = ['path' => $rel, 'size_bytes' => $size]; } } if ($scanned >= $max_files) { break 2; } } } catch (\Throwable $e) { // Best effort. } } arsort($ext_counts); $ext_counts = array_slice($ext_counts, 0, 25, true); // Permission flags (best effort). $perm_flags = []; $uploads = function_exists('wp_upload_dir') ? (array) wp_upload_dir(null, false) : []; $paths = [ 'SITE_ROOT' => function_exists('aetos_seo_site_root_path') ? aetos_seo_site_root_path() : '', 'PLUGINS' => function_exists('aetos_seo_plugins_root') ? aetos_seo_plugins_root() : '', 'UPLOADS' => isset($uploads['basedir']) ? (string) $uploads['basedir'] : '', ]; foreach ($paths as $label => $p) { if (!$p || !file_exists($p)) { continue; } $perms = @fileperms($p); if ($perms === false) { continue; } $mode = $perms & 0777; $perm_flags[$label] = [ 'path' => $p, 'mode_octal' => decoct($mode), 'world_writable' => (bool) ($mode & 0x0002), ]; } // Sort log files by size desc so the biggest one is always sample[0]. if (!empty($log_files)) { usort($log_files, static function ($a, $b) { return ((int) ($b['size_bytes'] ?? 0)) <=> ((int) ($a['size_bytes'] ?? 0)); }); } return [ 'fs_root' => $root, 'fs_roots' => $roots_clean, 'scanned_files' => $scanned, 'scan_limit' => $max_files, 'scan_time_limit_seconds' => $max_seconds, 'total_size_bytes_scanned' => $total_size, 'extension_counts_top25' => $ext_counts, 'risky_artifacts' => $risky, 'risky_artifacts_total' => $risky_total, // v3.3.52 P2.5 — uncapped true count. 'large_files' => $large, 'large_files_total' => $large_total, // v3.3.52 P2.5 — uncapped true count. // New in v1.0.55 — see SecurityCheck::ops_oversized_debug_log 'log_files' => $log_files, 'permission_flags' => $perm_flags, 'duration_ms' => (int) ((microtime(true) - $start) * 1000), ]; } }