#!/usr/bin/env bash
# check-checkmk.sh: strictly read-only, offline version check for the Checkmk security release of 11 Nov 2026:
# CVE-2026-118965 (werk 22120), CVE-2026-120495 (werk 22624, Robotmk, commercial editions) and CVE-2026-121374 (werk 17994).
# Checkmk rates all three Critical, CVSS 4.0 9.4. It only compares the version strings you pass, a CSV you saved, or the
# target of a site's "version" link (read with readlink, nothing is run). No network, no file writes, no payloads.
#
# Usage:
#   check-checkmk.sh --version VER [--name NAME]   VER: 2.4.0p39, 2.4.0p39.cre, 2.5.0p19.community, or the whole line
#                                                   printed by "omd version" (quote it)
#   check-checkmk.sh --site DIR [--name NAME]      DIR: a site home such as /omd/sites/mysite (reads DIR/version link)
#   check-checkmk.sh --inventory FILE              CSV header: name,version
# Checkmk's tables (werks 22120, 22624, 17994; CVE records):
#   2.5.0 before 2.5.0p19: affected, fixed in 2.5.0p19.   2.4.0 before 2.4.0p40: affected, fixed in 2.4.0p40.
#   2.3.0 (EOL): affected, Checkmk will not fix it.        Werk 22624 lists commercial editions only.
# Verdicts: PATCHED, VULNERABLE (2.3.0 is VULNERABLE with an EOL note), OUT OF RANGE (branch not listed by Checkmk, no claim),
#   UNKNOWN (pre-release i or b builds, daily builds, unreadable versions). Per CVE: PATCHED, VULNERABLE, NOT AFFECTED, UNKNOWN.
# 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-checkmk.sh --version VER [--name NAME]
       check-checkmk.sh --site DIR [--name NAME]
       check-checkmk.sh --inventory FILE
  --version    Checkmk version such as 2.4.0p39, 2.4.0p39.cre or 2.5.0p19.community, or the "omd version" line (quote it)
  --site       site home such as /omd/sites/mysite; reads where its version link points (nothing is run)
  --name       label for the output (default: input, or the site folder name)
  --inventory  CSV: name,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"; }

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

# Edition from the version suffix. Suffixes from Checkmk's public source (cmk/ccc/version.py):
# 2.4.0 branch: cre (Community, formerly Raw), cee, cce, cse, cme (commercial). 2.5.0 branch: community; pro, ultimate,
# ultimatemt, cloud (commercial). We assume 2.3.0 uses the 2.4.0 suffixes (UNVERIFIED).
edition() { # branch suffix
  local b=$1 s=$2
  [ -z "$s" ] && { echo unknown; return; }
  case "$b:$s" in
    2.5.0:community) echo community;;
    2.5.0:pro|2.5.0:ultimate|2.5.0:ultimatemt|2.5.0:cloud) echo commercial;;
    2.4.0:cre|2.3.0:cre) echo community;;
    2.4.0:cee|2.4.0:cce|2.4.0:cse|2.4.0:cme|2.3.0:cee|2.3.0:cce|2.3.0:cse|2.3.0:cme) echo commercial;;
    *) echo unknown;;
  esac
}

judge() { # name version
  local n=$1 v w a b c p s ed fix r2
  v=$(trim "$2")
  v=${v#OMD - Open Monitoring Distribution Version }
  v=$(trim "$v")
  w="$n (${v:-no version})"
  if [ -z "$v" ]; then say UNKNOWN "$w" "no version given."; return; fi
  if ! [[ $v =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})((p)([0-9]{1,4})|([ib])([0-9]{1,4}))?(\.([a-z]{2,12}))?$ ]]; then
    say UNKNOWN "$w" "not a Checkmk release version this pack can judge (use the form 2.4.0p39, 2.4.0p39.cre or 2.5.0p19.community). Daily builds are not judged."; return
  fi
  a=$((10#${BASH_REMATCH[1]})); b=$((10#${BASH_REMATCH[2]})); c=$((10#${BASH_REMATCH[3]}))
  p=0; [ -n "${BASH_REMATCH[6]}" ] && p=$((10#${BASH_REMATCH[6]}))
  s=${BASH_REMATCH[10]}
  if [ -n "${BASH_REMATCH[7]}" ]; then
    say UNKNOWN "$w" "pre-release build (${BASH_REMATCH[7]}${BASH_REMATCH[8]}). Checkmk's tables list stable patch releases only. Move to a stable release: 2.5.0p19 or 2.4.0p40 or above."; return
  fi
  ed=$(edition "$a.$b.$c" "$s")
  case $ed in
    community) r2="NOT AFFECTED (werk 22624 lists commercial editions only)";;
    commercial) r2=VULNERABLE;;
    *) r2="UNKNOWN (edition not given or not recognised; VULNERABLE if commercial)";;
  esac
  case "$a.$b.$c" in
    2.5.0) fix=19;;
    2.4.0) fix=40;;
    2.3.0)
      say VULNERABLE "$w" "Checkmk lists 2.3.0 (EOL) as affected and will not fix it. Upgrade to 2.4.0p40 or 2.5.0p19 or above. CVE-2026-118965 VULNERABLE; CVE-2026-120495 $r2; CVE-2026-121374 VULNERABLE."; return;;
    *)
      say "OUT OF RANGE" "$w" "branch $a.$b.$c is not listed in werks 22120, 22624 or 17994. Checkmk lists only 2.5.0, 2.4.0 and 2.3.0 (EOL), and this pack makes no claim about other branches. For 2.3.0 or lower, Checkmk recommends upgrading to a supported version."; return;;
  esac
  if [ "$p" -ge "$fix" ]; then
    if [ "$ed" = community ]; then r2="NOT AFFECTED (werk 22624 lists commercial editions only)"; else r2=PATCHED; fi
    say PATCHED "$w" "at or above $a.$b.${c}p$fix, Checkmk's fix. CVE-2026-118965 PATCHED; CVE-2026-120495 $r2; CVE-2026-121374 PATCHED."; return
  fi
  say VULNERABLE "$w" "below $a.$b.${c}p$fix, in Checkmk's affected range. Upgrade to $a.$b.${c}p$fix or above. CVE-2026-118965 VULNERABLE; CVE-2026-120495 $r2; CVE-2026-121374 VULNERABLE."
}

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

read_site() { # dir: prints the version folder name the link points to
  local d=${1%/} t
  [ -d "$d" ] || die "not a folder: $1"
  [ -L "$d/version" ] || die "no version link in $1 (expected $d/version)"
  t=$(readlink -- "$d/version") || die "cannot read version link in $1"
  t=${t%/}
  printf '%s' "${t##*/}"
}

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

# Read everything before printing anything, so read errors leave stdout empty.
if [ -n "$inv" ]; then
  out=$(read_inventory "$inv") || exit 4
elif [ -n "$site" ]; then
  sv=$(read_site "$site") || exit 4
  s=${site%/}
  out=$(judge "${name:-${s##*/}}" "$sv")
else
  out=$(judge "${name:-input}" "$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-checkmk: offline version check for Checkmk CVE-2026-118965, CVE-2026-120495 and CVE-2026-121374 (werks 22120, 22624, 17994; fixed in 2.5.0p19 and 2.4.0p40, released 11 Nov 2026)."
echo "KEV: none of the three is in CISA KEV catalog 2026.11.10 (released 10 Nov 2026 19:03 GMT), which predates the CVE records (11 Nov 2026 10:45 to 10:48 GMT). Checkmk published no IOCs."
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"
