'6.5.2', 'plugin/' => '1.0', * 'theme/' => '2.3'. Read at the top of run_scan before it is * overwritten at the end, which is the whole point — the snapshot diff * on its own cannot tell "somebody edited this file" apart from "a * routine update rewrote it", and the difference is entirely whether the * package the file belongs to moved between the two snapshots. * * Bounded by the number of installed packages, autoload off: this is * read once per scan and once per Cases sync, never per row and never on * a front-end request. */ const PACKAGE_VERSIONS_OPTION = 'guardforge_integrity_package_versions'; /** How many paths of each kind the summary row will carry. */ const MAX_SUMMARY_PATHS = 50; /** * Worst first. The screen sorts on this and the cap below applies after * the sort, so a truncated list is never truncated from the top. */ const SEVERITY = [ 'unexpected' => 0, 'missing' => 1, 'modified' => 2 ]; /** Every verdict a file can carry, in the order the summary lists them. */ const CLASSES = [ 'official', 'modified', 'unexpected', 'missing', 'unreferenced' ]; /** * Core-scope files WordPress never publishes a hash for because every * site writes its own. Without this list wp-config.php — the one file * every install has and no build contains — was the first "backdoor" the * scan reported. It is not unexpected; it is unreferenced. */ const CORE_LOCAL_FILES = [ 'wp-config.php' ]; private static $instance = null; public static function instance() { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } /** The daily scan's hook. On the schedule only while the switch is on. */ const CRON_HOOK = 'guardforge_integrity_cron'; /** The owner's switch for the daily checksum lookup. */ const CONSENT_KEY = 'integrity_monitor'; /** * When the owner switched the daily scan on (Unix time, UTC). The first * screen reads "switched on, then silence" from it: a scan that was * switched on a day ago and has not completed once is not "not run yet". * Written by sync_enabled_since, gone when the switch is off. */ const ENABLED_SINCE_OPTION = 'guardforge_integrity_enabled_since'; /** Has the owner asked for the daily scan? */ public static function consent_given() { return ! empty( GUARDFORGE_Options::get( self::CONSENT_KEY ) ); } /** * Keep the switch-on date in line with the switch: stamp "now" when it is * on and nothing is stored, drop it when it is off. Idempotent, so a save * that did not touch the switch leaves the date exactly where it was, and * so the one-off migration (GUARDFORGE_Schema_Manager::stamp_enabled_since) * can give an already-on site its date without moving a stored one. */ public static function sync_enabled_since() { if ( ! self::consent_given() ) { delete_option( self::ENABLED_SINCE_OPTION ); return; } if ( (int) get_option( self::ENABLED_SINCE_OPTION, 0 ) <= 0 ) { update_option( self::ENABLED_SINCE_OPTION, time(), false ); } } /** * Bring the schedule into line with the switch. Called after every save, * so it has to be idempotent. */ public static function apply_consent() { self::sync_enabled_since(); if ( ! self::consent_given() ) { wp_clear_scheduled_hook( self::CRON_HOOK ); return; } if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { // A minute out, not a day: the owner who just ticked the box should // see a result during the same visit. wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::CRON_HOOK ); } } /** Take the daily scan off the schedule. */ public static function unschedule() { wp_clear_scheduled_hook( self::CRON_HOOK ); } private function __construct() { add_action( self::CRON_HOOK, [ $this, 'run_scan_silently' ] ); add_action( 'admin_post_guardforge_run_integrity', [ $this, 'handle_manual_scan' ] ); // The switch is not only turned by the Settings form: `wp option update` // and a deploy script write the same row. Any write of it keeps the // switch-on date and the daily scan in line with the switch, the way // the Settings save does: on schedules the scan (a minute out), off // takes it off, and a write that leaves the switch where it was moves // nothing. Nothing is stored that the off branch would have to delete, // so the Settings save and a script need not be told apart. Core hands // these hooks arguments apply_consent has no use for, so it is // registered to take none. A write that bypasses the options API fires // nothing; GUARDFORGE_Hardening_Score::recompute dates and un-dates // that one later. add_action( 'update_option_' . GUARDFORGE_Options::OPTION_KEY, [ __CLASS__, 'apply_consent' ], 10, 0 ); add_action( 'add_option_' . GUARDFORGE_Options::OPTION_KEY, [ __CLASS__, 'apply_consent' ], 10, 0 ); } /** * Run a full scan. Returns an array describing what changed, and what the * official checksums make of what is on disk. * * @return array{scan_id:int,total:int,changed:string,added:string,removed:string,classes:array} */ public function run_scan( $asked_for = false ) { // $asked_for is the "Run scan now" press: an owner asking for this one // scan is consent for this one scan, and does not put anything on the // schedule. The daily task is the switch's business (apply_consent). if ( ! $asked_for && ! self::consent_given() ) { return [ 'scan_id' => 0, 'total' => 0, 'changed' => [], 'added' => [], 'removed' => [], 'classes' => self::empty_counts(), ]; } global $wpdb; $table = $wpdb->prefix . 'guardforge_integrity'; $previous_scan_id = (int) get_option( self::SCAN_ID_OPTION, 0 ); $next_scan_id = $previous_scan_id + 1; $files = $this->collect_files(); /* * The previous snapshot, read through THIS run's own scope filter * (§6.4 K1-e review round 1, major 1). * * «Include plugins and themes» is a plain setting, and switching it * changes what the scan LOOKS at — never the files. Without the * filter, the first scan after it was switched on found no plugin or * theme path in the previous snapshot and so called every one of * them `added`, which the local diff then turned into up to fifty * cases about files nobody had touched in years; and switching it * back off reported the same files as `removed`, on the screen and, * with `integrity_notify` on, by email. * * Both directions are one fact — a scope present in only one of the * two snapshots has nothing to diff — and asking the database for * only the scopes this run walks answers both at once: a scope this * run does not walk cannot be `removed` (its rows are not read), and * a scope with no rows at all in the answer is on its FIRST snapshot, * which the `added` test below reads off $previous_scopes. Cheaper * than the unfiltered read it replaces, too. */ $scopes = array_keys( $files ); $previous = []; $previous_scopes = []; if ( $previous_scan_id > 0 && $scopes ) { $placeholders = implode( ',', array_fill( 0, count( $scopes ), '%s' ) ); // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal; every value goes through a placeholder. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT file_path, file_hash, scope FROM {$table} WHERE scan_id = %d AND scope IN ({$placeholders})", array_merge( [ $previous_scan_id ], array_map( 'strval', $scopes ) ) ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared foreach ( (array) $rows as $row ) { $previous[ $row->file_path ] = $row->file_hash; $previous_scopes[ (string) $row->scope ] = true; } } // §6.4 K1-e: what each package's version was when that snapshot was // taken — read BEFORE this run overwrites it at the end. $previous_versions = (array) get_option( self::PACKAGE_VERSIONS_OPTION, [] ); $now = current_time( 'mysql' ); $changed = []; $added = []; $seen = []; $hashes = []; foreach ( $files as $scope => $paths ) { foreach ( $paths as $path ) { if ( ! is_readable( $path ) ) { continue; } $hash = @hash_file( 'sha256', $path ); if ( $hash === false ) { continue; } $seen[ $path ] = $hash; $hashes[ $scope ][ $path ] = $hash; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin-owned table; $wpdb escapes insert/update/replace/delete values itself, and there is nothing to cache on a write. $wpdb->insert( $table, [ 'scan_id' => $next_scan_id, 'file_path' => $path, 'file_hash' => $hash, 'scope' => $scope, 'scanned_at' => $now, ] ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery if ( isset( $previous[ $path ] ) ) { if ( $previous[ $path ] !== $hash ) { $changed[] = $path; } } elseif ( $previous_scan_id > 0 && isset( $previous_scopes[ $scope ] ) ) { // A scope the previous snapshot has no path for is on its // first snapshot — see the read above. Its files are not // new, they are newly LOOKED AT, and the diff has nothing // to compare them against. $added[] = $path; } } } $removed = array_keys( array_diff_key( $previous, $seen ) ); $verdict = $this->classify( $hashes ); // §6.4 K1-e: the local diff, each entry carrying what it takes to // decide whether the change is explained — which package the file // belongs to, that package's version now and at the previous // snapshot, the file's current hash, and the verdict the published // reference reached for it on THIS run. Computing it here and not at // read time is what keeps the reference lookup inside the scan: by // the time anything renders, the answer is an option row. $versions = self::package_versions( array_keys( $hashes ) ); $local_changes = self::local_change_rows( $changed, $added, $hashes, $verdict, $versions, $previous_versions ); update_option( self::SCAN_ID_OPTION, $next_scan_id, false ); $all = GUARDFORGE_Options::all(); $all['integrity_last_scan'] = $now; update_option( GUARDFORGE_Options::OPTION_KEY, $all ); // Summary of this scan, for readers that must not re-diff two full // snapshots to answer "is anything off?" — the Suite dashboard card // reads exactly this row. // The counts answer "is anything off?"; the two path lists answer // "which files?", which nothing could until 1.2.0 — the screen had a // number and no way to name a single file behind it, so there was // nothing for a per-file control to hang on. Recomputing the diff on // every page view was the alternative and is worse: the trim below // keeps only two scans, so the snapshot the diff needs is routinely // gone. Capped, because this is an option row that gets autoloaded // nowhere but is still read whole, and a core update that rewrites two // thousand files is a normal Tuesday. update_option( self::LAST_RESULT_OPTION, [ 'scanned_at' => $now, 'total' => count( $seen ), 'changed' => count( $changed ), 'added' => count( $added ), 'removed' => count( $removed ), 'changed_paths' => self::relative_paths( $changed ), 'removed_paths' => self::relative_paths( $removed ), // What the official checksums said. Counts for the whole install, // and the flagged rows themselves — capped, and capped after the // severity sort, so the fifty that survive are the fifty worst. 'classes' => $verdict['counts'], 'flagged' => array_slice( $verdict['flagged'], 0, self::MAX_SUMMARY_PATHS ), 'flagged_total' => count( $verdict['flagged'] ), 'unreferenced' => $verdict['unreferenced'], 'queued' => $verdict['queued'], // §6.4 K1-e. `referenced` names the packages this run actually // held a published reference for, which is the only honest way a // later reader can tell "not flagged because it matches the // published package" apart from "not flagged because nobody // publishes one" — the counts in `unreferenced` say how many // files, never which package. `local_changes` is capped the same // way `flagged` is, and `local_changes_total` tells the truth // about what fell off the cap. 'referenced' => $verdict['referenced'], 'local_changes' => array_slice( $local_changes, 0, self::MAX_SUMMARY_PATHS ), 'local_changes_total' => count( $local_changes ), ], false ); // Last, and only after the diff above was built against the previous // values: this run's versions become the next run's "previously". update_option( self::PACKAGE_VERSIONS_OPTION, $versions, false ); // Trim stale scans — keep only the last two so the page can show the // current snapshot and we can fall back if a scan dies mid-run. $keep_min_id = max( 0, $next_scan_id - 1 ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- plugin-owned table ($wpdb->prefix + a literal) that no WordPress API reads; every value is a prepare placeholder or a fragment prepared on its own. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE scan_id < %d", $keep_min_id ) ); if ( GUARDFORGE_Options::get( 'integrity_notify' ) && ( $changed || $removed ) ) { $this->notify( $changed, $removed, $now ); } /** * Fires after an integrity scan completes. * * @param array $result Same shape returned from this method. */ do_action( 'guardforge/integrity_scan_complete', [ 'scan_id' => $next_scan_id, 'total' => count( $seen ), 'changed' => $changed, 'added' => $added, 'removed' => $removed, 'classes' => $verdict['counts'], ] ); return [ 'scan_id' => $next_scan_id, 'total' => count( $seen ), 'changed' => $changed, 'added' => $added, 'removed' => $removed, 'classes' => $verdict['counts'], ]; } /** * Absolute scan paths as the site sees them: relative to ABSPATH, capped. * The count beside them stays the true total, so a truncated list never * reads as "and that was all of it". * * @param string $paths * @return string */ private static function relative_paths( array $paths ) { $out = []; foreach ( array_slice( array_values( $paths ), 0, self::MAX_SUMMARY_PATHS ) as $path ) { $out[] = ltrim( str_replace( ABSPATH, '', (string) $path ), '/' ); } return $out; } /** Every class at nought, so a reader never has to test for a key. */ public static function empty_counts() { return array_fill_keys( self::CLASSES, 0 ); } // ─────────────────── the local diff, reconciled (§6.4 K1-e) ───────────── /** * The installed version of every package this scan actually walked, keyed * the way a flagged path can be traced back to it ('core', * 'plugin/', 'theme/'). * * Only the scopes in $scopes are asked about: on a site with * `integrity_scope_extras` off the scan never looks at plugins or themes, * and enumerating them anyway would put a get_plugins/wp_get_themes * call into a scan that has no use for the answer. * * @param string $scopes Scope names this scan collected hashes for. * @return array */ private static function package_versions( array $scopes ) { $out = []; if ( in_array( 'core', $scopes, true ) ) { $core = GUARDFORGE_Integrity_Reference::core_version(); if ( $core !== '' ) { $out['core'] = $core; } } if ( in_array( 'plugin', $scopes, true ) ) { foreach ( GUARDFORGE_Integrity_Reference::installed_plugin_versions() as $slug => $version ) { $out[ 'plugin/' . $slug ] = (string) $version; } } if ( in_array( 'theme', $scopes, true ) ) { foreach ( GUARDFORGE_Integrity_Reference::installed_theme_versions() as $slug => $version ) { $out[ 'theme/' . $slug ] = (string) $version; } } return $out; } /** * Which package a scanned path belongs to, in the same key shape * package_versions uses. Core is one package; a plugin or theme is * named by its own directory, which is also the slug wordpress.org * publishes under. * * @param string $scope 'core' | 'plugin' | 'theme'. * @param string $relative Path relative to that scope's root. * @return string '' when the path names no package (a one-file plugin has * no directory, so nothing names its slug — the same case * classify_plugins already counts as unreferenced). */ private static function package_of( $scope, $relative ) { if ( $scope === 'core' ) { return 'core'; } $cut = strpos( (string) $relative, '/' ); if ( $cut === false || $cut === 0 ) { return ''; } return $scope . '/' . substr( (string) $relative, 0, $cut ); } /** * Turn this run's snapshot diff into rows the case model can reason about * (§6.4 K1-e). * * The snapshot diff is amnesiac by construction: a file that changed * between scan N-1 and N is, at scan N+1, identical to the snapshot it is * compared against, so it silently becomes the new normal. That is fine * for a file the published reference judges on every run — `flagged` is * recomputed from scratch each time, so a modified core file stays * flagged for as long as it is modified. It is not fine for the half of a * real install nothing publishes checksums for (themes, premium and * custom plugins), where the diff is the ONLY signal there ever was: for * those files "changed yesterday" was reported once and then vanished. * * So each entry carries the two facts that decide whether the change is * explained, and neither is available at read time: * * `verdict` — what the published package for the installed version * said about this exact file on this run. `official` means * the file's current bytes ARE the shipped bytes, which is * what a routine update leaves behind; `modified`/ * `unexpected` mean the published package contradicts what * is on disk; `unreferenced` means there was nothing to * compare against and says so instead of implying a pass. * `previous_version` — the package's version when the previous snapshot * was taken, so "the package was updated" can be * distinguished from "somebody edited a file at the same * version". Empty when this is the first scan since the * option existed, and the case model treats an unknown as * unknown rather than as "did not move". * * @param string $changed Absolute paths whose hash moved since the previous snapshot. * @param string $added Absolute paths the previous snapshot did not have. * @param array> $hashes scope => absolute path => sha256, as classify receives it. * @param array $verdict classify's own return. * @param array $versions package => installed version now. * @param array $previous_versions package => installed version at the previous snapshot. * @return array> */ private static function local_change_rows( array $changed, array $added, array $hashes, array $verdict, array $versions, array $previous_versions ) { $scope_of = []; foreach ( $hashes as $scope => $paths ) { foreach ( array_keys( $paths ) as $path ) { $scope_of[ $path ] = (string) $scope; } } // The verdict the published reference reached for each flagged file, // keyed the same "scope|path" way the case model keys a baseline. $flagged_class = []; foreach ( (array) ( $verdict['flagged'] ?? [] ) as $row ) { $flagged_class[ (string) ( $row['scope'] ?? '' ) . '|' . (string) ( $row['path'] ?? '' ) ] = (string) ( $row['class'] ?? '' ); } $referenced = array_fill_keys( array_map( 'strval', (array) ( $verdict['referenced'] ?? [] ) ), true ); // Only the scopes this scan actually walked: asking WordPress where // the theme root is on a core-only scan would be a call made for an // answer nothing uses. $roots = []; foreach ( array_keys( $hashes ) as $scope ) { if ( $scope === 'core' ) { $roots['core'] = ABSPATH; } elseif ( $scope === 'plugin' && defined( 'WP_PLUGIN_DIR' ) ) { $roots['plugin'] = WP_PLUGIN_DIR; } elseif ( $scope === 'theme' && function_exists( 'get_theme_root' ) ) { $roots['theme'] = get_theme_root(); } } $out = []; foreach ( [ 'changed' => $changed, 'added' => $added ] as $change => $paths ) { foreach ( $paths as $path ) { $scope = $scope_of[ $path ] ?? ''; if ( $scope === '' || empty( $roots[ $scope ] ) ) { continue; } $relative = self::relative_to( $roots[ $scope ], $path ); $package = self::package_of( $scope, $relative ); $out[] = [ 'path' => $relative, 'scope' => $scope, 'package' => $package, 'change' => (string) $change, 'hash' => (string) ( $hashes[ $scope ][ $path ] ?? '' ), 'version' => (string) ( $versions[ $package ] ?? '' ), 'previous_version' => (string) ( $previous_versions[ $package ] ?? '' ), 'verdict' => $flagged_class[ $scope . '|' . $relative ] ?? self::unflagged_verdict( $scope, $relative, $package, $referenced ), ]; } } return $out; } /** * The verdict for a locally changed file this run's flagged list does not * name. * * `official` is a claim about THIS file — that its current bytes are the * bytes the published package contains — so it may only be made for a * file that was actually compared against that package. "A reference for * the package was obtained" is a different statement, and reading the one * as the other was wrong on exactly one file, the worst one there is * (review round 1, blocker 2): wp-config.php is hashed by the scan * (collect_files globs ABSPATH.'*.php') and deliberately excluded from * the comparison by classify_core, because wordpress.org publishes no * checksum for a file every site writes itself. With `core` in * `referenced`, an edit to the site's database credentials, salts and * bootstrap PHP came out as a routine package file — and vanished from * the diff on the next scan, having become the snapshot it is compared * against. * * @param string $scope 'core' | 'plugin' | 'theme'. * @param string $relative Path relative to that scope's root. * @param string $package Package key the path belongs to. * @param array $referenced Packages this run held a published reference for. * @return string One of self::CLASSES. */ private static function unflagged_verdict( $scope, $relative, $package, array $referenced ) { if ( $scope === 'core' && in_array( $relative, self::CORE_LOCAL_FILES, true ) ) { return 'unreferenced'; } return isset( $referenced[ $package ] ) ? 'official' : 'unreferenced'; } // ─────────────────── against the published hashes ─────────────────── /** * Judge what the scan found against what wordpress.org publishes. * * Core is compared by MD5, which is the only hash the core endpoint * publishes. That is not a weakness here: nobody is attacking the hash, * they are attacking the file, and a collision against a specific core * file with working PHP on both sides is not the shape of any real * incident. Plugins publish SHA-256 as well and that is what gets used. * * Themes have no published reference anywhere, so they stay in snapshot * mode and are counted `unreferenced` rather than `official` — the screen * says which, because "0 problems" over a scope nothing was checked * against is the lie this whole feature exists to stop telling. * * @param array> $hashes scope => path => sha256 * @return array{counts:array,flagged:array>,unreferenced:array,queued:string,referenced:string} */ public function classify( array $hashes ) { $reference = GUARDFORGE_Integrity_Reference::instance(); $reference->begin_run(); $counts = self::empty_counts(); $flagged = []; $unreferenced = [ 'core' => 0, 'plugin' => 0, 'theme' => 0 ]; // §6.4 K1-e: the packages this run actually held a published // reference for. `unreferenced` counts files and so cannot answer // "was THIS package compared against anything?", which is the // question that separates "not flagged, therefore shipped as-is" // from "not flagged because nobody publishes hashes for it". $referenced = []; $this->classify_core( $hashes['core'] ?? [], $reference, $counts, $flagged, $unreferenced, $referenced ); $this->classify_plugins( $hashes['plugin'] ?? [], $reference, $counts, $flagged, $unreferenced, $referenced ); // No public checksums exist for themes, from anybody. The count is the // whole verdict. $themes = count( $hashes['theme'] ?? [] ); $counts['unreferenced'] += $themes; $unreferenced['theme'] += $themes; usort( $flagged, function ( $a, $b ) { $left = self::SEVERITY[ $a['class'] ] ?? 9; $right = self::SEVERITY[ $b['class'] ] ?? 9; return $left === $right ? strcmp( $a['path'], $b['path'] ) : $left - $right; } ); return [ 'counts' => $counts, 'flagged' => $flagged, 'unreferenced' => $unreferenced, 'queued' => $reference->queued(), 'referenced' => array_values( array_unique( $referenced ) ), ]; } /** * @param array $files path => sha256 * @param string $referenced §6.4 K1-e: appended to when a published reference WAS obtained. */ private function classify_core( array $files, $reference, array &$counts, array &$flagged, array &$unreferenced, array &$referenced ) { if ( ! $files ) { return; } $official = $reference->core(); if ( ! is_array( $official ) ) { $counts['unreferenced'] += count( $files ); $unreferenced['core'] += count( $files ); return; } $referenced[] = 'core'; // §6.4 K1-e: a trusted-baseline comparison (GUARDFORGE_Case_Model:: // candidates_from_integrity) needs the CURRENT hash and package // version of a flagged file to compare against a stored approval — // carried on the finding itself, from the sha256 this scan already // computed ($sha, below) and the WordPress version this comparison // ran against, rather than a second disk read at decision time. $wp_version = GUARDFORGE_Integrity_Reference::core_version(); foreach ( $files as $path => $sha ) { $relative = self::relative_to( ABSPATH, $path ); if ( in_array( $relative, self::CORE_LOCAL_FILES, true ) ) { $counts['unreferenced']++; $unreferenced['core']++; continue; } if ( ! isset( $official[ $relative ] ) ) { $counts['unexpected']++; $flagged[] = self::finding( $relative, 'unexpected', 'core', $sha, $wp_version ); continue; } $md5 = @md5_file( $path ); if ( is_string( $md5 ) && strtolower( $official[ $relative ] ) === strtolower( $md5 ) ) { $counts['official']++; continue; } $counts['modified']++; $flagged[] = self::finding( $relative, 'modified', 'core', $sha, $wp_version ); } foreach ( $official as $relative => $md5 ) { if ( ! GUARDFORGE_Integrity_Reference::in_core_scan_scope( $relative ) ) { continue; } // file_exists rather than "was it in this scan": a file the scan // could not read is present and unreadable, which is a different // problem from a file that is gone. if ( file_exists( ABSPATH . $relative ) ) { continue; } $counts['missing']++; $flagged[] = self::finding( $relative, 'missing', 'core' ); } } /** * @param array $files path => sha256 * @param string $referenced §6.4 K1-e: appended to per slug a published reference WAS obtained for. */ private function classify_plugins( array $files, $reference, array &$counts, array &$flagged, array &$unreferenced, array &$referenced ) { if ( ! $files ) { return; } $root = rtrim( (string) WP_PLUGIN_DIR, '/' ) . '/'; $versions = GUARDFORGE_Integrity_Reference::installed_plugin_versions(); $by_slug = []; foreach ( $files as $path => $sha ) { $relative = self::relative_to( $root, $path ); $cut = strpos( $relative, '/' ); if ( $cut === false ) { // A one-file plugin: nothing names its wordpress.org slug. $counts['unreferenced']++; $unreferenced['plugin']++; continue; } $by_slug[ substr( $relative, 0, $cut ) ][ substr( $relative, $cut + 1 ) ] = [ 'path' => $path, 'sha256' => $sha ]; } foreach ( $by_slug as $slug => $members ) { $version = $versions[ $slug ] ?? ''; $official = $version === '' ? null : $reference->plugin( $slug, $version ); /* * null — wordpress.org has no such release: premium, custom, or * renamed. false — this run's fetch budget is spent and the slug * is queued for the next one. Both leave the files on the * snapshot comparison, and neither is "clean". */ if ( ! is_array( $official ) ) { $counts['unreferenced'] += count( $members ); $unreferenced['plugin'] += count( $members ); continue; } $referenced[] = 'plugin/' . $slug; foreach ( $members as $relative => $member ) { if ( ! isset( $official[ $relative ] ) ) { $counts['unexpected']++; $flagged[] = self::finding( $slug . '/' . $relative, 'unexpected', 'plugin', $member['sha256'], $version ); continue; } if ( self::hash_matches( $official[ $relative ], $member ) ) { $counts['official']++; continue; } $counts['modified']++; $flagged[] = self::finding( $slug . '/' . $relative, 'modified', 'plugin', $member['sha256'], $version ); } foreach ( $official as $relative => $hashes ) { if ( substr( $relative, -4 ) !== '.php' ) { continue; } if ( file_exists( $root . $slug . '/' . $relative ) ) { continue; } $counts['missing']++; $flagged[] = self::finding( $slug . '/' . $relative, 'missing', 'plugin' ); } } } /** * SHA-256 when the release published one, MD5 when it did not — and the * MD5 is read off the disk only in that case, so the ordinary scan never * hashes a file twice. * * @param array{md5:string,sha256:string} $official * @param array{path:string,sha256:string} $member */ private static function hash_matches( array $official, array $member ) { if ( ! empty( $official['sha256'] ) ) { return in_array( strtolower( $member['sha256'] ), $official['sha256'], true ); } if ( ! empty( $official['md5'] ) ) { $md5 = @md5_file( $member['path'] ); return is_string( $md5 ) && in_array( strtolower( $md5 ), $official['md5'], true ); } return false; } /** * @param string $hash §6.4 K1-e: the sha256 this scan already computed for * this exact file, for the trusted-baseline comparison * — omitted (not '') when there is none worth recording * (a 'missing' finding has no file left to hash). * @param string $version §6.4 K1-e: the installed core/plugin version this * finding was classified against. */ private static function finding( $relative, $class, $scope, $hash = '', $version = '' ) { $out = [ 'path' => (string) $relative, 'class' => (string) $class, 'scope' => (string) $scope ]; if ( $hash !== '' ) { $out['hash'] = (string) $hash; } if ( $version !== '' ) { $out['version'] = (string) $version; } return $out; } private static function relative_to( $root, $path ) { $root = rtrim( (string) $root, '/' ) . '/'; $path = (string) $path; return strpos( $path, $root ) === 0 ? substr( $path, strlen( $root ) ) : ltrim( str_replace( $root, '', $path ), '/' ); } public function run_scan_silently() { // A task scheduled by an older version, or left behind by a switch // turned off while it was queued, takes itself off the schedule rather // than making the request this site never asked for. if ( ! self::consent_given() ) { add_action( 'shutdown', [ __CLASS__, 'unschedule' ] ); return; } try { $this->run_scan(); } catch ( \Throwable $e ) { // Surfaced via Forge_Telemetry::watch_plugin_errors. } } public function handle_manual_scan() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Access denied.', 'guardforge' ) ); } check_admin_referer( 'guardforge_run_integrity' ); $result = $this->run_scan( true ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer above; read only to pick the screen to return to. $return = isset( $_POST['gf_return'] ) ? sanitize_key( wp_unslash( $_POST['gf_return'] ) ) : ''; wp_safe_redirect( self::manual_scan_return_url( $result, $return ) ); exit; } /** * Where the "Run scan now" press goes back to. The button is on two * screens — File integrity, and the first card of Settings ("Run first * scan" / "Run scan now") — and the owner who pressed it expects to land * on the one it was on. Same handler, same nonce; only the way back differs. * * @param array $result run_scan's answer. * @param string $return 'settings' for the Settings screen; anything else is File integrity. * @return string */ public static function manual_scan_return_url( array $result, $return = '' ) { return add_query_arg( [ 'scanned' => 1, 'changed' => count( (array) ( $result['changed'] ?? [] ) ), 'removed' => count( (array) ( $result['removed'] ?? [] ) ), 'unexpected' => (int) ( $result['classes']['unexpected'] ?? 0 ), ], admin_url( $return === 'settings' ? 'admin.php?page=guardforge' : 'admin.php?page=guardforge-integrity' ) ); } /** * @return array scope => [absolute paths] */ public function collect_files() { $core = $this->collect_php_files( [ ABSPATH . 'wp-admin', ABSPATH . 'wp-includes' ] ); foreach ( glob( ABSPATH . '*.php' ) ?: [] as $f ) { $core[] = $f; } $result = [ 'core' => $core ]; // Scope is a plain setting, not an entitlement: the code that walks // plugins and themes ships in the free package, so gating it on a // licence would be a paywall over shipped code. if ( GUARDFORGE_Options::get( 'integrity_scope_extras' ) ) { $result['plugin'] = $this->collect_php_files( [ WP_PLUGIN_DIR ] ); $result['theme'] = $this->collect_php_files( [ get_theme_root() ] ); } /** * Filter integrity scan scope. * * @param array $result scope => paths. */ return apply_filters( 'guardforge/integrity_files', $result ); } private function collect_php_files( array $dirs ) { $out = []; foreach ( $dirs as $dir ) { if ( ! is_dir( $dir ) ) { continue; } $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) ); foreach ( $iter as $file ) { if ( $file->isFile() && $file->getExtension() === 'php' ) { $out[] = $file->getPathname(); } } } return $out; } private function notify( array $changed, array $removed, $now ) { // Every deploy to a copy rewrites files, so on staging this mails a // diff of the developer's own work to the site owner. The scan still // runs and the result is still stored — only the e-mail stops. if ( GUARDFORGE_Staging::is_active() ) { GUARDFORGE_Staging::note_suppressed( 'email', 'integrity' ); return; } $to = GUARDFORGE_Options::get( 'notify_email' ); if ( ! $to ) { $to = get_option( 'admin_email' ); } if ( ! is_email( $to ) ) { return; } $subject = sprintf( '[%s] %s', get_bloginfo( 'name' ), __( 'GuardForge — file integrity alert', 'guardforge' ) ); $body = ''; if ( $changed ) { $body .= __( "Modified files:\n", 'guardforge' ) . implode( "\n", $changed ) . "\n\n"; } if ( $removed ) { $body .= __( "Removed files:\n", 'guardforge' ) . implode( "\n", $removed ) . "\n\n"; } /* translators: %s is the date/time of the scan. */ $body .= sprintf( __( "Time: %s", 'guardforge' ), $now ); wp_mail( $to, $subject, $body ); } }