Store security

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.

How this guide was made

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

QuestionAnswer
What's checked?Known vulnerabilities in every plugin, theme and core version; modified wordpress.org plugins; obfuscated code in your own code
Data sourceWordfence 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
ExceptionsAn 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 siteWordfence 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

ProblemCaught in CIHow
A plugin or theme version with a published vulnerabilityYesVersion headers matched against the feed's affected ranges
A vulnerable version pulled in by Composer (wpackagist)YesRead from composer.lock, even before it's installed
A wordpress.org plugin that was edited, or a "nulled" copyYesSHA-256 of every file against wordpress.org's checksums for that version
Obfuscated code in your own plugin or themePartlyPatterns: decoded data being executed, decoder chains, long encoded strings
A vulnerability disclosed after your last deployOnly with the daily runThe scheduled run checks unchanged code against the new feed
Premium plugins not on wordpress.orgVulnerabilities if the feed lists them; tampering noThere are no public checksums for them
Attacks, stolen passwords, malware written to the live site or databaseNoThat's the job of a firewall and scanner on the live site

4. The check flow

Security gate: from pull request to deploy
Security gate: from pull request to deployPull request, push or daily run → Inventory → Known vulnerabilities → Modified plugins → Obfuscated code → Blocker found / All clearPull request, push or daily run: GitHub Actions checks out the repositoryPull request, push or daily runGitHub Actions checks out the repositoryInventory: plugin and theme headers, wp-includes/version.php, wpackagist packages in composer.lockInventoryplugin and theme headers, wp-includes/version.php, wpackagistpackages in composer.lockKnown vulnerabilities: Wordfence Intelligence v3 feed, cached for a day; CVSS 7+ or unrated blocksKnown vulnerabilitiesWordfence Intelligence v3 feed, cached for a day; CVSS 7+ orunrated blocksModified plugins: every file of each wordpress.org plugin against the checksums for its exact versionModified pluginsevery file of each wordpress.org plugin against the checksumsfor its exact versionObfuscated code: your own plugin and theme: decoded data executed, decoder chains, long encoded stringsObfuscated codeyour own plugin and theme: decoded data executed, decoderchains, long encoded stringsBlocker found: the job fails with annotations; the deploy never startsBlocker foundthe job fails withannotations; the deploy neverstartsAll clear: the deploy job runsAll clearthe deploy job runs
Medium and low findings are reported as warnings and don't block. The run summary lists every finding with its fix version and a link to the Wordfence record.

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.

YAML
# .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-child

To make a deploy wait for it, call it from your deploy workflow (for example the one in our Kinsta deployment guide):

YAML
jobs:
  security:
    uses: ./.github/workflows/security-gate.yml
    secrets: inherit

  test:
    needs: security
    # ... the rest of the deploy pipeline is unchanged

Both 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.

Python
#!/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.0rc1 is below 1.0, and 1.0 equals 1.0.0. The function matched PHP's version_compare() on all 12 test pairs, including -dev, -beta, RC and pl versions.
  • Ranges: the feed's affected_versions have 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, .phtml and .js files 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:

Code
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 disabled

An 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:

Shell
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.csv

What 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-scan never fails, so something has to read the output.
  • It can crash on unrated records. With cvss_score or cvss_rating in --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 allows cvss: 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.lock but not installed isn't scanned. The gate reads the lock file too.
  • It needs Linux or macOS. It imports fcntl and pwd, 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

ScenarioResult
InventoryCore 7.1.3, 5 plugins (including a single-file plugin and one only in composer.lock), 2 themes
MatchingPlugin <= 2.3.1, plugin < 3.0.1 and theme * matched; exclusive ends and informational records left out
GateExit 1 on the 8.8 and the unrated finding; the 5.3 finding is a warning
--fail-on 9.0The 8.8 finding becomes a warning; the unrated one still blocks
Ignore fileFuture dates: exit 0. Expired entry: warning and blocking again
Clean WooCommerce 11.2.1 and Akismet 5.7.2Match wordpress.org's checksums
One edited Akismet file, one added PHP fileBoth named as errors; an added .txt file ignored
Custom plugin404 from wordpress.org, skipped
Decoder chain, 1,200-character encoded stringError and warning, with file and line; a plain base64_decode() not flagged
Feed downloadSent 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 unreachablePlugins reported as not verified, build blocked, no crash
Wordfence CLI 5.0.6Same 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 auditCI gate
How oftenQuarterly, or before a launchEvery change and every day
Known vulnerabilitiesSomeone checks each plugin by handAll of them, in seconds
Modified vendor codeEasy to miss in a large pluginEvery file hashed
Logic flaws in your own codeYes, this is what people are good atNo
Proof it happenedA reportA 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

ToolRoleWhy it fits
WordfenceVulnerability data in CI, protection on the live siteIts 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.

12. Pitfalls

  • Running a scanner without checking its output: Wordfence CLI's vuln-scan exits 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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.