#!/usr/bin/env bash
# check-xen-orchestra.sh: strictly read-only, offline triage for Vates VSA-2026-062 (Xen Orchestra
# empty-object login token, published 10 Nov 2026, no CVE or CVSS assigned, actively exploited per Vates).
# Reads only the values and files you pass. No network, no writes, no payloads, never contacts Xen Orchestra.
#
# Usage:
#   check-xen-orchestra.sh --xo-server VER [--xo-version VER] [--edition xoa|sources] [--host NAME] [--scan FILE ...]
#   check-xen-orchestra.sh --xo-version VER [--edition xoa|sources] [--host NAME] [--scan FILE ...]
#   check-xen-orchestra.sh --csv INVENTORY.csv [--scan FILE ...]
#   check-xen-orchestra.sh --scan FILE [--scan FILE ...]
# CSV header: host,edition,xo_version,xo_server_version   (lines starting # are skipped)
# --scan takes saved text: proxy, WAF or web access logs, tcpdump text, firewall exports, and the saved output of
#   the host checks in the Vates advisory (xe vm-list, ls -la, zgrep of xensource.log).
# Exit: 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN, 3 NEEDS MORE INFO, 4 IOC MATCH (overrides), 5 usage or read error.
# Inventory modes exit with the worst row (1 > 2 > 3 > 0). --scan alone exits 2 when nothing matches.
set -u
export LC_ALL=C

# Fixed xo-server builds named by Vates in VSA-2026-062 (Resolution section, 10 Nov 2026):
#   5.213.1 (latest channel, Xen Orchestra 6.10.0 as updated on 10 Nov) and 5.211.2 (stable channel, 6.9.1 as updated).
C2_IP='91.211.251.112'
C2_ONION='ustar3rqxgnskzgnwkgpofgtvxevm6cx3lxdbwt4mndtveqyaohid.onion'

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

valid_ver() { printf '%s' "$1" | grep -qE '^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$'; }
norm_ver() { local v; v=$(printf '%s' "$1" | tr -d '[:space:]'); v=${v#[vV]}; printf '%s' "$v"; }
norm_ed() {
  local e; e=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
  case $e in
    '') printf '';;
    xoa|appliance) printf 'xoa';;
    sources|source|from-sources|xoce|community) printf 'sources';;
    *) printf 'other';;
  esac
}

N=0; WORST=-1; CP=0; CV=0; CU=0; CN=0
rank() { case $1 in 1) echo 4;; 2) echo 3;; 3) echo 2;; 0) echo 1;; *) echo 0;; esac; }
add() {
  N=$((N+1))
  case $1 in 0) CP=$((CP+1));; 1) CV=$((CV+1));; 2) CU=$((CU+1));; 3) CN=$((CN+1));; esac
  if [ "$WORST" -lt 0 ] || [ "$(rank "$1")" -gt "$(rank "$WORST")" ]; then WORST=$1; fi
}

# evaluate HOST EDITION XO_VERSION XO_SERVER_VERSION
evaluate() {
  local h=$1 ed xv xs edn where a b c
  ed=$(norm_ed "$2"); xv=$(norm_ver "$3"); xs=$(norm_ver "$4")
  edn=${ed:-not given}; [ "$ed" = other ] && edn="\"$2\""
  where="$h (edition $edn, XO ${xv:-not given}, xo-server ${xs:-not given})"
  if [ "$ed" = other ]; then
    echo "UNKNOWN: $where: edition not recognised (use xoa or sources)."; add 2; return
  fi
  if [ -n "$xs" ]; then
    if ! valid_ver "$xs"; then echo "UNKNOWN: $where: xo-server version not recognised. Expect three numbers, for example 5.213.1."; add 2; return; fi
    IFS=. read -r a b c <<< "$xs"; a=$((10#$a)); b=$((10#$b)); c=$((10#$c))
    if [ "$a" -lt 5 ] || { [ "$a" -eq 5 ] && [ "$b" -lt 211 ]; } || { [ "$a" -eq 5 ] && [ "$b" -eq 211 ] && [ "$c" -lt 2 ]; }; then
      echo "VULNERABLE: $where: below the fixed xo-server builds 5.211.2 (stable) and 5.213.1 (latest). Update now and hunt."; add 1; return
    fi
    if [ "$a" -eq 5 ] && [ "$b" -eq 211 ]; then
      echo "PATCHED: $where: at or above the fixed stable build 5.211.2. Hunt anyway: exploitation may predate the update."; add 0; return
    fi
    if [ "$a" -eq 5 ] && [ "$b" -eq 212 ]; then
      echo "VULNERABLE: $where: 5.212.x was released on 23 Oct 2026, before the fix, and is below 5.213.1. Update now and hunt."; add 1; return
    fi
    if [ "$a" -eq 5 ] && [ "$b" -eq 213 ] && [ "$c" -eq 0 ]; then
      if [ "$ed" = xoa ]; then
        echo "VULNERABLE: $where: XOA with xo-server 5.213.0 is the build Xen Orchestra 6.10.0 shipped before the fix. Update to 5.213.1 now and hunt."; add 1; return
      fi
      echo "NEEDS MORE INFO: $where: a sources install still reports 5.213.0 after the fix was merged. Check that git history contains commit 50166fe3 (merged 10 Nov 2026) and that you rebuilt. For XOA, 5.213.0 is vulnerable."; add 3; return
    fi
    if [ "$a" -eq 5 ] && [ "$b" -eq 213 ]; then
      echo "PATCHED: $where: at or above the fixed latest build 5.213.1. Hunt anyway: exploitation may predate the update."; add 0; return
    fi
    echo "NEEDS MORE INFO: $where: newer than the fixed builds Vates named. Confirm against the Vates advisory."; add 3; return
  fi
  if [ -n "$xv" ]; then
    if ! valid_ver "$xv"; then echo "UNKNOWN: $where: Xen Orchestra version not recognised. Expect three numbers, for example 6.10.0."; add 2; return; fi
    IFS=. read -r a b c <<< "$xv"; a=$((10#$a)); b=$((10#$b)); c=$((10#$c))
    if [ "$a" -lt 6 ] || { [ "$a" -eq 6 ] && [ "$b" -lt 9 ]; } || { [ "$a" -eq 6 ] && [ "$b" -eq 9 ] && [ "$c" -lt 1 ]; }; then
      echo "VULNERABLE: $where: before Xen Orchestra 6.9.1 (stable) and 6.10.0 (latest). Update now and hunt."; add 1; return
    fi
    if { [ "$a" -eq 6 ] && [ "$b" -eq 9 ] && [ "$c" -eq 1 ]; } || { [ "$a" -eq 6 ] && [ "$b" -eq 10 ] && [ "$c" -eq 0 ]; }; then
      echo "NEEDS MORE INFO: $where: Vates updated 6.9.1 and 6.10.0 in place on 10 Nov 2026, and both first shipped with vulnerable xo-server (5.211.1 and 5.213.0). Read the xo-server version from the About page and check again."; add 3; return
    fi
    echo "NEEDS MORE INFO: $where: not a version Vates named. Read the xo-server version from the About page and check again."; add 3; return
  fi
  echo "UNKNOWN: $where: no version given. Give the xo-server version from the About page."; add 2
}

check_csv() {
  local f=$1 line h ed xv xs rest n=0
  { [ -r "$f" ] && [ -f "$f" ]; } || die "cannot read: $f"
  head -n1 "$f" | tr -d '\r' | sed 's/^\xef\xbb\xbf//' | tr '[:upper:]' '[:lower:]' | grep -qxF 'host,edition,xo_version,xo_server_version' || die "CSV header must be: host,edition,xo_version,xo_server_version"
  while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}
    [ -z "$line" ] && continue
    case $line in \#*) continue;; esac
    IFS=, read -r h ed xv xs rest <<< "$line"
    h=$(printf '%s' "$h" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'); [ -n "$h" ] || h='host not given'
    n=$((n+1))
    evaluate "$h" "$ed" "$xv" "$xs"
  done < <(tail -n +2 "$f")
  [ "$n" -gt 0 ] || die "CSV has no rows: $f"
}

# scan FILE: prints one line per hit. Tags: [Vates IOC ...] from VSA-2026-062, [ours ...] our own patterns.
# Lines that report a missing file (ls or zgrep errors) are skipped so saved clean output does not match.
scan() {
  awk -v file="$1" -v ip="$C2_IP" -v onion="$C2_ONION" '
    function hit(tag, sev) { printf "%s %s:%d: [%s] %s\n", sev, file, NR, tag, substr(l, 1, 200); if (sev == "IOC") ioc = 1 }
    { l = $0; sub(/\r$/, "", l); t = tolower(l)
      if (t ~ /no such file or directory|cannot access/) next
      if (index(l, "authenticationToken=j:%7B%7D") || index(l, "authenticationToken=j:{}")) hit("Vates IOC: empty-object authenticationToken cookie", "IOC")
      else if (t ~ /authenticationtoken(=|%3d)j(:|%3a)( |\t|%20|\+)*(\{|%7b)( |\t|%20|\+)*(\}|%7d)/) hit("ours: encoded or spaced variant of the empty-object cookie", "IOC")
      if (match(l, /(^|[^0-9.])91\.211\.251\.112([^0-9]|$)/)) hit("Vates IOC: C2 address " ip, "IOC")
      if (index(t, onion)) hit("Vates IOC: C2 onion host", "IOC")
      if (t ~ /(^|[^a-z0-9_-])pivot2([^a-z0-9_-]|$)/) hit("Vates IOC: VM named pivot2", "IOC")
      if (t ~ /hvm_serial:[ \t]*file/) hit("Vates IOC: VM platform hvm_serial: file", "IOC")
      if (index(l, "-serial file:")) hit("Vates IOC: -serial file: in xensource.log", "IOC")
      if (index(l, "zz-ustar")) hit("Vates IOC: /etc/cron.d/zz-ustar cron file", "IOC")
      if (l ~ /\/\.ustar[56]([^0-9]|$)/) hit("Vates IOC: /.ustar5 or /.ustar6 file", "IOC")
      else if (index(l, "/.ustar")) hit("ours: other /.ustar file, same naming as the Vates IOCs", "IOC")
      if (index(l, "/rest/v0/users/me/authentication_tokens")) hit("ours: token listing API path, also used legitimately", "NOTE")
      if (index(l, "/etc/ld.so.preload")) hit("ours: /etc/ld.so.preload present, listed by the Vates check command but not named as an IOC", "NOTE")
    }
    END { exit ioc ? 0 : 1 }' "$1"
}

csv="" host="" xv="" xs="" ed="" have_v=0 scans=()
while [ $# -gt 0 ]; do
  case $1 in
    --csv) [ $# -ge 2 ] || usage; csv=$2; shift 2;;
    --host) [ $# -ge 2 ] || usage; host=$2; shift 2;;
    --xo-version) [ $# -ge 2 ] || usage; xv=$2; have_v=1; shift 2;;
    --xo-server) [ $# -ge 2 ] || usage; xs=$2; have_v=1; shift 2;;
    --edition) [ $# -ge 2 ] || usage; ed=$2; shift 2;;
    --scan) [ $# -ge 2 ] || usage; scans+=("$2"); shift 2;;
    *) usage;;
  esac
done
[ -n "$csv" ] || [ "$have_v" -eq 1 ] || [ ${#scans[@]} -gt 0 ] || usage
[ -n "$csv" ] && [ "$have_v" -eq 1 ] && die "use only one of --csv or --xo-server/--xo-version"
for f in "${scans[@]+"${scans[@]}"}"; do { [ -r "$f" ] && [ -f "$f" ]; } || die "cannot read: $f"; done

if [ -n "$csv" ]; then
  out=$(check_csv "$csv") || exit 5
  printf '%s\n' "$out"
  N=$(printf '%s\n' "$out" | grep -c ''); CP=$(printf '%s\n' "$out" | grep -c '^PATCHED: '); CV=$(printf '%s\n' "$out" | grep -c '^VULNERABLE: ')
  CU=$(printf '%s\n' "$out" | grep -c '^UNKNOWN: '); CN=$(printf '%s\n' "$out" | grep -c '^NEEDS MORE INFO: ')
  if [ "$CV" -gt 0 ]; then WORST=1; elif [ "$CU" -gt 0 ]; then WORST=2; elif [ "$CN" -gt 0 ]; then WORST=3; else WORST=0; fi
elif [ "$have_v" -eq 1 ]; then
  evaluate "${host:-input}" "$ed" "$xv" "$xs"
fi
if [ "$N" -gt 0 ]; then
  case $WORST in 0) w=PATCHED;; 1) w=VULNERABLE;; 2) w=UNKNOWN;; *) w="NEEDS MORE INFO";; esac
  echo "RESULT: $w ($N checked: $CP PATCHED, $CV VULNERABLE, $CU UNKNOWN, $CN NEEDS MORE INFO)"
fi

ioc=0
for f in "${scans[@]+"${scans[@]}"}"; do
  if o=$(scan "$f"); then ioc=1; fi
  [ -n "$o" ] && printf '%s\n' "$o"
done
if [ ${#scans[@]} -gt 0 ]; then
  if [ "$ioc" -eq 1 ]; then
    echo "IOC MATCH: treat Xen Orchestra and every managed pool as compromised. Keep the evidence. Vates advises blocking the C2 address and Tor and rotating all passwords and secrets used by Vates VMS (proxy, backup, S3, root, user accounts). Our advice: also revoke every XO authentication token and investigate every XCP-ng host."
    exit 4
  fi
  echo "SCAN: no IOC match in ${#scans[@]} file(s). Absence is not proof: Vates says XO keeps no log of the unauthenticated access, most access logs do not record cookies, and these IOCs only cover the known campaign."
fi
[ "$N" -gt 0 ] || exit 2
exit "$WORST"
