Imported from pantheon-systems-ps/wp2shell-audit (
SKILL.md). Install upstream withnpx skills add pantheon-systems-ps/wp2shell-audit. Copyright stays with the author.
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
terminusCLI installed and authenticated (terminus auth:login), with access to the target site.dig,rsync,nc, andssh— Stage 1 fetches logs directly from every appserver backing the environment when running with--site(the normal way this is used). Not needed for--logsmode.gwsCLI 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 withgws drive about get --params '{"fields":"user"}'when you get there.python3for the doc generator (scripts/lib/generate_google_doc.py, bundled in this repo — no external framework needed) — same Stage-3-only scope asgws.
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.
If a run fails with Site '<domain>' not found / Verify DOMAIN_CURRENT_SITE matches an existing site, re-run with --url <main-site-url>. This is not a broken site or a bad flag combination — it means the environment's Pantheon platform domain isn't a registered blog in the network's wp_blogs table, so WordPress won't bootstrap. It's common on a WPMS serving subsites under a custom domain. Pass the network's main site (blog 1), e.g. --url sites.example.edu; get it from the customer, the site's domain list, or terminus domain:list SITE.ENV. Do not respond to this error by dropping --multisite — without --url the subsite enumeration (wp site list) is exactly what's failing, so that would silently reduce the audit to the main site. --url only works with --site, and only the space-separated form is accepted.
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
--multisiteonly:== 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_loginthat looks auto-generated — random hex/alphanumeric, no relation to a real nameuser_emailmismatched withuser_login, or on a disposable/throwaway-mail domain- A cluster of
user_registeredtimestamps 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_automaticis 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 includewoocommerce— normal: matches an actually-installed plugin, meaningful row counts across real store data.xk29_temp: 1, no plugin in the active list corresponds toxk29— 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-afterMatch
--titleto 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-afterremoves 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
gwsisn'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 (
--outputdir, 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
gwscredentials created it. Share it yourself once it's published. - There is no cover page or logo in the generated doc by design.
- A
0in 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.