#!/usr/bin/env bash
# check-ms-2026-11.sh: strictly read-only, offline build check for Microsoft's November 2026 Patch Tuesday.
#   Windows (Microsoft Critical): CVE-2026-119958 Windows LDAP RCE (CVSS 3.1 9.8) and CVE-2026-119952 Windows Schannel RCE
#   (CVSS 3.1 8.1, AC:H). One cumulative update fixes both on each Windows release below.
#   Office (Microsoft Moderate, Exploitation Detected): CVE-2026-124194 Outlook spoofing (CVSS 3.1 6.5).
# It only compares the build strings and KB lists you pass, or a CSV you saved (for example an RMM or Intune export).
# No network, no file writes, no payloads, never contacts Microsoft or an endpoint.
#
# Usage:
#   check-ms-2026-11.sh --product PRODUCT --build BUILD [--channel CHANNEL] [--kbs "KB1;KB2"] [--name NAME]
#   check-ms-2026-11.sh --inventory FILE    (CSV header: name,product,build,channel,kbs; channel and kbs may be blank)
# PRODUCT: windows, windows-server, m365-apps, office-ltsc-2021, office-ltsc-2024, office-2019
# Fixed builds and KBs come from the MSRC CVRF for 2026-Nov and the cve.org records (read 10 Nov 2026 19:08 GMT):
#   10.0.14393.9688 KB5131291 (Windows 10 1607, Server 2016)   10.0.17763.9487 KB5131085 (Windows 10 1809, Server 2019)
#   10.0.19044.7846 and 10.0.19045.7846 KB5131096 (Windows 10 21H2, 22H2)   10.0.20348.5870 KB5131090 (Server 2022)
#   10.0.22631.7711 KB5131087 (Windows 11 23H2)   10.0.26100/26200/26300.9974 KB5132061 (Windows 11 24H2, 25H2, 26H2)
#     or Hotpatch KB5132043 (.9911)   10.0.28000.3501 KB5132059 (Windows 11 26H1)
#   10.0.26100.33822 KB5131080 (Server 2025) or Hotpatch KB5131077 (10.0.26100.33730)
#   Office LTSC 2021 16.0.14334.21048, Office LTSC 2024 16.0.17932.21140, Microsoft 365 Apps per channel (see README).
# Exit: 0 PATCHED, 1 VULNERABLE, 2 OUT OF RANGE (none vulnerable), 3 UNKNOWN (none vulnerable or out of range), 4 usage or read error.
set -u

usage() {
  cat >&2 <<'U'
Usage: check-ms-2026-11.sh --product PRODUCT --build BUILD [--channel CHANNEL] [--kbs "KB1;KB2"] [--name NAME]
       check-ms-2026-11.sh --inventory FILE
  --product    windows, windows-server, m365-apps, office-ltsc-2021, office-ltsc-2024 or office-2019
  --build      Windows build such as 10.0.26100.9974 or 26100.9974; Office build such as 16.0.20527.20196 or 20527.20196
  --channel    Microsoft 365 Apps only: current, monthly or semi-annual (optional)
  --kbs        installed KBs separated by ; or spaces, such as "KB5132061;KB5131080" (optional, Windows only)
  --name       label for the output (default: input)
  --inventory  CSV: name,product,build,channel,kbs
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"; }
dash() { local s; s=$(lc "$(trim "$1")"); s=${s//_/-}; s=${s// /-}; printf '%s' "$s"; }

norm_product() {
  local p; p=$(dash "$1")
  case $p in
    windows|win|client|windows-10|windows-11|win10|win11|microsoft-windows) echo windows;;
    windows-server|server|windows-server-2016|windows-server-2019|windows-server-2022|windows-server-2025) echo windows-server;;
    m365-apps|m365|microsoft-365-apps|microsoft-365-apps-for-enterprise|m365-apps-for-enterprise|o365) echo m365-apps;;
    office-ltsc-2021|ltsc-2021|office-2021-ltsc) echo office-ltsc-2021;;
    office-ltsc-2024|ltsc-2024|office-2024-ltsc) echo office-ltsc-2024;;
    office-2019|office-2019-vl|office-2019-volume-licensed) echo office-2019;;
    *) echo "$p";;
  esac
}
norm_channel() {
  local c; c=$(dash "$1")
  case $c in
    current|current-channel|cc) echo current;;
    monthly|monthly-enterprise|monthly-enterprise-channel|mec) echo monthly;;
    semi-annual|semi-annual-enterprise|semi-annual-enterprise-channel|saec|deferred) echo semi-annual;;
    *) echo "$c";;
  esac
}
norm_kbs() { # upper case, separators to single spaces
  local k; k=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr ';|' '  ' | tr -s ' '); trim "$k"
}
has_kb() { # kbs kb
  case " $1 " in *" $2 "*) return 0;; esac; return 1
}

say() { # verdict where reason
  printf '%s: %s: %s\n' "$1" "$2" "$3"
}

WIN='CVE-2026-119958 (LDAP, Critical 9.8) and CVE-2026-119952 (Schannel, Critical 8.1)'
OFF='CVE-2026-124194 (Outlook spoofing, Moderate 6.5, Exploitation Detected)'

# win_row family major -> "fixed_ubr kb label hot_ubr hot_kb" (hot fields "-" when none)
win_row() {
  case "$1:$2" in
    windows:14393) echo "9688 KB5131291 Windows-10-1607 - -";;
    windows-server:14393) echo "9688 KB5131291 Windows-Server-2016 - -";;
    windows:17763) echo "9487 KB5131085 Windows-10-1809 - -";;
    windows-server:17763) echo "9487 KB5131085 Windows-Server-2019 - -";;
    windows:19044) echo "7846 KB5131096 Windows-10-21H2 - -";;
    windows:19045) echo "7846 KB5131096 Windows-10-22H2 - -";;
    windows-server:20348) echo "5870 KB5131090 Windows-Server-2022 - -";;
    windows:22631) echo "7711 KB5131087 Windows-11-23H2 - -";;
    windows:26100) echo "9974 KB5132061 Windows-11-24H2 9911 KB5132043";;
    windows:26200) echo "9974 KB5132061 Windows-11-25H2 9911 KB5132043";;
    windows:26300) echo "9974 KB5132061 Windows-11-26H2 9911 KB5132043";;
    windows:28000) echo "3501 KB5132059 Windows-11-26H1 - -";;
    windows-server:26100) echo "33822 KB5131080 Windows-Server-2025 33730 KB5131077";;
    *) echo "";;
  esac
}

judge_windows() { # where family build kbs
  local w=$1 f=$2 b=$3 k=$4 maj ubr row fix kb lab hu hk
  if ! [[ $b =~ ^(10\.0\.)?([0-9]{5})\.([0-9]{1,6})$ ]]; then
    say UNKNOWN "$w" "not a Windows build this pack can judge (use the form 10.0.26100.9974 or 26100.9974)."; return
  fi
  maj=$((10#${BASH_REMATCH[2]})); ubr=$((10#${BASH_REMATCH[3]}))
  row=$(win_row "$f" "$maj")
  if [ -z "$row" ]; then
    say UNKNOWN "$w" "build $maj is not a $f release MSRC lists for CVE-2026-119958 or CVE-2026-119952. Check the product, its support status and the MSRC pages."; return
  fi
  read -r fix kb lab hu hk <<< "$row"
  lab=${lab//-/ }
  if has_kb "$k" "$kb"; then say PATCHED "$w" "$kb installed, the November 2026 update MSRC lists for $lab. Fixes $WIN."; return; fi
  if [ "$hk" != - ] && has_kb "$k" "$hk"; then say PATCHED "$w" "Hotpatch $hk installed, which MSRC lists for $lab. Fixes $WIN."; return; fi
  if [ "$ubr" -ge "$fix" ]; then say PATCHED "$w" "at or above $maj.$fix, the November 2026 fixed build MSRC lists for $lab. Fixes $WIN."; return; fi
  if [ "$hu" != - ] && [ "$ubr" -ge "$hu" ]; then
    say UNKNOWN "$w" "at or above the November 2026 Hotpatch build $maj.$hu but below $maj.$fix. Confirm $hk or $kb is installed (pass --kbs)."; return
  fi
  if [ "$hk" != - ]; then hk=" or Hotpatch $hk"; else hk=""; fi
  say VULNERABLE "$w" "below $maj.$fix for $lab, so affected by $WIN. Install $kb (November 2026 cumulative update)$hk and restart if required."
}

# Microsoft 365 Apps November 2026 fixed builds (major:minor) per channel, from Microsoft's Office security release notes.
m365_list() {
  case $1 in
    current) echo "20527:20196";;
    monthly) echo "20527:20194 20430:20264 20326:20320";;
    semi-annual) echo "20527:20192 20430:20262 20326:20318";;
  esac
}

judge_office() { # where product build channel
  local w=$1 p=$2 b=$3 c=$4 maj min pair m n top bot hi lo
  if [ "$p" = office-2019 ]; then
    say "OUT OF RANGE" "$w" "Office 2019 is not in MSRC's affected product list for $OFF. Microsoft's November 2026 release still lists Office 2019 Volume Licensed build 10417.20236 for other fixes, so update anyway."; return
  fi
  if ! [[ $b =~ ^(16\.0\.)?([0-9]{5})\.([0-9]{5})$ ]]; then
    say UNKNOWN "$w" "not an Office build this pack can judge (use the form 16.0.20527.20196 or 20527.20196)."; return
  fi
  maj=$((10#${BASH_REMATCH[2]})); min=$((10#${BASH_REMATCH[3]}))
  case $p in
    office-ltsc-2021|office-ltsc-2024)
      if [ "$p" = office-ltsc-2021 ]; then m=14334; n=21048; else m=17932; n=21140; fi
      if [ "$maj" -ne "$m" ]; then say UNKNOWN "$w" "build $maj is not the $p version line ($m) MSRC lists."; return; fi
      if [ "$min" -ge "$n" ]; then say PATCHED "$w" "at or above 16.0.$m.$n, the November 2026 fixed build MSRC lists for $OFF."; return; fi
      say VULNERABLE "$w" "below 16.0.$m.$n, so affected by $OFF. Install the November 2026 Office update."; return;;
  esac
  # m365-apps
  if [ -n "$c" ] && [ "$c" != current ] && [ "$c" != monthly ] && [ "$c" != semi-annual ]; then
    say UNKNOWN "$w" "channel $c is not one Microsoft's November 2026 release notes list (current, monthly or semi-annual)."; return
  fi
  if [ -n "$c" ]; then
    top=0; bot=99999; hi=""
    for pair in $(m365_list "$c"); do
      m=${pair%%:*}; n=${pair##*:}
      [ "$m" -gt "$top" ] && top=$m; [ "$m" -lt "$bot" ] && bot=$m
      [ "$m" -eq "$maj" ] && hi=$n
    done
    if [ -n "$hi" ]; then
      if [ "$min" -ge "$hi" ]; then say PATCHED "$w" "at or above $maj.$hi, the November 2026 fixed build for the $c channel. Fixes $OFF."; return; fi
      say VULNERABLE "$w" "below $maj.$hi, the November 2026 fixed build for the $c channel, so affected by $OFF. Update Microsoft 365 Apps."; return
    fi
    if [ "$maj" -gt "$top" ]; then say PATCHED "$w" "newer version than every November 2026 fixed build for the $c channel. Fixes $OFF."; return; fi
    if [ "$maj" -lt "$bot" ]; then say VULNERABLE "$w" "older than every November 2026 fixed build for the $c channel, so affected by $OFF. Move to a supported, updated version."; return; fi
    say UNKNOWN "$w" "build $maj is not a version Microsoft lists for the $c channel in November 2026."; return
  fi
  hi=""; lo=""
  for pair in $(m365_list monthly) $(m365_list semi-annual) $(m365_list current); do
    m=${pair%%:*}; n=${pair##*:}
    if [ "$m" -eq "$maj" ]; then
      if [ -z "$hi" ] || [ "$n" -gt "$hi" ]; then hi=$n; fi
      if [ -z "$lo" ] || [ "$n" -lt "$lo" ]; then lo=$n; fi
    fi
  done
  if [ -n "$hi" ]; then
    if [ "$min" -ge "$hi" ]; then say PATCHED "$w" "at or above $maj.$hi, the highest November 2026 fixed build for this version on any channel. Fixes $OFF."; return; fi
    if [ "$min" -lt "$lo" ]; then say VULNERABLE "$w" "below $maj.$lo, the lowest November 2026 fixed build for this version on any channel, so affected by $OFF. Update Microsoft 365 Apps."; return; fi
    say UNKNOWN "$w" "between $maj.$lo and $maj.$hi, where the verdict depends on the update channel. Pass --channel."; return
  fi
  if [ "$maj" -gt 20527 ]; then say PATCHED "$w" "newer version than every November 2026 fixed build. Fixes $OFF."; return; fi
  if [ "$maj" -lt 20326 ]; then say VULNERABLE "$w" "older than every November 2026 fixed build, so affected by $OFF. Move to a supported, updated version."; return; fi
  say UNKNOWN "$w" "build $maj is not a version Microsoft lists in November 2026. Pass --channel or check the channel."
}

judge() { # name product build channel kbs
  local n=$1 p b c k w
  p=$(norm_product "$2"); b=$(trim "$3"); c=$(norm_channel "$4"); k=$(norm_kbs "$5")
  w="$n (${p:-no product}, ${b:-no build}${c:+, $c}${k:+, $k})"
  if [ -z "$p" ]; then say UNKNOWN "$w" "no product given."; return; fi
  if [ -z "$b" ]; then say UNKNOWN "$w" "no build given."; return; fi
  case $p in
    windows|windows-server)
      if [ -n "$c" ]; then say UNKNOWN "$w" "a channel was given for a Windows row. Check the product column."; return; fi
      judge_windows "$w" "$p" "$b" "$k";;
    m365-apps|office-ltsc-2021|office-ltsc-2024|office-2019)
      if [ -n "$c" ] && [ "$p" != m365-apps ]; then say UNKNOWN "$w" "a channel only applies to m365-apps. Check the product column."; return; fi
      judge_office "$w" "$p" "$b" "$c";;
    *) say UNKNOWN "$w" "product $p is not one this pack judges (windows, windows-server, m365-apps, office-ltsc-2021, office-ltsc-2024, office-2019).";;
  esac
}

read_inventory() {
  local f=$1 h line n p b c k 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,build,channel,kbs" ] || die "CSV header must be: name,product,build,channel,kbs"
  while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}
    case $(trim "$line") in ''|\#*) continue;; esac
    IFS=, read -r n p b c k _ <<< "$line"
    n=$(trim "${n:-}")
    judge "${n:-name not given}" "${p:-}" "${b:-}" "${c:-}" "${k:-}"
    rows=$((rows+1))
  done < <(tail -n +2 "$f")
  [ "$rows" -gt 0 ] || die "inventory has no data rows: $f"
}

prod="" build="" chan="" kbs="" name="input" inv="" seen=0
while [ $# -gt 0 ]; do
  [ $# -ge 2 ] || usage
  case $1 in
    --product) prod=$2; seen=1;;
    --build) build=$2; seen=1;;
    --channel) chan=$2; seen=1;;
    --kbs) kbs=$2; seen=1;;
    --name) name=$2; seen=1;;
    --inventory) inv=$2;;
    *) usage;;
  esac
  shift 2
done
if [ -n "$inv" ] && [ "$seen" -eq 1 ]; then usage; fi
if [ -z "$inv" ] && { [ -z "$prod" ] || [ -z "$build" ]; }; 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" "$build" "$chan" "$kbs")
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-ms-2026-11: offline build check for Microsoft November 2026 Patch Tuesday (MSRC CVRF 2026-Nov read 10 Nov 2026 19:08 GMT)."
echo "KEV: catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT) lists CVE-2026-124194 (due 24 Nov 2026, binds US federal agencies only). CVE-2026-119958 and CVE-2026-119952 are not listed. Patch now regardless."
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"
