| 1 |
<?php |
| 2 |
/** |
| 3 |
* Vigilant: verify MANIFEST.sha256 from the command line. |
| 4 |
* |
| 5 |
* Plain PHP >= 7.4, no WordPress required. Verifies the plugin tree against |
| 6 |
* MANIFEST.sha256 (modified, missing and extra files, symbolic links, and |
| 7 |
* folders that cannot be listed), |
| 8 |
* and optionally cross-checks the manifest against the SHA-256 checksums |
| 9 |
* that WordPress.org publishes for the released version (the manifest must |
| 10 |
* describe exactly what WordPress.org distributes). |
| 11 |
* |
| 12 |
* Usage: |
| 13 |
* php bin/verify-manifest.php [/path/to/plugin/root] |
| 14 |
* php bin/verify-manifest.php --wporg (version read from vigilante.php) |
| 15 |
* php bin/verify-manifest.php --wporg=3.0.0 |
| 16 |
* |
| 17 |
* Exit codes: |
| 18 |
* 0 everything verified clean |
| 19 |
* 1 local tree does not match the manifest (modified/missing/extra files, links, |
| 20 |
* or folders that cannot be listed) |
| 21 |
* 2 manifest missing, unreadable or not valid, wp.org checksums unavailable, or |
| 22 |
* the plugin folder cannot be listed |
| 23 |
* 3 manifest disagrees with what wp.org distributes |
| 24 |
* |
| 25 |
* The tree is the same the plugin checks at runtime |
| 26 |
* (includes/class-self-integrity.php): readme.txt, changelog.txt and the |
| 27 |
* manifest itself at the root, the files svn writes in .svn/ at the root, and |
| 28 |
* .DS_Store and Thumbs.db anywhere are not in the manifest. A link, or a file |
| 29 |
* a web server may run (PHP extensions, .htaccess, .user.ini, php.ini), is |
| 30 |
* never skipped, and an extension anywhere in the name counts (x.php.jpg). |
| 31 |
* Text files are also compared with a UTF-8 BOM removed and CRLF line endings |
| 32 |
* turned into LF, as the plugin does, so a host rewriting them is not reported |
| 33 |
* (sha256sum -c reports them). |
| 34 |
* |
| 35 |
* @package Vigilante |
| 36 |
* @since 3.0.0 |
| 37 |
*/ |
| 38 |
|
| 39 |
// Outside WordPress only the command line may run this tool (from the web it |
| 40 |
// answers 404); loaded inside WordPress it does nothing. |
| 41 |
if ( ! defined( 'ABSPATH' ) ) { |
| 42 |
if ( 'cli' !== PHP_SAPI ) { |
| 43 |
http_response_code( 404 ); |
| 44 |
exit; |
| 45 |
} |
| 46 |
} else { |
| 47 |
return; |
| 48 |
} |
| 49 |
|
| 50 |
// phpcs:disable WordPress.WP.AlternativeFunctions -- Command line tool that runs outside WordPress, where WP_Filesystem and the HTTP API do not exist: fwrite() to STDOUT/STDERR, file_get_contents() and fopen() are the only way. The guard above makes the file inert inside WordPress and unreachable from the web. Closed at the end of the file. |
| 51 |
|
| 52 |
/* |
| 53 |
* Exclusions. The same three lists live in includes/class-self-integrity.php |
| 54 |
* and in the release tool generate-manifest.php, and the release checks |
| 55 |
* compare them. |
| 56 |
*/ |
| 57 |
$vigilante_root_excluded_files = array( 'MANIFEST.sha256', 'readme.txt', 'changelog.txt' ); |
| 58 |
$vigilante_svn_metadata = '#^\.svn/(?:wc\.db|wc\.db-journal|format|entries|pristine/[0-9a-f]{2}/[0-9a-f]{40}\.svn-base)$#'; |
| 59 |
$vigilante_junk_file_names = array( '.DS_Store', 'Thumbs.db' ); |
| 60 |
$vigilante_executable_ext = array( 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', 'pht', 'phar', 'inc' ); |
| 61 |
$vigilante_executable_names = array( '.htaccess', '.user.ini', 'php.ini' ); |
| 62 |
|
| 63 |
$vigilante_is_excluded = function ( $relative ) use ( $vigilante_root_excluded_files, $vigilante_svn_metadata, $vigilante_junk_file_names ) { |
| 64 |
$relative = (string) $relative; |
| 65 |
$segments = explode( '/', $relative ); |
| 66 |
if ( 1 === count( $segments ) && in_array( $segments[0], $vigilante_root_excluded_files, true ) ) { |
| 67 |
return true; |
| 68 |
} |
| 69 |
if ( preg_match( $vigilante_svn_metadata, $relative ) ) { |
| 70 |
return true; |
| 71 |
} |
| 72 |
return in_array( end( $segments ), $vigilante_junk_file_names, true ); |
| 73 |
}; |
| 74 |
|
| 75 |
$vigilante_is_executable = function ( $relative ) use ( $vigilante_executable_ext, $vigilante_executable_names ) { |
| 76 |
$name = strtolower( basename( (string) $relative ) ); |
| 77 |
if ( in_array( $name, $vigilante_executable_names, true ) ) { |
| 78 |
return true; |
| 79 |
} |
| 80 |
// Every extension counts, not only the last one (x.php.jpg runs as PHP with AddHandler). |
| 81 |
$parts = explode( '.', $name ); |
| 82 |
array_shift( $parts ); |
| 83 |
foreach ( $parts as $part ) { |
| 84 |
if ( in_array( $part, $vigilante_executable_ext, true ) ) { |
| 85 |
return true; |
| 86 |
} |
| 87 |
} |
| 88 |
return false; |
| 89 |
}; |
| 90 |
|
| 91 |
// Text files a host may rewrite (UTF-8 BOM, CRLF): compared normalized too, as the plugin does. |
| 92 |
// Line endings count in every text file, a UTF-8 BOM only where it changes nothing (not in PHP, |
| 93 |
// where it is output, nor in JSON, which json_decode() then rejects), and nothing above 5 MB. |
| 94 |
$vigilante_normalized_hash = function ( $path ) use ( $vigilante_is_executable ) { |
| 95 |
$extension = strtolower( pathinfo( (string) $path, PATHINFO_EXTENSION ) ); |
| 96 |
if ( ! in_array( $extension, array( 'php', 'js', 'css', 'json', 'txt', 'md', 'html', 'htm', 'xml', 'svg', 'po', 'pot', 'ini' ), true ) ) { |
| 97 |
return null; |
| 98 |
} |
| 99 |
$size = filesize( $path ); |
| 100 |
if ( false === $size || $size > 5242880 ) { |
| 101 |
return null; |
| 102 |
} |
| 103 |
// The read stops past the limit too: the size can change between the check and the read. |
| 104 |
$content = file_get_contents( $path, false, null, 0, 5242881 ); |
| 105 |
if ( false === $content || strlen( $content ) > 5242880 ) { |
| 106 |
return null; |
| 107 |
} |
| 108 |
// Not in an executable name either: x.php.svg runs as PHP where a handler matches any extension. |
| 109 |
if ( "\xEF\xBB\xBF" === substr( $content, 0, 3 ) && in_array( $extension, array( 'js', 'css', 'txt', 'md', 'html', 'htm', 'xml', 'svg', 'po', 'pot' ), true ) && ! $vigilante_is_executable( $path ) ) { |
| 110 |
$content = substr( $content, 3 ); |
| 111 |
} |
| 112 |
return hash( 'sha256', str_replace( array( "\r\n", "\r" ), "\n", $content ) ); |
| 113 |
}; |
| 114 |
|
| 115 |
// Paths come from the disk and from wp.org: printed with control characters |
| 116 |
// escaped, so a crafted file name cannot rewrite what the terminal shows. |
| 117 |
$vigilante_printable = function ( $text ) { |
| 118 |
$text = addcslashes( (string) $text, "\0..\37\177" ); |
| 119 |
if ( ! preg_match( '//u', $text ) ) { |
| 120 |
// Not valid UTF-8: every byte above ASCII is shown as \xNN. |
| 121 |
return preg_replace_callback( |
| 122 |
'/[\x80-\xFF]/', |
| 123 |
function ( $m ) { |
| 124 |
return sprintf( '\\x%02X', ord( $m[0] ) ); |
| 125 |
}, |
| 126 |
$text |
| 127 |
); |
| 128 |
} |
| 129 |
// C1 controls, the bidirectional marks that reorder what a terminal shows (a name |
| 130 |
// that reads informe-php.png and is informe-<U+202E>gnp.php), and the invisible or blank |
| 131 |
// characters that let one name pass for another (zero width, line separators, BOM, soft |
| 132 |
// hyphen, grapheme joiner, Hangul and Mongolian fillers). |
| 133 |
return preg_replace_callback( |
| 134 |
'/[\x{0080}-\x{009F}\x{00AD}\x{034F}\x{061C}\x{115F}\x{1160}\x{180E}\x{200B}-\x{200F}\x{2028}-\x{202E}\x{2060}-\x{2069}\x{3164}\x{FEFF}\x{FFA0}]/u', |
| 135 |
function ( $m ) { |
| 136 |
// Two or three byte UTF-8 sequences only; decoded by hand (mbstring is optional). |
| 137 |
$b = array_values( unpack( 'C*', $m[0] ) ); |
| 138 |
$code = 2 === count( $b ) ? ( ( $b[0] & 0x1F ) << 6 ) | ( $b[1] & 0x3F ) : ( ( $b[0] & 0x0F ) << 12 ) | ( ( $b[1] & 0x3F ) << 6 ) | ( $b[2] & 0x3F ); |
| 139 |
return sprintf( '\\u{%04X}', $code ); |
| 140 |
}, |
| 141 |
$text |
| 142 |
); |
| 143 |
}; |
| 144 |
|
| 145 |
$vigilante_is_safe_path = function ( $path ) { |
| 146 |
if ( ! is_string( $path ) || '' === $path || strlen( $path ) > 255 ) { |
| 147 |
return false; |
| 148 |
} |
| 149 |
if ( ! preg_match( '#^[A-Za-z0-9._@+-]+(?:/[A-Za-z0-9._@+-]+)*$#', $path ) ) { |
| 150 |
return false; |
| 151 |
} |
| 152 |
foreach ( explode( '/', $path ) as $segment ) { |
| 153 |
if ( '.' === $segment || '..' === $segment ) { |
| 154 |
return false; |
| 155 |
} |
| 156 |
} |
| 157 |
return true; |
| 158 |
}; |
| 159 |
|
| 160 |
// --------------------------------------------------------------------------- |
| 161 |
// Arguments. |
| 162 |
// --------------------------------------------------------------------------- |
| 163 |
$vigilante_root = dirname( __DIR__ ); |
| 164 |
$vigilante_wporg = null; // null = off, '' = auto version, 'X.Y.Z' = explicit. |
| 165 |
|
| 166 |
$vigilante_argv = array_slice( isset( $argv ) ? $argv : array(), 1 ); |
| 167 |
foreach ( $vigilante_argv as $vigilante_arg ) { |
| 168 |
if ( '--wporg' === $vigilante_arg ) { |
| 169 |
$vigilante_wporg = ''; |
| 170 |
} elseif ( 0 === strpos( $vigilante_arg, '--wporg=' ) ) { |
| 171 |
$vigilante_wporg = substr( $vigilante_arg, 8 ); |
| 172 |
} elseif ( '-' !== substr( $vigilante_arg, 0, 1 ) ) { |
| 173 |
$vigilante_root = rtrim( $vigilante_arg, '/' ); |
| 174 |
} else { |
| 175 |
fwrite( STDERR, 'Unknown option: ' . $vigilante_arg . "\n" ); |
| 176 |
exit( 2 ); |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
if ( ! is_dir( $vigilante_root ) ) { |
| 181 |
fwrite( STDERR, 'ERROR: not a directory: ' . $vigilante_root . "\n" ); |
| 182 |
exit( 2 ); |
| 183 |
} |
| 184 |
|
| 185 |
// --------------------------------------------------------------------------- |
| 186 |
// Read the manifest. |
| 187 |
// --------------------------------------------------------------------------- |
| 188 |
$vigilante_manifest_path = $vigilante_root . '/MANIFEST.sha256'; |
| 189 |
if ( is_link( $vigilante_manifest_path ) || ! is_readable( $vigilante_manifest_path ) ) { |
| 190 |
fwrite( STDERR, 'ERROR: MANIFEST.sha256 not found, unreadable or a link at ' . $vigilante_manifest_path . "\n" ); |
| 191 |
exit( 2 ); |
| 192 |
} |
| 193 |
// 2000 lines take well under a megabyte: a bigger file is not a valid manifest and is not read, |
| 194 |
// and the read stops past the limit too, since the size can change between the check and the read. |
| 195 |
$vigilante_manifest_size = filesize( $vigilante_manifest_path ); |
| 196 |
if ( false === $vigilante_manifest_size ) { |
| 197 |
fwrite( STDERR, "ERROR: could not read the size of MANIFEST.sha256\n" ); |
| 198 |
exit( 2 ); |
| 199 |
} |
| 200 |
if ( $vigilante_manifest_size > 1048576 ) { |
| 201 |
fwrite( STDERR, "ERROR: MANIFEST.sha256 is larger than 1 MB, so it is not a valid manifest and was not read\n" ); |
| 202 |
exit( 2 ); |
| 203 |
} |
| 204 |
|
| 205 |
$vigilante_manifest_raw = file_get_contents( $vigilante_manifest_path, false, null, 0, 1048577 ); |
| 206 |
if ( false !== $vigilante_manifest_raw && strlen( $vigilante_manifest_raw ) > 1048576 ) { |
| 207 |
fwrite( STDERR, "ERROR: MANIFEST.sha256 is larger than 1 MB, so it is not a valid manifest and was not read\n" ); |
| 208 |
exit( 2 ); |
| 209 |
} |
| 210 |
if ( false !== $vigilante_manifest_raw ) { |
| 211 |
// A rewrite of the line endings reaches the manifest too, and gives nobody anything. |
| 212 |
if ( "\xEF\xBB\xBF" === substr( $vigilante_manifest_raw, 0, 3 ) ) { |
| 213 |
$vigilante_manifest_raw = substr( $vigilante_manifest_raw, 3 ); |
| 214 |
} |
| 215 |
$vigilante_manifest_raw = str_replace( array( "\r\n", "\r" ), "\n", $vigilante_manifest_raw ); |
| 216 |
} |
| 217 |
if ( false === $vigilante_manifest_raw ) { |
| 218 |
fwrite( STDERR, "ERROR: could not read MANIFEST.sha256\n" ); |
| 219 |
exit( 2 ); |
| 220 |
} |
| 221 |
|
| 222 |
$vigilante_manifest = array(); |
| 223 |
$vigilante_lines = 0; |
| 224 |
$vigilante_length = strlen( $vigilante_manifest_raw ); |
| 225 |
$vigilante_offset = 0; |
| 226 |
// Line by line over the string, not explode(): a manifest of line breaks would build an array of |
| 227 |
// millions of empty strings and exhaust memory. Blank lines count towards the limit too. |
| 228 |
while ( $vigilante_offset < $vigilante_length ) { |
| 229 |
$vigilante_end = strpos( $vigilante_manifest_raw, "\n", $vigilante_offset ); |
| 230 |
$vigilante_end = false === $vigilante_end ? $vigilante_length : $vigilante_end; |
| 231 |
$vigilante_line = substr( $vigilante_manifest_raw, $vigilante_offset, $vigilante_end - $vigilante_offset ); |
| 232 |
$vigilante_offset = $vigilante_end + 1; |
| 233 |
$vigilante_lines++; |
| 234 |
if ( $vigilante_lines > 2000 ) { |
| 235 |
fwrite( STDERR, "ERROR: MANIFEST.sha256 has more than 2000 lines\n" ); |
| 236 |
exit( 2 ); |
| 237 |
} |
| 238 |
if ( '' === trim( $vigilante_line ) ) { |
| 239 |
continue; |
| 240 |
} |
| 241 |
if ( ! preg_match( '/^([0-9a-f]{64}) (.+)$/', $vigilante_line, $vigilante_m ) || ! $vigilante_is_safe_path( $vigilante_m[2] ) || isset( $vigilante_manifest[ $vigilante_m[2] ] ) ) { |
| 242 |
fwrite( STDERR, 'ERROR: not a valid manifest line (format, unsafe path or repeated path): ' . $vigilante_printable( $vigilante_line ) . "\n" ); |
| 243 |
exit( 2 ); |
| 244 |
} |
| 245 |
if ( $vigilante_is_excluded( $vigilante_m[2] ) ) { |
| 246 |
continue; |
| 247 |
} |
| 248 |
$vigilante_manifest[ $vigilante_m[2] ] = $vigilante_m[1]; |
| 249 |
} |
| 250 |
|
| 251 |
if ( empty( $vigilante_manifest ) ) { |
| 252 |
fwrite( STDERR, "ERROR: MANIFEST.sha256 is empty\n" ); |
| 253 |
exit( 2 ); |
| 254 |
} |
| 255 |
|
| 256 |
// --------------------------------------------------------------------------- |
| 257 |
// Local verification: mismatches, missing files, links, extra files. |
| 258 |
// --------------------------------------------------------------------------- |
| 259 |
$vigilante_mismatch = array(); |
| 260 |
$vigilante_missing = array(); |
| 261 |
$vigilante_links = array(); |
| 262 |
$vigilante_extra = array(); |
| 263 |
$vigilante_hidden = array(); |
| 264 |
$vigilante_realroot = realpath( $vigilante_root ); |
| 265 |
|
| 266 |
foreach ( $vigilante_manifest as $vigilante_relative => $vigilante_expected ) { |
| 267 |
$vigilante_path = $vigilante_root . '/' . $vigilante_relative; |
| 268 |
$vigilante_real = realpath( $vigilante_path ); |
| 269 |
if ( is_link( $vigilante_path ) || ( false !== $vigilante_real && false !== $vigilante_realroot && 0 !== strpos( $vigilante_real, $vigilante_realroot . DIRECTORY_SEPARATOR ) ) ) { |
| 270 |
$vigilante_links[] = $vigilante_relative; |
| 271 |
continue; |
| 272 |
} |
| 273 |
if ( ! is_file( $vigilante_path ) ) { |
| 274 |
$vigilante_missing[] = $vigilante_relative; |
| 275 |
continue; |
| 276 |
} |
| 277 |
if ( ! is_readable( $vigilante_path ) ) { |
| 278 |
$vigilante_mismatch[] = $vigilante_relative; |
| 279 |
continue; |
| 280 |
} |
| 281 |
if ( hash_file( 'sha256', $vigilante_path ) !== $vigilante_expected && $vigilante_normalized_hash( $vigilante_path ) !== $vigilante_expected ) { |
| 282 |
$vigilante_mismatch[] = $vigilante_relative; |
| 283 |
} |
| 284 |
} |
| 285 |
|
| 286 |
// A folder that cannot be listed hides what is inside it, and a web server can |
| 287 |
// still run a file in it by name: it is reported, and the walk goes on past it |
| 288 |
// instead of stopping with an uncaught exception. |
| 289 |
try { |
| 290 |
$vigilante_iterator = new RecursiveIteratorIterator( |
| 291 |
new RecursiveDirectoryIterator( $vigilante_root, RecursiveDirectoryIterator::SKIP_DOTS ), |
| 292 |
RecursiveIteratorIterator::SELF_FIRST, |
| 293 |
RecursiveIteratorIterator::CATCH_GET_CHILD |
| 294 |
); |
| 295 |
} catch ( Exception $vigilante_e ) { |
| 296 |
fwrite( STDERR, 'ERROR: cannot list ' . $vigilante_root . "\n" ); |
| 297 |
exit( 2 ); |
| 298 |
} |
| 299 |
foreach ( $vigilante_iterator as $vigilante_file ) { |
| 300 |
$vigilante_relative = str_replace( '\\', '/', substr( $vigilante_file->getPathname(), strlen( $vigilante_root ) + 1 ) ); |
| 301 |
$vigilante_is_link = $vigilante_file->isLink(); |
| 302 |
if ( ! $vigilante_is_link && $vigilante_file->isDir() ) { |
| 303 |
// On Windows is_executable() of a folder is always false: only readability counts there. |
| 304 |
if ( ! $vigilante_file->isReadable() || ( '\\' !== DIRECTORY_SEPARATOR && ! $vigilante_file->isExecutable() ) ) { |
| 305 |
$vigilante_hidden[] = $vigilante_relative . '/'; |
| 306 |
} |
| 307 |
continue; |
| 308 |
} |
| 309 |
if ( ! $vigilante_is_link && ! $vigilante_file->isFile() ) { |
| 310 |
continue; |
| 311 |
} |
| 312 |
if ( isset( $vigilante_manifest[ $vigilante_relative ] ) ) { |
| 313 |
continue; |
| 314 |
} |
| 315 |
$vigilante_executable = $vigilante_is_executable( $vigilante_relative ); |
| 316 |
if ( $vigilante_is_excluded( $vigilante_relative ) && ! $vigilante_is_link && ! $vigilante_executable ) { |
| 317 |
continue; |
| 318 |
} |
| 319 |
if ( $vigilante_is_link ) { |
| 320 |
$vigilante_links[] = $vigilante_relative; |
| 321 |
} else { |
| 322 |
$vigilante_extra[] = $vigilante_relative; |
| 323 |
} |
| 324 |
} |
| 325 |
|
| 326 |
sort( $vigilante_mismatch, SORT_STRING ); |
| 327 |
sort( $vigilante_missing, SORT_STRING ); |
| 328 |
sort( $vigilante_links, SORT_STRING ); |
| 329 |
sort( $vigilante_extra, SORT_STRING ); |
| 330 |
sort( $vigilante_hidden, SORT_STRING ); |
| 331 |
|
| 332 |
foreach ( $vigilante_mismatch as $vigilante_relative ) { |
| 333 |
fwrite( STDOUT, 'MODIFIED: ' . $vigilante_printable( $vigilante_relative ) . "\n" ); |
| 334 |
} |
| 335 |
foreach ( $vigilante_missing as $vigilante_relative ) { |
| 336 |
fwrite( STDOUT, 'MISSING: ' . $vigilante_printable( $vigilante_relative ) . "\n" ); |
| 337 |
} |
| 338 |
foreach ( $vigilante_links as $vigilante_relative ) { |
| 339 |
fwrite( STDOUT, 'LINK: ' . $vigilante_printable( $vigilante_relative ) . "\n" ); |
| 340 |
} |
| 341 |
foreach ( $vigilante_extra as $vigilante_relative ) { |
| 342 |
fwrite( STDOUT, 'EXTRA: ' . $vigilante_printable( $vigilante_relative ) . "\n" ); |
| 343 |
} |
| 344 |
foreach ( $vigilante_hidden as $vigilante_relative ) { |
| 345 |
fwrite( STDOUT, 'UNLISTED: ' . $vigilante_printable( $vigilante_relative ) . "\n" ); |
| 346 |
} |
| 347 |
|
| 348 |
$vigilante_local_clean = empty( $vigilante_mismatch ) && empty( $vigilante_missing ) && empty( $vigilante_links ) && empty( $vigilante_extra ) && empty( $vigilante_hidden ); |
| 349 |
if ( $vigilante_local_clean ) { |
| 350 |
fwrite( STDOUT, 'Local tree OK: ' . count( $vigilante_manifest ) . " files match MANIFEST.sha256, no extras.\n" ); |
| 351 |
} |
| 352 |
|
| 353 |
// --------------------------------------------------------------------------- |
| 354 |
// Optional wp.org cross-check. |
| 355 |
// --------------------------------------------------------------------------- |
| 356 |
$vigilante_wporg_clean = true; |
| 357 |
if ( null !== $vigilante_wporg ) { |
| 358 |
$vigilante_version = $vigilante_wporg; |
| 359 |
if ( '' === $vigilante_version ) { |
| 360 |
$vigilante_main = (string) file_get_contents( $vigilante_root . '/vigilante.php' ); |
| 361 |
if ( preg_match( '/^\s*\*\s*Version:\s*([0-9][0-9a-zA-Z.\-]*)\s*$/m', $vigilante_main, $vigilante_m ) ) { |
| 362 |
$vigilante_version = $vigilante_m[1]; |
| 363 |
} else { |
| 364 |
fwrite( STDERR, "ERROR: could not parse Version: header from vigilante.php; pass --wporg=X.Y.Z\n" ); |
| 365 |
exit( 2 ); |
| 366 |
} |
| 367 |
} |
| 368 |
|
| 369 |
$vigilante_url = 'https://downloads.wordpress.org/plugin-checksums/vigilante/' . rawurlencode( $vigilante_version ) . '.json'; |
| 370 |
|
| 371 |
// TLS verification stays ON. Some PHP builds ship without a default CA |
| 372 |
// bundle; fall back to the system one if available. |
| 373 |
$vigilante_ssl = array( |
| 374 |
'verify_peer' => true, |
| 375 |
'verify_peer_name' => true, |
| 376 |
); |
| 377 |
if ( '' === (string) ini_get( 'openssl.cafile' ) && is_readable( '/etc/ssl/cert.pem' ) ) { |
| 378 |
$vigilante_ssl['cafile'] = '/etc/ssl/cert.pem'; |
| 379 |
} |
| 380 |
$vigilante_context = stream_context_create( |
| 381 |
array( |
| 382 |
'http' => array( |
| 383 |
'timeout' => 15, |
| 384 |
'ignore_errors' => true, |
| 385 |
'user_agent' => 'Vigilant verify-manifest (https://wordpress.org/plugins/vigilante/)', |
| 386 |
), |
| 387 |
'ssl' => $vigilante_ssl, |
| 388 |
) |
| 389 |
); |
| 390 |
$vigilante_stream = fopen( $vigilante_url, 'r', false, $vigilante_context ); |
| 391 |
$vigilante_body = false; |
| 392 |
$vigilante_status = 0; |
| 393 |
if ( false !== $vigilante_stream ) { |
| 394 |
$vigilante_body = stream_get_contents( $vigilante_stream, 2097152 ); |
| 395 |
$vigilante_meta = stream_get_meta_data( $vigilante_stream ); |
| 396 |
fclose( $vigilante_stream ); |
| 397 |
if ( ! empty( $vigilante_meta['wrapper_data'] ) && is_array( $vigilante_meta['wrapper_data'] ) ) { |
| 398 |
foreach ( $vigilante_meta['wrapper_data'] as $vigilante_header ) { |
| 399 |
if ( is_string( $vigilante_header ) && preg_match( '#^HTTP/\S+\s+(\d{3})#', $vigilante_header, $vigilante_m ) ) { |
| 400 |
$vigilante_status = (int) $vigilante_m[1]; |
| 401 |
} |
| 402 |
} |
| 403 |
} |
| 404 |
} |
| 405 |
|
| 406 |
if ( false === $vigilante_body || 200 !== $vigilante_status ) { |
| 407 |
fwrite( STDERR, 'ERROR: could not fetch wp.org checksums for ' . $vigilante_printable( $vigilante_version ) . ' (HTTP ' . $vigilante_status . ").\n" ); |
| 408 |
fwrite( STDERR, "Right after a release wp.org may not have generated them yet: retry later.\n" ); |
| 409 |
exit( 2 ); |
| 410 |
} |
| 411 |
|
| 412 |
$vigilante_json = json_decode( $vigilante_body, true ); |
| 413 |
if ( ! is_array( $vigilante_json ) || empty( $vigilante_json['files'] ) || ! is_array( $vigilante_json['files'] ) ) { |
| 414 |
fwrite( STDERR, "ERROR: unexpected wp.org checksums payload.\n" ); |
| 415 |
exit( 2 ); |
| 416 |
} |
| 417 |
|
| 418 |
$vigilante_wporg_files = array(); |
| 419 |
foreach ( $vigilante_json['files'] as $vigilante_relative => $vigilante_sums ) { |
| 420 |
if ( ! is_array( $vigilante_sums ) || ! isset( $vigilante_sums['sha256'] ) ) { |
| 421 |
continue; |
| 422 |
} |
| 423 |
// wp.org publishes a string, or an array when the file changed on the same tag. |
| 424 |
$vigilante_wporg_files[ str_replace( '\\', '/', (string) $vigilante_relative ) ] = array_map( 'strval', (array) $vigilante_sums['sha256'] ); |
| 425 |
} |
| 426 |
|
| 427 |
// Direction 1: every manifest entry must be distributed with the same hash. |
| 428 |
foreach ( $vigilante_manifest as $vigilante_relative => $vigilante_expected ) { |
| 429 |
if ( ! isset( $vigilante_wporg_files[ $vigilante_relative ] ) ) { |
| 430 |
fwrite( STDOUT, 'WPORG-MISSING: ' . $vigilante_printable( $vigilante_relative ) . " (in manifest, not distributed by wp.org)\n" ); |
| 431 |
$vigilante_wporg_clean = false; |
| 432 |
continue; |
| 433 |
} |
| 434 |
if ( ! in_array( $vigilante_expected, $vigilante_wporg_files[ $vigilante_relative ], true ) ) { |
| 435 |
fwrite( STDOUT, 'WPORG-MISMATCH: ' . $vigilante_printable( $vigilante_relative ) . " (manifest hash differs from what wp.org distributes)\n" ); |
| 436 |
$vigilante_wporg_clean = false; |
| 437 |
} |
| 438 |
} |
| 439 |
|
| 440 |
// Direction 2: everything wp.org distributes must be in the manifest, |
| 441 |
// except what is never in it (the manifest, readme.txt, changelog.txt). |
| 442 |
foreach ( $vigilante_wporg_files as $vigilante_relative => $vigilante_hashes ) { |
| 443 |
if ( $vigilante_is_excluded( $vigilante_relative ) ) { |
| 444 |
continue; |
| 445 |
} |
| 446 |
if ( ! isset( $vigilante_manifest[ $vigilante_relative ] ) ) { |
| 447 |
fwrite( STDOUT, 'WPORG-EXTRA: ' . $vigilante_printable( $vigilante_relative ) . " (distributed by wp.org, not in manifest)\n" ); |
| 448 |
$vigilante_wporg_clean = false; |
| 449 |
} |
| 450 |
} |
| 451 |
|
| 452 |
if ( $vigilante_wporg_clean ) { |
| 453 |
fwrite( STDOUT, 'wp.org cross-check OK for ' . $vigilante_printable( $vigilante_version ) . ': manifest and distributed checksums agree.' . "\n" ); |
| 454 |
} else { |
| 455 |
fwrite( STDOUT, "wp.org cross-check FAILED: the manifest does not describe what wp.org distributes (see SECURITY.md).\n" ); |
| 456 |
} |
| 457 |
} |
| 458 |
|
| 459 |
// phpcs:enable WordPress.WP.AlternativeFunctions |
| 460 |
|
| 461 |
if ( ! $vigilante_wporg_clean ) { |
| 462 |
exit( 3 ); |
| 463 |
} |
| 464 |
if ( ! $vigilante_local_clean ) { |
| 465 |
exit( 1 ); |
| 466 |
} |
| 467 |
exit( 0 ); |
| 468 |
|