#!/usr/bin/env bash
# check-freeswitch.sh: strictly read-only, offline exposure check for FreeSWITCH CVE-2026-116779
# (GHSA-mg63-h38w-8p9p, inbound SIP MESSAGE "api" chat proto runs FreeSWITCH API commands; fixed in 1.11.3).
# Reads files only. No network, no writes, no payloads, never sends SIP.
#
# Usage:
#   check-freeswitch.sh --conf CONF_ROOT [--version V] [--host NAME] [--log FILE ...]
#   check-freeswitch.sh --version V [--host NAME] [--log FILE ...]
#   check-freeswitch.sh --csv INVENTORY.csv [--log FILE ...]
#   check-freeswitch.sh --log FILE [--log FILE ...]
# CONF_ROOT is a FreeSWITCH config dir (for example /etc/freeswitch) or a copy of one.
# V is the output of "freeswitch -version" or a version such as 1.10.12-release.
# --log takes a saved freeswitch.log, a sofia siptrace, or SIP capture text (sngrep or tshark text export).
# CSV header: host,version,chat_enabled_profiles,chat_api_proto_profiles,passwordless_users,disable_system_api_commands,mod_lua
# Exit: 0 PATCHED, 1 LIKELY VULNERABLE, 2 UNKNOWN, 3 MITIGATED, 4 LOG HIT (overrides), 5 usage/read error.
# CSV mode exits with the worst row (1 > 2 > 3 > 0).
set -u
export LC_ALL=C

FIXED=1.11.3

usage() { sed -n '6,16p' "$0" | sed 's/^# \{0,1\}//'; exit 5; }
die() { echo "ERROR: $*" >&2; exit 5; }

ver_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }

# ver_state RAW -> PATCHED | VULN | DEV | BAD
ver_state() {
  local raw=$1 base
  base=$(printf '%s' "$raw" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
  [ -n "$base" ] || { echo BAD; return; }
  # master carried "1.11.3-dev" from 2026-08-09, before the fix landed on 2026-08-26.
  if [ "$base" = "$FIXED" ] && printf '%s' "$raw" | grep -qiE '1\.11\.3-dev'; then echo DEV; return; fi
  if ver_ge "$base" "$FIXED"; then echo PATCHED; else echo VULN; fi
}
ver_base() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1; }

# Removes XML comments and puts each tag on its own line.
read -r -d '' AWK_STRIP <<'AWKEOF'
{ s = s $0 "\n" }
END {
  out = ""
  while ((i = index(s, "<!--")) > 0) {
    rest = substr(s, i + 4); j = index(rest, "-->")
    out = out substr(s, 1, i - 1)
    if (j == 0) { s = ""; break }
    s = substr(rest, j + 3)
  }
  out = out s
  gsub(/[\r\n\t]/, " ", out); gsub(/</, "\n<", out)
  print out
}
AWKEOF

# Shared helpers: attr(line, name) and cls(value) with switch_true() semantics.
read -r -d '' AWK_LIB <<'AWKEOF'
function attr(l, n,   v) {
  if (match(l, "[ \t]" n "[ \t]*=[ \t]*\"[^\"]*\"")) { v = substr(l, RSTART, RLENGTH); sub(/^[^"]*"/, "", v); sub(/"$/, "", v); return v }
  if (match(l, "[ \t]" n "[ \t]*=[ \t]*\047[^\047]*\047")) { v = substr(l, RSTART, RLENGTH); sub(/^[^\047]*\047/, "", v); sub(/\047$/, "", v); return v }
  return "\001"
}
function cls(v,   lv) {
  if (v == "\001") return "false"
  if (v ~ /\$\$?\{/) return "var"
  lv = tolower(v)
  if (lv == "yes" || lv == "on" || lv == "true" || lv == "t" || lv == "enabled" || lv == "active" || lv == "allow") return "true"
  if (lv ~ /^-?[0-9]+$/ && lv + 0 != 0) return "true"
  return "false"
}
AWKEOF

strip() { awk "$AWK_STRIP" "$1"; }

# profiles FILE -> lines "P|name|chat|apiproto|authmsg" (chat/apiproto: default|true|false|var)
profiles() {
  strip "$1" | awk "$AWK_LIB"'
    /^<profile[ \t>]/ { inp = 1; pn = attr($0, "name"); if (pn == "\001") pn = "(unnamed)"; chat = "default"; api = "default"; am = "default"; next }
    /^<\/profile>/ { if (inp) print "P|" pn "|" chat "|" api "|" am; inp = 0; next }
    inp && /^<param[ \t]/ {
      n = tolower(attr($0, "name")); v = attr($0, "value")
      if (n == "enable-chat") chat = cls(v)
      else if (n == "enable-chat-api-proto") api = cls(v)
      else if (n == "auth-messages") am = cls(v)
      else if (n == "disable-auth-messages") { c = cls(v); am = (c == "true") ? "false" : (c == "var" ? "var" : "true") }
    }'
}

# users FILE -> lines "U|id|pw|nopw|blocked". Never prints password or a1-hash values.
users() {
  strip "$1" | awk "$AWK_LIB"'
    /^<user[ \t>\/]/ {
      id = attr($0, "id"); typ = attr($0, "type")
      if ($0 ~ /\/>[ \t]*$/) { if (typ != "pointer") print "U|" id "|nopw"; next }
      inu = 1; pw = 0; aep = "true"; next
    }
    /^<\/user>/ { if (inu && typ != "pointer") print "U|" id "|" (pw ? "pw" : (aep == "false" ? "blocked" : "nopw")); inu = 0; next }
    inu && /^<param[ \t]/ {
      n = tolower(attr($0, "name")); v = attr($0, "value")
      if ((n == "password" || n == "a1-hash") && v != "" && v != "\001") pw = 1
      if (n == "allow-empty-password") aep = cls(v)
    }'
}

# globals FILE -> "G|dsac|true/false/var" and "G|defpw|vanilla" (value never printed)
globals() {
  strip "$1" | awk "$AWK_LIB"'
    /^<X-PRE-PROCESS[ \t]/ {
      if (tolower(attr($0, "cmd")) != "set") next
      d = attr($0, "data")
      if (d ~ /^disable_system_api_commands=/) { sub(/^[^=]*=/, "", d); print "G|dsac|" cls(d) }
      if (d == "default_password=1234") print "G|defpw|vanilla"
    }'
}

# modules FILE -> "M|mod_x"
modules() {
  strip "$1" | awk "$AWK_LIB"'/^<load[ \t]/ { m = attr($0, "module"); if (m != "\001") print "M|" m }'
}

# evaluate host vstate vsrc nprof chat_on_n chat_on_l chat_var_n api_n api_l pw_n pw_l dsac lua verto_api
evaluate() {
  local host=$1 vs=$2 vsrc=$3 nprof=$4 con=$5 conl=$6 cvar=$7 api=$8 apil=$9 pwn=${10} pwl=${11} dsac=${12} lua=${13} vapi=${14} r=""
  if [ "$vs" = PATCHED ]; then
    if [ "$api" != unknown ] && [ "$api" -gt 0 ]; then
      echo "LIKELY VULNERABLE: $host: $vsrc is fixed, but enable-chat-api-proto=true re-opens the api chat route on profile(s): ${apil:-$api profile(s)}. Remove it unless the peers are fully trusted."; return 1
    fi
    if [ "$vapi" = yes ]; then
      echo "LIKELY VULNERABLE: $host: $vsrc is fixed, but verto.conf.xml sets enable-chat-api-proto=true (mod_verto api chat route)."; return 1
    fi
    if [ "$nprof" = unknown ]; then
      echo "PATCHED: $host: $vsrc >= $FIXED. Config not checked: confirm no profile sets enable-chat-api-proto=true."
    else
      echo "PATCHED: $host: $vsrc >= $FIXED and no SIP profile sets enable-chat-api-proto=true."
    fi
    return 0
  fi
  if [ "$nprof" != unknown ] && [ "$nprof" -gt 0 ] && [ "$con" = 0 ] && [ "$cvar" = 0 ]; then
    echo "MITIGATED: $host: enable-chat=false on every SIP profile; $vsrc. Inbound SIP chat is off. Upgrade to $FIXED anyway."
    [ "$vapi" != no ] && echo "WARN: $host: mod_verto is loaded; 1.11.3 also gates the api chat route in mod_verto (release notes, commit f3900693). enable-chat=false does not cover verto."
    return 3
  fi
  [ "$nprof" != unknown ] && [ "$nprof" = 0 ] && { echo "UNKNOWN: $host: no SIP profile found under the config root (looked in sip_profiles/*.xml and autoload_configs/sofia.conf.xml); $vsrc."; return 2; }
  if [ "$con" != unknown ] && [ "$con" = 0 ] && [ "$cvar" != 0 ]; then
    echo "UNKNOWN: $host: enable-chat is set from a variable on $cvar profile(s) and off elsewhere; resolve the variable. $vsrc."; return 2
  fi
  if [ "$con" = unknown ]; then r=" config not checked (enable-chat is on by default);"
  else r=" inbound chat on (enable-chat true or absent) for: ${conl:-$con profile(s)};"; [ "$cvar" != 0 ] && r="$r enable-chat from a variable on $cvar profile(s);"; fi
  case $pwn in
    unknown) ;;
    0) r="$r no directory user without password or a1-hash (any one account is still enough);";;
    *) r="$r directory user(s) without password or a1-hash: ${pwl:-$pwn} (no account needed);";;
  esac
  case $dsac in
    yes) r="$r disable_system_api_commands=true (removes the system API only, route still open);";;
    no) r="$r disable_system_api_commands not set (system API available);";;
  esac
  [ "$lua" = yes ] && r="$r mod_lua loaded (inline Lua is a second sink);"
  [ "$vapi" = loaded ] && r="$r mod_verto loaded (its api chat route is also gated only from 1.11.3);"
  r="${r%;}."
  if [ "$vs" = VULN ]; then echo "LIKELY VULNERABLE: $host: $vsrc < $FIXED;$r"; return 1; fi
  echo "UNKNOWN: $host: version not confirmed ($vsrc); if below $FIXED it is exposed:$r"; return 2
}

vdesc() { # state raw
  case $1 in
    PATCHED|VULN) echo "FreeSWITCH $(ver_base "$2")";;
    DEV) echo "FreeSWITCH $(ver_base "$2")-dev build (pre-release master; may predate the 2026-08-26 fix)";;
    *) echo "version not supplied";;
  esac
}

join_csv() { paste -sd, -; }

check_conf() { # root version host
  local root=$1 ver=$2 host=$3 vs=UNKNOWN f plines ulines glines mlines nprof con conl cvar api apil pwn pwl dsac=no lua=no vapi=no b
  [ -d "$root" ] || die "not a directory: $root"
  if [ -n "$ver" ]; then vs=$(ver_state "$ver"); [ "$vs" = BAD ] && die "cannot parse version: $ver"; [ "$vs" = DEV ] && vs=UNKNOWN; fi
  plines=""
  for f in "$root"/sip_profiles/*.xml "$root"/autoload_configs/sofia.conf.xml; do
    [ -f "$f" ] || continue; [ -r "$f" ] || die "cannot read: $f"
    plines="$plines$(profiles "$f")"$'\n'
  done
  plines=$(printf '%s' "$plines" | grep '^P|' || true)
  ulines=""
  if [ -d "$root/directory" ]; then
    while IFS= read -r -d '' f; do ulines="$ulines$(users "$f")"$'\n'; done < <(find "$root/directory" -type f -name '*.xml' -print0 | sort -z)
  fi
  ulines=$(printf '%s' "$ulines" | grep '^U|' || true)
  glines=""
  while IFS= read -r -d '' f; do glines="$glines$(globals "$f")"$'\n'; done < <(find "$root" -maxdepth 3 -type f -name '*.xml' -print0 | sort -z)
  mlines=""; [ -f "$root/autoload_configs/modules.conf.xml" ] && mlines=$(modules "$root/autoload_configs/modules.conf.xml")
  nprof=$(printf '%s\n' "$plines" | grep -c '^P|')
  con=$(printf '%s\n' "$plines" | awk -F'|' '$3=="true"||$3=="default"' | grep -c '^P|')
  conl=$(printf '%s\n' "$plines" | awk -F'|' '$3=="true"||$3=="default"{print $2}' | join_csv)
  cvar=$(printf '%s\n' "$plines" | awk -F'|' '$3=="var"' | grep -c '^P|')
  api=$(printf '%s\n' "$plines" | awk -F'|' '$4=="true" && $3!="false"' | grep -c '^P|')
  apil=$(printf '%s\n' "$plines" | awk -F'|' '$4=="true" && $3!="false"{print $2}' | join_csv)
  pwn=$(printf '%s\n' "$ulines" | grep -c '^U|.*|nopw$')
  pwl=$(printf '%s\n' "$ulines" | awk -F'|' '$3=="nopw"{print $2}' | sort -u | join_csv)
  printf '%s\n' "$glines" | grep -q '^G|dsac|true$' && dsac=yes
  printf '%s\n' "$mlines" | grep -qx 'M|mod_lua' && lua=yes
  if [ -f "$root/autoload_configs/verto.conf.xml" ] && printf '%s\n' "$mlines" | grep -qx 'M|mod_verto'; then
    vapi=loaded
    profiles "$root/autoload_configs/verto.conf.xml" | awk -F'|' '$4=="true"' | grep -q . && vapi=yes
  fi
  # Details
  b=$(printf '%s\n' "$plines" | awk -F'|' 'NF>1{printf "%s%s(enable-chat=%s, enable-chat-api-proto=%s)", s, $2, $3, $4; s=" "}')
  echo "INFO: $host: SIP profiles: ${b:-none}"
  echo "INFO: $host: directory users parsed: $(printf '%s\n' "$ulines" | grep -c '^U|'), without password or a1-hash: $pwn${pwl:+ ($pwl)}"
  echo "INFO: $host: disable_system_api_commands=$dsac, mod_lua loaded=$lua"
  b=$(printf '%s\n' "$plines" | awk -F'|' '$5=="false"{print $2}' | join_csv)
  [ -n "$b" ] && echo "WARN: $host: MESSAGE authentication disabled (auth-messages false) on: $b. No account needed there."
  printf '%s\n' "$glines" | grep -q '^G|defpw|vanilla$' && echo "WARN: $host: vars.xml still sets the vanilla default_password. Change it; users that use \$\${default_password} share it."
  b=$(ver_base "${ver:-0.0.0}")
  if [ -n "$ver" ] && ! ver_ge "$b" 1.10.7; then echo "WARN: $host: versions before 1.10.7 do not authenticate MESSAGE by default (advisory)."; fi
  local vsrc; vsrc=$(vdesc "$(ver_state "${ver:-x}")" "$ver")
  evaluate "$host" "$vs" "$vsrc" "$nprof" "$con" "$conl" "$cvar" "$api" "$apil" "$pwn" "$pwl" "$dsac" "$lua" "$vapi"
}

check_csv() {
  local csv=$1 hdr worst=0 rc nprof v host ver con api pwn dsac lua vs extra
  [ -r "$csv" ] || die "cannot read: $csv"
  hdr=$(head -n1 "$csv" | tr -d '\r')
  [ "$hdr" = "host,version,chat_enabled_profiles,chat_api_proto_profiles,passwordless_users,disable_system_api_commands,mod_lua" ] || die "bad CSV header in $csv"
  while IFS=, read -r host ver con api pwn dsac lua extra || [ -n "$host" ]; do
    host=${host%$'\r'}; lua=${lua%$'\r'}
    [ -z "$host" ] && continue
    [ -n "${extra:-}" ] && { echo "UNKNOWN: $host: too many CSV fields"; rc=2; }
    for v in con api pwn; do case ${!v} in ''|unknown) printf -v "$v" unknown;; *[!0-9]*) printf -v "$v" unknown;; esac; done
    case $dsac in yes|true) dsac=yes;; no|false) dsac=no;; *) dsac=unknown;; esac
    case $lua in yes|true) lua=yes;; no|false) lua=no;; *) lua=unknown;; esac
    if [ -z "$ver" ] || [ "$ver" = unknown ]; then vs=UNKNOWN; else vs=$(ver_state "$ver"); case $vs in BAD|DEV) vs=UNKNOWN;; esac; fi
    nprof=unknown
    if [ "$con" != unknown ]; then nprof=$con; [ "$con" = 0 ] && nprof=1; fi
    evaluate "$host" "$vs" "$(vdesc "$(ver_state "${ver:-x}")" "$ver")" "$nprof" "$con" "" 0 "$api" "" "$pwn" "" "$dsac" "$lua" no
    rc=$?
    case "$worst:$rc" in
      1:*) ;; *:1) worst=1;; 2:*) ;; *:2) worst=2;; 3:*) ;; *:3) worst=3;;
    esac
  done < <(tail -n +2 "$csv")
  return "$worst"
}

scan_log() { # file -> prints hits, returns 0 if any hit
  local f=$1
  [ -r "$f" ] || die "cannot read: $f"
  awk -v F="$f" '
    { l = tolower($0); k = ""
      if (l ~ /addressed to the .api. chat proto/) k = "1.11.3 rejected api chat proto (403)"
      else if (l ~ /message[ \t]+sips?:api\+/ || l ~ /^[ \t]*(to|t)[ \t]*:.*sips?:api\+/) k = "api+ MESSAGE"
      else if (l ~ /message[ \t]+sips?:[^@ \t]*(system|bgapi|lua|load)[^@ \t]*@/) k = "keyword in MESSAGE To user"
      if (k != "") { printf "LOG HIT: %s:%d: [%s] %s\n", F, NR, k, substr($0, 1, 160); n++ }
    }
    END { exit (n > 0) ? 0 : 1 }' "$f"
}

conf="" ver="" host="" csv="" logs=()
while [ $# -gt 0 ]; do
  case $1 in
    --conf) [ $# -ge 2 ] || usage; conf=$2; shift 2;;
    --version) [ $# -ge 2 ] || usage; ver=$2; shift 2;;
    --host) [ $# -ge 2 ] || usage; host=$2; shift 2;;
    --csv) [ $# -ge 2 ] || usage; csv=$2; shift 2;;
    --log) [ $# -ge 2 ] || usage; logs+=("$2"); shift 2;;
    -h|--help) usage;;
    *) usage;;
  esac
done
[ -n "$conf$ver$csv" ] || [ ${#logs[@]} -gt 0 ] || usage
[ -n "$csv" ] && [ -n "$conf$ver" ] && die "--csv cannot be combined with --conf or --version"

rc=0
if [ -n "$csv" ]; then
  check_csv "$csv"; rc=$?
elif [ -n "$conf" ]; then
  [ -n "$host" ] || host=$(basename "$conf")
  check_conf "$conf" "$ver" "$host"; rc=$?
elif [ -n "$ver" ]; then
  vs=$(ver_state "$ver"); [ "$vs" = BAD ] && die "cannot parse version: $ver"
  [ "$vs" = DEV ] && vs=UNKNOWN
  ver_ge "$(ver_base "$ver")" 1.10.7 || echo "WARN: ${host:-host}: versions before 1.10.7 do not authenticate MESSAGE by default (advisory)."
  evaluate "${host:-host}" "$vs" "$(vdesc "$(ver_state "$ver")" "$ver")" unknown unknown "" 0 unknown "" unknown "" unknown unknown no; rc=$?
fi
[ "$rc" -eq 5 ] && exit 5

hits=""
for lf in "${logs[@]+"${logs[@]}"}"; do
  [ -r "$lf" ] || die "cannot read: $lf"
  out=$(scan_log "$lf") && { printf '%s\n' "$out"; hits="$hits$out"$'\n'; }
done
if [ ${#logs[@]} -gt 0 ]; then
  if printf '%s' "$hits" | grep -qE '\[(api\+ MESSAGE|keyword in MESSAGE To user)\]'; then
    echo "LOG HIT: api chat proto activity found. Treat as possible compromise: isolate, preserve logs, rotate SIP, trunk, ESL and directory credentials, check for child processes of freeswitch and changed config."
    exit 4
  elif [ -n "$hits" ]; then
    echo "LOG HIT: only 1.11.3 rejections (403) found, so these attempts were blocked. Block the sources and check whether they reached this host before it was upgraded."
    exit 4
  fi
  echo "LOGS: no api+ MESSAGE, keyword MESSAGE or 1.11.3 rejection lines in ${#logs[@]} file(s). Absence is not proof: sofia does not log SIP bodies unless siptrace is on."
fi
exit "$rc"
