fetch = $fetch ?? static function (string $url): ?string { $response = wp_remote_get($url, ['timeout' => 30]); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) { return null; } return (string) wp_remote_retrieve_body($response); }; } public static function inCoreScope(string $path): bool { return str_starts_with($path, 'wp-admin/') || str_starts_with($path, 'wp-includes/') || !str_contains($path, '/'); } /** @return array|null */ public function lastResult(): ?array { $last = Config::get(self::RESULT_KEY); return is_array($last) ? $last : null; } /** @return array */ public function run(): array { $manifest = $this->manifest(); $result = [ 'version' => $this->version(), 'locale' => $this->locale(), 'checked_at' => gmdate('c'), 'scope' => 'wp-admin, wp-includes et fichiers racine (wp-content exclu)', ]; if ($manifest === null) { $result += ['available' => false, 'error' => 'Liste officielle des empreintes indisponible (api.wordpress.org injoignable ou version inconnue).']; Config::set(self::RESULT_KEY, $result); return $result; } $compare = $manifest->compare($this->root(), [self::class, 'inCoreScope']); $unexpected = $manifest->unexpectedScripts($this->root(), ['wp-admin', 'wp-includes']); $result += [ 'available' => true, 'checked' => $compare['checked'], 'modified' => array_slice($compare['modified'], 0, self::LIST_CAP), 'missing' => array_slice($compare['missing'], 0, self::LIST_CAP), 'unexpected' => array_slice($unexpected, 0, self::LIST_CAP), 'modified_count' => count($compare['modified']), 'missing_count' => count($compare['missing']), 'unexpected_count' => count($unexpected), // Un script ajouté ne se « restaure » pas : il se supprime, et c'est une décision humaine. 'restorable' => 'Seuls les fichiers modifiés ou manquants se restaurent ; un script inattendu se supprime à la main, après examen.', ]; Config::set(self::RESULT_KEY, $result); return $result; } /** * @return array{ok: bool, http: int, code?: string, message?: string, data?: array} */ public function restore(string $path): array { // Espaces seulement : `trim()` retirerait aussi un octet nul final, et transformerait un // chemin fabriqué en chemin valide au lieu de le refuser. $path = trim($path, " \t"); if (!ChecksumManifest::isSafeRelativePath($path) || !self::inCoreScope($path)) { return $this->refuse(400, 'VALIDATION_ERROR', 'Chemin refusé : seul un fichier du cœur (wp-admin, wp-includes, racine) peut être restauré.'); } $manifest = $this->manifest(); if ($manifest === null) { return $this->refuse(503, 'CHECKSUMS_UNAVAILABLE', 'Liste officielle des empreintes indisponible : rien n\'a été modifié.'); } $expected = $manifest->expected($path); if ($expected === null) { return $this->refuse(404, 'NOT_FOUND', 'Ce fichier ne figure pas dans la liste officielle de cette version : il ne peut pas être restauré.'); } $target = $this->root() . $path; if (is_file($target) && hash_equals($expected, (string) @md5_file($target))) { return ['ok' => true, 'http' => 200, 'data' => ['restored' => false, 'already_intact' => true, 'path' => $path, 'md5' => $expected]]; } $version = $this->version(); if (preg_match('/^\d+\.\d+(?:\.\d+)?$/', $version) !== 1) { return $this->refuse(409, 'CONFLICT', 'Version du cœur illisible : le fichier officiel ne peut pas être désigné.'); } $content = ($this->fetch)('https://core.svn.wordpress.org/tags/' . $version . '/' . $path); if ($content === null || strlen($content) > self::MAX_FILE_BYTES) { return $this->refuse(502, 'DOWNLOAD_FAILED', 'Téléchargement du fichier officiel impossible : rien n\'a été modifié.'); } if (!ChecksumManifest::matches($content, $expected)) { return $this->refuse(502, 'CHECKSUM_MISMATCH', 'Le fichier téléchargé ne correspond pas à l\'empreinte officielle : rien n\'a été modifié.'); } $directory = dirname($target); if (!is_dir($directory) && !wp_mkdir_p($directory)) { return $this->refuse(409, 'NOT_WRITABLE', 'Répertoire du fichier non inscriptible : rien n\'a été modifié.'); } $realRoot = realpath($this->root()); $realDirectory = realpath($directory); if ($realRoot === false || $realDirectory === false || !str_starts_with($realDirectory . '/', rtrim($realRoot, '/') . '/')) { return $this->refuse(400, 'VALIDATION_ERROR', 'Chemin refusé : il sort du répertoire du site.'); } // 1. On écarte : copie de l'ancien fichier, vérifiée identique, AVANT tout remplacement. $asideRef = null; $previousMd5 = null; if (is_file($target)) { $previousMd5 = (string) md5_file($target); $asideRef = gmdate('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '-' . str_replace('/', '__', $path) . '.aside'; $aside = $this->asideDir() . '/' . $asideRef; if (!$this->prepareAsideDir() || !@copy($target, $aside) || !hash_equals($previousMd5, (string) @md5_file($aside))) { @unlink($aside); return $this->refuse(409, 'NOT_WRITABLE', 'Impossible de mettre l\'ancien fichier de côté : rien n\'a été modifié.'); } } // 2. On remplace par renommage : le fichier en place n'est jamais à moitié écrit. $tmp = $target . '.sentinelo-new-' . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, $content) === false || !@rename($tmp, $target)) { @unlink($tmp); return $this->refuse(409, 'NOT_WRITABLE', 'Fichier non inscriptible : l\'ancien fichier est resté en place.'); } // 3. On vérifie après avoir remis ; sinon l'ancien revient. if (!hash_equals($expected, (string) @md5_file($target))) { if ($asideRef !== null) { @copy($this->asideDir() . '/' . $asideRef, $target); } return $this->refuse(500, 'RESTORE_FAILED', 'Le fichier en place ne porte pas l\'empreinte attendue ; l\'ancien a été remis.'); } Logger::warning('security', sprintf('Fichier du cœur restauré depuis la version officielle %s : %s', $version, $path)); $this->forgetFromLastResult($path); return ['ok' => true, 'http' => 200, 'data' => [ 'restored' => true, 'path' => $path, 'md5' => $expected, 'previous_md5' => $previousMd5, 'aside_ref' => $asideRef, ]]; } private function manifest(): ?ChecksumManifest { foreach (array_unique([$this->locale(), 'en_US']) as $locale) { $body = ($this->fetch)(sprintf( 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=%s', rawurlencode($this->version()), rawurlencode($locale) )); $decoded = is_string($body) ? json_decode($body, true) : null; if (is_array($decoded) && is_array($decoded['checksums'] ?? null) && $decoded['checksums'] !== []) { $manifest = new ChecksumManifest($decoded['checksums']); if ($manifest->count() > 0) { return $manifest; } } } return null; } /** @return array{ok: false, http: int, code: string, message: string} */ private function refuse(int $http, string $code, string $message): array { return ['ok' => false, 'http' => $http, 'code' => $code, 'message' => $message]; } private function root(): string { return rtrim($this->root ?? ABSPATH, '/') . '/'; } private function version(): string { return $this->version ?? (string) get_bloginfo('version'); } private function locale(): string { return $this->locale ?? (function_exists('get_locale') ? (string) get_locale() : 'en_US'); } private function asideDir(): string { return rtrim($this->asideDir ?? SENTINELO_CORE_ASIDE_DIR, '/'); } /** Répertoire fermé au web : les fichiers écartés peuvent être précisément les fichiers piégés. */ private function prepareAsideDir(): bool { $dir = $this->asideDir(); if (!is_dir($dir) && !wp_mkdir_p($dir)) { return false; } if (!is_file($dir . '/.htaccess')) { @file_put_contents($dir . '/.htaccess', "Require all denied\nDeny from all\n"); } if (!is_file($dir . '/index.php')) { @file_put_contents($dir . '/index.php', "lastResult(); if ($last === null) { return; } foreach (['modified', 'missing'] as $list) { if (isset($last[$list]) && is_array($last[$list]) && in_array($path, $last[$list], true)) { $last[$list] = array_values(array_diff($last[$list], [$path])); $last[$list . '_count'] = max(0, (int) ($last[$list . '_count'] ?? 1) - 1); } } Config::set(self::RESULT_KEY, $last); } }