#!/usr/bin/env bash
# check-ninja-forms-uploads.sh: strictly read-only, offline check for CVE-2026-126537 in the Ninja Forms File Uploads add-on for
# WordPress (slug ninja-forms-uploads). The CNA rates it Critical, CVSS 3.1 9.1. The CVE record lists versions up to and including
# 3.3.36 as affected; Ninja Forms' changelog shows 3.3.37 on 2 Nov 2026. It reads plug-in headers under a WordPress folder, or a
# saved "wp plugin list" CSV or JSON file, or a version you pass. No network, no file writes, nothing is run (not even wp-cli).
#
# Usage:
#   check-ninja-forms-uploads.sh --path WPROOT [--name NAME]    WordPress root (the folder holding wp-content)
#   check-ninja-forms-uploads.sh --list FILE [--name NAME]      output of: wp plugin list --format=csv  (or --format=json)
#   check-ninja-forms-uploads.sh --version VER [--name NAME]    the add-on's version
# Verdicts: PATCHED (3.3.37 or above), VULNERABLE (3.3.36 or below), NOT LISTED (add-on not found; core Ninja Forms alone is
#   not listed as affected in the CVE record), UNKNOWN (version missing or unreadable, or between 3.3.36 and 3.3.37).
# --path also checks, read only, that wp-config.php exists where WordPress looks for it (the root, or one folder up).
# Exit: 0 PATCHED or NOT LISTED, 1 VULNERABLE, 3 UNKNOWN, 4 usage or read error,
#   5 wp-config.php missing (with --path, when the verdict is not VULNERABLE).
set -u
SLUG=ninja-forms-uploads

usage() {
  cat >&2 <<'U'
Usage: check-ninja-forms-uploads.sh --path WPROOT [--name NAME]
       check-ninja-forms-uploads.sh --list FILE [--name NAME]
       check-ninja-forms-uploads.sh --version VER [--name NAME]
  --path     WordPress root folder (holds wp-content); headers are read, nothing is run
  --list     saved output of "wp plugin list --format=csv" or "--format=json"
  --version  the File Uploads add-on version, such as 3.3.37
  --name     label for the output (default: input)
Exit: 0 PATCHED or NOT LISTED, 1 VULNERABLE, 3 UNKNOWN, 4 usage or read error, 5 wp-config.php missing
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
lc() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
trim() { local s=$1; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
unq() { local s; s=$(trim "$1"); s=${s#\"}; s=${s%\"}; printf '%s' "$s"; }
say() { printf '%s: %s: %s\n' "$1" "$2" "$3"; }
NL_CORE="Core Ninja Forms is not listed as affected in the CVE record, which names only the File Uploads add-on"

judge() { # where version extra
  local w=$1 v e=$3 a b c d
  v=$(trim "$2")
  if [ -z "$v" ]; then say UNKNOWN "$w" "File Uploads add-on found but no version given${e}."; return; fi
  if ! [[ $v =~ ^([0-9]{1,4})\.([0-9]{1,4})\.([0-9]{1,4})(\.([0-9]{1,4}))?$ ]]; then
    say UNKNOWN "$w" "File Uploads add-on version \"$v\" is not one this pack can judge (use the form 3.3.37)${e}."; return
  fi
  a=$((10#${BASH_REMATCH[1]})); b=$((10#${BASH_REMATCH[2]})); c=$((10#${BASH_REMATCH[3]})); d=0
  [ -n "${BASH_REMATCH[5]}" ] && d=$((10#${BASH_REMATCH[5]}))
  local k=$(( ((a*10000 + b)*10000 + c)*10000 + d ))
  local lo=$(( ((3*10000 + 3)*10000 + 36)*10000 )) hi=$(( ((3*10000 + 3)*10000 + 37)*10000 ))
  if [ "$k" -le "$lo" ]; then say VULNERABLE "$w" "File Uploads add-on $v is in the affected range (up to and including 3.3.36) for CVE-2026-126537${e}. Update to 3.3.37 or above."; return; fi
  if [ "$k" -ge "$hi" ]; then say PATCHED "$w" "File Uploads add-on $v is 3.3.37 or above, outside the affected range for CVE-2026-126537${e}."; return; fi
  say UNKNOWN "$w" "File Uploads add-on $v sits between 3.3.36 and 3.3.37, which the sources do not cover${e}. Update to 3.3.37 or above."
}

header() { # file field: prints the first matching plug-in header value from the first 8 KB, WordPress style
  local v
  v=$(head -c 8192 -- "$1" | tr -d '\r' | grep -m1 -iE "^([[:space:]]*<\?php)?[[:space:]/*#@]*$2:" | sed -E "s/^([[:space:]]*<\?php)?[[:space:]/*#@]*$2:(.*)$/\2/I")
  v=$(trim "$v"); v=${v%\?>}; v=$(trim "$v"); v=${v%\*/}; trim "$v"
}

from_path() { # root name
  local r=${1%/} n=$2 p m nm ver
  [ -d "$r" ] || die "not a folder: $1"
  [ -d "$r/wp-content/plugins" ] || die "no wp-content/plugins folder in $1 (pass the WordPress root)"
  p="$r/wp-content/plugins/$SLUG"; m="$p/file-uploads.php"
  if [ -d "$p" ]; then
    if [ -f "$m" ]; then
      [ -r "$m" ] || die "cannot read: $m"
      nm=$(header "$m" "Plugin Name"); ver=$(header "$m" "Version")
      judge "$n ($m)" "$ver" "${nm:+, plug-in name \"$nm\"}"
    else
      say UNKNOWN "$n ($p)" "the $SLUG folder exists but $SLUG/file-uploads.php is missing, so the version cannot be read."
    fi
  elif [ -f "$r/wp-content/plugins/ninja-forms/ninja-forms.php" ]; then
    say "NOT LISTED" "$n ($r)" "core Ninja Forms found, File Uploads add-on ($SLUG) not found. $NL_CORE."
  else
    say "NOT LISTED" "$n ($r)" "File Uploads add-on ($SLUG) not found under wp-content/plugins."
  fi
  # wp-config.php: WordPress loads it from the root, or from one folder up when that folder has no wp-settings.php (wp-load.php).
  local up=${r%/*}
  [ "$up" = "$r" ] && up=.
  if [ -f "$r/wp-config.php" ]; then echo "WP-CONFIG: found: $r/wp-config.php"
  elif [ -f "$up/wp-config.php" ] && [ ! -f "$up/wp-settings.php" ]; then echo "WP-CONFIG: found one folder up: $up/wp-config.php"
  else echo "WP-CONFIG: MISSING: no wp-config.php in $r or one folder up. WordPress would show its setup screen. Restore it from a backup you trust and investigate (CVE-2026-126537 can delete it)."; fi
}

from_list() { # file name
  local f=$1 n=$2 raw first rows="" line name ver st hdr i ni=-1 vi=-1 si=-1 cols
  if [ ! -f "$f" ] || [ ! -r "$f" ]; then die "cannot read list file: $f"; fi
  raw=$(tr -d '\r' < "$f") || die "cannot read list file: $f"
  first=$(trim "$raw"); first=${first:0:1}
  if [ "$first" = "[" ]; then
    while IFS= read -r line; do
      [ -n "$line" ] || continue
      name=$(printf '%s' "$line" | grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/')
      ver=$(printf '%s' "$line" | grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/')
      st=$(printf '%s' "$line" | grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/')
      [ -n "$name" ] || continue
      rows+="$name|$ver|$st"$'\n'
    done < <(printf '%s' "$raw" | tr '\n' ' ' | grep -oE '\{[^{}]*\}')
    [ -n "$rows" ] || die "no plug-in objects with a name in JSON list: $f"
  else
    hdr=$(printf '%s\n' "$raw" | head -n1)
    IFS=, read -r -a cols <<< "$hdr"
    for i in "${!cols[@]}"; do
      case $(lc "$(unq "${cols[$i]}")") in name) ni=$i;; version) vi=$i;; status) si=$i;; esac
    done
    { [ "$ni" -ge 0 ] && [ "$vi" -ge 0 ]; } || die "CSV list needs name and version columns (wp plugin list --format=csv): $f"
    while IFS= read -r line; do
      case $(trim "$line") in ''|\#*) continue;; esac
      IFS=, read -r -a cols <<< "$line"
      name=$(unq "${cols[$ni]:-}"); ver=$(unq "${cols[$vi]:-}"); st=""
      [ "$si" -ge 0 ] && st=$(unq "${cols[$si]:-}")
      [ -n "$name" ] || continue
      rows+="$name|$ver|$st"$'\n'
    done < <(printf '%s\n' "$raw" | tail -n +2)
    [ -n "$rows" ] || die "CSV list has no plug-in rows: $f"
  fi
  local hit=0 core=0
  while IFS='|' read -r name ver st; do
    [ -n "$name" ] || continue
    if [ "$name" = "$SLUG" ]; then hit=1; judge "$n ($SLUG)" "$ver" "${st:+, status $st}"; fi
    [ "$name" = ninja-forms ] && core=1
  done <<< "$rows"
  if [ "$hit" -eq 0 ]; then
    if [ "$core" -eq 1 ]; then say "NOT LISTED" "$n (plug-in list)" "core Ninja Forms listed, File Uploads add-on ($SLUG) not listed. $NL_CORE."
    else say "NOT LISTED" "$n (plug-in list)" "File Uploads add-on ($SLUG) not in the plug-in list."; fi
  fi
}

mode="" arg="" name="input" nseen=0
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in
    --path|--list|--version) [ -z "$mode" ] || usage; mode=${1#--}; arg=$2;;
    --name) [ "$nseen" -eq 0 ] || usage; name=$2; nseen=1;;
    *) usage;;
  esac
  shift 2
done
[ -n "$mode" ] || usage

# Read everything before printing anything, so read errors leave stdout empty.
case $mode in
  path) out=$(from_path "$arg" "$name") || exit 4;;
  list) out=$(from_list "$arg" "$name") || exit 4;;
  version) out=$(judge "$name (version given)" "$arg" "");;
esac
NP=$(printf '%s\n' "$out" | grep -c '^PATCHED: ')
NV=$(printf '%s\n' "$out" | grep -c '^VULNERABLE: ')
NN=$(printf '%s\n' "$out" | grep -c '^NOT LISTED: ')
NU=$(printf '%s\n' "$out" | grep -c '^UNKNOWN: ')
NC=$(printf '%s\n' "$out" | grep -c '^WP-CONFIG: MISSING')

echo "check-ninja-forms-uploads: offline check for CVE-2026-126537 in the Ninja Forms File Uploads add-on (affected up to and including 3.3.36; 3.3.37 shipped 2 Nov 2026)."
echo "KEV: CVE-2026-126537 is not in CISA KEV catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT), which predates the CVE record (published 11 Nov 2026 12:34 GMT)."
printf '%s\n' "$out"
if [ "$NV" -gt 0 ]; then res=VULNERABLE; rc=1
elif [ "$NC" -gt 0 ]; then res="WP-CONFIG MISSING"; rc=5
elif [ "$NU" -gt 0 ]; then res=UNKNOWN; rc=3
elif [ "$NP" -gt 0 ]; then res=PATCHED; rc=0
else res="NOT LISTED"; rc=0; fi
echo "RESULT: $res ($NP PATCHED, $NV VULNERABLE, $NN NOT LISTED, $NU UNKNOWN)"
exit "$rc"
