#!/usr/bin/env bash
# check-ncentral.sh: strictly read-only, offline triage for the N-able N-central "Able2Shell" disclosure (Nov 2026).
# No CVE and no patch exist yet. This checker never reports any build as patched (see FIXED_BUILDS).
# Reads files and arguments only. No network, no writes, no payloads, never contacts N-central.
#
# Usage:
#   check-ncentral.sh --csv INVENTORY.csv [--log FILE ...] [--audit FILE ...]
#   check-ncentral.sh --host NAME --exposed yes|no|offline [--version VERSION] [--log FILE ...] [--audit FILE ...]
#   check-ncentral.sh --log FILE ... | --audit FILE ...
# CSV header: host,version,internet_exposed   (internet_exposed: yes, no or offline; lines starting # are skipped)
# --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.
# Exit: 0 OFFLINE/NOT EXPOSED, 1 EXPOSED / NO PATCH AVAILABLE, 2 UNKNOWN, 4 LOG REVIEW (overrides), 5 usage/read error.
# Exit 3 is reserved for a future FIXED BUILD verdict and is never returned while FIXED_BUILDS is empty.
# CSV mode exits with the worst row (1 > 2 > 0). --log or --audit alone exits 2 when nothing is found.
set -u
export LC_ALL=C

# Space separated list of N-central builds that N-able names as fixed for Able2Shell. EMPTY ON PURPOSE:
# as of 9 Nov 2026 N-able has published no hotfix. Add builds here only from N-able's own advisory.
FIXED_BUILDS=""

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

# evaluate HOST VERSION EXPOSED -> prints verdict line, returns exit code
evaluate() {
  local host=$1 ver=$2 exp b vtext
  exp=$(printf '%s' "$3" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
  vtext="N-central ${ver:-(version not recorded)}"
  for b in $FIXED_BUILDS; do
    if [ "$ver" = "$b" ]; then
      echo "FIXED BUILD: $host: $vtext is on N-able's fixed list. Confirm against N-able's advisory, then hunt anyway."
      return 3
    fi
  done
  case $exp in
    yes|true|1)
      echo "EXPOSED / NO PATCH AVAILABLE: $host: $vtext is recorded as internet exposed. N-able has no fix yet and advises taking on-premises servers offline until further notice. Take it offline now and hunt from 8 Nov."
      return 1;;
    no|false|0)
      echo "OFFLINE/NOT EXPOSED: $host: $vtext is recorded as not internet exposed. No build is patched. N-able advises taking on-premises servers offline until further notice, so internal reach is still a risk."
      return 0;;
    offline)
      echo "OFFLINE/NOT EXPOSED: $host: $vtext is recorded as offline. Keep it offline until N-able confirms it is safe, install the hotfix first, and hunt from 8 Nov."
      return 0;;
    *)
      echo "UNKNOWN: $host: internet_exposed value \"$3\" not recognised (use yes, no or offline). Treat it as exposed until checked."
      return 2;;
  esac
}

# rank 1 > 2 > 0 > 3
rank() { case $1 in 1) echo 4;; 2) 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 rc n=0
  [ -r "$f" ] || die "cannot read: $f"
  head -n1 "$f" | tr -d '\r' | grep -qE '^host,version,internet_exposed$' || die "CSV header must be: host,version,internet_exposed"
  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=""
    n=$((n+1))
    evaluate "$host" "$ver" "$exp"; 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" '
    { 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]++
    }
    END {
      for (i in al) printf "%s: [applianceLog path request] %d request(s) from %s\n", file, al[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"
}

csv="" host="" ver="" exposed="" have_exposed=0 logs=() audits=()
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;;
    -h|--help) usage;;
    *) usage;;
  esac
done
[ -n "$csv" ] || [ "$have_exposed" -eq 1 ] || [ ${#logs[@]} -gt 0 ] || [ ${#audits[@]} -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"

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"; rc=$?
fi
[ "$rc" -eq 5 ] && exit 5

for f in "${logs[@]+"${logs[@]}"}" "${audits[@]+"${audits[@]}"}"; do [ -r "$f" ] || die "cannot read: $f"; 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
n_files=$(( ${#logs[@]} + ${#audits[@]} ))
if [ "$n_files" -gt 0 ]; then
  if [ "$hits" -eq 1 ]; then
    echo "LOG REVIEW: matches for our hunting patterns (not N-able indicators). Check each against change records. If any is unexplained, isolate the server, keep logs, rotate N-central and client credentials, and start incident response for every client it manages."
    exit 4
  fi
  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
[ "$had_inv" -eq 1 ] || exit 2
exit "$rc"
