0 ) { return $update_version ? 'Files differ from the official version.' . $upd . ' Update the plugin, then rescan. If it is still flagged, reinstall it and investigate.' : 'Files differ from the official version and the plugin is up to date. Reinstall the same version from WordPress.org, then rescan. If it is still flagged, treat as a possible compromise.'; } if ( $review > 0 ) { return $update_version ? 'Minor differences (missing or extra non-PHP files).' . $upd . ' Update first, then rescan.' : 'Minor differences (missing or extra non-PHP files), often cache or log files the plugin creates. Review the listed paths; no action if they are expected.'; } return $update_version ? 'Files match the installed version.' . $upd : 'No action needed.'; } /** * @param array $args { * @type string $plugins Optional comma-separated plugin folder slugs to * limit the scan to (e.g. "akismet,gravity-forms"). * Omit to scan every installed plugin. * @type int $max_plugins Safety cap on plugins scanned in one call. Default 50. * } * @return array */ function miintg_verify_plugin_checksums( $args = array() ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $limits = miintg_limits(); $max_plugins = isset( $args['max_plugins'] ) ? max( 1, (int) $args['max_plugins'] ) : 50; $only_slugs = array(); if ( ! empty( $args['plugins'] ) ) { $only_slugs = array_filter( array_map( 'trim', explode( ',', (string) $args['plugins'] ) ) ); $only_slugs = array_flip( $only_slugs ); } $walker = new MIINTG_FileWalker( array( 'max_files' => $limits['max_files'], 'max_depth' => $limits['max_depth'], 'max_read_bytes' => $limits['max_read_bytes'], ) ); $all_plugins = get_plugins(); // Newer versions WordPress already knows about (cached, read-only). $update_data = get_site_transient( 'update_plugins' ); $update_map = ( is_object( $update_data ) && ! empty( $update_data->response ) ) ? (array) $update_data->response : array(); $plugins_out = array(); $scanned = 0; $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR ); foreach ( $all_plugins as $plugin_file => $data ) { $folder = dirname( $plugin_file ); $slug = ( '.' !== $folder ) ? $folder : null; $name = isset( $data['Name'] ) ? $data['Name'] : $plugin_file; $version = isset( $data['Version'] ) ? $data['Version'] : ''; $update_version = null; if ( isset( $update_map[ $plugin_file ] ) ) { $upd_row = (object) $update_map[ $plugin_file ]; $update_version = isset( $upd_row->new_version ) ? (string) $upd_row->new_version : null; } if ( ! empty( $only_slugs ) && ( null === $slug || ! isset( $only_slugs[ $slug ] ) ) ) { continue; } if ( $scanned >= $max_plugins ) { break; } $scanned++; if ( null === $slug ) { $plugins_out[] = array( 'plugin' => $name, 'slug' => null, 'version' => $version, 'manifest_available' => false, 'reason' => 'Single-file plugin; its wordpress.org slug cannot be reliably derived from the filename.', 'update_available' => (bool) $update_version, 'update_version' => $update_version, 'recommendation' => miintg_plugin_recommendation( false, 0, 0, $update_version ), ); continue; } $checksums = MIINTG_Manifest::get_plugin_checksums( $slug, $version ); if ( null === $checksums ) { $plugins_out[] = array( 'plugin' => $name, 'slug' => $slug, 'version' => $version, 'manifest_available' => false, 'reason' => 'No checksum manifest at this slug/version — likely a premium/non-.org plugin, or this version was never published there.', 'update_available' => (bool) $update_version, 'update_version' => $update_version, 'recommendation' => miintg_plugin_recommendation( false, 0, 0, $update_version ), ); continue; } $plugin_dir = trailingslashit( $plugins_dir ) . $slug; $norm_dir = wp_normalize_path( $plugin_dir ); $modified = array(); $missing = array(); foreach ( $checksums as $rel_path => $hashes ) { $abs_path = $plugin_dir . '/' . $rel_path; if ( ! file_exists( $abs_path ) || ! is_file( $abs_path ) ) { $missing[] = $rel_path; continue; } $actual_md5 = @md5_file( $abs_path ); $expected = isset( $hashes['md5'] ) ? $hashes['md5'] : null; if ( null === $expected || false === $actual_md5 || $actual_md5 !== $expected ) { $modified[] = $rel_path; } } $unexpected = array(); if ( is_dir( $plugin_dir ) ) { foreach ( $walker->walk( $plugin_dir, true ) as $abs_path ) { $rel = ltrim( substr( wp_normalize_path( $abs_path ), strlen( $norm_dir ) ), '/' ); if ( ! isset( $checksums[ $rel ] ) ) { $unexpected[] = $rel; } } } sort( $modified ); sort( $missing ); sort( $unexpected ); // Drop findings the user marked as reviewed (still in the same state). $scope = 'plugin:' . $slug; list( $modified, $rev_modified ) = miintg_reviewed_partition( $scope, $modified, 'modified' ); list( $missing, $rev_missing ) = miintg_reviewed_partition( $scope, $missing, 'missing' ); list( $unexpected, $rev_unexpected ) = miintg_reviewed_partition( $scope, $unexpected, 'unexpected' ); $reviewed_out = array(); foreach ( $rev_modified as $p ) { $reviewed_out[] = array( 'path' => $p, 'kind' => 'modified' ); } foreach ( $rev_missing as $p ) { $reviewed_out[] = array( 'path' => $p, 'kind' => 'missing' ); } foreach ( $rev_unexpected as $p ) { $reviewed_out[] = array( 'path' => $p, 'kind' => 'unexpected' ); } $is_php = function ( $rel_path ) { return 'php' === strtolower( pathinfo( $rel_path, PATHINFO_EXTENSION ) ); }; // An unexpected PHP file is Critical only if it carries obfuscation / // backdoor call patterns (the same head-only flags the uploads check // uses). Without them it is Review: a plugin's own generated or // bundled file is far more likely than a webshell. Not proof either // way — a webshell can avoid these patterns, and legitimate code can // contain them. $unexpected_php_flagged = array(); $unexpected_php_plain = 0; $unexpected_nonphp = 0; foreach ( $unexpected as $rel_path ) { if ( ! $is_php( $rel_path ) ) { $unexpected_nonphp++; continue; } $flags = $walker->signature_flags( $plugin_dir . '/' . $rel_path ); $hits = array_keys( array_filter( $flags ) ); if ( $hits ) { $unexpected_php_flagged[ $rel_path ] = $hits; } else { $unexpected_php_plain++; } } $max = $limits['max_results']; $sev_critical = count( $modified ) + count( $unexpected_php_flagged ); $sev_review = count( $missing ) + $unexpected_nonphp + $unexpected_php_plain; $plugins_out[] = array( 'plugin' => $name, 'slug' => $slug, 'version' => $version, 'manifest_available' => true, 'checksummed_files' => count( $checksums ), 'modified_count' => count( $modified ), 'missing_count' => count( $missing ), 'unexpected_count' => count( $unexpected ), 'modified' => array_slice( $modified, 0, $max ), 'missing' => array_slice( $missing, 0, $max ), 'unexpected' => array_slice( $unexpected, 0, $max ), 'unexpected_php_flagged' => array_slice( $unexpected_php_flagged, 0, $max, true ), 'unexpected_php_plain_count' => $unexpected_php_plain, 'reviewed_count' => count( $reviewed_out ), 'reviewed' => array_slice( $reviewed_out, 0, $max ), 'severity' => array( 'critical' => $sev_critical, 'review' => $sev_review, ), 'update_available' => (bool) $update_version, 'update_version' => $update_version, 'recommendation' => miintg_plugin_recommendation( true, $sev_critical, $sev_review, $update_version ), ); } $totals = array( 'plugins_scanned' => $scanned, 'plugins_installed' => count( $all_plugins ), 'manifest_available_for' => count( array_filter( $plugins_out, function ( $p ) { return ! empty( $p['manifest_available'] ); } ) ), ); return miintg_envelope( 'wp_verify_plugin_checksums', array( 'scope' => 'plugins only — see notes', 'totals' => $totals, 'plugins' => $plugins_out, 'notes' => 'Themes are out of scope: WordPress.org has no theme-checksums manifest API (open feature request, meta ticket #6810); the only workaround downloads and unzips each theme, which would write a temp file and violate this plugin\'s no-write guardrail. Plugin slug is assumed to equal its wp-content/plugins/ folder name, which is standard but not guaranteed for manually renamed installs. Comparison uses md5 only; sha256 is available in the manifest but unused for consistency with the other tools in this plugin.', ) ); }