DEV Community

Cover image for Start with an inventory you can diff against next year
Calin V.
Calin V.

Posted on Originally published at wpghost.com

Start with an inventory you can diff against next year

WP-CLI already knows most of what you need.

wp plugin list \
  --fields=name,title,status,version,update,update_version,auto_update \
  --format=csv > "inventory-$(date +%F).csv"
Enter fullscreen mode Exit fullscreen mode

Run that on every site and keep the files. A year of these is a version history you did not have to ask a vendor for, and the diff between two of them answers the renewal question directly.

For a portfolio, fan it out over an alias group and tag each row with the site:

#!/usr/bin/env bash
# inventory.sh - collect plugin state across an alias group into one CSV
set -euo pipefail

OUT="inventory-$(date +%F).csv"
echo "site,name,version,update,update_version,status" > "$OUT"

for site in $(wp @all cli info --format=json 2>/dev/null | jq -r 'keys[]'); do
  wp "@$site" plugin list \
     --fields=name,version,update,update_version,status \
     --format=csv --skip-plugins --skip-themes 2>/dev/null \
   | tail -n +2 \
   | sed "s/^/$site,/" >> "$OUT"
done

wc -l "$OUT"
Enter fullscreen mode Exit fullscreen mode

--skip-plugins --skip-themes keeps a broken plugin from taking the inventory run down with it. You want the census even when one site is unhappy.

Ask the directory what changed, for the plugins it knows about

For anything hosted on wordpress.org, the public API will tell you the release history without any authentication.

slug="query-monitor"

curl -sS "https://api.wordpress.org/plugins/info/1.0/${slug}.json" \
  | jq '{
      name,
      version,
      last_updated,
      active_installs,
      tested,
      requires_php,
      releases: (.versions | keys | map(select(. != "trunk")) | sort | .[-8:])
    }'
Enter fullscreen mode Exit fullscreen mode

Two fields matter for a renewal decision. last_updated tells you whether the product is being worked on at all. The tail of versions tells you how many releases shipped in the period you paid for, which is a proxy for whether the value arrived as a stream or as a one-time setup.

A wrapper over the whole inventory, so you get one table instead of forty curl calls:

#!/usr/bin/env bash
# release-cadence.sh - how many releases has each .org plugin shipped, and when was the last one
set -euo pipefail

printf '%-32s %-12s %-22s %s\n' SLUG INSTALLED LAST_UPDATED RELEASES_KNOWN

wp plugin list --field=name --status=active | while read -r slug; do
  json=$(curl -sS --max-time 10 \
    "https://api.wordpress.org/plugins/info/1.0/${slug}.json" 2>/dev/null || echo '{}')

  # a premium plugin is not in the directory and returns null or an error object
  if [ "$(echo "$json" | jq -r 'type')" != "object" ] || [ "$(echo "$json" | jq -r '.version // "null"')" = "null" ]; then
    printf '%-32s %-12s %-22s %s\n' "$slug" "$(wp plugin get "$slug" --field=version)" "not-in-directory" "-"
    continue
  fi

  printf '%-32s %-12s %-22s %s\n' \
    "$slug" \
    "$(wp plugin get "$slug" --field=version)" \
    "$(echo "$json" | jq -r '.last_updated' | cut -d' ' -f1)" \
    "$(echo "$json" | jq -r '.versions | keys | length')"
done
Enter fullscreen mode Exit fullscreen mode

The rows that print not-in-directory are the interesting ones. Those are your paid plugins, and they are exactly the ones the public tooling cannot help you with.

The integrity check does not cover the things you pay for

This is the part that surprised me when I first hit it, and it is load-bearing for anyone reasoning about premium plugin risk.

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

That command compares the files on disk against the checksums published by wordpress.org. It works, it is fast, and it is genuinely useful for catching tampering. It also only works for plugins hosted in the directory. A premium plugin returns something like:

Warning: Could not retrieve the checksums for version 4.1.2 of plugin acme-pro, skipping.
Enter fullscreen mode Exit fullscreen mode

There is no published source of truth to compare against, because the vendor distributes the zip themselves. It is documented behaviour on developer.wordpress.org and a long-standing open request in the wp-cli repo, not a bug in your setup.

The implication is worth stating plainly. The one built-in integrity check WordPress ships does not cover premium plugins, which is precisely where vendor-delivered tampering has actually occurred. The ShapedPlugin compromise in June 2026, CVE-2026-10735 at CVSS 9.8, distributed backdoored builds to paying customers through the vendor's own update system. As reported by BleepingComputer and The Hacker News, and per data collected by Defiant, the backdoor went into the Pro builds on 21 May and the first customer reports arrived on 10 June, so roughly twenty days passed with the malicious release being served as a legitimate update. Only the paid tiers were affected.

wp core verify-checksums is the equivalent for core and does work, so run it anyway:

wp core verify-checksums
Enter fullscreen mode Exit fullscreen mode

Diff what a release actually changed

For premium plugins, the only reliable record of what you bought is the code itself. Keep the plugin directory in git and every update becomes a reviewable diff.

cd wp-content/plugins
git init
printf '*/cache/\n*/logs/\n*.log\n' > .gitignore
git add -A
git commit -m "baseline $(date +%F)"
Enter fullscreen mode Exit fullscreen mode

Then commit after each update round, on a schedule, with the version in the message:

#!/usr/bin/env bash
# snapshot.sh - commit the plugin tree after an update round
set -euo pipefail
cd "$(wp plugin path)"

wp plugin list --fields=name,version --format=csv > .versions.csv
git add -A
git commit -q -m "plugins $(date +%F)" || { echo "no change"; exit 0; }
git --no-pager log -1 --stat | head -40
Enter fullscreen mode Exit fullscreen mode

A year later, the renewal question has an exact answer:

# what did this vendor ship in the last 12 months, by volume and by file
git log --since='12 months ago' --oneline -- acme-pro | wc -l
git diff --stat "@{12 months ago}" HEAD -- acme-pro
Enter fullscreen mode Exit fullscreen mode

Read the diff, not just the counter. A vendor shipping eleven releases that only bump a version constant and update a readme is not the same as a vendor shipping four releases that rewrite the request handling. I have had both, and only one of them was worth the renewal.

Two things to keep in mind before you do this on a real server. The tree contains no secrets in a normal install, but check for any plugin writing licence keys into its own directory before you commit, and never push the repo anywhere public. And keep the repo out of the web root's reachable space, or at minimum confirm your server config rejects requests for .git.

Put a number on it

Once you have release cadence and diff volume, the pricing comparison stops being a feeling. This computes break-even against a one-time price and prints the cadence you measured alongside it, so you are not deciding on cost alone.

#!/usr/bin/env python3
"""breakeven.py - compare annual vs one-time licensing across an inventory.

Usage: ./breakeven.py licences.csv
CSV columns: name,annual,onetime,kind,releases_12mo
  kind = detection | configuration
"""
import csv
import sys

HORIZON = 6  # years to model

def main(path):
    rows = list(csv.DictReader(open(path)))
    print(f"{'plugin':<24}{'kind':<15}{'rel/yr':>7}{'annual':>9}{'once':>8}{'break-even':>12}")
    total_annual = 0.0
    for r in rows:
        annual = float(r["annual"] or 0)
        once = float(r["onetime"] or 0)
        total_annual += annual
        if annual <= 0 or once <= 0:
            be = "n/a"
        else:
            years = once / annual
            be = f"{years:.1f} yr" if years <= HORIZON else f">{HORIZON} yr"
        print(f"{r['name']:<24}{r['kind']:<15}{r['releases_12mo']:>7}{annual:>9.0f}{once:>8.0f}{be:>12}")

    print(f"\nannual spend: {total_annual:.0f}/yr, {total_annual * HORIZON:.0f} over {HORIZON} years")

    stale = [r for r in rows
             if r["kind"] == "configuration" and int(r["releases_12mo"] or 0) <= 2]
    if stale:
        print("\nconfiguration tooling with <=2 releases in 12 months (review these first):")
        for r in stale:
            print(f"  - {r['name']}: {r['releases_12mo']} releases, {float(r['annual']):.0f}/yr")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "licences.csv")
Enter fullscreen mode Exit fullscreen mode

The kind column is doing the real work, and it is the one judgement call in the whole exercise. Detection products are worth the most on the day after a new vulnerability lands, so their value arrives as a stream and a recurring fee matches it. Configuration products apply a setting once and then hold it, so the maintenance curve is flatter. A plugin in the second category shipping two releases a year is the row where recurring pricing is doing the least work for you.

Why any of this matters more than a tighter budget

Two findings from Patchstack's State of WordPress Security in 2026 changed how I read a renewal invoice.

The first: of 1,983 valid vulnerability reports filed against premium or freemium WordPress products in 2025, 76% were rated exploitable in real-world attacks, and premium products carried three times as many Known Exploited Vulnerabilities as free ones. Paying buys you features, support and a vendor with a commercial reason to respond. It does not buy you a product with fewer flaws, and reading it as a quality signal is a mistake.

The second: 46% of 2025 vulnerabilities had no patch available at disclosure, and the weighted median time to first exploitation was five hours. The report's own line on the first figure is that it shows why site owners cannot rely on plugin updates as a security measure. For roughly half of last year's disclosures, updating within a day was not slow, it was impossible.

Both of those push the same way. What you can measure, budget and verify is your own configuration and your own inventory. What you cannot control is when the next disclosure lands or whether a fix exists when it does.

What this does not tell you

Release cadence is a proxy, not a verdict. A mature product with a small surface can legitimately ship twice a year, and diff volume rewards churn. Read the diffs before cancelling anything.

It also says nothing about support quality, which for some teams is the entire product, and nothing about whether the plugin is doing its job. This is a purchasing audit, not a security audit.

One question

For anyone running more than ten client sites: what do you check before renewing a plugin licence, other than whether it is still installed? I have asked a lot of agencies this and the honest answer is usually nothing.

I run engineering at Squirrly and spend more time in access logs than I would choose to. The longer argument about which licensing model fits which kind of tool is here: https://wpghost.com/lifetime/

Top comments (0)