#!/usr/bin/env bash
# check-polyshell.sh - STRICTLY READ-ONLY checker for Adobe Commerce / Magento Open Source
#   CVE-2026-48356 (Adobe bulletin APSB26-73, CWE-434 Unrestricted Upload of File with Dangerous Type),
#   publicly nicknamed "PolyShell". CISA KEV: listed 2026-10-27.
# Affected (Adobe APSB26-73 / CVE record): Adobe Commerce 2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15, 2.4.5-p17,
#   2.4.4-p18 and earlier; Magento Open Source 2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15 and earlier;
#   Commerce B2B 1.5.3, 1.5.2-p5, 1.4.2-p10, 1.3.4-p17, 1.3.3-p18 and earlier; Commerce Events 1.6.0 to 1.20.0.
# Fixed: "<line>-2026-jul" = Adobe's July 2026 isolated patch for each line (no Composer release ships the fix
#   yet); Commerce Cloud: latest cloud-patches; Commerce Events 1.21.0.
#
# What it does (read-only): reads composer.lock / composer.json, looks for content that Adobe's July 2026
# isolated patch adds, lists files in pub/media/custom_options/quote/ and reads their bytes, and optionally
# reads an nginx/Apache config and an access log. It never writes, deletes, moves, quarantines, executes or
# includes any file, never calls the Magento API, sends no network traffic and contains no exploit payloads.
#
# Usage:
#   bash check-polyshell.sh --magento-root /var/www/magento
#   bash check-polyshell.sh --magento-root DIR --web-config /etc/nginx/sites-enabled/magento.conf
#   bash check-polyshell.sh --magento-root DIR --access-log /var/log/nginx/access.log   (repeatable, .gz ok)
#   options: -h|--help
#
# Verdicts / exit codes: PATCHED 0 | LIKELY VULNERABLE 1 | UNKNOWN 2 | error 3
#   Indicators of compromise (suspicious files in the upload folder, PHP files under pub/media, or access-log
#   requests into the upload folder that were served) force exit 1 even when the version is PATCHED, because
#   patched does not mean clean. Web-config findings and guest-cart request counts are informational only.
set -u
export LC_ALL=C

CVE="CVE-2026-48356"
ROOT=""; WEBCFG=(); ALOGS=()
VULN=0; UNK=0; PATCHED=0; IND=0
MAXLIST=${POLYSHELL_MAX_LIST:-200}

say() { printf '%s\n' "$*"; }
hdr() { printf '\n==== %s ====\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 3; }
usage() { sed -n '2,/^set -u/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; }

status_block() {
  hdr "KEV status and hunt reminder"
  say "CISA KEV: LISTED - $CVE dateAdded 2026-10-27, knownRansomwareCampaignUse Unknown,"
  say "  forensicTriage No (KEV catalog 2026.10.27)."
  say "  CISA due date 10 Nov 2026 (applies to US federal agencies only, under BOD 26-04; patch now regardless)."
  say "Adobe: apply the July 2026 isolated patch for your line (APSB26-73 / KB ka-37421), or on Commerce Cloud the"
  say "  latest cloud-patches; then confirm with Adobe's Commerce Version Tool (vendor/bin/patch-status)."
  say "  This script does not run that tool: it fetches patch data over the network and writes a log."
  say "PATCHED DOES NOT MEAN CLEAN: a store that was exposed before patching may already hold a web shell or"
  say "  card skimmer. Review pub/media/custom_options/quote/, PHP files under pub/media, new admin users,"
  say "  changed CMS blocks and checkout JavaScript, and access logs (see README.md and the KQL/Sigma content)."
}

while [ $# -gt 0 ]; do
  case "$1" in
    -h|--help) usage; exit 0 ;;
    --magento-root) [ $# -ge 2 ] || die "--magento-root needs a directory"; ROOT=$2; shift 2 ;;
    --web-config) [ $# -ge 2 ] || die "--web-config needs a file"; WEBCFG+=("$2"); shift 2 ;;
    --access-log) [ $# -ge 2 ] || die "--access-log needs a file"; ALOGS+=("$2"); shift 2 ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
done
[ -n "$ROOT" ] || die "--magento-root is required (see --help)"
[ -d "$ROOT" ] || die "no such directory: $ROOT"
{ [ -r "$ROOT" ] && [ -x "$ROOT" ]; } || die "cannot read directory: $ROOT"
ROOT=${ROOT%/}; [ -z "$ROOT" ] && ROOT=/
for f in "${WEBCFG[@]}" "${ALOGS[@]}"; do { [ -f "$f" ] && [ -r "$f" ]; } || die "cannot read $f"; done

# ---------------- helpers ----------------
ver_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ]; }   # $1 >= $2 (dotted)
flat() { tr -d '\r' < "$1" | tr '\n' ' '; }
# composer.lock: "name": "<pkg>", "version": "<v>"  (Composer always writes name then version)
lock_version() {
  flat "$1" | grep -Eo "\"name\"[[:space:]]*:[[:space:]]*\"$2\"[[:space:]]*,[[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" \
    | head -n1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]*)"/\1/; s/^v//'
}
# composer.json require entry: "<pkg>": "<constraint>"
json_require() {
  flat "$1" | grep -Eo "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -n1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/'
}
json_key() { flat "$1" | grep -Eo "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -n1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/'; }
is_exact() { printf '%s' "$1" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; }
first_file() { local p; for p in "$@"; do [ -f "$ROOT/$p" ] && { printf '%s' "$ROOT/$p"; return 0; }; done; return 1; }
has_text() { [ -f "$1" ] && grep -aqF -- "$2" "$1" 2>/dev/null; }

# parse "2.4.8-p5" / "2.4.10-alpha1" -> globals V_LINE V_P V_PRE
parse_ver() {
  V_LINE=""; V_P=0; V_PRE=""
  local v=${1#v}
  V_LINE=$(printf '%s' "$v" | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+') || return 1
  local rest=${v#"$V_LINE"}
  if printf '%s' "$rest" | grep -Eq '^-p[0-9]+'; then
    V_P=$(printf '%s' "$rest" | sed -E 's/^-p([0-9]+).*/\1/'); rest=$(printf '%s' "$rest" | sed -E 's/^-p[0-9]+//')
  fi
  V_PRE=$rest
  return 0
}

setv() { VERDICT=$1; WHY=$2; }
count() { case "$VERDICT" in PATCHED) PATCHED=$((PATCHED+1)) ;; "LIKELY VULNERABLE") VULN=$((VULN+1)) ;; *) UNK=$((UNK+1)) ;; esac; }

# ---------------- version ----------------
hdr "Magento version ($ROOT)"
LOCK="$ROOT/composer.lock"; CJSON="$ROOT/composer.json"
CE_V=""; EE_V=""; B2B_V=""; EV_V=""; SRC=""; CLOUDP=""
if [ -f "$LOCK" ] && [ -r "$LOCK" ]; then
  SRC=composer.lock
  CE_V=$(lock_version "$LOCK" 'magento/product-community-edition')
  EE_V=$(lock_version "$LOCK" 'magento/product-enterprise-edition')
  B2B_V=$(lock_version "$LOCK" 'magento/extension-b2b')
  EV_V=$(lock_version "$LOCK" 'magento/commerce-eventing')
  CLOUDP=$(lock_version "$LOCK" 'magento/magento-cloud-patches')
elif [ -f "$LOCK" ]; then
  say "composer.lock exists but is not readable."
fi
if [ -z "$CE_V$EE_V" ] && [ -f "$CJSON" ] && [ -r "$CJSON" ]; then
  SRC=composer.json
  for pkg in magento/product-community-edition magento/product-enterprise-edition magento/extension-b2b magento/commerce-eventing; do
    c=$(json_require "$CJSON" "$pkg")
    [ -z "$c" ] && continue
    if is_exact "$c"; then v=${c#v}
    else say "composer.json requires $pkg \"$c\" (a constraint, not an exact version; composer.lock is needed)"; v="?"; fi
    case "$pkg" in
      *community*) CE_V=$v ;; *enterprise*) EE_V=$v ;; *b2b) B2B_V=$v ;; *eventing) EV_V=$v ;;
    esac
  done
  # git-based installs: root composer.json is magento/magento2ce or magento/magento2ee with a "version" key
  nm=$(json_key "$CJSON" name)
  if [ -z "$CE_V$EE_V" ] && printf '%s' "$nm" | grep -Eq '^magento/magento2(ce|ee)$'; then
    rv=$(json_key "$CJSON" version)
    if [ -n "$rv" ]; then case "$nm" in *ee) EE_V=${rv#v} ;; *) CE_V=${rv#v} ;; esac; fi
  fi
fi
EDITION=""; CORE_V=""
if [ -n "$EE_V" ]; then EDITION="Adobe Commerce"; CORE_V=$EE_V; elif [ -n "$CE_V" ]; then EDITION="Magento Open Source"; CORE_V=$CE_V; fi
say "Source: ${SRC:-none (no readable composer.lock or composer.json)}"
say "magento/product-community-edition: ${CE_V:-not found}"
say "magento/product-enterprise-edition: ${EE_V:-not found}"
say "magento/extension-b2b: ${B2B_V:-not found}"
say "magento/commerce-eventing (Adobe Commerce Events): ${EV_V:-not found}"
[ -n "$CLOUDP" ] && say "magento/magento-cloud-patches: $CLOUDP (Commerce Cloud)"

# ---------------- patch markers (content added by Adobe's July 2026 isolated patch files) ----------------
hdr "APSB26-73 isolated patch markers (read-only file content checks)"
QUOTE_GC=$(first_file vendor/magento/module-quote/Model/GuestCart/GetGuestCart.php app/code/Magento/Quote/Model/GuestCart/GetGuestCart.php) || QUOTE_GC=""
QUOTE_IR=$(first_file vendor/magento/module-quote/Model/GuestCart/GuestCartItemRepository.php app/code/Magento/Quote/Model/GuestCart/GuestCartItemRepository.php) || QUOTE_IR=""
EE_GC=$(first_file vendor/magento/module-gift-card-account/Model/GuestCart/GiftCardAccountManagement.php app/code/Magento/GiftCardAccount/Model/GuestCart/GiftCardAccountManagement.php) || EE_GC=""
B2B_SC=$(first_file vendor/magento/module-company/Model/SaveValidator/SanitizeCompanyName.php app/code/Magento/Company/Model/SaveValidator/SanitizeCompanyName.php) || B2B_SC=""
M_CE1=0; M_CE2=0; M_EE=0; M_B2B=0
has_text "$QUOTE_GC" 'checkIsGuestCart' && M_CE1=1
has_text "$QUOTE_IR" 'getGuestCart->execute(' && M_CE2=1
has_text "$EE_GC" 'getGuestCart->execute(' && M_EE=1
[ -n "$B2B_SC" ] && M_B2B=1
yn() { [ "$1" -eq 1 ] && printf 'present' || printf 'absent'; }
say "CE  marker 1 (new GetGuestCart.php with checkIsGuestCart): $(yn $M_CE1)"
say "CE  marker 2 (GuestCartItemRepository.php calls getGuestCart->execute): $(yn $M_CE2)"
[ -n "$EE_V" ] && say "EE  marker (GiftCardAccountManagement.php calls getGuestCart->execute): $(yn $M_EE)"
[ -n "$B2B_V" ] && say "B2B marker (new SanitizeCompanyName.php): $(yn $M_B2B)"
[ -f "$ROOT/vendor/bin/patch-status" ] && say "Adobe Commerce Version Tool present: vendor/bin/patch-status (added by the CE patch; not executed)"
HOTFIX=""
if [ -d "$ROOT/m2-hotfixes" ]; then
  HOTFIX=$(find "$ROOT/m2-hotfixes" -maxdepth 1 -type f -name '*2026-07-001*' 2>/dev/null | sed 's#.*/##' | sort | tr '\n' ' ' | sed 's/ $//')
  say "m2-hotfixes/ (Commerce Cloud): ${HOTFIX:-no 2026-07-001 patch files}"
fi
say "Markers are taken from Adobe's July 2026 patch files for 2.4.6-p15, 2.4.7-p10, 2.4.8-p5 and 2.4.9. They show that"
say "  Adobe's patch was applied; Adobe does not say which hunk fixes $CVE."

# ---------------- verdicts ----------------
COMPONENTS=0
assess_core() {
  local ed=$1 v=$2 ceil="" lowest
  if ! parse_ver "$v"; then setv UNKNOWN "could not parse version '$v'"; return; fi
  if [ "$ed" = "Adobe Commerce" ]; then lowest=2.4.4
    case "$V_LINE" in 2.4.9) ceil=0 ;; 2.4.8) ceil=5 ;; 2.4.7) ceil=10 ;; 2.4.6) ceil=15 ;; 2.4.5) ceil=17 ;; 2.4.4) ceil=18 ;; esac
  else lowest=2.4.6
    case "$V_LINE" in 2.4.9) ceil=0 ;; 2.4.8) ceil=5 ;; 2.4.7) ceil=10 ;; 2.4.6) ceil=15 ;; esac
  fi
  local markers_all=0 markers_any=0
  if [ $M_CE1 -eq 1 ] && [ $M_CE2 -eq 1 ] && { [ "$ed" != "Adobe Commerce" ] || [ $M_EE -eq 1 ]; }; then markers_all=1; fi
  [ $((M_CE1 + M_CE2 + M_EE)) -gt 0 ] && markers_any=1
  if [ -z "$ceil" ]; then
    if ! ver_ge "$V_LINE" "$lowest"; then
      if [ $markers_any -eq 1 ]; then setv UNKNOWN "$v is older than $lowest (covered by Adobe's 'and earlier'); patch-like content found but Adobe publishes no isolated patch for this line - confirm with Adobe"
      else setv "LIKELY VULNERABLE" "$v is older than $lowest, the oldest $ed line APSB26-73 lists ('and earlier' covers it), and Adobe publishes no isolated patch for it - upgrade to a supported line and patch"; fi
      return
    fi
    if [ -n "$V_PRE" ]; then setv UNKNOWN "$v is a pre-release on a line newer than APSB26-73 lists; confirm the fix with Adobe's Commerce Version Tool"; return; fi
    setv PATCHED "$v is on a release line newer than the lines APSB26-73 lists as affected"; return
  fi
  local inrange=0
  if [ "$V_P" -lt "$ceil" ] || { [ "$V_P" -eq "$ceil" ]; }; then inrange=1; fi
  if [ $inrange -eq 0 ]; then setv PATCHED "$v is above Adobe's affected ceiling for $V_LINE ($V_LINE$([ "$ceil" -gt 0 ] && printf -- '-p%s' "$ceil") and earlier)"; return; fi
  if [ $markers_all -eq 1 ]; then
    case "$V_LINE" in
      2.4.4|2.4.5) setv UNKNOWN "$v: APSB26-73 patch content found, but Adobe's $V_LINE patch (Composer keys needed) was not inspected for this pack - confirm with the Commerce Version Tool" ;;
      *) setv PATCHED "$v is in Adobe's affected range, but Adobe's July 2026 isolated patch content is present (Adobe: the isolated patch resolves the APSB26-73 issues)" ;;
    esac
    [ "$V_P" -lt "$ceil" ] && WHY="$WHY; note: Adobe tests isolated patches only on the latest -p ($V_LINE-p$ceil)"
    return
  fi
  if [ $markers_any -eq 1 ]; then setv UNKNOWN "$v: only part of Adobe's July 2026 patch content found (CE1=$M_CE1 CE2=$M_CE2 EE=$M_EE) - re-apply the CE and EE patch files in order and verify"; return; fi
  if [ -n "$HOTFIX" ] || [ -n "$CLOUDP" ]; then setv UNKNOWN "$v: Commerce Cloud patch sources present (m2-hotfixes or cloud-patches) but vendor/ in this tree is unpatched - check the deployed environment with the Quality Patches Tool / Commerce Version Tool"; return; fi
  setv "LIKELY VULNERABLE" "$v is in Adobe's affected range ($V_LINE$([ "$ceil" -gt 0 ] && printf -- '-p%s' "$ceil") and earlier) and no July 2026 isolated patch content was found"
}
assess_b2b() {
  local v=$1 ceil=""
  if ! parse_ver "$v"; then setv UNKNOWN "could not parse B2B version '$v'"; return; fi
  case "$V_LINE" in 1.5.3) ceil=0 ;; 1.5.2) ceil=5 ;; 1.4.2) ceil=10 ;; 1.3.4) ceil=17 ;; 1.3.3) ceil=18 ;; esac
  if [ -z "$ceil" ]; then
    if ver_ge "$V_LINE" 1.5.4; then
      if [ -n "$V_PRE" ]; then setv UNKNOWN "B2B $v is a pre-release newer than APSB26-73 lists"; else setv PATCHED "B2B $v is newer than the B2B versions APSB26-73 lists"; fi
    elif [ $M_B2B -eq 1 ]; then setv UNKNOWN "B2B $v is covered by Adobe's 'and earlier'; B2B patch content found but Adobe publishes no patch for this B2B line"
    else setv "LIKELY VULNERABLE" "B2B $v is covered by Adobe's 'and earlier' and no Adobe B2B patch exists for it - upgrade"; fi
    return
  fi
  if [ "$V_P" -gt "$ceil" ]; then setv PATCHED "B2B $v is above Adobe's affected ceiling for $V_LINE"; return; fi
  if [ $M_B2B -eq 1 ]; then
    case "$V_LINE" in 1.3.*) setv UNKNOWN "B2B $v: B2B patch content found, but Adobe's 1.3.x patch was not inspected for this pack - confirm with the Commerce Version Tool" ;;
      *) setv PATCHED "B2B $v is in Adobe's affected range, but the July 2026 B2B patch content is present" ;; esac
    return
  fi
  setv "LIKELY VULNERABLE" "B2B $v is in Adobe's affected range and the July 2026 B2B patch content was not found"
}
assess_events() {
  local v=${1#v} b
  b=$(printf '%s' "$v" | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+') || { setv UNKNOWN "could not parse Commerce Events version '$v'"; return; }
  if ver_ge "$b" 1.21.0; then setv PATCHED "Commerce Events $v >= 1.21.0 (fixed release)"
  elif ver_ge "$b" 1.6.0; then setv "LIKELY VULNERABLE" "Commerce Events $v is in 1.6.0 to 1.20.0 (affected; fixed in 1.21.0)"
  else setv UNKNOWN "Commerce Events $v is below 1.6.0: Adobe's bulletin lists 1.6.0 to 1.20.0, the CVE record lists <= 1.20.0 - update to 1.21.0"; fi
}

hdr "Verdicts"
if [ -n "$CORE_V" ] && [ "$CORE_V" != "?" ]; then
  assess_core "$EDITION" "$CORE_V"; say "$EDITION $CORE_V: VERDICT: $VERDICT - $WHY"; count; COMPONENTS=$((COMPONENTS+1))
elif [ "$CORE_V" = "?" ]; then
  setv UNKNOWN "only a version constraint was found in composer.json"; say "Core: VERDICT: $VERDICT - $WHY"; count; COMPONENTS=$((COMPONENTS+1))
fi
if [ -n "$B2B_V" ]; then
  if [ "$B2B_V" = "?" ]; then setv UNKNOWN "B2B: only a version constraint was found"; else assess_b2b "$B2B_V"; fi
  say "Adobe Commerce B2B $B2B_V: VERDICT: $VERDICT - $WHY"; count; COMPONENTS=$((COMPONENTS+1))
fi
if [ -n "$EV_V" ]; then
  if [ "$EV_V" = "?" ]; then setv UNKNOWN "Commerce Events: only a version constraint was found"; else assess_events "$EV_V"; fi
  say "Adobe Commerce Events $EV_V: VERDICT: $VERDICT - $WHY"; count; COMPONENTS=$((COMPONENTS+1))
fi
if [ "$COMPONENTS" -eq 0 ]; then
  setv UNKNOWN "no Magento / Adobe Commerce version found under $ROOT"; say "VERDICT: $VERDICT - $WHY"; count
fi

# ---------------- upload folder scan (read-only) ----------------
hdr "Upload folder: pub/media/custom_options/quote/"
Q="$ROOT/pub/media/custom_options/quote"
magic_hex() { od -An -tx1 -N12 -- "$1" 2>/dev/null | tr -d ' \n'; }
listed=0
report() { IND=$((IND+1)); listed=$((listed+1)); [ "$listed" -le "$MAXLIST" ] && say "[!] INDICATOR $1"; }
EXEC_RE='^(php[0-9]?|pht|phtml|phar|phps|pgif|inc|shtml|shtm|cgi|pl|py|sh|jsp|asp|aspx)$'
XSS_RE='^(svg|svgz|html|htm|xhtml|xml|js)$'
IMG_RE='^(jpg|jpeg|jpe|png|gif|webp|bmp|avif)$'
QFILES=0
if [ -d "$Q" ]; then
  while IFS= read -r -d '' f; do
    QFILES=$((QFILES+1))
    rel=${f#"$ROOT/"}; name=${f##*/}; reasons=""
    if [ -L "$f" ]; then report "$rel - symbolic link inside the upload folder (not followed)"; continue; fi
    lname=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')
    case "$lname" in *.*) ext=${lname##*.} ;; *) ext="" ;; esac
    if [ "$lname" = ".htaccess" ] || [ "$lname" = ".user.ini" ]; then reasons="$reasons; server config file ($name) in upload folder"
    elif printf '%s' "$ext" | grep -Eq "$EXEC_RE"; then reasons="$reasons; executable/script extension .$ext"
    elif printf '%s' "$lname" | grep -Eq '\.(php[0-9]?|pht|phtml|phar)\.'; then reasons="$reasons; double extension hiding a PHP extension"
    elif printf '%s' "$ext" | grep -Eq "$XSS_RE"; then reasons="$reasons; script-capable extension .$ext (stored XSS risk)"
    elif ! printf '%s' "$ext" | grep -Eq "$IMG_RE"; then reasons="$reasons; non-image extension '${ext:-<none>}'"
    fi
    if grep -aqiF -- '<?php' "$f" 2>/dev/null; then reasons="$reasons; contains PHP open tag '<?php'"
    elif grep -aqF -- '<?=' "$f" 2>/dev/null; then reasons="$reasons; contains PHP short echo tag '<?=' (can occur by chance in binary data - review)"
    fi
    if printf '%s' "$ext" | grep -Eq "$IMG_RE"; then
      mh=$(magic_hex "$f"); ok=0
      case "$ext" in
        jpg|jpeg|jpe) case "$mh" in ffd8ff*) ok=1 ;; esac ;;
        png) case "$mh" in 89504e470d0a1a0a*) ok=1 ;; esac ;;
        gif) case "$mh" in 474946383761*|474946383961*) ok=1 ;; esac ;;
        webp) case "$mh" in 52494646????????57454250) ok=1 ;; esac ;;
        bmp) case "$mh" in 424d*) ok=1 ;; esac ;;
        avif) case "$mh" in ????????6674797061766966|????????6674797061766973) ok=1 ;; esac ;;
      esac
      [ $ok -eq 0 ] && reasons="$reasons; content does not match .$ext magic bytes (first bytes ${mh:-unreadable})"
    fi
    [ -n "$reasons" ] && report "$rel -${reasons#;}"
  done < <(find "$Q" -mindepth 1 \( -type f -o -type l \) -print0 2>/dev/null | sort -z)
  say "Files examined: $QFILES"
  [ "$IND" -eq 0 ] && say "No suspicious files in the upload folder (files were read, never executed)."
else
  say "Not present. (No custom-option uploads stored locally; if media is on remote storage or a CDN origin, check there.)"
fi

hdr "PHP / script files anywhere under pub/media/ (Magento ships none)"
PMEDIA="$ROOT/pub/media"; before=$IND
if [ -d "$PMEDIA" ]; then
  while IFS= read -r -d '' f; do
    case "$f" in "$Q"/*) continue ;; esac
    report "${f#"$ROOT/"} - PHP/script file under pub/media"
  done < <(find "$PMEDIA" -type f \( -iname '*.php' -o -iname '*.php[0-9]' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.pht' -o -iname '*.php.*' -o -iname '*.phtml.*' \) -print0 2>/dev/null)
  [ "$IND" -eq "$before" ] && say "None found."
else
  say "pub/media not present under $ROOT."
fi
[ "$listed" -gt "$MAXLIST" ] && say "(only the first $MAXLIST indicators are listed; total $IND)"

# Apache .htaccess files shipped by Magento (informational)
hdr "Magento's own .htaccess files (Apache only; informational)"
if [ -f "$PMEDIA/.htaccess" ]; then
  if grep -Eqi 'php_flag[[:space:]]+engine[[:space:]]+(0|off)' "$PMEDIA/.htaccess" && grep -qi 'SetHandler default-handler' "$PMEDIA/.htaccess"; then
    say "pub/media/.htaccess: present with 'php_flag engine 0' and 'SetHandler default-handler' (Adobe default)"
  else say "pub/media/.htaccess: present but without Adobe's PHP-disabling directives - compare with Adobe's file"; fi
else say "pub/media/.htaccess: absent (only matters on Apache with AllowOverride)"; fi
if [ -f "$PMEDIA/custom_options/.htaccess" ]; then
  if grep -Eqi 'Require all denied|deny from all' "$PMEDIA/custom_options/.htaccess"; then say "pub/media/custom_options/.htaccess: present, denies all (Adobe default)"
  else say "pub/media/custom_options/.htaccess: present but does not deny access"; fi
else say "pub/media/custom_options/.htaccess: absent"; fi

# ---------------- web server config (informational) ----------------
web_nginx() {
  local f=$1
  sed -e 's/#.*$//' "$f" | tr '\r\n' '  ' | awk '
    function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); gsub(/[ \t]+/, " ", s); return s }
    function inner(   i) { for (i = d; i >= 1; i--) if (st[i] ~ /^location[ ]/) return st[i]; return "" }
    { s = s $0 " " }
    END {
      d = 0; b = ""
      for (i = 1; i <= length(s); i++) {
        c = substr(s, i, 1)
        if (c == "{") { st[++d] = trim(b); b = "" }
        else if (c == "}") { if (d > 0) d--; b = "" }
        else if (c == ";") {
          t = trim(b); b = ""
          if (t ~ /^fastcgi_pass[ ]/) print "FCGI\t" inner()
          else if (t == "deny all") print "DENY\t" inner()
        } else b = b c
      }
    }'
}
check_web() {
  local f=$1 kind=""
  if grep -Eq '(^|[[:space:];{])(location|fastcgi_pass|server_name|try_files)[[:space:]]' "$f"; then kind=nginx
  elif grep -Eqi '<(Directory|Files|FilesMatch|IfModule|VirtualHost|Location)|php_(admin_)?(flag|value)|SetHandler|Require all|Deny from' "$f"; then kind=apache; fi
  hdr "Web server config: $f (${kind:-unrecognised})"
  if [ "$kind" = nginx ]; then
    local out generic=0 nf=0 co=0 coc=0 mphp=0 h
    out=$(web_nginx "$f")
    while IFS=$'\t' read -r typ h; do
      [ -z "$typ" ] && continue
      if [ "$typ" = FCGI ]; then
        nf=$((nf+1))
        if [ -z "$h" ]; then say "fastcgi_pass outside any location block: every request may reach PHP-FPM"; generic=1
        elif printf '%s' "$h" | grep -Eq '\^/\(?[A-Za-z0-9_|/-]+\)?\\?\.php|=[[:space:]]*/[A-Za-z0-9_/-]+\.php'; then say "fastcgi_pass limited to named entry points: $h"
        elif printf '%s' "$h" | grep -Eqi 'php' || [ "$h" = "location /" ]; then say "fastcgi_pass for a generic PHP pattern: $h"; generic=1
        else say "fastcgi_pass in: $h (review)"; fi
      else
        if printf '%s' "$h" | grep -q 'custom_options'; then co=1; printf '%s' "$h" | grep -q '\^~' && coc=1; fi
        printf '%s' "$h" | grep -q 'media' && printf '%s' "$h" | grep -qi 'php' && mphp=1
      fi
    done <<< "$out"
    [ "$nf" -eq 0 ] && say "No fastcgi_pass in this file (it may sit in an included file; check those too)."
    if [ "$generic" -eq 1 ] && [ "$coc" -eq 0 ] && [ "$mphp" -eq 0 ]; then
      say "[!] WEB-CONFIG RISK: a generic PHP location passes any .php (including under /media/) to PHP-FPM. A plain prefix"
      say "    'location /media/custom_options/ { deny all; }' does NOT override a regex PHP location in nginx. Use Adobe's"
      say "    sample: only ^/(index|get|static|errors/report|errors/404|errors/503|health_check)\\.php\$ goes to PHP."
    elif [ "$generic" -eq 1 ]; then
      say "[~] WEB-CONFIG WARN: generic PHP location present, but a ^~ custom_options deny or a /media/ PHP deny is set; verify order."
    fi
    if [ "$co" -eq 0 ]; then say "[!] WEB-CONFIG RISK: no 'deny all' for /media/custom_options/ (Adobe's sample denies it): uploaded files can be fetched directly."; fi
    if [ "$generic" -eq 0 ] && [ "$co" -eq 1 ] && [ "$nf" -gt 0 ]; then say "WEB-CONFIG OK: PHP only for named entry points and /media/custom_options/ denied (matches Adobe's sample)."; fi
  elif [ "$kind" = apache ]; then
    local fl eng=0 hnd=0 co=0 bad=0 fpm=0
    fl=$(sed -e 's/#.*$//' "$f" | tr '\r\n' '  ')
    printf '%s' "$fl" | grep -Eqi 'php_(admin_)?flag[[:space:]]+engine[[:space:]]+(0|off)|php_admin_value[[:space:]]+engine[[:space:]]+off' && eng=1
    printf '%s' "$fl" | grep -Eqi '<FilesMatch[^>]*ph[^>]*>[^<]*SetHandler[[:space:]]+default-handler|RemoveHandler[^<]*\.php|RemoveType[^<]*\.php' && hnd=1
    if printf '%s' "$fl" | grep -Eqi 'custom_options[^<]*>[^<]*(Require all denied|Deny from all)' || \
       { printf '%s' "$f" | grep -q 'custom_options' && printf '%s' "$fl" | grep -Eqi 'Require all denied|Deny from all'; }; then co=1; fi
    printf '%s' "$fl" | grep -Eqi '(AddHandler|AddType|SetHandler)[^<>]*php[^<>]*\.(jpe?g|png|gif|webp|bmp)([[:space:]]|$)' && bad=1
    printf '%s' "$fl" | grep -Eqi 'proxy:(unix|fcgi)' && fpm=1
    say "php engine off: $eng | PHP handler reset (SetHandler default-handler / RemoveHandler): $hnd | custom_options denied: $co | PHP-FPM via proxy: $fpm"
    [ "$bad" -eq 1 ] && say "[!] WEB-CONFIG RISK: a PHP handler or type is mapped to an image extension."
    if [ "$hnd" -eq 0 ] && [ "$co" -eq 0 ]; then
      if [ "$eng" -eq 1 ] && [ "$fpm" -eq 0 ]; then say "[~] WEB-CONFIG WARN: only 'php_flag engine 0' (works for mod_php only); add Adobe's SetHandler default-handler block and deny custom_options."
      else say "[!] WEB-CONFIG RISK: PHP execution under pub/media is not denied in this file (no handler reset and no custom_options deny)."; fi
    elif [ "$bad" -eq 0 ]; then say "WEB-CONFIG OK: PHP under pub/media is neutralised or custom_options is denied in this file (requires AllowOverride if it is an .htaccess)."; fi
  else
    say "Could not tell whether this is an nginx or Apache config; nothing checked."
  fi
}
for f in "${WEBCFG[@]}"; do check_web "$f"; done

# ---------------- access log hunt ----------------
readlog() { case "$1" in *.gz) zcat -- "$1" 2>/dev/null ;; *) cat -- "$1" 2>/dev/null ;; esac; }
for L in "${ALOGS[@]}"; do
  hdr "Access log: $L (combined/common log format assumed)"
  res=$(readlog "$L" | awk '
    {
      ip = $1
      if (!match($0, /"(GET|POST|PUT|HEAD|DELETE|PATCH|OPTIONS) [^ "]+/)) next
      req = substr($0, RSTART + 1, RLENGTH - 1); rest = substr($0, RSTART + RLENGTH)
      sp = index(req, " "); m = substr(req, 1, sp - 1); u = substr(req, sp + 1)
      st = "-"; if (match(rest, /" [0-9][0-9][0-9]/)) st = substr(rest, RSTART + 2, 3)
      lu = tolower(u)
      if ((m == "POST" || m == "PUT") && lu ~ /\/v1\/guest-carts\/[^\/?]+\/items(\/[0-9]+)?([?]|$)/) { seen[ip] = 1; print "GC\t" NR "\t" ip "\t" m "\t" st "\t" u; next }
      if (lu ~ /\/media\/custom_options\/quote\//) { print (seen[ip] ? "SEQ" : "QF") "\t" NR "\t" ip "\t" m "\t" st "\t" u; next }
      if (lu ~ /\/media\/.*\.(php[0-9]?|phtml|phar|pht)([?\/;]|$)/) print "MP\t" NR "\t" ip "\t" m "\t" st "\t" u
    }')
  gc=$(printf '%s\n' "$res" | grep -c '^GC	')
  say "Guest-cart item POST/PUT requests: $gc (normal shopping traffic uses these too; informational)"
  if [ "$gc" -gt 0 ]; then
    say "  Top sources:"; printf '%s\n' "$res" | awk -F'\t' '$1=="GC"{c[$3]++} END{for(i in c) print c[i], i}' | sort -rn | head -n 5 | sed 's/^/    /'
  fi
  nq=0; np=0
  while IFS=$'\t' read -r typ ln ip m st u; do
    case "$typ" in QF|SEQ|MP) ;; *) continue ;; esac
    what="request into /media/custom_options/quote/"; [ "$typ" = MP ] && what="PHP file requested under /media/"
    [ "$typ" = SEQ ] && what="$what AFTER a guest-cart item POST/PUT from the same IP"
    case "$st" in
      2??|-) report "access log line $ln: $ip $m $u -> $st ($what)"; nq=$((nq+1)) ;;
      *) say "[~] probe (not served, status $st) line $ln: $ip $m $u ($what)"; np=$((np+1)) ;;
    esac
  done <<< "$res"
  [ "$nq" -eq 0 ] && [ "$np" -eq 0 ] && say "No requests into the upload folder and no PHP requests under /media/."
  [ "$np" -gt 0 ] && say "$np probe(s) were refused or not found: informational, but they show the store is being targeted."
done

# ---------------- summary ----------------
hdr "Summary"
say "PATCHED=$PATCHED LIKELY_VULNERABLE=$VULN UNKNOWN=$UNK indicators=$IND"
if   [ "$VULN" -gt 0 ]; then ov="LIKELY VULNERABLE"; ec=1
elif [ "$UNK" -gt 0 ];  then ov="UNKNOWN"; ec=2
elif [ "$PATCHED" -gt 0 ]; then ov="PATCHED"; ec=0
else ov="UNKNOWN"; ec=2; fi
if [ "$IND" -gt 0 ]; then
  say "VERSION VERDICT: $ov"
  say "OVERALL: INDICATORS FOUND ($IND) - treat the store as possibly compromised (version verdict: $ov)"
  say "  Keep logs and copies of the files for investigation; do not just delete them (see README.md)."
  ec=1
else
  say "OVERALL: $ov"
fi
status_block
exit "$ec"
