Automated vulnerability scanning in CI/CD for WordPress: Wordfence CLI vs manual audits
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Most WordPress sites learn about a vulnerable plugin from a security email, a scanner on the live site, or a hacked checkout. By then the vulnerable version is already in production. If your plugins and themes live in git, you can check them before they ship: every pull request, every deploy, and every morning for code that hasn't changed but has just become vulnerable.
We wrote the gate script and tested it against a repository with real WooCommerce 11.2.1, Akismet 5.7.2 and Hello Dolly files, a custom plugin and theme, and a composer.lock. The checksums came live from wordpress.org. The vulnerability records were synthetic, written in Wordfence's documented v3 feed format; we have no feed API key, and real records would tie real products to our test. We also ran the real Wordfence CLI 5.0.6 against a mock Wordfence API serving the same records. Version comparisons were checked against PHP's own version_compare(). All 28 checks pass, and the workflow passed actionlint 1.7.12 and shellcheck 0.11.0. We didn't run it in GitHub's runners.
1. The short answer
| Question | Answer |
|---|---|
| What's checked? | Known vulnerabilities in every plugin, theme and core version; modified wordpress.org plugins; obfuscated code in your own code |
| Data source | Wordfence Intelligence v3 production feed: free with an API key, downloaded once a day |
| What fails the build? | CVSS 7.0 or higher, anything without a CVSS score, a modified or added file in a wordpress.org plugin, and code that runs decoded data |
| When does it run? | Every pull request, every push to main, daily, and before every deploy |
| Exceptions | An ignore file where each entry has an expiry date |
| Wordfence CLI? | Closed to new licenses in October 2026. If you still have one, the gate can read its CSV |
| On the live site | Wordfence Premium, $149 a year: real-time firewall rules and malware signatures |
2. Wordfence CLI in October 2026
Wordfence CLI is the open-source command-line scanner Wordfence released in 2023, with malware-scan and vuln-scan commands. Its README, updated on October 7, 2026, says it "is no longer available for new Free or Premium licenses". The Free version reached end-of-life on October 14, 2026, and existing Free licenses stopped working that day. Existing Premium licenses keep working until their term ends and won't be renewed. The 5.0.6 release on PyPI prints the same notice.
The data behind vuln-scan is still available. Wordfence publishes its vulnerability database as the Vulnerability Data Feed, "publicly available for free for personal and commercial use". Version 3 needs a free API key, created under Integrations in your wordfence.com account. Versions 1 and 2 were switched off on March 9, 2026. The script in this guide reads the v3 feed directly, so it works with or without a CLI license.
3. What a CI gate can catch
| Problem | Caught in CI | How |
|---|---|---|
| A plugin or theme version with a published vulnerability | Yes | Version headers matched against the feed's affected ranges |
| A vulnerable version pulled in by Composer (wpackagist) | Yes | Read from composer.lock, even before it's installed |
| A wordpress.org plugin that was edited, or a "nulled" copy | Yes | SHA-256 of every file against wordpress.org's checksums for that version |
| Obfuscated code in your own plugin or theme | Partly | Patterns: decoded data being executed, decoder chains, long encoded strings |
| A vulnerability disclosed after your last deploy | Only with the daily run | The scheduled run checks unchanged code against the new feed |
| Premium plugins not on wordpress.org | Vulnerabilities if the feed lists them; tampering no | There are no public checksums for them |
| Attacks, stolen passwords, malware written to the live site or database | No | That's the job of a firewall and scanner on the live site |
4. The check flow
5. The workflow
Store the script as .github/security/wp_vuln_gate.py and the API key as the repository secret WFI_API_KEY. The cache step keeps one copy of the feed per day, and restores yesterday's if today's download fails.
# .github/workflows/security-gate.yml
name: security-gate
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "23 5 * * *" # daily: new disclosures land on code that hasn't changed
workflow_call: # lets deploy.yml run it first: needs: security-gate
permissions:
contents: read
jobs:
security-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- id: day
run: echo "date=$(date -u +%F)" >> "$GITHUB_OUTPUT"
- name: Restore the vulnerability feed (one download a day, yesterday's copy if the API says 429)
uses: actions/cache@v4
with:
path: .cache/wfi-production.json
key: wfi-feed-${{ steps.day.outputs.date }}
restore-keys: wfi-feed-
- name: Known vulnerabilities, modified wordpress.org plugins, obfuscated code
env:
WFI_API_KEY: ${{ secrets.WFI_API_KEY }}
run: >
python3 .github/security/wp_vuln_gate.py
--fail-on 7.0
--scan-dir wp-content/plugins/store-core
--scan-dir wp-content/themes/store-childTo make a deploy wait for it, call it from your deploy workflow (for example the one in our Kinsta deployment guide):
jobs:
security:
uses: ./.github/workflows/security-gate.yml
secrets: inherit
test:
needs: security
# ... the rest of the deploy pipeline is unchangedBoth files together passed actionlint with no findings. If you'd rather block merges than deploys, make security-gate a required status check in the branch protection rules.
6. The gate script
Python standard library only.
#!/usr/bin/env python3
"""Fail a CI build when the WordPress plugins, themes or core in this repository have known vulnerabilities, when a
wordpress.org plugin was modified, or when code looks obfuscated.
python3 wp_vuln_gate.py [--root .] [--fail-on 7.0] [--ignore .wpvuln-ignore] [--cli-csv results.csv]
Vulnerability data: the Wordfence Intelligence v3 production feed (free API key in WFI_API_KEY), cached in
FEED_CACHE for 24 hours. With --cli-csv, findings come from `wordfence vuln-scan --output-format csv` instead.
Exit 1 when anything fails the gate. Python 3.9+, standard library only."""
import argparse, csv, datetime, hashlib, json, os, re, sys, time, urllib.error, urllib.request
from pathlib import Path
FEED_URL = os.environ.get('WFI_FEED_URL', 'https://www.wordfence.com/api/intelligence/v3/vulnerabilities/production')
FEED_CACHE = Path(os.environ.get('FEED_CACHE', '.cache/wfi-production.json'))
CHECKSUMS = os.environ.get('WPORG_CHECKSUMS', 'https://downloads.wordpress.org/plugin-checksums/{slug}/{version}.json')
HEADER = re.compile(r'^[ \t/*#@]*{}:(.*)$', re.M | re.I)
FORMS = {'dev': 0, 'alpha': 1, 'a': 1, 'beta': 2, 'b': 2, 'rc': 3, '#': 4, 'pl': 6, 'p': 6}
# ------------------------------------------------------------------ versions: PHP's version_compare(), as WordPress uses
def _parts(v):
v = re.sub(r'[-_+]', '.', v.strip())
v = re.sub(r'(?<=\d)(?=[^\d.])|(?<=[^\d.])(?=\d)', '.', v)
return [(5, int(p)) if p.isdigit() else (FORMS.get(p.lower(), -1), 0) for p in v.split('.') if p]
def vcmp(a, b):
pa, pb = _parts(a), _parts(b)
for i in range(max(len(pa), len(pb))):
x = pa[i] if i < len(pa) else (4, 0) # a missing part ranks like PHP's "#": below numbers, above RC
y = pb[i] if i < len(pb) else (4, 0)
if x != y:
return -1 if x < y else 1
return 0
def in_range(version, r):
lo = r['from_version'] == '*' or vcmp(version, r['from_version']) >= (0 if r['from_inclusive'] else 1)
hi = r['to_version'] == '*' or vcmp(version, r['to_version']) <= (0 if r['to_inclusive'] else -1)
return lo and hi
# ------------------------------------------------------------------ inventory
def header(path, field):
m = re.compile(HEADER.pattern.format(field), HEADER.flags).search(path.read_bytes()[:8192].decode('utf-8', 'replace'))
return m and m.group(1).strip().rstrip('*/').strip() # WordPress also reads only the first 8 KB
def inventory(root):
found = [] # (type, slug, version, path)
m = (root / 'wp-includes/version.php').exists() and re.search(r"\$wp_version\s*=\s*'([^']+)'", (root / 'wp-includes/version.php').read_text())
if m:
found.append(('core', 'wordpress', m.group(1), root / 'wp-includes'))
for content in (root / 'wp-content', root / 'web/app'): # standard layout and Bedrock
for p in sorted((content / 'plugins').glob('*')):
mains = [p] if p.suffix == '.php' else sorted(p.glob('*.php')) if p.is_dir() else []
main = next((f for f in mains if header(f, 'Plugin Name')), None)
if main:
found.append(('plugin', p.stem if p.is_file() else p.name, header(main, 'Version') or '0', p))
for p in sorted((content / 'themes').glob('*/style.css')):
if header(p, 'Theme Name'):
found.append(('theme', p.parent.name, header(p, 'Version') or '0', p.parent))
lock = root / 'composer.lock'
if lock.exists():
for pkg in json.loads(lock.read_text()).get('packages', []):
kind, _, slug = pkg['name'].partition('/')
if kind in ('wpackagist-plugin', 'wpackagist-theme') and not any(f[1] == slug for f in found):
found.append((kind.split('-')[1], slug, pkg['version'].lstrip('v'), lock))
return found
# ------------------------------------------------------------------ known vulnerabilities
def load_feed():
fresh = FEED_CACHE.exists() and time.time() - FEED_CACHE.stat().st_mtime < 86400
if not fresh and os.environ.get('WFI_API_KEY'):
req = urllib.request.Request(FEED_URL, headers={'Authorization': 'Bearer ' + os.environ['WFI_API_KEY']})
try:
with urllib.request.urlopen(req, timeout=120) as r:
data = r.read()
json.loads(data)
FEED_CACHE.parent.mkdir(parents=True, exist_ok=True)
FEED_CACHE.write_bytes(data)
except (urllib.error.URLError, ValueError) as e:
if not FEED_CACHE.exists():
sys.exit(f'::error::cannot download the vulnerability feed and there is no cached copy: {e}')
print(f'::warning::feed download failed ({e}), using the cached copy from '
f'{datetime.datetime.fromtimestamp(FEED_CACHE.stat().st_mtime):%Y-%m-%d %H:%M}')
if not FEED_CACHE.exists():
sys.exit('::error::set WFI_API_KEY (free key from your wordfence.com account) or provide FEED_CACHE')
return json.loads(FEED_CACHE.read_text(encoding='utf-8'))
def match_feed(feed, items):
index = {}
for v in feed.values():
for sw in v['software']:
index.setdefault((sw['type'], sw['slug']), []).append((v, sw))
findings = []
for kind, slug, version, path in items:
for v, sw in index.get((kind, slug), []):
if not v.get('informational') and any(in_range(version, r) for r in sw['affected_versions'].values()):
cvss = v.get('cvss') or {}
findings.append({'id': v['id'], 'cve': v.get('cve') or '', 'slug': slug, 'version': version, 'path': str(path),
'title': v['title'], 'score': cvss.get('score'), 'rating': cvss.get('rating') or 'unrated',
'fix': ', '.join(sw['patched_versions']) if sw['patched'] else 'no fix: remove or replace',
'link': f'https://www.wordfence.com/threat-intel/vulnerabilities/id/{v["id"]}'})
return findings
def from_cli_csv(path):
"""`wordfence vuln-scan --output-format csv --output-headers --output-columns software_type,slug,version,id,cve,title,patched,scanned_path`
Add cvss_score,cvss_rating only if every match has a CVSS score: CLI 5.0.6 crashes on a record without one.
Rows without a score count as unrated and block."""
with open(path, newline='', encoding='utf-8') as f:
return [{'id': r['id'], 'cve': r.get('cve', ''), 'slug': r['slug'], 'version': r['version'], 'path': r.get('scanned_path', ''),
'title': r['title'], 'score': float(r['cvss_score']) if r.get('cvss_score') else None,
'rating': r.get('cvss_rating') or 'unrated', 'fix': 'see link' if r.get('patched') == 'True' else 'no fix: remove or replace',
'link': f'https://www.wordfence.com/threat-intel/vulnerabilities/id/{r["id"]}'} for r in csv.DictReader(f)]
# ------------------------------------------------------------------ modified wordpress.org plugins
def integrity(items):
"""Compare each wordpress.org plugin with the checksums of that exact release. 404 = not from wordpress.org."""
problems = []
for kind, slug, version, path in items:
if kind != 'plugin' or not path.is_dir():
continue
try:
with urllib.request.urlopen(CHECKSUMS.format(slug=slug, version=version), timeout=60) as r:
files = json.loads(r.read())['files']
except urllib.error.HTTPError as e:
if e.code != 404:
problems.append((path, f'{slug} {version}: checksums unavailable (HTTP {e.code}), not verified'))
continue
except urllib.error.URLError as e:
problems.append((path, f'{slug} {version}: checksums unavailable ({e.reason}), not verified'))
continue
for f in path.rglob('*'):
rel = f.relative_to(path).as_posix()
if not f.is_file() or rel.startswith('.git'):
continue
want = files.get(rel, {}).get('sha256')
if want is None:
if f.suffix.lower() in ('.php', '.phtml', '.js'):
problems.append((f, f'{slug} {version}: {rel} is not part of the wordpress.org release'))
elif hashlib.sha256(f.read_bytes()).hexdigest() not in ([want] if isinstance(want, str) else want):
problems.append((f, f'{slug} {version}: {rel} differs from the wordpress.org release'))
return problems
# ------------------------------------------------------------------ obfuscated code
DECODERS = r'(?:base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|strrev|hex2bin|convert_uudecode)'
RULES = [
('error', 'decoded data executed', re.compile(r'\b(?:eval|assert|create_function)\s*\(\s*(?:@\s*)?' + DECODERS + r'\s*\(', re.I)),
('error', 'decoder chain', re.compile(DECODERS + r'\s*\(\s*' + DECODERS + r'\s*\(', re.I)),
('warning', 'long encoded string', re.compile(r'[\'"][A-Za-z0-9+/=\\x]{1000,}[\'"]')),
]
def obfuscation(text):
return [(level, what, text.count('\n', 0, m.start()) + 1) for level, what, rx in RULES for m in rx.finditer(text)]
# ------------------------------------------------------------------ gate
def ignores(path):
"""Lines: <vulnerability UUID or CVE> until=YYYY-MM-DD # reason. Expired lines stop counting."""
active = {}
if path and Path(path).exists():
for line in Path(path).read_text().splitlines():
m = re.match(r'\s*(\S+)\s+until=(\d{4}-\d\d-\d\d)', line)
if m and datetime.date.fromisoformat(m.group(2)) >= datetime.date.today():
active[m.group(1).lower()] = m.group(2)
elif m:
print(f'::warning::ignore entry expired on {m.group(2)}: {m.group(1)}')
return active
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--root', default='.')
ap.add_argument('--fail-on', type=float, default=7.0, help='fail on CVSS >= this (unrated findings always fail)')
ap.add_argument('--ignore', default='.wpvuln-ignore')
ap.add_argument('--cli-csv')
ap.add_argument('--scan-dir', action='append', default=[], help='your own code to check for obfuscation')
a = ap.parse_args()
root = Path(a.root)
items = inventory(root)
findings = from_cli_csv(a.cli_csv) if a.cli_csv else match_feed(load_feed(), items)
skip = ignores(a.ignore)
failed, rows = 0, []
for f in sorted(findings, key=lambda f: -(f['score'] if f['score'] is not None else 11)):
ignored = f['id'].lower() in skip or f['cve'].lower() in skip
blocking = not ignored and (f['score'] is None or f['score'] >= a.fail_on)
failed += blocking
level = 'error' if blocking else 'warning' if not ignored else 'notice'
print(f'::{level} title={f["slug"]} {f["version"]}::{f["title"]} [{f["rating"]} {f["score"] or "-"}] fix: {f["fix"]} {f["link"]}')
rows.append(f'| {"FAIL" if blocking else "ignored" if ignored else "warn"} | {f["slug"]} {f["version"]} | {f["title"]} | '
f'{f["rating"]} {f["score"] or ""} | {f["fix"]} | [details]({f["link"]}) |')
for path, msg in integrity(items):
failed += 1
print(f'::error file={path.relative_to(root).as_posix() if path.is_relative_to(root) else path}::{msg}')
rows.append(f'| FAIL | {path.name} | {msg} | | compare with the official zip | |')
for d in a.scan_dir:
for f in Path(d).rglob('*.php'):
for level, what, line in obfuscation(f.read_text(encoding='utf-8', errors='replace')):
failed += level == 'error'
print(f'::{level} file={f.as_posix()},line={line}::{what}')
rows.append(f'| {"FAIL" if level == "error" else "warn"} | {f.name}:{line} | {what} | | review by hand | |')
summary = (f'### WordPress security gate\n{len(items)} components, {len(findings)} known vulnerabilities, {failed} blocking\n\n'
+ ('| Result | Component | Issue | Severity | Fix | |\n|---|---|---|---|---|---|\n' + '\n'.join(rows) if rows else 'Nothing found.') + '\n')
if os.environ.get('GITHUB_STEP_SUMMARY'):
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a', encoding='utf-8') as s:
s.write(summary)
print(f'{len(items)} components checked, {len(findings)} known vulnerabilities, {failed} blocking')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())- Versions: compared the way WordPress and PHP compare them:
1.0rc1is below1.0, and1.0equals1.0.0. The function matched PHP'sversion_compare()on all 12 test pairs, including-dev,-beta,RCandplversions. - Ranges: the feed's
affected_versionshave inclusive or exclusive ends and*for "any". Our tests include both kinds of boundary: 2.3.1 is not in (2.0, 2.3.1), and 7.1.3 is not in [7.1.0, 7.1.3). - Unrated means blocking: a record without a CVSS score fails the build until you look at it. Informational records are skipped, as the CLI does by default.
- Checksums: a plugin that isn't on wordpress.org (your own, or a premium one) gets a 404 and is skipped. If wordpress.org can't be reached, each plugin is reported as "not verified", which blocks the build. Added
.php,.phtmland.jsfiles count; added text files don't. Files missing from your copy aren't reported. Checking WooCommerce's 3,929 local files took 2.2 seconds. - Obfuscation rules: run them on your own code only. Vendor plugins legitimately use
base64_decode, and they're covered by the checksums. - Feed terms: Wordfence's license for the feed asks for a link to each vulnerability record when you reproduce its data. The summary links every finding to its record.
Exceptions that expire
Sometimes the fix isn't out yet, or you've mitigated the issue another way. Put the vulnerability's UUID or CVE ID in .wpvuln-ignore with a date and a reason:
CVE-2026-90001 until=2026-11-05 # fixed on staging, ships with next week's release
11111111-0000-4000-8000-000000000004 until=2026-10-25 # theme being replaced, upload form disabledAn ignored finding is still listed, as a notice. When the date passes, the entry stops counting, the build fails again, and the log says the entry expired.
7. If you still have a Wordfence CLI license
Run vuln-scan with CSV output and give the file to the gate:
pip install wordfence
wordfence vuln-scan --license "$WORDFENCE_CLI_LICENSE" --accept-terms --no-check-for-update \
--output-format csv --output-headers --output-path wf.csv \
--output-columns software_type,slug,version,id,cve,title,patched,scanned_path \
--plugin-directory wp-content/plugins --theme-directory wp-content/themes
python3 .github/security/wp_vuln_gate.py --cli-csv wf.csvWhat we saw running CLI 5.0.6 against the mock API:
- It exits 0 when it finds vulnerabilities. The source returns 0 at the end of every scan. A CI step that only runs
wordfence vuln-scannever fails, so something has to read the output. - It can crash on unrated records. With
cvss_scoreorcvss_ratingin--output-columns, a match whose record has no CVSS data stopped the scan with'NoneType' object has no attribute 'score'and exit 1. The feed format allowscvss: null. Leave those columns out, as above; the gate then treats every finding as unrated, so every finding blocks. - It reads the disk only. A plugin listed in
composer.lockbut not installed isn't scanned. The gate reads the lock file too. - It needs Linux or macOS. It imports
fcntlandpwd, which Windows doesn't have. On GitHub's Ubuntu runners that's not an issue. - It authenticates to the feed with your license, as
Authorization: Bearer cli-<license>, so once your license expires, so does this path.
8. Test results
| Scenario | Result |
|---|---|
| Inventory | Core 7.1.3, 5 plugins (including a single-file plugin and one only in composer.lock), 2 themes |
| Matching | Plugin <= 2.3.1, plugin < 3.0.1 and theme * matched; exclusive ends and informational records left out |
| Gate | Exit 1 on the 8.8 and the unrated finding; the 5.3 finding is a warning |
--fail-on 9.0 | The 8.8 finding becomes a warning; the unrated one still blocks |
| Ignore file | Future dates: exit 0. Expired entry: warning and blocking again |
| Clean WooCommerce 11.2.1 and Akismet 5.7.2 | Match wordpress.org's checksums |
| One edited Akismet file, one added PHP file | Both named as errors; an added .txt file ignored |
| Custom plugin | 404 from wordpress.org, skipped |
| Decoder chain, 1,200-character encoded string | Error and warning, with file and line; a plain base64_decode() not flagged |
| Feed download | Sent with Bearer <key> and cached; on 429 with a stale cache: warning and the cached copy; with no cache: clear error, exit 1 |
| wordpress.org unreachable | Plugins reported as not verified, build blocked, no crash |
| Wordfence CLI 5.0.6 | Same records as the gate for files on disk; exit 0 with findings; crash with CVSS columns on an unrated match |
9. Automated gate vs manual audits
| Manual audit | CI gate | |
|---|---|---|
| How often | Quarterly, or before a launch | Every change and every day |
| Known vulnerabilities | Someone checks each plugin by hand | All of them, in seconds |
| Modified vendor code | Easy to miss in a large plugin | Every file hashed |
| Logic flaws in your own code | Yes, this is what people are good at | No |
| Proof it happened | A report | A log and summary for every commit |
They aren't alternatives. The gate covers the repetitive part on every change; a person reviewing your checkout, payment and permission code covers what no feed knows about.
10. What it costs
The feed and the API key are free. The feed API has usage limits and answers 429 when you ask too often, which is why the workflow downloads it once a day.
On the live site, Wordfence Free applies firewall rules and malware signatures 30 days after paid users get them. Premium is $149 a year per site with real-time rules and signatures, the real-time IP blocklist, country blocking and ticket support. Care, $590 a year, adds hands-on setup, a yearly audit and incident response; Response, $1,250 a year, adds 24/7 response within an hour (pricing page, October 2026).
Related: catching card skimmers with file integrity checks, stopping carding bots, and monitoring background jobs.
11. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Vulnerability data in CI, protection on the live site | Its Intelligence feed powers the gate for free. Premium, $149 a year, adds real-time firewall rules and malware signatures for the gap CI can't close: attacks, and vulnerabilities disclosed between deploys (October 2026). |
Alternatives: free tools and references
We don't earn anything from these links.
- wp plugin verify-checksums: the same checksum comparison on a running site with WP-CLI.
- Wordfence CLI on GitHub: source and the end-of-life notice.
- Semgrep: open-source static analysis with PHP rules, for your own code.
- actionlint: checks workflow files before you push.
12. Pitfalls
- Running a scanner without checking its output: Wordfence CLI's
vuln-scanexits 0 with findings. - Scanning only on pull requests: code that doesn't change still becomes vulnerable. Keep the daily run.
- Downloading the feed on every run: you'll hit 429. Cache it.
- Ignoring findings without a date: exceptions become permanent. Every entry here expires.
- Treating "no CVSS score" as low: unrated isn't the same as harmless. Look before you let it through.
- Obfuscation rules on vendor code: lots of noise. Use checksums for vendor plugins.
- Relying on CI alone: it checks code, not the running site. Keep a firewall and scanner in production.
Check every plugin, theme and core version against Wordfence's vulnerability feed on every change and every morning, hash wordpress.org plugins against their official checksums, and fail the build on anything serious or unrated. Wordfence CLI is winding down, but its data isn't: the feed is free. Protect the live site with Wordfence Premium for what happens after the deploy.