#!/usr/bin/env bash
# check-rails-activestorage.sh: strictly read-only, offline exposure check for GHSA-f3w6-w445-fwh9
# (Active Storage image analysis, arbitrary file write and read via ImageMagick coders).
# Reads files only. No network, no writes, no exploit payloads.
#
# Usage:
#   check-rails-activestorage.sh --app APP_DIR [--policy POLICY_XML] [--storage STORAGE_DIR]
#   check-rails-activestorage.sh --csv INVENTORY.csv
#   check-rails-activestorage.sh --version X.Y.Z[.P] [--processor mini_magick|vips] [--policy POLICY_XML]
# CSV header: host,activestorage_version,variant_processor,policy
#   (variant_processor: mini_magick|vips|unknown, policy: blocks|open|unknown)
# Exit: 0 PATCHED, 1 LIKELY VULNERABLE, 2 UNKNOWN, 3 NOT EXPOSED, 5 usage or read error.
# CSV mode exits with the worst row (1 > 2 > 3 > 0).
set -u
export LC_ALL=C

ADV="GHSA-f3w6-w445-fwh9"
REQ_CODERS="MSL PS PS2 PS3 EPS EPS2 EPS3 EPI EPSF EPSI EPT EPT2 EPT3 PDF AI EPDF PDFA POCKETMOD XPS PCL"

usage() { sed -n '5,12p' "$0" | sed 's/^# \{0,1\}//'; exit 5; }
die() { echo "ERROR: $*" >&2; exit 5; }

# ver_ge A B: true if A >= B (numeric dotted versions)
ver_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }

# version_state V -> prints PATCHED|VULN|PRERELEASE|BAD
version_state() {
  local v=$1
  if ! printf '%s' "$v" | grep -Eq '^[0-9]+(\.[0-9]+){1,3}$'; then
    if printf '%s' "$v" | grep -Eq '^[0-9]+(\.[0-9]+)+\.?[A-Za-z]'; then echo PRERELEASE; else echo BAD; fi
    return
  fi
  if ver_ge "$v" 8.1; then
    if ver_ge "$v" 8.1.4.1; then echo PATCHED; else echo VULN; fi
  else
    if ver_ge "$v" 8.0.5.2; then echo PATCHED; else echo VULN; fi
  fi
}

eol_note() { # V
  local v=$1
  if printf '%s' "$v" | grep -Eq '^[0-9]' && ! ver_ge "$v" 8.0; then
    echo "EOL: Rails $v is below 8.0. That series is unsupported (rubyonrails.org/maintenance) and has no fixed release for $ADV. Upgrade to 8.0.5.2 or 8.1.4.1+."
  elif printf '%s' "$v" | grep -Eq '^8\.0(\.|$)'; then
    echo "NOTE: Rails 8.0 security support ends 2026-11-07 (rubyonrails.org/maintenance). Plan a move to 8.1."
  fi
}

# policy_state FILE -> prints BLOCKS|PARTIAL|OPEN, and the missing coders on stderr-free second line
policy_state() {
  local f=$1 body tags coders="" deleg="" missing="" c
  [ -r "$f" ] || { echo "UNREADABLE"; return; }
  body=$(perl -0777 -pe 's/<!--.*?-->//gs' "$f" 2>/dev/null) || { echo "UNREADABLE"; return; }
  tags=$(printf '%s' "$body" | tr '\n' ' ' | grep -oiE '<policy[^>]*>')
  while IFS= read -r t; do
    [ -n "$t" ] || continue
    local dom rights pat
    dom=$(printf '%s' "$t" | sed -nE 's/.*domain="([^"]*)".*/\1/Ip' | tr '[:lower:]' '[:upper:]')
    rights=$(printf '%s' "$t" | sed -nE 's/.*rights="([^"]*)".*/\1/Ip' | tr '[:lower:]' '[:upper:]')
    pat=$(printf '%s' "$t" | sed -nE 's/.*pattern="([^"]*)".*/\1/Ip' | tr '[:lower:]' '[:upper:]' | tr -d '{} ' | tr ',' ' ')
    [ "$rights" = "NONE" ] || continue
    case $dom in
      CODER) coders="$coders $pat" ;;
      DELEGATE) deleg="$deleg $pat" ;;
    esac
  done <<< "$tags"
  for c in $REQ_CODERS; do
    case " $coders " in *" $c "*|*" * "*) ;; *) missing="$missing $c" ;; esac
  done
  case " $deleg " in *" GS "*|*" * "*) ;; *) missing="$missing gs-delegate" ;; esac
  if [ -z "$missing" ]; then echo BLOCKS
  elif case " $coders " in *" MSL "*|*" * "*) true ;; *) false ;; esac; then echo "PARTIAL:${missing# }"
  else echo "OPEN:${missing# }"; fi
}

# decide VERSION PROCESSOR PROCNOTE POLICYSTATE [MINIMAGICK_PRESENT yes|no|unknown] [MM_VERSION]
# Prints verdict line(s), returns exit code.
decide() {
  local v=$1 proc=$2 pnote=$3 pol=$4 mm=${5:-unknown} mmv=${6:-} st
  st=$(version_state "$v")
  case $st in
    BAD) echo "UNKNOWN: activestorage version '$v' could not be parsed"; return 2 ;;
    PRERELEASE) echo "UNKNOWN: pre-release activestorage '$v', compare manually with $ADV"; return 2 ;;
  esac
  eol_note "$v"
  if [ "$st" = PATCHED ]; then
    echo "PATCHED: activestorage $v is outside the affected ranges (< 8.0.5.2, >= 8.1 < 8.1.4.1)"
    if [ "$proc" = mini_magick ] && [ -n "$mmv" ] && ! ver_ge "$mmv" 5.1; then
      echo "WARN: mini_magick $mmv < 5.1. The fixed release raises at boot with :mini_magick unless mini_magick >= 5.1 (advisory)."
    fi
    return 0
  fi
  if [ "$mm" = no ]; then
    echo "NOT EXPOSED: activestorage $v is in the affected range but the mini_magick gem is not in Gemfile.lock, so the ImageMagick analyzer cannot run. Upgrade anyway."
    return 3
  fi
  local analyzer_mm=no
  if [ "$proc" = mini_magick ]; then analyzer_mm=yes
  elif [ "$proc" = vips ] && ! ver_ge "$v" 7.0; then analyzer_mm=yes; pnote="$pnote; activestorage < 7.0 analyzes images with MiniMagick regardless of variant_processor"
  elif [ "$proc" = vips ]; then
    echo "NOT EXPOSED: activestorage $v in affected range, but variant_processor is :vips ($pnote). Still upgrade: GHSA-v924-7rp8-4mxh and other fixes in the same release apply."
    return 3
  else
    echo "UNKNOWN: activestorage $v in affected range; variant_processor could not be determined ($pnote)"
    return 2
  fi
  case $pol in
    BLOCKS)
      echo "NOT EXPOSED: activestorage $v in affected range, ImageMagick in use ($pnote), but the policy denies MSL, the Ghostscript coders and the gs delegate (advisory workaround). Upgrade anyway: the advisory does not assume the reported chain is the only one."
      return 3 ;;
  esac
  local ptxt
  case $pol in
    PARTIAL:*) ptxt="HOLDS: policy denies MSL but not:${pol#PARTIAL:}" ;;
    OPEN:*) ptxt="HOLDS: policy does not deny MSL (missing:${pol#OPEN:})" ;;
    UNREADABLE) ptxt="UNKNOWN: policy file unreadable" ;;
    *) ptxt="NOT CHECKED: no --policy given (Debian and Ubuntu packaged policies do not deny the coders, per advisory)" ;;
  esac
  echo "LIKELY VULNERABLE: activestorage $v is in the affected range for $ADV"
  echo "  condition 1 ImageMagick in use: HOLDS ($pnote)"
  echo "  condition 2 policy does not block coders: $ptxt"
  echo "  condition 3 untrusted uploads: NOT DETERMINABLE offline, confirm with the app owner"
  [ "$analyzer_mm" = yes ] && echo "  fix: upgrade activestorage to 8.0.5.2 or 8.1.4.1+ (with mini_magick >= 5.1); interim: deny the coders in policy.xml or switch to :vips"
  return 1
}

lock_ver() { # LOCK GEM
  sed -nE "s/^    $2 \(([^)]+)\)\r?$/\1/p" "$1" | head -n1
}

app_mode() {
  local app=$1 policy=$2 lock v mmv mm=no ld="" proc="" pnote f
  lock="$app/Gemfile.lock"
  [ -d "$app" ] || die "app dir not found: $app"
  if [ ! -r "$lock" ]; then echo "UNKNOWN: no readable Gemfile.lock in $app"; return 2; fi
  v=$(lock_ver "$lock" activestorage)
  if [ -z "$v" ]; then echo "NOT EXPOSED: activestorage is not in Gemfile.lock"; return 3; fi
  mmv=$(lock_ver "$lock" mini_magick)
  [ -n "$mmv" ] && mm=yes
  echo "INFO: activestorage $v, mini_magick ${mmv:-absent}"
  if [ -r "$app/config/application.rb" ]; then
    ld=$(grep -E '^[[:space:]]*config\.load_defaults' "$app/config/application.rb" | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1)
  else
    echo "UNKNOWN: config/application.rb not readable in $app"; return 2
  fi
  for f in "$app/config/application.rb" "$app"/config/environments/*.rb "$app"/config/initializers/*.rb; do
    [ -r "$f" ] || continue
    local p
    p=$(grep -E '^[[:space:]]*[^#]*variant_processor[[:space:]]*=[[:space:]]*:[a-z_]+' "$f" | grep -oE ':(mini_magick|vips)' | tail -n1 | tr -d ':')
    if [ -n "$p" ]; then
      if [ -n "$proc" ] && [ "$proc" != "$p" ]; then pnote="$pnote; ${f#"$app"/} sets :$p"; [ "$p" = mini_magick ] && proc=mini_magick
      else proc=$p; pnote="explicit :$p in ${f#"$app"/}"; fi
    fi
  done
  if [ -z "$proc" ]; then
    if [ -n "$ld" ] && ver_ge "$ld" 7.0; then proc=vips; pnote="load_defaults $ld default :vips"
    elif [ -n "$ld" ]; then proc=mini_magick; pnote="load_defaults $ld default :mini_magick"
    else proc=mini_magick; pnote="no load_defaults, engine fallback :mini_magick"; fi
  fi
  echo "INFO: load_defaults ${ld:-none}, variant_processor :$proc"
  local pol=NONE
  [ -n "$policy" ] && pol=$(policy_state "$policy")
  decide "$v" "$proc" "$pnote" "$pol" "$mm" "$mmv"
}

storage_scan() { # DIR (read-only listing)
  local d=$1 n=0 f kind head
  [ -d "$d" ] || die "storage dir not found: $d"
  echo "INFO: scanning $d for SVG/MSL/MVG content (read-only, first 512 bytes)"
  while IFS= read -r -d '' f; do
    kind=""
    case ${f,,} in *.svg|*.svgz) kind=svg-ext ;; *.msl) kind=msl-ext ;; *.mvg) kind=mvg-ext ;; esac
    head=$(head -c 512 "$f" 2>/dev/null | tr -d '\0' | tr '[:upper:]' '[:lower:]')
    case $head in
      *"<svg"*) kind="${kind:+$kind,}svg-content" ;;
    esac
    if printf '%s' "$head" | grep -Eq '^(<\?xml[^>]*>)?[[:space:]]*<image[[:space:]>]'; then kind="${kind:+$kind,}msl-like-content"; fi
    if printf '%s' "$head" | grep -Eq '^[[:space:]]*(push[[:space:]]+graphic-context|viewbox[[:space:]])'; then kind="${kind:+$kind,}mvg-like-content"; fi
    if [ -n "$kind" ]; then echo "REVIEW: $f ($kind)"; n=$((n+1)); fi
  done < <(find "$d" -type f -print0 2>/dev/null | sort -z)
  echo "INFO: $n file(s) to review. Presence is not proof of exploitation; no IOCs are primary-published."
}

csv_mode() {
  local csv=$1 hdr worst=0 rc host v proc pol pstate
  [ -r "$csv" ] || die "CSV not readable: $csv"
  hdr=$(head -n1 "$csv" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d ' ')
  [ "$hdr" = "host,activestorage_version,variant_processor,policy" ] || { echo "UNKNOWN: bad CSV header '$hdr' (want host,activestorage_version,variant_processor,policy)"; return 2; }
  local rows=0
  while IFS=, read -r host v proc pol || [ -n "$host" ]; do
    host=${host%$'\r'}; v=${v%$'\r'}; proc=${proc%$'\r'}; pol=${pol%$'\r'}
    [ -n "$host$v" ] || continue
    rows=$((rows+1))
    case ${pol,,} in blocks) pstate=BLOCKS ;; open) pstate="OPEN: inventory reports policy open" ;; *) pstate=NONE ;; esac
    proc=${proc,,}; proc=${proc#:}
    echo "== $host"
    decide "$v" "$proc" "inventory processor ${proc:-blank}" "$pstate" unknown ""; rc=$?
    worst=$(rank "$worst" "$rc")
  done < <(tail -n +2 "$csv")
  [ "$rows" -gt 0 ] || { echo "UNKNOWN: inventory has no rows"; return 2; }
  return "$worst"
}

rank() { # current new -> worst by 1 > 2 > 3 > 0
  local s; for s in 1 2 3 0; do if [ "$1" = "$s" ] || [ "$2" = "$s" ]; then echo "$s"; return; fi; done
}

main() {
  local app="" csv="" ver="" policy="" storage="" proc=""
  [ $# -gt 0 ] || usage
  while [ $# -gt 0 ]; do
    case $1 in
      --app) app=${2:-}; shift 2 ;;
      --csv) csv=${2:-}; shift 2 ;;
      --version) ver=${2:-}; shift 2 ;;
      --processor) proc=${2:-}; shift 2 ;;
      --policy) policy=${2:-}; shift 2 ;;
      --storage) storage=${2:-}; shift 2 ;;
      -h|--help) usage ;;
      *) die "unknown argument: $1" ;;
    esac
  done
  echo "INFO: $ADV, KEV: not listed (CISA KEV catalog 2026.11.03), no CVE assigned at build time"
  local rc
  if [ -n "$csv" ]; then csv_mode "$csv"; rc=$?
  elif [ -n "$app" ]; then app_mode "$app" "$policy"; rc=$?
  elif [ -n "$ver" ]; then
    local pol=NONE; [ -n "$policy" ] && pol=$(policy_state "$policy")
    decide "$ver" "${proc:-unknown}" "given :${proc:-unknown}" "$pol" unknown ""; rc=$?
  elif [ -n "$storage" ]; then rc=0
  else usage; fi
  [ -n "$storage" ] && storage_scan "$storage"
  exit "$rc"
}
main "$@"
