#!/usr/bin/env bash
# check-netscaler-console.sh: strictly read-only, offline check for Citrix NetScaler Console (formerly NetScaler ADM)
# CVE-2026-120869, security bulletin CTX697320. Citrix rates it Critical.
# Reads only the values and files you pass. No network, no writes, no payloads, never contacts Citrix or an appliance.
#
# Usage:
#   check-netscaler-console.sh --version BUILD [--product PRODUCT] [--name NAME] [--log FILE ...] [--admin-net LIST]
#   check-netscaler-console.sh --inventory FILE [--log FILE ...] [--admin-net LIST]
#   check-netscaler-console.sh --log FILE ... [--admin-net LIST]
#   --version    build as Citrix writes it: 14.1-74.34 or "14.1 Build 74.34" (quote it if it has spaces)
#   --product    console (default), netscaler console, adm or netscaler adm; anything else is reported UNKNOWN
#   --inventory  CSV with header name,product,version (# lines skipped)
#   --log        saved Console syslog or audit export to review (read only; only counts and line numbers are printed)
#   --admin-net  comma separated IPv4 addresses or CIDR ranges your administrators use
# Exit: 0 PATCHED, 1 VULNERABLE, 2 NOT LISTED (release not in CTX697320), 3 UNKNOWN, 4 usage or read error,
#       5 LOG REVIEW. Order: 1 > 5 > 2 > 3 > 0. A --log run with no inventory and no matches exits 3.
set -u
export LC_ALL=C

FIX_RELEASE="14.1"
FIX_BUILD="74.34"
FIX_FULL="14.1-74.34"

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

lines=(); nP=0; nV=0; nN=0; nU=0
add() { # verdict where reason
  case $1 in PATCHED) nP=$((nP+1));; VULNERABLE) nV=$((nV+1));; "NOT LISTED") nN=$((nN+1));; UNKNOWN) nU=$((nU+1));; esac
  lines+=("$1: $2: $3")
}
norm_product() {
  local p; p=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
  case $p in
    console|"netscaler console"|"citrix netscaler console"|adm|"netscaler adm"|"citrix adm"|"citrix netscaler adm") echo console;;
    *) printf '%s\n' "$p";;
  esac
}
check_item() { # name product version
  local name=$1 p v w rel bmaj bmin
  p=$(norm_product "$2"); v=$(printf '%s' "$3" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
  w="$name (${p:-no product}, ${v:-no version})"
  if [ -z "$v" ]; then add UNKNOWN "$w" "no version given."; return; fi
  if [ -z "$p" ]; then add UNKNOWN "$w" "no product given. CTX697320 lists only Citrix NetScaler Console."; return; fi
  if [ "$p" != console ]; then
    add UNKNOWN "$w" "CTX697320 lists only Citrix NetScaler Console 14.1. It says nothing about the NetScaler Console service, Console agents, NetScaler ADC or Gateway, and this pack makes no claim about them."; return
  fi
  if [[ $v =~ ^([0-9]{1,3})\.([0-9]{1,3})(-|[[:space:]]+[Bb][Uu][Ii][Ll][Dd][[:space:]]+)([0-9]{1,5})\.([0-9]{1,5})$ ]]; then
    rel="$((10#${BASH_REMATCH[1]})).$((10#${BASH_REMATCH[2]}))"; bmaj=$((10#${BASH_REMATCH[4]})); bmin=$((10#${BASH_REMATCH[5]}))
  else
    add UNKNOWN "$w" "not a Console build this pack can judge (use the form 14.1-74.34 or 14.1 Build 74.34)."; return
  fi
  if [ "$rel" != "$FIX_RELEASE" ]; then
    add "NOT LISTED" "$w" "release $rel is not listed in CTX697320, which lists only Citrix NetScaler Console 14.1 before $FIX_FULL. Citrix makes no statement about $rel there and neither does this pack. Check with Citrix."; return
  fi
  if [ "$bmaj" -gt "${FIX_BUILD%.*}" ] || { [ "$bmaj" -eq "${FIX_BUILD%.*}" ] && [ "$bmin" -ge "${FIX_BUILD#*.}" ]; }; then
    add PATCHED "$w" "at or above $FIX_FULL, Citrix's fixed build for CVE-2026-120869 (CTX697320). Not proof the appliance was not accessed before the upgrade."
  else
    add VULNERABLE "$w" "in Citrix's affected range, NetScaler Console 14.1 before $FIX_FULL (CVE-2026-120869, CTX697320). Upgrade to $FIX_FULL or later."
  fi
}

# IPv4 helpers for --admin-net.
ip2int() { # dotted quad to integer, or fail
  local a b c d
  [[ $1 =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
  a=$((10#${BASH_REMATCH[1]})); b=$((10#${BASH_REMATCH[2]})); c=$((10#${BASH_REMATCH[3]})); d=$((10#${BASH_REMATCH[4]}))
  if [ "$a" -gt 255 ] || [ "$b" -gt 255 ] || [ "$c" -gt 255 ] || [ "$d" -gt 255 ]; then return 1; fi
  echo $(( (a << 24) | (b << 16) | (c << 8) | d ))
}
nets=()
parse_nets() {
  local item ip len n
  IFS=',' read -r -a items <<< "$1"
  for item in "${items[@]}"; do
    item=$(printf '%s' "$item" | tr -d '[:space:]')
    [ -n "$item" ] || continue
    ip=${item%%/*}; len=32
    if [ "$item" != "$ip" ]; then
      len=${item#*/}
      if ! [[ $len =~ ^[0-9]{1,2}$ ]] || [ "$((10#$len))" -gt 32 ]; then die "--admin-net needs IPv4 addresses or CIDR ranges"; fi
      len=$((10#$len))
    fi
    n=$(ip2int "$ip") || die "--admin-net needs IPv4 addresses or CIDR ranges"
    nets+=("$n/$len")
  done
  [ "${#nets[@]}" -gt 0 ] || die "--admin-net needs IPv4 addresses or CIDR ranges"
}
in_nets() { # ip: 0 if inside any admin net
  local n e base len mask
  n=$(ip2int "$1") || return 1
  for e in "${nets[@]}"; do
    base=${e%/*}; len=${e#*/}
    if [ "$len" -eq 0 ]; then mask=0; else mask=$(( (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF )); fi
    [ $(( n & mask )) -eq $(( base & mask )) ] && return 0
  done
  return 1
}

loglines=(); logreview=0
fmt_lines() { # "n1 n2 ..." to "k line(s): a,b,c" (first 5 shown)
  local -a a; read -r -a a <<< "$1"
  local k=${#a[@]} s
  s=$(printf '%s,' "${a[@]:0:5}"); s=${s%,}
  [ "$k" -gt 5 ] && s="$s,..."
  if [ "$k" -eq 1 ]; then echo "1 line: $s"; else echo "$k lines: $s"; fi
}
review_log() { # file
  local f=$1 ln=0 l ip cmdc=0 failc=0 recc=0 devc=0 usr="" rec="" dev=""
  local -a cips=() fips=()
  local -A cl=() fl=()
  if ! [ -f "$f" ] || ! [ -r "$f" ]; then die "cannot read log file: $f"; fi
  while IFS= read -r l || [ -n "$l" ]; do
    ln=$((ln+1)); l=${l%$'\r'}
    if [[ $l == *CMD_EXECUTED* ]]; then
      cmdc=$((cmdc+1))
      if [[ $l =~ Remote_ip[[:space:]]*([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})([^0-9.]|$) ]]; then
        ip=${BASH_REMATCH[1]}
        if ip2int "$ip" >/dev/null; then
          [ -n "${cl[$ip]+x}" ] || cips+=("$ip")
          cl[$ip]="${cl[$ip]:-} $ln"
        fi
      fi
      [[ $l =~ DEVICECONFIG[[:space:]]+CMD_EXECUTED ]] && { devc=$((devc+1)); dev="$dev $ln"; }
      if [[ $l =~ Command[[:space:]]*\"([^\"]*)\" ]]; then
        if printf '%s\n' "${BASH_REMATCH[1]}" | grep -qiE '(^|[^a-z])(add|set|rm|remove|delete|create|modify|update|change|bind|unbind)([^a-z]|$)' &&
           printf '%s\n' "${BASH_REMATCH[1]}" | grep -qiE '(user|group|role|password|policy)'; then usr="$usr $ln"; fi
      fi
    fi
    if [[ $l =~ Invalid\ .*\ login\ for\ user\ .*\ from\ client\ IP\ Address\ ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})([^0-9.]|$) ]] ||
       [[ $l =~ User\ Account\ Locked\ for\ .*\ client\ IP\ Address\ ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})([^0-9.]|$) ]]; then
      failc=$((failc+1)); ip=${BASH_REMATCH[1]}
      if ip2int "$ip" >/dev/null; then
        [ -n "${fl[$ip]+x}" ] || fips+=("$ip")
        fl[$ip]="${fl[$ip]:-} $ln"
      fi
    fi
    if [[ $l == *"user recovered to default credentials"* ]]; then recc=$((recc+1)); rec="$rec $ln"; fi
  done < "$f"
  loglines+=("LOG $f: $ln lines read; $cmdc CMD_EXECUTED, $failc login failure, $recc recovered to default credentials, $devc DEVICECONFIG CMD_EXECUTED (message formats from the NetScaler ADM 14.1 syslog reference; syslog header handling UNVERIFIED).")
  for ip in "${cips[@]}"; do
    if [ "${#nets[@]}" -eq 0 ]; then loglines+=("LOG INFO: $f: command source Remote_ip $ip ($(fmt_lines "${cl[$ip]}")). Give --admin-net to flag sources outside your admin networks.")
    elif ! in_nets "$ip"; then loglines+=("LOG REVIEW: $f: command from outside admin networks, Remote_ip $ip ($(fmt_lines "${cl[$ip]}"))."); logreview=$((logreview+1)); fi
  done
  for ip in "${fips[@]}"; do
    if [ "${#nets[@]}" -eq 0 ]; then loglines+=("LOG INFO: $f: login failure from $ip ($(fmt_lines "${fl[$ip]}")). Give --admin-net to flag sources outside your admin networks.")
    elif ! in_nets "$ip"; then loglines+=("LOG REVIEW: $f: login failure from outside admin networks, $ip ($(fmt_lines "${fl[$ip]}"))."); logreview=$((logreview+1)); fi
  done
  if [ -n "$rec" ]; then loglines+=("LOG REVIEW: $f: user recovered to default credentials ($(fmt_lines "$rec"))."); logreview=$((logreview+1)); fi
  if [ -n "$usr" ]; then loglines+=("LOG REVIEW: $f: commands that may change users, groups, roles, passwords or policies, by keyword (command wording UNVERIFIED) ($(fmt_lines "$usr")). Check them against change records."); logreview=$((logreview+1)); fi
  if [ -n "$dev" ]; then loglines+=("LOG REVIEW: $f: DEVICECONFIG commands run on managed instances ($(fmt_lines "$dev")). Check them against change records."); logreview=$((logreview+1)); fi
}

read_inventory() {
  local f=$1 h row n rows=0 first=1 a b c
  if ! [ -f "$f" ] || ! [ -r "$f" ]; then die "cannot read inventory file: $f"; fi
  [ -s "$f" ] || die "inventory file is empty: $f"
  while IFS= read -r row || [ -n "$row" ]; do
    row=${row%$'\r'}
    if [ $first = 1 ]; then
      first=0; h=$(printf '%s' "$row" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
      [ "$h" = "name,product,version" ] || die "CSV header must be: name,product,version"
      continue
    fi
    case $(printf '%s' "$row" | sed 's/^[[:space:]]*//') in ''|\#*) continue;; esac
    IFS=',' read -r a b c _ <<< "$row"
    n=$(printf '%s' "${a:-}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'); [ -n "$n" ] || n="name not given"
    check_item "$n" "${b:-}" "${c:-}"; rows=$((rows+1))
  done < "$f"
  [ "$rows" -gt 0 ] || die "inventory has no data rows: $f"
}

version=""; hasv=0; product=""; hasp=0; name=""; hasn=0; inv=""; logs=(); netarg=""; hasnet=0
[ $# -gt 0 ] || usage
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') in
    --version|-version) version=$2; hasv=1;;
    --product|-product) product=$2; hasp=1;;
    --name|-name) name=$2; hasn=1;;
    --inventory|-inventory) inv=$2;;
    --log|-log) logs+=("$2");;
    --admin-net|-adminnet) netarg=$2; hasnet=1;;
    *) usage;;
  esac
  shift 2
done
if [ -n "$inv" ] && { [ $hasv = 1 ] || [ $hasp = 1 ] || [ $hasn = 1 ]; }; then usage; fi
if [ -z "$inv" ] && [ $hasv = 0 ] && [ "${#logs[@]}" -eq 0 ]; then usage; fi
if [ $hasv = 0 ] && { [ $hasp = 1 ] || [ $hasn = 1 ]; }; then usage; fi
[ $hasnet = 0 ] || parse_nets "$netarg"
if [ -n "$inv" ]; then read_inventory "$inv"
elif [ $hasv = 1 ]; then
  [ $hasp = 1 ] || product=console; [ $hasn = 1 ] || name=input
  check_item "$name" "$product" "$version"
fi
for f in "${logs[@]}"; do review_log "$f"; done

echo "check-netscaler-console: offline check for Citrix NetScaler Console CVE-2026-120869 (CTX697320 read 12 Nov 2026 15:06 GMT)."
echo "KEV: CVE-2026-120869 is not in CISA KEV catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT), which predates the CVE (public 12 Nov 2026 13:57 GMT). Absence from KEV says nothing about exploitation."
for out in "${lines[@]}"; do printf '%s\n' "$out"; done
for out in "${loglines[@]}"; do printf '%s\n' "$out"; done
n=$((nP+nV+nN+nU))
if [ $nV -gt 0 ]; then res=VULNERABLE; rc=1
elif [ $logreview -gt 0 ]; then res="LOG REVIEW"; rc=5
elif [ $nN -gt 0 ]; then res="NOT LISTED"; rc=2
elif [ $nU -gt 0 ] || [ $n -eq 0 ]; then res=UNKNOWN; rc=3
else res=PATCHED; rc=0; fi
echo "RESULT: $res ($n checked: $nP PATCHED, $nV VULNERABLE, $nN NOT LISTED, $nU UNKNOWN; $logreview log review item(s))"
exit $rc
