| Current Path : /proc/3/cwd/opt/dedrads/ |
| Current File : //proc/3/cwd/opt/dedrads/wp-xray.sh |
#!/usr/bin/bash
#
# wp-xray.sh -- WordPress incident triage. Read-only.
#
# It reports. It changes nothing. There is no second mode, no --execute, no
# repair path. The docroot and the database are read and never written.
#
# Design rules that are not negotiable, and why:
#
# 1. It never executes site PHP. A compromised wp-config.php, drop-in, or
# mu-plugin runs on any WP bootstrap -- including wp-cli's. So this script
# reads wp-config.php with PHP's tokenizer (lexes, does not execute),
# queries the database with the mysql client directly, and verifies core
# against wordpress.org's published checksum manifest by hashing files
# itself (modified, missing, and unexpected executables in
# wp-admin/wp-includes and at the docroot root). wp-cli is never invoked.
#
# 2. Only literal wp-config.php values are read. getenv(), concatenation and
# other non-literals are reported as non-literal, never guessed. Every
# non-standard directive is reported for review.
#
# 3. Everything the script writes goes into the workdir: the report, the log
# and the supporting file lists. The workdir is mode 700 and must not be
# web-reachable -- the report carries database names, admin emails and
# malware paths.
#
# 4. Expensive walks are bounded. Cache dirs and pathological inode counts
# would otherwise make a live ticket unworkable.
#
# Run as the site user. Reading as that user is what shows you the site as the
# web application sees it, and it keeps the report files out of root's hands.
#
set -Eeuo pipefail
VERSION="2.2.0"
PROGNAME=${0##*/}
# Absolute path to this script, so the re-run commands printed for nested
# installations can be pasted as-is from a ticket into any shell. Falls back to
# the bare name if $0 is unresolvable (piped from stdin, exotic launcher).
SELF=$(realpath -e -- "$0" 2>/dev/null) || SELF=$PROGNAME
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DOCROOT=""
SKIP_DB=0
SKIP_CORE_VERIFY=0
WORKDIR_BASE=""
SCAN_MAX_BYTES=$((3 * 1024 * 1024)) # skip grep on files larger than this
# Archive/backup artifacts. They waste quota, and a .sql dump under the docroot
# is publicly readable on most hosts.
JUNK_PATTERNS=(
'*.sql' '*.sql.gz' '*.sql.zip' '*.sql.bz2' '*.dump'
'*.zip' '*.tar' '*.tar.gz' '*.tgz' '*.gz' '*.bz2' '*.7z' '*.rar'
'*.wpress' '*.wpstg' '*.bak' '*.old' '*.orig' '*.save' '*.swp'
'*.log' '*error_log' 'error_log' '*.tmp'
# Old PHP/WordPress core dumps: multi-gig files named core or core.<pid>
'core' 'core.[0-9]*'
)
# wp-content directory basenames that are disposable and often dominate size
# and inodes (cache plugins, optimizers, Wordfence logs).
CACHE_DIR_NAMES=(
cache et-cache litespeed wflogs boost-cache
)
# Full-tree walks (sort every file by size, pattern scan) become unusable past this.
FILE_COUNT_WARN=250000
DIR_COUNT_WARN=100000
# Anything the web server may be persuaded to execute out of uploads/.
EXEC_EXTENSIONS=(
'php' 'php2' 'php3' 'php4' 'php5' 'php6' 'php7' 'php8' 'phps' 'pht'
'phtml' 'phar' 'inc' 'shtml' 'shtm' 'cgi' 'pl' 'py' 'sh' 'suspected'
)
# Drop-ins execute on every request and are not covered by core checksums.
DROPINS=(
advanced-cache.php db.php object-cache.php sunrise.php maintenance.php
db-error.php install.php php-error.php fatal-error-handler.php
blog-deleted.php blog-inactive.php blog-suspended.php
)
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[36m'
else
C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GRN=""; C_YEL=""; C_BLU=""
fi
REPORT_FILE="" # set once the workdir exists
# Strip CSI/ANSI colour codes for the ticket-friendly report file.
strip_ansi() { sed $'s/\033\\[[0-9;]*[[:alpha:]]//g'; }
# rpt: coloured (or plain) on the terminal; always plain in report.txt
rpt() {
printf '%s\n' "$*"
if [[ -n $REPORT_FILE ]]; then
printf '%s\n' "$*" | strip_ansi >>"$REPORT_FILE" || true
fi
}
hdr() { rpt ""; rpt "${C_BOLD}== $* ==${C_RESET}"; }
info() { printf '%s\n' "${C_BLU}--${C_RESET} $*" >&2; }
ok() { printf '%s\n' "${C_GRN}ok${C_RESET} $*" >&2; }
warn() { printf '%s\n' "${C_YEL}!!${C_RESET} $*" >&2; }
die() { printf '%s\n' "${C_RED}xx${C_RESET} $*" >&2; exit 1; }
# Findings are accumulated and re-printed as a ranked summary at the end.
FINDING_HIGH=(); FINDING_MED=(); FINDING_LOW=()
finding() {
case $1 in
high) FINDING_HIGH+=("$2") ;;
med) FINDING_MED+=("$2") ;;
*) FINDING_LOW+=("$2") ;;
esac
}
usage() {
cat <<EOF
$PROGNAME $VERSION -- WordPress incident triage. Read-only.
USAGE
$PROGNAME [options] <docroot>
The docroot and the database are read, never written. No site PHP is executed.
All output goes to a report in the workdir.
OPTIONS
--no-db Skip the database audit.
--no-core-verify Skip core checksum verification (faster on huge sites).
--workdir=PATH Where to put the report and the supporting file lists.
Default: \$HOME/wp-xray-<timestamp>
Must not be web-reachable: the report contains database
names, administrator emails and malware paths.
--scan-max=SIZE Skip malware grep on files larger than this. Accepts a byte
count or a K/M/G suffix. Default 3M.
-h, --help This.
EXAMPLES
$PROGNAME ~/public_html
$PROGNAME --no-db --workdir=/home/user/xray ~/public_html/blog
Run as the site user, never as root.
EOF
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
# parse_size 3145728 | 3M | 512K | 1G -> bytes on stdout, non-zero on garbage.
#
# This exists because find's -size takes its own suffixes. A bare "10M" reaching
# `-size -10Mc` makes find error out, the scan is wrapped in `|| true`, and the
# report then says "0 suspicious files" on a site full of shells. A tech reads
# that as clean. Refusing the input up front is the only safe behaviour.
parse_size() {
local v=$1 n u
[[ $v =~ ^([0-9]+)([KkMmGg]?)[Bb]?$ ]] || return 1
n=${BASH_REMATCH[1]}; u=${BASH_REMATCH[2]}
case $u in
K|k) n=$((n * 1024)) ;;
M|m) n=$((n * 1024 * 1024)) ;;
G|g) n=$((n * 1024 * 1024 * 1024)) ;;
esac
[[ $n -gt 0 ]] || return 1
printf '%s' "$n"
}
while [[ $# -gt 0 ]]; do
case $1 in
--no-db) SKIP_DB=1 ;;
--no-core-verify) SKIP_CORE_VERIFY=1 ;;
--workdir=*) WORKDIR_BASE=${1#*=} ;;
--scan-max=*) SCAN_MAX_BYTES=$(parse_size "${1#*=}") \
|| die "--scan-max needs a positive byte count, optionally suffixed K, M or G (got '${1#*=}')." ;;
-h|--help) usage; exit 0 ;;
--) shift; DOCROOT=${1:-}; break ;;
-*) die "Unknown option: $1 (try --help)" ;;
*) [[ -n $DOCROOT ]] && die "Only one docroot may be given."
DOCROOT=$1 ;;
esac
shift
done
[[ -n $DOCROOT ]] || { usage; exit 1; }
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
# http_get URL OUTFILE
http_get() {
if have curl; then
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 900 -o "$2" -- "$1"
else
wget -q --tries=3 --timeout=30 -O "$2" -- "$1"
fi
}
# http_body URL -> stdout, empty on failure
http_body() {
if have curl; then
curl -fsSL --retry 2 --connect-timeout 15 --max-time 120 -- "$1" 2>/dev/null || true
else
wget -q --tries=2 --timeout=30 -O - -- "$1" 2>/dev/null || true
fi
}
# strip_prefix PREFIX [FIELD] -- remove PREFIX from the start of tab-field FIELD
# (default 1) of each line on stdin, matched literally.
#
# sed would treat the path as a regular expression, so a docroot containing
# ".", "[" or "|" -- /home/u/public_html/site[1]/ is not hypothetical -- either
# fails to strip or strips the wrong thing. awk's index() has no such problem.
strip_prefix() {
awk -F'\t' -v p="$1" -v fn="${2:-1}" 'BEGIN{OFS="\t"; n=length(p)}
{ if (index($fn, p) == 1) $fn = substr($fn, n+1); print }'
}
human() { # bytes -> human
local b=${1:-0}
awk -v b="$b" 'BEGIN{
split("B KB MB GB TB",u," "); i=1;
while (b>=1024 && i<5) { b/=1024; i++ }
printf (i==1 ? "%d %s" : "%.1f %s"), b, u[i]
}'
}
# json_get JSONFILE PHP-EXPR-PATH -- our own PHP, not the site's.
json_get() {
php -d error_reporting=0 -r '
$j = json_decode(file_get_contents($argv[1]), true);
if ($j === null) { exit(1); }
$path = $argv[2] === "" ? [] : explode("/", $argv[2]);
foreach ($path as $k) {
if (is_array($j) && array_key_exists($k, $j)) { $j = $j[$k]; }
else { exit(1); }
}
if (is_array($j)) { foreach ($j as $k=>$v) { if (!is_array($v)) echo $k, "\t", $v, "\n"; } }
else { echo $j, "\n"; }
' "$1" "${2:-}" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
[[ ${EUID:-$(id -u)} -ne 0 ]] || die "Refusing to run as root. su to the site user first: su -s /bin/bash - <cpuser>"
for bin in find stat du df awk sed grep sort head md5sum; do
have "$bin" || die "Missing required command: $bin"
done
have php || die "Missing php CLI. Needed to parse wp-config.php safely and to read JSON."
have curl || have wget || die "Need curl or wget."
HAVE_MYSQL=0; have mysql && HAVE_MYSQL=1
DOCROOT=${DOCROOT/#\~/$HOME}
DOCROOT=$(realpath -e -- "$DOCROOT" 2>/dev/null) || die "Path does not exist: $DOCROOT"
[[ -d $DOCROOT ]] || die "Not a directory: $DOCROOT"
[[ $DOCROOT != "/" ]] || die "Refusing to operate on /"
[[ $DOCROOT != "$HOME" ]] || die "Refusing to operate on \$HOME itself ($HOME)"
[[ ${#DOCROOT} -gt 6 ]] || die "Suspiciously short path, refusing: $DOCROOT"
[[ -f $DOCROOT/wp-includes/version.php ]] \
|| die "Not a WordPress docroot (no wp-includes/version.php): $DOCROOT"
DOCROOT_UID=$(stat -c '%u' -- "$DOCROOT")
# Reading as another user is allowed -- the run is read-only -- but anything the
# site user can see and you cannot is silently missing from the report.
[[ $DOCROOT_UID -eq ${EUID:-$(id -u)} ]] \
|| warn "You do not own $DOCROOT (owned by uid $DOCROOT_UID). Unreadable files will be missing from the report. Run as that user for a complete picture."
PARENT=$(dirname -- "$DOCROOT")
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
# Default under $HOME so the workdir is not a sibling of a nested docroot
# sitting inside public_html (the report must not be web-served).
WORKDIR=${WORKDIR_BASE:-$HOME/wp-xray-$STAMP}
mkdir -p -- "$WORKDIR" || die "Could not create workdir: $WORKDIR"
WORKDIR=$(realpath -e -- "$WORKDIR" 2>/dev/null) || die "Workdir path could not be resolved: ${WORKDIR_BASE:-$HOME/wp-xray-$STAMP}"
chmod 700 -- "$WORKDIR"
# Refuse paths that are or sit under the live docroot.
case $WORKDIR in
"$DOCROOT"|"$DOCROOT"/*)
die "Workdir must not be inside the docroot (refusing $WORKDIR). Use --workdir under \$HOME."
;;
esac
# Nested-docroot case: parent is public_html (etc.) and a sibling workdir would be
# web-served. Also refuse any path with those names as a directory segment.
PARENT_BASE=$(basename -- "$PARENT")
case $PARENT_BASE in
public_html|httpdocs|www|htdocs)
case $WORKDIR in
"$PARENT"|"$PARENT"/*)
die "Workdir $WORKDIR sits under web root $PARENT. Use the default under \$HOME, or --workdir outside $PARENT_BASE."
;;
esac
;;
esac
case /$WORKDIR/ in
*/public_html/*|*/httpdocs/*|*/htdocs/*)
die "Workdir looks web-reachable ($WORKDIR). Choose a path under \$HOME outside public_html/httpdocs/htdocs."
;;
esac
# Belt and braces if the path is somehow still web-served.
printf '%s\n' 'Require all denied' '<IfModule !mod_authz_core.c>' 'Deny from all' '</IfModule>' \
>"$WORKDIR/.htaccess" 2>/dev/null || true
printf '%s\n' 'deny all;' >"$WORKDIR/.nginx.deny" 2>/dev/null || true
# Drop-in nginx snippet: include this from a server block if the tree is ever mapped.
cat >"$WORKDIR/nginx-deny.conf" 2>/dev/null <<'NGX' || true
# Deny HTTP access to this wp-xray workdir (report, database and malware detail).
location ~ /wp-xray-[^/]+ {
deny all;
return 403;
}
NGX
: >"$WORKDIR/index.html" 2>/dev/null || true
REPORT_FILE="$WORKDIR/report.txt"
: >"$REPORT_FILE"
LOG_FILE="$WORKDIR/run.log"
exec 2> >(tee -a "$LOG_FILE" >&2)
TMPDIR_LOCAL="$WORKDIR/tmp"
mkdir -p -- "$TMPDIR_LOCAL"
# Files outside the docroot that execute for this site anyway. They are called
# out separately because every "reinstall the docroot" instinct misses them.
#
# Entries are "path<TAB>reason". A referenced path is recorded whether or not it
# exists right now: an auto_prepend_file pointing at a file the site user cannot
# read, or one the attacker has not dropped yet, is still a live directive, and
# it is the directive -- not the file -- that survives a docroot replacement.
PARENT_PERSISTENCE=()
note_parent_persistence() {
local f=$1 reason=${2:-referenced by site configuration}
[[ -n $f ]] || return 0
case $f in
"$DOCROOT"|"$DOCROOT"/*) return 0 ;; # inside the docroot; reported elsewhere
/*) ;;
*) return 0 ;; # relative/unresolvable -- nothing to point at
esac
local x
for x in ${PARENT_PERSISTENCE[@]+"${PARENT_PERSISTENCE[@]}"}; do
[[ ${x%%$'\t'*} == "$f" ]] && return 0
done
PARENT_PERSISTENCE+=("$f"$'\t'"$reason")
}
# ---------------------------------------------------------------------------
# Exit handling
#
# Nothing outside the workdir is ever written, so there is nothing to roll back.
# ---------------------------------------------------------------------------
cleanup() {
local rc=$?
set +e
[[ $rc -ne 0 ]] && warn "Failed (exit $rc). The docroot and the database were not modified."
rm -rf -- "$TMPDIR_LOCAL" 2>/dev/null
printf '%s\n' "Report: $REPORT_FILE" >&2
printf '%s\n' "Log: $LOG_FILE" >&2
exit $rc
}
trap cleanup EXIT
trap 'warn "Interrupted."; exit 130' INT TERM
# ---------------------------------------------------------------------------
# 1. Inventory
# ---------------------------------------------------------------------------
hdr "wp-xray $VERSION -- $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
rpt "Docroot : $DOCROOT"
rpt "Owner : $(stat -c '%U:%G' -- "$DOCROOT") (uid $DOCROOT_UID)"
rpt "Workdir : $WORKDIR"
rpt "Host : $(hostname 2>/dev/null || echo unknown)"
# Installed WP version -- read statically, no bootstrap.
INSTALLED_VER=$(sed -n "s/^[[:space:]]*\$wp_version[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$DOCROOT/wp-includes/version.php" 2>/dev/null | head -1)
[[ -n $INSTALLED_VER ]] || INSTALLED_VER="unknown"
rpt "WP version : $INSTALLED_VER"
# wp-config.php may legitimately live one level above the docroot.
WPCONFIG=""
if [[ -f $DOCROOT/wp-config.php ]]; then WPCONFIG="$DOCROOT/wp-config.php"
elif [[ -f $PARENT/wp-config.php ]]; then WPCONFIG="$PARENT/wp-config.php"
warn "wp-config.php found ABOVE the docroot: $WPCONFIG"
fi
[[ -n $WPCONFIG ]] || warn "No wp-config.php found. The database audit will be skipped."
rpt "wp-config : ${WPCONFIG:-<not found>}"
# A config above the docroot is outside every "replace the docroot" instinct, and
# it is a live credential store either way.
if [[ -f $PARENT/wp-config.php ]]; then
case $PARENT/wp-config.php in
"$DOCROOT"|"$DOCROOT"/*) ;;
*)
note_parent_persistence "$PARENT/wp-config.php" "wp-config.php above the docroot -- holds live database credentials"
if [[ ${WPCONFIG:-} != "$PARENT/wp-config.php" ]]; then
warn "Additional wp-config.php above the docroot: $PARENT/wp-config.php"
finding high "A wp-config.php exists above the docroot at $PARENT/wp-config.php in addition to the in-docroot config. WordPress prefers the in-docroot file, so this one is unused by the site but still holds database credentials -- and it sits outside anything a docroot replacement would touch. Read it."
fi
;;
esac
fi
if [[ -n ${WPCONFIG:-} ]]; then
case $WPCONFIG in
"$DOCROOT"|"$DOCROOT"/*) ;;
*) note_parent_persistence "$WPCONFIG" "the wp-config.php this site actually loads" ;;
esac
fi
# ---------------------------------------------------------------------------
# 2. Disk, quota, and the big-file problem
# ---------------------------------------------------------------------------
hdr "Disk usage and quota"
info "Sizing the docroot (this is the slow part on large sites)..."
SIZE_BYTES=$(du -sb --one-file-system -- "$DOCROOT" 2>/dev/null | awk '{print $1}' || true)
: "${SIZE_BYTES:=0}"
FILE_COUNT=$(find "$DOCROOT" -xdev -type f 2>/dev/null | wc -l || true)
DIR_COUNT=$(find "$DOCROOT" -xdev -type d 2>/dev/null | wc -l || true)
# wc -l pads with spaces on some systems
FILE_COUNT=${FILE_COUNT// /}
DIR_COUNT=${DIR_COUNT// /}
: "${FILE_COUNT:=0}"
: "${DIR_COUNT:=0}"
rpt "Docroot size : $(human "$SIZE_BYTES")"
rpt "Files / dirs : $FILE_COUNT / $DIR_COUNT"
PATHOLOGICAL_TREE=0
if [[ $FILE_COUNT -gt $FILE_COUNT_WARN || $DIR_COUNT -gt $DIR_COUNT_WARN ]]; then
PATHOLOGICAL_TREE=1
warn "Pathological file/dir count ($FILE_COUNT files, $DIR_COUNT dirs). Deep sorts and scans will be bounded; cache dirs are likely the cause."
finding med "Docroot has $FILE_COUNT files / $DIR_COUNT directories. Counts this high are almost always a cache-plugin inode bomb (or similar). See the cache directory sizes below -- clearing them is usually the whole fix for an inode complaint."
fi
# cPanel/CWP quota. Not reported as a routine line -- a support rep has the
# account's disk usage on screen already. It is read only so the near-full
# warning below can fire, which is the part they cannot see from the panel:
# a quota this close to the limit breaks uploads, updates and mail quietly.
QUOTA_FREE_BYTES=""
if have quota; then
# quota -w output in KB: fs blocks quota limit ...
QLINE=$(quota -w 2>/dev/null | awk 'NR>2 && $1 ~ /\// {print; exit}' || true)
if [[ -n ${QLINE:-} ]]; then
QUSED=$(awk '{gsub(/\*/,"",$2); print $2}' <<<"$QLINE")
QLIMIT=$(awk '{print $4}' <<<"$QLINE")
if [[ ${QLIMIT:-0} =~ ^[0-9]+$ && ${QLIMIT:-0} -gt 0 ]]; then
QUOTA_FREE_BYTES=$(( (QLIMIT - QUSED) * 1024 ))
fi
fi
fi
# Reclaimable junk: backup archives, dumps, logs, core dumps.
info "Locating backup artifacts, core dumps, and oversized files..."
JUNK_FIND=(); for p in "${JUNK_PATTERNS[@]}"; do JUNK_FIND+=( -o -iname "$p" ); done
JUNK_LIST="$TMPDIR_LOCAL/junk.txt"
find "$DOCROOT" -xdev -type f \( "${JUNK_FIND[@]:1}" \) -printf '%s\t%p\n' 2>/dev/null \
| sort -rn >"$JUNK_LIST" || true
JUNK_BYTES=$(awk -F'\t' '{s+=$1} END{print s+0}' "$JUNK_LIST")
JUNK_N=$(wc -l <"$JUNK_LIST"); JUNK_N=${JUNK_N// /}
rpt "Backup/log/core-dump artifacts: $JUNK_N files, $(human "$JUNK_BYTES")"
# The walk always runs, so the list always exists -- empty means none were found.
strip_prefix "$DOCROOT/" 2 <"$JUNK_LIST" >"$WORKDIR/backup-artifacts.txt" 2>/dev/null || true
if [[ $JUNK_N -gt 0 ]]; then
rpt " largest:"
head -10 "$JUNK_LIST" | while IFS=$'\t' read -r sz path; do
rpt " $(human "$sz") ${path#"$DOCROOT"/}"
done
[[ $JUNK_BYTES -gt $((200*1024*1024)) ]] && \
finding low "$(human "$JUNK_BYTES") of backup archives/dumps/logs/core dumps in the docroot. A .sql or .wpress file under the docroot is downloadable by anyone who guesses the name. Full list: $WORKDIR/backup-artifacts.txt"
fi
# Cache / optimizer / log dirs: disposable, often most of the bulk and inodes.
# Only top-level under wp-content (plus uploads/cache) -- a recursive -name cache
# would double-count children inside wp-content/cache and flag tiny plugin caches.
CACHE_BYTES=0
CACHE_FILES=0
CACHE_FOUND=()
declare -A CACHE_SZ=() CACHE_FC=()
WPC_EARLY="$DOCROOT/wp-content"
UPLOADS_EARLY="$WPC_EARLY/uploads"
if [[ -d $WPC_EARLY ]]; then
for n in "${CACHE_DIR_NAMES[@]}"; do
[[ -d $WPC_EARLY/$n ]] && CACHE_FOUND+=("wp-content/$n")
done
[[ -d $UPLOADS_EARLY/cache ]] && CACHE_FOUND+=("wp-content/uploads/cache")
fi
rpt ""
if [[ ${#CACHE_FOUND[@]} -eq 0 ]]; then
rpt "Cache/optimizer dirs: none of ${CACHE_DIR_NAMES[*]} / uploads/cache"
else
for rel in "${CACHE_FOUND[@]}"; do
cdir="$DOCROOT/$rel"
csz=$(du -sb --one-file-system -- "$cdir" 2>/dev/null | awk '{print $1}' || true)
: "${csz:=0}"
cfc=$(find "$cdir" -xdev -type f 2>/dev/null | wc -l || true); cfc=${cfc// /}
: "${cfc:=0}"
CACHE_SZ[$rel]=$csz
CACHE_FC[$rel]=$cfc
CACHE_BYTES=$((CACHE_BYTES + csz))
CACHE_FILES=$((CACHE_FILES + cfc))
done
rpt "Cache/optimizer dirs: ${#CACHE_FOUND[@]} path(s), $(human "$CACHE_BYTES"), ~$CACHE_FILES files"
for rel in "${CACHE_FOUND[@]}"; do
rpt " $rel ($(human "${CACHE_SZ[$rel]}"), ${CACHE_FC[$rel]} files)"
done
[[ $CACHE_FILES -gt 50000 || $CACHE_BYTES -gt $((500*1024*1024)) ]] && \
finding med "$(human "$CACHE_BYTES") / ${CACHE_FILES} files in cache-like directories (${CACHE_FOUND[*]}). Regenerated content, safe to clear, and usually the entire answer to a quota or inode complaint. They also dominate the scan time of this report."
fi
# Largest files -- bound the walk when the tree is an inode bomb.
rpt ""
rpt "Largest files in docroot:"
if [[ $PATHOLOGICAL_TREE -eq 1 ]]; then
rpt " (bounded: only files larger than 50 MB -- full sort skipped on pathological trees)"
find "$DOCROOT" -xdev -type f -size +50M -printf '%s\t%p\n' 2>/dev/null | sort -rn | head -15 \
| while IFS=$'\t' read -r sz path; do rpt " $(human "$sz") ${path#"$DOCROOT"/}"; done || true
else
find "$DOCROOT" -xdev -type f -printf '%s\t%p\n' 2>/dev/null | sort -rn | head -10 \
| while IFS=$'\t' read -r sz path; do rpt " $(human "$sz") ${path#"$DOCROOT"/}"; done || true
fi
rpt ""
rpt "Largest directories:"
du -xb --max-depth=2 -- "$DOCROOT" 2>/dev/null | sort -rn | sed -n '2,11p' \
| while read -r sz path; do rpt " $(human "$sz") ${path#"$DOCROOT"/}"; done || true
# How much of the bulk is media the site actually needs, and how much is not.
UPLOADS_BYTES=0
if [[ -d $UPLOADS_EARLY ]]; then
UPLOADS_BYTES=$(du -sb --one-file-system -- "$UPLOADS_EARLY" 2>/dev/null | awk '{print $1}' || true)
: "${UPLOADS_BYTES:=0}"
fi
UPLOAD_JUNK_BYTES=$(awk -F'\t' -v u="$UPLOADS_EARLY/" '
index($2, u) == 1 { s += $1 }
END { print s+0 }
' "$JUNK_LIST")
# Cache under uploads (e.g. uploads/cache) is counted inside UPLOADS_BYTES.
UPLOAD_CACHE_BYTES=0
for rel in ${CACHE_FOUND[@]+"${CACHE_FOUND[@]}"}; do
case $rel in
wp-content/uploads/*) UPLOAD_CACHE_BYTES=$((UPLOAD_CACHE_BYTES + ${CACHE_SZ[$rel]:-0})) ;;
esac
done
RECLAIMABLE_BYTES=$(( JUNK_BYTES + CACHE_BYTES ))
rpt ""
rpt "Uploads size : $(human "$UPLOADS_BYTES") (junk under uploads: $(human "$UPLOAD_JUNK_BYTES"); cache under uploads: $(human "$UPLOAD_CACHE_BYTES"))"
rpt "Disposable bulk : $(human "$RECLAIMABLE_BYTES") (backup artifacts + cache dirs)"
if [[ -n $QUOTA_FREE_BYTES && $QUOTA_FREE_BYTES -lt $((500*1024*1024)) ]]; then
warn "Account quota is nearly full ($(human "$QUOTA_FREE_BYTES") free)."
finding med "Account quota has only $(human "$QUOTA_FREE_BYTES") free. $(human "$RECLAIMABLE_BYTES") of that is backup artifacts and cache directories listed above. A full quota breaks uploads, updates and mail before it breaks anything obvious."
fi
# ---------------------------------------------------------------------------
# 3. wp-config.php -- parsed with the tokenizer, never executed
# ---------------------------------------------------------------------------
hdr "wp-config.php"
declare -A WPCFG=()
# Kept in the workdir, not the temp dir: the non-standard directives are the part
# of the config a tech reads line by line, and the report truncates nothing here.
CFG_EXTRA="$WORKDIR/wpconfig-nonstandard.txt"
: >"$CFG_EXTRA"
parse_wpconfig() {
# token_get_all() lexes PHP source into tokens. It does not run it. This is
# the only safe way to read credentials out of a file that may be trojaned.
php -d error_reporting=0 -r '
$src = @file_get_contents($argv[1]);
if ($src === false) exit(1);
$t = @token_get_all($src);
if (!$t) exit(1);
// Backslashes here pass through bash single quotes AND the PHP lexer, so
// literal escape sequences are far too easy to get wrong by one level.
// chr() removes all ambiguity: 92 = backslash, 39 = single quote.
function unq($s) {
$bs = chr(92); $sq = chr(39);
$q = $s[0];
$inner = substr($s, 1, -1);
// Single-quoted PHP strings only recognise \\ and \" as escapes.
if ($q === $sq) return str_replace([$bs.$bs, $bs.$sq], [$bs, $sq], $inner);
return stripcslashes($inner);
}
// Flatten to a list of [id, text], dropping whitespace and comments.
$f = [];
foreach ($t as $tok) {
if (is_array($tok)) {
if (in_array($tok[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) continue;
$f[] = [$tok[0], $tok[1]];
} else {
$f[] = [-1, $tok];
}
}
$n = count($f);
for ($i = 0; $i < $n; $i++) {
// define("NAME", "value") -- only accept a single literal value token
// immediately closed by ")". Concatenation, getenv(), variables, etc. are
// reported as non-literal rather than guessed at.
if ($f[$i][0] === T_STRING && strtolower($f[$i][1]) === "define"
&& isset($f[$i+4]) && $f[$i+1][1] === "(" && $f[$i+3][1] === ","
&& $f[$i+2][0] === T_CONSTANT_ENCAPSED_STRING) {
$name = unq($f[$i+2][1]);
$vtok = $f[$i+4];
$closed = isset($f[$i+5]) && $f[$i+5][1] === ")";
if (!$closed) {
echo "C\t", $name, "\t\n";
} elseif ($vtok[0] === T_CONSTANT_ENCAPSED_STRING) {
echo "D\t", $name, "\t", base64_encode(unq($vtok[1])), "\n";
} elseif ($vtok[0] === T_LNUMBER || $vtok[0] === T_DNUMBER) {
echo "D\t", $name, "\t", base64_encode($vtok[1]), "\n";
} elseif ($vtok[0] === T_STRING
&& in_array(strtolower($vtok[1]), ["true","false","null"], true)) {
echo "D\t", $name, "\t", base64_encode($vtok[1]), "\n";
} else {
echo "C\t", $name, "\t\n";
}
}
// $table_prefix = "wp_"
if ($f[$i][0] === T_VARIABLE && $f[$i][1] === "\$table_prefix"
&& isset($f[$i+2]) && $f[$i+1][1] === "=") {
if ($f[$i+2][0] === T_CONSTANT_ENCAPSED_STRING
&& isset($f[$i+3]) && $f[$i+3][1] === ";") {
echo "P\ttable_prefix\t", base64_encode(unq($f[$i+2][1])), "\n";
} else {
echo "C\ttable_prefix\t\n";
}
}
// Anything that executes at include time is a red flag in a config file.
if ($f[$i][0] === T_STRING
&& in_array(strtolower($f[$i][1]), ["eval","assert","base64_decode","gzinflate",
"gzuncompress","str_rot13","create_function","system","shell_exec","passthru",
"proc_open","popen","file_get_contents","curl_exec","fsockopen","preg_replace"], true)) {
echo "S\t", strtolower($f[$i][1]), "\t\n";
}
if (in_array($f[$i][0], [T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE], true)) {
// Reassemble the argument until ";" so we report what is actually being
// pulled in, and so the stock "require_once ABSPATH . \x27wp-settings.php\x27"
// does not show up as a finding on every single site.
$arg = "";
for ($j = $i + 1; $j < $n && $f[$j][1] !== ";"; $j++) { $arg .= $f[$j][1]; }
$arg = trim($arg, "() ");
$norm = preg_replace("/\s+/", "", $arg);
if ($norm === "ABSPATH.\x27wp-settings.php\x27" || $norm === "ABSPATH.\"wp-settings.php\"") continue;
echo "S\tinclude:", $arg, "\t\n";
}
}
' "$1" 2>/dev/null
}
CFG_SUSPECT=()
CFG_COMPLEX=()
if [[ -n $WPCONFIG ]]; then
while IFS=$'\t' read -r kind key val; do
[[ -z ${kind:-} ]] && continue
case $kind in
D|P)
decoded=$(printf '%s' "$val" | base64 -d 2>/dev/null || true)
WPCFG[$key]=$decoded
;;
C) CFG_COMPLEX+=("$key") ;;
S) CFG_SUSPECT+=("$key")
# An include of a literal absolute path is the classic wp-config
# backdoor: one line in the config, payload parked in a dotfile
# somewhere the customer never looks. Record what it points at.
case $key in
include:*)
inc=${key#include:}
inc=${inc%\"}; inc=${inc#\"}; inc=${inc%\'}; inc=${inc#\'}
note_parent_persistence "$inc" "included by wp-config.php on every request"
;;
esac
;;
esac
done < <(parse_wpconfig "$WPCONFIG")
if [[ ${#WPCFG[@]} -eq 0 && ${#CFG_COMPLEX[@]} -eq 0 ]]; then
warn "Could not tokenize wp-config.php -- it may be obfuscated or truncated."
finding high "wp-config.php could not be parsed by the PHP tokenizer. Inspect it by hand before doing anything else."
fi
# ABSPATH is defined as __DIR__ . '/' by stock WordPress itself, so it is
# non-literal on every healthy site on earth. Reporting it as HIGH put a
# false positive at the top of every single report, which is the fastest way
# to teach a tech that finding #1 is noise worth skipping.
EXPECTED_NONLITERAL=" ABSPATH "
if [[ ${#CFG_COMPLEX[@]} -gt 0 ]]; then
uniq_complex=$(printf '%s\n' "${CFG_COMPLEX[@]}" | sort -u | tr '\n' ' ')
real_complex=""
for k in $uniq_complex; do
[[ $EXPECTED_NONLITERAL == *" $k "* ]] && continue
real_complex+="$k "
done
rpt ""
rpt "Non-literal defines (value not resolved): $uniq_complex"
[[ -n $real_complex && $real_complex != "$uniq_complex" ]] && \
rpt " (ABSPATH is non-literal in stock WordPress and is expected here)"
if [[ -n $real_complex ]]; then
finding high "wp-config.php has non-literal value(s) for: $real_complex. This script lexes the file rather than running it, so getenv()/concatenation/variables are reported, never evaluated. Read those lines yourself -- the database audit below is skipped if DB credentials are among them."
fi
fi
for k in DB_NAME DB_USER DB_HOST table_prefix; do
if [[ ${#CFG_COMPLEX[@]} -gt 0 ]] && printf '%s\n' "${CFG_COMPLEX[@]}" | grep -qxF "$k"; then
rpt "$(printf '%-16s: %s' "$k" "<non-literal -- not resolved>")"
else
rpt "$(printf '%-16s: %s' "$k" "${WPCFG[$k]:-<missing>}")"
fi
done
# Distinguish "define exists but is an empty string" from "no define at all";
# the first is a legitimate (if alarming) config, the second means the parse failed.
if [[ ${#CFG_COMPLEX[@]} -gt 0 ]] && printf '%s\n' "${CFG_COMPLEX[@]}" | grep -qxF "DB_PASSWORD"; then
PWSTATE="<non-literal -- not resolved>"
elif [[ ${WPCFG[DB_PASSWORD]+set} == set ]]; then
# Length is not printed. report.txt gets pasted into tickets, and the
# character count of a live database password is free reconnaissance for
# anyone reading the thread later.
if [[ -n ${WPCFG[DB_PASSWORD]} ]]; then PWSTATE="<found>"
else PWSTATE="<defined but EMPTY>"; finding med "DB_PASSWORD is defined as an empty string in wp-config.php."; fi
else
PWSTATE="<not found>"
fi
rpt "$(printf '%-16s: %s' "DB_PASSWORD" "$PWSTATE")"
# Everything that is NOT a stock define gets surfaced for a human.
STOCK="DB_NAME DB_USER DB_PASSWORD DB_HOST DB_CHARSET DB_COLLATE table_prefix \
AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT \
LOGGED_IN_SALT NONCE_SALT WP_DEBUG ABSPATH WP_DEBUG_LOG WP_DEBUG_DISPLAY"
rpt ""
rpt "Non-standard directives in wp-config.php (review each):"
FOUND_EXTRA=0
for k in "${!WPCFG[@]}"; do
[[ " $STOCK " == *" $k "* ]] && continue
FOUND_EXTRA=1
rpt " $k = ${WPCFG[$k]}"
printf '%s=%s\n' "$k" "${WPCFG[$k]}" >>"$CFG_EXTRA"
case $k in
WP_CONTENT_DIR|WP_CONTENT_URL|WP_PLUGIN_DIR|WP_PLUGIN_URL|WPMU_PLUGIN_DIR)
finding high "wp-config.php redefines $k = ${WPCFG[$k]} -- plugins, themes or uploads may load from outside the docroot. Nothing in this report covers that path: scan it separately."
case $k in
WP_CONTENT_DIR|WP_PLUGIN_DIR|WPMU_PLUGIN_DIR)
note_parent_persistence "${WPCFG[$k]}" "$k override -- site loads plugin/theme code from here" ;;
esac ;;
DISALLOW_FILE_MODS|DISALLOW_FILE_EDIT)
: ;;
*) : ;;
esac
done
[[ $FOUND_EXTRA -eq 0 ]] && rpt " (none)"
[[ $FOUND_EXTRA -eq 1 ]] && rpt " (also written to $CFG_EXTRA)"
if [[ ${#CFG_SUSPECT[@]} -gt 0 ]]; then
uniq_susp=$(printf '%s\n' "${CFG_SUSPECT[@]}" | sort -u | tr '\n' ' ')
rpt ""
rpt "Code-execution constructs in wp-config.php: $uniq_susp"
finding high "wp-config.php contains executable constructs ($uniq_susp). This file runs on every request and on every wp-cli invocation."
fi
# Anything after the "stop editing" marker is a classic injection point.
if [[ -f $WPCONFIG ]]; then
# Note: no "--" here. mawk (Debian/Ubuntu default awk) does not accept it,
# and the path is absolute from realpath so it cannot look like an option.
TAIL_AFTER=$(awk '/stop editing|Happy publishing|Happy blogging/{found=1; next} found' "$WPCONFIG" \
| grep -vE "^\s*($|//|#|/\*|\*)" | grep -vE "ABSPATH|wp-settings\.php|define\(\s*'ABSPATH'" | head -20 || true)
if [[ -n ${TAIL_AFTER:-} ]]; then
rpt ""
rpt "Code after the 'stop editing' marker:"
printf '%s\n' "$TAIL_AFTER" | while IFS= read -r l; do rpt " $l"; done
finding high "wp-config.php has code appended after the 'stop editing' marker -- a very common injection location."
fi
fi
fi
# Workdir invariant: a list file exists iff the step that fills it ran. Empty
# here means "parsed the config, found nothing non-stock". No file at all means
# there was no wp-config.php to parse, which is a different answer.
[[ -n $WPCONFIG ]] || rm -f -- "$CFG_EXTRA"
# ---------------------------------------------------------------------------
# 4. Server config: .htaccess, php.ini, .user.ini
# ---------------------------------------------------------------------------
hdr "Server configuration files"
# The value of an auto_prepend_file/auto_append_file directive, unquoted. This
# is the payload path -- the directive is worthless to an attacker without it,
# and it is almost always outside the docroot, where nothing else here looks.
prepend_targets() {
sed -nE 's/^[^#;]*(auto_prepend_file|auto_append_file)[[:space:]]*=[[:space:]]*//Ip' -- "$1" 2>/dev/null \
| sed -E 's/^"([^"]*)".*/\1/; s/^'"'"'([^'"'"']*)'"'"'.*/\1/; s/[[:space:]]*$//' \
| grep -v '^$' || true
}
scan_ini() {
local f=$1
[[ -f $f ]] || return 0
rpt " $f"
local hits t
hits=$(grep -inE 'auto_prepend_file|auto_append_file|include_path|extension\s*=|open_basedir|disable_functions' -- "$f" 2>/dev/null || true)
if [[ -n $hits ]]; then
printf '%s\n' "$hits" | while IFS= read -r l; do rpt " $l"; done
if grep -qiE 'auto_prepend_file|auto_append_file' -- "$f" 2>/dev/null; then
finding high "$f sets auto_prepend_file/auto_append_file -- this executes arbitrary PHP on every request without touching any WordPress file. Read the file it points at."
# An ini above the docroot is outside everything else this report walks.
note_parent_persistence "$f" "sets auto_prepend_file/auto_append_file"
while IFS= read -r t; do
[[ -n $t ]] || continue
note_parent_persistence "$t" "auto_prepend/append payload loaded by ${f#"$DOCROOT"/}"
done < <(prepend_targets "$f")
fi
fi
}
for ini in "$DOCROOT/php.ini" "$DOCROOT/.user.ini" "$PARENT/php.ini" "$PARENT/.user.ini"; do
scan_ini "$ini"
done
# Nested .user.ini anywhere in the tree is almost never legitimate.
while IFS= read -r f; do
[[ $f == "$DOCROOT/.user.ini" ]] && continue
rpt " nested: $f"
finding high "Nested .user.ini at $f -- PHP-FPM reads these per-directory; a common persistence trick."
scan_ini "$f"
done < <(find "$DOCROOT" -xdev -name '.user.ini' -type f 2>/dev/null)
HTACCESS_COUNT=$(find "$DOCROOT" -xdev -name '.htaccess' -type f 2>/dev/null | wc -l || true)
rpt ""
rpt ".htaccess files in tree: $HTACCESS_COUNT"
if [[ -f $DOCROOT/.htaccess ]]; then
# Everything outside the standard WordPress block is worth a look.
# Whitespace in the stock block varies between WordPress versions and hosts,
# so match loosely on the shape of each stock line rather than exact text.
NONSTD=$(grep -vnE '^[[:space:]]*(#|$)' -- "$DOCROOT/.htaccess" \
| grep -viE 'BEGIN WordPress|END WordPress|RewriteEngine|RewriteBase|RewriteRule[[:space:]]+\^index\\?\.php\$|RewriteCond[[:space:]]+%\{REQUEST_FILENAME\}[[:space:]]+!-[fd]|RewriteRule[[:space:]]+\.[[:space:]]+/?index\.php|RewriteRule[[:space:]]+\.\*[[:space:]]+-[[:space:]]+\[E=HTTP_AUTHORIZATION|</?IfModule' \
|| true)
if [[ -n ${NONSTD:-} ]]; then
rpt "Non-standard directives in docroot .htaccess:"
printf '%s\n' "$NONSTD" | head -40 | while IFS= read -r l; do rpt " $l"; done || true
finding med "Docroot .htaccess contains directives beyond the stock WordPress rewrite block. The non-standard lines are listed above with their line numbers."
fi
if grep -qiE 'php_value\s+auto_prepend|AddHandler|AddType.*php|SetHandler|ErrorDocument\s+40[0-9]\s+http' -- "$DOCROOT/.htaccess" 2>/dev/null; then
finding high "Docroot .htaccess changes PHP handlers or auto_prepend, or redirects error pages offsite."
fi
fi
# Every .htaccess in the tree, not just the docroot one and uploads. Apache reads
# them per-directory, so a handler override three levels down works exactly as
# well as one at the root -- and sits in a directory no reinstall checklist names.
# Only handler/prepend tricks are reported here; hardening rules (Deny from all,
# the block plugins ship) are silent, or this fires on every healthy site.
HTA_PHP_RE='php_value[[:space:]]+auto_(prepend|append)|php_admin_value[[:space:]]+auto_(prepend|append)|Add(Handler|Type)[[:space:]]+[^[:space:]]*php[^[:space:]]*[[:space:]]|SetHandler[[:space:]]+[^[:space:]]*php|php_flag[[:space:]]+engine[[:space:]]+on'
while IFS= read -r f; do
[[ -n $f ]] || continue
rel=${f#"$DOCROOT"/}
in_uploads=0
case $rel in wp-content/uploads/*|wp-content/uploads) in_uploads=1 ;; esac
[[ $f == "$DOCROOT/.htaccess" ]] && continue # reported above
if [[ $in_uploads -eq 1 ]]; then
rpt " uploads .htaccess: $rel"
fi
if grep -qiE "$HTA_PHP_RE" -- "$f" 2>/dev/null; then
if [[ $in_uploads -eq 1 ]]; then
finding high "$rel re-enables PHP execution inside uploads."
else
rpt " nested .htaccess with a PHP handler/prepend directive: $rel"
grep -inE "$HTA_PHP_RE" -- "$f" 2>/dev/null | head -10 \
| while IFS= read -r l; do rpt " $l"; done || true
finding high "$rel maps files to the PHP handler or sets auto_prepend. Apache applies it to that directory and everything under it, so it grants code execution without any WordPress file being touched."
fi
while IFS= read -r t; do
[[ -n $t ]] || continue
note_parent_persistence "$t" "auto_prepend/append payload loaded by $rel"
done < <(sed -nE 's/^[^#]*php(_admin)?_value[[:space:]]+auto_(prepend|append)_file[[:space:]]+//Ip' -- "$f" 2>/dev/null \
| sed -E 's/^"([^"]*)".*/\1/; s/^'"'"'([^'"'"']*)'"'"'.*/\1/; s/[[:space:]]*$//' | grep -v '^$' || true)
fi
done < <(find "$DOCROOT" -xdev -name '.htaccess' -type f 2>/dev/null || true)
# The docroot .htaccess is handled above, but its prepend target still counts.
if [[ -f $DOCROOT/.htaccess ]]; then
while IFS= read -r t; do
[[ -n $t ]] || continue
note_parent_persistence "$t" "auto_prepend/append payload loaded by the docroot .htaccess"
done < <(sed -nE 's/^[^#]*php(_admin)?_value[[:space:]]+auto_(prepend|append)_file[[:space:]]+//Ip' -- "$DOCROOT/.htaccess" 2>/dev/null \
| sed -E 's/^"([^"]*)".*/\1/; s/^'"'"'([^'"'"']*)'"'"'.*/\1/; s/[[:space:]]*$//' | grep -v '^$' || true)
fi
# ---------------------------------------------------------------------------
# 5. Core integrity -- verified against wordpress.org, statically
# ---------------------------------------------------------------------------
CORE_MODIFIED=(); CORE_MISSING=(); CORE_EXTRA=()
CORE_CHECKSUMS_OK=0
CORE_SKIP_REASON=""
if [[ $SKIP_CORE_VERIFY -eq 0 && $INSTALLED_VER != "unknown" ]]; then
hdr "Core integrity (WordPress $INSTALLED_VER)"
# Locale affects which files wordpress.org lists. Prefer the packaged locale
# from version.php, then WPLANG from wp-config, then en_US.
WP_LOCALE="en_US"
_lp=$(sed -n "s/^[[:space:]]*\$wp_local_package[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$DOCROOT/wp-includes/version.php" 2>/dev/null | head -1)
[[ -n ${_lp:-} ]] && WP_LOCALE=$_lp
if [[ -n ${WPCFG[WPLANG]:-} ]]; then
WP_LOCALE=${WPCFG[WPLANG]}
fi
if [[ ! $WP_LOCALE =~ ^[A-Za-z]{2,3}([_-][A-Za-z0-9]+)*$ ]]; then
warn "Unrecognised locale '$WP_LOCALE'; using en_US for checksums."
WP_LOCALE="en_US"
fi
rpt "Checksum locale: $WP_LOCALE"
info "Fetching checksum manifest from wordpress.org (locale=$WP_LOCALE)..."
CK_JSON="$TMPDIR_LOCAL/checksums.json"
if http_get "https://api.wordpress.org/core/checksums/1.0/?version=${INSTALLED_VER}&locale=${WP_LOCALE}" "$CK_JSON" 2>/dev/null; then
CK_LIST="$TMPDIR_LOCAL/checksums.txt"
# The API has shipped two shapes over the years: {"checksums":{file:md5}} and
# {"checksums":{"<version>":{file:md5}}}. Accept either.
json_get "$CK_JSON" "checksums/$INSTALLED_VER" >"$CK_LIST" || true
[[ -s $CK_LIST ]] || json_get "$CK_JSON" "checksums" >"$CK_LIST" || true
# If a non-en_US locale returned nothing useful, retry en_US once.
if [[ ! -s $CK_LIST && $WP_LOCALE != "en_US" ]]; then
warn "No checksums for locale $WP_LOCALE; retrying en_US."
if http_get "https://api.wordpress.org/core/checksums/1.0/?version=${INSTALLED_VER}&locale=en_US" "$CK_JSON" 2>/dev/null; then
json_get "$CK_JSON" "checksums/$INSTALLED_VER" >"$CK_LIST" || true
[[ -s $CK_LIST ]] || json_get "$CK_JSON" "checksums" >"$CK_LIST" || true
WP_LOCALE="en_US"
fi
fi
if [[ -s $CK_LIST ]]; then
CORE_CHECKSUMS_OK=1
info "Hashing core files (this compares the HACKED tree, which is the check that matters)..."
MOD_F="$TMPDIR_LOCAL/core-modified.txt"; : >"$MOD_F"
MISS_F="$TMPDIR_LOCAL/core-missing.txt"; : >"$MISS_F"
while IFS=$'\t' read -r relpath md5; do
[[ -z ${relpath:-} ]] && continue
# wp-content is user data; wordpress.org ships stock themes we do not care about here.
[[ $relpath == wp-content/* ]] && continue
f="$DOCROOT/$relpath"
if [[ ! -f $f ]]; then
printf '%s\n' "$relpath" >>"$MISS_F"
continue
fi
actual=$(md5sum -- "$f" 2>/dev/null | awk '{print $1}')
[[ $actual == "$md5" ]] || printf '%s\n' "$relpath" >>"$MOD_F"
done <"$CK_LIST"
mapfile -t CORE_MODIFIED <"$MOD_F" || true
mapfile -t CORE_MISSING <"$MISS_F" || true
rpt "Modified core files: ${#CORE_MODIFIED[@]}"
if [[ ${#CORE_MODIFIED[@]} -gt 0 ]]; then
printf '%s\n' "${CORE_MODIFIED[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#CORE_MODIFIED[@]} WordPress core files do not match wordpress.org checksums for $INSTALLED_VER ($WP_LOCALE)."
fi
rpt "Missing core files: ${#CORE_MISSING[@]}"
if [[ ${#CORE_MISSING[@]} -gt 0 ]]; then
printf '%s\n' "${CORE_MISSING[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#CORE_MISSING[@]} WordPress core files from the $INSTALLED_VER manifest are absent from the docroot."
fi
# Files present in wp-admin/wp-includes that upstream does not ship, plus
# executable-looking files sitting in the docroot root (classic webshell drop).
EX_F="$TMPDIR_LOCAL/core-extra.txt"; : >"$EX_F"
KNOWN="$TMPDIR_LOCAL/known.txt"
awk -F'\t' '{print $1}' "$CK_LIST" | sort >"$KNOWN"
for d in wp-admin wp-includes; do
[[ -d $DOCROOT/$d ]] || continue
find "$DOCROOT/$d" -xdev -type f -printf '%P\n' 2>/dev/null \
| sed "s|^|$d/|" | sort > "$TMPDIR_LOCAL/present-$d.txt"
comm -23 "$TMPDIR_LOCAL/present-$d.txt" "$KNOWN" >>"$EX_F" || true
done
# Docroot-root only (maxdepth 1). favicon.ico / google*.html are noise;
# executable extensions and PHP-ish names are the signal.
ROOT_EXEC_FIND=()
for e in "${EXEC_EXTENSIONS[@]}"; do ROOT_EXEC_FIND+=( -o -iname "*.$e" ); done
find "$DOCROOT" -xdev -maxdepth 1 -type f \( "${ROOT_EXEC_FIND[@]:1}" -o -iname '*.php*' \) \
-printf '%f\n' 2>/dev/null | sort >"$TMPDIR_LOCAL/present-root-exec.txt" || true
# wp-config.php is expected and is not in the upstream checksum manifest.
if [[ -s $TMPDIR_LOCAL/present-root-exec.txt ]]; then
grep -vxF 'wp-config.php' "$TMPDIR_LOCAL/present-root-exec.txt" \
| comm -23 - "$KNOWN" >>"$EX_F" || true
fi
mapfile -t CORE_EXTRA <"$EX_F" || true
rpt "Unexpected files (wp-admin/wp-includes/docroot-root executables): ${#CORE_EXTRA[@]}"
if [[ ${#CORE_EXTRA[@]} -gt 0 ]]; then
printf '%s\n' "${CORE_EXTRA[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#CORE_EXTRA[@]} unexpected file(s) in wp-admin, wp-includes, or the docroot root that upstream WordPress does not ship. Root-level drops (radio.php, wp-tmp.php, …) are a common blind spot for core-only checks."
fi
else
warn "Checksum manifest for $INSTALLED_VER unavailable (version may be too old)."
CORE_SKIP_REASON="wordpress.org publishes no checksum manifest for $INSTALLED_VER (the version is too old, or version.php has been altered)"
fi
else
warn "Could not reach api.wordpress.org for checksums."
CORE_SKIP_REASON="api.wordpress.org could not be reached to fetch the $INSTALLED_VER manifest"
fi
fi
# When checksums did not run, still surface executable drops at the docroot root —
# these are invisible to the wp-content pattern scan.
if [[ $CORE_CHECKSUMS_OK -eq 0 ]]; then
# Silence here is not a clean bill of health, and on an incident ticket that
# distinction is the whole ballgame. Say plainly that core was never checked.
if [[ $SKIP_CORE_VERIFY -eq 1 ]]; then
CORE_SKIP_REASON=${CORE_SKIP_REASON:-"--no-core-verify was passed"}
elif [[ $INSTALLED_VER == "unknown" ]]; then
CORE_SKIP_REASON=${CORE_SKIP_REASON:-"the WordPress version could not be read from wp-includes/version.php"}
fi
finding med "WordPress core was NOT verified against wordpress.org: ${CORE_SKIP_REASON:-checksum verification did not run}. Modified, missing and planted files under wp-admin/ and wp-includes/ are therefore unknown for this run -- the absence of core findings above means nothing. Re-run with core verification once possible."
hdr "Docroot-root executable files"
STOCK_ROOT_PHP="index.php wp-activate.php wp-blog-header.php wp-comments-post.php wp-config-sample.php wp-cron.php wp-links-opml.php wp-load.php wp-login.php wp-mail.php wp-settings.php wp-signup.php wp-trackback.php xmlrpc.php"
ROOT_SUSPECT=()
while IFS= read -r rf; do
[[ -n $rf ]] || continue
base=$rf
# wp-config.php is expected; handled elsewhere.
[[ $base == "wp-config.php" ]] && continue
[[ " $STOCK_ROOT_PHP " == *" $base "* ]] && continue
ROOT_SUSPECT+=("$base")
done < <(find "$DOCROOT" -xdev -maxdepth 1 -type f \( -iname '*.php' -o -iname '*.php*' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.cgi' -o -iname '*.pl' \) -printf '%f\n' 2>/dev/null | sort -u || true)
if [[ ${#ROOT_SUSPECT[@]} -gt 0 ]]; then
rpt "Unexpected executable-looking files in docroot root: ${#ROOT_SUSPECT[@]}"
printf '%s\n' "${ROOT_SUSPECT[@]}" | head -30 | while IFS= read -r l; do rpt " $l"; done || true
finding high "${#ROOT_SUSPECT[@]} unexpected executable-looking file(s) in the docroot root (${ROOT_SUSPECT[*]}). Core checksum verify was skipped or unavailable; these would not appear in a wp-content scan."
else
rpt "None beyond the stock WordPress root PHP set."
fi
fi
# ---------------------------------------------------------------------------
# 6. wp-content: mu-plugins, drop-ins, plugins, themes, uploads
# ---------------------------------------------------------------------------
WPC="$DOCROOT/wp-content"
[[ -d $WPC ]] || warn "No wp-content directory at $WPC"
# ---------------------------------------------------------------------------
# 5b. Other WordPress installations inside the docroot
#
# On cPanel/CWP these are routine: addon and subdomain docroots live under
# public_html, and abandoned "blog/", "old/", "staging/" copies accumulate for
# years. An unmaintained nested install is one of the most common entry points:
# the main site gets cleaned, the forgotten WordPress 4.9 in /blog keeps its
# vulnerable plugin, and the site is reinfected within days.
#
# This report covers the target docroot. Each nested install needs its own run.
#
# A WordPress tree under wp-content/uploads is a separate matter: there is no
# legitimate reason for one to be there.
# ---------------------------------------------------------------------------
hdr "Other WordPress installations inside this docroot"
NESTED_PATHS=() # relative to $DOCROOT
while IFS= read -r vf; do
[[ -n $vf ]] || continue
ndir=${vf%/wp-includes/version.php}
[[ $ndir == "$DOCROOT" ]] && continue # the target install itself
NESTED_PATHS+=("${ndir#"$DOCROOT"/}")
done < <(find "$DOCROOT" -xdev -type f -path '*/wp-includes/version.php' 2>/dev/null | sort || true)
if [[ ${#NESTED_PATHS[@]} -eq 0 ]]; then
rpt "None found. This docroot contains a single WordPress installation."
else
rpt "Found ${#NESTED_PATHS[@]} additional WordPress installation(s) below the target docroot."
rpt "Only the pattern scan below covers them. Run this script against each one."
rpt ""
for np in "${NESTED_PATHS[@]}"; do
nfull="$DOCROOT/$np"
nver=$(sed -n "s/^[[:space:]]*\$wp_version[[:space:]]*=[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$nfull/wp-includes/version.php" 2>/dev/null | head -1)
: "${nver:=unknown}"
nsize=$(du -sb --one-file-system -- "$nfull" 2>/dev/null | awk '{print $1}' || true)
nfiles=$(find "$nfull" -xdev -type f 2>/dev/null | wc -l || true)
ncfg="no"; [[ -f $nfull/wp-config.php ]] && ncfg="yes"
nmtime=$(stat -c '%y' -- "$nfull" 2>/dev/null | cut -d. -f1)
# Full path, not the docroot-relative one. This is the line a rep copies to
# start the next run, and a relative path makes them reconstruct it by hand.
rpt " $nfull"
rpt " version $nver | $(human "${nsize:-0}") | ${nfiles:-0} files | own wp-config: $ncfg | mtime $nmtime"
# Its own wp-config means its own database, which this run never reads.
if [[ $ncfg == "yes" ]]; then
ndb=$(sed -n "s/.*define([[:space:]]*['\"]DB_NAME['\"][[:space:]]*,[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" \
-- "$nfull/wp-config.php" 2>/dev/null | head -1)
[[ -n ${ndb:-} ]] && rpt " database: $ndb (not audited by this run)"
fi
if [[ $np == wp-content/uploads/* || $np == */uploads/* ]]; then
finding high "A WordPress installation exists inside uploads at $np (version $nver). There is no legitimate reason for one to be there -- it is either an attacker's staged copy or an old migration dump left web-accessible."
else
# Anything on a branch older than the current one is unmaintained by definition.
nmajor=${nver%%.*}
if [[ $nver != "unknown" && $nmajor =~ ^[0-9]+$ && $nmajor -lt 6 ]]; then
finding high "Nested WordPress installation at $np is version $nver -- long unmaintained and a prime candidate for the original entry point. Cleaning the parent site will not help if this is how they got in."
else
finding med "Nested WordPress installation at $np (version $nver) is outside this report except for the pattern scan. If it is an addon or subdomain docroot, run this script against it too."
fi
fi
done
rpt ""
rpt " Entry-point note: whatever you do to this docroot does nothing for the"
rpt " installs above. Run this script against each of them separately."
rpt ""
rpt " To do that, as this user ($(id -un)), copy and run:"
rpt ""
for np in "${NESTED_PATHS[@]}"; do
# A tree under uploads is not a site to triage -- it is a find, and running
# a second report on it wastes the rep's time. Say what to do instead.
if [[ $np == wp-content/uploads/* || $np == */uploads/* ]]; then
rpt " # $np -- inside uploads. Do not triage this as a site;"
rpt " # it should not exist. Read it, then remove it."
continue
fi
rpt " $SELF $(printf '%q' "$DOCROOT/$np")"
done
fi
hdr "Must-use plugins"
MUDIR="$WPC/mu-plugins"
MU_FILES=()
if [[ -d $MUDIR ]]; then
mapfile -t MU_FILES < <(find "$MUDIR" -xdev -maxdepth 2 -type f -name '*.php' -printf '%P\n' 2>/dev/null | sort) || true
rpt "mu-plugins directory present: ${#MU_FILES[@]} PHP file(s)"
for f in ${MU_FILES[@]+"${MU_FILES[@]}"}; do
rpt " $f ($(human "$(stat -c '%s' -- "$MUDIR/$f")"), mtime $(stat -c '%y' -- "$MUDIR/$f" | cut -d. -f1))"
done
if [[ ${#MU_FILES[@]} -gt 0 ]]; then
finding high "${#MU_FILES[@]} must-use plugin file(s) present. mu-plugins load unconditionally, cannot be deactivated from wp-admin, and are not listed on the plugins screen. Read every one. A site that never asked for an mu-plugin should not have one."
fi
else
rpt "No mu-plugins directory."
fi
hdr "Drop-ins"
DROPINS_FOUND=()
for d in "${DROPINS[@]}"; do
if [[ -f $WPC/$d ]]; then
DROPINS_FOUND+=("$d")
rpt " $d ($(human "$(stat -c '%s' -- "$WPC/$d")"), mtime $(stat -c '%y' -- "$WPC/$d" | cut -d. -f1))"
fi
done
if [[ ${#DROPINS_FOUND[@]} -gt 0 ]]; then
finding high "Drop-in(s) present: ${DROPINS_FOUND[*]}. These execute on every request, are not covered by core checksums, and are not shown as plugins. Match each one against an installed caching or object-cache plugin; a drop-in with no plugin behind it is a backdoor."
else
rpt " (none)"
fi
# --- Plugin and theme inventory. Directory names are .org slugs for repo items.
declare -a PLUGIN_SLUGS=() THEME_SLUGS=()
if [[ -d $WPC/plugins ]]; then
mapfile -t PLUGIN_SLUGS < <(find "$WPC/plugins" -xdev -mindepth 1 -maxdepth 1 -type d -printf '%P\n' 2>/dev/null | sort) || true
fi
if [[ -d $WPC/themes ]]; then
mapfile -t THEME_SLUGS < <(find "$WPC/themes" -xdev -mindepth 1 -maxdepth 1 -type d -printf '%P\n' 2>/dev/null | sort) || true
fi
# Loose PHP files sitting directly in plugins/ (not in a subdirectory) are
# either single-file plugins or dropped shells.
LOOSE_PLUGIN_FILES=()
if [[ -d $WPC/plugins ]]; then
mapfile -t LOOSE_PLUGIN_FILES < <(find "$WPC/plugins" -xdev -mindepth 1 -maxdepth 1 -type f -name '*.php' -printf '%P\n' 2>/dev/null | sort) || true
fi
# Ask the .org API which of these actually exist upstream. Repo membership is
# the dividing line for everything downstream: a .org slug can be diffed against
# an authoritative copy, anything else can only be read by a human.
info "Checking ${#PLUGIN_SLUGS[@]} plugin(s) and ${#THEME_SLUGS[@]} theme(s) against the wordpress.org repository..."
REPO_PLUGINS=(); ORPHAN_PLUGINS=(); UNKNOWN_PLUGINS=()
REPO_THEMES=(); ORPHAN_THEMES=(); UNKNOWN_THEMES=()
# "Not in the repository" and "could not ask the repository" are different
# answers, and conflating them is how a network blip turns a healthy site into a
# report claiming all forty plugins are planted. Distinguish them at the HTTP
# layer: 200 means the API answered, 404 means it answered "no such slug",
# anything else means we never got an answer and must say so.
#
# org_fetch URL OUTFILE -> 0 = answered 200, 1 = answered 404, 2 = no answer
org_fetch() {
local url=$1 out=$2 code rc
: >"$out"
if have curl; then
code=$(curl -sS -o "$out" -w '%{http_code}' --retry 2 --retry-delay 1 \
--connect-timeout 10 --max-time 60 -- "$url" 2>/dev/null) || code="000"
else
rc=0; wget -q --tries=2 --timeout=30 -O "$out" -- "$url" 2>/dev/null || rc=$?
case $rc in
0) code=200 ;;
8) code=404 ;; # wget: "server issued an error response"
*) code=000 ;;
esac
fi
case $code in
200) return 0 ;;
404) return 1 ;;
*) return 2 ;;
esac
}
for slug in ${PLUGIN_SLUGS[@]+"${PLUGIN_SLUGS[@]}"}; do
[[ $slug =~ ^[A-Za-z0-9._-]+$ ]] || { ORPHAN_PLUGINS+=("$slug"); continue; }
rc=0; org_fetch "https://api.wordpress.org/plugins/info/1.0/${slug}.json" "$TMPDIR_LOCAL/p.json" || rc=$?
case $rc in
0) dl=$(json_get "$TMPDIR_LOCAL/p.json" "download_link" | head -1)
if [[ -n ${dl:-} && $dl == https://* ]]; then REPO_PLUGINS+=("$slug")
else ORPHAN_PLUGINS+=("$slug"); fi ;;
1) ORPHAN_PLUGINS+=("$slug") ;;
*) UNKNOWN_PLUGINS+=("$slug") ;;
esac
done
for slug in ${THEME_SLUGS[@]+"${THEME_SLUGS[@]}"}; do
[[ $slug =~ ^[A-Za-z0-9._-]+$ ]] || { ORPHAN_THEMES+=("$slug"); continue; }
rc=0; org_fetch "https://api.wordpress.org/themes/info/1.1/?action=theme_information&request%5Bslug%5D=${slug}" "$TMPDIR_LOCAL/t.json" || rc=$?
case $rc in
0) dl=$(json_get "$TMPDIR_LOCAL/t.json" "download_link" | head -1)
if [[ -n ${dl:-} && $dl == https://* ]]; then REPO_THEMES+=("$slug")
else ORPHAN_THEMES+=("$slug"); fi ;;
1) ORPHAN_THEMES+=("$slug") ;;
*) UNKNOWN_THEMES+=("$slug") ;;
esac
done
ORG_API_DEGRADED=0
if [[ ${#UNKNOWN_PLUGINS[@]} -gt 0 || ${#UNKNOWN_THEMES[@]} -gt 0 ]]; then
ORG_API_DEGRADED=1
warn "api.wordpress.org did not answer for ${#UNKNOWN_PLUGINS[@]} plugin(s) and ${#UNKNOWN_THEMES[@]} theme(s). They are NOT classified below."
fi
hdr "Plugins (${#PLUGIN_SLUGS[@]})"
rpt "In the wordpress.org repository (${#REPO_PLUGINS[@]}):"
for s in ${REPO_PLUGINS[@]+"${REPO_PLUGINS[@]}"}; do rpt " $s"; done
rpt ""
rpt "NOT in the .org repository -- premium, custom, renamed or planted (${#ORPHAN_PLUGINS[@]}):"
for s in ${ORPHAN_PLUGINS[@]+"${ORPHAN_PLUGINS[@]}"}; do
n=$(find "$WPC/plugins/$s" -xdev -type f 2>/dev/null | wc -l)
sz=$(du -sb -- "$WPC/plugins/$s" 2>/dev/null | awk '{print $1}')
rpt " $s ($n files, $(human "${sz:-0}"))"
done
[[ ${#ORPHAN_PLUGINS[@]} -eq 0 ]] && rpt " (none)"
if [[ ${#UNKNOWN_PLUGINS[@]} -gt 0 ]]; then
rpt ""
rpt "COULD NOT BE CHECKED -- api.wordpress.org did not answer (${#UNKNOWN_PLUGINS[@]}):"
for s in "${UNKNOWN_PLUGINS[@]}"; do rpt " $s"; done
rpt " These are unclassified, NOT suspicious. Re-run when the API is reachable."
fi
if [[ ${#LOOSE_PLUGIN_FILES[@]} -gt 0 ]]; then
rpt ""
rpt "Loose PHP files directly in plugins/ (${#LOOSE_PLUGIN_FILES[@]}):"
# index.php and hello.php ship with WordPress itself. Flagging them would put
# a HIGH on every healthy site and teach techs to skim past this finding.
UNEXPECTED_LOOSE=()
for f in "${LOOSE_PLUGIN_FILES[@]}"; do
if [[ $f == "index.php" || $f == "hello.php" ]]; then
rpt " $f (ships with WordPress)"
else
rpt " $f"
UNEXPECTED_LOOSE+=("$f")
fi
done
[[ ${#UNEXPECTED_LOOSE[@]} -gt 0 ]] && \
finding high "Loose PHP file(s) directly in wp-content/plugins/ that WordPress does not ship: ${UNEXPECTED_LOOSE[*]}. Single-file plugins are legitimate, but this is also where droppers land."
fi
[[ ${#ORPHAN_PLUGINS[@]} -gt 0 ]] && \
finding med "${#ORPHAN_PLUGINS[@]} plugin director(ies) are not in the .org repo, so there is no authoritative copy to diff them against. Premium and bespoke code lives here, and so does a shell in a directory named to look like a plugin. Check each against a vendor original."
hdr "Themes (${#THEME_SLUGS[@]})"
rpt "In the wordpress.org repository (${#REPO_THEMES[@]}):"
for s in ${REPO_THEMES[@]+"${REPO_THEMES[@]}"}; do rpt " $s"; done
rpt ""
rpt "NOT in the .org repository (${#ORPHAN_THEMES[@]}):"
for s in ${ORPHAN_THEMES[@]+"${ORPHAN_THEMES[@]}"}; do
n=$(find "$WPC/themes/$s" -xdev -type f 2>/dev/null | wc -l)
rpt " $s ($n files)"
done
[[ ${#ORPHAN_THEMES[@]} -eq 0 ]] && rpt " (none)"
if [[ ${#UNKNOWN_THEMES[@]} -gt 0 ]]; then
rpt ""
rpt "COULD NOT BE CHECKED -- api.wordpress.org did not answer (${#UNKNOWN_THEMES[@]}):"
for s in "${UNKNOWN_THEMES[@]}"; do rpt " $s"; done
rpt " These are unclassified, NOT suspicious. Re-run when the API is reachable."
fi
[[ ${#ORPHAN_THEMES[@]} -gt 0 ]] && \
finding med "${#ORPHAN_THEMES[@]} theme director(ies) are not in the .org repo. Child themes and bespoke builds are normal here; so is a trojaned copy of a stock theme renamed to look bespoke."
if [[ $ORG_API_DEGRADED -eq 1 ]]; then
finding med "api.wordpress.org could not be reached for ${#UNKNOWN_PLUGINS[@]} plugin(s) and ${#UNKNOWN_THEMES[@]} theme(s), so this run cannot say whether they are stock. Treat the repo/non-repo split above as incomplete, and treat the pattern-scan review list as over-long: hits inside those unclassified directories may well be ordinary plugin code. Re-run once outbound HTTPS to api.wordpress.org works."
fi
# --- Uploads: anything executable
hdr "Executable files in uploads"
UPLOADS="$WPC/uploads"
UPLOAD_EXEC="$TMPDIR_LOCAL/uploads-exec.txt"; : >"$UPLOAD_EXEC"
if [[ -d $UPLOADS ]]; then
EXT_FIND=(); for e in "${EXEC_EXTENSIONS[@]}"; do EXT_FIND+=( -o -iname "*.$e" ); done
find "$UPLOADS" -xdev -type f \( "${EXT_FIND[@]:1}" \) -printf '%P\n' 2>/dev/null | sort >>"$UPLOAD_EXEC" || true
# Files with an innocent extension but PHP inside -- polyglot images, mostly.
# -a, not -I: these files ARE binary, which is the whole point. -I would skip
# exactly the files we are looking for.
find "$UPLOADS" -xdev -type f -size -2M \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' \
-o -iname '*.ico' -o -iname '*.svg' -o -iname '*.webp' -o -iname '*.txt' \) \
-exec grep -la -e '<?php' -e '<?=' {} + 2>/dev/null \
| strip_prefix "$UPLOADS/" >>"$UPLOAD_EXEC" || true
sort -u -o "$UPLOAD_EXEC" "$UPLOAD_EXEC"
UP_N=$(wc -l <"$UPLOAD_EXEC")
rpt "Executable / PHP-bearing files under uploads: $UP_N"
head -50 "$UPLOAD_EXEC" | while IFS= read -r l; do rpt " $l"; done
[[ $UP_N -gt 50 ]] && rpt " ... and $((UP_N - 50)) more (full list: $WORKDIR/uploads-executable.txt)"
cp -- "$UPLOAD_EXEC" "$WORKDIR/uploads-executable.txt" 2>/dev/null || true
[[ $UP_N -gt 0 ]] && \
finding high "$UP_N executable or PHP-bearing file(s) in wp-content/uploads. Uploads should contain no executable code at all, so every one of these is either a webshell or a plugin doing something it should not. Full list: $WORKDIR/uploads-executable.txt"
else
rpt "No uploads directory."
fi
# --- Symlinks
hdr "Symlinks"
SYM_N=0
while IFS= read -r l; do
tgt=$(readlink -f -- "$l" 2>/dev/null || readlink -- "$l")
SYM_N=$((SYM_N+1))
rpt " ${l#"$DOCROOT"/} -> ${tgt:-?}"
if [[ -n ${tgt:-} && $tgt != "$DOCROOT"/* ]]; then
finding high "Symlink ${l#"$DOCROOT"/} points outside the docroot, to ${tgt}. Content is served from a path nothing in this report walked, and it survives any docroot replacement."
note_parent_persistence "$tgt" "symlink target of ${l#"$DOCROOT"/} -- served as site content"
fi
done < <(find "$DOCROOT" -xdev -type l 2>/dev/null | head -100)
[[ $SYM_N -eq 0 ]] && rpt " (none)"
# --- Bounded malware grep
hdr "Suspicious code patterns in wp-content"
declare -a PAT_HIGH=(
'eval[[:space:]]*\('
'assert[[:space:]]*\([[:space:]]*\$'
'create_function[[:space:]]*\('
'preg_replace[[:space:]]*\([[:space:]]*["'"'"'].*/[a-z]*e[a-z]*["'"'"']'
'\$_(POST|GET|REQUEST|COOKIE|SERVER)[[:space:]]*\[[^]]*\][[:space:]]*\('
'base64_decode[[:space:]]*\([[:space:]]*\$'
'(gzinflate|gzuncompress|str_rot13)[[:space:]]*\([[:space:]]*(base64_decode|\$)'
'auto_prepend_file|auto_append_file'
'(FilesMan|WSOsetcookie|b374k|c99shell|r57shell|IndoXploit|AnonymousFox|wp-vcd|wsoFooter)'
'(shell_exec|passthru|proc_open|popen|pcntl_exec)[[:space:]]*\('
)
declare -a PAT_MED=(
'base64_decode[[:space:]]*\('
'file_put_contents[[:space:]]*\([[:space:]]*\$'
'move_uploaded_file[[:space:]]*\('
'(\\x[0-9a-fA-F]{2}){8,}'
'[A-Za-z0-9+/]{300,}={0,2}'
'error_reporting[[:space:]]*\([[:space:]]*0[[:space:]]*\)'
'@?ini_set[[:space:]]*\([[:space:]]*["'"'"']display_errors'
)
grep_pat() { # grep_pat "regex" -> file list
# Scan wp-content plus any nested WordPress installations. Without the nested
# roots, a webshell in an abandoned /blog install is invisible here -- and that
# abandoned install is a likely entry point, so it is exactly what we want to see.
# Prune known cache/log dirs -- they dominate inodes and never hold the signal.
local prune=( \( )
local n first=1
for n in "${CACHE_DIR_NAMES[@]}"; do
[[ $first -eq 1 ]] || prune+=( -o )
prune+=( -name "$n" )
first=0
done
prune+=( \) -type d -prune -o )
find "$WPC" ${SCAN_EXTRA_ROOTS[@]+"${SCAN_EXTRA_ROOTS[@]}"} \
-xdev "${prune[@]}" \
-type f -size -"${SCAN_MAX_BYTES}c" \
\( -iname '*.php' -o -iname '*.phtml' -o -iname '*.inc' -o -iname '*.js' \
-o -iname '.htaccess' -o -iname '*.ini' \) \
-exec grep -lIE "$1" {} + 2>/dev/null || true
}
SCAN_EXTRA_ROOTS=()
for np in ${NESTED_PATHS[@]+"${NESTED_PATHS[@]}"}; do
# Skip nested trees already inside wp-content -- find would walk them twice.
[[ $np == wp-content/* ]] && continue
SCAN_EXTRA_ROOTS+=("$DOCROOT/$np")
done
HIGH_HITS="$TMPDIR_LOCAL/hits-high.txt"; : >"$HIGH_HITS"
MED_HITS="$TMPDIR_LOCAL/hits-med.txt"; : >"$MED_HITS"
info "Scanning wp-content${SCAN_EXTRA_ROOTS[0]+ and ${#SCAN_EXTRA_ROOTS[@]} nested install(s)} for suspicious patterns (files under $(human "$SCAN_MAX_BYTES"); cache dirs pruned)..."
if [[ $PATHOLOGICAL_TREE -eq 1 ]]; then
warn "Pathological tree: pattern scan still runs but skips ${CACHE_DIR_NAMES[*]} directories."
fi
for p in "${PAT_HIGH[@]}"; do grep_pat "$p" >>"$HIGH_HITS"; done
for p in "${PAT_MED[@]}"; do grep_pat "$p" >>"$MED_HITS"; done
sort -u -o "$HIGH_HITS" "$HIGH_HITS"; sort -u -o "$MED_HITS" "$MED_HITS"
if comm -23 "$MED_HITS" "$HIGH_HITS" >"$TMPDIR_LOCAL/hits-med2.txt" 2>/dev/null; then
mv -- "$TMPDIR_LOCAL/hits-med2.txt" "$MED_HITS"
fi
HH=$(wc -l <"$HIGH_HITS"); MH=$(wc -l <"$MED_HITS")
# Split the hits by whether an authoritative copy of the file exists. Legitimate
# plugins do use base64_decode and friends -- Yoast SEO's crypto code trips
# these patterns on a completely healthy site. A hit inside a .org-repo plugin
# or theme can be settled by diffing against the .org zip, so it is not where a
# tech should start. What deserves eyes first is everything else: mu-plugins,
# drop-ins, uploads, non-repo plugins and themes.
DIFFABLE_RE=""
for s in ${REPO_PLUGINS[@]+"${REPO_PLUGINS[@]}"}; do DIFFABLE_RE+="|wp-content/plugins/$s/"; done
for s in ${REPO_THEMES[@]+"${REPO_THEMES[@]}"}; do DIFFABLE_RE+="|wp-content/themes/$s/"; done
DIFFABLE_RE=${DIFFABLE_RE#|}
HITS_NEEDING_EYES="$TMPDIR_LOCAL/hits-eyes.txt"; : >"$HITS_NEEDING_EYES"
HITS_DIFFABLE="$TMPDIR_LOCAL/hits-diffable.txt"; : >"$HITS_DIFFABLE"
if [[ -n $DIFFABLE_RE ]]; then
grep -E "$DIFFABLE_RE" "$HIGH_HITS" >"$HITS_DIFFABLE" 2>/dev/null || true
grep -vE "$DIFFABLE_RE" "$HIGH_HITS" >"$HITS_NEEDING_EYES" 2>/dev/null || true
else
cp -- "$HIGH_HITS" "$HITS_NEEDING_EYES" 2>/dev/null || true
fi
HE=$(wc -l <"$HITS_NEEDING_EYES"); HR=$(wc -l <"$HITS_DIFFABLE")
rpt "High-confidence pattern hits : $HH file(s)"
rpt ""
rpt " (a) In .org plugins/themes -- diff against the .org zip to settle: $HR"
rpt " Usually benign -- legitimate plugins do use base64_decode and crypto."
strip_prefix "$DOCROOT/" <"$HITS_DIFFABLE" | head -10 | while IFS= read -r l; do rpt " $l"; done || true
[[ $HR -gt 10 ]] && rpt " ... and $((HR-10)) more"
rpt ""
rpt " (b) NEEDS REVIEW -- no authoritative copy exists to compare against: $HE"
[[ $ORG_API_DEGRADED -eq 1 ]] && \
rpt " NOTE: api.wordpress.org was unreachable for some slugs, so this list is"
[[ $ORG_API_DEGRADED -eq 1 ]] && \
rpt " longer than it should be -- unclassified plugins land here by default."
strip_prefix "$DOCROOT/" <"$HITS_NEEDING_EYES" | head -40 | while IFS= read -r l; do rpt " $l"; done || true
[[ $HE -gt 40 ]] && rpt " ... and $((HE-40)) more"
rpt ""
rpt "Lower-confidence hits : $MH file(s) (full list in workdir)"
cp -- "$HIGH_HITS" "$WORKDIR/scan-high-confidence.txt" 2>/dev/null || true
cp -- "$MED_HITS" "$WORKDIR/scan-low-confidence.txt" 2>/dev/null || true
cp -- "$HITS_NEEDING_EYES" "$WORKDIR/scan-needs-review.txt" 2>/dev/null || true
[[ $HE -gt 0 ]] && finding high "$HE file(s) match high-confidence webshell/obfuscation patterns in code with no authoritative copy to compare against (mu-plugins, drop-ins, uploads, non-repo plugins/themes). These are the ones to actually read. Full list: $WORKDIR/scan-needs-review.txt"
[[ $HE -eq 0 && $HH -gt 0 ]] && finding low "All $HH pattern hits are inside .org-repo plugins/themes, where a diff against the .org zip settles the question. Most likely benign."
rpt ""
rpt "These patterns catch careless malware. A competent implant matches none of"
rpt "them, so a clean scan here is not evidence of a clean site. Read the findings"
rpt "above it -- an unexplained drop-in beats any regex."
# --- Recently modified PHP, a useful timeline signal
hdr "Recently modified PHP files (last 30 days)"
# Scan from the docroot only. Passing both $DOCROOT and $WPC listed every
# wp-content file twice, since -printf %P is relative to each start point.
# Prune cache dirs so an inode bomb does not bury the timeline signal.
_recent_prune=( \( )
_rf=1
for _n in "${CACHE_DIR_NAMES[@]}"; do
[[ $_rf -eq 1 ]] || _recent_prune+=( -o )
_recent_prune+=( -name "$_n" )
_rf=0
done
_recent_prune+=( \) -type d -prune -o )
find "$DOCROOT" -xdev "${_recent_prune[@]}" \
-type f -name '*.php' -mtime -30 -printf '%TY-%Tm-%Td %TH:%TM\t%p\n' 2>/dev/null \
| sort -r | head -25 | strip_prefix "$DOCROOT/" 2 \
| while IFS= read -r l; do rpt " $l"; done || true
rpt ""
rpt "Oldest modification among flagged files is your best lead for the log review."
# ---------------------------------------------------------------------------
# 7. Database audit -- direct SQL, no PHP bootstrap
# ---------------------------------------------------------------------------
DB_DEFAULTS="$TMPDIR_LOCAL/my.cnf"
TABLE_PREFIX=""
# Every statement passed to db_query below is a SELECT or a SHOW. That is the
# invariant that makes this section read-only; keep it that way.
db_query() { mysql --defaults-extra-file="$DB_DEFAULTS" -N -B -D "${WPCFG[DB_NAME]}" -e "$1" 2>/dev/null || true; }
if [[ $SKIP_DB -eq 0 && $HAVE_MYSQL -eq 1 && -n ${WPCFG[DB_NAME]:-} ]]; then
hdr "Database audit"
TABLE_PREFIX=${WPCFG[table_prefix]:-wp_}
# This goes straight into SQL identifiers -- validate hard.
if [[ ! $TABLE_PREFIX =~ ^[A-Za-z0-9_]+$ ]]; then
warn "Refusing to use table prefix '$TABLE_PREFIX' -- not [A-Za-z0-9_]."
rpt "Not audited: the table prefix '$TABLE_PREFIX' is not [A-Za-z0-9_], and it"
rpt "would go straight into SQL identifiers. Nothing below was read."
finding high "Table prefix in wp-config.php is not alphanumeric: '$TABLE_PREFIX'. Investigate by hand."
TABLE_PREFIX=""
fi
if [[ -n $TABLE_PREFIX ]]; then
# WordPress DB_HOST forms: host, host:port, host:/socket, :/socket, [::1], [::1]:port.
DBHOST_RAW=${WPCFG[DB_HOST]:-localhost}
DBHOST=$DBHOST_RAW
DBPORT=""
DBSOCKET=""
if [[ $DBHOST_RAW == :/* ]]; then
# Socket-only (:/path/to.sock)
DBHOST="localhost"
DBSOCKET=${DBHOST_RAW#:}
elif [[ $DBHOST_RAW =~ ^\[([^\]]+)\]:([0-9]+)$ ]]; then
DBHOST=${BASH_REMATCH[1]}
DBPORT=${BASH_REMATCH[2]}
elif [[ $DBHOST_RAW =~ ^\[([^\]]+)\]$ ]]; then
DBHOST=${BASH_REMATCH[1]}
elif [[ $DBHOST_RAW =~ ^[^:]+:/.+ ]]; then
# host:/path/to.sock
DBHOST=${DBHOST_RAW%%:*}
DBSOCKET=${DBHOST_RAW#*:}
elif [[ $DBHOST_RAW =~ ^([^:]+):([0-9]+)$ ]]; then
DBHOST=${BASH_REMATCH[1]}
DBPORT=${BASH_REMATCH[2]}
fi
# else: bare hostname, or unbracketed IPv6 like ::1 — leave as-is for the client.
umask 077
{
printf '[client]\n'
printf 'user=%s\n' "${WPCFG[DB_USER]:-}"
# Quote the password so # and leading/trailing spaces survive option-file parsing.
pw=${WPCFG[DB_PASSWORD]:-}
pw=${pw//\\/\\\\}
pw=${pw//\"/\\\"}
printf 'password="%s"\n' "$pw"
printf 'host=%s\n' "$DBHOST"
[[ -n $DBPORT && $DBPORT =~ ^[0-9]+$ ]] && printf 'port=%s\n' "$DBPORT"
[[ -n $DBSOCKET ]] && printf 'socket=%s\n' "$DBSOCKET"
} >"$DB_DEFAULTS"
chmod 600 -- "$DB_DEFAULTS"
if [[ -n $(db_query "SELECT 1;") ]]; then
P=$TABLE_PREFIX
ok "Database connection established (no PHP executed)."
# Multisite changes the capability meta key per blog; flag rather than guess.
IS_MULTISITE=0
[[ -n $(db_query "SHOW TABLES LIKE '${P}blogs';") ]] && IS_MULTISITE=1
if [[ $IS_MULTISITE -eq 1 ]]; then
rpt "MULTISITE detected -- per-blog capability keys are not fully audited below."
finding med "This is a multisite network. Audit super-admins (${P}sitemeta / site_admins) and every sub-site's ${P}N_capabilities meta separately."
fi
rpt ""
rpt "Administrators:"
rpt "$(printf '%-6s %-24s %-34s %-20s' ID login email registered)"
while IFS=$'\t' read -r id login email reg; do
[[ -z ${id:-} ]] && continue
rpt "$(printf '%-6s %-24s %-34s %-20s' "$id" "$login" "$email" "$reg")"
done < <(db_query "SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id = u.ID
AND m.meta_key = '${P}capabilities'
WHERE m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;")
ADMIN_N=$(db_query "SELECT COUNT(*) FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id=u.ID AND m.meta_key='${P}capabilities'
WHERE m.meta_value LIKE '%administrator%';")
rpt ""
rpt "Administrator count: ${ADMIN_N:-?}"
# Admins created in the last 90 days are the ones worth asking about.
RECENT=$(db_query "SELECT COUNT(*) FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id=u.ID AND m.meta_key='${P}capabilities'
WHERE m.meta_value LIKE '%administrator%'
AND u.user_registered > DATE_SUB(NOW(), INTERVAL 90 DAY);")
if [[ ${RECENT:-0} -gt 0 ]]; then
finding high "${RECENT} administrator account(s) were created in the last 90 days. Confirm every one with the customer. This script reports accounts and never touches them."
fi
# Users holding admin caps without the role string is a rarer trick.
# An account with user_level>=10 but no "administrator" capability string is
# admin-privileged yet invisible to `wp user list --role=administrator`,
# so any password rotation driven off that list misses it.
SNEAKY=$(db_query "SELECT CONCAT(u.ID, '|', u.user_login) FROM \`${P}users\` u
JOIN \`${P}usermeta\` m ON m.user_id=u.ID
WHERE m.meta_key='${P}user_level' AND m.meta_value >= 10
AND u.ID NOT IN (SELECT user_id FROM \`${P}usermeta\`
WHERE meta_key='${P}capabilities'
AND meta_value LIKE '%administrator%');")
if [[ -n ${SNEAKY:-} ]]; then
rpt ""
rpt "Users with user_level>=10 but no administrator role:"
while IFS='|' read -r sid slogin; do
[[ -n ${sid:-} ]] || continue
rpt " id=$sid $slogin"
done <<<"$SNEAKY"
finding high "Account(s) carry user_level>=10 without the administrator role string -- a way to stay off the Users screen filter. Reset these by ID; a rotation driven off 'wp user list --role=administrator' skips them."
fi
rpt ""
rpt "Key options:"
while IFS=$'\t' read -r k v; do
[[ -z ${k:-} ]] && continue
rpt "$(printf ' %-22s %s' "$k" "$v")"
done < <(db_query "SELECT option_name, LEFT(option_value,120) FROM \`${P}options\`
WHERE option_name IN ('siteurl','home','admin_email','users_can_register',
'default_role','template','stylesheet','blog_public');")
DEF_ROLE=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='default_role';")
UCR=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='users_can_register';")
[[ ${UCR:-0} == "1" && ${DEF_ROLE:-} == "administrator" ]] && \
finding high "Open registration is enabled AND the default role is administrator. Anyone can register as an admin. Fix immediately."
[[ ${DEF_ROLE:-} == "administrator" ]] && \
finding high "default_role is set to 'administrator'."
rpt ""
rpt "Largest autoloaded options (loaded on every page request):"
while IFS=$'\t' read -r n l; do
[[ -z ${n:-} ]] && continue
rpt "$(printf ' %-46s %s' "$n" "$(human "${l:-0}")")"
done < <(db_query "SELECT option_name, LENGTH(option_value) len FROM \`${P}options\`
WHERE autoload NOT IN ('no','off') ORDER BY len DESC LIMIT 12;")
AUTOLOAD_TOTAL=$(db_query "SELECT SUM(LENGTH(option_value)) FROM \`${P}options\` WHERE autoload NOT IN ('no','off');")
rpt " total autoloaded: $(human "${AUTOLOAD_TOTAL:-0}")"
[[ ${AUTOLOAD_TOTAL:-0} -gt $((3*1024*1024)) ]] && \
finding med "$(human "${AUTOLOAD_TOTAL}") of autoloaded options -- this is loaded and unserialized on every single request and is a common cause of a site feeling slow independent of the compromise."
SUSP_OPT=$(db_query "SELECT option_name FROM \`${P}options\`
WHERE option_value REGEXP 'eval\\\\(|base64_decode|gzinflate|<script|document\\\\.write'
LIMIT 25;")
if [[ -n ${SUSP_OPT:-} ]]; then
rpt ""
rpt "Options containing code-like content:"
printf '%s\n' "$SUSP_OPT" | while IFS= read -r l; do rpt " $l"; done
finding high "Options table contains code-like values (eval/base64_decode/<script>). Database-resident injection survives any filesystem work at all -- reinstalling every file on the server changes nothing here. Clean these by hand."
fi
CRON_HOOKS=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='cron';" \
| grep -oE '"[a-z0-9_]{3,60}"' | tr -d '"' | sort -u | head -40 || true)
if [[ -n ${CRON_HOOKS:-} ]]; then
rpt ""
rpt "Scheduled cron hooks:"
printf '%s\n' "$CRON_HOOKS" | while IFS= read -r l; do rpt " $l"; done
fi
ACTIVE_PLUGINS=$(db_query "SELECT option_value FROM \`${P}options\` WHERE option_name='active_plugins';" \
| grep -oE '"[^"]+\.php"' | tr -d '"' | sed 's|/.*||' | sort -u || true)
# Written whenever the option was read, empty or not. No file means the
# database was never reached.
: >"$WORKDIR/active-plugins.txt"
if [[ -n ${ACTIVE_PLUGINS:-} ]]; then
printf '%s\n' "$ACTIVE_PLUGINS" >"$WORKDIR/active-plugins.txt"
rpt ""
rpt "Active plugins (from DB): $(tr '\n' ' ' <<<"$ACTIVE_PLUGINS")"
fi
else
warn "Could not connect to the database with the credentials from wp-config.php."
rpt "Not audited: could not connect to '${WPCFG[DB_NAME]}' on '${DBHOST_RAW}' with"
rpt "the credentials in wp-config.php. Nothing below was read. Rogue accounts,"
rpt "injected options and cron hooks are all invisible to this run."
finding med "Database credentials from wp-config.php did not authenticate. The DB may be down, moved, or the config may be stale."
fi
fi
elif [[ $SKIP_DB -eq 1 ]]; then
hdr "Database audit"; rpt "Skipped (--no-db)."
else
hdr "Database audit"; rpt "Skipped (mysql client or DB credentials unavailable)."
fi
# ---------------------------------------------------------------------------
# 8. Files outside the docroot that still execute for this site
#
# Collected while parsing wp-config.php and the ini files. They are listed
# together because they are the ones a docroot reinstall silently leaves behind.
# ---------------------------------------------------------------------------
hdr "Persistence outside the docroot"
if [[ ${#PARENT_PERSISTENCE[@]} -eq 0 ]]; then
rpt "None found above $DOCROOT."
else
rpt "These paths sit outside the docroot and are not covered by anything you do"
rpt "inside it. Replacing the docroot leaves every one of them in place."
rpt ""
PP_UNREADABLE=0
for entry in "${PARENT_PERSISTENCE[@]}"; do
f=${entry%%$'\t'*}
why=${entry#*$'\t'}
if [[ -e $f ]]; then
if [[ -d $f ]]; then
state="directory, $(find "$f" -xdev -type f 2>/dev/null | wc -l | tr -d ' ') file(s)"
else
state="$(human "$(stat -c '%s' -- "$f" 2>/dev/null || echo 0)"), mtime $(stat -c '%y' -- "$f" 2>/dev/null | cut -d. -f1)"
fi
else
# Either the attacker has not dropped it yet, or -- far more often -- it
# exists but this user cannot stat it. Both mean "go look as someone who can".
state="NOT PRESENT or not readable as $(id -un)"
PP_UNREADABLE=1
fi
rpt " $f"
rpt " $why"
rpt " [$state]"
done
finding high "${#PARENT_PERSISTENCE[@]} path(s) outside the docroot are loaded by this site: listed under 'Persistence outside the docroot'. These are the files a docroot reinstall does not touch and a core checksum scan never sees. Read every one before calling the site clean."
[[ $PP_UNREADABLE -eq 1 ]] && \
finding med "At least one path referenced above could not be stat'd as $(id -un). A directive pointing at a file you cannot read is still executing for the site -- check it as root or as the owning user rather than assuming it is absent."
fi
# ---------------------------------------------------------------------------
# 9. Findings summary
# ---------------------------------------------------------------------------
hdr "FINDINGS"
n=0
if [[ ${#FINDING_HIGH[@]} -gt 0 ]]; then
rpt ""
rpt "${C_RED}HIGH${C_RESET}"
for f in "${FINDING_HIGH[@]}"; do n=$((n+1)); rpt " $n. $f"; done
fi
if [[ ${#FINDING_MED[@]} -gt 0 ]]; then
rpt ""
rpt "${C_YEL}MEDIUM${C_RESET}"
for f in "${FINDING_MED[@]}"; do n=$((n+1)); rpt " $n. $f"; done
fi
if [[ ${#FINDING_LOW[@]} -gt 0 ]]; then
rpt ""
rpt "${C_DIM}LOW / HOUSEKEEPING${C_RESET}"
for f in "${FINDING_LOW[@]}"; do n=$((n+1)); rpt " $n. $f"; done
fi
[[ $n -eq 0 ]] && rpt " Nothing flagged. That is not the same as clean."
# ---------------------------------------------------------------------------
# 10. Wrap up
# ---------------------------------------------------------------------------
hdr "REPORT COMPLETE"
rpt "Nothing was modified. The docroot and the database are exactly as they were."
rpt ""
rpt "Report : $REPORT_FILE"
rpt "Log : $LOG_FILE"
rpt "Workdir : $WORKDIR (mode 700 -- it lists live credentials paths and"
rpt " malware locations; do not paste it into a public ticket)"
rpt " Each list file there exists if that check ran. An empty one means"
rpt " the check ran and found nothing; a missing one means it did not run."
rpt ""
rpt "WHAT THIS REPORT DOES NOT TELL YOU:"
rpt " 1. The entry point. Nothing here identifies how they got in. Start the"
rpt " log review at the mtime of the earliest modified file listed above."
rpt " Without that answer, a cleaned site is reinfected within days."
rpt " 2. Whether the code is clean. The pattern scan catches careless malware"
rpt " only. Findings about files that should not exist at all -- drop-ins,"
rpt " mu-plugins, executables in uploads -- are worth more than any scan."
rpt " 3. Anything about a nested installation listed above, beyond the pattern"
rpt " scan. Run this script against each of them."
rpt " 4. Anything about injected post or postmeta content, which is not"
rpt " scanned at all and survives every filesystem repair."
rpt ""
rpt "Everything above is a report. Acting on it -- rotating credentials,"
rpt "replacing code, removing accounts -- is a separate, deliberate decision."
ok "Report written to $REPORT_FILE"
exit 0
rpt ""