*/ public static function enabledIds(): array { $raw = Config::get(self::OPTION_KEY, []); return is_array($raw) ? array_values(array_intersect(Catalogue::ids(), $raw)) : []; } /** @return list> */ public function states(): array { return array_map(fn (string $id): array => $this->state($id), Catalogue::ids()); } /** @return array */ public function state(string $id): array { $enabled = in_array($id, self::enabledIds(), true); return match ($id) { Catalogue::DISABLE_FILE_EDIT => $enabled ? Catalogue::state($id, Catalogue::STATUS_APPLIED) : (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT === true ? Catalogue::state($id, Catalogue::STATUS_APPLIED, Catalogue::SOURCE_CONFIG, 'DISALLOW_FILE_EDIT est défini dans wp-config.php : l\'agent ne peut pas le retirer.') : Catalogue::state($id, Catalogue::STATUS_NOT_APPLIED)), Catalogue::BLOCK_PHP_IN_UPLOADS => $this->uploadsState(), default => Catalogue::state($id, $enabled ? Catalogue::STATUS_APPLIED : Catalogue::STATUS_NOT_APPLIED), }; } /** * @return array{ok: bool, http: int, code?: string, message?: string, state: array} */ public function apply(string $id, bool $enable): array { if (!Catalogue::has($id)) { return ['ok' => false, 'http' => 400, 'code' => 'VALIDATION_ERROR', 'message' => 'Action de durcissement inconnue.', 'state' => []]; } $current = $this->state($id); if ($current['status'] === Catalogue::STATUS_UNSUPPORTED) { return ['ok' => false, 'http' => 501, 'code' => 'NOT_SUPPORTED', 'message' => (string) $current['reason'], 'state' => $current]; } if (!$enable && $current['status'] === Catalogue::STATUS_APPLIED && !$current['revertible']) { return ['ok' => false, 'http' => 409, 'code' => 'CONFLICT', 'message' => (string) $current['reason'], 'state' => $current]; } if ($id === Catalogue::BLOCK_PHP_IN_UPLOADS) { $error = $this->writeUploadsBlock($enable); if ($error !== null) { return ['ok' => false, 'http' => 409, 'code' => 'NOT_WRITABLE', 'message' => $error, 'state' => $this->state($id)]; } } $ids = self::enabledIds(); $ids = $enable ? array_values(array_unique([...$ids, $id])) : array_values(array_diff($ids, [$id])); Config::set(self::OPTION_KEY, $ids); Logger::info('security', sprintf('Durcissement « %s » %s depuis la plateforme.', $id, $enable ? 'appliqué' : 'retiré')); return ['ok' => true, 'http' => 200, 'state' => $this->state($id)]; } /** * Pose les filtres des actions activées. Appelé à chaque démarrage de l'agent : * ce qui n'est pas réaffirmé à chaque requête cesse d'exister (piège n°16). */ public static function enforce(): void { $enabled = self::enabledIds(); if (in_array(Catalogue::DISABLE_FILE_EDIT, $enabled, true)) { // Édition privée : la constante aussi. L'édition wordpress.org ne définit // aucune constante du cœur (Edition::definesCoreConstants()) et s'en tient // au filtre, qui suffit et ne subsiste pas si l'action est retirée. // Le filtre tient même quand wp-config.php définit la constante à false. add_filter('map_meta_cap', static function (array $caps, string $cap): array { return in_array($cap, ['edit_plugins', 'edit_themes', 'edit_files'], true) ? ['do_not_allow'] : $caps; }, 10, 2); } if (in_array(Catalogue::HIDE_VERSION, $enabled, true)) { remove_action('wp_head', 'wp_generator'); add_filter('the_generator', static fn (): string => ''); $strip = static function (string $src): string { $version = (string) get_bloginfo('version'); return $version !== '' && str_contains($src, 'ver=' . $version) ? (string) remove_query_arg('ver', $src) : $src; }; add_filter('style_loader_src', $strip, 9999); add_filter('script_loader_src', $strip, 9999); } if (in_array(Catalogue::DISABLE_XMLRPC, $enabled, true)) { add_filter('xmlrpc_enabled', '__return_false'); add_filter('xmlrpc_methods', static fn (): array => []); add_filter('wp_headers', static function (array $headers): array { unset($headers['X-Pingback']); return $headers; }); } if (in_array(Catalogue::DISABLE_USER_ENUMERATION, $enabled, true)) { // Anonymes seulement : l'éditeur de blocs lit cette route pour les comptes connectés. add_filter('rest_endpoints', static function (array $endpoints): array { if (is_user_logged_in()) { return $endpoints; } foreach (array_keys($endpoints) as $route) { if (preg_match('#^/wp/v2/users(?:/|$)#', (string) $route) === 1) { unset($endpoints[$route]); } } return $endpoints; }); add_action('template_redirect', static function (): void { if (!is_user_logged_in() && isset($_GET['author'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- blocks ?author= user enumeration; reads no value. wp_safe_redirect(home_url('/'), 301); exit; } }, 1); } } /** @return array */ private function uploadsState(): array { $id = Catalogue::BLOCK_PHP_IN_UPLOADS; $software = strtolower($this->serverSoftware ?? sanitize_text_field(wp_unslash((string) ($_SERVER['SERVER_SOFTWARE'] ?? '')))); if (!str_contains($software, 'apache') && !str_contains($software, 'litespeed')) { $reason = str_contains($software, 'nginx') ? 'Serveur nginx : le fichier .htaccess n\'y est pas lu. Règle à ajouter côté serveur : ' . 'location ~* ' . untrailingslashit((string) wp_parse_url((string) wp_upload_dir(null, false)['baseurl'], PHP_URL_PATH)) . '/.*\.(php[0-9]?|phtml|phar)$ { deny all; }' : 'Serveur web non reconnu (' . ($software !== '' ? $software : 'inconnu') . ') : l\'agent ne sait pas y bloquer les scripts.'; return Catalogue::state($id, Catalogue::STATUS_UNSUPPORTED, null, $reason); } $content = @file_get_contents($this->htaccessPath()); return Catalogue::state( $id, is_string($content) && str_contains($content, self::MARKER_BEGIN) ? Catalogue::STATUS_APPLIED : Catalogue::STATUS_NOT_APPLIED ); } private function htaccessPath(): string { $dir = $this->uploadsDir ?? (string) (wp_upload_dir()['basedir'] ?? ''); return rtrim($dir, '/') . '/.htaccess'; } /** Écrit ou retire le bloc balisé ; rend le motif d'échec, ou null. */ private function writeUploadsBlock(bool $enable): ?string { $path = $this->htaccessPath(); $dir = dirname($path); if (!is_dir($dir) || !is_writable($dir) || (is_file($path) && !is_writable($path))) { return 'Le répertoire des médias n\'est pas inscriptible par PHP : rien n\'a été modifié.'; } $existing = is_file($path) ? (string) file_get_contents($path) : ''; $stripped = rtrim((string) preg_replace( '/\n?' . preg_quote(self::MARKER_BEGIN, '/') . '.*?' . preg_quote(self::MARKER_END, '/') . '\n?/s', "\n", $existing )); $content = $enable ? ($stripped !== '' ? $stripped . "\n\n" : '') . self::MARKER_BEGIN . "\n" . "\n" . " \n Require all denied\n \n" . " \n Order allow,deny\n Deny from all\n \n" . "\n" . self::MARKER_END . "\n" : ($stripped !== '' ? $stripped . "\n" : ''); // Le fichier d'origine est gardé de côté avant toute écriture. if ($existing !== '' && !is_file($path . '.sentinelo-bak')) { @copy($path, $path . '.sentinelo-bak'); } if ($content === '' && is_file($path)) { return @unlink($path) ? null : 'Impossible de retirer le fichier .htaccess des médias.'; } // Écriture atomique : un fichier temporaire, puis un renommage. $tmp = $path . '.sentinelo-tmp-' . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, $content) === false || !@rename($tmp, $path)) { @unlink($tmp); return 'Écriture du fichier .htaccess des médias impossible : rien n\'a été modifié.'; } return null; } }