#!/usr/bin/env bash
# check-httpd-cve-2026-119618.sh: strictly read-only, offline check of one Apache HTTP Server instance for CVE-2026-119618
# (mod_http2 assign_headers() race; Apache rates it critical, and Apache and NVD publish no CVSS score). It reads files you
# hand it (or, with --local, well-known files on this Linux host). No network, no writes, nothing is run (httpd is never
# started; with --binary the version string is read from the file's bytes).
#
# Inputs (any combination; one server per run):
#   --version-output FILE  saved output of httpd -v or apache2 -v ("Server version: Apache/2.4.62 (Unix)")
#   --binary FILE          the httpd or apache2 binary (or a copy); the "Apache/2.4.N" string is read from its bytes
#   --modules FILE         saved output of httpd -M or apache2ctl -M (is http2_module loaded?)
#   --config PATH          a config file or folder (*.conf and *.load, 6 levels, symlinks followed; *-available folders
#                          skipped): LoadModule http2_module and Protocols h2 / h2c
#   --packages FILE        saved rpm -qa, dpkg -l or dpkg-query -W output (distro packages: httpd, apache2, mod_http2)
#   --mod-h2 VERSION       version of a standalone mod_h2 build from GitHub, if you installed one
#   --name NAME            label for the output (default: input)
#   --local                read /usr/sbin/httpd or /usr/sbin/apache2, /etc/httpd or /etc/apache2, and /var/lib/dpkg/status
# Verdicts: VULNERABLE, NOT EXPOSED (HTTP/2 off), PATCHED, NOT AFFECTED (version outside the range), DISTRO BACKPORT CHECK,
#   UNKNOWN. SECONDARY lines report CVE-2026-106023 and CVE-2026-109163 from the same release.
# Exit: 0 PATCHED, NOT EXPOSED or NOT AFFECTED; 1 VULNERABLE; 5 DISTRO BACKPORT CHECK; 3 UNKNOWN; 4 usage or read error.
set -uf
export LC_ALL=C

usage() {
  cat >&2 <<'U'
Usage: check-httpd-cve-2026-119618.sh [--version-output FILE] [--binary FILE] [--modules FILE] [--config PATH] [--packages FILE] [--mod-h2 VERSION] [--name NAME] [--local]
  --version-output  saved httpd -v or apache2 -v output
  --binary          httpd or apache2 binary (read as bytes, never run)
  --modules         saved httpd -M or apache2ctl -M output
  --config          config file or folder (*.conf, *.load)
  --packages        saved rpm -qa, dpkg -l or dpkg-query -W output
  --mod-h2          version of a standalone mod_h2 build from GitHub
  --name            label for the output (default: input)
  --local           read well-known files on this host (bash: Linux; PowerShell: Windows)
At least one input is needed. Read-only: nothing is changed or run.
Exit: 0 PATCHED, NOT EXPOSED or NOT AFFECTED; 1 VULNERABLE; 5 DISTRO BACKPORT CHECK; 3 UNKNOWN; 4 usage or read error
U
  exit 4
}
die() { echo "ERROR: $1" >&2; exit 4; }
CLEAN_AWK='function clean(v) { return substr(cleanall(v), 1, 120) }
  function cleanall(v,   o, i, c) {
    o = ""
    for (i = 1; i <= length(v); i++) {
      c = substr(v, i, 1)
      if (c ~ /[A-Za-z0-9 ._:@\/+=,()?%~#-]/) o = o c
      else if (c >= "\200" && c <= "\277") continue
      else o = o "?"
    }
    return o
  }'
clean() { printf '%s' "$1" | awk -v RS='\001' "$CLEAN_AWK"' { printf "%s", clean($0) }'; }
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
readable() { [ -f "$1" ] && [ -r "$1" ]; }

# vcmp A B: prints -1, 0 or 1 comparing dotted numeric versions.
vcmp() { awk -v a="$1" -v b="$2" 'BEGIN { na = split(a, x, "."); nb = split(b, y, "."); n = na > nb ? na : nb
  for (i = 1; i <= n; i++) { p = (i <= na) ? x[i] + 0 : 0; q = (i <= nb) ? y[i] + 0 : 0; if (p < q) { print -1; exit } if (p > q) { print 1; exit } } print 0 }'; }

vout="" bin="" mods="" cfg="" pkgs="" modh2="" name="input" local=0 seen=" "
while [ $# -gt 0 ]; do
  if [ "$1" = --local ]; then case $seen in *" --local "*) usage;; esac; seen="$seen--local "; local=1; shift; continue; fi
  [ $# -ge 2 ] || usage
  case $1 in --version-output|--binary|--modules|--config|--packages|--mod-h2|--name) case $seen in *" $1 "*) usage;; esac; seen="$seen$1 ";; *) usage;; esac
  case $1 in --version-output) vout=$2;; --binary) bin=$2;; --modules) mods=$2;; --config) cfg=$2;; --packages) pkgs=$2;; --mod-h2) modh2=$2;; --name) name=$2;; esac
  shift 2
done
if [ "$local" = 1 ]; then
  case ${OSTYPE:-} in linux*) ;; *) die "--local works on Linux only in the bash checker; pass files instead";; esac
  if [ -z "$bin" ]; then for x in /usr/sbin/httpd /usr/sbin/apache2 /usr/local/apache2/bin/httpd; do if readable "$x"; then bin=$x; break; fi; done; fi
  if [ -z "$cfg" ]; then for x in /etc/httpd /etc/apache2 /usr/local/apache2/conf; do if [ -d "$x" ]; then cfg=$x; break; fi; done; fi
  if [ -z "$pkgs" ] && readable /var/lib/dpkg/status; then pkgs=/var/lib/dpkg/status; fi
fi
{ [ -n "$vout" ] || [ -n "$bin" ] || [ -n "$mods" ] || [ -n "$cfg" ] || [ -n "$pkgs" ] || [ -n "$modh2" ]; } || { [ "$local" = 1 ] && die "--local found no httpd binary, config or package list"; usage; }
if [ -n "$modh2" ] && ! [[ $modh2 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then die "--mod-h2 takes a version like 2.0.42: $(clean "$modh2")"; fi
modh2=${modh2#v}
name=$(clean "$name")
for f in "$vout" "$bin" "$mods" "$pkgs"; do if [ -n "$f" ] && ! readable "$f"; then die "cannot read file: $(clean "$f")"; fi; done
if [ -n "$cfg" ] && ! { readable "$cfg" || { [ -d "$cfg" ] && [ -r "$cfg" ] && [ -x "$cfg" ]; }; }; then die "cannot read config: $(clean "$cfg")"; fi

L=()   # detail lines, printed after the verdict
add() { L+=("$1: $name: $2"); }

# Version and build tag.
ver="" tag="" src=""
if [ -n "$vout" ]; then
  line=$(awk '{ sub(/\r$/, "") } /Server version:/ { print; exit }' "$vout")
  if [[ $line =~ Apache/([0-9]+\.[0-9]+\.[0-9]+)([[:space:]]+\(([^\)]*)\))? ]]; then ver=${BASH_REMATCH[1]}; tag=${BASH_REMATCH[3]}; src="httpd -v output"
  else
    ver=$(grep -aoE 'Apache/[0-9]+\.[0-9]+\.[0-9]+' "$vout" | awk 'NR == 1 { sub(/^Apache\//, ""); print }')
    if [ -n "$ver" ]; then tag="?"; src="httpd -v output (no Server version line)"; fi
  fi
  [ -n "$ver" ] || add NOTE "no Apache version found in the -v output."
fi
if [ -z "$ver" ] && [ -n "$bin" ]; then
  ver=$(grep -aoE 'Apache/2\.[0-9]+\.[0-9]+' "$bin" | awk 'NR == 1 { sub(/^Apache\//, ""); print }')
  if [ -n "$ver" ]; then src="binary"; tag="?"; else add NOTE "no Apache/2.x.y version string found in the binary."; fi
fi

# Packages (rpm -qa, dpkg -l, dpkg-query -W, or /var/lib/dpkg/status).
pk_http="" pk_h2=""
if [ -n "$pkgs" ]; then
  res=$(awk '{ sub(/\r$/, "") }
    /^Package: / { p = $2; next } /^Status: / { st = ($0 ~ / installed$/); next }
    /^Version: / && p != "" { if (st && (p == "apache2" || p == "apache2-bin" || p == "libapache2-mod-http2")) out[p] = $2; p = ""; next }
    /^ii[ \t]/ { if ($2 ~ /^(apache2|apache2-bin|libapache2-mod-http2)(:[a-z0-9]+)?$/) { q = $2; sub(/:.*/, "", q); out[q] = $3 }; next }
    NF == 2 && $1 ~ /^(apache2|apache2-bin|libapache2-mod-http2)(:[a-z0-9]+)?$/ { q = $1; sub(/:.*/, "", q); out[q] = $2; next }
    NF == 1 && match($1, /^(httpd|apache2|mod_http2|jbcs-httpd24-httpd|jbcs-httpd24-mod_http2)-[0-9]/) { n = substr($1, 1, RLENGTH - 2); v = substr($1, RLENGTH); out[n] = v }
    END { h = ""; m = ""
      if ("httpd" in out) h = "httpd-" out["httpd"]; else if ("apache2" in out) h = "apache2 " out["apache2"]; else if ("apache2-bin" in out) h = "apache2-bin " out["apache2-bin"]; else if ("jbcs-httpd24-httpd" in out) h = "jbcs-httpd24-httpd-" out["jbcs-httpd24-httpd"]
      if ("mod_http2" in out) m = "mod_http2-" out["mod_http2"]; else if ("libapache2-mod-http2" in out) m = "libapache2-mod-http2 " out["libapache2-mod-http2"]; else if ("jbcs-httpd24-mod_http2" in out) m = "jbcs-httpd24-mod_http2-" out["jbcs-httpd24-mod_http2"]
      print h "\001" m }' "$pkgs")
  pk_http=${res%%$'\001'*}; pk_h2=${res#*$'\001'}
  pk_http=$(clean "$pk_http"); pk_h2=$(clean "$pk_h2")
  if [ -z "$pk_http" ] && [ -z "$pk_h2" ]; then add NOTE "no httpd, apache2 or mod_http2 package in the package list."; fi
  if [ -z "$ver" ] && [[ $pk_http =~ [-\ ]([0-9]+\.[0-9]+\.[0-9]+) ]]; then ver=${BASH_REMATCH[1]}; src="package"; fi
fi
distro=0
lt=$(lower "$tag")
case $lt in ""|unix|win32|win64|windows|"?") ;; *) distro=1;; esac
[ -n "$pk_http$pk_h2" ] && distro=1

# Modules and config.
mstate=""   # yes, no or empty (not given)
if [ -n "$mods" ]; then
  if grep -qiE '^[[:space:]]*http2_module[[:space:]]' "$mods"; then mstate=yes; else mstate=no; fi
  if ! grep -qiE '^[[:space:]]*[a-z0-9_]+_module[[:space:]]' "$mods"; then add NOTE "the modules file lists no modules (is it httpd -M output?)."; mstate=""; fi
fi
has_mod() { [ -n "$mods" ] && grep -qiE "^[[:space:]]*$1[[:space:]]" "$mods"; }
cload=0 cprot=0 ch2=0 cinc=0 cfiles=0 h2where=""
if [ -n "$cfg" ]; then
  if [ -d "$cfg" ]; then
    list=$(find -L "$cfg" -maxdepth 6 \( -type d \( -name 'mods-available' -o -name 'sites-available' -o -name 'conf-available' \) -prune \) -o -type f \( -name '*.conf' -o -name '*.load' \) -print 2>/dev/null | sort)
  else list=$cfg; fi
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    readable "$f" || continue
    cfiles=$((cfiles+1))
    rel=${f#"$cfg"}; rel=${rel#/}; [ -n "$rel" ] || rel=${f##*/}
    r=$(awk '{ sub(/\r$/, ""); sub(/^\357\273\277/, ""); s = $0; sub(/^[ \t]+/, "", s); if (s == "" || substr(s, 1, 1) == "#") next
        n = split(s, w, /[ \t]+/); d = tolower(w[1])
        if (d == "loadmodule" && tolower(w[2]) == "http2_module") lm = 1
        if (d == "include" || d == "includeoptional") inc++
        if (d == "protocols") { pr++; for (i = 2; i <= n; i++) { t = tolower(w[i]); if (t == "h2" || t == "h2c") { if (first == "") first = NR; h2[t] = 1 } } }
      } END { print lm + 0, pr + 0, inc + 0, (first == "" ? 0 : first), ("h2" in h2) ("h2c" in h2) }' "$f")
    read -r a b c d e <<< "$r"
    [ "$a" = 1 ] && cload=1
    cprot=$((cprot+b)); cinc=$((cinc+c))
    if [ "$d" != 0 ]; then ch2=1; t=""; [ "${e:0:1}" = 1 ] && t="h2"; [ "${e:1:1}" = 1 ] && t="${t:+$t and }h2c"; h2where="${h2where:+$h2where, }$(clean "$rel"):$d ($t)"; fi
  done <<< "$list"
  [ "$cfiles" -gt 0 ] || add NOTE "no *.conf or *.load files read under the config path."
fi

# HTTP/2 exposure: on, off or unknown.
expo=unknown why=""
if [ "$mstate" = no ]; then expo=off; why="http2_module is not in the loaded modules list"
elif [ -n "$cfg" ] && [ "$cfiles" -gt 0 ] && [ "$ch2" = 0 ]; then expo=off; why="no Protocols directive in the config files read lists h2 or h2c (Apache's default is Protocols http/1.1)"
elif [ "$ch2" = 1 ] && { [ "$mstate" = yes ] || [ "$cload" = 1 ]; }; then expo=on; why="http2_module is loaded and Protocols lists HTTP/2 at $h2where"
elif [ "$ch2" = 1 ]; then expo=on; why="Protocols lists HTTP/2 at $h2where (LoadModule http2_module not seen in the files read; it may be loaded elsewhere or built in)"
elif [ "$mstate" = yes ]; then why="http2_module is loaded; Protocols not checked (pass --config)"
else why="not checked (pass --modules and --config)"; fi
[ "$cinc" -gt 0 ] && add NOTE "$cinc Include lines seen; files outside the given config path are not read, so check any Protocols there too."
[ "$cprot" -gt 0 ] && [ "$ch2" = 0 ] && add NOTE "$cprot Protocols lines read, none with h2 or h2c."

# Verdict.
v="" rs=""
inr=0; cmp55=""; cmp71=""
if [ -n "$ver" ]; then cmp55=$(vcmp "$ver" 2.4.55); cmp71=$(vcmp "$ver" 2.4.71); fi
h2s=""
if [ -n "$modh2" ]; then
  if [ "$(vcmp "$modh2" 2.0.8)" -ge 0 ] && [ "$(vcmp "$modh2" 2.0.42)" -le 0 ]; then h2s=affected
  elif [ "$(vcmp "$modh2" 2.0.8)" -lt 0 ]; then h2s=before
  else h2s=newer; add NOTE "mod_h2 $modh2 is newer than 2.0.42, the last GitHub release; we know of no such release, so it is not judged."; fi
fi
case $ver in 2.4.*) [ "$cmp55" -ge 0 ] && [ "$cmp71" -lt 0 ] && inr=1;; esac
if [ -z "$ver" ] && [ -z "$h2s" ] && [ "$distro" = 0 ]; then
  if [ "$expo" = off ]; then v="NOT EXPOSED"; rs="HTTP/2 is off: $why (OUR INFERENCE: the Apache advisory does not discuss configuration; Apache documents Protocols http/1.1 as the default, with h2 and h2c needed for HTTP/2). Version unknown; patch anyway when you can."
  else v=UNKNOWN; rs="no httpd version found (HTTP/2: $why)."; fi
elif [ "$expo" = off ]; then
  v="NOT EXPOSED"; rs="HTTP/2 is off: $why (OUR INFERENCE: the Apache advisory does not discuss configuration; Apache documents Protocols http/1.1 as the default, with h2 and h2c needed for HTTP/2)."
  if [ "$inr" = 1 ] || [ "$h2s" = affected ]; then rs="$rs The version is in the affected range, so update anyway; turning HTTP/2 on later would expose it."; fi
elif [ "$distro" = 1 ]; then
  v="DISTRO BACKPORT CHECK"; rs="this is a distribution or vendor build (${tag:+build tag \"$(clean "$tag")\"}${tag:+${pk_http:+, }}${pk_http:+package $pk_http}${pk_h2:+, $pk_h2}). Distributions backport fixes without changing the upstream version, so check the distribution's advisory for CVE-2026-119618; do not trust the version string. HTTP/2: $why."
elif [ "$h2s" = affected ]; then
  v=VULNERABLE; rs="standalone mod_h2 $modh2 is in the affected range 2.0.8 to 2.0.42 (Apache), and the archived GitHub project has published no fixed release; move to the mod_http2 in httpd 2.4.71. HTTP/2: $why."
elif [ -z "$ver" ]; then
  v=NOT\ AFFECTED; rs="standalone mod_h2 $modh2 is outside the affected range 2.0.8 to 2.0.42; httpd version unknown."
  [ "$h2s" = newer ] && { v=UNKNOWN; rs="standalone mod_h2 $modh2 is not a known release; httpd version unknown."; }
else
  case $ver in
    2.4.*)
      if [ "$cmp71" -ge 0 ]; then v=PATCHED; rs="httpd $ver is at or above 2.4.71, the fixed release Apache names."
      elif [ "$inr" = 1 ]; then v=VULNERABLE; rs="httpd $ver is in the affected range 2.4.55 to 2.4.70 (Apache); fixed in 2.4.71. HTTP/2: $why."
        [ "$expo" = on ] || rs="$rs We expect exposure only where HTTP/2 is on (OUR INFERENCE)."
      else v="NOT AFFECTED"; rs="httpd $ver is below 2.4.55, outside the range Apache lists (unless a standalone mod_h2 2.0.8 to 2.0.42 is loaded; pass --mod-h2). Older releases carry other fixed flaws, so update anyway."; fi
      if [ "$src" = binary ] && [ "$v" = VULNERABLE ]; then rs="$rs Version read from the binary, so the build origin is unknown: if a distribution or vendor packaged it, treat this as DISTRO BACKPORT CHECK."; fi;;
    2.2.*) v="NOT AFFECTED"; rs="httpd $ver (2.2 has no mod_http2 and is end of life; it has many unfixed flaws, so replace it).";;
    *) v=UNKNOWN; rs="httpd $ver is not a 2.4 release.";;
  esac
fi
[ -n "$ver" ] && add NOTE "httpd version $ver from the $src${tag:+, build tag \"$(clean "$tag")\"}."
[ -n "$pk_h2" ] && add NOTE "distribution HTTP/2 package: $pk_h2."

# Same release, secondary CVEs (upstream builds only; distro builds: check the advisory).
if [ -n "$ver" ] && [ "$distro" = 0 ]; then
  case $ver in 2.4.*)
    if [ "$(vcmp "$ver" 2.4.30)" -ge 0 ] && [ "$cmp71" -lt 0 ]; then
      if has_mod md_module; then add SECONDARY "CVE-2026-106023 (mod_md NULL pointer dereference, child process crash; Apache: important) affects 2.4.30 to 2.4.70 and md_module is loaded. Fixed in 2.4.71."
      elif [ -z "$mods" ]; then add SECONDARY "CVE-2026-106023 (mod_md NULL pointer dereference; Apache: important) affects 2.4.30 to 2.4.70 if mod_md is loaded. Fixed in 2.4.71."; fi
    fi
    if [ "$(vcmp "$ver" 2.4.69)" -ge 0 ] && [ "$cmp71" -lt 0 ]; then
      if has_mod vhost_alias_module; then add SECONDARY "CVE-2026-109163 (mod_vhost_alias 1 byte stack buffer overwrite; Apache: low) affects 2.4.69 and 2.4.70 and vhost_alias_module is loaded. Fixed in 2.4.71."
      elif [ -z "$mods" ]; then add SECONDARY "CVE-2026-109163 (mod_vhost_alias 1 byte stack buffer overwrite; Apache: low) affects 2.4.69 and 2.4.70 if mod_vhost_alias is loaded. Fixed in 2.4.71."; fi
    fi;;
  esac
elif [ "$distro" = 1 ]; then add SECONDARY "the same Apache release fixes CVE-2026-106023 (mod_md; Apache: important) and CVE-2026-109163 (mod_vhost_alias; Apache: low); check the distribution advisory for both."; fi

echo "check-httpd-cve-2026-119618: offline, read-only check for CVE-2026-119618 (mod_http2 assign_headers race). Apache rates it critical; Apache and NVD publish no CVSS score."
echo "ADVISORY (Apache, 11 Nov 2026): affects httpd 2.4.55 through 2.4.70 and mod_h2 from GitHub 2.0.8 through 2.0.42; fixed in httpd 2.4.71."
echo "$v: $name: $rs"
for x in "${L[@]+"${L[@]}"}"; do printf '%s\n' "$x"; done
case $v in VULNERABLE) rc=1;; "DISTRO BACKPORT CHECK") rc=5;; UNKNOWN) rc=3;; *) rc=0;; esac
echo "RESULT: $v"
exit "$rc"
