#!/usr/bin/env bash
# Zyxel GS1900 CVE-2026-107441 (and CVE-2026-7273) offline firmware checker.
# STRICTLY READ-ONLY AND OFFLINE: compares model/firmware strings you supply (or a
# CSV inventory, saved config or exported syslog file you supply) against Zyxel's
# published fixed firmware. It never contacts a switch, sends no network traffic,
# contains no payloads and writes no files.
# Data (checked 4 Nov 2026):
#   Zyxel advisory 2026-11-03 (CVE-2026-107441): affected 2.90(XXXX.2)C0 and earlier,
#   fixed 2.90(XXXX.3)C0. Zyxel advisory 2026-06-16 (CVE-2026-7273): affected
#   2.90(XXXX.1)C0 and earlier, fixed 2.90(XXXX.2)C0. So .2 builds are patched for
#   CVE-2026-7273 ONLY and remain vulnerable to CVE-2026-107441.
# Exit codes: 0 all PATCHED, 1 any LIKELY VULNERABLE, 2 any UNKNOWN (none vulnerable), 3 error.
set -u
KEV_STATUS="CISA KEV (catalog 2026.11.04): CVE-2026-7273 LISTED (added 2026-09-21, due 2026-09-24, US federal agencies, BOD 26-04). CVE-2026-107441 NOT listed. Patch now regardless."

usage() {
  cat <<'U'
Usage:
  check-zyxel-gs1900.sh --model <GS1900-xx> --firmware <2.90(AAHL.3)C0>
  check-zyxel-gs1900.sh --csv inventory.csv     (header: name,model,firmware)
  check-zyxel-gs1900.sh --config saved.cfg      (reads model/firmware strings if present)
  Optional with any: --syslog exported.log      (counts config/login/reboot lines; keywords UNVERIFIED)
U
}

# model -> Zyxel firmware code (advisory table)
model_code() {
  local m
  m=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' ' | sed -E 's/^ZYXEL//; s/\(REV\.?B1\)$//; s/REV\.?B1$//')
  case "$m" in
    GS1900-8) echo AAHH ;;
    GS1900-8HP) echo AAHI ;;
    GS1900-10HP) echo AAZI ;;
    GS1900-16) echo AAHJ ;;
    GS1900-24) echo AAHL ;;
    GS1900-24E) echo AAHK ;;
    GS1900-24EP) echo ABTO ;;
    GS1900-24HPV2) echo ABTP ;;
    GS1900-48) echo AAHN ;;
    GS1900-48HPV2) echo ABTQ ;;
    *) echo "" ;;
  esac
}

# verdict MODEL FIRMWARE -> "VERDICT|reason"
verdict() {
  local code fw ver fcode n maj min
  code=$(model_code "$1")
  if [ -z "$code" ]; then echo "UNKNOWN|model '$1' is not in Zyxel's affected table (end-of-support models are not covered; treat as unpatched if exposed)"; return; fi
  fw=$(printf '%s' "$2" | tr '[:lower:]' '[:upper:]' | grep -oE '[0-9]+\.[0-9]+\([A-Z]{4}\.[0-9]+\)C[0-9]+' | head -n1)
  if [ -z "$fw" ]; then echo "UNKNOWN|no GS1900 firmware string like 2.90(${code}.3)C0 parsed from '$2'"; return; fi
  ver=${fw%%(*}; fcode=$(printf '%s' "$fw" | sed -E 's/.*\(([A-Z]{4})\..*/\1/'); n=$(printf '%s' "$fw" | sed -E 's/.*\.([0-9]+)\).*/\1/')
  if [ "$fcode" != "$code" ]; then echo "UNKNOWN|firmware code $fcode does not match $1 (expected $code)"; return; fi
  maj=${ver%%.*}; min=${ver#*.}
  if (( 10#$maj > 2 || (10#$maj == 2 && 10#$min > 90) )); then echo "PATCHED|$fw is newer than fixed 2.90(${code}.3)C0"; return; fi
  if (( 10#$maj < 2 || (10#$maj == 2 && 10#$min < 90) )); then echo "LIKELY VULNERABLE|$fw is older than 2.90; vulnerable to CVE-2026-107441 and CVE-2026-7273, upgrade to 2.90(${code}.3)C0"; return; fi
  if (( 10#$n >= 3 )); then echo "PATCHED|$fw >= fixed 2.90(${code}.3)C0"; return; fi
  if (( 10#$n == 2 )); then echo "LIKELY VULNERABLE|$fw is patched for CVE-2026-7273 ONLY (7273-ONLY), still vulnerable to CVE-2026-107441, upgrade to 2.90(${code}.3)C0"; return; fi
  echo "LIKELY VULNERABLE|$fw vulnerable to CVE-2026-107441 and CVE-2026-7273, upgrade to 2.90(${code}.3)C0"
}

worst=0
record() { # name verdict|reason
  local v=${2%%|*} r=${2#*|}
  printf '%-20s %-18s %s\n' "$1" "$v" "$r"
  case "$v" in
    "LIKELY VULNERABLE") worst=1 ;;
    UNKNOWN) [ "$worst" -eq 0 ] && worst=2 ;;
  esac
}

model=""; fw=""; csv=""; cfg=""; syslog=""
while [ $# -gt 0 ]; do
  case "$1" in
    --model) model=${2-}; shift 2 || { usage; exit 3; } ;;
    --firmware) fw=${2-}; shift 2 || { usage; exit 3; } ;;
    --csv) csv=${2-}; shift 2 || { usage; exit 3; } ;;
    --config) cfg=${2-}; shift 2 || { usage; exit 3; } ;;
    --syslog) syslog=${2-}; shift 2 || { usage; exit 3; } ;;
    -h|--help) usage; exit 0 ;;
    *) echo "error: unknown argument $1" >&2; usage >&2; exit 3 ;;
  esac
done
if [ -z "$model$fw$csv$cfg$syslog" ]; then usage >&2; exit 3; fi

echo "$KEV_STATUS"
if [ -n "$model$fw" ]; then
  if [ -z "$model" ] || [ -z "$fw" ]; then echo "error: --model and --firmware go together" >&2; exit 3; fi
  record "$model" "$(verdict "$model" "$fw")"
fi
if [ -n "$csv" ]; then
  [ -r "$csv" ] || { echo "error: cannot read $csv" >&2; exit 3; }
  hdr=$(head -n1 "$csv" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d ' ')
  [ "$hdr" = "name,model,firmware" ] || { echo "error: CSV header must be name,model,firmware" >&2; exit 3; }
  rows=0
  while IFS=, read -r n m f || [ -n "${n:-}" ]; do
    n=${n%$'\r'}; m=${m%$'\r'}; f=${f%$'\r'}
    [ -z "$n$m$f" ] && continue
    rows=$((rows+1)); record "$n" "$(verdict "$m" "$f")"
  done < <(tail -n +2 "$csv")
  [ "$rows" -eq 0 ] && { echo "UNKNOWN: CSV has no device rows"; [ "$worst" -eq 0 ] && worst=2; }
fi
if [ -n "$cfg" ]; then
  [ -r "$cfg" ] || { echo "error: cannot read $cfg" >&2; exit 3; }
  cm=$(grep -oiE 'GS1900-[0-9]+[A-Z]*(v2)?' "$cfg" | head -n1)
  cf=$(grep -oiE '[0-9]+\.[0-9]+\([A-Z]{4}\.[0-9]+\)C[0-9]+' "$cfg" | head -n1)
  if [ -z "$cm" ] || [ -z "$cf" ]; then record "$(basename "$cfg")" "UNKNOWN|no model and firmware string found in saved config (format UNVERIFIED)"
  else record "$(basename "$cfg")" "$(verdict "$cm" "$cf")"; fi
fi
if [ -n "$syslog" ]; then
  [ -r "$syslog" ] || { echo "error: cannot read $syslog" >&2; exit 3; }
  c=$(grep -ciE 'config(uration)?.*(chang|sav|modif|upload|download)|copy running-config' "$syslog" || true)
  l=$(grep -ciE 'login|logged in|authenticat' "$syslog" || true)
  r=$(grep -ciE 'reboot|restart|cold start|warm start' "$syslog" || true)
  echo "syslog $(basename "$syslog"): config-change lines=$c admin-login lines=$l reboot lines=$r (keywords UNVERIFIED, review manually)"
  if [ -z "$model$fw$csv$cfg" ]; then
    sm=$(grep -oiE 'GS1900-[0-9]+[A-Z]*(v2)?' "$syslog" | head -n1); sf=$(grep -oiE '[0-9]+\.[0-9]+\([A-Z]{4}\.[0-9]+\)C[0-9]+' "$syslog" | head -n1)
    if [ -n "$sm" ] && [ -n "$sf" ]; then record "$(basename "$syslog")" "$(verdict "$sm" "$sf")"; else record "$(basename "$syslog")" "UNKNOWN|no model and firmware string in syslog"; fi
  fi
fi
exit "$worst"
