#!/usr/bin/env bash
# check-brotli.sh - STRICTLY READ-ONLY exposure checker for CVE-2026-111497
#   Out-of-bounds write in the Google Brotli decoder (c/dec/decode.c), nicknamed HTTP2Shell / Node2Shell /
#   Broccoli2Shell. Affected upstream: Brotli 1.0.3 to 1.2.0. Fixed upstream: 1.0.10, 1.1.1, 1.2.1.
# Fixed versions used here (each from the project's own advisory or release notes, checked 27 Oct 2026):
#   Node.js 22.23.4 / 24.22.1 / 26.12.1 (Node 20 and older are end-of-life: no fix) | Bun 1.4.4
#   PyPI Brotli 1.2.1 | brotlicffi 1.2.1.0 | .NET runtime 8.0.33 / 9.0.22 / 10.0.14 / 11.0.0-rc.3.26522.148
#   PowerShell 7.4.22 / 7.5.13 / 7.6.9 | Envoy 1.36.12 / 1.37.8 / 1.38.6 / 1.39.3 / 1.40.0
#   Debian/Ubuntu libbrotli backports: 1.0.9-2+deb12u1, 1.1.0-2+deb13u1, 1.2.0-4.1,
#   1.0.9-2ubuntu0.1, 1.1.0-2ubuntu0.1, 1.2.0-3ubuntu0.1, 1.2.0-4ubuntu0.1
#
# What it does: runs ONLY known runtime binaries (node, nodejs, bun, dotnet, curl, envoy, pwsh, python3) and
# ONLY with a version flag (-v / --version / --list-runtimes / -V); queries the package database, pkg-config
# and ldconfig; and reads file names and package metadata files. It never executes files it finds, never
# writes, restarts or reconfigures anything, sends no Brotli data anywhere and makes no network connections.
#
# Usage:
#   bash check-brotli.sh                                # system discovery
#   bash check-brotli.sh --search-dir /srv/app          # also look for bundled copies under DIR (repeatable)
#   bash check-brotli.sh --assess node=24.22.0          # assess COMPONENT=VERSION offline (repeatable)
#        components: node bun brotli python-brotli brotlicffi dotnet pwsh envoy curl
#        (curl takes a `curl -V` first line or the brotli/x.y.z it reports)
#   bash check-brotli.sh --csv inventory.csv            # CSV: host,component,version (header optional)
#   bash check-brotli.sh --bin /opt/node/bin/node       # assess a specific runtime binary (repeatable)
#   options: --no-system (skip system discovery; use with --search-dir / --bin)  -h|--help
#
# Execution guard: a candidate is only run if its file name is one of the runtime names above (python3 may
# carry a minor version, e.g. python3.12), it resolves to a regular executable file and its first 4 bytes are
# the ELF (7f 45 4c 46) or Mach-O magic. Anything else is reported as SKIPPED (not executed).
# BROTLI_TEST_ALLOW_SCRIPTS=1 is TEST-ONLY: it additionally accepts the pack's own mock fixtures (files that
# start with "#!/bin/sh" followed by the "# MOCK runtime binary for tests only" marker). Never set it in production.
#
# Verdicts / exit codes: PATCHED 0 (NOT AFFECTED counts as PATCHED) | LIKELY VULNERABLE 1 | UNKNOWN 2 | error 3
set -u
export LC_ALL=C

CVE='CVE-2026-111497'
VULN=0; UNK=0; PATCHED=0
NO_SYSTEM=0
DEB_FIXED='1.0.9-2+deb12u1 1.1.0-2+deb13u1 1.2.0-4.1'
UBU_FIXED='1.0.9-2ubuntu0.1 1.1.0-2ubuntu0.1 1.2.0-3ubuntu0.1 1.2.0-4ubuntu0.1'

say() { printf '%s\n' "$*"; }
hdr() { printf '\n==== %s ====\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 3; }
usage() { sed -n '2,/^set -u/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; }

status_block() {
  hdr "KEV status and hunt reminder"
  say "CISA KEV: LISTED - $CVE, dateAdded 2026-10-27, knownRansomwareCampaignUse Unknown,"
  say "  forensicTriage No (KEV catalog 2026.10.27)."
  say "  CISA due date 10 Nov 2026 (applies to US federal agencies only, under BOD 26-04; patch now regardless)."
  say "Exploitation: CISA lists the CVE in KEV. No primary source (Google, Node.js, Microsoft, CISA, NVD)"
  say "  publishes exploitation details or IOCs; none are included in this pack."
  say "Exposure needs attacker-controlled Brotli data reaching a vulnerable decoder (Content-Encoding: br"
  say "  request bodies or responses, fetch()/HTTP clients with auto-decompression, br file/stream parsing)."
  say "After updating: restart every Node/Bun/Python/.NET/Envoy process, and rebuild container images,"
  say "  self-contained/NativeAOT .NET apps and anything that bundles its own Brotli."
  say "PATCHED DOES NOT MEAN CLEAN: hunt on every host that ran a vulnerable decoder while exposed -"
  say "  crash/restart loops in node/bun/python/dotnet/envoy, those processes spawning shells, unusual outbound"
  say "  connections, and br-encoded requests from unfamiliar sources. See README.md and the KQL/Sigma content."
}

# returns 0 if $1 >= $2 (dotted numeric)
ver_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ]; }
# first dotted version token (2 to 4 parts) from a string
extract_ver() { printf '%s' "$1" | grep -Eo '[0-9]+\.[0-9]+(\.[0-9]+){0,2}' | head -n1; }
major() { printf '%s' "$1" | cut -d. -f1; }
majmin() { printf '%s' "$1" | cut -d. -f1,2; }

set_v() { VERDICT=$1; WHY=$2; }

# ---------- per-component verdicts: each sets VERDICT and WHY ----------
assess_node() { # $1 = node -v output, $2 = owning package (optional)
  local v m; v=$(extract_ver "$1"); m=$(major "${v:-x}")
  [ -n "$v" ] || { set_v UNKNOWN "could not determine the Node.js version"; return; }
  case "$m" in
    22) ver_ge "$v" 22.23.4 && { set_v PATCHED "$v >= 22.23.4"; return; }; set_v "LIKELY VULNERABLE" "$v < 22.23.4 (fixed release for 22.x)" ;;
    24) ver_ge "$v" 24.22.1 && { set_v PATCHED "$v >= 24.22.1"; return; }; set_v "LIKELY VULNERABLE" "$v < 24.22.1 (fixed release for 24.x)" ;;
    26) ver_ge "$v" 26.12.1 && { set_v PATCHED "$v >= 26.12.1"; return; }; set_v "LIKELY VULNERABLE" "$v < 26.12.1 (fixed release for 26.x)" ;;
    *)
      if [ "$m" -ge 27 ] 2>/dev/null; then set_v PATCHED "$v is on a line released after the fixes"; return; fi
      if [ "$m" -le 20 ] 2>/dev/null; then
        set_v "LIKELY VULNERABLE" "Node.js $v is END-OF-LIFE (Node 20 and older): no fixed release will ship. Move to 22.23.4 / 24.22.1 / 26.12.1 or later"
      else
        set_v "LIKELY VULNERABLE" "Node.js $v is an end-of-life odd-numbered line with no fixed release"
      fi ;;
  esac
  if [ "$VERDICT" != PATCHED ] && [ -n "${2:-}" ]; then
    set_v UNKNOWN "$v is a distribution build (package $2); distro builds may link the system libbrotli instead of the bundled copy - see the libbrotli verdict and the distro advisory"
  fi
}
assess_bun() {
  local v; v=$(extract_ver "$1")
  [ -n "$v" ] || { set_v UNKNOWN "could not determine the Bun version"; return; }
  if ver_ge "$v" 1.4.4; then set_v PATCHED "$v >= 1.4.4 (Bun's 1.4.4 notes list Brotli among the security fixes)"
  else set_v "LIKELY VULNERABLE" "$v < 1.4.4"; fi
}
# upstream Brotli number only
assess_brotli_upstream() {
  local v=$1 mm
  if ! ver_ge "$v" 1.0.3; then set_v PATCHED "NOT AFFECTED: $v is below the affected range (1.0.3 to 1.2.0)"; return; fi
  mm=$(majmin "$v")
  case "$mm" in
    1.0) ver_ge "$v" 1.0.10 && { set_v PATCHED "$v >= 1.0.10"; return; }; set_v "LIKELY VULNERABLE" "$v < 1.0.10 (fixed release for 1.0.x)" ;;
    1.1) ver_ge "$v" 1.1.1 && { set_v PATCHED "$v >= 1.1.1"; return; }; set_v "LIKELY VULNERABLE" "$v < 1.1.1 (fixed release for 1.1.x)" ;;
    1.2) ver_ge "$v" 1.2.1 && { set_v PATCHED "$v >= 1.2.1"; return; }; set_v "LIKELY VULNERABLE" "$v < 1.2.1 (fixed release for 1.2.x)" ;;
    *) set_v PATCHED "$v is newer than the fixed releases" ;;
  esac
}
# libbrotli: upstream number or a distro package version; $2 = changelog CVE hit (optional)
assess_brotli() {
  local raw=$1 cl=${2:-} v base fx flav
  if [ -n "$cl" ]; then set_v PATCHED "package changelog references $CVE (vendor backport)"; return; fi
  v=${raw#*:}                                   # drop a Debian epoch
  base=$(printf '%s' "$v" | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+')
  [ -n "$base" ] || { set_v UNKNOWN "could not determine the Brotli version from '$raw'"; return; }
  assess_brotli_upstream "$base"
  [ "$VERDICT" = "LIKELY VULNERABLE" ] || return 0
  [ "$v" = "$base" ] && return 0               # plain upstream number
  case "$v" in *ubuntu*) flav=$UBU_FIXED ;; *deb*|*+b[0-9]*|*-[0-9].[0-9]) flav=$DEB_FIXED ;; *) flav="" ;; esac
  for fx in $DEB_FIXED $UBU_FIXED; do
    [ "$v" = "$fx" ] && { set_v PATCHED "$v is the Debian/Ubuntu fixed package"; return; }
  done
  if [ -n "$flav" ] && command -v dpkg >/dev/null 2>&1; then
    for fx in $flav; do
      case "$fx" in "$base"-*) ;; *) continue ;; esac
      if dpkg --compare-versions "$v" ge "$fx"; then set_v PATCHED "$v >= $fx (Debian/Ubuntu fixed package)"; return; fi
      set_v "LIKELY VULNERABLE" "$v < $fx (Debian/Ubuntu fixed package for $base)"; return
    done
  fi
  set_v UNKNOWN "vendor build $v (upstream $base < fix); a backport is possible but not evidenced - check the distro advisory"
}
assess_pybrotli() { # $1 version, $2 "distro" if package-managed
  local v; v=$(extract_ver "$1")
  [ -n "$v" ] || { set_v UNKNOWN "could not read the Brotli package version"; return; }
  if ! ver_ge "$v" 1.0.3; then set_v PATCHED "NOT AFFECTED: $v is below the affected range (>= 1.0.3, < 1.2.1)"; return; fi
  if ver_ge "$v" 1.2.1; then set_v PATCHED "$v >= 1.2.1"; return; fi
  if [ "${2:-}" = distro ]; then set_v UNKNOWN "distro-packaged Brotli $v (< 1.2.1 upstream); check the distro advisory for a backport"; return; fi
  set_v "LIKELY VULNERABLE" "$v < 1.2.1 (PyPI never published 1.0.10 or 1.1.1: upgrade to 1.2.1)"
}
assess_brotlicffi() {
  local v; v=$(extract_ver "$1")
  [ -n "$v" ] || { set_v UNKNOWN "could not read the brotlicffi package version"; return; }
  if ! ver_ge "$v" 1.0.3; then set_v PATCHED "NOT AFFECTED: $v is below the affected range (>= 1.0.3, < 1.2.1.0)"; return; fi
  if ver_ge "$v" 1.2.1.0; then set_v PATCHED "$v >= 1.2.1.0"; return; fi
  if [ "${2:-}" = distro ]; then set_v UNKNOWN "distro-packaged brotlicffi $v; Debian notes it uses the system libbrotli - see the libbrotli verdict"; return; fi
  set_v "LIKELY VULNERABLE" "$v < 1.2.1.0"
}
# one Microsoft.NETCore.App runtime version, e.g. 8.0.32 or 11.0.0-rc.2.26473.101
assess_dotnet() {
  local raw=$1 v m pre
  v=$(printf '%s' "$raw" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?' | head -n1)
  [ -n "$v" ] || { set_v UNKNOWN "could not determine the .NET runtime version"; return; }
  m=$(major "$v"); pre=""; case "$v" in *-*) pre=${v#*-} ;; esac
  case "$m" in
    8)  ver_ge "${v%%-*}" 8.0.33  && { set_v PATCHED "$v >= 8.0.33"; return; };  set_v "LIKELY VULNERABLE" "$v < 8.0.33 (Microsoft advisory, runtime fix for .NET 8)" ;;
    9)  ver_ge "${v%%-*}" 9.0.22  && { set_v PATCHED "$v >= 9.0.22"; return; };  set_v "LIKELY VULNERABLE" "$v < 9.0.22 (Microsoft advisory, runtime fix for .NET 9)" ;;
    10) ver_ge "${v%%-*}" 10.0.14 && { set_v PATCHED "$v >= 10.0.14"; return; }; set_v "LIKELY VULNERABLE" "$v < 10.0.14 (Microsoft advisory, runtime fix for .NET 10)" ;;
    11)
      if [ -z "$pre" ]; then set_v PATCHED "$v is a .NET 11 release after the fixed preview"; return; fi
      case "$pre" in
        preview*) set_v "LIKELY VULNERABLE" "$v is a .NET 11 preview before 11.0.0-rc.3.26522.148" ;;
        rc.[12].*) set_v "LIKELY VULNERABLE" "$v is before 11.0.0-rc.3.26522.148" ;;
        rc.3.*) if ver_ge "${pre#rc.3.}" 26522.148; then set_v PATCHED "$v >= 11.0.0-rc.3.26522.148"; else set_v "LIKELY VULNERABLE" "$v < 11.0.0-rc.3.26522.148"; fi ;;
        *) set_v UNKNOWN "unrecognised .NET 11 pre-release $v" ;;
      esac ;;
    *)
      if [ "$m" -ge 12 ] 2>/dev/null; then set_v PATCHED "$v is newer than the fixed releases"
      else set_v UNKNOWN ".NET $v is out of support and not listed in Microsoft's advisory (no fix will ship); move to a patched supported runtime"; fi ;;
  esac
}
assess_pwsh() {
  local v mm; v=$(extract_ver "$1"); [ -n "$v" ] || { set_v UNKNOWN "could not determine the PowerShell version"; return; }
  mm=$(majmin "$v")
  case "$mm" in
    7.4) ver_ge "$v" 7.4.22 && { set_v PATCHED "$v >= 7.4.22"; return; }; set_v "LIKELY VULNERABLE" "$v < 7.4.22 (MSRC)" ;;
    7.5) ver_ge "$v" 7.5.13 && { set_v PATCHED "$v >= 7.5.13"; return; }; set_v "LIKELY VULNERABLE" "$v < 7.5.13 (MSRC)" ;;
    7.6) ver_ge "$v" 7.6.9  && { set_v PATCHED "$v >= 7.6.9"; return; };  set_v "LIKELY VULNERABLE" "$v < 7.6.9 (MSRC)" ;;
    *) set_v UNKNOWN "PowerShell $v is not listed by MSRC (only 7.4, 7.5, 7.6); check its bundled .NET runtime" ;;
  esac
}
assess_envoy() {
  local v mm fx; v=$(printf '%s' "$1" | grep -Eo '(^|[^0-9.])1\.[0-9]+\.[0-9]+' | head -n1 | sed -E 's/^[^0-9]//')
  [ -n "$v" ] || { set_v UNKNOWN "could not determine the Envoy version"; return; }
  case "$v" in 1.39.2|1.38.5|1.37.7|1.36.11) set_v "LIKELY VULNERABLE" "Envoy $v is listed as vulnerable in GHSA-3wvj-j6qm-62mr"; return ;; esac
  mm=$(majmin "$v")
  case "$mm" in 1.36) fx=1.36.12 ;; 1.37) fx=1.37.8 ;; 1.38) fx=1.38.6 ;; 1.39) fx=1.39.3 ;; *) fx="" ;; esac
  if [ -n "$fx" ]; then
    if ver_ge "$v" "$fx"; then set_v PATCHED "$v >= $fx"; else set_v UNKNOWN "Envoy $v is below $fx but not listed in the Envoy advisory; upgrade and confirm whether the Brotli decompressor filter is configured"; fi
    return
  fi
  if ver_ge "$v" 1.40.0; then set_v PATCHED "$v >= 1.40.0"; else set_v UNKNOWN "Envoy $v is older than the lines the Envoy advisory covers; upgrade"; fi
}
assess_curl() { # $1 = `curl -V` first line or a brotli version; $2 = "pkg" if curl is package-managed
  local line=$1 b
  b=$(printf '%s' "$line" | grep -Eo 'brotli/[0-9]+\.[0-9]+\.[0-9]+' | head -n1 | cut -d/ -f2)
  if [ -z "$b" ]; then
    case "$line" in
      *libcurl*) set_v PATCHED "NOT AFFECTED: this curl build reports no brotli support"; return ;;
      *) b=$(extract_ver "$line") ;;
    esac
  fi
  [ -n "$b" ] || { set_v UNKNOWN "could not read curl's brotli version"; return; }
  assess_brotli_upstream "$b"
  WHY="curl links brotli $b: $WHY"
  if [ "$VERDICT" = "LIKELY VULNERABLE" ] && [ "${2:-}" = pkg ]; then
    set_v UNKNOWN "curl reports brotli $b, but distro backports keep the upstream number - see the libbrotli package verdict"
  fi
}

count() { case "$VERDICT" in PATCHED) PATCHED=$((PATCHED+1)) ;; "LIKELY VULNERABLE") VULN=$((VULN+1)) ;; *) UNK=$((UNK+1)) ;; esac; }
report() { say "VERDICT [$1]: $VERDICT - $WHY"; count; }

assess_component() { # name value
  case "$1" in
    node) assess_node "$2" ;; bun) assess_bun "$2" ;; brotli|libbrotli) assess_brotli "$2" ;;
    python-brotli|brotli-py|pybrotli) assess_pybrotli "$2" ;; brotlicffi) assess_brotlicffi "$2" ;;
    dotnet) assess_dotnet "$2" ;; pwsh|powershell) assess_pwsh "$2" ;; envoy) assess_envoy "$2" ;; curl) assess_curl "$2" ;;
    *) return 1 ;;
  esac
}

# ---------------- argument parsing ----------------
MODE=local; IN_CSV=""
ASSESS=(); BINS=(); SEARCH_DIRS=()
while [ $# -gt 0 ]; do
  case "$1" in
    -h|--help) usage; exit 0 ;;
    --assess) [ $# -ge 2 ] || die "--assess needs COMPONENT=VERSION"; case "$2" in *=*) ;; *) die "--assess needs COMPONENT=VERSION" ;; esac
              MODE=assess; ASSESS+=("$2"); shift 2 ;;
    --csv) [ $# -ge 2 ] || die "--csv needs a file"; MODE=csv; IN_CSV=$2; shift 2 ;;
    --bin) [ $# -ge 2 ] || die "--bin needs a path"; BINS+=("$2"); shift 2 ;;
    --search-dir) [ $# -ge 2 ] || die "--search-dir needs a directory"; [ -d "$2" ] || die "no such directory: $2"; SEARCH_DIRS+=("$2"); shift 2 ;;
    --no-system) NO_SYSTEM=1; shift ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
done

summary_exit() {
  hdr "Summary"
  say "PATCHED=$PATCHED LIKELY_VULNERABLE=$VULN UNKNOWN=$UNK"
  if   [ "$VULN" -gt 0 ]; then ov="LIKELY VULNERABLE"; ec=1
  elif [ "$UNK" -gt 0 ];  then ov="UNKNOWN"; ec=2
  elif [ "$PATCHED" -gt 0 ]; then ov="PATCHED"; ec=0
  else ov="UNKNOWN"; ec=2; say "Nothing Brotli-related was found (Brotli may still be bundled inside an application, image or appliance)."; fi
  say "OVERALL: $ov"
  status_block
  exit "$ec"
}

# ---------------- offline modes ----------------
if [ "$MODE" = assess ]; then
  hdr "Offline assessment"
  for a in "${ASSESS[@]}"; do
    c=${a%%=*}; val=${a#*=}
    assess_component "$c" "$val" || die "unknown component '$c' (node bun brotli python-brotli brotlicffi dotnet pwsh envoy curl)"
    say "Input: $c=$val"; report "$c"
  done
  summary_exit
fi
if [ "$MODE" = csv ]; then
  if ! { [ -r "$IN_CSV" ] && [ -f "$IN_CSV" ]; }; then die "cannot read $IN_CSV"; fi
  hdr "CSV inventory: $IN_CSV"
  rows=0; n=0
  while IFS= read -r raw || [ -n "$raw" ]; do
    n=$((n+1)); raw=${raw%$'\r'}
    [ -z "${raw//[[:space:],]/}" ] && continue
    case "$raw" in '#'*) continue ;; esac
    IFS=, read -r host comp ver _ <<<"$raw"
    host=$(printf '%s' "$host" | tr -d '"' | sed 's/^ *//;s/ *$//')
    comp=$(printf '%s' "${comp:-}" | tr -d '"' | sed 's/^ *//;s/ *$//' | tr '[:upper:]' '[:lower:]')
    ver=$(printf '%s' "${ver:-}" | tr -d '"' | sed 's/^ *//;s/ *$//')
    if [ "$n" -eq 1 ] && [ "$comp" = component ]; then continue; fi
    rows=$((rows+1))
    if assess_component "$comp" "$ver"; then count; else set_v UNKNOWN "unknown component '$comp'"; count; fi
    say "$host,$comp,${ver:-<blank>},$VERDICT,$WHY"
  done < "$IN_CSV"
  [ "$rows" -gt 0 ] || die "no data rows in $IN_CSV"
  summary_exit
fi

# ---------------- execution guard ----------------
declare -A SEEN=()
FOUND=(); SKIPPED=0
runtime_kind() {
  case "$1" in
    node|nodejs) echo node ;; bun) echo bun ;; dotnet) echo dotnet ;; curl) echo curl ;; envoy) echo envoy ;;
    pwsh) echo pwsh ;; python3|python3.[0-9]|python3.[0-9][0-9]) echo python ;; *) echo "" ;;
  esac
}
magic_hex() {
  local f=$1 m
  if command -v od >/dev/null 2>&1; then od -An -tx1 -N4 -- "$f" 2>/dev/null | tr -d ' \n'
  else
    IFS= LC_ALL=C read -r -d '' -n 4 m < "$f" 2>/dev/null
    printf '%s' "$m" | LC_ALL=C awk 'BEGIN{for(i=0;i<256;i++)o[sprintf("%c",i)]=i}{for(i=1;i<=length($0);i++)printf "%02x",o[substr($0,i,1)]}'
  fi
}
is_mock_fixture() { # TEST-ONLY path, see header
  [ "${BROTLI_TEST_ALLOW_SCRIPTS:-0}" = 1 ] || return 1
  [ "$(head -n1 -- "$1" 2>/dev/null)" = '#!/bin/sh' ] && sed -n 2p -- "$1" 2>/dev/null | grep -q '^# MOCK runtime binary for tests only'
}
skip_note() { SKIPPED=$((SKIPPED+1)); say "SKIPPED (not executed): $1 - $2"; }
add_bin() {
  local b=$1 r name mh
  { [ -e "$b" ] || [ -L "$b" ]; } || return 0
  name=$(basename -- "$b")
  [ -n "$(runtime_kind "$name")" ] || { skip_note "$b" "name is not a known runtime name"; return 0; }
  r=$(readlink -f -- "$b" 2>/dev/null || printf '%s' "$b")
  [ -n "${SEEN[$r]:-}" ] && return 0
  if [ ! -f "$r" ]; then skip_note "$b" "does not resolve to a regular file"; return 0; fi
  if [ ! -x "$r" ]; then skip_note "$b" "not executable"; return 0; fi
  mh=$(magic_hex "$r")
  case "$mh" in
    7f454c46|feedface|feedfacf|cefaedfe|cffaedfe|cafebabe) : ;;
    *) if is_mock_fixture "$r"; then say "TEST-ONLY: accepting mock fixture $r (BROTLI_TEST_ALLOW_SCRIPTS=1)"
       else skip_note "$b" "not an ELF/Mach-O binary (magic ${mh:-unreadable})"; return 0; fi ;;
  esac
  SEEN[$r]=1; FOUND+=("$name|$r")
}
pkg_of() {
  local f=$1
  if command -v dpkg-query >/dev/null 2>&1; then dpkg -S "$f" 2>/dev/null | head -n1 | cut -d: -f1; return; fi
  if command -v rpm >/dev/null 2>&1; then rpm -qf --qf '%{NAME}\n' "$f" 2>/dev/null | grep -v 'not owned' | head -n1; return; fi
  if command -v apk >/dev/null 2>&1; then apk info -W "$f" 2>/dev/null | awk '/owned by/{print $NF}'; fi
}

hdr "Runtime binaries"
if [ ${#BINS[@]} -gt 0 ]; then
  for b in "${BINS[@]}"; do [ -e "$b" ] || die "no such binary: $b"; add_bin "$b"; done
elif [ "$NO_SYSTEM" -eq 0 ]; then
  for n in node nodejs bun dotnet curl envoy pwsh python3; do p=$(command -v "$n" 2>/dev/null) && add_bin "$p"; done
  for g in /usr/local/bin/node /opt/*/bin/node /usr/share/dotnet/dotnet /usr/lib/dotnet/dotnet /opt/dotnet/dotnet \
           "$HOME"/.dotnet/dotnet "$HOME"/.bun/bin/bun /usr/local/bin/envoy /opt/microsoft/powershell/7/pwsh \
           "$HOME"/.nvm/versions/node/*/bin/node /usr/bin/python3.[0-9]* /usr/local/bin/python3.[0-9]*; do
    case "$g" in *'*'*) continue ;; esac
    [ -d "$g" ] && continue
    add_bin "$g"
  done
  for pid in $(pgrep -f 'node|bun|dotnet|envoy|python' 2>/dev/null); do
    e=$(readlink -f "/proc/$pid/exe" 2>/dev/null) || continue
    [ -n "$(runtime_kind "$(basename "$e")")" ] && add_bin "$e"
  done
fi
for d in "${SEARCH_DIRS[@]}"; do
  while IFS= read -r g; do add_bin "$g"; done < <(find "$d" -xdev \( -name node -o -name nodejs -o -name bun -o -name dotnet -o -name envoy -o -name pwsh \) \( -type f -o -type l \) 2>/dev/null | head -n 200)
done
[ "$SKIPPED" -gt 0 ] && say "$SKIPPED candidate(s) skipped by the execution guard (never executed)."
[ ${#FOUND[@]} -eq 0 ] && say "No runtime binaries found."

for entry in "${FOUND[@]}"; do
  name=${entry%%|*}; b=${entry#*|}; kind=$(runtime_kind "$name")
  hdr "$b ($kind)"
  pkg=$(pkg_of "$b")
  case "$kind" in
    node)
      out=$(timeout 10 "$b" -v 2>/dev/null | head -n1); say "node -v: ${out:-<could not run>}"
      [ -n "$pkg" ] && say "Package: $pkg (distribution build)"
      nm=$(major "$(extract_ver "$out")"); if [ -n "$nm" ] && [ "$nm" -le 20 ] 2>/dev/null; then say "EOL: YES - upstream Node.js $nm is end-of-life (Node 20 and older get no fix)"; fi
      assess_node "$out" "$pkg"; report node ;;
    bun)
      out=$(timeout 10 "$b" --version 2>/dev/null | head -n1); say "bun --version: ${out:-<could not run>}"
      assess_bun "$out"; report bun ;;
    dotnet)
      out=$(timeout 15 "$b" --list-runtimes 2>/dev/null)
      if [ -z "$out" ]; then say "dotnet --list-runtimes: <no output>"; set_v UNKNOWN "no runtimes listed"; report dotnet; continue; fi
      printf '%s\n' "$out" | sed 's/^/  /'
      seen_core=0
      while IFS= read -r l; do
        case "$l" in Microsoft.NETCore.App\ *) seen_core=1; rv=$(printf '%s' "$l" | awk '{print $2}'); assess_dotnet "$rv"; report "Microsoft.NETCore.App $rv" ;; esac
      done <<<"$out"
      [ "$seen_core" -eq 1 ] || { set_v UNKNOWN "no Microsoft.NETCore.App runtime listed"; report dotnet; }
      say "Note: ASP.NET Core request decompression and HttpClient auto-decompression are off by default (Microsoft);"
      say "  self-contained and NativeAOT apps carry their own copy - use --search-dir on app folders." ;;
    curl)
      out=$(timeout 10 "$b" -V 2>/dev/null | head -n1); feat=$(timeout 10 "$b" -V 2>/dev/null | grep -i '^Features:')
      say "curl -V: ${out:-<could not run>}"; [ -n "$feat" ] && say "$feat"
      if [ -n "$out" ] && ! printf '%s' "$feat" | grep -qi brotli && ! printf '%s' "$out" | grep -q 'brotli/'; then
        set_v PATCHED "NOT AFFECTED: this curl build reports no brotli support"
      else assess_curl "$out" "${pkg:+pkg}"; fi
      say "Note: curl only decodes br with --compressed (libcurl: CURLOPT_ACCEPT_ENCODING / CURLOPT_ENCODING)."
      report curl ;;
    envoy)
      out=$(timeout 10 "$b" --version 2>/dev/null | grep -m1 -i version); say "envoy --version: ${out:-<could not run>}"
      assess_envoy "$out"; say "Note: Envoy is only exposed when a Brotli decompressor filter is configured (Envoy advisory)."
      report envoy ;;
    pwsh)
      out=$(timeout 15 "$b" --version 2>/dev/null | head -n1); say "pwsh --version: ${out:-<could not run>}"
      assess_pwsh "$out"; report pwsh ;;
    python)
      out=$(timeout 10 "$b" --version 2>&1 | head -n1); say "Interpreter: ${out:-<could not run>} (packages are read from site-packages metadata below)" ;;
  esac
done

# ---------------- system libbrotli ----------------
if [ "$NO_SYSTEM" -eq 0 ] && [ ${#BINS[@]} -eq 0 ]; then
  hdr "System libbrotli"
  pkglines=""
  if command -v dpkg-query >/dev/null 2>&1; then
    pkglines=$(dpkg-query -W -f='${db:Status-Abbrev}|${Package}|${Version}\n' 'libbrotli*' 2>/dev/null | awk -F'|' '$1 ~ /^ii/ {print $2"|"$3}')
  elif command -v rpm >/dev/null 2>&1; then
    pkglines=$(rpm -qa --qf '%{NAME}|%{VERSION}-%{RELEASE}\n' 'brotli*' 'libbrotli*' 2>/dev/null)
  elif command -v apk >/dev/null 2>&1; then
    pkglines=$(apk info -v 2>/dev/null | grep -E '^(brotli|brotli-libs)-[0-9]' | sed -E 's/^(brotli(-libs)?)-([0-9].*)$/\1|\3/')
  elif command -v brew >/dev/null 2>&1; then
    pkglines=$(brew list --versions brotli 2>/dev/null | awk '{print $1"|"$2}')
  fi
  if [ -n "$pkglines" ]; then
    while IFS='|' read -r p v; do
      [ -n "$p" ] || continue
      cl=""
      if command -v dpkg-query >/dev/null 2>&1; then
        src=$(dpkg-query -W -f='${source:Package}' "$p" 2>/dev/null)
        for f in "/usr/share/doc/$p/changelog.Debian.gz" "/usr/share/doc/${src:-$p}/changelog.Debian.gz"; do
          [ -r "$f" ] && zcat "$f" 2>/dev/null | grep -q "$CVE" && cl=yes
        done
      elif command -v rpm >/dev/null 2>&1; then
        rpm -q --changelog "$p" 2>/dev/null | grep -q "$CVE" && cl=yes
      fi
      say "Package: $p $v (changelog references $CVE: ${cl:-no})"
      assess_brotli "$v" "$cl"; report "package $p"
    done <<<"$pkglines"
  else
    say "No libbrotli package found in the package database."
  fi
  if command -v pkg-config >/dev/null 2>&1; then
    pv=$(pkg-config --modversion libbrotlidec 2>/dev/null) && say "pkg-config libbrotlidec: $pv (headers/dev files; informational)"
  fi
  if command -v ldconfig >/dev/null 2>&1; then
    ldconfig -p 2>/dev/null | grep -E 'libbrotlidec' | awk '{print $NF}' | while IFS= read -r so; do
      real=$(readlink -f -- "$so" 2>/dev/null); say "ldconfig: $so -> ${real:-?}"
    done
  fi
fi

# ---------------- Python packages (metadata only) ----------------
hdr "Python Brotli / brotlicffi (dist-info metadata, nothing imported or executed)"
SITE_DIRS=()
if [ "$NO_SYSTEM" -eq 0 ] && [ ${#BINS[@]} -eq 0 ]; then
  for g in /usr/lib/python3/dist-packages /usr/lib/python3*/site-packages /usr/lib/python3*/dist-packages /usr/lib64/python3*/site-packages \
           /usr/local/lib/python3*/site-packages /usr/local/lib/python3*/dist-packages /opt/*/lib/python3*/site-packages \
           /home/*/.local/lib/python3*/site-packages /root/.local/lib/python3*/site-packages /opt/homebrew/lib/python3*/site-packages \
           /Library/Frameworks/Python.framework/Versions/*/lib/python3*/site-packages; do
    [ -d "$g" ] && SITE_DIRS+=("$g")
  done
fi
PYHITS=0
check_distinfo() { # $1 = dist-info / egg-info directory
  local di=$1 meta name ver kind distro=""
  meta="$di/METADATA"; [ -f "$meta" ] || meta="$di/PKG-INFO"; [ -r "$meta" ] || return 0
  name=$(grep -m1 -i '^Name:' "$meta" | cut -d: -f2- | tr -d ' \r'); ver=$(grep -m1 -i '^Version:' "$meta" | cut -d: -f2- | tr -d ' \r')
  case "$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" in brotli) kind=python-brotli ;; brotlicffi) kind=brotlicffi ;; *) return 0 ;; esac
  PYHITS=$((PYHITS+1))
  [ -n "$(pkg_of "$meta")" ] && distro=distro
  say "$name $ver at $di${distro:+ (distro package $(pkg_of "$meta"))}"
  if [ "$kind" = python-brotli ]; then assess_pybrotli "$ver" "$distro"; else assess_brotlicffi "$ver" "$distro"; fi
  report "$kind"
}
for sd in "${SITE_DIRS[@]}"; do
  for di in "$sd"/[Bb]rotli-*.dist-info "$sd"/[Bb]rotli-*.egg-info "$sd"/brotlicffi-*.dist-info "$sd"/brotlicffi-*.egg-info; do
    [ -d "$di" ] && check_distinfo "$di"
  done
done
for d in "${SEARCH_DIRS[@]}"; do
  while IFS= read -r di; do check_distinfo "$di"; done < <(find "$d" -xdev -type d \( -iname 'brotli-*.dist-info' -o -iname 'brotli-*.egg-info' -o -iname 'brotlicffi-*.dist-info' -o -iname 'brotlicffi-*.egg-info' \) 2>/dev/null | sort | head -n 500)
done
[ "$PYHITS" -eq 0 ] && say "No Brotli or brotlicffi Python packages found in the searched site-packages."

# ---------------- bundled copies (by name, never executed) ----------------
if [ ${#SEARCH_DIRS[@]} -gt 0 ]; then
  hdr "Bundled copies under --search-dir (file names and metadata only)"
  BHITS=0
  for d in "${SEARCH_DIRS[@]}"; do
    while IFS= read -r f; do
      BHITS=$((BHITS+1)); bn=$(basename -- "$f")
      case "$bn" in
        libbrotli*|brotlidec*|brotlicommon*)
          v=$(printf '%s' "$bn" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
          if [ -n "$v" ]; then assess_brotli_upstream "$v"; WHY="version taken from file name: $WHY"
          else set_v UNKNOWN "bundled Brotli library with no version in its name; check the product that ships it"; fi
          say "$f"; report "bundled $bn" ;;
        *System.IO.Compression.Native*)
          dj=$(find "$(dirname -- "$f")" -maxdepth 1 -name '*.deps.json' 2>/dev/null | head -n1)
          rv=""; [ -n "$dj" ] && rv=$(grep -Eo 'Microsoft\.NETCore\.App\.Runtime\.[A-Za-z0-9.-]+/[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?' "$dj" 2>/dev/null | head -n1 | cut -d/ -f2)
          if [ -n "$rv" ]; then assess_dotnet "$rv"; WHY="self-contained .NET app (runtime pack $rv in $(basename "$dj")): $WHY"
          else set_v UNKNOWN "app-local .NET Brotli native library; runtime version not found in a .deps.json - rebuild with a patched SDK"; fi
          say "$f"; report "bundled .NET" ;;
        *.node)
          set_v UNKNOWN "native Node.js addon with Brotli in its name; check the package version against its maintainer's advisory"
          say "$f"; report "addon $bn" ;;
        go.sum)
          gv=$(grep -Eo 'github.com/andybalholm/brotli v[0-9]+\.[0-9]+\.[0-9]+' "$f" | awk '{print $2}' | sed 's/^v//' | sort -V | tail -n1)
          if [ -z "$gv" ]; then BHITS=$((BHITS-1)); continue; fi
          if ver_ge "$gv" 1.2.7; then set_v PATCHED "Go port github.com/andybalholm/brotli v$gv >= v1.2.7 (tag referenced by Google's CVE record)"
          else set_v UNKNOWN "Go port github.com/andybalholm/brotli v$gv < v1.2.7 (Google's CVE record references v1.2.7; no release notes published) - upgrade"; fi
          say "$f"; report "go module" ;;
      esac
    done < <(find "$d" -xdev -type f \( -name 'libbrotlidec*' -o -name 'libbrotlicommon*' -o -iname 'brotlidec*.dll' -o -iname 'brotlicommon*.dll' \
              -o -name '*System.IO.Compression.Native*' -o -iname '*brotli*.node' -o -name 'iltorb.node' -o -name go.sum \) 2>/dev/null | sort | head -n 500)
  done
  [ "$BHITS" -eq 0 ] && say "No bundled Brotli libraries, app-local .NET Brotli libraries, Brotli addons or Go Brotli modules found."
  say "Python C extensions (_brotli*.so) are versioned by their dist-info above; node binaries found here were assessed above."
fi

summary_exit
