#!/usr/bin/env bash
# check-openssh-musl-bypass.sh: strictly read-only, offline exposure check for the OpenSSH
# password authentication bypass on musl libc without PAM (CVE-2026-123158, fixed in OpenSSH 10.6p2).
# Reads files only. No network, no writes, no exploit payloads, never connects to sshd.
#
# Usage:
#   check-openssh-musl-bypass.sh --root HOST_ROOT [--sshd-t SSHD_T_DUMP] [--authlog LOG]
#   check-openssh-musl-bypass.sh --csv INVENTORY.csv
#   check-openssh-musl-bypass.sh --authlog LOG [--star-users u1,u2]
# HOST_ROOT is a mounted image or copy of a host filesystem (/ works but reads /etc/shadow, run as root).
# CSV header: host,libc,apk_openssh,openssh_version,pam,password_authentication,star_accounts,hash_accounts
#   (libc musl|glibc|unknown; pam yes|no|unknown means PAM active; password_authentication yes|no|unknown;
#    *_accounts are counts or unknown; apk_openssh like 10.3_p1-r2; openssh_version like 10.6p1)
# Exit: 0 PATCHED, 1 LIKELY VULNERABLE, 2 UNKNOWN, 3 NOT EXPOSED, 4 SUSPICIOUS LOGIN in authlog, 5 usage/read error.
# CSV mode exits with the worst row (1 > 2 > 3 > 0). Exit 4 overrides when the authlog has a hit.
set -u
export LC_ALL=C

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

# Alpine fixed openssh packages (alpinelinux.org advisory 2026-11-05): pkgver -> minimum pkgrel.
FIXED_APK="10.6_p2:0 10.3_p1:2 10.2_p1:1 10.0_p1:11 9.9_p2:1 9.7_p1:6 9.6_p1:3 9.3_p2:4 9.1_p1:7 9.0_p1:6"

ver_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }
# upstream "10.6p2" / apk pkgver "10.6_p2" -> "10.6.2"
norm_up() { printf '%s' "$1" | sed -E 's/^OpenSSH_//; s/_?p([0-9]+).*/.\1/'; }

# apk_state V (e.g. 10.3_p1-r2) -> PATCHED|VULN|BAD
apk_state() {
  local v=$1 pv rel e fpv frel
  printf '%s' "$v" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?(_p[0-9]+)?-r[0-9]+$' || { echo BAD; return; }
  pv=${v%-r*}; rel=${v##*-r}
  if ver_ge "$(norm_up "$pv")" 10.6.2; then echo PATCHED; return; fi
  for e in $FIXED_APK; do
    fpv=${e%%:*}; frel=${e##*:}
    if [ "$pv" = "$fpv" ]; then if [ "$rel" -ge "$frel" ]; then echo PATCHED; else echo VULN; fi; return; fi
  done
  echo VULN
}
up_state() { # upstream version
  local v; v=$(norm_up "$1")
  printf '%s' "$v" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || { echo BAD; return; }
  if ver_ge "$v" 10.6.2; then echo PATCHED; else echo VULN; fi
}

# evaluate host libc apk up pam pwauth star hash -> prints verdict lines, returns code
evaluate() {
  local host=$1 libc=$2 apk=$3 up=$4 pam=$5 pw=$6 star=$7 hash=$8 vs="" vsrc="" why="" unk=""
  if [ -n "$apk" ] && [ "$apk" != unknown ]; then vs=$(apk_state "$apk"); vsrc="Alpine openssh $apk"
  elif [ -n "$up" ] && [ "$up" != unknown ]; then vs=$(up_state "$up"); vsrc="OpenSSH $up (upstream version; distro backports not recognised)"; fi
  if [ "$vs" = PATCHED ]; then echo "PATCHED: $host: $vsrc includes the fix. Restart sshd if it was running before the upgrade."; return 0; fi
  [ "$libc" = glibc ] && why="$why glibc (musl only);"
  [ "$pam" = yes ] && why="$why PAM active (UsePAM yes with a PAM-built sshd);"
  [ "$pw" = no ] && why="$why PasswordAuthentication no;"
  [ "$star" = 0 ] && why="$why no account with password field '*';"
  [ "$hash" = 0 ] && why="$why no \$5\$/\$6\$ password hash;"
  if [ -n "$why" ]; then echo "NOT EXPOSED: $host:$why version ${vsrc:-unknown}. Still upgrade."; return 3; fi
  case $libc in musl) ;; *) unk="$unk libc;";; esac
  case $pam in no) ;; *) unk="$unk PAM;";; esac
  case $pw in yes) ;; *) unk="$unk PasswordAuthentication;";; esac
  case $star in ''|unknown|*[!0-9]*) unk="$unk '*' accounts;";; esac
  case $hash in ''|unknown|*[!0-9]*) unk="$unk \$5\$/\$6\$ hashes;";; esac
  [ -z "$vs" ] && unk="$unk OpenSSH version;"
  [ "$vs" = BAD ] && unk="$unk unparseable version '${apk:-$up}';"
  if [ -n "$unk" ]; then echo "UNKNOWN: $host: could not determine:$unk no condition rules exposure out."; return 2; fi
  echo "LIKELY VULNERABLE: $host: $vsrc is unfixed and all four conditions hold: [1] musl libc with PAM off, [2] PasswordAuthentication yes, [3] $star account(s) with '*' password field, [4] $hash account(s) with \$5\$/\$6\$ hash. Upgrade and restart sshd, or set PasswordAuthentication no and restart sshd."
  return 1
}

# ---- host root mode helpers ----
R=""; SSHDT=""; AUTHLOG=""; CSV=""; STARU=""
STAR_LIST=""
# cfg_value KEY -> first global value (Include expanded inline in order), empty if unset; prints MATCHYES if a Match block sets yes
cfg_value() {
  local key=$1
  [ -r "$R/etc/ssh/sshd_config" ] || return 1
  # Emit global-scope directives in file order with includes inlined at the Include line.
  awk -v key="$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]')" -v root="$R" '
    function scan(file,   line, a, n, i, cmd, g, inmatch) {
      inmatch=0
      while ((getline line < file) > 0) {
        sub(/#.*/, "", line); n=split(line, a, /[ \t=]+/); i=1; if (a[1]=="") i=2
        if (i>n) continue
        k=tolower(a[i])
        if (k=="match") { inmatch=1; continue }
        if (k=="include" && !inmatch) {
          for (j=i+1;j<=n;j++) { g=a[j]; if (g !~ /^\//) g="/etc/ssh/" g; cmd="ls -1 " root g " 2>/dev/null"
            while ((cmd | getline incf) > 0) scan(incf); close(cmd) }
          continue
        }
        if (k==key) { v=tolower(a[i+1]); if (inmatch) { if (v=="yes") matchyes=1 } else if (!(found)) { found=1; val=v } }
      }
      close(file)
    }
    BEGIN { scan(root "/etc/ssh/sshd_config"); print (found?val:""); if (matchyes) print "MATCHYES" }'
}

host_mode() {
  local host libc=unknown apk="" up="" pam=unknown pw=unknown star=unknown hash=unknown pamlinked=no usepam="" v m
  [ -d "$R" ] || die "not a directory: $R"
  host=$(head -n1 "$R/etc/hostname" 2>/dev/null); host=${host:-$R}
  # libc
  if compgen -G "$R/lib/ld-musl-*.so.1" >/dev/null; then libc=musl
  elif compgen -G "$R/lib*/ld-linux*.so.*" >/dev/null || compgen -G "$R/lib*/*/libc.so.6" >/dev/null || compgen -G "$R/lib*/libc.so.6" >/dev/null || compgen -G "$R/usr/lib*/libc.so.6" >/dev/null; then libc=glibc
  elif grep -qs '^ID=alpine' "$R/etc/os-release"; then libc=musl; fi
  echo "INFO: libc: $libc"
  [ -r "$R/etc/alpine-release" ] && echo "INFO: Alpine release $(head -n1 "$R/etc/alpine-release")"
  # version
  if [ -r "$R/lib/apk/db/installed" ]; then
    apk=$(awk -F: '/^P:/{p=$2} /^V:/{if(p=="openssh-server"||p=="openssh-server-pam"||p=="openssh-server-common"||p=="openssh"){print $2; exit}}' "$R/lib/apk/db/installed")
    grep -qx 'P:openssh-server-pam' "$R/lib/apk/db/installed" && pamlinked=yes
    [ -n "$apk" ] && echo "INFO: apk openssh package $apk"
  fi
  if [ -z "$apk" ] && [ -r "$R/var/lib/opkg/status" ]; then
    up=$(awk '/^Package: openssh-server$/{f=1} f&&/^Version:/{print $2; exit}' "$R/var/lib/opkg/status" | sed -E 's/-[0-9]+$//')
    [ -n "$up" ] && echo "INFO: opkg openssh-server $up"
  fi
  for b in usr/sbin/sshd usr/sbin/sshd.pam usr/lib/ssh/sshd-auth usr/lib/ssh/sshd-session usr/libexec/sshd-session usr/libexec/sshd-auth; do
    [ -r "$R/$b" ] || continue
    grep -aqs 'libpam\.so' "$R/$b" && pamlinked=yes
    if [ -z "$apk" ] && [ -z "$up" ]; then v=$(grep -aoE 'OpenSSH_[0-9]+\.[0-9]+p[0-9]+' "$R/$b" | head -n1); [ -n "$v" ] && up=${v#OpenSSH_} && echo "INFO: version string in $b: $v"; fi
  done
  echo "INFO: PAM-capable sshd present: $pamlinked"
  # config
  if [ -n "$SSHDT" ]; then
    [ -r "$SSHDT" ] || die "cannot read $SSHDT"
    pw=$(awk 'tolower($1)=="passwordauthentication"{print tolower($2); exit}' "$SSHDT")
    usepam=$(awk 'tolower($1)=="usepam"{print tolower($2); exit}' "$SSHDT")
    [ -n "$pw" ] || pw=unknown
    echo "INFO: from sshd -T dump: passwordauthentication=$pw usepam=${usepam:-absent}"
    [ -z "$usepam" ] && [ "$pamlinked" = no ] && usepam=no
  elif m=$(cfg_value passwordauthentication); then
    v=$(printf '%s\n' "$m" | head -n1); pw=${v:-yes}
    if printf '%s' "$m" | grep -q MATCHYES; then echo "WARN: a Match block sets PasswordAuthentication yes"; pw=yes; fi
    usepam=$(cfg_value usepam | head -n1); usepam=${usepam:-no}
    echo "INFO: sshd_config: PasswordAuthentication=$pw (default yes) UsePAM=$usepam (default no)"
  else echo "INFO: no readable etc/ssh/sshd_config"; fi
  if [ "$pamlinked" = yes ] && [ "$usepam" = yes ]; then pam=yes
  elif [ "$usepam" = no ] || { [ "$pamlinked" = no ] && [ -n "$usepam" ]; }; then pam=no
  elif [ "$pamlinked" = no ] && [ -n "$apk" ]; then pam=no; fi
  # accounts (fixtures or mounted images only; values are never printed)
  if [ -r "$R/etc/shadow" ]; then
    STAR_LIST=$(awk -F: '$2=="*"{printf "%s%s", s, $1; s=","}' "$R/etc/shadow")
    star=$(awk -F: '$2=="*"' "$R/etc/shadow" | wc -l | tr -d ' ')
    hash=$(awk -F: '$2 ~ /^\$[56]\$/' "$R/etc/shadow" | wc -l | tr -d ' ')
    if [ -r "$R/etc/passwd" ]; then # passwd entries with no shadow entry and '*' field
      local extra; extra=$(awk -F: 'NR==FNR{s[$1]=1;next} !($1 in s) && $2=="*"{print $1}' "$R/etc/shadow" "$R/etc/passwd")
      for u in $extra; do STAR_LIST="${STAR_LIST:+$STAR_LIST,}$u"; star=$((star+1)); done
    fi
    echo "INFO: accounts with '*' password field: ${STAR_LIST:-none}; accounts with \$5\$/\$6\$ hash: $hash"
  else echo "INFO: etc/shadow not readable"; fi
  if [ -n "$apk" ] && grep -qx 'P:musl-nscd' "$R/lib/apk/db/installed" 2>/dev/null; then
    echo "WARN: musl-nscd installed: directory users have no shadow entry and count as '*' accounts"; [ "$star" = 0 ] && star=unknown
  fi
  if printf ',%s,' "$STAR_LIST" | grep -q ',alpine,' && grep -rqsE '^[[:space:]]*permit[[:space:]]+nopass[[:space:]]+alpine' "$R/etc/doas.conf" "$R/etc/doas.d"; then
    echo "WARN: tiny-cloud style 'alpine' user with '*' password and passwordless doas: login as alpine leads to root"
  fi
  if printf ',%s,' "$STAR_LIST" | grep -q ',root,'; then
    v=$(cfg_value permitrootlogin 2>/dev/null | head -n1)
    [ -n "$SSHDT" ] && v=$(awk 'tolower($1)=="permitrootlogin"{print tolower($2); exit}' "$SSHDT")
    echo "INFO: root has '*' password field; PermitRootLogin=${v:-prohibit-password (default)}; root is directly reachable only with 'yes'"
  fi
  evaluate "$host" "$libc" "$apk" "$up" "$pam" "$pw" "$star" "$hash"
}

csv_mode() {
  local worst=0 rc n=0 h libc apk up pam pw star hash
  [ -r "$CSV" ] || die "cannot read $CSV"
  head -n1 "$CSV" | tr -d '\r' | grep -qx 'host,libc,apk_openssh,openssh_version,pam,password_authentication,star_accounts,hash_accounts' || die "bad CSV header"
  while IFS=, read -r h libc apk up pam pw star hash; do
    n=$((n+1)); [ "$n" -eq 1 ] && continue
    hash=${hash%$'\r'}; [ -n "$h" ] || continue
    evaluate "$h" "${libc:-unknown}" "$apk" "$up" "${pam:-unknown}" "${pw:-unknown}" "${star:-unknown}" "${hash:-unknown}"; rc=$?
    worst=$(rank "$worst" "$rc")
  done < "$CSV"
  return "$worst"
}
rank() { local a=$1 b=$2 p; for p in 1 2 3 0; do if [ "$a" = "$p" ] || [ "$b" = "$p" ]; then echo "$p"; return; fi; done; }

authlog_scan() { # prints hits, returns 4 if any
  local hits
  [ -r "$AUTHLOG" ] || die "cannot read $AUTHLOG"
  hits=$(awk -v star=",${STAR_LIST},${STARU}," '
    match($0, /sshd(-session|-auth)?(\[[0-9]+\])?: Accepted password for (invalid user )?[^ ]+ from [^ ]+/) {
      s=substr($0, RSTART, RLENGTH); n=split(s, a, " "); u=a[n-2]
      if (u=="root" || u=="alpine" || index(star, "," u ",")) print "SUSPICIOUS LOGIN: Accepted password for " u " from " a[n] " (account expected to have no password): " $0
    }' "$AUTHLOG")
  if [ -n "$hits" ]; then printf '%s\n' "$hits"; return 4; fi
  echo "INFO: authlog: no 'Accepted password' for root, alpine or known '*' accounts"; return 0
}

while [ $# -gt 0 ]; do
  case $1 in
    --root) R=${2:-}; shift 2;; --sshd-t) SSHDT=${2:-}; shift 2;; --authlog) AUTHLOG=${2:-}; shift 2;;
    --csv) CSV=${2:-}; shift 2;; --star-users) STARU=${2:-}; shift 2;; -h|--help) usage;; *) usage;;
  esac
done
rc=""
if [ -n "$CSV" ]; then csv_mode; rc=$?
elif [ -n "$R" ]; then R=${R%/}; host_mode; rc=$?
elif [ -z "$AUTHLOG" ]; then usage; fi
if [ -n "$AUTHLOG" ]; then authlog_scan; arc=$?; [ "$arc" -eq 4 ] && rc=4; fi
exit "${rc:-0}"
