#!/usr/bin/env bash
# check-faspex.sh: strictly read-only, offline exposure check for IBM Aspera Faspex CVE-2026-123218
# (incorrect authorization, CWE-863, crafted message gives full admin; affected 5.0.0 to 5.0.19; fixed 5.0.19 PL1).
# Reads files and arguments only. No network, no writes, no payloads, never contacts Faspex.
#
# Usage:
#   check-faspex.sh --version-file FILE [--host NAME] [--log FILE ...]
#   check-faspex.sh --core "5.0.19 (1ce4b13, Fix Pack: 1)" [--host NAME] [--log FILE ...]
#   check-faspex.sh --csv INVENTORY.csv [--log FILE ...]
#   check-faspex.sh --log FILE [--log FILE ...]
# FILE is saved output of "faspexctl version" (run it yourself as root on the host and save it).
# --core takes the faspex-core value from that output, for example "5.0.19 (1ce4b13, Fix Pack: 1)".
# --log takes a saved web access log in nginx combined format (for example from faspex-router or a reverse proxy).
# CSV header: host,faspex_core   (faspex_core holds the same text as --core)
# Exit: 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN, 3 NOT AFFECTED, 4 LOG REVIEW (overrides), 5 usage/read error.
# CSV mode exits with the worst row (1 > 2 > 0 > 3). --log alone exits 2 when nothing is found (version not checked).
set -u
export LC_ALL=C

FIX_VER=5.0.19
FIX_COMMIT=1ce4b13
BULK=20   # our threshold: download transfer specs per source IP per file

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

# cmp_ver A B -> prints -1, 0 or 1 (numeric, three part)
cmp_ver() {
  awk -v a="$1" -v b="$2" 'BEGIN { split(a, x, "."); split(b, y, ".");
    for (i = 1; i <= 3; i++) { if (x[i] + 0 < y[i] + 0) { print -1; exit } if (x[i] + 0 > y[i] + 0) { print 1; exit } } print 0 }'
}

# evaluate HOST CORE_TEXT -> prints verdict line, returns exit code
evaluate() {
  local host=$1 core=$2 ver commit fp lo hi
  ver=$(printf '%s' "$core" | grep -oE '^[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
  if [ -z "$ver" ]; then
    echo "UNKNOWN: $host: cannot read a faspex-core version from \"$core\". Run faspexctl version as root and supply its output."
    return 2
  fi
  commit=$(printf '%s' "$core" | sed -nE 's/^[^(]*\(([0-9a-fA-F]{7,40}).*/\1/p' | tr 'A-F' 'a-f')
  fp=$(printf '%s' "$core" | sed -nE 's/.*[Ff]ix [Pp]ack:?[[:space:]]*([0-9]+).*/\1/p')
  lo=$(cmp_ver "$ver" 5.0.0); hi=$(cmp_ver "$ver" "$FIX_VER")
  if [ "$lo" -lt 0 ]; then
    echo "NOT AFFECTED: $host: faspex-core $ver is below 5.0.0, outside IBM's affected range 5.0.0 to 5.0.19. Faspex 4 is out of support, so plan a move to a fixed Faspex 5."
    return 3
  fi
  if [ "$hi" -gt 0 ]; then
    echo "NOT AFFECTED: $host: faspex-core $ver is above 5.0.19, outside IBM's affected range. Confirm against the IBM bulletin."
    return 3
  fi
  if [ "$hi" -lt 0 ]; then
    echo "VULNERABLE: $host: faspex-core $ver is in IBM's affected range 5.0.0 to 5.0.19. Upgrade to 5.0.19 PL1 and check faspex-core reads 5.0.19 (1ce4b13, Fix Pack: 1)."
    return 1
  fi
  # exactly 5.0.19
  if [ "$commit" = "$FIX_COMMIT" ] && { [ -z "$fp" ] || [ "$fp" -ge 1 ]; }; then
    echo "PATCHED: $host: faspex-core 5.0.19 build $commit${fp:+, Fix Pack $fp} matches IBM's fixed image (1ce4b13, Fix Pack: 1)."
    return 0
  fi
  if [ -n "$fp" ] && [ "$fp" -ge 1 ]; then
    echo "UNKNOWN: $host: faspex-core 5.0.19 Fix Pack $fp but build ${commit:-missing} is not 1ce4b13. IBM only names 1ce4b13, so confirm this build with IBM."
    return 2
  fi
  echo "VULNERABLE: $host: faspex-core 5.0.19 build ${commit:-unknown} without Fix Pack 1 (IBM's fixed image is 1ce4b13, Fix Pack: 1). Apply 5.0.19 PL1."
  return 1
}

core_from_file() {
  sed -nE 's/^[[:space:]]*faspex-core:[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$1" | head -n1
}

check_csv() {
  local f=$1 worst=-1 line host core rc n=0
  [ -r "$f" ] || die "cannot read: $f"
  head -n1 "$f" | tr -d '\r' | grep -qE '^host,faspex_core$' || die "CSV header must be: host,faspex_core"
  while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}
    [ -z "$line" ] && continue
    host=${line%%,*}; core=${line#*,}; core=${core#\"}; core=${core%\"}
    n=$((n+1))
    evaluate "$host" "$core"; rc=$?
    worst=$(rank_worst "$worst" "$rc")
  done < <(tail -n +2 "$f")
  [ "$n" -gt 0 ] || die "CSV has no rows: $f"
  return "$worst"
}

# rank 1 > 2 > 0 > 3
rank() { case $1 in 1) echo 4;; 2) echo 3;; 0) echo 2;; 3) echo 1;; *) echo 0;; esac; }
rank_worst() { if [ "$(rank "$2")" -gt "$(rank "$1")" ]; then echo "$2"; else echo "$1"; fi; }

# scan_log FILE: our own hunting patterns (not IBM IOCs). Prints findings; returns 0 if any review-worthy hit.
scan_log() {
  awk -v file="$1" -v bulk="$BULK" '
    { ip = $1; req = $0; sub(/^[^"]*"/, "", req); sub(/".*/, "", req); split(req, r, " "); path = r[2]
      st = $0; sub(/^[^"]*"[^"]*" /, "", st); split(st, s, " "); status = s[1] }
    path ~ /^\/aspera\/faspex\/api\/v5\// {
      p = tolower(path)
      if (p ~ /\/api\/v5\/(users|admin|configuration|configurations|smtp|ldap|saml|api_clients|registration|accounts)([\/?]|$)/ && status ~ /^2/) {
        adm[ip]++; any = 1 }
      if (p ~ /\/api\/v5\/all\/packages/ && status ~ /^2/) { allp[ip]++; any = 1 }
      if (p ~ /\/transfer_spec\/download/ && status ~ /^2/) dl[ip]++
    }
    END {
      for (i in adm) printf "%s: [admin API path 2xx] %d request(s) from %s\n", file, adm[i], i
      for (i in allp) printf "%s: [all packages admin search 2xx] %d request(s) from %s\n", file, allp[i], i
      for (i in dl) if (dl[i] >= bulk) { printf "%s: [bulk download specs] %d request(s) from %s (our threshold %d)\n", file, dl[i], i, bulk; any = 1 }
      exit any ? 0 : 1
    }' "$1" | sort
  return "${PIPESTATUS[0]}"
}

vfile="" core="" host="" csv="" logs=()
while [ $# -gt 0 ]; do
  case $1 in
    --version-file) [ $# -ge 2 ] || usage; vfile=$2; shift 2;;
    --core) [ $# -ge 2 ] || usage; core=$2; shift 2;;
    --host) [ $# -ge 2 ] || usage; host=$2; shift 2;;
    --csv) [ $# -ge 2 ] || usage; csv=$2; shift 2;;
    --log) [ $# -ge 2 ] || usage; logs+=("$2"); shift 2;;
    -h|--help) usage;;
    *) usage;;
  esac
done
[ -n "$vfile$core$csv" ] || [ ${#logs[@]} -gt 0 ] || usage
n_in=0; for x in "$vfile" "$core" "$csv"; do [ -n "$x" ] && n_in=$((n_in+1)); done
[ "$n_in" -le 1 ] || die "use only one of --version-file, --core or --csv"

rc=0; had_ver=0
if [ -n "$csv" ]; then
  had_ver=1; check_csv "$csv"; rc=$?
elif [ -n "$vfile" ]; then
  had_ver=1
  [ -r "$vfile" ] || die "cannot read: $vfile"
  [ -n "$host" ] || host=$(basename "$vfile")
  c=$(core_from_file "$vfile")
  if [ -z "$c" ]; then echo "UNKNOWN: $host: no faspex-core line in $vfile. Supply the output of faspexctl version."; rc=2
  else evaluate "$host" "$c"; rc=$?; fi
elif [ -n "$core" ]; then
  had_ver=1; evaluate "${host:-host}" "$core"; rc=$?
fi
[ "$rc" -eq 5 ] && exit 5

hits=0
for lf in "${logs[@]+"${logs[@]}"}"; do
  [ -r "$lf" ] || die "cannot read: $lf"
  if out=$(scan_log "$lf"); then printf '%s\n' "$out"; hits=1
  elif [ -n "$out" ]; then printf '%s\n' "$out"; fi
done
if [ ${#logs[@]} -gt 0 ]; then
  if [ "$hits" -eq 1 ]; then
    echo "LOG REVIEW: admin API or bulk download activity found (our patterns, not IBM indicators). Check which account made each request and whether it is a real admin. If not, isolate the server, keep logs and container images, rotate Faspex and linked credentials, and check for data taken."
    exit 4
  fi
  echo "LOGS: no hits for our patterns in ${#logs[@]} file(s). Absence is not proof: access logs do not show which account or role made a request."
fi
[ "$had_ver" -eq 1 ] || exit 2
exit "$rc"
