captaincore/lib/remote-scripts/detect-seo-spam
2026-04-29 13:54:10 -04:00

418 lines
17 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Detect SEO spam injection in WordPress content
#
# Scans for the off-screen-cloaking pattern that SEO link-injection
# malware uses:
#
# <div style="position:absolute;left:-9999px;width:500px;">
# <a href="https://spamcasino.example/">keyword</a>
# </div>
#
# The element is shifted thousands of pixels off-screen so human visitors
# never see it, but Google's crawler indexes the link as an outbound
# endorsement from your domain. The attacker monetizes by selling SEO
# link juice to spam clients.
#
# This kind of injection lives in the database (post_content, widget
# options) as plain HTML with no PHP execution or obfuscation, so file-
# integrity scans, WP core checksums, plugin checksums, and signature-
# based malware scanners all walk past it. Only a content-pattern scan
# or Googlebot-perspective render check surfaces it.
#
# Detection strategies (all run by default):
#
# 1. wp_posts.post_content — finds <div>/<p> with position:absolute and
# negative-left in any publish/draft/private post or page.
#
# 2. wp_options.option_value — sidebar/footer widget content
# (widget_text, widget_custom_html, etc.) is a common spam location
# that lives outside post content.
#
# 3. Frontend curl as Googlebot via localhost (Host-header routed,
# cache-bypass) — confirms what's actually served to crawlers and
# catches any server-side cloaking that doesn't appear in the DB.
#
# Output format (pipe-delimited):
# SEVERITY|SOURCE|ID|TYPE|STATUS|TITLE|HITS|SAMPLE
#
# SOURCE = POST / OPTION / FRONTEND
# HITS = number of cloaked elements found
# SAMPLE = the external host found inside the cloak block
#
# To eliminate false positives from anti-spam honeypot fields (which use
# the same off-screen positioning trick to hide bot-decoy inputs), a row
# is only emitted when SAMPLE is a real external hostname — not the site
# itself, not a Kinsta staging sibling, and not CSS/text gibberish that
# the SAMPLE extraction picked up because no href existed near the cloak.
# In short: position:absolute + left:-9999px alone is not enough; there
# must also be an <a href> to an external domain inside the block.
#
# Usage:
# captaincore ssh <site> --script=detect-seo-spam
# captaincore ssh @all --script=detect-seo-spam --quiet
#
# Flags:
# --quiet Only emit output when findings exist
# --skip-frontend Skip the live curl step (DB scan only)
#
set -uo pipefail
# ── Argument parsing ─────────────────────────────────────────────
quiet=""
skip_frontend=""
for _arg in "$@"; do
case "$_arg" in
--quiet) quiet=true ;;
--skip-frontend) skip_frontend=true ;;
--*) ;;
*) WP_ROOT="$_arg" ;;
esac
done
WP_ROOT="${WP_ROOT:-.}"
cd "$WP_ROOT" 2>/dev/null || { echo "ERROR: Cannot access $WP_ROOT"; exit 1; }
if [ ! -f "wp-config.php" ] && [ -f "public/wp-config.php" ]; then
cd public
elif [ ! -f "wp-config.php" ] && [ -f "public_html/wp-config.php" ]; then
cd public_html
fi
if [ ! -f "wp-config.php" ]; then
[ -z "$quiet" ] && echo "WordPress not found."
exit 0
fi
if ! command -v wp &>/dev/null; then
echo "ERROR: WP-CLI not available"
exit 1
fi
WP_FLAGS="--skip-themes --skip-plugins --skip-packages"
if ! wp db prefix $WP_FLAGS &>/dev/null; then
[ -z "$quiet" ] && echo "ERROR: WordPress DB unreachable"
exit 1
fi
PREFIX=$(wp db prefix $WP_FLAGS 2>/dev/null)
# Strip protocol and trailing path. -E (extended regex) for cross-platform
# compatibility — BSD sed (macOS) doesn't honor \? in basic regex.
SITE_HOST=$(wp option get home $WP_FLAGS 2>/dev/null | sed -E 's|^https?://||;s|/.*$||')
strip_wpcli_status() {
grep -v -E '^(Success|Error|Warning):|^[[:space:]]*$' || true
}
# Validate that a SAMPLE looks like a real external hostname. Rejects:
# - empty / "-" → no href was found inside the cloak (honeypot field)
# - CSS or text → contains space/colon/semicolon/<>/parens
# - bare label → no dot (single token, not an FQDN)
# - self-references → matches site's own host or kinsta.cloud sibling
# (the same site's staging or production environment)
#
# Returns 0 (valid spam target) / 1 (false positive — skip).
is_valid_external_host() {
local sample="$1"
local self_host="${2:-}"
[ -z "$sample" ] || [ "$sample" = "-" ] && return 1
# Hostname charset only: letters, digits, dots, hyphens
case "$sample" in
*[!a-zA-Z0-9.-]*) return 1 ;;
esac
# Must be an FQDN (at least one dot)
case "$sample" in
*.*) ;;
*) return 1 ;;
esac
if [ -n "$self_host" ]; then
local self_norm="${self_host#www.}"
local sample_norm="${sample#www.}"
[ "$sample_norm" = "$self_norm" ] && return 1
# Same site's staging / production / kinsta.cloud sibling
case "$sample_norm" in
stg-*"$self_norm"|staging-*"$self_norm"|*-staging.kinsta.cloud|*.kinsta.cloud)
# Production->staging or staging->production cross-link.
# Only reject when self_host is also a kinsta.cloud or the
# bare slug appears in both — otherwise a real spam link
# ending in .kinsta.cloud would be hidden.
case "$self_norm" in
*.kinsta.cloud) return 1 ;;
esac
# Strip kinsta.cloud + staging prefix and compare slug
local sample_slug="${sample_norm%.kinsta.cloud}"
sample_slug="${sample_slug#stg-}"
sample_slug="${sample_slug#staging-}"
sample_slug="${sample_slug%-staging}"
local self_slug="${self_norm%.com}"
self_slug="${self_slug%.org}"
self_slug="${self_slug%.net}"
self_slug="${self_slug%.co}"
case "$sample_slug" in
"$self_slug"|"$self_slug"-*) return 1 ;;
esac
;;
esac
fi
return 0
}
found_any=""
seen_post_ids=" "
# Pattern matching shared between scans:
#
# <div|p ... style="...position:absolute...left:-DIGITS...">...</div|p>
#
# 3+ digits on the negative-left filters out the rare legitimate -50px-
# style overlap design while catching every off-screen cloak observed in
# the wild (typical values are -5000 to -84000).
#
# Some cloaking variants use a class instead of inline style:
# <div class="loading-frame-wrapper">...</div>
# combined with a CSS rule defined elsewhere. We match that as a fallback.
CLOAK_REGEX='<(div|p)[[:space:]][^>]*style="[^"]*position[[:space:]]*:[[:space:]]*absolute[^"]*left[[:space:]]*:[[:space:]]*-[0-9]{3,}'
CLOAK_CLASS_REGEX='<div[[:space:]][^>]*class="[^"]*loading-frame-wrapper'
# Spam-domain href pattern for the visible-spam scan (section 1b).
#
# Matches <a href="http(s)://...spam-keyword..."> anywhere in content.
# Keywords are drug names, payday-loan terms, and counterfeit-luxury
# patterns observed across multiple injection campaigns. Single hit could
# be a legitimate medical/news reference, so the visible-spam scan
# requires 2+ matches per post before flagging.
#
# This catches the unobfuscated variant where attackers append garbled
# <p> blocks containing 5-10 anchor tags to spam domains, with no
# cloaking style — the variant that sailed past CLOAK_REGEX on
# snlym.com posts 356 and 359.
SPAM_HREF_REGEX='<a[[:space:]]+[^>]*href="https?://[^"]*(cialis|viagra|payday|louisvuitton|kamagra|sildenafil|tadalafil|dapoxetine|nolvadex|propecia|tetracycline|isotretinoin|clomid|doxycycline|prednisone|wellbutrin|abilify|levitra|maxalt|elocon|rxpills|rxtabs|edtabs|pharmacyrx|onlinepharmacy|rxonline|rxbest|cheaprx|fastrx|norxbest|noprescription|nopres)[^"]*"'
# ── 1. wp_posts.post_content scan ───────────────────────────────
POST_QUERY="
SELECT ID, post_type, post_status, post_title
FROM ${PREFIX}posts
WHERE post_status IN ('publish', 'draft', 'private')
AND (post_content REGEXP '${CLOAK_REGEX}'
OR post_content REGEXP '${CLOAK_CLASS_REGEX}')
ORDER BY post_modified_gmt DESC
"
results=$(wp db query "$POST_QUERY" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
if [ -n "$results" ]; then
while IFS=$'\t' read -r id ptype pstatus ptitle; do
[ -z "${id// }" ] && continue
# Sample external host (best-effort first href= near cloak)
sample=$(wp db query "
SELECT SUBSTRING_INDEX(
SUBSTRING_INDEX(
SUBSTRING(post_content, LOCATE('position', post_content)),
'href=\"', -1),
'\"', 1)
FROM ${PREFIX}posts WHERE ID = $id
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1 | head -c 80 | sed -E 's|^https?://||;s|/.*$||')
# Skip false positives: honeypot fields, CSS gibberish, self-links
is_valid_external_host "$sample" "$SITE_HOST" || continue
found_any=true
# Hit count: number of "position" tokens (each cloak has one)
hits=$(wp db query "
SELECT (LENGTH(post_content) - LENGTH(REPLACE(post_content, 'position', '')))/8
FROM ${PREFIX}posts WHERE ID = $id
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1)
case "$pstatus" in
publish) sev=CRITICAL ;;
*) sev=HIGH ;;
esac
seen_post_ids="${seen_post_ids}${id} "
printf "%s|POST|%s|%s|%s|%s|%s|%s\n" \
"$sev" "$id" "$ptype" "$pstatus" "$ptitle" "${hits:-?}" "$sample"
done <<< "$results"
fi
# ── 1b. Visible spam-link paragraph scan ────────────────────────
#
# Catches the unobfuscated injection variant: posts with 2+ <a href>
# anchors pointing at known pharmacy / payday / luxury / drug-name
# domains, with no off-screen cloaking style. The attacker just
# appends garbled English seeded with affiliate links to the bottom
# of a post — visible to humans, indexed by search engines.
#
# Threshold: 2+ matching anchors per post. A single hit could be a
# legitimate medical/news reference; 2+ is unambiguous spam.
#
# Posts already flagged by section 1 are skipped to avoid duplicate
# rows for the cloaked-AND-visible case (e.g. snlym.com post 346,
# which had both a cloaked div and 3 trailing visible <p> blocks).
VISIBLE_QUERY="
SELECT ID, post_type, post_status, post_title
FROM ${PREFIX}posts
WHERE post_status IN ('publish', 'draft', 'private')
AND post_content REGEXP '${SPAM_HREF_REGEX}'
ORDER BY post_modified_gmt DESC
"
vis_results=$(wp db query "$VISIBLE_QUERY" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
if [ -n "$vis_results" ]; then
while IFS=$'\t' read -r id ptype pstatus ptitle; do
[ -z "${id// }" ] && continue
# Already emitted by section 1? Skip.
case "$seen_post_ids" in
*" $id "*) continue ;;
esac
# Count matching anchors. Fetch post_content and grep for the
# spam-href pattern. Threshold is 2+ to avoid flagging single
# legitimate medical/news references.
body=$(wp db query "
SELECT post_content
FROM ${PREFIX}posts WHERE ID = $id
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
anchor_hits=$(echo "$body" | grep -ioE '<a[[:space:]]+[^>]*href="https?://[^"]*(cialis|viagra|payday|louisvuitton|kamagra|sildenafil|tadalafil|dapoxetine|nolvadex|propecia|tetracycline|isotretinoin|clomid|doxycycline|prednisone|wellbutrin|abilify|levitra|maxalt|elocon|rxpills|rxtabs|edtabs|pharmacyrx|onlinepharmacy|rxonline|rxbest|cheaprx|fastrx|norxbest|noprescription|nopres)[^"]*"' | wc -l | tr -d ' ')
[ "${anchor_hits:-0}" -lt 2 ] && continue
# Sample: first spam href hostname found
sample=$(echo "$body" \
| grep -ioE '<a[[:space:]]+[^>]*href="https?://[^"]*(cialis|viagra|payday|louisvuitton|kamagra|sildenafil|tadalafil|dapoxetine|nolvadex|propecia|tetracycline|isotretinoin|clomid|doxycycline|prednisone|wellbutrin|abilify|levitra|maxalt|elocon|rxpills|rxtabs|edtabs|pharmacyrx|onlinepharmacy|rxonline|rxbest|cheaprx|fastrx|norxbest|noprescription|nopres)[^"]*"' \
| head -1 \
| grep -ioE 'href="[^"]*"' \
| sed -E 's|href="||;s|".*$||;s|^https?://||;s|/.*$||' \
| head -c 80)
# Sanity: extracted hostname looks like a real external domain
is_valid_external_host "$sample" "$SITE_HOST" || continue
found_any=true
seen_post_ids="${seen_post_ids}${id} "
case "$pstatus" in
publish) sev=CRITICAL ;;
*) sev=HIGH ;;
esac
printf "%s|POST|%s|%s|%s|%s|%s|%s\n" \
"$sev" "$id" "$ptype" "$pstatus" "$ptitle" "$anchor_hits" "$sample"
done <<< "$vis_results"
fi
# ── 2. wp_options.option_value scan (widgets, theme mods, etc.) ──
#
# Widget content (widget_text, widget_custom_html) is a common spam
# location independent of post_content. These options store serialized
# arrays where each widget instance has a "text" or "content" field
# containing raw HTML — directly editable by anyone with admin access.
#
# We scan ALL options whose value matches the cloaking pattern. The
# autoload column doesn't matter — both autoloaded and on-demand options
# can hold spam.
OPTION_QUERY="
SELECT option_id, option_name
FROM ${PREFIX}options
WHERE option_value REGEXP '${CLOAK_REGEX}'
OR option_value REGEXP '${CLOAK_CLASS_REGEX}'
"
opt_results=$(wp db query "$OPTION_QUERY" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status)
if [ -n "$opt_results" ]; then
while IFS=$'\t' read -r opt_id opt_name; do
[ -z "${opt_id// }" ] && continue
sample=$(wp db query "
SELECT SUBSTRING_INDEX(
SUBSTRING_INDEX(
SUBSTRING(option_value, LOCATE('position', option_value)),
'href=\"', -1),
'\"', 1)
FROM ${PREFIX}options WHERE option_id = $opt_id
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1 | head -c 80 | sed -E 's|^https?://||;s|/.*$||')
# Skip false positives: honeypot fields, CSS gibberish, self-links
is_valid_external_host "$sample" "$SITE_HOST" || continue
found_any=true
hits=$(wp db query "
SELECT (LENGTH(option_value) - LENGTH(REPLACE(option_value, 'position', '')))/8
FROM ${PREFIX}options WHERE option_id = $opt_id
" --skip-column-names $WP_FLAGS 2>/dev/null | strip_wpcli_status | head -1)
printf "CRITICAL|OPTION|%s|option|live|%s|%s|%s\n" \
"$opt_id" "$opt_name" "${hits:-?}" "$sample"
done <<< "$opt_results"
fi
# ── 3. Frontend curl as Googlebot ───────────────────────────────
#
# Cache-bypass headers + cachebuster query parameter ensure we don't
# see stale cached HTML. This catches server-side UA cloaking and
# corroborates DB findings (or surfaces spam in unscanned tables).
#
if [ -z "$skip_frontend" ] && command -v curl &>/dev/null && [ -n "$SITE_HOST" ]; then
UA_BOT="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
CACHEBUST=$(date +%s)
body=$(curl -ksL --max-time 20 \
-A "$UA_BOT" \
-H "Host: $SITE_HOST" \
-H "Cache-Control: no-cache" \
-H "Pragma: no-cache" \
"https://localhost/?nocache=${CACHEBUST}" 2>/dev/null)
if [ -n "$body" ]; then
cloak_hits=$(echo "$body" | grep -ioE 'position[[:space:]]*:[[:space:]]*absolute[^>]*left[[:space:]]*:[[:space:]]*-[0-9]{3,}' | wc -l | tr -d ' ')
class_hits=$(echo "$body" | grep -ioE 'class="[^"]*loading-frame-wrapper' | wc -l | tr -d ' ')
total_hits=$((cloak_hits + class_hits))
if [ "$total_hits" -gt 0 ]; then
# Find first href= inside any cloak block
sample=$(echo "$body" \
| grep -ioE '(<(div|p)[^>]*style="[^"]*position[^"]*absolute[^"]*left[^"]*-[0-9]+[^"]*"[^>]*>|<div[^>]*class="[^"]*loading-frame-wrapper[^"]*"[^>]*>)[^<]*<a[^>]*href="[^"]*"' \
| grep -ioE 'href="[^"]*"' \
| head -1 \
| sed -E 's|href="||;s|".*$||;s|^https?://||;s|/.*$||')
# Skip false positives: honeypot fields with no <a href> in the cloak
if is_valid_external_host "$sample" "$SITE_HOST"; then
found_any=true
printf "CRITICAL|FRONTEND|home|--|live|%s|%s|%s\n" \
"$SITE_HOST" "$total_hits" "$sample"
fi
fi
fi
fi
# ── 4. Result ────────────────────────────────────────────────────
if [ -z "$found_any" ]; then
[ -z "$quiet" ] && echo "No SEO spam detected."
exit 0
fi
exit 0