| name | wp2shell-audit |
| description | Use when auditing a Pantheon WordPress site for the wp2shell vulnerability chain (CVE-2026-60137 SQL injection + CVE-2026-63030 REST batch-confusion) — runs deterministic log/DB checks via scripts/wp2shell-audit.sh, reviews recently registered users for anomalies a fixed regex can't catch, and (only if asked) publishes a formatted Google Doc containing both. |
wp2shell Audit
Overview
Two required stages, plus one optional one, for wp2shell compromise on a Pantheon WordPress site. Stage 1 is a deterministic script (no LLM needed) covering nginx/PHP-error-log/DB signatures, saved to a local markdown file (or printed to the terminal — your choice). Stage 2 is an LLM-judged review of recently registered user accounts for patterns the Stage 1 regex can't express. Stage 3 — publishing a formatted Google Doc — is optional and only happens if the user explicitly asks for a Google Doc, or ran with --gws. gws is never required just to run an audit; if it isn't installed and nobody asked for a doc, don't mention it.
If Stage 3 does run, it happens only once, after both prior stages are done, because the doc is built via structured Docs API calls (Poppins typography, purple table headers) — not plain markdown auto-conversion. A publish-then-patch flow would mean re-uploading markdown to update the doc, which strips that formatting back out. Merge first, publish once.
Install
Clone or copy this repo into ~/.claude/skills/wp2shell-audit/ (personal, all projects) or <project>/.claude/skills/wp2shell-audit/ (project-scoped). Claude Code auto-discovers SKILL.md files in either location. You can also run the scripts directly without Claude at all — see the README.
Prerequisites
terminus CLI installed and authenticated (terminus auth:login), with access to the target site.
dig, rsync, nc, and ssh — Stage 1 fetches logs directly from every appserver backing the environment when running with --site (the normal way this is used). Not needed for --logs mode.
gws CLI installed and authenticated — only needed if you (or the user) actually want a Google Doc published (Stage 3). Stages 1 and 2 never require it — don't install it, mention it, or treat it as blocking unless a Google Doc was actually requested. Confirm Stage 3 readiness with gws drive about get --params '{"fields":"user"}' when you get there.
python3 for the doc generator (scripts/lib/generate_google_doc.py, bundled in this repo — no external framework needed) — same Stage-3-only scope as gws.
Stage 1 — deterministic audit
First, decide where the report goes: if the user didn't already tell you an output location, ask them — a directory to save the markdown report to (--output), or whether they'd rather just see it printed to the terminal (--stdout, no file saved). Don't guess a default location.
Run:
./scripts/wp2shell-audit.sh --site SITE.ENV --output /path/to/dir
or, for terminal-only output with no file saved:
./scripts/wp2shell-audit.sh --site SITE.ENV --stdout
Only add --gws if the user has explicitly asked for a Google Doc — see Stage 3.
If the site is a WordPress Multisite (WPMS) install, add --multisite. Without it, only the main site (blog 1) gets checked — every subsite's own tables are silently skipped, which misses real findings entirely, not just under-reports them. If the user's own request says it's multisite (e.g. "audit SITE.ENV, it's a multisite") — that's sufficient on its own, add the flag, don't ask again to double-confirm. If they didn't say either way and you don't already know, ask (or check wp-admin → Network Admin being present/absent, only if you have browser access). --multisite costs one extra WP-CLI call (wp site list) and is a no-op safety net if it turns out not to be multisite after all (falls back to single-site behavior with a warning) — when in doubt, use it.
Pulls logs directly from every appserver backing the environment, runs nginx/PHP-error-log/DB checks (batch/v1 traffic, author_exclude SQLi payloads — including the author.exclude/author exclude WAF-evasion spellings, nested privileged REST writes via batch (GET-based only), SQLi errors, forged customize_changeset rows in any status, forged nav_menu_item rows, postmeta rows referencing example.invalid, invalid post_status rows, <prefix>_<hex>-style usernames — across every subsite with --multisite, not just the main site), and writes the findings to a local markdown file (or prints them, with --stdout). It does not publish anywhere by default — Stage 3 is optional and only runs on request.
Output includes:
- A
[FLAG]/[ ok ] line per check
Report saved to: /path/to/wp2shell-report-<site-slug>-<timestamp>.md — capture this path, Stage 3 edits this exact file
- A block labeled
== Recent user accounts for anomaly review ==: the site's 100 most recently registered users (ID, user_login, user_email, user_registered, display_name)
- A block labeled
== Administrator-role accounts for anomaly review ==: every account holding the administrator role, by registration date — check these first, a nonzero count is normal (every site has admins), it's a priority list, not a flag. With --multisite, this covers admins on every subsite (tagged by which capability key/blog matched), not just the main site — a subsite-only admin is otherwise invisible.
- With
--multisite only: == Network Super Admin accounts for anomaly review == — accounts with network-wide Super Admin status (full control over every subsite). Check this block first, before the regular administrator list — a planted Super Admin is the highest-value target on a compromised multisite network.
If any WARNING: query for '<check>' failed or produced a PHP warning/notice lines appear (usually from a site's wp-config.php printing noise to stdout on every WP-CLI bootstrap), the affected check's count is 0, unknown — not confirmed clean. Never describe that check as clean in your own write-up; the saved report itself also carries an explicit Section 5 callout when this happened, so check there too if you're reading a report instead of live terminal output. Each such warning is followed by a Spot-check manually: <command> line — if the user wants extra confidence on a specific check, that's a ready-to-run command, not something you need to reconstruct yourself.
Stage 2 — user-account anomaly review
If this was run with --multisite, check == Network Super Admin accounts for anomaly review == first — full network control is a bigger prize than any single subsite's admin role. Then check == Administrator-role accounts for anomaly review == — a planted admin account is the highest-value target for this pattern of attack. Then read the == Recent user accounts for anomaly review == block. Look for accounts that break the pattern of the rest of the list:
user_login that looks auto-generated — random hex/alphanumeric, no relation to a real name
user_email mismatched with user_login, or on a disposable/throwaway-mail domain
- A cluster of
user_registered timestamps close together that doesn't match the site's normal onboarding cadence
- Anything registered on or near a known incident or CVE-disclosure date
State which specific pattern a flagged account breaks — don't flag on a hunch. Most accounts on most sites will be normal; expect to flag few or none most of the time.
Calibration — match this register, not more, not less:
142:jsmith2024:jsmith2024@gmail.com:2026-03-11 — normal: name-derived login, plausible address, unremarkable date.
98:x7f2a9b1c4d:x7f2a9b1c4d@mailinator.com:2026-07-21 — flagged: login is a bare hex string with no name relation, email domain is a known disposable-mail provider, and the registration date lands the same day as this audit.
A user_login/display_name matching the <prefix>_<hex> regex is not automatically suspicious on its own — apply the same three criteria above before flagging it, not the raw regex match. A real-name-derived login with a random suffix (e.g. janedoe_a1b2c3), a real consumer email domain (gmail/yahoo/outlook, not disposable), registration spread across months rather than clustered, and no administrator role — that's a normal auto-generated customer/membership username from an e-commerce or membership plugin, not a throwaway admin account. For example: a site's own "suspicious usernames" regex match can turn out to be exactly this — real-looking customer accounts, non-admin, registered across several months, on real email providers.
Stage 2b — post_status breakdown review (only if invalid post_status is non-zero)
If Section 4's wp_posts with invalid post_status count is non-zero, check the == post_status breakdown for anomaly review == block (also printed to stdout, not written to the report — distinct status values with row counts, not per-row IDs) against the == active plugins for post_status cross-reference == block printed right after it, before treating it as compromise:
- Match the status prefix/name to an actual active plugin in that list, don't just judge the naming "plausible." A status like
fgf_automatic is meaningless on its own — check whether an active plugin's name/slug corresponds to that prefix (e.g. a plugin literally named or abbreviating to "fgf") and name that specific plugin in your write-up if so. "Looks like it could be a plugin" without checking the actual list is a guess, not a finding.
- A status that matches an active plugin, covering many rows (dozens to hundreds of thousands) — that plugin's own post type or workflow. Not evidence of compromise.
- A status with no corresponding active plugin, especially on exactly one or two rows, or one that looks like random/injected text — the suspicious case. Note explicitly that no installed plugin explains it.
Calibration:
wc-partial-refund: 340, wc-backorder: 12, active plugins include woocommerce — normal: matches an actually-installed plugin, meaningful row counts across real store data.
xk29_temp: 1, no plugin in the active list corresponds to xk29 — flagged: single row, nothing installed explains this status.
State which specific plugin (or its absence) a status corresponds to — don't flag or clear on a hunch. A single common WooCommerce status like wc-completed can account for hundreds of thousands of legitimate rows on an active store — that scale is exactly why this cross-reference step exists rather than treating any non-core status as a flag.
Stage 3 — merge findings, and publish only if asked (optional)
Skip this stage entirely unless the user explicitly asked for a Google Doc, or ran Stage 1 with --gws. If Stage 1 was run with --stdout (no saved file), Stage 3 isn't possible — there's nothing to publish; either re-run with --output first or just leave it at the terminal output and Stage 2 findings written back in conversation.
If a doc was requested, the findings belong inside Section 4 (Database Analysis), directly after the "Suspicious usernames found" line (and after the assessment paragraph if there's no separate "sus users" list, e.g. on a clean site) — not tacked onto the end of the document.
-
Edit the Stage 1 markdown file directly (the path Stage 1 printed) — insert a **User Account Anomaly Review:** block (no parenthetical — keep the header plain) with your Stage 2 findings, and, if Stage 2b applied, a **Post-Status Anomaly Review:** block with those findings, right after the "Suspicious usernames found" line in Section 4, before that section's closing **Assessment:** paragraph.
-
Publish it once:
python3 scripts/lib/generate_google_doc.py \
--input /path/to/wp2shell-report-<site-slug>-<timestamp>.md \
--title "wp2shell Security Audit — SITE.ENV (YYYY-MM-DD)" \
--delete-after
Match --title to what Stage 1 would have used (wp2shell Security Audit — <site or log dir> (<today's date>)). The script prints the finished doc's URL — share that with the requester. --delete-after removes the local markdown file itself once the doc is confirmed created, so no staging file is left behind on whoever's machine ran this.
If gws isn't installed or isn't authenticated, tell the user that specifically (and that it's only needed for this optional publish step) rather than treating the audit itself as blocked — Stages 1 and 2 are already done and usable without it.
This produces exactly one doc per audit, containing both stages, with no local file left over and no separate re-upload/patch step to strip the formatting back out.
Notes
- Keep Stage 2's findings visibly separate from Stage 1's — never use "confirmed" language for a judgment call the way Stage 1 does for the invalid-
post_status/SQLi/forged-changeset checks.
- Stage 1 alone is fully automated and needs no LLM in the loop, and never publishes on its own. Stage 2 requires an agent — Claude or a person — actually reading the output; it doesn't run unattended. Stage 3 is opt-in only — never run it, or install/require
gws, unless a Google Doc was actually requested.
- Always confirm a report destination (
--output dir, or --stdout) before running Stage 1 — ask if the user hasn't said.
- The generator does not share the doc with anyone — it's private to whoever's
gws credentials created it. Share it yourself once it's published.
- There is no cover page or logo in the generated doc by design.
- A
0 in Section 4 of the report is not automatically "confirmed clean" — check Section 5 (Confidence Assessment) for a query-failure callout first (see Stage 1's output notes above) before treating a clean-looking Database section as verified.