A WordPress checksum command can exit 0 while leaving one plugin unverified. In the reproduced lab, wp plugin verify-checksums --all --strict reported “2 of 3 plugins (1 skipped)” because a local plugin had no WordPress.org checksum, yet the shell still saw success. Later, a trusted core reinstall restored one changed file but left an unexpected root file behind; --include-root warned about it and again returned 0.
Those results change the audit rule. A useful checksum receipt combines version, locale, package coverage, warnings, skipped items and exit status. Green output alone is not enough, and a mismatch alone does not prove malware.
This guide is for an agency maintainer or WordPress operator with authorized SSH access. The disposable workflow used WordPress 7.0.3, WP-CLI 2.12.0, PHP 8.4.24 and MariaDB 11.8.6 on Debian 13. It creates only a marker-owned /var/tmp tree and synthetic database, then removes both. Production work should preserve a full incident copy and use a maintenance window or isolated restore rather than introducing the controlled changes shown here.
WP-CLI compares local files with checksums published for a specific package. According to current core verification documentation, the core command runs before WordPress loads, downloads checksums for the selected version and locale, and can inspect unexpected root entries with --include-root. That makes it useful even when normal WordPress bootstrap is damaged.
Plugin scope is different. Current plugin checksum documentation covers packages distributed through WordPress.org and lets --strict turn soft changes such as readme.txt differences into errors. Premium, private and custom code has no WordPress.org reference unless its owner publishes one.
Four result classes therefore need different decisions:
Checksums answer “Do these supported package files match this published release?” They do not answer who changed a file, whether a database backdoor exists, whether uploads contain executable code or whether credentials were stolen.
On a live incident, begin read-only: record the site path, WordPress version, locale, WP-CLI version, active release owner, filesystem owner and backup identity. Do not run --insecure; both official command references warn that disabling TLS validation exposes checksum downloads to a man-in-the-middle attack.
WP-CLI itself belongs inside the trust boundary. WordPress now publishes a download-verification guide with signatures and SHA-256/SHA-512 files; it also explains that MD5 is not suitable as an authenticity control. The lab checks its installed WP-CLI 2.12.0 binary against the release SHA-256 before asking that binary to evaluate WordPress.
Run all eight input blocks in one authorized Bash shell so the variables and cleanup trap persist. The first block refuses a pre-existing path or database, obtains the exact WordPress package over HTTPS, creates a synthetic site, installs Akismet from WordPress.org and adds one owner-managed local plugin. A clean owner SHA-256 becomes that local plugin’s separate reference.
set -Eeuo pipefail
LAB=/var/tmp/voxfor-wp-checksum-lab-141
SITE="$LAB/wordpress"
EVIDENCE="$LAB/evidence"
MARKER="$LAB/.voxfor-wp-checksum-lab-141"
DB=voxfor_wp_checksum_141
WP_VERSION=7.0.3
WP_LOCALE=en_US
PACKAGE="$LAB/wordpress-${WP_VERSION}.zip"
[[ "$LAB" == /var/tmp/voxfor-wp-checksum-lab-141 && ! -e "$LAB" ]]
! mariadb -Nse "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='${DB}'" | grep -qx "$DB"
mkdir -m 700 -p "$LAB" "$EVIDENCE"
printf 'voxfor-wordpress-checksum-lab-141\n' >"$MARKER"
cleanup() {
if [[ "$LAB" == /var/tmp/voxfor-wp-checksum-lab-141 \
&& "$DB" == voxfor_wp_checksum_141 && -f "$MARKER" ]] \
&& grep -qx 'voxfor-wordpress-checksum-lab-141' "$MARKER"; then
mariadb -e "DROP DATABASE IF EXISTS \`voxfor_wp_checksum_141\`;" \
>/dev/null 2>&1 || true
rm -rf -- "$LAB"
else
printf 'cleanup refused: lab path, database name or marker mismatch\n' >&2
return 1
fi
}
trap cleanup EXIT
expected_wp_cli_sha=$(curl -fsSL https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar.sha256 | awk 'NF{print $1; exit}')
actual_wp_cli_sha=$(sha256sum /usr/local/bin/wp | awk '{print $1}')
[[ "$expected_wp_cli_sha" == "$actual_wp_cli_sha" ]]
printf 'wp_cli_sha256=ok\n'
curl -fsSL "https://wordpress.org/wordpress-${WP_VERSION}.zip" -o "$PACKAGE"
unzip -q "$PACKAGE" -d "$LAB"
wp config create --allow-root --path="$SITE" --dbname="$DB" --dbuser=root --dbhost=localhost --skip-check --quiet
wp db create --allow-root --path="$SITE" --quiet
LAB_ADMIN_EMAIL="checksum-lab$(printf '\100')example.invalid"
wp core install --allow-root --path="$SITE" --url='http://checksum-lab.invalid' \
--title='Checksum Lab' --admin_user='checksum-lab-admin' \
--admin_password='Checksum-Lab-141-Only!' --admin_email="$LAB_ADMIN_EMAIL" --skip-email --quiet
wp plugin install akismet --allow-root --path="$SITE" --force --quiet
AKISMET_VERSION=$(wp plugin get akismet --allow-root --path="$SITE" --field=version)
mkdir -m 755 -p "$SITE/wp-content/plugins/agency-local-tool"
printf '%s\n' '<?php' '/**' ' * Plugin Name: Agency Local Tool' \
' * Version: 1.0.0' ' */' \
>"$SITE/wp-content/plugins/agency-local-tool/agency-local-tool.php"
sha256sum "$SITE/wp-content/plugins/agency-local-tool/agency-local-tool.php" \
>"$EVIDENCE/agency-local-tool-owner.sha256"
The package becomes a usable lab baseline only after the next verification succeeds. A successful HTTPS download by itself is not the acceptance state.
Core and a known WordPress.org plugin should pass before any negative control. The all-plugin run is captured instead of trusted automatically because its output owns the coverage decision.
wp core verify-checksums --allow-root --path="$SITE" \
--version="$WP_VERSION" --locale="$WP_LOCALE"
wp plugin verify-checksums akismet --allow-root --path="$SITE" --strict
set +e
wp plugin verify-checksums --all --allow-root --path="$SITE" --strict \
>"$EVIDENCE/all-plugins-baseline.stdout" \
2>"$EVIDENCE/all-plugins-baseline.stderr"
all_baseline_rc=$?
set -e
printf 'all_plugins_baseline_exit=%s\n' "$all_baseline_rc"
sed -n '1,12p' "$EVIDENCE/all-plugins-baseline.stdout"
sed -n '1,12p' "$EVIDENCE/all-plugins-baseline.stderr"
Exit 0 meant the command ran as designed; it did not mean every installed plugin had a published checksum. The baseline named agency-local-tool as skipped while the separate owner manifest covered its exact file. A premium plugin needs equivalent vendor or release-system evidence. Excluding it from the command without naming another owner simply hides the gap.
For custom themes, uploads, mu-plugins, configuration and server files, trusted AIDE baseline shows why a reference must be created during a known release rather than after suspicion begins. That target is a better semantic fit than a generic scanner list because it owns the non-WordPress.org baseline decision.
The negative control changes one core file, one WordPress.org plugin file and one unexpected root path. Each changed file is hashed and copied before any repair. On production, copy the complete site, database and relevant logs to restricted incident storage; three files are sufficient only for this bounded fixture.
printf '\n<!-- controlled core mutation for lab 141 -->\n' >>"$SITE/readme.html"
printf '\n// controlled plugin mutation for lab 141\n' \
>>"$SITE/wp-content/plugins/akismet/akismet.php"
printf '%s\n' '<?php // controlled unexpected root file for lab 141' \
>"$SITE/wp-core-shadow.php"
sha256sum "$SITE/readme.html" \
"$SITE/wp-content/plugins/akismet/akismet.php" \
"$SITE/wp-core-shadow.php" | tee "$EVIDENCE/incident-sha256.txt"
cp -a -- "$SITE/readme.html" \
"$SITE/wp-content/plugins/akismet/akismet.php" \
"$SITE/wp-core-shadow.php" "$EVIDENCE/"
--include-root widens discovery to unexpected root items. A wrapper must retain stdout and stderr, then reject checksum mismatches, missing files and unexpected paths regardless of the numeric exit.
set +e
wp core verify-checksums --allow-root --path="$SITE" \
--version="$WP_VERSION" --locale="$WP_LOCALE" --include-root \
>"$EVIDENCE/core-check.stdout" 2>"$EVIDENCE/core-check.stderr"
core_rc=$?
set -e
sed -n '1,20p' "$EVIDENCE/core-check.stdout"
sed -n '1,20p' "$EVIDENCE/core-check.stderr"
if (( core_rc != 0 )) || grep -Eqi \
"doesn't verify against checksum|doesn't exist|should not exist" \
"$EVIDENCE/core-check.stdout" "$EVIDENCE/core-check.stderr"; then
core_gate=reject
else
core_gate=accept
fi
printf 'core_failed_exit=%s core_gate=%s\n' "$core_rc" "$core_gate"
[[ "$core_gate" == reject ]]
A single supported plugin test answers whether Akismet matches its WordPress.org release. The all-plugin test answers which installed packages WordPress.org could evaluate. Combining them into one green/red label would hide the custom plugin skip.
set +e
wp plugin verify-checksums akismet --allow-root --path="$SITE" \
--strict --format=json >"$EVIDENCE/plugin-check.stdout" \
2>"$EVIDENCE/plugin-check.stderr"
plugin_rc=$?
wp plugin verify-checksums --all --allow-root --path="$SITE" \
--strict --format=json >"$EVIDENCE/all-plugins-check.stdout" \
2>"$EVIDENCE/all-plugins-check.stderr"
all_plugins_rc=$?
set -e
sed -n '1,20p' "$EVIDENCE/plugin-check.stdout"
sed -n '1,20p' "$EVIDENCE/plugin-check.stderr"
sed -n '1,24p' "$EVIDENCE/all-plugins-check.stdout"
sed -n '1,24p' "$EVIDENCE/all-plugins-check.stderr"
if (( plugin_rc != 0 || all_plugins_rc != 0 )) || grep -Eqi \
'checksum does not match|failed|skipping|skipped' \
"$EVIDENCE/plugin-check.stdout" "$EVIDENCE/plugin-check.stderr" \
"$EVIDENCE/all-plugins-check.stdout" "$EVIDENCE/all-plugins-check.stderr"; then
plugin_gate=reject
else
plugin_gate=accept
fi
printf 'plugin_failed_exit=%s all_plugins_failed_exit=%s plugin_gate=%s\n' \
"$plugin_rc" "$all_plugins_rc" "$plugin_gate"
[[ "$plugin_gate" == reject ]]
Representative output from the reproduced run shows why both policies matter:
wp_cli_sha256=ok
Success: WordPress installation verifies against checksums.
Success: Verified 1 of 1 plugins.
all_plugins_baseline_exit=0
Success: Verified 2 of 3 plugins (1 skipped).
Warning: Could not retrieve the checksums for version 1.0.0 of plugin agency-local-tool, skipping.
core_failed_exit=1
Warning: File doesn't verify against checksum: readme.html
Warning: File should not exist: wp-core-shadow.php
plugin_failed_exit=1 all_plugins_failed_exit=1
[{"plugin_name":"akismet","file":"akismet.php","message":"Checksum does not match"}]
post_reinstall_include_root_exit=0
Success: WordPress installation verifies against checksums.
Warning: File should not exist: wp-core-shadow.php
core_clean=yes plugin_clean=yes unexpected_root_absent=yes local_plugin_owner_hash=ok
cleanup_guard_reject=yes database_preserved=yes
cleanup_lab_absent=yes cleanup_database_absent=yes
Notice the two counterexamples: baseline plugin coverage was incomplete at exit 0, and the unexpected root warning remained after package restoration at exit 0. An automation job that monitors only $? would miss both decisions.
The retained ZIP produced a clean baseline for the exact version and locale. Re-extract it to a separate directory, copy only core package scope, and reinstall the recorded Akismet version from WordPress.org. The current core download reference documents --version, --locale, --force and --skip-content; the lab uses its already accepted package so the repair input is immutable inside this run. Current plugin install documentation confirms that --version selects a specific WordPress.org version and --force overwrites the installed package.
mkdir -m 700 "$LAB/trusted-restore"
unzip -q "$PACKAGE" -d "$LAB/trusted-restore"
rsync -a --exclude='wp-content/' "$LAB/trusted-restore/wordpress/" "$SITE/"
wp plugin install akismet --allow-root --path="$SITE" \
--version="$AKISMET_VERSION" --force --quiet
set +e
wp core verify-checksums --allow-root --path="$SITE" \
--version="$WP_VERSION" --locale="$WP_LOCALE" --include-root \
>"$EVIDENCE/post-restore.stdout" 2>"$EVIDENCE/post-restore.stderr"
post_restore_rc=$?
set -e
sed -n '1,20p' "$EVIDENCE/post-restore.stdout"
sed -n '1,20p' "$EVIDENCE/post-restore.stderr"
printf 'post_reinstall_include_root_exit=%s\n' "$post_restore_rc"
grep -q 'File should not exist: wp-core-shadow.php' \
"$EVIDENCE/post-restore.stderr"
cmp -s "$EVIDENCE/wp-core-shadow.php" "$SITE/wp-core-shadow.php"
grep -qx '<?php // controlled unexpected root file for lab 141' \
"$SITE/wp-core-shadow.php"
rm -- "$SITE/wp-core-shadow.php"
Package restoration deliberately does not delete unknown files. The extra root path survived until the workflow proved it was the exact lab-owned copy. On a real site, a similarly named path might be a backdoor, a host integration or evidence of initial access; move it into restricted incident storage and let the incident owner decide its disposition.
If the evidence suggests compromise, continue with WordPress malware-removal workflow rather than treating a clean package reinstall as containment. Confirmed credential exposure also needs a separate browser-session invalidation proof; checksum repair does not revoke an attacker’s session or Application Password.
Final acceptance rechecks supported core and plugin packages, verifies the local plugin through its owner manifest, confirms the unexpected path is absent and checks the restored PHP syntax. A skip remains visible in the incident record even after the owner hash closes it.
wp core verify-checksums --allow-root --path="$SITE" \
--version="$WP_VERSION" --locale="$WP_LOCALE" --include-root
wp plugin verify-checksums akismet --allow-root --path="$SITE" --strict
sha256sum -c "$EVIDENCE/agency-local-tool-owner.sha256"
php -l "$SITE/wp-includes/version.php"
php -l "$SITE/wp-content/plugins/akismet/akismet.php"
[[ ! -e "$SITE/wp-core-shadow.php" ]]
wp core is-installed --allow-root --path="$SITE"
[[ "$(wp option get siteurl --allow-root --path="$SITE")" \
== 'http://checksum-lab.invalid' ]]
[[ "$(wp plugin get akismet --allow-root --path="$SITE" --field=version)" \
== "$AKISMET_VERSION" ]]
printf 'core_clean=yes plugin_clean=yes unexpected_root_absent=yes local_plugin_owner_hash=ok\n'
wp core is-installed proves database bootstrap, not checkout, login, forms or scheduled work. Choose checks that match the real site: authenticated login, a representative front-end page, REST endpoints, media delivery, form submission, payment sandbox, cache behavior and scheduled tasks. An isolated restore can use post-migration application checklist to keep DNS, TLS, SEO and public behavior separate from file integrity.
Write failure ownership into the change record. When a trusted reinstall cannot replace files, inspect user/group ownership, parent traversal, ACLs, immutable attributes and confinement before widening mode bits; Linux authorization layers explain why chmod 777 can still miss the denying control.
The incident receipt is acceptable when the exact WordPress version and locale match the intended release; supported core and plugin checks are clean; every warning, missing path, unexpected item and skipped package has a named disposition; unsupported code matches an owner-controlled reference; preserved evidence remains readable; application-specific checks succeed; and no production path was changed outside the approved package or incident scope.
Where shell access or recovery ownership sits with the provider, match the incident plan against documented hosting recovery duties before anyone overwrites suspect files. The live scope lists SSH access, WP Toolkit scanning/removal, malware removal, backups and recovery options, but the ticket still needs exact evidence and acceptance criteria.
If production behavior worsens after a reviewed package restore, keep the incident copy and failed acceptance output, return only the changed core/plugin paths from the pre-change backup or last approved deployment artifact, restore the matching database only when the change included database state, clear the affected cache layer, and rerun the same package plus application checks. Do not restore the suspicious file set over a clean investigation copy or erase logs merely to recover availability.
No. It proves supported core files match published checksums for the selected version and locale. Custom code, uploads, configuration, themes, database records, other accounts, scheduled persistence and stolen credentials remain outside that statement.
--include-root warn and still return exit zero?Yes. In the reproduced WP-CLI 2.12.0 run, an unexpected wp-core-shadow.php produced File should not exist while the command returned 0 after the changed package file had been restored. Capture output and reject unexplained warnings instead of monitoring only exit status.
WordPress.org cannot publish checksums for a package it does not distribute. The lab’s custom plugin was skipped at exit 0; its separate owner SHA-256 closed that coverage gap. Premium or private plugins need an equivalent trusted vendor package, signed release, repository commit or deployment manifest.
--strict change for plugin checksums?--strict makes soft differences such as plugin readme.txt changes count as checksum errors. Use it when the policy expects exact WordPress.org package bytes, then document any approved packaging exception rather than excluding files silently.
No. Preserve it, hash it and identify its owner first. Old package debris, host tooling, custom configuration and attacker files can all appear unexpected; deletion before attribution destroys evidence and can break a legitimate integration.
It restores supported package bytes, but it does not determine initial access, remove persistence outside those packages, rotate exposed credentials, invalidate sessions or validate business functions. Treat reinstall as one bounded recovery action inside a broader incident plan.
Compare commercial/custom themes with trusted vendor or deployment artifacts; inspect uploads for allowed file types and executable content; verify mu-plugins and configuration through owner manifests; and review database users, options, scheduled events and content against known operational state. No single WordPress.org checksum command owns those surfaces.
Cleanup is a tested input because a technical article should not leave a database, administrator or package tree behind. The function refuses to remove anything unless the exact path, database name and marker all match.
saved_db=$DB
DB=voxfor_wp_checksum_141_wrong
set +e
cleanup >"$EVIDENCE/cleanup-refusal.stdout" \
2>"$EVIDENCE/cleanup-refusal.stderr"
cleanup_refusal_rc=$?
set -e
DB=$saved_db
[[ "$cleanup_refusal_rc" -eq 1 && -d "$LAB" ]]
mariadb -Nse \
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='voxfor_wp_checksum_141'" \
| grep -qx voxfor_wp_checksum_141
grep -q 'cleanup refused: lab path, database name or marker mismatch' \
"$EVIDENCE/cleanup-refusal.stderr"
printf 'cleanup_guard_reject=yes database_preserved=yes\n'
cleanup
trap - EXIT
[[ ! -e /var/tmp/voxfor-wp-checksum-lab-141 ]]
! mariadb -Nse \
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='voxfor_wp_checksum_141'" \
| grep -qx voxfor_wp_checksum_141
printf 'cleanup_lab_absent=yes cleanup_database_absent=yes\n'
Retain the production incident receipt somewhere the compromised site cannot rewrite: site identity, WordPress/WP-CLI versions, locale, package inventory, tool verification, timestamps, stdout/stderr, exit codes, skipped-package owners, preserved hashes, trusted repair artifacts, changed paths, application acceptance and rollback result. That record explains what became clean—and, just as importantly, what the checksums never covered.