wp-ops malware-scan: Checking a Remote WordPress Site Without Leaving Anything Behind
Malware scanning a WordPress site you can only reach over SSH used to mean a small ritual: scp the scanner script up to /tmp, run it, and remember to delete it before anyone else finds a security tool sitting in a web-adjacent directory. Forget that last step once and you’ve left a scanner — sometimes one with debug output revealing your file structure — reachable on a production box indefinitely. wp-ops 5.23.0 adds a command that skips the whole ritual, and getting it fully right took one more release after that. Here’s both halves.
The scanners, briefly
wp-cli/security/scanner-targeted.php and scanner-general.php are pattern-matching malware scanners for a WordPress install — targeted looks for site-specific threats, general does broader-spectrum detection, both checking PHP/JS/HTML files for known-bad signatures. Since 5.22.0 they also run wp core verify-checksums first and skip pattern-scanning any core or plugin file that hashes clean against WordPress.org’s official checksums — the single biggest source of false positives (an unmodified wp-includes/SimplePie/... file matching a mysql.php-shaped pattern by pure coincidence of its name, for instance) goes away automatically.
Running them locally, or via wp eval-file on a box the scanner already lives on, was always fine. Remote was the awkward case.
The old remote workflow, and why it broke
Before 5.22.0, scanning a remote site meant copying scanner-targeted.php up by hand and running it over SSH. Once the checksum module shipped, that stopped working outright — both scanners now do:
require_once dirname(__FILE__) . '/checksum-verify.php';
A lone copied file has no sibling checksum-verify.php next to it, so it fatals on Failed opening required '.../checksum-verify.php'. The capability to run this properly against a remote or VM target only existed inside the project’s MCP server tool — not from the CLI.
wp-ops malware-scan
wp-ops malware-scan example.com production
wp-ops malware-scan example.com production --mode both
--mode picks targeted, general, or both (default targeted). What it does under the hood: it inlines checksum-verify.php’s source in place of that require_once line, then streams the assembled, self-contained script to php /dev/stdin on the target over SSH. Nothing is ever written to the server — there’s no file to scp, no file to forget, no file to delete.
A real run, both scanners, against a production Bedrock site:
========================================
Malware Scan: example.com (production)
========================================
Target: web@example.com:/srv/www/example.com/current/web/wp
Mode: both
--- TARGETED SCANNER ---
WordPress Core: VERIFIED
Plugins Verified: 6
Files Verified: 3338
Files Failed: 0
Found 1,955 files to scan
Skipping 1955 verified core/plugin files (checksum passed)
After checksum filtering: 0 files to pattern-scan
SCAN SUMMARY:
Files with matches: 0
Total matches: 0
Checksum-verified files skipped: 1,955
✓ No threats detected!
--- GENERAL SCANNER ---
WordPress Core: VERIFIED
Plugins Verified: 6
SCAN SUMMARY:
Suspicious filenames: 0
Files with matches: 0
Checksum-verified filename hits suppressed: 4
✓ No threats detected!
Both scanners agree, checksum verification did the heavy lifting (1,955 files skipped from pattern-matching because they’re provably unmodified core/plugin code), and the whole thing ran in about 11 seconds total.
Host and WordPress path default from a small site registry when one exists, so a site whose SSH host isn’t its own domain — several client sites sharing one box, which is exactly our own setup — needs no flags at all. Without a registry it falls back to the stock Trellis layout: web@<site-name>, /srv/www/<site>/current/web/wp.
The bug the first release shipped with
Both scanners end with a block like this:
============================================
SECURITY WARNING
============================================
⚠ DELETE THIS FILE after use or move it outside the web root!
This scanner should not remain accessible on a production server.
That warning is correct advice — for the FTP-upload, cPanel/Plesk-terminal, or scp’d-wp eval-file workflows, where the scanner genuinely does land as a file in or near the web root. But it kept printing on malware-scan runs too, where nothing was ever written to disk. Stale advice in a security tool’s own output is exactly the kind of thing that erodes trust in the rest of what it tells you, so it needed fixing before the next scan.
My first attempt checked __FILE__ against a short list of expected stdin paths — /dev/fd/0, /dev/stdin, php://stdin. It passed clean on my Mac. It did not pass against the actual production target, which is Linux: php /dev/stdin there resolves __FILE__ to /proc/<pid>/fd/pipe:[18694749] — a real pipe, just not one of the strings I’d guessed. String-matching platform-specific pipe representations was never going to be a complete list.
The fix that actually holds across platforms:
function scanner_file_is_on_disk() {
return is_file(__FILE__);
}
is_file() asks the filesystem directly rather than pattern-matching a path. It returns false for a pipe on every platform I could test — macOS’s /dev/fd/0 and Linux’s /proc/.../fd/pipe:[...] both fail it — and true for a script actually sitting on disk. Wrap the warning block in that check, and it fires exactly when there’s a real file for someone to go delete, on any host, regardless of how that platform happens to name its stdin pipe internally.
The lesson that actually generalizes here: a fix verified only against your own machine, for anything touching cross-platform filesystem or process behavior, isn’t verified. Re-running the real command against the real remote target — not a local approximation of it — is what caught the gap.
Manually checking a file the scanner skipped
Pattern-matching has a 5MB file-size cap for performance, so scan output sometimes includes a note like:
============================================
SKIPPED FILES
============================================
/srv/www/example.com/current/web/wp/wp-includes/js/dist/script-modules/vips/worker.min.js
Reason: File too large (12.8 MB)
Worth knowing: that cap only applies to the pattern-matching pass. Checksum verification has no size limit — wp core verify-checksums hashes every core file regardless of size — so a skipped file is still very likely covered by the earlier “WordPress Core: VERIFIED” result, just not pattern-scanned on top of that. If you want to confirm one specific file yourself rather than take that on faith, WordPress.org’s checksums API makes it a two-line check:
ssh web@example.com '
WP_VER=$(cd /srv/www/example.com/current && wp core version --path=web/wp)
FILE="wp-includes/js/dist/script-modules/vips/worker.min.js"
curl -s "https://api.wordpress.org/core/checksums/1.0/?version=${WP_VER}&locale=en_US" \
| python3 -c "import json,sys; print(json.load(sys.stdin)[\"checksums\"].get(\"$FILE\", \"NOT FOUND\"))"
md5sum /srv/www/example.com/current/web/wp/$FILE
'
The API returns every core file’s official md5 for that exact WordPress version, keyed by its relative path from the wp root. Fetch the one hash you care about, md5sum the live file, and compare by eye:
Official md5: 2710fbcfdcdf95a4ed07e251bc09c66c
Local md5: 2710fbcfdcdf95a4ed07e251bc09c66c
A match means the file is stock, unmodified core straight from wordpress.org for that version — no scanner, no size cap, no waiting required.
Where this leaves things
malware-scan is on the tap now:
brew upgrade --cask wp-ops
wp-ops malware-scan example.com production --mode both
Both scanners are the same ones you’d run locally or via wp eval-file — the remote path just gets you there without a file to clean up afterward, and without a leftover warning telling you to clean up something that was never there.
This is part of the tooling we run at Imagewize for client WordPress operations on Trellis. If your team is still doing the scp-and-remember-to-delete dance for remote security checks, get in touch.
Questions about your own Trellis workflow? Find me on Mastodon at @jfrumau@mastodon.social.