get('wp', []); $constants = isset($wp['constants']) && is_array($wp['constants']) ? $wp['constants'] : []; $wp_debug = !empty($constants['WP_DEBUG']); if ($wp_debug) { // 4.0.18: say only what was detected. The production-risk // wording and severity need a production environment type, a // public host and errors that can reach visitors. $environment = function_exists('wp_get_environment_type') ? (string) wp_get_environment_type() : 'production'; $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $is_local_host = in_array($host, ['localhost', '127.0.0.1', '::1'], true) || (bool) preg_match('/\.(local|test|localhost|invalid|example)$/', $host); $shows_errors = !array_key_exists('WP_DEBUG_DISPLAY', $constants) || $constants['WP_DEBUG_DISPLAY'] === null || !empty($constants['WP_DEBUG_DISPLAY']); if ('production' === $environment && !$is_local_host && $shows_errors) { $debug_title = I18n::tr('WordPress debug mode is on and can show errors to visitors', 'وضع التصحيح في ووردبريس مفعّل وقد يعرض الأخطاء للزوار'); $debug_severity = 'high'; $debug_impact = -8; } elseif ('production' === $environment && !$is_local_host) { $debug_title = I18n::tr('WordPress debug mode is on (errors are logged, not shown)', 'وضع التصحيح في ووردبريس مفعّل (تُسجَّل الأخطاء ولا تُعرض)'); $debug_severity = 'low'; $debug_impact = -2; } else { $debug_title = I18n::tr('WordPress debug mode is on (turn it off before the site goes live)', 'وضع التصحيح في ووردبريس مفعّل (عطّله قبل إطلاق الموقع)'); $debug_severity = 'info'; $debug_impact = 0; } $findings[] = $this->finding( 'sec_wp_debug_enabled', $debug_title, 'Security', $debug_severity, $debug_impact, [ 'source' => 'options', 'details' => I18n::tr('WP_DEBUG=true (from runtime constants)', 'وضع التصحيح مفعّل (WP_DEBUG=true)'), 'samples' => [ 'WP_DEBUG' => $constants['WP_DEBUG'], 'WP_DEBUG_LOG' => $constants['WP_DEBUG_LOG'] ?? null, 'WP_DEBUG_DISPLAY' => $constants['WP_DEBUG_DISPLAY'] ?? null, ], ], I18n::tr('It can show visitors error messages with sensitive details about the site, which raises the risk of attack.', 'قد يعرض للزوار رسائل أخطاء فيها معلومات حساسة عن الموقع، وهذا يزيد مخاطر الاختراق.'), [ I18n::tr('Disable WP_DEBUG in production.', 'عطّل وضع التصحيح في ملف wp-config.php: اجعل WP_DEBUG يساوي false.'), I18n::tr('If you need logging: use WP_DEBUG_LOG only, with the log files protected.', 'إن احتجت تسجيل الأخطاء فاستخدم WP_DEBUG_LOG وحده، واحمِ ملف السجل من الوصول العام.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => 'WP_DEBUG=false', ], $this->id() ); } // v3.3.48 — X-Powered-By / Server header information disclosure. // Round-3 review (Claude) noted that the live response leaks // `X-Powered-By: PHP/8.x.x` which exposes PHP version to attackers // looking for known-vulnerable PHP releases. Same for verbose Server // headers (e.g. `nginx/1.18.0 (Ubuntu)`). Both are easily silenced. $http = (array) $ctx->get('http', []); $http_headers = isset($http['headers']) && is_array($http['headers']) ? $http['headers'] : []; $norm_http_headers = []; foreach ($http_headers as $k => $v) { $norm_http_headers[strtolower((string) $k)] = (string) $v; } $x_powered_by = (string) ($norm_http_headers['x-powered-by'] ?? ''); $server_hdr = (string) ($norm_http_headers['server'] ?? ''); $server_verbose = ($server_hdr !== '' && preg_match('~/\d+\.\d+~', $server_hdr) === 1); if ($x_powered_by !== '' || $server_verbose) { $samples = []; if ($x_powered_by !== '') { $samples[] = ['header' => 'X-Powered-By', 'value' => $x_powered_by]; } if ($server_verbose) { $samples[] = ['header' => 'Server', 'value' => $server_hdr]; } $findings[] = $this->finding( 'sec_information_disclosure_headers', I18n::tr('HTTP headers expose the PHP/server version (X-Powered-By / Server)', 'رؤوس HTTP تكشف إصدار PHP أو الخادم (X-Powered-By / Server)'), 'Security', 'low', -2, [ 'source' => 'http', 'details' => I18n::tr('The site sends informational headers about the runtime stack — attackers use them to look for known vulnerabilities in the announced version.', 'يرسل الموقع ترويسات تكشف إصدارات برامج الخادم، ويستخدمها المهاجمون للبحث عن ثغرات معروفة في هذه الإصدارات.'), 'samples' => $samples, ], I18n::tr('X-Powered-By: PHP/x.y.z + Server: nginx/x.y give the attacker the exact version. If there is a CVE in that version, the site becomes a direct target instead of needing fingerprinting.', 'ترويسات مثل X-Powered-By: PHP/x.y.z وServer: nginx/x.y تكشف الإصدار بدقة. وإذا كانت في هذا الإصدار ثغرة معروفة يصبح الموقع هدفًا مباشرًا.'), [ I18n::tr('Hide X-Powered-By: in php.ini set `expose_php = Off` (requires restarting PHP-FPM).', 'أخفِ ترويسة X-Powered-By: ضع `expose_php = Off` في ملف php.ini (يحتاج إعادة تشغيل PHP).'), I18n::tr('In .htaccess (Apache): `Header unset X-Powered-By`', 'في ملف .htaccess على خادم Apache: `Header unset X-Powered-By`'), I18n::tr('In nginx: `more_clear_headers \'X-Powered-By\' \'Server\';` (requires ngx_headers_more), or `proxy_hide_header X-Powered-By;` behind a reverse proxy.', 'في خادم nginx: `more_clear_headers \'X-Powered-By\' \'Server\';` (يحتاج وحدة headers_more)، أو `proxy_hide_header X-Powered-By;` خلف الوكيل العكسي.'), I18n::tr('In WordPress: in an mu-plugin add: `add_action(\'send_headers\', function(){ header_remove(\'X-Powered-By\'); });`', 'في ووردبريس ضع في إضافة أساسية (mu-plugin): `add_action(\'send_headers\', function(){ header_remove(\'X-Powered-By\'); });`'), I18n::tr('The verbose Server header (nginx/1.18.0): show a name without a version number via server_tokens off in the nginx config.', 'ترويسة Server التي تعرض الإصدار (مثل nginx/1.18.0): اجعلها تعرض الاسم دون رقم الإصدار بخيار server_tokens off في إعدادات nginx.'), ], [ 'how' => I18n::tr('After the change: `curl -sI https://example.com` — there should be no X-Powered-By and a Server with no version number.', 'بعد التعديل شغّل `curl -sI https://example.com`، ويجب ألا تظهر X-Powered-By وأن تظهر Server دون رقم إصدار.'), 'pass_condition' => I18n::tr('No X-Powered-By header, and the Server header has no version.', 'لا توجد ترويسة X-Powered-By، وترويسة Server بلا رقم إصدار.'), ], $this->id() ); } // xmlrpc accessibility. $xmlrpc = (array) $ctx->get('xmlrpc', []); $x_status = (int) ($xmlrpc['status_code'] ?? 0); $x_body = (string) ($xmlrpc['body_sample'] ?? ''); // v3.3.52 P1.4 — xmlrpc.php is only "accessible" when it actually // responds AS an XML-RPC endpoint: HTTP 200 or 405 (GET returns // "XML-RPC server accepts POST requests only.") WITH the XML-RPC text. // A 401/403/404/410 means a security plugin / WAF / removal is BLOCKING // it — and those block pages frequently still contain the words // "xml-rpc" in their message, which used to trigger a false "accessible" // finding (the old guard accepted any status < 500). $xmlrpc_has_text = stripos($x_body, 'xml-rpc') !== false || stripos($x_body, 'xmlrpc') !== false; $xmlrpc_accessible = in_array($x_status, [200, 405], true) && $xmlrpc_has_text; $xmlrpc_blocked = in_array($x_status, [401, 403, 404, 410], true); if ($xmlrpc_accessible) { $findings[] = $this->finding( 'sec_xmlrpc_accessible', I18n::tr('xmlrpc.php looks reachable (estimate)', 'ملف xmlrpc.php يبدو متاحًا (تقدير)'), 'Security', 'med', -4, [ 'source' => 'http', 'details' => sprintf(I18n::tr('xmlrpc.php answered with HTTP status %s.', 'أعاد الملف xmlrpc.php رمز الحالة %s.'), (string) $x_status), 'snippet' => $ctx->maybe_snippet($x_body, 500), ], I18n::tr('xmlrpc can be targeted by brute-force/pingback attempts in some cases.', 'ملف xmlrpc.php قد يُستهدف بمحاولات تخمين كلمات المرور أو بطلبات الإشارات (pingback).'), [ I18n::tr('If you do not need it: disable XML-RPC via a security plugin or code.', 'إن لم تكن تحتاجه فعطّل XML-RPC من إضافة الأمان أو بشيفرة بسيطة.'), I18n::tr('Keep rate limiting and block pingbacks if you do not need them.', 'حدّد عدد المحاولات وامنع طلبات الإشارات إن لم تكن تحتاجها.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('xmlrpc.php is unavailable or disabled by your policy.', 'ملف xmlrpc.php غير متاح أو معطّل حسب سياسة الموقع.'), ], $this->id() ); } elseif ($xmlrpc_blocked) { // Reassurance finding — we checked and it is blocked. info/0, no // penalty, so the user sees the endpoint is handled, not silence. $findings[] = $this->finding( 'sec_xmlrpc_blocked', sprintf(I18n::tr('xmlrpc.php blocked (HTTP %d) — good', 'xmlrpc.php محظور (HTTP %d) — تمام'), $x_status), 'Security', 'info', 0, [ 'source' => 'http', 'details' => sprintf(I18n::tr('A GET request to xmlrpc.php returned %d — meaning a security plugin/WAF or the server blocks access. No action needed.', 'أعاد طلب ملف xmlrpc.php الحالة %d، أي أن إضافة الأمان أو جدار الحماية أو الخادم يمنع الوصول إليه. لا يلزم أي إجراء.'), $x_status), 'status' => $x_status, ], I18n::tr('Blocking access to xmlrpc.php reduces the attack surface (brute force / pingback). This state is reassuring.', 'منع الوصول إلى xmlrpc.php يقلل سطح الهجوم (brute force وpingback) وهذه الحالة مطمئنة.'), [ I18n::tr('No action needed. The current setup is safe.', 'لا يلزم أي إجراء، والإعداد الحالي آمن.'), ], [ 'how' => I18n::tr('For your information only.', 'للعلم فقط.'), 'pass_condition' => I18n::tr('Nothing to check.', 'لا يوجد ما يُفحص.'), ], $this->id() ); } // Risky artifacts (backups, sql dumps, zips) - based on file names only. $fs = (array) $ctx->get('fs', []); $artifacts = isset($fs['risky_artifacts']) && is_array($fs['risky_artifacts']) ? $fs['risky_artifacts'] : []; // v3.3.52 P2.5 — uncapped true count (falls back to the capped list for // older fs payloads that predate the *_total field). $artifacts_total = (int) ($fs['risky_artifacts_total'] ?? count($artifacts)); if (!empty($artifacts)) { $findings[] = $this->finding( 'sec_backup_artifacts_found', I18n::tr('Files resembling backups/dumps detected within the site files (without opening the content)', 'تم رصد ملفات تُشبه نسخ احتياطية/دَمْب داخل ملفات الموقع (بدون فتح المحتوى)'), 'Security', 'high', -10, [ 'source' => 'filesystem', // v3.3.52 P2.5 — prefer FsCollector's uncapped true total; // the stored list itself is display-capped at 80. 'details' => I18n::tr('Found risky artifacts by filename/ext. (No content read). count=', 'رُصدت ملفات خطرة من اسمها أو امتدادها دون قراءة محتواها. العدد=') . $artifacts_total, 'affected_count' => $artifacts_total, 'shown_in_samples' => min(20, count($artifacts)), 'sample_limit' => 20, 'sampled_only' => $artifacts_total > count($artifacts), 'samples' => array_slice($artifacts, 0, 20), ], I18n::tr('Backup files or SQL dumps, if inside the webroot, can leak or be downloaded by an attacker.', 'ملفات النسخ الاحتياطية أو نسخ قاعدة البيانات داخل مجلد الموقع العام قد يحمّلها أي شخص يعرف اسمها.'), [ I18n::tr('Move backups outside the webroot.', 'انقل النسخ الاحتياطية خارج webroot.'), I18n::tr('Make sure access to these files is blocked at the server (Nginx/Apache) if any exist.', 'تأكد من حظر الوصول لهذه الملفات عبر السيرفر (Nginx/Apache) إن وجدت.'), I18n::tr('Delete unnecessary files safely (manually).', 'احذف الملفات غير اللازمة بأمان (يدويًا).'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('No backup/dump files within the scannable site paths.', 'لا توجد ملفات backup/dump داخل مسارات الموقع القابلة للفحص.'), ], $this->id() ); } // ── Oversized active debug logs (NEW in v1.0.55) ───────────────────── // Replaces the old false-positive that classified `debug.log.old` as a backup // artifact. Now we surface actual operational concerns: // - any debug log >= 100 MB → high severity (eats disk + leaks data) // - any debug log >= 10 MB → med severity (rotation needed) $log_files = isset($fs['log_files']) && is_array($fs['log_files']) ? $fs['log_files'] : []; if (!empty($log_files)) { $very_large = []; // >= 100 MB $large_logs = []; // >= 10 MB foreach ($log_files as $lf) { if (!is_array($lf)) { continue; } $size = (int) ($lf['size_bytes'] ?? 0); if ($size >= 100 * 1024 * 1024) { $very_large[] = $lf; } elseif ($size >= 10 * 1024 * 1024) { $large_logs[] = $lf; } } if (!empty($very_large)) { $biggest = (int) ($very_large[0]['size_bytes'] ?? 0); $biggest_mb = (int) round($biggest / (1024 * 1024)); $findings[] = $this->finding( 'ops_oversized_debug_log', I18n::tr('A very large debug log is eating server space', 'ملف سجل الأخطاء ضخم جدًا ويستهلك مساحة الخادم'), 'Security', 'high', -8, [ 'source' => 'filesystem', 'details' => sprintf( 'Found %d oversized debug log file(s). Biggest is ~%d MB.', count($very_large), $biggest_mb ), 'samples' => array_slice($very_large, 0, 10), ], I18n::tr('A debug.log reaching hundreds of megabytes eats space, slows I/O, and may contain error messages exposing database queries or sensitive paths. Its presence indicates WP_DEBUG_LOG was enabled for a long time without rotation.', 'ملف debug.log الذي يصل إلى مئات الميجابايت يستهلك المساحة ويبطئ الخادم، وقد يحتوي رسائل تكشف استعلامات قاعدة البيانات أو مسارات حساسة. وجوده يعني أن تسجيل الأخطاء بقي مفعّلًا مدة طويلة دون تدوير.'), [ I18n::tr('Open wp-content/ via FTP or File Manager and delete the files listed in samples (or empty them with "truncate" instead of deleting the file itself).', 'افتح مجلد wp-content من مدير الملفات أو عبر FTP، واحذف الملفات المذكورة في القائمة أو أفرغ محتواها.'), I18n::tr('If you still need debugging: in wp-config.php set WP_DEBUG_LOG to a custom path outside the webroot, or enable rotation to a monthly file inside a non-public logs folder.', 'إذا كنت ما زلت تحتاج تسجيل الأخطاء فاجعل ملف السجل خارج المجلد العام للموقع، وفعّل تدويره شهريًا.'), I18n::tr('If the site is production: disable WP_DEBUG_LOG entirely: define(\'WP_DEBUG_LOG\', false);', 'إذا كان الموقع يعمل للزوار فعطّل تسجيل الأخطاء تمامًا: define(\'WP_DEBUG_LOG\', false);'), I18n::tr('After deleting, confirm the space was freed in cPanel → Disk Usage.', 'بعد الحذف تأكد من عودة المساحة من لوحة الاستضافة.'), ], [ 'how' => I18n::tr('Run the audit again after cleanup and check that the log files are gone.', 'شغّل فحصًا جديدًا بعد التنظيف.'), 'pass_condition' => I18n::tr('No log file of 100 MB or more.', 'لا يوجد ملف سجل بحجم 100 ميجابايت أو أكثر.'), ], $this->id() ); } elseif (!empty($large_logs)) { $biggest = (int) ($large_logs[0]['size_bytes'] ?? 0); $biggest_mb = (int) round($biggest / (1024 * 1024)); $findings[] = $this->finding( 'ops_large_debug_log', I18n::tr('A large debug log needs rotation', 'ملف سجل الأخطاء كبير ويحتاج إلى تدوير'), 'Security', 'med', -3, [ 'source' => 'filesystem', 'details' => sprintf( 'Found %d large debug log file(s). Biggest is ~%d MB.', count($large_logs), $biggest_mb ), 'samples' => array_slice($large_logs, 0, 10), ], I18n::tr('A debug log reaching tens of megabytes without rotation gradually eats space. Not urgent, but it should be handled.', 'ملف السجل الذي يصل إلى عشرات الميجابايت دون تدوير يستهلك المساحة تدريجيًا. ليس أمرًا عاجلًا لكنه يحتاج إلى معالجة.'), [ I18n::tr('Delete or truncate the files listed in samples.', 'احذف الملفات المذكورة في القائمة أو أفرغ محتواها.'), I18n::tr('Enable log rotation at the server level (logrotate) or via a custom monthly WP_DEBUG_LOG path.', 'فعّل تدوير السجلات على الخادم أو اجعل ملف السجل شهريًا.'), ], [ 'how' => I18n::tr('Run the audit after cleanup.', 'شغّل فحصًا جديدًا بعد التنظيف.'), 'pass_condition' => I18n::tr('No log file of 10 MB or more.', 'لا يوجد ملف سجل بحجم 10 ميجابايت أو أكثر.'), ], $this->id() ); } } // Permissions (best effort). // FsCollector returns permission_flags keyed by label (SITE_ROOT/PLUGINS/UPLOADS...), each entry containing // {path, mode_octal, world_writable}. Older builds expected permission_flags.world_writable as a list. // Support both shapes. $perm_flags = isset($fs['permission_flags']) && is_array($fs['permission_flags']) ? $fs['permission_flags'] : []; $world_writable = []; // Shape A (old/expected): permission_flags.world_writable => [ ... ] if (isset($perm_flags['world_writable']) && is_array($perm_flags['world_writable'])) { $world_writable = $perm_flags['world_writable']; } else { // Shape B (current): permission_flags => [ label => [world_writable=>bool, path=>...], ... ] foreach ($perm_flags as $label => $info) { if (!is_array($info)) { continue; } if (!empty($info['world_writable'])) { $world_writable[] = [ 'label' => (string) $label, 'path' => (string) ($info['path'] ?? ''), 'mode_octal' => (string) ($info['mode_octal'] ?? ''), ]; } } } if (!empty($world_writable)) { $findings[] = $this->finding( 'sec_world_writable_paths', I18n::tr('Folders any server user can write to (estimate)', 'مجلدات يستطيع أي مستخدم على الخادم الكتابة فيها (تقدير)'), 'Security', 'med', -5, [ 'source' => 'filesystem', 'details' => I18n::tr('The file permissions let any user on the server write here.', 'صلاحيات الملفات تسمح لأي مستخدم على الخادم بالكتابة هنا.'), 'samples' => Redactor::redact_array($world_writable), ], I18n::tr('777 or world-writable permissions increase the risk of file injection or unauthorized modification.', 'الصلاحية 777 أو الكتابة المفتوحة لأي مستخدم تزيد خطر زرع ملفات ضارة أو تعديل الموقع دون إذن.'), [ I18n::tr('Set permissions to safe values (usually 755 for folders and 644 for files).', 'اضبط الصلاحيات إلى قيم آمنة (عادة 755 للمجلدات و 644 للملفات).'), I18n::tr('Make sure the file owner is correct for the user running PHP.', 'تأكد من مالك الملفات (owner) صحيح للمستخدم الذي يشغّل PHP.'), ], [ 'how' => I18n::tr('Run a new audit, then open this finding again.', 'شغّل فحصًا جديدًا ثم افتح هذه النتيجة مرة أخرى.'), 'pass_condition' => I18n::tr('No file or folder is writable by everyone.', 'قائمة world_writable فارغة.'), ], $this->id() ); } // ── SSL Certificate expiry check ────────────────────────────────────── $scheme = wp_parse_url((string) home_url(), PHP_URL_SCHEME); if ($scheme === 'https') { $host = (string) (wp_parse_url((string) home_url(), PHP_URL_HOST) ?? ''); if ($host !== '') { $cert_info = $this->check_ssl_expiry($host); if ($cert_info['days_remaining'] !== null) { if ($cert_info['days_remaining'] < 0) { $findings[] = $this->finding( 'sec_ssl_certificate_expired', I18n::tr('SSL certificate expired', 'شهادة SSL منتهية الصلاحية'), 'Security', 'high', -15, [ 'source' => 'ssl', 'details' => sprintf(I18n::tr('The SSL certificate expired on %s.', 'انتهت شهادة SSL في %s.'), (string) $cert_info['valid_to']), 'days_remaining' => $cert_info['days_remaining'], 'valid_to' => $cert_info['valid_to'], ], I18n::tr('An expired SSL certificate shows users a security warning and blocks them from accessing. Google lowers the ranking of sites with expired certificates.', 'شهادة SSL منتهية تُظهر للمستخدمين تحذير أمان وتمنعهم من الوصول. Google تُخفّض ترتيب المواقع بشهادات منتهية.'), [ I18n::tr('Renew the SSL certificate immediately from your host or via Let\'s Encrypt (free).', 'جدّد شهادة SSL فورًا من الاستضافة أو مجانًا عبر Let\'s Encrypt.'), I18n::tr('Enable auto-renew if available.', 'فعّل التجديد التلقائي إن كان متاحًا.'), ], [ 'how' => I18n::tr('Run the audit after renewing the certificate.', 'شغّل فحصًا جديدًا بعد تجديد الشهادة.'), 'pass_condition' => 'days_remaining > 30', ], $this->id() ); } elseif ($cert_info['days_remaining'] <= 30) { $findings[] = $this->finding( 'sec_ssl_certificate_expiring_soon', I18n::tr('SSL certificate expiring soon (less than 30 days)', 'شهادة SSL ستنتهي قريباً (أقل من 30 يوم)'), 'Security', 'high', -8, [ 'source' => 'ssl', 'details' => sprintf(I18n::tr('The SSL certificate expires on %2$s (days left: %1$d).', 'تنتهي شهادة SSL في %2$s (الأيام المتبقية: %1$d).'), (int) $cert_info['days_remaining'], (string) $cert_info['valid_to']), 'days_remaining' => $cert_info['days_remaining'], 'valid_to' => $cert_info['valid_to'], ], I18n::tr('SSL certificate expiry shows users a warning and stops access to the site.', 'انتهاء شهادة SSL يُظهر تحذير للمستخدمين ويوقف الوصول للموقع.'), [ I18n::tr('Renew the certificate now.', 'جدّد الشهادة الآن.'), I18n::tr('Enable auto-renew to avoid this problem in the future.', 'فعّل التجديد التلقائي حتى لا تتكرر المشكلة.'), ], [ 'how' => I18n::tr('Run the audit after renewing.', 'شغّل فحصًا جديدًا بعد التجديد.'), 'pass_condition' => 'days_remaining > 30', ], $this->id() ); } } } } // v2.3.0 — REST API users enumeration exposure (anonymous wp/v2/users route) $rest_exposure = $this->check_rest_users_exposure(); if (is_array($rest_exposure) && (int) ($rest_exposure['exposed_count'] ?? 0) > 0) { $findings[] = $this->finding( 'sec_rest_users_enumeration_exposed', sprintf(I18n::tr('Site usernames are visible to anyone (%d)', 'أسماء مستخدمي الموقع ظاهرة لأي زائر (%d)'), (int) $rest_exposure['exposed_count']), 'Security', 'high', -7, [ 'source' => 'http', 'details' => I18n::tr('An anonymous GET against the REST wp/v2/users route returned a list of users. The slug + name are available without authentication. These usernames can be used in brute-force against /wp-login.php.', 'طلب بلا تسجيل دخول إلى المسار wp/v2/users أعاد قائمة المستخدمين بأسمائهم، وقد تُستخدم هذه الأسماء في محاولات تخمين كلمات المرور على صفحة الدخول.'), 'samples' => $rest_exposure['samples'], 'endpoint' => $rest_exposure['endpoint'], ], I18n::tr('Exposing usernames greatly lowers the cost of a brute-force attack. The attacker then needs only the password (not username + password). One of the most common silent leaks in WordPress.', 'معرفة اسم المستخدم تسهّل الاختراق كثيرًا، لأن المهاجم لا يحتاج بعدها إلا كلمة المرور. وهذا من أكثر التسريبات الصامتة شيوعًا في ووردبريس.'), [ I18n::tr('Add this snippet to functions.php (or an mu-plugin):', 'أضف هذا المقطع في ملف functions.php أو في إضافة أساسية (mu-plugin):'), 'add_filter("rest_endpoints", function($e){ if (isset($e["/wp/v2/users"])) unset($e["/wp/v2/users"]); if (isset($e["/wp/v2/users/(?P[\\d]+)"])) unset($e["/wp/v2/users/(?P[\\d]+)"]); return $e; });', I18n::tr('Or use your security plugin: most have an option to stop user enumeration.', 'أو استخدم إضافة الأمان لديك، فأغلبها فيها خيار لمنع كشف أسماء المستخدمين.'), ], [ 'how' => I18n::tr('After applying, test the REST route wp/v2/users on your site — it should return 401/403/404.', 'بعد التطبيق افتح المسار wp/v2/users على موقعك، ويجب أن يعيد 401 أو 403 أو 404.'), 'pass_condition' => I18n::tr('The endpoint does not return a user list anonymously.', 'لم يعد المسار يرجع قائمة المستخدمين لطلب مجهول.'), ], $this->id() ); } // v2.3.0 — wp-config.php hardening flags $constants_now = isset($wp['constants']) && is_array($wp['constants']) ? $wp['constants'] : []; $missing_hardening = []; if (empty($constants_now['DISALLOW_FILE_EDIT'])) { $missing_hardening[] = I18n::tr( 'DISALLOW_FILE_EDIT (disables the plugin/theme editor in WordPress admin)', 'DISALLOW_FILE_EDIT (يعطّل محرر الإضافات والقوالب داخل لوحة ووردبريس)' ); } if (empty($constants_now['DISALLOW_FILE_MODS']) && empty($constants_now['DISALLOW_FILE_EDIT'])) { // Either DISALLOW_FILE_MODS or DISALLOW_FILE_EDIT should be set. // No double-counting. } if (isset($constants_now['WP_DEBUG_DISPLAY']) && $constants_now['WP_DEBUG_DISPLAY']) { $missing_hardening[] = I18n::tr('WP_DEBUG_DISPLAY=true (can expose errors to visitors)', 'عرض الأخطاء للزوار مفعّل (WP_DEBUG_DISPLAY=true)' ); } if (!empty($missing_hardening)) { $findings[] = $this->finding( 'sec_wp_config_hardening_gaps', sprintf(I18n::tr('Missing protection settings in wp-config (%d)', 'إعدادات حماية ناقصة في ملف wp-config (%d)'), count($missing_hardening)), 'Security', 'low', -2, [ 'source' => 'options', 'details' => I18n::tr('wp-config.php lacks the basic hardening flags.', 'ملف wp-config.php تنقصه إعدادات الحماية الأساسية.'), 'samples' => $missing_hardening, ], I18n::tr('These flags reduce the attack surface area. Even if the admin is compromised, DISALLOW_FILE_EDIT prevents the attacker from injecting code via the plugin editor.', 'هذه الإعدادات تقلل فرص الاختراق. مثلًا منع تعديل الملفات من لوحة التحكم يمنع من يسرق حساب المدير من زرع كود ضار.'), [ I18n::tr('In wp-config.php (before /* That\'s all */):', 'في wp-config.php (قبل /* That\'s all */):'), 'define(\'DISALLOW_FILE_EDIT\', true);', 'define(\'WP_DEBUG_DISPLAY\', false);', 'define(\'AUTOMATIC_UPDATER_DISABLED\', false);', I18n::tr('Make sure wp-config.php itself has permission 600 or 644.', 'تأكد أن صلاحية ملف wp-config.php نفسه 600 أو 644.'), ], [ 'how' => I18n::tr('Run the audit after adding the flags.', 'شغّل فحصًا جديدًا بعد إضافة الإعدادات.'), 'pass_condition' => I18n::tr('All the protection settings are in place.', 'كل إعدادات الحماية مفعّلة.'), ], $this->id() ); } return $findings; } /** * Check SSL certificate expiry via stream_context. * * @return array{days_remaining: int|null, valid_to: string} */ private function check_ssl_expiry(string $host): array { $result = ['days_remaining' => null, 'valid_to' => '']; if (!function_exists('stream_context_create')) { return $result; } try { $ctx = stream_context_create([ 'ssl' => [ 'capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false, ], ]); $socket = @stream_socket_client( 'ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx ); if (!is_resource($socket)) { return $result; } $params = stream_context_get_params($socket); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- $socket is a stream_socket_client resource, not a filesystem handle; WP_Filesystem does not cover SSL sockets. fclose($socket); $cert = $params['options']['ssl']['peer_certificate'] ?? null; if (!is_resource($cert)) { return $result; } $cert_info = openssl_x509_parse($cert); if (!is_array($cert_info) || empty($cert_info['validTo_time_t'])) { return $result; } $valid_to_ts = (int) $cert_info['validTo_time_t']; $days = (int) floor(($valid_to_ts - time()) / 86400); $result['days_remaining'] = $days; $result['valid_to'] = gmdate('Y-m-d', $valid_to_ts); } catch (\Throwable $e) { // Silent — SSL check is best-effort. } return $result; } /** * v2.3.0 — REST API public exposure check. * The default WP REST users route (with ?context=embed) exposes ALL author * usernames anonymously. Combined with /wp-login.php, this enables * enumeration → brute-force. Best practice: lock REST * users/* behind authentication via filter. * * @return array|null */ private function check_rest_users_exposure(): ?array { // Use rest_url() (not a hardcoded /wp-json/ path) so the self-check hits // the site's real REST base — correct on installs with a custom REST // prefix or plain permalinks (where the route is ?rest_route=). $url = rest_url('wp/v2/users'); $http = new \AetosSeo\Collectors\HttpCollector(); try { $resp = $http->request($url, ['timeout' => 8], 2); } catch (\Throwable $_e) { return null; } $status = (int) ($resp['status_code'] ?? 0); $body = (string) ($resp['body_sample'] ?? ''); if ($status !== 200 || $body === '') { return null; } $decoded = json_decode($body, true); if (!is_array($decoded) || empty($decoded)) { return null; } $exposed = []; foreach ($decoded as $u) { if (!is_array($u)) continue; $slug = (string) ($u['slug'] ?? ''); $name = (string) ($u['name'] ?? ''); if ($slug !== '' || $name !== '') { $exposed[] = ['slug' => $slug, 'name' => $name]; } if (count($exposed) >= 10) break; } return [ 'exposed_count' => count($exposed), 'samples' => array_slice($exposed, 0, 5), 'endpoint' => $url, ]; } }