/.json * (MD5 and SHA-256 per file) * * There is no third one for themes, and a 404 on the second is not a clean * bill of health — it means the plugin is premium, custom or simply not * hosted on wordpress.org, and the only honest verdict for its files is "no * reference". * * Two things bound what this costs a site. * * A (slug, version) pair names an immutable zip, so its answer is cached for * a month; only an update — which moves the version, and so the cache key — * can invalidate it. An absent answer is cached for three days rather than * for ever, because wordpress.org generates a checksum file shortly *after* a * release goes out, so "not there yet" and "never will be" look identical on * the day. * * And one scan is allowed a fixed number of network calls. A site with sixty * plugins would otherwise open sixty connections inside one cron tick, on the * hosting where a cron tick is already the thing that times out. Whatever the * budget does not reach this run is queued: it stays uncached, so the next * run — which pays nothing for the pairs already answered — spends its budget * on the next slice, and the whole list is covered in a few days without any * run ever being the expensive one. */ if ( ! defined( 'ABSPATH' ) ) { exit; } class GUARDFORGE_Integrity_Reference { const CORE_ENDPOINT = 'https://api.wordpress.org/core/checksums/1.0/'; const PLUGIN_ENDPOINT = 'https://downloads.wordpress.org/plugin-checksums/'; const CACHE_PREFIX = 'gf_intref_'; /** * A (slug, version) pair names a zip that will never be rebuilt, so the * answer cannot go stale while the key stays the same. */ const TTL_FOUND = 30 * DAY_IN_SECONDS; /** * "No such checksum file" is cached too — and for days rather than for * ever, because wordpress.org writes one a little after a release ships. * Long enough that sixty premium plugins never crowd out a real fetch, * short enough that a brand-new version stops being a mystery within the * week. */ const TTL_ABSENT = 3 * DAY_IN_SECONDS; /** Network calls one scan may make, core included. */ const FETCH_BUDGET = 10; /** Seconds any one of them may take. */ const TIMEOUT = 10; private static $instance = null; /** Calls made since begin_run. @var int */ private $spent = 0; /** Slugs this run wanted and could not afford. @var string */ private $queued = []; /** Per-request memo, so two callers asking the same thing cost one read. */ private $memo = []; public static function instance() { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } /** * Start a scan: the budget is per run, not per request, and the memo goes * with it. A run is the only thing that reads the memo, and a scan that * inherited the previous one's would spend no budget and see no queue. */ public function begin_run() { $this->spent = 0; $this->queued = []; $this->memo = []; } /** @return int Calls made since begin_run. */ public function spent() { return $this->spent; } /** * Slugs this run asked for after the budget ran out. Their files fall * back to snapshot comparison and the screen says so. * * @return string */ public function queued() { return array_values( array_unique( $this->queued ) ); } // ── core ───────────────────────────────────────────────────────────── /** * MD5 of every file of this exact WordPress, keyed by path relative to * ABSPATH. * * @return array|null Null when there is no answer to be * had — no version, no budget, or * wordpress.org said nothing usable. */ public function core() { $version = $this->wp_version(); if ( $version === '' ) { return null; } $locale = $this->locale(); $key = 'core|' . $version . '|' . $locale; if ( array_key_exists( $key, $this->memo ) ) { return $this->memo[ $key ]; } $cached = $this->cached( $key ); if ( $cached !== null ) { $this->memo[ $key ] = isset( $cached['files'] ) ? $cached['files'] : null; return $this->memo[ $key ]; } if ( ! $this->can_fetch() ) { $this->queued[] = 'core'; return null; } $url = add_query_arg( [ 'version' => $version, 'locale' => $locale ], self::CORE_ENDPOINT ); $body = $this->fetch( $url ); if ( $body === null ) { // A transport failure is not an answer, so nothing is written to // the cache: the next run asks again rather than reporting a whole // core as unreferenced for a month because one request timed out. return $this->memo[ $key ] = null; } if ( $body === false ) { $this->remember( $key, [ 'absent' => true ], self::TTL_ABSENT ); return $this->memo[ $key ] = null; } $decoded = json_decode( $body, true ); $files = is_array( $decoded ) && isset( $decoded['checksums'] ) && is_array( $decoded['checksums'] ) ? $decoded['checksums'] : null; /* * The endpoint answers a localised request with either a flat map or * one nested under the version. Both shapes are documented nowhere * and both are served today. */ if ( is_array( $files ) && ! empty( $files[ $version ] ) && is_array( $files[ $version ] ) ) { $files = $files[ $version ]; } if ( ! is_array( $files ) || $files === [] ) { $this->remember( $key, [ 'absent' => true ], self::TTL_ABSENT ); return $this->memo[ $key ] = null; } $files = array_map( 'strval', $files ); $this->remember( $key, [ 'files' => $files ], self::TTL_FOUND ); return $this->memo[ $key ] = $files; } /** * Is a core reference path one the scan would have walked? * * The checksum list covers the whole install, including the bundled * themes and akismet under wp-content. The scan walks PHP under * wp-admin, wp-includes and the root, so anything else has to stay out * of the "missing" count: a site that deleted Twenty Twenty-Four has not * lost a core file, it has tidied up, and reporting it would bury the one * finding that matters under a hundred that do not. */ public static function in_core_scan_scope( $relative ) { $relative = (string) $relative; if ( substr( $relative, -4 ) !== '.php' ) { return false; } if ( strpos( $relative, 'wp-admin/' ) === 0 || strpos( $relative, 'wp-includes/' ) === 0 ) { return true; } return strpos( $relative, '/' ) === false; } // ── plugins ────────────────────────────────────────────────────────── /** * MD5 and SHA-256 of every file of one wordpress.org plugin release. * * @param string $slug * @param string $version * @return array|null|false * The map; null when wordpress.org has no such release (premium, * custom, or renamed); false when this run's budget is spent and * the pair has been queued for the next one. */ public function plugin( $slug, $version ) { $slug = (string) $slug; $version = (string) $version; if ( $slug === '' || $version === '' || ! preg_match( '/^[a-z0-9][a-z0-9\-_]*$/i', $slug ) ) { return null; } $key = 'plugin|' . $slug . '|' . $version; if ( array_key_exists( $key, $this->memo ) ) { return $this->memo[ $key ]; } $cached = $this->cached( $key ); if ( $cached !== null ) { $this->memo[ $key ] = isset( $cached['files'] ) ? $cached['files'] : null; return $this->memo[ $key ]; } if ( ! $this->can_fetch() ) { $this->queued[] = $slug; return false; } $url = self::PLUGIN_ENDPOINT . rawurlencode( $slug ) . '/' . rawurlencode( $version ) . '.json'; $body = $this->fetch( $url ); if ( $body === null ) { return $this->memo[ $key ] = null; } if ( $body === false ) { // 404 — the honest answer, and the one that must not be confused // with "clean". Cached so sixty premium plugins cost one round of // requests every three days rather than sixty every night. $this->remember( $key, [ 'absent' => true ], self::TTL_ABSENT ); return $this->memo[ $key ] = null; } $decoded = json_decode( $body, true ); $files = is_array( $decoded ) && isset( $decoded['files'] ) && is_array( $decoded['files'] ) ? $decoded['files'] : null; if ( ! is_array( $files ) || $files === [] ) { $this->remember( $key, [ 'absent' => true ], self::TTL_ABSENT ); return $this->memo[ $key ] = null; } $normalised = []; foreach ( $files as $path => $hashes ) { if ( ! is_array( $hashes ) ) { continue; } $normalised[ (string) $path ] = [ 'md5' => self::hash_list( $hashes['md5'] ?? [] ), 'sha256' => self::hash_list( $hashes['sha256'] ?? [] ), ]; } if ( $normalised === [] ) { $this->remember( $key, [ 'absent' => true ], self::TTL_ABSENT ); return $this->memo[ $key ] = null; } $this->remember( $key, [ 'files' => $normalised ], self::TTL_FOUND ); return $this->memo[ $key ] = $normalised; } /** * Both hash fields come as a string most of the time and as a list when * one release ships a file with two acceptable line endings. Reading only * the string form would call every one of those files modified. * * @param mixed $value * @return string */ private static function hash_list( $value ) { $out = []; foreach ( (array) $value as $hash ) { if ( is_string( $hash ) && $hash !== '' ) { $out[] = strtolower( $hash ); } } return $out; } /** * slug => version for every plugin WordPress knows about, directory * plugins only. * * A one-file plugin has no directory to name it, and its wordpress.org * slug is not derivable from the filename — hello.php lives under * hello-dolly. Guessing would hand somebody else's checksums to a file * and call it modified, so those stay unreferenced. * * @return array */ public static function installed_plugin_versions() { if ( ! function_exists( 'get_plugins' ) ) { $include = ABSPATH . 'wp-admin/includes/plugin.php'; if ( ! is_readable( $include ) ) { return []; } require_once $include; } if ( ! function_exists( 'get_plugins' ) ) { return []; } $out = []; foreach ( (array) get_plugins() as $file => $data ) { $file = (string) $file; if ( strpos( $file, '/' ) === false ) { continue; } $slug = substr( $file, 0, strpos( $file, '/' ) ); $version = isset( $data['Version'] ) ? (string) $data['Version'] : ''; if ( $slug !== '' && $version !== '' && ! isset( $out[ $slug ] ) ) { $out[ $slug ] = $version; } } return $out; } /** * slug => version for every installed theme. * * Deliberately NOT a step towards fetching a theme reference: there is * no published checksum source for themes anywhere (see the class * docblock), and this method does not change that. It exists because * §6.4 K1-e still has to answer "why did this theme file change?" on a * site where nothing can be compared against a published package — and * the one fact available is whether the theme's own version moved * between the two snapshots. That is a weaker statement than a checksum * match and the case model says so in as many words rather than * presenting it as a verified explanation. * * @return array */ public static function installed_theme_versions() { if ( ! function_exists( 'wp_get_themes' ) ) { return []; } $out = []; foreach ( (array) wp_get_themes() as $slug => $theme ) { $slug = (string) $slug; if ( $slug === '' || ! is_object( $theme ) || ! method_exists( $theme, 'get' ) ) { continue; } $version = (string) $theme->get( 'Version' ); if ( $version !== '' ) { $out[ $slug ] = $version; } } return $out; } // ── plumbing ───────────────────────────────────────────────────────── private function can_fetch() { if ( $this->spent >= self::FETCH_BUDGET ) { return false; } $this->spent++; return true; } /** * One request, through the shared SSRF gate. * * @param string $url * @return string|false|null The body; false for a 4xx (a definite "there * is nothing here"); null for anything else — * an empty body included — which is not an * answer and is not cached. */ private function fetch( $url ) { if ( ! class_exists( 'Forge_Net' ) ) { // The shared gate is what makes this safe to call at all. Falling // back to a bare wp_remote_get would be an ungated fetch in a // security plugin. return null; } /* * No site address in the user-agent, unlike WordPress's own update * checks. What goes out is a WordPress version, a locale and a plugin * slug — enough to name a public zip and nothing that names this * site. That is the whole disclosure in readme.txt, and it stays true * only while nobody puts the site address back in here for the sake * of friendlier logs at the other end. */ $response = Forge_Net::safe_remote_get( $url, [ 'timeout' => self::TIMEOUT, 'user-agent' => 'GuardForge/' . ( defined( 'GUARDFORGE_VERSION' ) ? GUARDFORGE_VERSION : '0' ) . ' (file integrity)', ] ); if ( is_wp_error( $response ) ) { return null; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code === 404 || $code === 403 || $code === 410 ) { return false; } if ( $code !== 200 ) { return null; } $body = (string) wp_remote_retrieve_body( $response ); if ( $body === '' ) { return null; } return $body; } /** @return array|null The cached answer, or null for a miss. */ private function cached( $key ) { $value = get_transient( self::CACHE_PREFIX . md5( $key ) ); return is_array( $value ) ? $value : null; } private function remember( $key, array $value, $ttl ) { set_transient( self::CACHE_PREFIX . md5( $key ), $value, (int) $ttl ); } /** * The core release this comparison is about — the single source of truth * for "the version installed at scan time". * * §6.4 K1-e reads it too (GUARDFORGE_Integrity::package_versions, for * the local diff's "did this package's version move between the two * snapshots?" question), and the two must never disagree about which * release a finding was judged against: a finding stamped with a version * the fetch never asked wordpress.org about would make the whole * comparison unfalsifiable. * * @return string '' when WordPress reports nothing usable. */ public static function core_version() { $version = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'version' ) : ''; // Only what a URL path may carry, so a filtered version string cannot // steer the request somewhere else. return preg_match( '/^[0-9][0-9.\-a-zA-Z]*$/', $version ) ? $version : ''; } private function wp_version() { return self::core_version(); } private function locale() { $locale = function_exists( 'get_locale' ) ? (string) get_locale() : 'en_US'; return preg_match( '/^[a-zA-Z_\-]{2,20}$/', $locale ) ? $locale : 'en_US'; } }