Enterprise WordPress · 9 min read

WordPress Security Scans in GitHub Actions with WP-CLI and WPScan

Two scheduled jobs: WP-CLI checks what is installed and whether files changed; WPScan checks what is known to be vulnerable. Mind the API quota and licence.

By Praful Patel

Short answer

Automate WordPress security audits as two scheduled GitHub Actions jobs: an inside check with WP-CLI and an outside check with WPScan. WP-CLI, run over SSH, verifies core and plugin files against WordPress.org checksums and reports outdated or closed plugins and risky configuration. WPScan scans the public site against its vulnerability database and exits with code 5 when it finds something, which makes it easy to gate on. Watch the WPScan API quota and licence terms before you schedule it.

Most WordPress compromises go through known vulnerabilities in plugins someone forgot to update, or files modified after deployment. Both are cheap to detect and expensive to discover late. This article sets up a workflow that checks for them every night and reports findings where the team already looks. It is the operational half of the practices in WordPress Security Hardening for Enterprise Platforms.

What each check can and cannot see

Check Detects Cannot detect
wp core verify-checksums Modified or missing core files, compared with WordPress.org MD5 checksums Anything in plugins, themes or uploads
wp plugin verify-checksums --all Changed or added files in plugins hosted on WordPress.org Premium and custom plugins, which are skipped with a warning
wp plugin list / wp core check-update Pending updates; plugins closed on WordPress.org (wporg_status) Whether an update fixes a security issue
WPScan Known vulnerabilities in core, plugins and themes from its database; exposed files and other “interesting findings” Bugs in your own code; anything without a published advisory

The WP-CLI checks are authoritative about what is installed. WPScan is authoritative about what is known to be vulnerable. You want both.

Job 1: integrity and hygiene with WP-CLI

Checksum verification only means something where the files actually run. Running it against a fresh CI checkout proves nothing: the files came straight from WordPress.org a minute earlier. Run it against staging and production with WP-CLI’s global --ssh parameter, which executes the command on the remote server. That needs WP-CLI installed on the server and an SSH key with access to it.

#!/usr/bin/env bash
# scripts/wp-audit.sh: run against a remote site; any failed check fails the job.
set -uo pipefail
wp() { command wp --ssh="$AUDIT_SSH_TARGET" "$@"; }
status=0
fail() { echo "::error::$1"; status=1; }

wp core verify-checksums || fail "Core files do not match WordPress.org checksums"

# Exits non-zero on mismatches; premium/custom plugins are skipped with a warning.
wp plugin verify-checksums --all || fail "Plugin files do not match WordPress.org checksums"

[ "$(wp core check-update --format=count)" -eq 0 ] || fail "WordPress core update available"
[ "$(wp plugin list --update=available --format=count)" -eq 0 ] || fail "Plugin updates available"

closed=$(wp plugin list --fields=name,wporg_status --format=json \
  | jq -r '.[] | select(.wporg_status == "closed") | .name')
[ -z "$closed" ] || fail "Plugins closed on WordPress.org: $closed"

# Evaluate at runtime so a constant defined outside wp-config.php still counts.
[ "$(wp eval 'echo ( defined( "DISALLOW_FILE_EDIT" ) && DISALLOW_FILE_EDIT ) ? "on" : "off";')" = "on" ] \
  || fail "DISALLOW_FILE_EDIT is not enabled"

admins=$(wp user list --role=administrator --format=count)
[ "$admins" -le "${MAX_ADMINS:-3}" ] || fail "$admins administrator accounts (limit ${MAX_ADMINS:-3})"

exit $status

Notes on what this reports:

  • “File was added” from the plugin check usually means someone patched a vendor plugin in place, or a file was dropped into its directory. Either deserves a look. The first also means the next update will silently undo the patch.
  • Skipped plugins are not passes. Premium and custom plugins have no WordPress.org checksums. Cover them by deploying them from version control and comparing the deployed tree with the release artefact.
  • Closed plugins were removed from WordPress.org, sometimes for unpatched security issues. A closed plugin gets no updates through the dashboard.
  • Thresholds are policy. If updates are deliberately staged, warn on pending updates instead of failing.

Larger platforms will want these audits batched and logged like any other long-running command. That approach is in WP-CLI at Scale.

Job 2: vulnerability scanning with WPScan

WPScan is a black-box scanner: it fingerprints a site over HTTP and looks up what it finds in the WPScan vulnerability database. Version 4.0, released in May 2026, changed several defaults that matter in CI:

  • Plugins are no longer enumerated by default. Pass --enumerate vp (vulnerable plugins) or ap, or you will scan core and themes only.
  • --wp-auth takes a username and an Application Password. WPScan then reads the authoritative plugin and theme list from the REST API instead of guessing from public assets. The plugins endpoint needs a user who can manage plugins, so use a dedicated audit account with two-factor login, and revoke its Application Password if the secret is ever exposed.
  • --format sarif produces SARIF 2.1.0, which GitHub code scanning can display.

Exit codes come from WPScan’s ExitCode module: 0 clean, 5 at least one vulnerability found, 1 to 4 option errors, interrupts, exceptions and failed scans. Treat 5 as “findings” and anything else non-zero as “the scan itself broke”. They need different responses.

The workflow

# .github/workflows/wordpress-security-audit.yml
name: WordPress security audit
on:
  schedule:
    - cron: '23 2 * * *'  # daily, UTC; scheduled runs can start late
  workflow_dispatch:

permissions:
  contents: read

jobs:
  integrity:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: wp-cli
      - name: Configure SSH
        run: |
          install -d -m 700 ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/audit_key && chmod 600 ~/.ssh/audit_key
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          printf 'Host *\n  IdentityFile ~/.ssh/audit_key\n' > ~/.ssh/config
        env:
          SSH_KEY: ${{ secrets.AUDIT_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.AUDIT_KNOWN_HOSTS }}
      - name: WP-CLI audit
        run: bash scripts/wp-audit.sh
        env:
          AUDIT_SSH_TARGET: ${{ secrets.AUDIT_SSH_TARGET }}  # user@host/path/to/wordpress

  wpscan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write  # upload SARIF to code scanning
    steps:
      - name: Run WPScan
        id: scan
        run: |
          set +e
          docker run --rm -e WPSCAN_API_TOKEN wpscanteam/wpscan:latest \
            --url "$SCAN_URL" --wp-auth "$WP_AUTH" \
            --enumerate vp,vt --update --format sarif --no-banner > wpscan.sarif
          code=$?
          echo "code=$code" >> "$GITHUB_OUTPUT"
          [ "$code" -eq 0 ] || [ "$code" -eq 5 ]  # only fail here if the scan broke
        env:
          WPSCAN_API_TOKEN: ${{ secrets.WPSCAN_API_TOKEN }}
          WP_AUTH: ${{ secrets.WPSCAN_WP_AUTH }}  # audit-user:application-password
          SCAN_URL: ${{ vars.SCAN_URL }}
      - uses: github/codeql-action/upload-sarif@v4
        if: always() && hashFiles('wpscan.sarif') != ''
        with:
          sarif_file: wpscan.sarif
          category: wpscan
      - name: Fail on vulnerabilities
        if: steps.scan.outputs.code == '5'
        run: |
          echo "::error::WPScan reported known vulnerabilities. See the code scanning alerts."
          exit 1

Practical details:

  • Secrets stay in secrets. The API token is read from the WPSCAN_API_TOKEN environment variable, which WPScan supports, so it never appears on a command line. The workflow passes it with -e and does not echo it.
  • Update the database every run. The container’s vulnerability database is baked in at build time and discarded with --rm. --update refreshes it explicitly instead of letting WPScan stop to ask.
  • Pin the image. latest keeps the example short. In practice, pin a version tag or digest and update it deliberately, as you would any other action.
  • Code scanning availability. SARIF upload works on public repositories. Private repositories need GitHub’s code security features enabled. Without them, upload the SARIF as an artefact instead.
  • Scan staging when you can. A daily scan of production is fine, but check with your host or WAF first, so scanner traffic does not trip rate limits or get your runner’s IP blocked.

The API quota and licence, before you schedule it

WPScan’s vulnerability data comes from its API. Per the WPScan README, a scan uses one request for the WordPress version, one per installed plugin and one per theme, and the free tier gives 25 API requests per day. When the quota runs out, the scan continues without vulnerability data. For a CI gate, that means a silent pass. A site with twenty plugins uses most of a free day’s quota on a single run, and two environments will exceed it.

Licensing needs reading too. The CLI licence lists testing your own systems as a use that does not need a commercial licence. The WPScan pricing page describes the free API plan as non-commercial and says commercial use of the API requires a paid licence. An agency scanning client sites as part of a paid service is clearly commercial. For an in-house team, ask WPScan rather than guess.

Run it on pull requests too

The scheduled audit finds drift on live servers. Pull requests need a different, cheaper gate: PHPCS with the WordPress and VIP rulesets for escaping, sanitisation and direct queries, and a check that new plugins are added through version control rather than the dashboard. The coding standards side is covered in Engineering for WPVIP. Block-level checks belong in the same pipeline, as in Gutenberg block validation in CI.

Checklist

  • Run checksums against deployed servers, not CI checkouts.
  • Treat skipped premium plugins as a gap to cover separately, not a pass.
  • Use --enumerate vp or --wp-auth with WPScan 4.x, or plugins are not checked.
  • Separate exit code 5 (findings) from other failures (broken scan).
  • Budget API requests per site and per environment; a quota-exhausted scan passes silently.
  • Confirm your WPScan licence position before scanning sites you are paid to maintain.
  • Send findings to code scanning or your alerting, not just to a log nobody reads.

Questions people actually ask

Is the free WPScan API token enough for CI?
For one small site scanned once a day, often yes: 25 requests covers core, a theme and around twenty plugins. For several environments, frequent scans or larger plugin sets, no. Remember that when the quota is exhausted the scan keeps running without vulnerability data, so it can pass without checking anything.

What does wp plugin verify-checksums do with premium plugins?
It cannot verify them, because WordPress.org has no checksums for plugins it does not host. It prints a warning that it could not retrieve checksums and counts the plugin as skipped. It only exits with an error when a plugin it could check has mismatched or added files.

Should the audit block deployments?
Not the scheduled one. It reports on what is already live. Block deployments with the pull-request checks, and route scheduled findings to alerts with an owner and a response time.

Want security audits that run without anyone remembering to?

I set up automated integrity checks, vulnerability scanning and alerting for WordPress platforms, matched to your hosting and licence position.

Automate your WordPress audits →
← Back to all insights