array( 'schedule' => 'csdt_every_1min', 'label' => 'Every minute', 'secs' => 60 ), '5min' => array( 'schedule' => 'csdt_every_5min', 'label' => 'Every 5 minutes', 'secs' => 300 ), 'hourly' => array( 'schedule' => 'hourly', 'label' => 'Hourly', 'secs' => 3600 ), 'daily' => array( 'schedule' => 'daily', 'label' => 'Daily', 'secs' => 86400 ), ); /** The default: what this check was already doing before the frequency became a choice. */ const FREQUENCY_DEFAULT = '5min'; /** * Which top-level area a scanned path belongs to, for the coverage tally. * * Files at the root of the install (wp-load.php, index.php, xmlrpc.php …) are bucketed as * "/" rather than each becoming its own heading, because a list of 30 one-file "folders" * is not a summary. */ public static function coverage_bucket( string $rel ): string { $pos = strpos( $rel, '/' ); return false === $pos ? '/' : substr( $rel, 0, $pos ) . '/'; } /** The chosen frequency key, always one this class actually offers. */ public static function frequency(): string { $f = (string) get_option( self::OPTION_FREQUENCY, self::FREQUENCY_DEFAULT ); return isset( self::FREQUENCIES[ $f ] ) ? $f : self::FREQUENCY_DEFAULT; } /** * Is scheduled verification switched on? * * OFF on a new install. Every check fetches its reference data from WordPress.org (core * checksums, plugin checksums, theme zips), and WordPress.org round T21 rejected scheduled * requests that start without the administrator opting in, disclosed or not. A site that * already ran the check before this change keeps it (OPTION_LEGACY_ON). */ public static function is_enabled(): bool { $default = '1' === (string) get_option( self::OPTION_LEGACY_ON, '0' ) ? '1' : '0'; return '1' === (string) get_option( self::OPTION_ENABLED, $default ); } /** * Brings the schedule into line with the two settings. Safe to call on every load. * * Reschedules when the INTERVAL has changed, not merely when nothing is scheduled: the bug * this avoids is choosing Daily, seeing the panel agree, and still being scanned every five * minutes because an event was already booked under the old slug. */ public static function sync_schedule(): void { $hook = self::CRON_HOOK; if ( ! self::is_enabled() ) { wp_clear_scheduled_hook( $hook ); return; } $want = self::FREQUENCIES[ self::frequency() ]['schedule']; $ev = function_exists( 'wp_get_scheduled_event' ) ? wp_get_scheduled_event( $hook ) : null; if ( $ev && isset( $ev->schedule ) && $ev->schedule === $want ) { return; } if ( $ev ) { wp_clear_scheduled_hook( $hook ); } wp_schedule_event( time() + 60, $want, $hook ); } /** * One scheduled batch, then a continuation if the pass is not finished. * * WHY THE CONTINUATION EXISTS. A pass is budgeted and checkpointed, so on a slow host it * takes several ticks. Tie that to the chosen frequency and Daily would advance the scan by * one batch a day, a tree that takes four batches would report a completed pass every four * days while the panel showed it as daily. The frequency chooses how often a pass STARTS; * finishing one is not the user's decision to make. */ public static function cron_run(): void { if ( ! self::is_enabled() ) { return; } // Through the threat monitor, not straight to run(): the alerting, the once-per-finding // deduplication and the csdt_threat_last_file_alert bookkeeping all live in that method. // Calling run() here instead would have updated the panel on schedule and never sent // another alert, and a security check that has gone quiet looks exactly like a clean site. if ( class_exists( 'CSDT_Threat_Monitor' ) ) { $result = CSDT_Threat_Monitor::check_file_integrity(); } else { $result = self::run(); } if ( empty( $result['done'] ) && empty( $result['reason'] ) ) { // Not finished and not broken: come straight back rather than waiting out the // interval. Single event, so these cannot stack up. if ( ! wp_next_scheduled( self::CRON_HOOK . '_continue' ) ) { wp_schedule_single_event( time() + 60, self::CRON_HOOK . '_continue' ); } } } /** * Paths this run could not read, per pass. * * A directory that cannot be traversed used to be skipped with a bare `continue`, * which meant the scan reported CLEAN over ground it had never looked at. That is * the same "reports success while doing nothing" fault this class was written to * remove, in the part of it I wrote. Counted now, and surfaced, so a verdict can * say "verified except N unreadable paths" instead of just "verified". */ private static $unreadable = 0; /** * Extensions that must never appear under wp-content/uploads. * * A media directory has no reason to contain anything executable. This is the * single highest-signal check in the file: it is how almost every real * WordPress compromise persists, and the old integrity check did not look at * uploads at all. */ const EXECUTABLE_EXT = array( 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phar', 'phps' ); /** * Paths under a core directory that legitimately are not in the manifest. * * Everything else found there is reported as unknown, so this list is the only * thing standing between a real finding and a false alarm. Keep it minimal and * justified rather than broad. */ const CORE_DIR_EXCEPTIONS = array( // Locale files are downloaded per site, not shipped in the tarball. 'wp-includes/languages/', // Some hosts drop a per-install marker beside index.php. 'wp-admin/.htaccess', 'wp-includes/.htaccess', ); // ── Checksum manifest ──────────────────────────────────────────────── /** * WordPress's published md5 for every file in this version and locale. * * @return array|null Null when the manifest is unavailable, which * callers must treat as "cannot verify" rather than as "nothing wrong". */ public static function core_checksums(): ?array { $version = (string) get_bloginfo( 'version' ); $locale = (string) get_locale(); $key = self::TRANSIENT_SUMS . '_' . md5( $version . '|' . $locale ); $cached = get_transient( $key ); if ( is_array( $cached ) && ! empty( $cached ) ) { return $cached; } if ( ! function_exists( 'get_core_checksums' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } if ( ! function_exists( 'get_core_checksums' ) ) { return null; } $sums = get_core_checksums( $version, $locale ); // A non-default locale may have no manifest; en_US always does. if ( ! is_array( $sums ) || empty( $sums ) ) { $sums = get_core_checksums( $version, 'en_US' ); } if ( ! is_array( $sums ) || empty( $sums ) ) { return null; } set_transient( $key, $sums, self::SUMS_TTL ); return $sums; } /** * Does this file match the en_US manifest, when the site's own locale said it did not? * * Only ever asked about a file that has ALREADY failed the site-locale comparison, so on an * en_US site it is never asked at all and costs nothing. On a localized site it is asked * about version.php and, in practice, nothing else. * * Fails CLOSED: if the en_US list cannot be fetched, the answer is no and the mismatch is * reported. A network problem must not be able to silence a real finding, which is the * opposite of the false positive this exists to remove, and the more expensive mistake. * * @param string $rel Path relative to the install root, as the manifest keys it. * @param string $hash md5 of the file on disk, already computed by the caller. */ private static function matches_en_us( string $rel, string $hash ): bool { if ( 'en_US' === (string) get_locale() ) { return false; } static $en_us = null; if ( null === $en_us ) { $version = (string) get_bloginfo( 'version' ); $key = self::TRANSIENT_SUMS . '_' . md5( $version . '|en_US' ); $cached = get_transient( $key ); if ( is_array( $cached ) && ! empty( $cached ) ) { $en_us = $cached; } else { if ( ! function_exists( 'get_core_checksums' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $fetched = function_exists( 'get_core_checksums' ) ? get_core_checksums( $version, 'en_US' ) : null; if ( is_array( $fetched ) && ! empty( $fetched ) ) { set_transient( $key, $fetched, self::SUMS_TTL ); $en_us = $fetched; } else { $en_us = array(); // fetch failed; every mismatch stands } } } return isset( $en_us[ $rel ] ) && self::hash_matches( $en_us[ $rel ], $hash ); } // ── Scanning ───────────────────────────────────────────────────────── /** * Verifies core files against the manifest, resuming where the last run stopped. * * Batched because a full tree is roughly two thousand md5s: doing them all in one * cron tick is what turns a monitor into an outage on modest hardware. The * checkpoint is the index into the manifest key list, so a run that is cut short * loses nothing. * * @param int $budget Seconds this run may spend. * @return array{done:bool,checked:int,modified:array,missing:array,unknown:array,progress:int,total:int,reason?:string} */ public static function scan_core( int $budget = self::DEFAULT_BUDGET ): array { $empty = array( 'done' => false, 'checked' => 0, 'modified' => array(), 'missing' => array(), 'unknown' => array(), 'progress' => 0, 'total' => 0, ); $sums = self::core_checksums(); if ( null === $sums ) { // Cannot verify. Explicitly NOT the same as "verified clean", the caller // records this so the panel can say the check did not run, rather than // showing a reassuring green tick for a scan that never happened. return array_merge( $empty, array( 'reason' => 'checksums-unavailable' ) ); } $state = get_option( self::OPTION_STATE, array() ); if ( ! is_array( $state ) ) { $state = array(); } $version = (string) get_bloginfo( 'version' ); // A core update invalidates any part-finished pass. if ( ( $state['version'] ?? '' ) !== $version ) { $state = array( 'version' => $version, 'index' => 0, 'modified' => array(), 'missing' => array(), 'dirs' => array(), 'files' => array(), ); } $files = array_keys( $sums ); $total = count( $files ); $index = max( 0, (int) ( $state['index'] ?? 0 ) ); $modified = is_array( $state['modified'] ?? null ) ? $state['modified'] : array(); $missing = is_array( $state['missing'] ?? null ) ? $state['missing'] : array(); // WHAT WAS ACTUALLY LOOKED AT, accumulated across batches because a pass is spread over // several cron ticks and a coverage figure assembled from the last tick alone would // describe 25 files and read as if it described the tree. $dirs = is_array( $state['dirs'] ?? null ) ? $state['dirs'] : array(); $slist = is_array( $state['files'] ?? null ) ? $state['files'] : array(); $abspath = CSDT_Paths::site_root() . '/'; $deadline = time() + max( 1, $budget ); $checked = 0; while ( $index < $total ) { for ( $i = 0; $i < self::CHECK_CHUNK && $index < $total; $i++, $index++ ) { $rel = $files[ $index ]; // Only core is checked: files at the root, wp-admin and wp-includes. The // manifest also lists the default themes and plugins in the content folder; // changing those is the owner's business and is not evidence of compromise. if ( false !== strpos( $rel, '/' ) && 0 !== strpos( $rel, 'wp-admin/' ) && 0 !== strpos( $rel, 'wp-includes/' ) ) { continue; } $abs = $abspath . $rel; if ( ! is_readable( $abs ) ) { // wp-config-sample.php and readme.html are commonly deleted on // purpose as hardening, so absence alone is only reported, never // alerted as a compromise. $missing[ $rel ] = 1; ++$checked; continue; } // Counted per top-level directory. A per-file list of 4,000 paths in an option is // a quarter of a megabyte read on every admin page; the folder tally answers the // question a reader actually has ("was wp-includes covered?") for a few bytes. // The exact paths are kept as well, but only while there are few enough to read. $top = self::coverage_bucket( $rel ); $dirs[ $top ] = ( (int) ( $dirs[ $top ] ?? 0 ) ) + 1; if ( count( $slist ) < self::COVERAGE_DETAIL_MAX ) { $slist[] = $rel; } $hash = md5_file( $abs ); if ( $hash !== $sums[ $rel ] ) { // SECOND OPINION FROM en_US BEFORE REPORTING. // // A localized build differs from the en_US one in exactly one file: // wp-includes/version.php, which carries $wp_local_package. So a site whose // locale is en_ZA while its core files came from the en_US download fails // this comparison on that one file, on every pass, for ever, and everything // else matches, because the other ~3,900 files are byte-identical across // locales. // // That is what this site was doing: locale en_ZA, version.php hashing to the // published en_US value, reported CRITICAL continuously with no way to clear // it. A permanent finding on a security panel is worse than no panel; it // teaches the reader that red means nothing. // // Accepting either manifest is not a weakening. Both lists are WordPress's // own, published per version, so a file that matches one of them is a file // WordPress shipped. Forging that would mean producing an MD5 preimage. if ( ! self::matches_en_us( $rel, $hash ) ) { $modified[ $rel ] = 1; } } ++$checked; } if ( time() >= $deadline ) { break; } } $done = $index >= $total; update_option( self::OPTION_STATE, array( 'version' => $version, 'index' => $done ? 0 : $index, 'modified' => $done ? array() : $modified, 'missing' => $done ? array() : $missing, 'dirs' => $done ? array() : $dirs, 'files' => $done ? array() : $slist, ), false ); return array( 'done' => $done, 'checked' => $checked, 'modified' => array_keys( $modified ), 'missing' => array_keys( $missing ), 'unknown' => $done ? self::find_unknown_core_files( $sums ) : array(), 'progress' => $done ? $total : $index, 'total' => $total, // Coverage travels with the result so the panel can say what was examined, not just // how many were. "0 findings" means nothing until you know what was looked at. 'dirs' => $dirs, 'files' => count( $slist ) < self::COVERAGE_DETAIL_MAX ? $slist : array(), ); } /** * One whole pass over core files now, without touching the scheduled pass's checkpoint. * * For Upgrade Verification (CSDT_Upgrade_Verify), after a core version change: "did the * update land whole?". scan_core() is batched across cron ticks and keeps its place in * OPTION_STATE, so calling it from here would advance or reset the scheduled pass; this * keeps nothing. The same rules: root, wp-admin and wp-includes only, the en_US second * opinion, and checksums that cannot be fetched are "cannot verify", never "clean". A * missing file is not counted (scan_core() reports those; deleting readme.html is hardening). * * @param int $budget Seconds it may spend. * @return array{ok:?bool,checked:int,modified:array,reason:string} ok null: not verified (reason says why). */ public static function verify_core_now( int $budget = 20 ): array { $sums = self::core_checksums(); if ( null === $sums ) { return array( 'ok' => null, 'checked' => 0, 'modified' => array(), 'reason' => 'checksums-unavailable', ); } $abspath = CSDT_Paths::site_root() . '/'; $deadline = time() + max( 1, $budget ); $checked = 0; $modified = array(); foreach ( $sums as $rel => $md5 ) { $rel = (string) $rel; if ( false !== strpos( $rel, '/' ) && 0 !== strpos( $rel, 'wp-admin/' ) && 0 !== strpos( $rel, 'wp-includes/' ) ) { continue; } if ( time() >= $deadline ) { return array( 'ok' => array() === $modified ? null : false, 'checked' => $checked, 'modified' => $modified, 'reason' => 'time-budget', ); } if ( ! is_readable( $abspath . $rel ) ) { continue; } $hash = (string) md5_file( $abspath . $rel ); if ( $hash !== $md5 && ! self::matches_en_us( $rel, $hash ) ) { $modified[] = $rel; } ++$checked; } return array( 'ok' => array() === $modified, 'checked' => $checked, 'modified' => $modified, 'reason' => '', ); } /** * Files sitting in wp-admin or wp-includes that WordPress never shipped. * * The old check could not see these at all: it only ever re-examined paths it had * already recorded, so a dropped file was invisible. Run only on a completed * pass, since a partial manifest would report every unvisited file as unknown. * * @param array $sums Manifest, file => md5. * @return array */ public static function find_unknown_core_files( array $sums ): array { $abspath = CSDT_Paths::site_root() . '/'; $unknown = array(); foreach ( array( 'wp-admin', 'wp-includes' ) as $dir ) { $root = $abspath . $dir; if ( ! is_dir( $root ) ) { continue; } try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $it as $file ) { if ( ! $file->isFile() ) { continue; } $rel = str_replace( '\\', '/', substr( (string) $file->getPathname(), strlen( $abspath ) ) ); if ( isset( $sums[ $rel ] ) ) { continue; } if ( self::is_core_exception( $rel ) ) { continue; } $unknown[] = $rel; if ( count( $unknown ) >= self::MAX_REPORT ) { return $unknown; } } } catch ( \Throwable $e ) { // An unreadable subdirectory must not abort the whole scan, but it must // not be invisible either: a skipped directory is unscanned ground, and // a clean verdict over unscanned ground is a false reassurance. ++self::$unreadable; continue; } } return $unknown; } /** Whether a core-directory path is a known legitimate non-manifest file. */ public static function is_core_exception( string $rel ): bool { foreach ( self::CORE_DIR_EXCEPTIONS as $prefix ) { if ( 0 === strpos( $rel, $prefix ) ) { return true; } } return false; } /** * Executable files under wp-content/uploads. * * The highest-signal check here, and one the previous implementation did not make * at all. A media library has no legitimate reason to hold PHP, and dropping a * payload into uploads is how most real WordPress compromises persist. * * @return array Paths relative to the uploads directory. */ public static function scan_uploads_executables(): array { $dir = wp_get_upload_dir(); $base = isset( $dir['basedir'] ) ? (string) $dir['basedir'] : ''; if ( '' === $base || ! is_dir( $base ) ) { return array(); } $found = array(); try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $it as $file ) { if ( ! $file->isFile() ) { continue; } $ext = strtolower( (string) $file->getExtension() ); if ( ! in_array( $ext, self::EXECUTABLE_EXT, true ) ) { continue; } // A file that contains no executable code is a directory guard, not a // payload. Dozens of plugins drop an empty or "Silence is golden." // index.php into their own upload folders to prevent directory // listing, and a live scan of this very site found 18 of them: every // one would have been reported as CRITICAL on a completely clean // install. Judged by CONTENT rather than by filename, so a real // payload called index.php is still caught. if ( self::is_inert_php( (string) $file->getPathname() ) ) { continue; } // The other guard shape: a stub that answers 404 and stops. See is_guard_stub(). if ( self::is_guard_stub( (string) $file->getPathname() ) ) { continue; } $found[] = ltrim( str_replace( '\\', '/', substr( (string) $file->getPathname(), strlen( $base ) ) ), '/' ); if ( count( $found ) >= self::MAX_REPORT ) { break; } } } catch ( \Throwable $e ) { // Same reasoning as the core walk: report what was found, and record that // the uploads tree was not fully traversed. ++self::$unreadable; return $found; } return $found; } /** * Largest file worth inspecting for inertness. A directory guard is a few dozen * bytes; anything substantial with an executable extension in uploads is worth * reporting whatever it contains. */ const INERT_MAX_BYTES = 4096; /** * Whether a PHP file contains no executable code. * * Tokenised rather than pattern-matched, because the question is precisely "are * there any real tokens here", and that is what the tokeniser answers. An open * tag, a close tag, comments and whitespace are inert; a single statement is not. * That cannot be gamed by naming a payload index.php or by hiding code in a * comment-heavy file, because the payload itself still produces tokens. * * Conservative on every failure path: anything it cannot read or cannot parse is * reported as NOT inert, so a suspicious file is never hidden by this check. * * @since 1.10.457 */ public static function is_inert_php( string $path ): bool { if ( ! is_readable( $path ) ) { return false; } // Suppressed deliberately: this walks a directory tree it does not control, so // a file removed between the scandir and this call is expected, not exceptional. // Every failure path below returns "not inert", so a suppressed error can only // ever make the scan MORE cautious, never less. $size = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see above; failure means "report it" if ( false === $size || $size > self::INERT_MAX_BYTES ) { return false; } if ( 0 === $size ) { return true; // A zero-byte guard file. } $code = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file read, never a URL; WP_Filesystem is not available in a cron context and would add nothing here if ( false === $code ) { return false; } try { $tokens = @token_get_all( $code ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a malformed file emits parse warnings; it is then reported, which is the safe outcome } catch ( \Throwable $e ) { return false; } if ( ! is_array( $tokens ) ) { return false; } $inert = array( T_OPEN_TAG, T_CLOSE_TAG, T_OPEN_TAG_WITH_ECHO, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ); foreach ( $tokens as $token ) { // A bare string token (';', '}', …) is code. if ( ! is_array( $token ) ) { return false; } if ( T_INLINE_HTML === $token[0] ) { // Text outside PHP tags is only inert when it is whitespace. if ( '' !== trim( (string) $token[1] ) ) { return false; } continue; } if ( ! in_array( $token[0], $inert, true ) ) { return false; } } return true; } /** * Functions a directory guard may call. Nothing here can execute code or touch the * filesystem: they emit a response header or stop the request. That is the whole * vocabulary of a "you cannot browse this folder" stub. */ const GUARD_FUNCTIONS = array( 'header', 'http_response_code', 'exit', 'die' ); /** * Whether a small PHP file is a directory guard rather than a payload. * * is_inert_php() covers the "Silence is golden." shape, which is most of them. It * does not cover the other common shape, a stub that answers 404 and stops: * * self::INERT_MAX_BYTES || 0 === $size ) { return false; } $code = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file read if ( false === $code ) { return false; } try { $tokens = @token_get_all( $code ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed means "report it" } catch ( \Throwable $e ) { return false; } if ( ! is_array( $tokens ) ) { return false; } $allowed_types = array( T_OPEN_TAG, T_CLOSE_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_VARIABLE, // only $_SERVER, checked below T_STRING, // only GUARD_FUNCTIONS, checked below T_EXIT, // exit / die ); // Punctuation a guard needs: call parens, argument/array brackets, concatenation, // statement ends. $allowed_chars = array( '(', ')', '[', ']', '.', ';', ',' ); $saw_guard_call = false; foreach ( $tokens as $token ) { if ( ! is_array( $token ) ) { if ( ! in_array( $token, $allowed_chars, true ) ) { return false; } continue; } if ( T_INLINE_HTML === $token[0] ) { if ( '' !== trim( (string) $token[1] ) ) { return false; } continue; } if ( ! in_array( $token[0], $allowed_types, true ) ) { return false; } if ( T_VARIABLE === $token[0] && '$_SERVER' !== $token[1] ) { return false; } if ( T_STRING === $token[0] ) { if ( ! in_array( strtolower( (string) $token[1] ), self::GUARD_FUNCTIONS, true ) ) { return false; } $saw_guard_call = true; } if ( T_EXIT === $token[0] ) { $saw_guard_call = true; } } // A file with no call at all is is_inert_php()'s business, not this one. return $saw_guard_call; } // ── Plugin verification ────────────────────────────────────────────── // // Core was the easy half. A plugin file is the likelier target: plugins are // compromised far more often than core, and until now a modified plugin file was // invisible to this plugin entirely. // // WordPress.org publishes a manifest per plugin per version. Two details of that // format matter and both were verified against the live endpoint rather than // assumed: // // A hash may be a STRING or an ARRAY of acceptable strings. readme.txt in // hello-dolly lists seven valid md5s, because line endings and trailing whitespace // legitimately vary; comparing against a string only would report readme.txt as // modified for practically every plugin on the site. // // A plugin not distributed through wordpress.org answers 404. That is "cannot // verify", NOT "modified", and premium plugins are entirely normal. /** Stored plugin verification result. */ const OPTION_PLUGIN_RESULT = 'csdt_integrity_plugins'; /** Resume checkpoint for plugin verification. */ const OPTION_PLUGIN_STATE = 'csdt_integrity_plugin_state'; /** Manifest endpoint: slug, then version. */ const PLUGIN_SUMS_URL = 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json'; /** * Manifest cache lifetime. A published version's file list never changes, so this * is long on purpose: it keeps a routine check from hammering wordpress.org. */ const PLUGIN_SUMS_TTL = MONTH_IN_SECONDS; /** Manifests fetched per run, so one pass cannot make dozens of HTTP calls. */ const MAX_PLUGIN_FETCH = 4; /** * Whether a file's hash matches what wordpress.org published. * * Accepts a string or a list, because the manifest uses both. Comparison is * hash_equals() per candidate rather than a loose compare. * * @param mixed $expected String or array of acceptable hashes. * @param string $actual Hash computed from disk. * @since 1.10.458 */ public static function hash_matches( $expected, string $actual ): bool { if ( is_string( $expected ) ) { return hash_equals( $expected, $actual ); } if ( is_array( $expected ) ) { foreach ( $expected as $candidate ) { if ( is_string( $candidate ) && hash_equals( $candidate, $actual ) ) { return true; } } } return false; } /** * The published manifest for one plugin version, or null when there is none. * * A null return means "not distributed through wordpress.org, or no manifest for * this version" and must never be presented as a failed check. The negative is * cached too, so a site full of premium plugins does not re-request 404s. * * @return array>|null Map of relative path => hashes. * @since 1.10.458 */ public static function plugin_manifest( string $slug, string $version ): ?array { if ( '' === $slug || '' === $version ) { return null; } $key = 'csdt_psum_' . md5( $slug . '|' . $version ); $cached = get_transient( $key ); if ( 'none' === $cached ) { return null; } if ( is_array( $cached ) ) { return $cached; } $res = wp_remote_get( sprintf( self::PLUGIN_SUMS_URL, rawurlencode( $slug ), rawurlencode( $version ) ), array( 'timeout' => 8, 'user-agent' => 'CloudScale-Cyber-DevTools/integrity (+https://cloudscale.consulting)', ) ); $r = CSDT_Http::json( $res ); if ( 0 === $r['code'] ) { // A transport failure is not evidence of anything. Cached briefly so a // flaky network does not retry on every pass, but far shorter than a real // answer, because this one may well succeed next time. set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } if ( 200 !== $r['code'] ) { set_transient( $key, 'none', self::PLUGIN_SUMS_TTL ); return null; } $body = $r['data']; if ( null === $body || ! isset( $body['files'] ) || ! is_array( $body['files'] ) ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } set_transient( $key, $body['files'], self::PLUGIN_SUMS_TTL ); return $body['files']; } /** * Verifies installed plugins against their published manifests. * * Reports MODIFIED files only. It deliberately does NOT report files present in a * plugin directory but absent from the manifest: plugins routinely write caches, * logs and generated assets inside their own folders, so that list would be almost * entirely false positives. Eighteen of those shipped in the uploads check earlier * in this work and had to be undone; the lesson is applied here in advance rather * than after the fact. * * @param int $budget Seconds this run may spend. * @return array{done:bool,modified:array,unverifiable:array,checked:int,progress:int,total:int} * @since 1.10.458 */ public static function scan_plugins( int $budget = self::DEFAULT_BUDGET ): array { // Existence-checked, not just function_exists-guarded. A hardened install can // have no wp-admin at all, and an unconditional require there is a fatal // error on a cron run rather than a graceful skip. if ( ! function_exists( 'get_plugins' ) && is_readable( CSDT_Paths::root_file( 'wp-admin/includes/plugin.php' ) ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! function_exists( 'get_plugins' ) ) { // Cannot enumerate plugins, so report nothing rather than claiming a clean // pass over something never looked at. return array( 'done' => false, 'modified' => array(), 'unverifiable' => array(), 'checked' => 0, 'progress' => 0, 'total' => 0, ); } $installed = (array) get_plugins(); // Stable order, so a checkpoint index means the same thing next run. $files = array_keys( $installed ); sort( $files ); $total = count( $files ); $state = get_option( self::OPTION_PLUGIN_STATE, array() ); if ( ! is_array( $state ) || ( $state['count'] ?? -1 ) !== $total ) { // A plugin installed or removed mid-pass invalidates the index. $state = array( 'count' => $total, 'index' => 0, 'modified' => array(), 'unverifiable' => array(), ); } $index = max( 0, (int) ( $state['index'] ?? 0 ) ); $modified = is_array( $state['modified'] ?? null ) ? $state['modified'] : array(); $unverifiable = is_array( $state['unverifiable'] ?? null ) ? $state['unverifiable'] : array(); $deadline = time() + max( 1, $budget ); $fetched = 0; $checked = 0; while ( $index < $total && $fetched < self::MAX_PLUGIN_FETCH && time() < $deadline ) { $plugin_file = $files[ $index ]; $data = $installed[ $plugin_file ] ?? array(); $version = (string) ( $data['Version'] ?? '' ); $dir = dirname( $plugin_file ); // A single-file plugin (hello.php) has no directory; its slug is the // filename without the extension, and those are on wordpress.org too. $slug = ( '.' === $dir ) ? basename( $plugin_file, '.php' ) : $dir; ++$index; if ( '' === $version ) { $unverifiable[ $slug ] = 'no version header'; continue; } ++$fetched; $manifest = self::plugin_manifest( $slug, $version ); if ( null === $manifest ) { $unverifiable[ $slug ] = 'not on wordpress.org, or no manifest for this version'; continue; } $base = trailingslashit( CSDT_Paths::plugins_dir() ) . ( '.' === $dir ? '' : $dir . '/' ); foreach ( $manifest as $rel => $hashes ) { $abs = $base . $rel; if ( ! is_readable( $abs ) ) { // A file the manifest lists but the site does not have. Not // reported: partial installs and deliberately pruned files are // common, and this check is about altered code. continue; } ++$checked; $expected = $hashes['md5'] ?? null; if ( null === $expected ) { continue; } if ( ! self::hash_matches( $expected, (string) md5_file( $abs ) ) ) { $modified[] = $slug . '/' . $rel; if ( count( $modified ) >= self::MAX_REPORT ) { break 2; } } } } $done = $index >= $total; update_option( self::OPTION_PLUGIN_STATE, array( 'count' => $total, 'index' => $done ? 0 : $index, 'modified' => $done ? array() : $modified, 'unverifiable' => $done ? array() : $unverifiable, ), false ); $result = array( 'done' => $done, 'modified' => array_values( array_unique( $modified ) ), 'unverifiable' => $unverifiable, 'checked' => $checked, 'progress' => $done ? $total : $index, 'total' => $total, ); if ( $done ) { $result['ts'] = time(); update_option( self::OPTION_PLUGIN_RESULT, $result, false ); } return $result; } /** Last stored plugin verification result. */ public static function last_plugin_result(): array { $r = get_option( self::OPTION_PLUGIN_RESULT, array() ); return is_array( $r ) ? $r : array(); } // ── Themes ─────────────────────────────────────────────────────────── // // Themes are the one component wordpress.org publishes NO checksum manifest for. // /plugin-checksums//.json exists; /theme-checksums/ answers 404, // verified, not assumed. So a theme is verified by hashing the official zip instead, // which is heavier and is therefore budgeted and cached hard. // // And a large share of real themes are not in the directory at all: this very site runs // a bespoke theme. For those there is no upstream to compare against, so the only // possible check is against a snapshot the administrator explicitly takes. That is a // weaker guarantee and it is reported as such, "no snapshot taken" is never dressed up // as "verified". /** Stored theme verification result. */ const OPTION_THEME_RESULT = 'csdt_integrity_themes'; /** Administrator-taken hash snapshots for themes with no upstream. */ const OPTION_THEME_SNAPSHOTS = 'csdt_integrity_theme_snaps'; /** Official zip: slug, then version. */ const THEME_ZIP_URL = 'https://downloads.wordpress.org/theme/%s.%s.zip'; /** Derived hash map cache. A published version never changes, so this is long. */ const THEME_SUMS_TTL = MONTH_IN_SECONDS; /** * Zips downloaded per run. * * One. A theme zip is megabytes, the default WordPress theme is over 3 MB, so this is * nothing like the four cheap JSON manifests the plugin pass allows itself. A site with * six directory themes takes six passes to verify them all, and that is the right * trade on a Raspberry Pi. */ const MAX_THEME_FETCH = 1; /** * Largest zip that will be downloaded, bytes. * * Some commercial themes are enormous. Above this the theme is reported unverifiable * with the size as the reason, rather than the check quietly exhausting memory on a * small host, a check that kills the request it runs in is worse than no check. */ const THEME_ZIP_MAX = 25000000; /** Files hashed into a snapshot, so one theme cannot fill an option row. */ const THEME_SNAPSHOT_MAX_FILES = 3000; /** * Files inside a theme worth hashing. * * Code and templates. Deliberately not images, fonts or minified vendor bundles: they * are the bulk of a theme's bytes and the least interesting to an attacker, and hashing * them would make every routine media change look like tampering. */ const THEME_HASH_EXT = array( 'php', 'js', 'css', 'json', 'html', 'htm', 'twig', 'inc', 'phtml' ); /** * The official file hashes for one theme version, derived from the published zip. * * Null means "no upstream copy", a bespoke or commercial theme, and must never be * shown as a failed check. The negative is cached for a month like the positive, so a * site full of custom themes does not re-download 404s. * * @since 1.10.493 * @param string $slug Theme directory name. * @param string $version Installed version. * @return array|null Map of theme-relative path => sha256. */ public static function theme_manifest( string $slug, string $version ): ?array { if ( '' === $slug || '' === $version ) { return null; } $key = 'csdt_tsum_' . md5( $slug . '|' . $version ); $cached = get_transient( $key ); if ( 'none' === $cached ) { return null; } if ( is_array( $cached ) ) { return $cached; } // Without ZipArchive nothing here is possible. Cached briefly, not for a month: a // host can gain the extension, and this is a capability problem rather than an // answer about the theme. if ( ! class_exists( 'ZipArchive' ) ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } $url = sprintf( self::THEME_ZIP_URL, rawurlencode( $slug ), rawurlencode( $version ) ); // HEAD first, purely to learn the size before committing to the download. $head = wp_remote_head( $url, array( 'timeout' => 8, 'redirection' => 3, 'user-agent' => 'CloudScale-Cyber-DevTools/integrity (+https://cloudscale.consulting)', ) ); if ( is_wp_error( $head ) ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } $code = (int) wp_remote_retrieve_response_code( $head ); if ( 404 === $code || 410 === $code ) { // A definite "not in the directory". Cached long, because that does not change. set_transient( $key, 'none', self::THEME_SUMS_TTL ); return null; } if ( $code < 200 || $code > 399 ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } $size = (int) wp_remote_retrieve_header( $head, 'content-length' ); if ( $size > self::THEME_ZIP_MAX ) { set_transient( $key, 'none', self::THEME_SUMS_TTL ); return null; } // Streamed to a temp file rather than held in memory, the way core's download_url() // does it; a 25 MB string on a small host is avoidable. Requested directly rather than // through download_url() because that takes no request arguments, and its default // user agent carries this site's URL, which the "WordPress.org APIs" readme entry says // is never sent. if ( ! function_exists( 'wp_tempnam' ) ) { if ( ! is_readable( CSDT_Paths::root_file( 'wp-admin/includes/file.php' ) ) ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } require_once ABSPATH . 'wp-admin/includes/file.php'; } $tmp = wp_tempnam( $slug . '.zip' ); if ( ! $tmp ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } $resp = wp_safe_remote_get( $url, array( 'timeout' => 30, 'stream' => true, 'filename' => $tmp, 'user-agent' => CSDT_DevTools::USER_AGENT, ) ); if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { wp_delete_file( $tmp ); set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } $sums = self::hash_zip_entries( (string) $tmp, $slug ); wp_delete_file( (string) $tmp ); if ( null === $sums || empty( $sums ) ) { set_transient( $key, 'none', HOUR_IN_SECONDS ); return null; } set_transient( $key, $sums, self::THEME_SUMS_TTL ); return $sums; } /** * Hashes the interesting entries of a theme zip, keyed relative to the theme root. * * Separate and public so it can be tested against a real zip without the network. * * @since 1.10.493 * @param string $zip_path Local zip file. * @param string $slug Expected top-level directory inside the zip. * @return array|null Null when the zip cannot be opened. */ public static function hash_zip_entries( string $zip_path, string $slug ): ?array { if ( ! class_exists( 'ZipArchive' ) || ! is_readable( $zip_path ) ) { return null; } $zip = new ZipArchive(); if ( true !== $zip->open( $zip_path ) ) { return null; } $out = array(); $prefix = $slug . '/'; for ( $i = 0; $i < $zip->numFiles; $i++ ) { $name = (string) $zip->getNameIndex( $i ); if ( '' === $name || '/' === substr( $name, -1 ) ) { continue; // Directory entry. } if ( 0 !== strpos( $name, $prefix ) ) { continue; // Not under the expected root; ignored rather than guessed at. } $rel = substr( $name, strlen( $prefix ) ); if ( ! self::theme_file_is_hashable( $rel ) ) { continue; } $body = $zip->getFromIndex( $i ); if ( false === $body ) { continue; } $out[ $rel ] = hash( 'sha256', $body ); } $zip->close(); return $out; } /** * Whether a theme-relative path is one worth hashing. * * @since 1.10.493 * @param string $rel Theme-relative path. * @return bool */ public static function theme_file_is_hashable( string $rel ): bool { $ext = strtolower( (string) pathinfo( $rel, PATHINFO_EXTENSION ) ); return in_array( $ext, self::THEME_HASH_EXT, true ); } /** * Hashes the code files of an installed theme on disk. * * @since 1.10.493 * @param string $dir Absolute theme directory. * @return array Map of theme-relative path => sha256. */ public static function hash_theme_dir( string $dir ): array { $dir = rtrim( $dir, '/' ); if ( '' === $dir || ! is_dir( $dir ) ) { return array(); } $out = array(); try { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ( $it as $file ) { if ( count( $out ) >= self::THEME_SNAPSHOT_MAX_FILES ) { break; } if ( ! $file->isFile() ) { continue; } $rel = ltrim( str_replace( $dir, '', (string) $file->getPathname() ), '/' ); if ( ! self::theme_file_is_hashable( $rel ) ) { continue; } $path = (string) $file->getPathname(); if ( ! is_readable( $path ) ) { ++self::$unreadable; continue; } $hash = hash_file( 'sha256', $path ); if ( is_string( $hash ) ) { $out[ $rel ] = $hash; } } } catch ( \Throwable $e ) { // An unreadable subtree is counted, never treated as "nothing to see". ++self::$unreadable; } return $out; } /** * Compares an on-disk hash map against an expected one. * * Reports modified and missing files. Deliberately NOT extra files: a theme legitimately * gains a child-theme override, a generated CSS file or an editor backup, and reporting * those would be the uploads false-positive storm all over again, eighteen of those * shipped once in this work and had to be undone. * * @since 1.10.493 * @param array $expected Reference hashes. * @param array $actual Hashes from disk. * @return array{modified:array,missing:array} */ public static function diff_hashes( array $expected, array $actual ): array { $modified = array(); $missing = array(); foreach ( $expected as $rel => $hash ) { if ( ! isset( $actual[ $rel ] ) ) { $missing[] = (string) $rel; continue; } if ( ! self::hash_matches( $hash, (string) $actual[ $rel ] ) ) { $modified[] = (string) $rel; } } sort( $modified ); sort( $missing ); return array( 'modified' => $modified, 'missing' => $missing, ); } /** * Filesystem mtime for a short list of already-identified paths, relative to $dir. * * Read only for files diff_hashes() has already proved changed by content hash, never * used to DECIDE a file changed, only to say when. That distinction is the whole reason * this is safe where the retired core check's mtime baseline was not: mtime is trivially * forgeable with `touch -r` (security-parity-todo.md item 2's original finding), so it * must never be the detector, but it is honest corroborating context once a real hash * mismatch has already established that something changed. * * @since 1.10.691 * @param string $dir Absolute theme directory. * @param array $rels Theme-relative paths already known to be modified. * @return array rel path => unix mtime. A path whose mtime cannot be read * (already deleted, or a permissions problem) is simply absent * rather than reported as zero, which would print as 1970. */ public static function file_mtimes( string $dir, array $rels ): array { $dir = rtrim( $dir, '/' ); $out = array(); foreach ( $rels as $rel ) { $path = $dir . '/' . $rel; $mt = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- false on a missing/unreadable file; handled on the next line, not worth a stat() first if ( false !== $mt ) { $out[ $rel ] = (int) $mt; } } return $out; } /** * Records the current state of a theme as the reference to compare against. * * For themes with no upstream this is the only check available, and it is only worth * anything if the administrator takes it when they believe the site is clean, which is * exactly the flaw the old mtime baseline had, except that one took itself silently at * first run and then presented the result as verification. This one is an explicit act, * is dated, and its weaker status is stated wherever it is reported. * * @since 1.10.493 * @param string $slug Theme directory name. * @return array{files:int,ts:int}|null Null when the theme cannot be read. */ public static function snapshot_theme( string $slug ): ?array { $slug = trim( $slug ); if ( '' === $slug || false !== strpos( $slug, '.' ) || false !== strpos( $slug, '/' ) ) { return null; // No traversal, and no relative segments. } $root = self::themes_root(); if ( '' === $root ) { return null; // No theme root: cannot snapshot, and must not claim to have. } $dir = trailingslashit( $root ) . $slug; if ( ! is_dir( $dir ) ) { return null; } $hashes = self::hash_theme_dir( $dir ); if ( empty( $hashes ) ) { return null; } $snaps = get_option( self::OPTION_THEME_SNAPSHOTS, array() ); if ( ! is_array( $snaps ) ) { $snaps = array(); } $entry = array( 'ts' => time(), 'hashes' => $hashes, ); $snaps[ $slug ] = $entry; update_option( self::OPTION_THEME_SNAPSHOTS, $snaps, false ); return array( 'files' => count( $hashes ), 'ts' => (int) $entry['ts'], ); } /** Stored snapshot for one theme, or null. */ public static function theme_snapshot( string $slug ): ?array { $snaps = get_option( self::OPTION_THEME_SNAPSHOTS, array() ); if ( ! is_array( $snaps ) || ! isset( $snaps[ $slug ] ) || ! is_array( $snaps[ $slug ] ) ) { return null; } return $snaps[ $slug ]; } /** * The themes directory, overridable in tests. * * get_theme_root() is the only supported source, because it is the only one that respects * a custom theme root. This used to fall back to a path built from the wp-content constant * standalone test harness; that branch was unreachable in production (this class only runs * inside WordPress, where get_theme_root() always exists) and no harness ever called it, * so it was removed rather than annotated, WordPress.org reads the constant, not the * comment explaining it. A harness that needs a root sets $GLOBALS['csdt_themes_root']. * * Returns '' when there is no root to report. Both callers below guard on that explicitly * and report "cannot scan" rather than "scanned and found nothing", an empty root would * otherwise build the path '/', which does not exist and would read as a clean scan. * * @since 1.10.493 * @return string Absolute path, no trailing slash guaranteed. '' if indeterminable. */ public static function themes_root(): string { if ( isset( $GLOBALS['csdt_themes_root'] ) ) { return (string) $GLOBALS['csdt_themes_root']; } return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : ''; } /** * Verifies installed themes. * * Three outcomes per theme, and the third is the point: `verified` against the official * zip, `snapshot` against an administrator-taken reference, or `unverifiable` with the * reason given. A theme that cannot be checked is never counted as clean. * * @since 1.10.493 * @param int $budget Seconds this run may spend. * @return array{done:bool,themes:array,modified:array,unverifiable:array,checked:int} */ public static function scan_themes( int $budget = self::DEFAULT_BUDGET ): array { $deadline = time() + max( 1, $budget ); $fetched = 0; $themes = array(); $modified = array(); $unverif = array(); $installed = function_exists( 'wp_get_themes' ) ? wp_get_themes() : array(); if ( isset( $GLOBALS['csdt_test_themes'] ) ) { $installed = (array) $GLOBALS['csdt_test_themes']; } // Resolved once, outside the loop, and checked: with no theme root every $dir below // would be '/', which never exists, and the scan would report every theme as // verifiable-but-absent instead of admitting it could not look. $root = self::themes_root(); foreach ( $installed as $slug => $theme ) { $slug = (string) $slug; $version = is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Version' ) : (string) ( is_array( $theme ) ? ( $theme['version'] ?? '' ) : '' ); $dir = '' === $root ? '' : trailingslashit( $root ) . $slug; $row = array( 'slug' => $slug, 'version' => $version, 'status' => 'unverifiable', 'reason' => '', 'modified' => array(), 'missing' => array(), // WHEN THE VIOLATION WAS DETECTED, the moment THIS pass compared the theme, // not the baseline it compared against. "Checked against: snapshot 2026-08-18" // answers what the theme was compared to; a reader asking "when did this show // up" needs a second, different timestamp, and until now nothing on this row // carried one at all. Reported 2026-08-18: "how do I know when the violation // was detected, or when the file was modified". 'checked_at' => 0, // WHEN THE FILE ITSELF LAST CHANGED, per modified path, from the filesystem's // own mtime, read once here rather than left for the panel to stat() on every // render. Stated with its real limitation in the panel, not left implied: mtime // is exactly what CSDT_Threat_Monitor's OLD core check used as its only signal // and was rejected for (forgeable with touch -r, security-parity-todo.md item 2), // sound here only because the HASH already proved a real change, and mtime // is corroborating context for it, not the detection mechanism itself. 'mtimes' => array(), ); // Without a root there is nothing to hash, and hashing nothing is not a clean // result: diff_hashes() would return every manifest file as MISSING and the theme // would be reported `modified`, a false critical. Say "cannot verify" instead. if ( '' === $dir ) { $row['reason'] = 'theme root could not be determined'; $unverif[] = $slug . ', ' . $row['reason']; $themes[ $slug ] = $row; continue; } $manifest = null; if ( '' !== $version && $fetched < self::MAX_THEME_FETCH && time() < $deadline ) { $manifest = self::theme_manifest( $slug, $version ); ++$fetched; } elseif ( '' !== $version ) { // Already cached from an earlier pass? Read it without spending a fetch. $cached = get_transient( 'csdt_tsum_' . md5( $slug . '|' . $version ) ); if ( is_array( $cached ) ) { $manifest = $cached; } } if ( is_array( $manifest ) && ! empty( $manifest ) ) { $diff = self::diff_hashes( $manifest, self::hash_theme_dir( $dir ) ); $row['status'] = ( empty( $diff['modified'] ) && empty( $diff['missing'] ) ) ? 'verified' : 'modified'; $row['modified'] = $diff['modified']; $row['missing'] = $diff['missing']; $row['reason'] = 'wordpress.org'; $row['checked_at'] = time(); $row['mtimes'] = self::file_mtimes( $dir, $diff['modified'] ); } else { $snap = self::theme_snapshot( $slug ); if ( is_array( $snap ) && ! empty( $snap['hashes'] ) ) { $diff = self::diff_hashes( (array) $snap['hashes'], self::hash_theme_dir( $dir ) ); $row['status'] = ( empty( $diff['modified'] ) && empty( $diff['missing'] ) ) ? 'snapshot' : 'modified'; $row['modified'] = $diff['modified']; $row['missing'] = $diff['missing']; $row['checked_at'] = time(); $row['mtimes'] = self::file_mtimes( $dir, $diff['modified'] ); // DATE AND TIME, in the site's timezone. A snapshot is an assertion that the // theme was clean at a moment the administrator chose, so the moment is the // evidence, "snapshot 2026-08-18" cannot tell you whether it was taken before // or after the change you are investigating today. wp_date() rather than gmdate() // for the same reason the Telegram alerts carry local time: a reader comparing // this against their own logs should not have to convert from UTC first. $row['reason'] = 'snapshot ' . wp_date( 'Y-m-d H:i T', (int) ( $snap['ts'] ?? 0 ) ); } else { $row['status'] = 'unverifiable'; $row['reason'] = '' === $version ? 'no version declared' : 'not in the wordpress.org directory, and no snapshot taken'; } } if ( 'modified' === $row['status'] ) { foreach ( array_slice( $row['modified'], 0, 10 ) as $rel ) { $modified[] = $slug . '/' . $rel; } foreach ( array_slice( $row['missing'], 0, 10 ) as $rel ) { $modified[] = $slug . '/' . $rel . ' (missing)'; } } elseif ( 'unverifiable' === $row['status'] ) { $unverif[] = $slug . ', ' . $row['reason']; } $themes[ $slug ] = $row; if ( time() >= $deadline ) { break; } } $result = array( 'done' => true, 'ts' => time(), 'themes' => $themes, 'modified' => array_slice( $modified, 0, self::MAX_REPORT ), 'unverifiable' => array_slice( $unverif, 0, self::MAX_REPORT ), 'checked' => count( $themes ), ); update_option( self::OPTION_THEME_RESULT, $result, false ); return $result; } /** Last stored theme verification result. */ public static function last_theme_result(): array { $r = get_option( self::OPTION_THEME_RESULT, array() ); return is_array( $r ) ? $r : array(); } // ── Orchestration ──────────────────────────────────────────────────── /** * One monitoring pass. Safe to call from cron every few minutes. * * @param int $budget Seconds this run may spend. * @return array The stored result. */ public static function run( int $budget = self::DEFAULT_BUDGET ): array { self::$unreadable = 0; $core = self::scan_core( $budget ); // Uploads only on a completed core pass, so one run does not blow the budget // twice over. It is cheap: an extension check, no hashing. $uploads = $core['done'] ? self::scan_uploads_executables() : array(); // Plugins after core, for the same reason. Kept in its own stored result with // its own checkpoint, because it depends on the network and core does not: // a site with no outbound HTTPS still gets a complete core verdict. $plugins = $core['done'] ? self::scan_plugins( $budget ) : array(); // Themes last, and only on a completed plugin pass. A theme check may download a // multi-megabyte zip, so it must never compete for budget with the two cheaper // passes, on a small host that would starve them both. $themes = ( $core['done'] && ! empty( $plugins['done'] ) ) ? self::scan_themes( $budget ) : array(); $result = array( 'ts' => time(), 'wp_version' => (string) get_bloginfo( 'version' ), 'verified' => empty( $core['reason'] ), 'reason' => $core['reason'] ?? '', 'done' => (bool) $core['done'], 'progress' => (int) $core['progress'], 'total' => (int) $core['total'], 'modified' => array_slice( $core['modified'], 0, self::MAX_REPORT ), 'missing' => array_slice( $core['missing'], 0, self::MAX_REPORT ), 'unknown' => array_slice( $core['unknown'], 0, self::MAX_REPORT ), 'uploads' => $uploads, 'unreadable' => self::$unreadable, 'plugins' => isset( $plugins['modified'] ) ? (array) $plugins['modified'] : array(), 'plugins_done' => ! empty( $plugins['done'] ), 'plugins_total' => (int) ( $plugins['total'] ?? 0 ), 'plugins_progress' => (int) ( $plugins['progress'] ?? 0 ), 'unverifiable' => isset( $plugins['unverifiable'] ) ? (array) $plugins['unverifiable'] : array(), // Themes kept under their own keys rather than merged into the plugin ones. // "Modified" means the same thing but "unverifiable" does not: a premium plugin // with no manifest is routine, whereas an unverifiable THEME is the case this // whole addition exists to make visible. 'themes' => isset( $themes['modified'] ) ? (array) $themes['modified'] : array(), 'themes_unverif' => isset( $themes['unverifiable'] ) ? (array) $themes['unverifiable'] : array(), 'themes_checked' => (int) ( $themes['checked'] ?? 0 ), ); // Only overwrite the stored result on a completed pass, or when the manifest // could not be fetched. A partial pass must not replace a finished verdict // with a half-empty one, which would read as "nothing found". if ( $core['done'] || ! empty( $core['reason'] ) ) { update_option( self::OPTION_RESULT, $result, false ); } else { $prev = get_option( self::OPTION_RESULT, array() ); if ( is_array( $prev ) ) { $prev['progress'] = $result['progress']; $prev['total'] = $result['total']; $prev['done'] = false; update_option( self::OPTION_RESULT, $prev, false ); } } return $result; } /** Last stored result, normalised. */ public static function last_result(): array { $r = get_option( self::OPTION_RESULT, array() ); return is_array( $r ) ? $r : array(); } /** * Findings that warrant paging someone, and the ones that do not. * * A modified core file, an unknown file in a core directory, or anything * executable in uploads is evidence of compromise. A MISSING core file is not: * deleting readme.html, license.txt and wp-config-sample.php is standard * hardening, and this plugin's own Fix Recipes recommend exactly that. Paging * for a change we advised would train the reader to ignore the alert. * * @param array $result A result from run(). * @return array{count:int,lines:array} */ public static function alertable( array $result ): array { $lines = array(); foreach ( (array) ( $result['modified'] ?? array() ) as $f ) { $lines[] = 'modified core file: ' . $f; } foreach ( (array) ( $result['unknown'] ?? array() ) as $f ) { $lines[] = 'unknown file in a core directory: ' . $f; } foreach ( (array) ( $result['uploads'] ?? array() ) as $f ) { $lines[] = 'executable file in uploads: ' . $f; } foreach ( (array) ( $result['plugins'] ?? array() ) as $f ) { $lines[] = 'modified plugin file: ' . $f; } // A modified theme file IS alertable, that is where a great deal of real tampering // lands. An UNVERIFIABLE theme is not: a bespoke theme with no snapshot is the normal // state of a custom-built site, and paging for it every run would train the reader to // ignore the alert. It is shown on the panel instead, which is where a one-off // decision belongs. foreach ( (array) ( $result['themes'] ?? array() ) as $f ) { $lines[] = 'modified theme file: ' . $f; } return array( 'count' => count( $lines ), 'lines' => $lines, ); } /** * Records a theme snapshot on request. * * @since 1.10.493 */ /** * Saves the two scheduling settings and reports the schedule that resulted. * * Returns the NEXT RUN it actually booked rather than echoing the choice back. Those are * different claims, and only the second one is evidence: a panel that says "Daily" because * that is what you picked tells you nothing about whether cron agreed. */ public static function ajax_settings_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $enabled = '1' === sanitize_text_field( wp_unslash( $_POST['enabled'] ?? '0' ) ) ? '1' : '0'; $freq = sanitize_text_field( wp_unslash( $_POST['frequency'] ?? '' ) ); if ( ! isset( self::FREQUENCIES[ $freq ] ) ) { $freq = self::FREQUENCY_DEFAULT; } try { update_option( self::OPTION_ENABLED, $enabled, false ); update_option( self::OPTION_FREQUENCY, $freq, false ); self::sync_schedule(); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } $next = wp_next_scheduled( self::CRON_HOOK ); wp_send_json_success( array( 'enabled' => '1' === $enabled, 'frequency' => $freq, 'next' => $next ? (int) $next : 0, 'next_text' => $next ? sprintf( /* translators: 1: local date and time of the next run, 2: how far away it is. */ __( 'Next run %1$s (in %2$s)', 'cloudscale-cyber-devtools' ), wp_date( 'Y-m-d H:i T', (int) $next ), human_time_diff( time(), (int) $next ) ) : __( 'Scheduled verification is off. Verify Now still works.', 'cloudscale-cyber-devtools' ), ) ); } public static function ajax_theme_snapshot(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $slug = sanitize_text_field( wp_unslash( $_POST['slug'] ?? '' ) ); try { $snap = self::snapshot_theme( $slug ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); return; } // Outside the try: a refusal here is the guard working, and letting the catch-all // rewrite it as "Internal error" would hide which of the two causes applied. if ( null === $snap ) { wp_send_json_error( __( 'That theme could not be read, so no snapshot was taken.', 'cloudscale-cyber-devtools' ) ); return; } // Re-scan so the row updates to "Matches your snapshot" immediately, rather than // showing "Not checked" until the next cron pass and looking as if nothing happened. self::scan_themes( 8 ); wp_send_json_success( array( 'msg' => sprintf( /* translators: %d: number of files hashed. */ _n( 'Snapshot taken: %d file hashed.', 'Snapshot taken: %d files hashed.', (int) $snap['files'], 'cloudscale-cyber-devtools' ), (int) $snap['files'] ), 'files' => (int) $snap['files'], ) ); } /** Clears the resume checkpoint and the alerted memo. */ public static function reset(): void { delete_option( self::OPTION_STATE ); delete_option( self::OPTION_ALERTED ); delete_option( self::OPTION_PLUGIN_STATE ); delete_option( self::OPTION_PLUGIN_RESULT ); delete_option( self::OPTION_THEME_RESULT ); // Snapshots are deliberately NOT cleared. They are an administrator's explicit // statement about what a clean theme looks like, and re-running a scan must not // silently discard the only reference a bespoke theme has. } // ── Admin UI ───────────────────────────────────────────────────────── /** * Human label and remediation advice for each finding category. * * Severity is deliberate. A modified core file or a webshell in uploads is a * compromise; a missing core file is usually hardening this plugin recommended. * Presenting those at the same weight is how a panel teaches people to ignore it. * * @return array */ public static function categories(): array { return array( 'uploads' => array( 'label' => __( 'Executable files in your uploads folder', 'cloudscale-cyber-devtools' ), 'sev' => __( 'CRITICAL', 'cloudscale-cyber-devtools' ), 'colour' => '#dc2626', 'advice' => __( 'A media folder should hold images and documents, not code. This is the most common way a compromise stays on a site after the original hole is closed. Do not open these files in a browser. Take a copy for evidence, delete them, then find out how they were written: look for an out-of-date plugin, and add a web-server rule that refuses to execute PHP anywhere under uploads. One legitimate exception is worth knowing: a few plugins place a tiny index.php in their own upload folder to stop directory listing, and some of those send a 404 header rather than being empty, so they are listed here because they do genuinely contain code. A file of a few lines that only sets headers or exits is that pattern. Anything longer, anything with a random-looking name, and anything containing eval, base64_decode or $_POST is not.', 'cloudscale-cyber-devtools' ), ), 'modified' => array( 'label' => __( 'Core files that do not match WordPress', 'cloudscale-cyber-devtools' ), 'sev' => __( 'CRITICAL', 'cloudscale-cyber-devtools' ), 'colour' => '#dc2626', 'advice' => __( 'The contents differ from what WordPress published for this exact version, so this is not an update in progress. Reinstall core from Dashboard, Updates, Reinstall Now, which overwrites these files without touching your content. If a file changes again afterwards, something on the site is rewriting it and reinstalling alone will not fix it.', 'cloudscale-cyber-devtools' ), ), 'unknown' => array( 'label' => __( 'Files WordPress never shipped, inside its own folders', 'cloudscale-cyber-devtools' ), 'sev' => __( 'HIGH', 'cloudscale-cyber-devtools' ), 'colour' => '#ea580c', 'advice' => __( 'These are in wp-admin or wp-includes but are not part of WordPress. Some hosts and security tools do legitimately add a file here, so identify before deleting. Anything with a random-looking name, or PHP inside a folder that otherwise holds only images or JavaScript, should be treated as hostile.', 'cloudscale-cyber-devtools' ), ), 'plugins' => array( 'label' => __( 'Plugin files that do not match what WordPress.org published', 'cloudscale-cyber-devtools' ), 'sev' => __( 'HIGH', 'cloudscale-cyber-devtools' ), 'colour' => '#ea580c', 'advice' => __( 'The file differs from the version published for the plugin you have installed. There are two innocent explanations and one serious one. Innocent: somebody edited the plugin deliberately, or a partial update left mixed versions behind. Serious: an attacker added code to a file that runs on every page. Reinstall the plugin from Plugins, Add New, which replaces the files without touching its settings, and see whether the finding returns. If it does, the change is being made after installation and the site needs proper investigation.', 'cloudscale-cyber-devtools' ), ), 'missing' => array( 'label' => __( 'Core files that are absent', 'cloudscale-cyber-devtools' ), 'sev' => __( 'INFO', 'cloudscale-cyber-devtools' ), 'colour' => '#64748b', 'advice' => __( 'Reported for completeness, and deliberately not alerted on. Removing readme.html, license.txt and wp-config-sample.php is ordinary hardening, and this plugin\'s own fix recipes suggest it. Worth a glance only if a file you did not remove appears here.', 'cloudscale-cyber-devtools' ), ), ); } /** * Renders the File Integrity panel. * * Deliberately reports three distinct states rather than two, because "we could * not check" is not "you are fine" and must never look like it: verified with * findings, verified clean, and unable to verify. */ public static function render_panel(): void { $r = self::last_result(); $cats = self::categories(); $enabled = self::is_enabled(); $verified = ! empty( $r['verified'] ); $done = ! empty( $r['done'] ); $progress = (int) ( $r['progress'] ?? 0 ); $total = (int) ( $r['total'] ?? 0 ); $ts = (int) ( $r['ts'] ?? 0 ); $counts = array(); $alertable = 0; foreach ( array_keys( $cats ) as $k ) { $counts[ $k ] = count( (array) ( $r[ $k ] ?? array() ) ); if ( 'missing' !== $k ) { $alertable += $counts[ $k ]; } } ?>
🧾 'What it compares against', 'rec' => 'How it works', 'html' => 'WordPress publishes an MD5 hash for every file it ships, for each version and language. This panel fetches that list from api.wordpress.org and hashes your files to compare.

There is no local baseline. That matters: a baseline has to be recorded at some point, and if the site was already compromised when it was recorded, the compromise becomes the definition of "normal" and can never be detected afterwards. Comparing against WordPress\'s own list means the very first check is as trustworthy as the hundredth.', ), array( 'name' => 'Executable files in uploads', 'rec' => 'Critical', 'html' => 'Your media folder should contain images, video and documents, never code. A .php file there is how most real compromises persist after the original way in has been closed, because it gives an attacker a URL they can call at any time.

This check covers .php and its variants, plus .phtml, .phar and .phps. It looks at file extension only, so it is fast and runs on every completed pass.', ), array( 'name' => 'Files WordPress never shipped', 'rec' => 'High', 'html' => 'Any file inside wp-admin/ or wp-includes/ that is not on WordPress\'s list is reported. That is how a dropped file is found, which a check that only re-examines files it already knows about can never do.

Translation files under wp-includes/languages/ are excluded, because they are downloaded per site rather than shipped, and reporting them would alarm every translated site.', ), array( 'name' => 'Why a missing file is only INFO', 'rec' => 'By design', 'html' => 'Deleting readme.html, license.txt and wp-config-sample.php is ordinary hardening, and this plugin\'s own fix recipes recommend it. Alerting on a change we advised would teach you to ignore these alerts, so a missing file is listed for completeness and never paged.', ), array( 'name' => 'What is not checked', 'rec' => 'Honest limits', 'html' => 'wp-content is excluded from core verification: your themes and plugins are yours to change, and WordPress\'s core list only covers the default ones it bundles.

Plugins from WordPress.org ARE verified, against the per-plugin checksums it publishes. Two gaps remain, and both are stated on screen with the result rather than left for you to assume:

  • Themes are not verified. WordPress.org publishes no theme equivalent, the /theme-checksums/ endpoint answers 404, so there is nothing to compare a theme against.
  • Paid and custom plugins are not verified, because no manifest exists for them. The result says how many fell into this group, so the number is never silently absorbed into a pass.
', ), array( 'name' => 'Why it runs in pieces', 'rec' => 'Performance', 'html' => 'A WordPress install is roughly two thousand files. Hashing all of them at once would stall a page load or be killed by a timeout on modest hosting, so each background run takes a time budget and resumes where the last one stopped. Progress is shown while that happens, and a partial pass never replaces a completed result, which would read as "nothing found".', ), ) ); ?>

✓


0 && ! $done && $total > 0 ) : ?>
⏳
$cat ) : $items = (array) ( $r[ $key ] ?? array() ); if ( empty( $items ) ) { continue; } ?>
()
  • 25 ) : ?>


12 ? '…' : '' ); ?>

'What a snapshot is', 'rec' => 'How it works', 'html' => 'A theme built for this site has no copy on wordpress.org, so there is no published checksum to compare against. Pressing Snapshot hashes the theme\'s files as they are right now and stores those hashes as a reference. Later runs compare against it and report anything that has changed since.', ), array( 'name' => 'It is weaker evidence than a checksum', 'rec' => 'Read this first', 'html' => 'A snapshot records what is on disk, not what is correct. If the theme was already tampered with when you took it, the tampering becomes the definition of "normal" and no later run can find it — which is precisely why the core check compares against WordPress\'s published list instead of a local baseline.

So take it when you have reason to trust the theme: straight after you deploy it yourself, not after investigating a problem.', ), array( 'name' => 'Why the date and time matter', 'rec' => 'By design', 'html' => 'The Checked against column shows the moment the snapshot was taken, in this site\'s timezone. That moment is the whole value of the result: "matches your snapshot" only means something once you know whether the snapshot predates the change you are looking into.', ), array( 'name' => 'Re-snapshot replaces the reference', 'rec' => 'Careful', 'html' => 'Re-snapshot overwrites the stored hashes with the theme as it is now, so anything currently reported as CHANGED stops being reported. Use it after a theme update you made deliberately; do not use it to clear a finding you have not explained.', ), array( 'name' => '"Checked against" vs "Detected" vs "modified on disk"', 'rec' => 'Three different times', 'html' => 'Checked against is when the reference (the snapshot, or nothing yet for a wordpress.org theme) was taken. Detected is when THIS row\'s comparison actually ran. Modified on disk, shown next to a changed file, is that file\'s filesystem timestamp.

The filesystem timestamp is shown for context only, never as the evidence: it is trivially forgeable with a tool like touch -r, which is exactly why this check compares file CONTENT by hash rather than by modification time. A file is reported changed because its hash no longer matches — the "modified on disk" date just says when, according to the filesystem, which may itself be wrong.', ), ), 'Themes get a snapshot because wordpress.org has nothing to check them against. It is a reference you create, so when you create it decides what it is worth.', true ); ?>

$t_row ) : ?>
0 ? esc_html( wp_date( 'Y-m-d H:i T', $t_checked ) . ' (' . human_time_diff( $t_checked ) . ' ago)' ) : esc_html__( 'not yet', 'cloudscale-cyber-devtools' ) ); ?>
0 ) { printf( /* translators: 1: local date and time, 2: how long ago. */ esc_html__( 'Last run %1$s (%2$s ago).', 'cloudscale-cyber-devtools' ), esc_html( wp_date( 'Y-m-d H:i T', $ts ) ), esc_html( human_time_diff( $ts ) ) ); } else { esc_html_e( 'It has not run yet.', 'cloudscale-cyber-devtools' ); } echo ' '; if ( ! $enabled ) { esc_html_e( 'Scheduled verification is off; Verify Now still works.', 'cloudscale-cyber-devtools' ); } elseif ( $next_ts > 0 ) { printf( /* translators: 1: local date and time of the next run, 2: how far off it is. */ esc_html__( 'Next run %1$s (in %2$s).', 'cloudscale-cyber-devtools' ), esc_html( wp_date( 'Y-m-d H:i T', $next_ts ) ), esc_html( human_time_diff( time(), $next_ts ) ) ); } else { // Said plainly rather than left blank: "on" with nothing booked is the // state where the panel looks armed and nothing is watching. esc_html_e( 'It is switched on but nothing is scheduled: check that WP-Cron is running.', 'cloudscale-cyber-devtools' ); } ?>
0 && $verified ) { printf( /* translators: 1: number of core files, 2: number of findings. */ esc_html__( 'Result: %1$s core files checked, %2$s finding(s) needing attention.', 'cloudscale-cyber-devtools' ), esc_html( number_format_i18n( $total ) ), esc_html( number_format_i18n( $alertable ) ) ); } elseif ( $ts > 0 ) { esc_html_e( 'Result: the check could not run, so nothing was compared.', 'cloudscale-cyber-devtools' ); } ?>
0 || ! empty( $flist ) ) : ?>
$d_count ) : ?>

(bool) $result['done'], 'verified' => (bool) $result['verified'], 'reason' => (string) $result['reason'], 'progress' => (int) $result['progress'], 'total' => (int) $result['total'], 'findings' => (int) $alertable['count'], ) ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __METHOD__ ); } } }