Skip to content

Fix plugin verification to detect directories without main PHP files#143

Open
Copilot wants to merge 12 commits intomainfrom
copilot/fix-plugin-verification-checks
Open

Fix plugin verification to detect directories without main PHP files#143
Copilot wants to merge 12 commits intomainfrom
copilot/fix-plugin-verification-checks

Conversation

Copy link

Copilot AI commented Nov 1, 2025

Summary

Fixes security issue where wp plugin verify-checksums skipped plugin directories without valid main plugin files, allowing malware to hide in these "shadow" directories.

Changes

1. Enhanced Plugin Detection (get_all_plugin_names())

  • Scans wp-content/plugins/ filesystem for all plugin directories
  • Includes both plugins with valid headers AND directories without headers
  • Security measures:
    • Directory readability validation
    • Symlink exclusion to prevent traversal attacks
    • Proper error handling

2. Enhanced Plugin Fetcher (UnfilteredPlugin::get())

  • Detects plugin directories even when main files are missing
  • Constructs conventional plugin file path for verification

3. Version Detection Fallback (detect_version_from_directory())

  • Scans PHP files for Version header using WordPress's get_file_data() function
  • Only looks at .php files (standard PHP extension)
  • Readability checks before processing
  • get_file_data() handles file reading efficiently (reads only 8KB)

4. User Warnings

  • Clear warning when main plugin file is missing during verification
  • Helps administrators identify potential security issues

5. Test Coverage

  • Comprehensive test scenario for missing main file case
  • Uses explicit --version flag when main file is missing to ensure verification proceeds
  • Validates renamed files are detected
  • Validates warnings are shown
  • Tests both single plugin and --all flag scenarios

Security Impact

Before: Attackers could hide malicious files in plugin directories by removing/renaming the main plugin file. These directories were completely ignored during checksum verification.

After: All plugin directories are verified against WordPress.org checksums, regardless of whether they have valid plugin headers. Malware cannot hide using this technique.

Usage Note

When a plugin's main file is missing or renamed and version cannot be auto-detected from other PHP files in the directory, users can explicitly provide the version using the --version flag:

wp plugin verify-checksums plugin-name --version=1.2.3

Backward Compatibility

✅ Fully backward compatible - existing plugins with valid headers work exactly as before
✅ Only affects detection of plugin directories without valid main files (previously ignored)
✅ No breaking changes to API or command syntax

Files Changed

  • src/Checksum_Plugin_Command.php: +77 lines
  • src/WP_CLI/Fetchers/UnfilteredPlugin.php: +11 lines
  • features/checksum-plugin.feature: +34 lines (test)

Total: 122 lines added, 21 lines removed

Testing Status

  • PHP syntax validation passed
  • Code review completed and all issues addressed
  • Security hardening implemented
  • Code style issues fixed
  • Using WordPress's get_file_data() function
  • Only scanning .php files (not renamed files)
  • Test updated to use explicit --version flag
  • Behat tests (requires full test environment)
Original prompt

This section details on the original issue you should resolve

<issue_title>Verification checks skipped when plugin missing main PHP file</issue_title>
<issue_description>Recently ran into a situation where a website had malware reinfection issues which required a bit of a deep dive to resolve. During the process I discovered that wp plugin verify-checksums --all will only check plugins which have their main plugin.php file. For example, let's install a plugin then break the main file by renaming:

wp plugin install wordfence
mv wp-content/plugins/wordfence/wordfence.php wp-content/plugins/wordfence/wordfence.php.bad

Now if we try and run wp plugin verify-checksums wordfence we'll get the following:

Warning: The 'wordfence' plugin could not be found.
Error: You need to specify either one or more plugin slugs to check or use the --all flag to check all plugins.

Also if we run wp plugin verify-checksums --all it will say success and not even attempt to run any checks on the /wordfence/ directory. This is a problem as bad actors can use this method to hide files in these shadow plugin folders. Also there is no indication that these PHP files exist from /wp-admin/plugins.php.

I think the solution should be to run checksums verifications based solely on the directory names. If a plugin directory matches a wordpress.org plugin then maybe run the verification checks?</issue_description>

Comments on the Issue (you are @copilot in this section)

@danielbachhuber Interesting report!

I agree, this is worth putting together an enhancement for.</comment_new>


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 3 commits November 1, 2025 19:59
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix verification checks for plugins missing main PHP file Fix plugin verification to detect directories without main PHP files Nov 1, 2025
Copilot AI requested a review from swissspidy November 1, 2025 20:12
@codecov
Copy link

codecov bot commented Nov 1, 2025

Codecov Report

❌ Patch coverage is 94.59459% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Checksum_Plugin_Command.php 93.93% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@swissspidy

This comment was marked as resolved.

Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>

This comment was marked as resolved.

@swissspidy swissspidy marked this pull request as ready for review November 1, 2025 21:19
@swissspidy swissspidy requested a review from a team as a code owner November 1, 2025 21:19
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
@swissspidy swissspidy added the command:plugin-verify-checksums Related to 'plugin verify-checksums' command label Dec 12, 2025
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
@swissspidy

This comment was marked as resolved.

Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>

This comment was marked as resolved.

@swissspidy

This comment was marked as resolved.

…plicitly

Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>

This comment was marked as resolved.

@swissspidy

This comment was marked as resolved.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses a security vulnerability where plugin directories without a main PHP file were skipped during checksum verification. The changes are well-implemented, introducing fallbacks to detect such 'shadow' plugins by scanning the filesystem. The new logic in get_all_plugin_names and detect_version_from_directory is sound, and the UnfilteredPlugin fetcher correctly identifies plugins even without their main file. The addition of a Behat test scenario ensures this fix is properly verified. I have a couple of suggestions to improve code quality and maintainability, but overall this is an excellent contribution.

Comment on lines +293 to +309
if ( is_dir( $plugin_dir ) && is_readable( $plugin_dir ) ) {
$dirs = @scandir( $plugin_dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false !== $dirs ) {
foreach ( $dirs as $dir ) {
// Skip special directories and files
if ( '.' === $dir || '..' === $dir ) {
continue;
}

$full_path = $plugin_dir . '/' . $dir;
// Only include real directories, not symlinks or files
if ( is_dir( $full_path ) && ! is_link( $full_path ) && ! in_array( $dir, $names, true ) ) {
$names[] = $dir;
}
}
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider refactoring this block to use DirectoryIterator. This provides a more modern, object-oriented approach to iterating over filesystem entries and handles errors through exceptions, which is cleaner than suppressing warnings with @scandir. It also simplifies the loop by providing methods like isDot(), isDir(), and isLink(), making the code more readable and robust.

if ( is_dir( $plugin_dir ) && is_readable( $plugin_dir ) ) {
	try {
		foreach ( new DirectoryIterator( $plugin_dir ) as $fileinfo ) {
			if ( $fileinfo->isDot() || ! $fileinfo->isDir() || $fileinfo->isLink() ) {
				continue;
			}
			$dir = $fileinfo->getFilename();
			if ( ! in_array( $dir, $names, true ) ) {
				$names[] = $dir;
			}
		}
	} catch ( UnexpectedValueException $e ) {
		WP_CLI::warning( "Could not scan plugin directory '{$plugin_dir}': " . $e->getMessage() );
	}
}

}
}

return array_unique( $names );

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to array_unique() is redundant here. The ! in_array( $dir, $names, true ) check within the loop already ensures that no duplicate plugin names are added to the $names array. Removing this call will make the code slightly more efficient and clearer.

return $names;

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a security vulnerability where wp plugin verify-checksums would skip plugin directories that lack valid main PHP files, allowing malware to hide undetected in these "shadow" directories. The fix enhances plugin detection to scan the filesystem for all plugin directories and verify them against WordPress.org checksums, regardless of whether they have valid plugin headers.

Changes:

  • Enhanced get_all_plugin_names() to scan the filesystem for all plugin directories, not just those with valid headers, with security measures including symlink exclusion
  • Modified UnfilteredPlugin::get() to detect plugin directories even when main files are missing
  • Added detect_version_from_directory() fallback method to find version information from any PHP file in a plugin directory

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/WP_CLI/Fetchers/UnfilteredPlugin.php Adds fallback logic to detect plugin directories without main files by checking filesystem directly
src/Checksum_Plugin_Command.php Enhances plugin detection with filesystem scanning, adds version detection fallback, includes security measures and user warnings
features/checksum-plugin.feature Adds test scenario for verifying plugins with missing/renamed main files

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

// Try scanning PHP files for Version header using WordPress's get_file_data()
$files = glob( $plugin_path . '/*.php' );
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The glob pattern does not escape special glob characters in the plugin directory path. If a plugin directory contains characters like *, ?, [, or ], the glob function could match unintended files or fail unexpectedly. Consider using glob() with the GLOB_BRACE flag or pre-escaping the path with a function that escapes glob metacharacters, or use an alternative approach like scandir() followed by filtering.

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +240
When I try `wp plugin verify-checksums --all --format=json`
Then STDOUT should contain:
"""
"plugin_name":"duplicate-post"
"""
And STDERR should contain:
"""
Warning: Plugin duplicate-post main file is missing: duplicate-post/duplicate-post.php
"""
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test scenario for verifying a plugin directory with --all flag (lines 232-240) doesn't specify whether version detection succeeds or fails. If the version cannot be auto-detected from the renamed file, the plugin would be skipped with a "Could not retrieve the version" warning, not verified. The test should either verify that version detection succeeds from other PHP files in the directory, or it should expect the version warning and skip message. Consider adding explicit assertions about the version detection outcome.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

command:plugin-verify-checksums Related to 'plugin verify-checksums' command

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Verification checks skipped when plugin missing main PHP file

2 participants