#!/usr/bin/env bash
# check-forticlient.sh: strictly read-only, offline version check for Fortinet FortiClient CVE-2026-121529 (FG-IR-26-194).
# Fortinet rates it Critical, CVSSv3 9.1 (advisory). It only compares the version strings you pass or a CSV you saved
# (for example a FortiClient EMS or RMM export). No network, no file writes, no payloads, never contacts Fortinet or an endpoint.
#
# Usage:
#   check-forticlient.sh --platform windows|windows-pam|mac --version VER [--name NAME]
#   check-forticlient.sh --inventory FILE      (CSV header: name,platform,version)
# Fortinet's table (FG-IR-26-194, 10 Nov 2026):
#   FortiClientWindows and FortiClientWindowsPAM 7.4.0 through 7.4.7: upgrade to 7.4.8 or above.
#   FortiClientWindows and FortiClientWindowsPAM 7.2.0 through 7.2.14: upgrade to 7.2.15 or above.
#   FortiClientMac 7.4.0 through 7.4.7: upgrade to 7.4.8 or above.
#   8.0 (all three) and FortiClientMac 7.2: not affected.
# Verdicts: PATCHED (at or above the fix in a listed branch), NOT AFFECTED (Fortinet says so), VULNERABLE (in range),
#   OUT OF RANGE (branch not listed by Fortinet, such as 7.0 or 6.x), UNKNOWN (other platform or unreadable version).
# Exit: 0 every row PATCHED or NOT AFFECTED, 1 any VULNERABLE, 2 any OUT OF RANGE (none vulnerable),
#   3 any UNKNOWN (none vulnerable or out of range), 4 usage or read error.
set -u

usage() {
  cat >&2 <<'U'
Usage: check-forticlient.sh --platform windows|windows-pam|mac --version VER [--name NAME]
       check-forticlient.sh --inventory FILE
  --platform   windows, windows-pam or mac (required in single mode)
  --version    FortiClient version such as 7.4.8 or 7.2.15.1309 (major.minor.patch.build)
  --name       label for the output (default: input)
  --inventory  CSV: name,platform,version
Exit: 0 PATCHED or NOT AFFECTED, 1 VULNERABLE, 2 OUT OF RANGE, 3 UNKNOWN, 4 usage or read error
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
lc() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
trim() { local s=$1; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }

norm_platform() {
  local p; p=$(lc "$(trim "$1")")
  case $p in
    windows|win|win32|win64|"windows 10"|"windows 11"|"microsoft windows"|forticlientwindows|"forticlient windows") echo windows;;
    windows-pam|windowspam|"windows pam"|win-pam|pam|forticlientwindowspam|"forticlient windows pam") echo windows-pam;;
    mac|macos|"mac os"|osx|"os x"|darwin|forticlientmac|"forticlient mac") echo mac;;
    *) echo "$p";;
  esac
}
label() { case $1 in windows) echo FortiClientWindows;; windows-pam) echo FortiClientWindowsPAM;; mac) echo FortiClientMac;; esac; }

say() { printf '%s: %s: %s\n' "$1" "$2" "$3"; }

judge() { # name platform version
  local n=$1 p v w a b c L pam=""
  p=$(norm_platform "$2"); v=$(trim "$3")
  w="$n (${p:-no platform}, ${v:-no version})"
  if [ -z "$v" ]; then say UNKNOWN "$w" "no version given."; return; fi
  if [ -z "$p" ]; then say UNKNOWN "$w" "no platform given. Use windows, windows-pam or mac."; return; fi
  case $p in windows|windows-pam|mac) ;; *)
    say UNKNOWN "$w" "FG-IR-26-194 lists only FortiClient Windows, Windows PAM and Mac. This pack does not judge other platforms or products (for example FortiClient Linux, mobile or EMS)."; return;;
  esac
  if ! [[ $v =~ ^[vV]?([0-9]{1,4})\.([0-9]{1,4})\.([0-9]{1,6})(\.[0-9]{1,8})?$ ]]; then
    say UNKNOWN "$w" "not a FortiClient version this pack can judge (use the form 7.4.8 or 7.2.15.1309)."; return
  fi
  a=$((10#${BASH_REMATCH[1]})); b=$((10#${BASH_REMATCH[2]})); c=$((10#${BASH_REMATCH[3]}))
  L=$(label "$p")
  [ "$p" = windows-pam ] && pam=" The CVE record lists only FortiClientWindowsPAM 7.4.7 and 7.2.14; this pack follows the wider ranges in Fortinet's advisory."
  if [ "$a" -eq 8 ] && [ "$b" -eq 0 ]; then
    say "NOT AFFECTED" "$w" "Fortinet lists $L 8.0 as not affected by CVE-2026-121529."; return
  fi
  if [ "$a" -eq 7 ] && [ "$b" -eq 4 ]; then
    if [ "$c" -ge 8 ]; then say PATCHED "$w" "at or above 7.4.8, Fortinet's fix for $L 7.4 (CVE-2026-121529)."; return; fi
    say VULNERABLE "$w" "in Fortinet's affected range $L 7.4.0 through 7.4.7 for CVE-2026-121529 (FG-IR-26-194). Upgrade to 7.4.8 or above.$pam"; return
  fi
  if [ "$a" -eq 7 ] && [ "$b" -eq 2 ]; then
    if [ "$p" = mac ]; then say "NOT AFFECTED" "$w" "Fortinet lists FortiClientMac 7.2 as not affected by CVE-2026-121529."; return; fi
    if [ "$c" -ge 15 ]; then say PATCHED "$w" "at or above 7.2.15, Fortinet's fix for $L 7.2 (CVE-2026-121529)."; return; fi
    say VULNERABLE "$w" "in Fortinet's affected range $L 7.2.0 through 7.2.14 for CVE-2026-121529 (FG-IR-26-194). Upgrade to 7.2.15 or above.$pam"; return
  fi
  say "OUT OF RANGE" "$w" "branch $a.$b is not listed in FG-IR-26-194, so Fortinet makes no statement about it for CVE-2026-121529. Our advice, not Fortinet's: confirm with Fortinet that this branch is still supported and move to a listed fixed release (7.4.8 or above, or 8.0)."
}

read_inventory() {
  local f=$1 h line n p v rows=0
  if [ ! -f "$f" ] || [ ! -r "$f" ]; then die "cannot read inventory file: $f"; fi
  IFS= read -r h < "$f" || die "inventory file is empty: $f"
  h=${h%$'\r'}
  [ "$(lc "$(trim "$h")")" = "name,platform,version" ] || die "CSV header must be: name,platform,version"
  while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}
    case $(trim "$line") in ''|\#*) continue;; esac
    IFS=, read -r n p v _ <<< "$line"
    n=$(trim "${n:-}")
    judge "${n:-name not given}" "${p:-}" "${v:-}"
    rows=$((rows+1))
  done < <(tail -n +2 "$f")
  [ "$rows" -gt 0 ] || die "inventory has no data rows: $f"
}

ver="" plat="" name="input" inv="" seen_ver=0 seen_plat=0 seen_name=0
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in
    --version) ver=$2; seen_ver=1;;
    --platform) plat=$2; seen_plat=1;;
    --name) name=$2; seen_name=1;;
    --inventory) inv=$2;;
    *) usage;;
  esac
  shift 2
done
if [ -n "$inv" ] && [ $((seen_ver + seen_plat + seen_name)) -gt 0 ]; then usage; fi
if [ -z "$inv" ] && { [ "$seen_ver" -eq 0 ] || [ "$seen_plat" -eq 0 ]; }; then usage; fi

# Read the inventory fully before printing anything, so read errors leave stdout empty.
if [ -n "$inv" ]; then
  out=$(read_inventory "$inv") || exit 4
else
  out=$(judge "$name" "$plat" "$ver")
fi
NP=$(printf '%s\n' "$out" | grep -c '^PATCHED: ')
NA=$(printf '%s\n' "$out" | grep -c '^NOT AFFECTED: ')
NV=$(printf '%s\n' "$out" | grep -c '^VULNERABLE: ')
NO=$(printf '%s\n' "$out" | grep -c '^OUT OF RANGE: ')
NU=$(printf '%s\n' "$out" | grep -c '^UNKNOWN: ')
N=$((NP+NA+NV+NO+NU))

echo "check-forticlient: offline version check for Fortinet FortiClient CVE-2026-121529 (FG-IR-26-194 read 10 Nov 2026 21:02 GMT)."
echo "KEV: CVE-2026-121529 not listed in CISA KEV catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT), which predates the CVE record (published 10 Nov 2026 20:10 GMT). Fortinet says not known exploited."
printf '%s\n' "$out"
if [ "$NV" -gt 0 ]; then res=VULNERABLE; rc=1
elif [ "$NO" -gt 0 ]; then res="OUT OF RANGE"; rc=2
elif [ "$NU" -gt 0 ]; then res=UNKNOWN; rc=3
elif [ "$NP" -gt 0 ]; then res=PATCHED; rc=0
else res="NOT AFFECTED"; rc=0; fi
echo "RESULT: $res ($N checked: $NP PATCHED, $NA NOT AFFECTED, $NV VULNERABLE, $NO OUT OF RANGE, $NU UNKNOWN)"
exit "$rc"
