#!/usr/bin/env bash # # Verify an installed WordPress plugin against its published checksums. # # Reads the version from the plugin's own header, fetches the manifest for that # version, and compares every file on disk against it. Added, modified, removed # and symlinked files are all reported by name. # # One HTTPS GET for a public manifest. No license key, no site URL, nothing # that identifies the site being checked, and nothing is sent anywhere. # # ./verify.sh /path/to/wp-content/plugins/ninja-tables-pro # ./verify.sh /path/to/plugin --version 5.2.15 --strict # # Requires: curl, python3. # Exit codes: 0 clean, 1 differences found, 2 could not run. # # Published at https://checksums.wpmanageninja.com/verify.sh — read it before # you run it. Source: https://github.com/techjewel/wpmn-plugins-checksum set -euo pipefail BASE_URL="${CHECKSUMS_BASE_URL:-https://checksums.wpmanageninja.com}" PLUGIN_DIR="" SLUG="" VERSION="" STRICT=0 usage() { sed -n '3,17p' "$0" | sed 's/^# \{0,1\}//' exit 2 } while [ $# -gt 0 ]; do case "$1" in --base-url) BASE_URL="$2"; shift 2 ;; --slug) SLUG="$2"; shift 2 ;; --version) VERSION="$2"; shift 2 ;; --strict) STRICT=1; shift ;; -h|--help) usage ;; -*) echo "unknown option: $1" >&2; usage ;; *) PLUGIN_DIR="$1"; shift ;; esac done [ -n "$PLUGIN_DIR" ] || usage [ -d "$PLUGIN_DIR" ] || { echo "not a directory: $PLUGIN_DIR" >&2; exit 2; } command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; } command -v curl >/dev/null || { echo "curl is required" >&2; exit 2; } PLUGIN_DIR="$(cd "$PLUGIN_DIR" && pwd -P)" [ -n "$SLUG" ] || SLUG="$(basename "$PLUGIN_DIR")" # Take the version from the plugin's own header, the same value WordPress # reports for the installed copy. if [ -z "$VERSION" ]; then VERSION="$( for f in "$PLUGIN_DIR"/*.php; do [ -f "$f" ] || continue head -c 8192 "$f" | grep -qiE '^[ \t/*#@]*Plugin Name:' || continue head -c 8192 "$f" \ | grep -iE '^[ \t/*#@]*Version:' \ | head -1 \ | sed -E 's/^[ \t\/*#@]*[Vv]ersion:[ \t]*//; s/[ \t]*(\*\/|\?>).*$//; s/[ \t]*$//' break done )" fi [ -n "$VERSION" ] || { echo "could not detect version; pass --version" >&2; exit 2; } URL="${BASE_URL%/}/plugin-checksums/${SLUG}/${VERSION}.json" echo "Plugin: $SLUG $VERSION" echo "Manifest: $URL" MANIFEST="$(mktemp)" trap 'rm -f "$MANIFEST"' EXIT HTTP_CODE="$(curl -sS -w '%{http_code}' -o "$MANIFEST" "$URL" || true)" if [ "$HTTP_CODE" != "200" ]; then echo "error: manifest fetch returned HTTP $HTTP_CODE" >&2 exit 2 fi PLUGIN_DIR="$PLUGIN_DIR" MANIFEST="$MANIFEST" STRICT="$STRICT" python3 <<'PY' import fnmatch, hashlib, json, os, sys root = os.environ["PLUGIN_DIR"] strict = os.environ["STRICT"] == "1" with open(os.environ["MANIFEST"], "rb") as fh: manifest = json.load(fh) files = manifest.get("files") or {} meta = manifest.get("_meta") or {} ignore = meta.get("ignore") or [] soft = set(meta.get("softChange") or ["readme.txt", "readme.md"]) # Archiver artefacts are excluded from manifests, so they must be excluded here # too or every macOS-touched install reports phantom additions. NOISE = ("__MACOSX", ".DS_Store", "Thumbs.db") def ignored(path): return any(fnmatch.fnmatch(path, pattern) for pattern in ignore) def sha256(path): digest = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(1 << 20), b""): digest.update(chunk) return digest.hexdigest() def accepts(entry, actual): expected = entry.get("sha256") # WordPress.org may list several acceptable digests per file. return actual in (expected if isinstance(expected, list) else [expected]) on_disk = set() findings = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in NOISE] for name in filenames: if name in NOISE: continue full = os.path.join(dirpath, name) if os.path.islink(full): findings.append(("symlink", os.path.relpath(full, root))) continue rel = os.path.relpath(full, root).replace(os.sep, "/") if ignored(rel): continue on_disk.add(rel) entry = files.get(rel) if entry is None: findings.append(("added", rel)) elif not accepts(entry, sha256(full)): if rel in soft and not strict: continue findings.append(("modified", rel)) for rel in files: if rel not in on_disk and not ignored(rel): findings.append(("missing", rel)) label = { "added": "+ added ", "modified": "~ modified ", "missing": "- missing ", "symlink": "! symlink ", } for kind, rel in sorted(findings, key=lambda f: (f[0], f[1])): print(f" {label[kind]}{rel}") checked = len(on_disk) if not findings: print(f"\nClean: {checked} files match the published manifest.") sys.exit(0) print(f"\n{len(findings)} finding(s) across {checked} files checked.") if not strict: print("Soft-change files were skipped; re-run with --strict to include them.") sys.exit(1) PY