| @@ -16,10 +16,11 @@ | ||
| 16 | 16 | * write access). |
| 17 | 17 | * |
| 18 | 18 | * What this deliberately does NOT cover: vulnerabilities in Vigilant's own |
| 19 | 19 | * code (this detects tampering, not bugs), and an attacker with database |
| 20 | - * write access, who can switch the toggle off or blank the fingerprint, as | |
| 21 | - * with any security plugin. Both are documented in SECURITY.md. | |
| 20 | + * write access, who can blank the fingerprint, as with any security plugin. | |
| 21 | + * There is no toggle to switch off: only the filter of is_on(), and using it | |
| 22 | + * is reported as critical. Both are documented in SECURITY.md. | |
| 22 | 23 | * |
| 23 | 24 | * Entry points: |
| 24 | 25 | * - File Integrity scan (first block of run_scan(), exempt from the time |
| 25 | 26 | * budget and from user exclusions). |
| @@ -886,9 +887,10 @@ | ||
| 886 | 887 | * - disk != manifest but == wp.org -> manifest_stale (critical) |
| 887 | 888 | * - disk == manifest but != wp.org -> distribution_mismatch (warning) |
| 888 | 889 | * - on disk, not in manifest -> self_extra (critical if PHP or a link) |
| 889 | 890 | * Anchor level: |
| 890 | - * - manifest hash != A3, same version -> manifest_replaced (critical) | |
| 891 | + * - manifest hash != A3, same version -> rebaseline if wp.org confirms the manifest; | |
| 892 | + * manifest_replaced (critical) otherwise | |
| 891 | 893 | * - manifest hash != A3, version changed -> rebaseline if the upgrader did it or wp.org confirms; |
| 892 | 894 | * manifest_replaced if wp.org contradicts it; |
| 893 | 895 | * manifest_unverified (sticky warning) otherwise; |
| 894 | 896 | * a downgrade adds self_downgraded (warning) |
| @@ -916,8 +918,10 @@ | ||
| 916 | 918 | 'wporg' => false, |
| 917 | 919 | 'fingerprint' => false, |
| 918 | 920 | ), |
| 919 | 921 | 'rebaselined' => false, |
| 922 | + // Same version, manifest adopted because wp.org confirms it (3.0.1). | |
| 923 | + 'manifest_adopted' => false, | |
| 920 | 924 | 'downgraded' => false, |
| 921 | 925 | ); |
| 922 | 926 | |
| 923 | 927 | if ( ! $this->is_enabled() ) { |
| @@ -1014,15 +1018,35 @@ | ||
| 1014 | 1018 | // upgrader hook also fires for a plugin that was not written (a |
| 1015 | 1019 | // bulk update that skipped it, an install that failed before |
| 1016 | 1020 | // copying), so it is not proof that WordPress replaced the files. |
| 1017 | 1021 | if ( $stored_version === $disk_version ) { |
| 1018 | - // Same version, different manifest: swapped without an update. | |
| 1019 | - $findings[] = $this->finding( | |
| 1020 | - 'manifest_replaced', | |
| 1021 | - self::MANIFEST_FILE, | |
| 1022 | - 'critical', | |
| 1023 | - __( 'MANIFEST.sha256 was replaced without a plugin update. An attacker regenerating the manifest to hide file changes would look exactly like this.', 'vigilante' ) | |
| 1024 | - ); | |
| 1022 | + if ( $wporg_ok && $this->manifest_consistent_with_wporg( $manifest, $wporg ) ) { | |
| 1023 | + // The manifest on disk IS the one WordPress.org | |
| 1024 | + // distributes for this version, so whatever was anchored | |
| 1025 | + // before was not. That happens to a site that had a test | |
| 1026 | + // zip of a version and then installed the published one: | |
| 1027 | + // until 3.0.1 it stayed critical for good, because the | |
| 1028 | + // repair reinstalls those same official files and lands | |
| 1029 | + // here again (B45 of the roadmap, reproduced on 18 Sep | |
| 1030 | + // 2026 with a seeded anchor). | |
| 1031 | + // | |
| 1032 | + // Adopting it is the same argument the version-change | |
| 1033 | + // branch below already makes: an attacker who edits | |
| 1034 | + // files and regenerates the manifest cannot also make | |
| 1035 | + // WordPress.org's published checksums agree with it, | |
| 1036 | + // and that agreement is what is being checked here, | |
| 1037 | + // file by file and in both directions. | |
| 1038 | + $result['rebaselined'] = true; | |
| 1039 | + $result['manifest_adopted'] = true; | |
| 1040 | + } else { | |
| 1041 | + // Same version, different manifest: swapped without an update. | |
| 1042 | + $findings[] = $this->finding( | |
| 1043 | + 'manifest_replaced', | |
| 1044 | + self::MANIFEST_FILE, | |
| 1045 | + 'critical', | |
| 1046 | + __( 'MANIFEST.sha256 was replaced without a plugin update. An attacker regenerating the manifest to hide file changes would look exactly like this.', 'vigilante' ) | |
| 1047 | + ); | |
| 1048 | + } | |
| 1025 | 1049 | } else { |
| 1026 | 1050 | // Version changed: the WordPress updater, a manual/FTP |
| 1027 | 1051 | // update, or an attacker who bumped the Version: header to |
| 1028 | 1052 | // dodge the same-version manifest_replaced check above. |
| @@ -1198,11 +1222,28 @@ | ||
| 1198 | 1222 | |
| 1199 | 1223 | // --- Rebaseline A3 ------------------------------------------------ |
| 1200 | 1224 | if ( $result['rebaselined'] ) { |
| 1201 | 1225 | $this->capture_fingerprint( 'upgrader' === $context ? 'upgrader' : 'version_change' ); |
| 1202 | - if ( 'upgrader' !== $context ) { | |
| 1226 | + if ( 'upgrader' !== $context && $result['manifest_adopted'] ) { | |
| 1227 | + // Same version, another manifest, and wp.org confirms the one on | |
| 1228 | + // disk. Saying "version change" here would be false, and a log | |
| 1229 | + // line that consoles with something untrue is worse than no line. | |
| 1203 | 1230 | $this->log( |
| 1204 | 1231 | 'self_rebaselined', |
| 1232 | + sprintf( | |
| 1233 | + /* translators: %s: plugin version */ | |
| 1234 | + __( 'Vigilant self-protection: MANIFEST.sha256 of version %s adopted as the reference, confirmed by WordPress.org; baseline refreshed.', 'vigilante' ), | |
| 1235 | + $disk_version | |
| 1236 | + ), | |
| 1237 | + array( | |
| 1238 | + 'previous_version' => $stored_version, | |
| 1239 | + 'new_version' => $disk_version, | |
| 1240 | + ), | |
| 1241 | + 'info' | |
| 1242 | + ); | |
| 1243 | + } elseif ( 'upgrader' !== $context ) { | |
| 1244 | + $this->log( | |
| 1245 | + 'self_rebaselined', | |
| 1205 | 1246 | $result['downgraded'] |
| 1206 | 1247 | ? sprintf( |
| 1207 | 1248 | /* translators: %s: plugin version */ |
| 1208 | 1249 | __( 'Vigilant self-protection: downgrade to %s detected outside the WordPress updater and confirmed by WordPress.org; baseline refreshed.', 'vigilante' ), |
| @@ -1954,11 +1995,11 @@ | ||
| 1954 | 1995 | * |
| 1955 | 1996 | * Deliberately NOT gated by the File Integrity instant_alert toggle |
| 1956 | 1997 | * (unlike Vigilante_Plugin_Status): a tamper of the guardian itself is |
| 1957 | 1998 | * the product's maximum alarm. There is no setting that silences it; only |
| 1958 | - * the filter of is_on(), which SECURITY.md documents. Findings from a | |
| 1959 | - * File Integrity scan go in the scan email instead, which follows its | |
| 1960 | - * notification setting. | |
| 1999 | + * the filter of is_on(), which SECURITY.md documents. Findings raised by a | |
| 2000 | + * File Integrity scan come through here too: switching off the report about | |
| 2001 | + * changed files must not switch off the alarm about the plugin itself. | |
| 1961 | 2002 | * |
| 1962 | 2003 | * Sent only from the site that owns the installation's shared files: the |
| 1963 | 2004 | * plugin files are the same for every site and every network of an |
| 1964 | 2005 | * installation, so one email. Deduped by a fingerprint of the finding |