#!/usr/bin/env bash
# check-fortimanager.sh: strictly read-only, offline version check for Fortinet FortiManager CVE-2026-126261 (FG-IR-26-275).
# Fortinet rates it Critical, CVSSv3 9.0 (advisory). It only compares the version strings you pass or a CSV you saved
# (for example an asset register or RMM export). No network, no file writes, no payloads, never contacts Fortinet or a device.
#
# Usage:
#   check-fortimanager.sh --version VER [--product PRODUCT] [--name NAME]
#   check-fortimanager.sh --inventory FILE      (CSV header: name,product,version)
# Fortinet's table (FG-IR-26-275, 10 Nov 2026):
#   FortiManager 8.0.0: upgrade to 8.0.1 or above.
#   FortiManager 7.6.0 through 7.6.7: upgrade to 7.6.8 or above.
#   FortiManager 7.4.0 through 7.4.5: upgrade to 7.4.6 or above.
#   FortiManager 7.2.0 through 7.2.8: upgrade to 7.2.9 or above.
# Verdicts: PATCHED (at or above the fix in a listed branch), VULNERABLE (in range), OUT OF RANGE (branch not listed by
#   Fortinet, such as 7.0; no claim either way), UNKNOWN (other product, or a version this script cannot read).
# Exit: 0 every row PATCHED, 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-fortimanager.sh --version VER [--product PRODUCT] [--name NAME]
       check-fortimanager.sh --inventory FILE
  --version    FortiManager version such as 7.4.6, 7.4.6.2588 or "v7.4.6-build2588" (quote it if it has spaces)
  --product    fortimanager (default), or anything else, which is reported UNKNOWN
  --name       label for the output (default: input)
  --inventory  CSV: name,product,version
Exit: 0 PATCHED, 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_product() {
  local p; p=$(lc "$(trim "$1")")
  case $p in
    fortimanager|fmg|"fortimanager vm"|fortimanager-vm|"fortimanager-vm64"|fmg-vm|fmg-vm64|"fortinet fortimanager") echo fortimanager;;
    *) echo "$p";;
  esac
}

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

judge() { # name product version
  local n=$1 p v w a b c
  p=$(norm_product "$2"); v=$(trim "$3")
  w="$n (${p:-no product}, ${v:-no version})"
  if [ -z "$v" ]; then say UNKNOWN "$w" "no version given."; return; fi
  if [ -z "$p" ]; then say UNKNOWN "$w" "no product given. FG-IR-26-275 lists only FortiManager."; return; fi
  if [ "$p" != fortimanager ]; then
    say UNKNOWN "$w" "FG-IR-26-275 lists only FortiManager. It does not list FortiManager Cloud, FortiAnalyzer or any other product, and this pack makes no claim about them."; return
  fi
  if ! [[ $v =~ ^[vV]?([0-9]{1,4})\.([0-9]{1,4})\.([0-9]{1,6})(\.[0-9]{1,8})?([-,\ ]+[bB][uU][iI][lL][dD]\ ?[0-9]{1,6})?$ ]]; then
    say UNKNOWN "$w" "not a FortiManager version this pack can judge (use the form 7.4.6, 7.4.6.2588 or v7.4.6-build2588)."; return
  fi
  a=$((10#${BASH_REMATCH[1]})); b=$((10#${BASH_REMATCH[2]})); c=$((10#${BASH_REMATCH[3]}))
  local fix="" last=""
  case "$a.$b" in
    8.0) fix=1; last=0;;
    7.6) fix=8; last=7;;
    7.4) fix=6; last=5;;
    7.2) fix=9; last=8;;
  esac
  if [ -z "$fix" ]; then
    say "OUT OF RANGE" "$w" "branch $a.$b is not listed in FG-IR-26-275. Fortinet makes no statement about it for CVE-2026-126261 and neither does this pack. Check with Fortinet."; return
  fi
  if [ "$c" -ge "$fix" ]; then
    say PATCHED "$w" "at or above $a.$b.$fix, Fortinet's fix for FortiManager $a.$b (CVE-2026-126261)."; return
  fi
  if [ "$last" -eq 0 ]; then
    say VULNERABLE "$w" "FortiManager $a.$b.0 is listed as affected by CVE-2026-126261 (FG-IR-26-275). Upgrade to $a.$b.$fix or above."; return
  fi
  say VULNERABLE "$w" "in Fortinet's affected range FortiManager $a.$b.0 through $a.$b.$last for CVE-2026-126261 (FG-IR-26-275). Upgrade to $a.$b.$fix or above."
}

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,product,version" ] || die "CSV header must be: name,product,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="" prod="fortimanager" name="input" inv="" seen_ver=0 seen_other=0
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in
    --version) ver=$2; seen_ver=1;;
    --product) prod=$2; seen_other=1;;
    --name) name=$2; seen_other=1;;
    --inventory) inv=$2;;
    *) usage;;
  esac
  shift 2
done
if [ -n "$inv" ] && [ $((seen_ver + seen_other)) -gt 0 ]; then usage; fi
if [ -z "$inv" ] && [ "$seen_ver" -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" "$prod" "$ver")
fi
NP=$(printf '%s\n' "$out" | grep -c '^PATCHED: ')
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+NV+NO+NU))

echo "check-fortimanager: offline version check for Fortinet FortiManager CVE-2026-126261 (FG-IR-26-275 read 11 Nov 2026 00:01 GMT)."
echo "KEV: CVE-2026-126261 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 22:23 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
else res=PATCHED; rc=0; fi
echo "RESULT: $res ($N checked: $NP PATCHED, $NV VULNERABLE, $NO OUT OF RANGE, $NU UNKNOWN)"
exit "$rc"
