#!/usr/bin/env bash
# check-ncentral.sh: strictly read-only, offline triage for the N-able N-central "Able2Shell" disclosure (Nov 2026).
# Pack version 1.3.0 (12 Nov 2026). N-able's fix: N-central 2026.4 HF2, build 2026.4.1.26 (HF2 release notes).
# Builds at or above 2026.4.1.26 are PATCHED for the seven CVEs N-able lists for HF2, and nothing else. Since 1.2.0
# they also carry NEW UNPATCHED ISSUES (4EverAble2Shell/Agent2Shell). Since 1.3.0: BleepingComputer (12 Nov 2026)
# reports 4EverAble2Shell exploited in the wild, including on HF2 servers; N-able has no fixed build yet.
# Reads files, directory entries and arguments only. No network, no writes, no payloads, never contacts N-central,
# never prints file contents from --root.
#
# Usage:
#   check-ncentral.sh --csv INVENTORY.csv [--log FILE ...] [--audit FILE ...] [--extra-ip IP ...]
#   check-ncentral.sh --host NAME --exposed yes|no|offline [--version BUILD] [--legacy-api enabled|disabled] [...]
#   check-ncentral.sh --log FILE ... | --audit FILE ... | --root DIR
# CSV header: host,version,internet_exposed[,legacy_api]   (full build such as 2026.4.1.26; # lines are skipped)
# legacy_api / --legacy-api: the Legacy API setting as YOU read it in the N-central UI (operator value; this checker
# cannot read it). enabled|on|yes|true|1 or disabled|off|no|false|0; empty means not recorded.
# --log takes a saved web access log in combined format (Apache or nginx style) from the server or a reverse proxy.
# --audit takes a saved text or CSV export of N-central audit or user management records.
# --extra-ip adds your own IPv4 indicators to the log scan (the news-sourced IOC is built in).
# --root DIR checks a filesystem root (/ on the server itself, or a read-only mounted image) for the news-sourced
#   artifacts: an xvd.service systemd unit and /tmp/.xvd or /var/tmp/.xvd. It only tests whether paths exist.
# Exit: 0 OFFLINE/NOT EXPOSED (build not confirmed), 1 VULNERABLE, EXPOSED / BUILD NOT CONFIRMED or LEGACY API
#       ENABLED, 2 UNKNOWN, 3 PATCHED (HF2 CVEs) with Legacy API recorded disabled, 4 LOG REVIEW (overrides 0 to 3
#       and 6), 5 usage/read error, 6 PATCHED (HF2 CVEs) with Legacy API not recorded (check it),
#       7 HOST ARTIFACT FOUND (overrides everything except 5: treat the server as fully compromised).
# CSV mode exits with the worst row (1 > 2 > 6 > 0 > 3). --log, --audit or --root alone exits 2 when nothing is found.
set -u
export LC_ALL=C

# Fixed build from N-able's "N-central 2026.4 HF2 Release Notes" (status.n-able.com, posted 10 Nov 2026 20:48 UTC):
# "Build Number: 2026.4.1.26". The same notes list the CVEs below as "released and fixed with the hotfix".
# N-able's CVE records give the affected range as "before 26.4.1" (six CVEs) or "before 2026.4.1" (CVE-2026-91000).
# We use the release notes build. Change these only from N-able's own release notes, and keep Check-NCentral.ps1 in step.
FIX_BUILD="2026.4.1.26"
FIX_CVES="CVE-2026-125878, CVE-2026-125879, CVE-2026-125880, CVE-2026-125881, CVE-2026-125882, CVE-2026-125883 and CVE-2026-91000"
# 4EverAble2Shell and Agent2Shell: claims reported 12 Nov 2026 against 2026.4 HF2 (build 2026.4.1.26). N-able's
# 12 Nov Uptime update: ensure Legacy API access is disabled, Intune integration deactivated as a precaution, no signs
# of compromise seen when written, remediation still being developed. No build clears them. BleepingComputer
# (12 Nov 2026) reports 4EverAble2Shell exploited against production servers, most of them on HF2.
NEW_ISSUES="4EverAble2Shell/Agent2Shell"
LEGACY_PATH="Administration > Mail and Network Settings > Network Security > LEGACY APIS"
# IOC (news-sourced): BleepingComputer, 12 Nov 2026: probing source, and the server the payload fetched a shell script
# from and sent command output back to. Not an N-able indicator.
IOC_IPS="157.20.241.16"
# NEWS IOC (BleepingComputer, 12 Nov 2026): Java class referenced in exploit requests to /dms/services/ServerUI.
JLINE_CLASS="jline.internal.TerminalLineSettings"
# NEWS IOC (BleepingComputer, 12 Nov 2026): artifacts to check for. The unit folders searched are OUR choice (the
# standard systemd unit folders); the source names only the unit, not where it was written.
XVD_UNIT="xvd.service"
XVD_PATHS="tmp/.xvd var/tmp/.xvd"
UNIT_DIRS="etc/systemd/system run/systemd/system lib/systemd/system usr/lib/systemd/system usr/local/lib/systemd/system etc/systemd/user usr/lib/systemd/user root/.config/systemd/user"
# Builds named in N-able's HF2 release notes (2026.4.1.26 supersedes 2026.4.0.27 and 2026.4.0.32).
build_label() {
  case $1 in
    2026.4.0.27) echo " (2026.4)";;
    2026.4.0.32) echo " (2026.4 HF1)";;
    2026.4.1.26) echo " (2026.4 HF2)";;
  esac
}

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

# build_status VERSION -> ge (at or above FIX_BUILD), lt (below), or unknown (missing, partial or not a build number).
build_status() {
  local v=$1 i a b
  local -a vp fp
  [[ $v =~ ^[0-9]{4}(\.[0-9]{1,9}){0,3}$ ]] || { echo unknown; return; }
  IFS=. read -r -a vp <<< "$v"
  IFS=. read -r -a fp <<< "$FIX_BUILD"
  for i in "${!vp[@]}"; do
    a=$((10#${vp[$i]})); b=$((10#${fp[$i]}))
    if [ "$a" -gt "$b" ]; then echo ge; return; fi
    if [ "$a" -lt "$b" ]; then echo lt; return; fi
  done
  if [ "${#vp[@]}" -eq 4 ]; then echo ge; else echo unknown; fi
}

# evaluate HOST VERSION EXPOSED -> prints verdict line, returns exit code
# legacy_state VALUE -> enabled, disabled, unset or unrecognised
legacy_state() {
  case $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') in
    enabled|on|yes|true|1) echo enabled;;
    disabled|off|no|false|0) echo disabled;;
    '') echo unset;;
    *) echo unrecognised;;
  esac
}

# evaluate HOST VERSION EXPOSED [LEGACY] -> verdict line, exit code (LEGACY ENABLED raises any verdict to 1)
evaluate() {
  local rc lg
  lg=$(legacy_state "${4:-}")
  evaluate_build "$@"; rc=$?
  if [ "$lg" = enabled ] && [ "$rc" -ne 1 ] && [ "$rc" -ne 3 ] && [ "$rc" -ne 6 ]; then rc=1; fi
  return "$rc"
}

evaluate_build() {
  local host=$1 ver exp vtext st lg sfx=""
  ver=$(printf '%s' "$2" | tr -d '[:space:]')
  exp=$(printf '%s' "$3" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
  lg=$(legacy_state "${4:-}")
  [ "$lg" = enabled ] && sfx=" Legacy API is recorded as ENABLED: N-able (12 Nov) says to disable it now ($LEGACY_PATH)."
  [ "$lg" = unrecognised ] && sfx=" legacy_api value \"$4\" not recognised (use enabled or disabled)."

  if [ -n "$ver" ]; then vtext="N-central $ver$(build_label "$ver")"; else vtext="N-central (version not recorded)"; fi
  st=$(build_status "$ver")
  if [ "$st" = ge ]; then
    local base="$host: $vtext is at or above $FIX_BUILD (2026.4 HF2), which N-able lists as fixing $FIX_CVES. Not fully patched: security researchers claim $NEW_ISSUES work against HF2 (reported 12 Nov 2026); N-able is developing remediation and no fixed build exists, and BleepingComputer (12 Nov 2026) reports 4EverAble2Shell exploited against production servers on HF2. Not proof of a clean server: N-able says a follow-up hotfix will bring further security updates for the original disclosure."
    case $lg in
      enabled)
        echo "PATCHED (HF2 CVEs); NEW UNPATCHED ISSUES ($NEW_ISSUES): LEGACY API ENABLED: $base Legacy API is recorded as ENABLED. N-able (12 Nov): \"If Legacy API is enabled: Disable Legacy API now.\" Disable it ($LEGACY_PATH), keep the server off the internet, and hunt from 11 Nov."
        return 1;;
      disabled)
        echo "PATCHED (HF2 CVEs); NEW UNPATCHED ISSUES ($NEW_ISSUES): Legacy API recorded disabled: $base N-able's first 12 Nov update said the server issue depends on Legacy API being on and internet exposed, and security researchers say the exploit needs it enabled; N-able has not said whether Agent2Shell does. Run --root on the server and hunt for the news-sourced indicators. Keep the server off the internet, keep Agents and Probes upgraded, watch N-able's Uptime page, and hunt from 8 Nov."
        return 3;;
      *)
        echo "PATCHED (HF2 CVEs); NEW UNPATCHED ISSUES ($NEW_ISSUES): check Legacy API: $base Legacy API state not recorded.$sfx N-able (12 Nov): disable Legacy API if it is enabled, and confirm it is disabled otherwise ($LEGACY_PATH). Then keep Agents and Probes upgraded and hunt from 8 Nov."
        return 6;;
    esac
  fi
  if [ "$st" = lt ]; then
    case $exp in
      yes|true|1) echo "VULNERABLE: $host: $vtext is below $FIX_BUILD (2026.4 HF2) and is recorded as internet exposed. N-able reports scanning linked to the published proof of concept and recommends keeping servers offline until upgraded. Take it offline, upgrade to $FIX_BUILD or later now, upgrade Agents and Probes, and hunt from 8 Nov.$sfx";;
      no|false|0) echo "VULNERABLE: $host: $vtext is below $FIX_BUILD (2026.4 HF2) and is recorded as not internet exposed. Upgrade to $FIX_BUILD or later now, since internal reach is still a risk, then upgrade Agents and Probes and hunt from 8 Nov.$sfx";;
      offline) echo "VULNERABLE: $host: $vtext is below $FIX_BUILD (2026.4 HF2) and is recorded as offline. Keep it offline until the upgrade to $FIX_BUILD or later has succeeded, then upgrade Agents and Probes and hunt from 8 Nov.$sfx";;
      *) echo "VULNERABLE: $host: $vtext is below $FIX_BUILD (2026.4 HF2). internet_exposed value \"$3\" not recognised (use yes, no or offline). Treat it as exposed and upgrade to $FIX_BUILD or later now.$sfx";;
    esac
    return 1
  fi
  case $exp in
    yes|true|1)
      echo "EXPOSED / BUILD NOT CONFIRMED: $host: $vtext is recorded as internet exposed and its build is missing or not a full build number. Confirm it is $FIX_BUILD (2026.4 HF2) or later. Until then take it offline, as N-able recommends keeping servers offline until upgraded, and hunt from 8 Nov.$sfx"
      return 1;;
    no|false|0)
      echo "OFFLINE/NOT EXPOSED: $host: $vtext is recorded as not internet exposed. Build not confirmed as $FIX_BUILD (2026.4 HF2) or later. Check it, since internal reach is still a risk.$sfx"
      return 0;;
    offline)
      echo "OFFLINE/NOT EXPOSED: $host: $vtext is recorded as offline. Confirm the build is $FIX_BUILD (2026.4 HF2) or later before bringing it back, and hunt from 8 Nov.$sfx"
      return 0;;
    *)
      echo "UNKNOWN: $host: internet_exposed value \"$3\" not recognised (use yes, no or offline). Treat it as exposed until checked.$sfx"
      return 2;;
  esac
}

# rank 1 > 2 > 6 > 0 > 3
rank() { case $1 in 1) echo 5;; 2) echo 4;; 6) echo 3;; 0) echo 2;; 3) echo 1;; *) echo 0;; esac; }
rank_worst() { if [ "$(rank "$2")" -gt "$(rank "$1")" ]; then echo "$2"; else echo "$1"; fi; }

check_csv() {
  local f=$1 worst=-1 line host ver exp rest lg rc n=0
  [ -r "$f" ] || die "cannot read: $f"
  head -n1 "$f" | tr -d '\r' | grep -qE '^host,version,internet_exposed(,legacy_api)?$' || die "CSV header must be: host,version,internet_exposed (optionally ,legacy_api)"
  while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}
    [ -z "$line" ] && continue
    case $line in \#*) continue;; esac
    host=${line%%,*}; rest=${line#*,}; ver=${rest%%,*}; exp=${rest#*,}
    [ "$rest" = "$line" ] && { ver=""; exp=""; }
    [ "$exp" = "$rest" ] && exp=""
    lg=""; case $exp in *,*) lg=${exp#*,}; exp=${exp%%,*};; esac
    n=$((n+1))
    evaluate "$host" "$ver" "$exp" "$lg"; rc=$?
    worst=$(rank_worst "$worst" "$rc")
  done < <(tail -n +2 "$f")
  [ "$n" -gt 0 ] || die "CSV has no rows: $f"
  return "$worst"
}

# scan_log FILE: our own hunting patterns (not N-able indicators). Returns 0 if any review-worthy hit.
scan_log() {
  awk -v file="$1" -v iocs="$IOC_IPS" -v extra="$xips" -v jline="$JLINE_CLASS" '
    BEGIN { ni = split(iocs, ioc, " "); ne = split(extra, ex, " "); jl = tolower(jline) }
    function has(line, a,  re) { re = a; gsub(/\./, "\\.", re); return line ~ ("(^|[^0-9.])" re "([^0-9.]|$)") }
    { for (k = 1; k <= ni; k++) if (has($0, ioc[k])) { hi[ioc[k]]++; any = 1 }
      for (k = 1; k <= ne; k++) if (has($0, ex[k])) { he[ex[k]]++; any = 1 }
      if (index(tolower($0), jl) > 0) { jh++; any = 1 } }
    { ip = $1; req = $0
      if (index(req, "\"") == 0) next
      sub(/^[^"]*"/, "", req); sub(/".*/, "", req); split(req, r, " "); path = r[2]; sub(/\?.*/, "", path)
      st = $0; status = ""; if (sub(/^[^"]*"[^"]*" /, "", st)) { split(st, s, " "); status = s[1] }
      p = tolower(path)
      if (p ~ /\/appliancelog\//) { al[ip]++; any = 1 }
      if (status ~ /^2/ && p ~ /\/dms2?\/services2?\/servermms2?$/) mms[ip]++
      if (status ~ /^2/ && p ~ /\/dms2?\/services2?\/serverui2?$/) ui[ip]++
      if (status ~ /^2/ && p ~ /\/dms\/services\/serverei$/) { ei[ip]++; any = 1 }
      if (p ~ /\/dms\/services\/serverui$/) { sui[ip]++; any = 1 }
    }
    END {
      for (i in al) printf "%s: [applianceLog path request] %d request(s) from %s\n", file, al[i], i
      for (i in hi) printf "%s: [IOC %s, news-sourced (BleepingComputer, 12 Nov 2026)] %d line(s)\n", file, i, hi[i]
      for (i in he) printf "%s: [operator IP %s] %d line(s)\n", file, i, he[i]
      if (jh) printf "%s: [NEWS IOC %s (BleepingComputer, 12 Nov 2026)] %d line(s)\n", file, jline, jh
      for (i in sui) printf "%s: [NEWS lead: /dms/services/ServerUI request, any status (BleepingComputer: investigate suspicious requests)] %d request(s) from %s\n", file, sui[i], i
      for (i in ei) printf "%s: [Legacy SOAP EI API 2xx: path documented by N-able; link to the LEGACY APIS setting UNVERIFIED] %d request(s) from %s\n", file, ei[i], i
      for (i in ui) if (i in mms) { printf "%s: [MMS and UI SOAP 2xx from one source] %d MMS, %d UI request(s) from %s\n", file, mms[i], ui[i], i; any = 1 }
      exit any ? 0 : 1
    }' "$1" | sort
  return "${PIPESTATUS[0]}"
}

# scan_audit FILE: our own keyword patterns over a saved audit export. Returns 0 if any hit.
scan_audit() {
  awk -v file="$1" '
    { l = $0; sub(/\r$/, "", l); t = tolower(l); tag = ""
      if (t ~ /default administrator/) tag = "default administrator"
      else if (t ~ /password/ && t ~ /(reset|changed)/) tag = "password reset"
      else if (t ~ /(user|account|administrator|admin)/ && t ~ /(created|added|enabled)/) tag = "account created or enabled"
      if (tag != "") { printf "%s:%d: [%s] %s\n", file, NR, tag, substr(l, 1, 200); any = 1 }
    }
    END { exit any ? 0 : 1 }' "$1"
}

# path_kind PATH -> symlink, directory, file (anything else that exists), or nothing (does not follow or read the path).
path_kind() {
  if [ -L "$1" ]; then echo symlink
  elif [ -d "$1" ]; then echo directory
  elif [ -e "$1" ]; then echo file
  fi
}

# scan_root DIR: NEWS IOC artifacts (BleepingComputer). Tests existence only; never reads or prints file contents.
scan_root() {
  local r=$1 d k w found=1
  while [ "${r%/}" != "$r" ]; do r=${r%/}; done
  for d in $UNIT_DIRS; do
    k=$(path_kind "$r/$d/$XVD_UNIT")
    if [ -n "$k" ]; then echo "HOST ARTIFACT: [NEWS IOC $XVD_UNIT systemd unit (BleepingComputer, 12 Nov 2026)] $r/$d/$XVD_UNIT ($k)"; found=0; fi
    for w in "$r/$d"/*.wants/"$XVD_UNIT"; do
      k=$(path_kind "$w")
      if [ -n "$k" ]; then echo "HOST ARTIFACT: [NEWS IOC $XVD_UNIT enabled (wants entry) (BleepingComputer, 12 Nov 2026)] $w ($k)"; found=0; fi
    done
  done
  for d in $XVD_PATHS; do
    k=$(path_kind "$r/$d")
    if [ -n "$k" ]; then echo "HOST ARTIFACT: [NEWS IOC /$d path (BleepingComputer, 12 Nov 2026)] $r/$d ($k)"; found=0; fi
  done
  return "$found"
}

csv="" host="" ver="" exposed="" legacy="" have_exposed=0 have_legacy=0 logs=() audits=() xips="" roots=()
while [ $# -gt 0 ]; do
  case $1 in
    --csv) [ $# -ge 2 ] || usage; csv=$2; shift 2;;
    --host) [ $# -ge 2 ] || usage; host=$2; shift 2;;
    --version) [ $# -ge 2 ] || usage; ver=$2; shift 2;;
    --exposed) [ $# -ge 2 ] || usage; exposed=$2; have_exposed=1; shift 2;;
    --log) [ $# -ge 2 ] || usage; logs+=("$2"); shift 2;;
    --audit) [ $# -ge 2 ] || usage; audits+=("$2"); shift 2;;
    --legacy-api) [ $# -ge 2 ] || usage; legacy=$2; have_legacy=1; shift 2;;
    --root) [ $# -ge 2 ] || usage; roots+=("$2"); shift 2;;
    --extra-ip) [ $# -ge 2 ] || usage
      [[ $2 =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "--extra-ip needs an IPv4 address"
      xips="${xips:+$xips }$2"; shift 2;;
    -h|--help) usage;;
    *) usage;;
  esac
done
[ -n "$csv" ] || [ "$have_exposed" -eq 1 ] || [ ${#logs[@]} -gt 0 ] || [ ${#audits[@]} -gt 0 ] || [ ${#roots[@]} -gt 0 ] || usage
[ -n "$csv" ] && [ "$have_exposed" -eq 1 ] && die "use only one of --csv or --exposed"
[ "$have_exposed" -eq 1 ] && [ -z "$host" ] && die "--exposed needs --host"
[ "$have_legacy" -eq 1 ] && [ "$have_exposed" -ne 1 ] && die "--legacy-api needs --host and --exposed (use a legacy_api column with --csv)"

rc=0; had_inv=0
if [ -n "$csv" ]; then
  had_inv=1; check_csv "$csv"; rc=$?
elif [ "$have_exposed" -eq 1 ]; then
  had_inv=1; evaluate "$host" "$ver" "$exposed" "$legacy"; rc=$?
fi
[ "$rc" -eq 5 ] && exit 5
if [ "$had_inv" -eq 1 ]; then
  echo "NEW ISSUES: $NEW_ISSUES (claims reported 12 Nov 2026 against 2026.4 HF2, build 2026.4.1.26) have no fix in any build; N-able is developing remediation (Uptime page). BleepingComputer reports 4EverAble2Shell exploited in the wild since 12 Nov, including on HF2. This checker cannot read the Legacy API setting: supply it with --legacy-api or a legacy_api CSV column, read from $LEGACY_PATH."
fi

for f in "${logs[@]+"${logs[@]}"}" "${audits[@]+"${audits[@]}"}"; do [ -r "$f" ] || die "cannot read: $f"; done
for d in "${roots[@]+"${roots[@]}"}"; do [ -d "$d" ] || die "cannot read directory: $d"; done
hits=0
for lf in "${logs[@]+"${logs[@]}"}"; do
  if out=$(scan_log "$lf"); then printf '%s\n' "$out"; hits=1
  elif [ -n "$out" ]; then printf '%s\n' "$out"; fi
done
for af in "${audits[@]+"${audits[@]}"}"; do
  if out=$(scan_audit "$af"); then printf '%s\n' "$out"; hits=1; fi
done
art=0
for d in "${roots[@]+"${roots[@]}"}"; do
  if scan_root "$d"; then art=1
  else echo "HOST: no xvd.service unit in the standard systemd unit folders and no /tmp/.xvd or /var/tmp/.xvd under $d. Absence is not proof: attackers change names and clean up."; fi
done
n_files=$(( ${#logs[@]} + ${#audits[@]} ))
if [ "$n_files" -gt 0 ]; then
  if [ "$hits" -eq 1 ]; then
    echo "LOG REVIEW: matches for our hunting patterns and news-sourced indicators (not N-able indicators; IOC lines are news-sourced, operator IP lines are yours). Check each against change records. If any is unexplained, or any NEWS IOC line appears, treat the server as compromised: isolate it, rebuild it, rotate every credential and integration token on it, and review what it pushed to managed devices."
  else
    echo "LOGS: no hits for our patterns in $n_files file(s). Absence is not proof: access logs do not show request bodies, and audit exports vary by version."
  fi
fi
if [ "$art" -eq 1 ]; then
  echo "HOST ARTIFACT FOUND: news-sourced indicators of 4EverAble2Shell exploitation (BleepingComputer, 12 Nov 2026). Treat the server as fully compromised at root: isolate it, keep a disk image and logs, rebuild it from known good media, rotate every credential and integration token it held, and review every action it pushed to managed devices."
  exit 7
fi
[ "$hits" -eq 1 ] && exit 4
[ "$had_inv" -eq 1 ] || exit 2
exit "$rc"
