DEV Community

Calin V.
Calin V.

Posted on

The integrity check you already have skips your paid extensions

WP-CLI ships a real integrity check. It compares installed plugin files against the checksums published by wordpress.org:

wp plugin verify-checksums --all
Enter fullscreen mode Exit fullscreen mode

On a blog, that is a decent baseline. On a store, run it and read the warnings rather than the success line:

Warning: Could not retrieve the checksums for version 5.4.1 of plugin woocommerce-subscriptions, skipping.
Warning: Could not retrieve the checksums for version 1.15.6 of plugin woocommerce-bookings, skipping.
Warning: Could not retrieve the checksums for version 3.2.0 of plugin advanced-shipping-rates, skipping.
Success: Verified 12 of 34 plugins.
Enter fullscreen mode Exit fullscreen mode

The skips are not a bug. Checksums exist only for plugins hosted in the wordpress.org directory. Premium and custom plugins have no published source of truth to compare against, which is documented behaviour and a long-standing open request in the wp-cli repo. wp core verify-checksums works fine for core, because core publishes checksums.

Now line that up with what a WooCommerce build is. Gateways, subscriptions, shipping logic, tax, currency, abandoned-cart recovery. Fifteen to forty extensions attached to the payment path, and nearly all of them paid. According to Patchstack's State of WordPress Security in 2026, 91% of the 11,334 vulnerabilities disclosed across the ecosystem in 2025 were in plugins rather than core, and of 1,983 valid reports against premium or freemium products, 76% were rated exploitable in real-world attacks, with premium products carrying three times as many known exploited vulnerabilities as free ones.

The one integrity check WordPress gives you covers the third of your plugin list with the least exposure, and skips the two thirds with the most.

Build the baseline the checksum command cannot

If nobody publishes a manifest for your paid extensions, publish your own. Do it immediately after an update, from a state you have reason to trust, and store it off the box.

#!/usr/bin/env bash
# baseline.sh - snapshot every plugin file hash after a known-good update
set -euo pipefail

WP_ROOT="/var/www/store"
OUT="/opt/manifests/plugins-$(date +%Y%m%d-%H%M).sha256"

find "${WP_ROOT}/wp-content/plugins" \
  -type f \( -name '*.php' -o -name '*.js' \) \
  -not -path '*/node_modules/*' \
  -print0 | sort -z | xargs -0 sha256sum > "${OUT}"

ln -sfn "${OUT}" /opt/manifests/plugins-latest.sha256
echo "wrote ${OUT} ($(wc -l < "${OUT}") files)"
Enter fullscreen mode Exit fullscreen mode

Then the check, which is the half that runs on a schedule:

#!/usr/bin/env bash
# drift.sh - fail loudly if a plugin file changed outside an update window
set -uo pipefail

if ! sha256sum -c --quiet /opt/manifests/plugins-latest.sha256 2>/dev/null; then
  echo "PLUGIN FILE DRIFT on $(hostname) at $(date -Is)" >&2
  exit 1
fi
echo "clean"
Enter fullscreen mode Exit fullscreen mode

Two things make this worth the twenty minutes. It covers premium code, which is the part verify-checksums cannot see. And it produces a dated artifact, which matters later, because "when did this file change" is the first question anyone asks and the hardest one to answer after the fact.

One honest limit up front. File hashing catches changes on disk. It does not catch a compromise where no plugin file changes at all, which is a real shape: a plugin that renders a remote feed inside wp-admin can serve attacker-controlled content while every file on the server hashes clean. Treat drift detection as one signal, not the answer.

Watch what the checkout page actually loads

A skimmer earns in the browser, so check the browser. The useful inventory is not "what plugins are installed" but "what script origins does my checkout page pull from, and has that set changed".

Collect the current set from the rendered page:

// paste in DevTools on /checkout/, in an incognito window, logged out
[...document.querySelectorAll('script[src]')]
  .map(s => new URL(s.src, location.href).origin)
  .filter((v, i, a) => a.indexOf(v) === i)
  .sort();
Enter fullscreen mode Exit fullscreen mode

On a working store that list is short and boring. Your own origin, the payment gateway, maybe an analytics host. If a fourth origin you do not recognise is in there, you have found something without running a scanner.

Once you know the real set, enforce it. Content-Security-Policy on the checkout route turns the inventory into a control, because a script injected from anywhere else stops executing:

location = /checkout/ {
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com; connect-src 'self' https://api.stripe.com; object-src 'none'; base-uri 'self'" always;
}
Enter fullscreen mode Exit fullscreen mode

Ship it in Content-Security-Policy-Report-Only with a report-uri for a week first. A checkout is the worst page on the site to break silently, and page builders and tag managers will surface violations you did not predict.

Change what a scanner can map before it maps it

The reconnaissance step is cheap for the attacker and it is the step you can actually take away. An automated crawler reads paths and version strings, matches them against a public vulnerability list, and only then decides your store is worth a request. Break that chain and a large share of traffic never reaches the exploit stage, because the target selection fails.

Stop advertising versions:

// mu-plugin: 000-no-fingerprints.php
add_filter('the_generator', '__return_empty_string');
remove_action('wp_head', 'wp_generator');
remove_action('wp_head', 'rsd_link');
remove_action('wp_head', 'wlwmanifest_link');

// drop the ?ver= query string that pins every asset to a release
add_filter('style_loader_src', 'store_strip_ver', 10, 1);
add_filter('script_loader_src', 'store_strip_ver', 10, 1);
function store_strip_ver($src) {
    return $src ? remove_query_arg('ver', $src) : $src;
}
Enter fullscreen mode Exit fullscreen mode

Return 404 on the endpoints a store has no reason to expose publicly, at the rewrite layer, before PHP starts:

# probes that only ever precede an exploit attempt on a store
location ~* ^/(wp-config\.php\.(bak|old|save|swp)|\.env|\.git/) { return 404; }
location = /xmlrpc.php { return 404; }
location ~* ^/wp-content/(plugins|themes)/[^/]+/readme\.txt$ { return 404; }
location ~* ^/wp-content/uploads/.*\.(php|phtml|phar)$ { return 404; }
Enter fullscreen mode Exit fullscreen mode

Apache, same idea:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteRule ^wp-content/(plugins|themes)/[^/]+/readme\.txt$ - [R=404,L]
  RewriteRule ^wp-content/uploads/.*\.(php|phtml|phar)$ - [R=404,L]
  RewriteRule ^xmlrpc\.php$ - [R=404,L]
</IfModule>
Enter fullscreen mode Exit fullscreen mode

Those readme.txt files are the quiet one. Every plugin ships one, it names the plugin and its version in plain text, and a crawler reads it without authenticating. Reconfigure the response and version-matched targeting fails at the reconnaissance step.

Two numbers that explain why this sits alongside patching rather than after it. Patchstack 2026 puts the weighted median time to first exploitation at five hours, and reports that 46% of 2025 vulnerabilities had no patch available at disclosure. Patch on the day, which is faster than most portfolios manage, and you are still late for a large share of disclosures, with nothing to apply for roughly half of them.

What this stack does not do

It does not detect and it does not clean. Everything above reduces what is reachable and tells you when something on disk moved. None of it finds malware already resident, and rewrite rules have nothing to say about a file that is already executing.

If you think a skimmer is live in your checkout right now, the order is the other way round. Scan and clean with something built for detection, restore from a backup you have reason to trust, then close the path. Doing surface reduction first feels productive and accomplishes very little.

Worth knowing before the cleanup, because it changes what "done" means: Patchstack 2026 records that the two top variants of the Monarx/Lock360 malware family accounted for 38% and 32% of all injected file detections in 2025, and that the family rewrites cleaned files from server memory as they are restored. Monarx's own conclusion, from roughly nine trillion file signals, is that signature-based delete-only security is no longer sufficient. A clean that leaves the entry path open frequently does not hold.

And the part that is not a technical decision. If card data was skimmed, this is a cardholder-data event under PCI DSS rather than a cleanup job, and the disclosure obligations depend on how your gateway handles card data. That is worth establishing while nothing is wrong, not during.

One question

For the people running stores in production: what is actually watching your paid extensions between updates? A hash manifest, a commercial file-integrity service, your host, or, if we are being honest about it, nothing at all?

I run engineering at Squirrly and spend more time in access logs than I would choose to. The longer version of this argument, with the layer comparison as a table, is here: https://wpghost.com/woocommerce-security/

Top comments (0)