#!/usr/bin/env bash
# check-sap.sh: strictly read-only, offline review of saved SAP data for the SAP November 2026 Security Patch Day.
# Reads only the files you pass. No network, no writes, no payloads, never contacts an SAP system.
# Fixed kernel and support package levels are in SAP Notes behind an S-user login, so this checker does NOT
# judge version numbers. It judges whether you say each Note is applied: yes, no or unknown.
#
# Usage:
#   check-sap.sh [--inventory FILE] [--profile FILE] [--secinfo FILE] [--reginfo FILE] [--audit FILE]
# --inventory  CSV header: sid,host,component,kernel_patch,note,note_applied,internet_exposed
#              one row per system and Note; note_applied and internet_exposed take yes, no or unknown.
# --profile    saved profile parameter export, one "name = value" per line (for example from RZ11 or the profile files).
# --secinfo    saved copy of the RFC Gateway secinfo file.
# --reginfo    saved copy of the RFC Gateway reginfo file.
# --audit      saved Security Audit Log export as CSV, header: date,time,client,user,terminal,tcode,program,message_id,text
# Exit: 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN, 3 NOT IN SCOPE, 4 REVIEW (config, ACL or audit findings; overrides),
#       5 usage or read error. Inventory exits with the worst row (1 > 2 > 0 > 3).
#       Without --inventory, exit is 4 on findings, otherwise 2 (patch state not checked).
set -u
export LC_ALL=C

usage() {
  cat <<'USAGE'
Usage: check-sap.sh [--inventory FILE] [--profile FILE] [--secinfo FILE] [--reginfo FILE] [--audit FILE]
  --inventory  CSV: sid,host,component,kernel_patch,note,note_applied,internet_exposed (yes/no/unknown)
  --profile    saved profile parameters, one "name = value" per line
  --secinfo    saved RFC Gateway secinfo file
  --reginfo    saved RFC Gateway reginfo file
  --audit      saved Security Audit Log CSV: date,time,client,user,terminal,tcode,program,message_id,text
Exit: 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN, 3 NOT IN SCOPE, 4 REVIEW, 5 usage or read error
USAGE
  exit 5
}
die() { echo "ERROR: $*" >&2; exit 5; }

# note|cve|score|access|what (from the SAP CNA records on cve.org, 10 Nov 2026)
NOTES='3849308|CVE-2026-120017|10.0|no account needed|AS ABAP SAML2 Service Provider authentication bypass
3778530|CVE-2026-76964|10.0|no account needed|AS ABAP Internet Communication Manager memory corruption
3778556|CVE-2026-89126|10.0|no account needed|Web Dispatcher memory corruption
3798345|CVE-2026-89103|10.0|no account needed|RFC Gateway default ACL treats an external attacker as internal
3792735|CVE-2026-76965|9.9|needs a low privilege account|AS ABAP OS command execution
3834842|CVE-2026-109795|9.9|needs a low privilege account|S4CRM remote-enabled function code injection
3809153|CVE-2026-109803|9.9|needs a low privilege account|Knowledge Management XML Forms XSL code injection
3804858|CVE-2026-89119|9.6|no account needed but an admin must open a link|Web Dispatcher and ICM admin request forgery
3799117|CVE-2026-109798|9.1|needs a low privilege account|BusinessObjects Web Intelligence XXE'

note_info() { printf '%s\n' "$NOTES" | awk -F'|' -v n="$1" '$1 == n { print; exit }'; }

lc() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
trim() { local s=$1; s=${s#"${s%%[![:space:]]*}"}; s=${s%"${s##*[![:space:]]}"}; printf '%s' "$s"; }

rank() { case $1 in 1) echo 4;; 2) echo 3;; 0) echo 2;; 3) echo 1;; *) echo 0;; esac; }

check_inventory() {
  local f=$1 worst=-1 n=0 ln=1 line sid host comp kern note applied exposed info cve score access what rc where
  [ -r "$f" ] || die "cannot read: $f"
  line=$(head -n1 "$f" | tr -d '\r'); line=${line#$'\xef\xbb\xbf'}
  [ "$line" = "sid,host,component,kernel_patch,note,note_applied,internet_exposed" ] || die "CSV header must be: sid,host,component,kernel_patch,note,note_applied,internet_exposed"
  while IFS= read -r line || [ -n "$line" ]; do
    ln=$((ln+1)); line=${line%$'\r'}
    case $line in ''|\#*) continue;; esac
    IFS=, read -r sid host comp kern note applied exposed <<< "$line"
    sid=$(trim "${sid:-}"); host=$(trim "${host:-}"); comp=$(trim "${comp:-}"); kern=$(trim "${kern:-}")
    note=$(trim "${note:-}"); applied=$(lc "$(trim "${applied:-}")"); exposed=$(lc "$(trim "${exposed:-}")")
    case $exposed in yes|no) ;; *) exposed=unknown;; esac
    n=$((n+1)); where="$sid/$host ($comp, kernel or patch ${kern:-not given})"
    info=$(note_info "$note")
    if [ -z "$info" ]; then
      echo "NOT IN SCOPE: $where: Note ${note:-missing} is not one of the nine November 2026 Notes this pack covers."
      rc=3
    else
      IFS='|' read -r _ cve score access what <<< "$info"
      case $applied in
        yes) echo "PATCHED: $where: Note $note ($cve, CVSS $score, $what) marked applied. Confirm in SNOTE or the kernel patch level the Note names."; rc=0;;
        no)
          if [ "$exposed" = yes ] && [ "$access" != "needs a low privilege account" ]; then
            echo "VULNERABLE: $where: Note $note ($cve, CVSS $score, $what, $access) not applied and internet exposed. Apply the Note now and remove internet exposure until it is in."
          else
            echo "VULNERABLE: $where: Note $note ($cve, CVSS $score, $what, $access) not applied. Internet exposed: $exposed. Apply the Note now."
          fi
          rc=1;;
        *) echo "UNKNOWN: $where: Note $note ($cve, CVSS $score, $what) applied state is \"${applied:-blank}\". Check SNOTE or the kernel patch level against the Note (S-user login needed)."; rc=2;;
      esac
    fi
    if [ "$(rank "$rc")" -gt "$(rank "$worst")" ]; then worst=$rc; fi
  done < <(tail -n +2 "$f")
  [ "$n" -gt 0 ] || die "CSV has no rows: $f"
  return "$worst"
}

FIND=0
finding() { echo "FINDING: $*"; FIND=1; }

check_profile() {
  local f=$1 line name val acl="" sim="" mon="" msi="" seen_msi=0
  [ -r "$f" ] || die "cannot read: $f"
  while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}; line=$(trim "$line")
    case $line in ''|\#*) continue;; esac
    case $line in *=*) ;; *) continue;; esac
    name=$(lc "$(trim "${line%%=*}")"); val=$(trim "${line#*=}")
    case $name in
      gw/acl_mode) acl=$val;;
      gw/sim_mode) sim=$val;;
      gw/monitor) mon=$val;;
      rdisp/msserv_internal) msi=$val; seen_msi=1;;
    esac
  done < "$f"
  if [ -z "$acl" ]; then echo "INFO: $f: gw/acl_mode not set. SAP Help gives the default as 1."
  elif [ "$acl" = 0 ]; then finding "$f: gw/acl_mode = 0. With no secinfo or reginfo file the RFC Gateway falls back to permissive rules. SAP Help gives 1 as the default."
  else echo "INFO: $f: gw/acl_mode = $acl."; fi
  if [ "$acl" != 0 ]; then echo "INFO: $f: if no secinfo or reginfo file exists, the Gateway uses its default ACL, which is what CVE-2026-89103 concerns. Apply Note 3798345."; fi
  [ "$sim" = 1 ] && finding "$f: gw/sim_mode = 1. Gateway simulation mode lets connections through that the ACLs would deny (our check)."
  [ "$mon" = 2 ] && finding "$f: gw/monitor = 2. Remote Gateway monitoring is allowed (our check, 1 limits it to local)."
  if [ "$seen_msi" -eq 0 ] || [ "$msi" = 0 ] || [ -z "$msi" ]; then
    finding "$f: rdisp/msserv_internal not set. The message server has no separate internal port (our check)."
  fi
}

# acl_hosts LINE KEY -> comma list of values for KEY= (HOST never matches USER-HOST); handles "HOST=a,b" and "HOST=a, b"
acl_hosts() {
  printf '%s\n' "$1" | awk -v k="$2" '{ s = $0; gsub(/\t/, " ", s)
    while (match(s, /,[ ]*[A-Za-z-]+=/)) s = substr(s, 1, RSTART - 1) " " substr(s, RSTART + 1)
    n = split(s, t, " "); out = ""; on = 0
    for (i = 1; i <= n; i++) { w = t[i]
      if (w ~ /^[A-Za-z-]+=/) { on = 0; if (index(toupper(w), k "=") == 1) { on = 1; w = substr(w, length(k) + 2) } }
      if (on) out = out "," w }
    gsub(/,+/, ",", out); sub(/^,/, "", out); sub(/,$/, "", out); print out }'
}
has_star() { case ",$1," in *,\*,*) return 0;; esac; return 1; }

check_acl() {
  local kind=$1 f=$2 line ln=0 ver=1 first=1 tp host uh rules=0
  [ -r "$f" ] || die "cannot read: $f"
  while IFS= read -r line || [ -n "$line" ]; do
    ln=$((ln+1)); line=${line%$'\r'}; line=$(trim "$line")
    [ -z "$line" ] && continue
    if [ "$first" -eq 1 ]; then first=0; case $line in '#VERSION=2'*) ver=2; continue;; esac; fi
    case $line in \#*) continue;; esac
    if [ "$ver" -eq 2 ]; then
      case $line in 'P '*|P$'\t'*) line=${line#P};; 'D '*|D$'\t'*) rules=$((rules+1)); continue;; *) continue;; esac
    fi
    rules=$((rules+1))
    tp=$(acl_hosts "$line" TP); host=$(acl_hosts "$line" HOST); uh=$(acl_hosts "$line" USER-HOST)
    has_star "$tp" || continue
    if [ "$kind" = secinfo ]; then
      if has_star "$host" && { [ -z "$uh" ] || has_star "$uh"; }; then
        finding "$f:$ln: secinfo permits any program to be started from any host (TP=* HOST=*${uh:+ USER-HOST=$uh})."
      fi
    else
      if [ -z "$host" ] || has_star "$host"; then
        finding "$f:$ln: reginfo permits any program to register from any host (TP=* HOST=${host:-not set})."
      fi
    fi
  done < "$f"
  [ "$ver" -eq 1 ] && echo "INFO: $f: no #VERSION=2 first line, so this is version 1 syntax without deny rules. SAP Help documents version 2."
  [ "$rules" -eq 0 ] && echo "INFO: $f: no rules found."
  return 0
}

check_audit() {
  local f=$1 line ln=1 cl user term mid text u m
  [ -r "$f" ] || die "cannot read: $f"
  line=$(head -n1 "$f" | tr -d '\r'); line=${line#$'\xef\xbb\xbf'}
  [ "$line" = "date,time,client,user,terminal,tcode,program,message_id,text" ] || die "audit CSV header must be: date,time,client,user,terminal,tcode,program,message_id,text"
  while IFS= read -r line || [ -n "$line" ]; do
    ln=$((ln+1)); line=${line%$'\r'}
    case $line in ''|\#*) continue;; esac
    IFS=, read -r _ _ cl user term _ _ mid text <<< "$line"
    user=$(trim "${user:-}"); mid=$(trim "${mid:-}"); text=${text:-}
    u=$(printf '%s' "$user" | tr '[:lower:]' '[:upper:]'); m=$(printf '%s' "$mid" | tr '[:lower:]' '[:upper:]')
    case $u in 'SAP*'|DDIC|EARLYWATCH|SAPCPIC|TMSADM) finding "$f:$ln: [standard user activity] user=$user msg=$mid client=$cl terminal=$term";; esac
    case $m in AUB|AUD) finding "$f:$ln: [user master or authorization change] user=$user msg=$mid client=$cl terminal=$term";; esac
    case $m in AU7) finding "$f:$ln: [user created, message ID unverified] user=$user msg=$mid client=$cl terminal=$term";; esac
    case $(printf '%s' "$text" | tr '[:lower:]' '[:upper:]') in *SAP_ALL*|*SAP_NEW*) finding "$f:$ln: [SAP_ALL or SAP_NEW in event text] user=$user msg=$mid client=$cl";; esac
  done < <(tail -n +2 "$f")
  return 0
}

inv="" prof="" sec="" reg="" aud=""
while [ $# -gt 0 ]; do
  case $1 in
    --inventory) [ $# -ge 2 ] || usage; inv=$2; shift 2;;
    --profile) [ $# -ge 2 ] || usage; prof=$2; shift 2;;
    --secinfo) [ $# -ge 2 ] || usage; sec=$2; shift 2;;
    --reginfo) [ $# -ge 2 ] || usage; reg=$2; shift 2;;
    --audit) [ $# -ge 2 ] || usage; aud=$2; shift 2;;
    *) usage;;
  esac
done
[ -n "$inv$prof$sec$reg$aud" ] || usage
# read errors must stop before any output
for x in "$inv" "$prof" "$sec" "$reg" "$aud"; do [ -z "$x" ] || [ -r "$x" ] || die "cannot read: $x"; done

rc=2
if [ -n "$inv" ]; then check_inventory "$inv"; rc=$?; [ "$rc" -eq 5 ] && exit 5; fi
[ -n "$prof" ] && check_profile "$prof"
[ -n "$sec" ] && check_acl secinfo "$sec"
[ -n "$reg" ] && check_acl reginfo "$reg"
[ -n "$aud" ] && check_audit "$aud"
if [ "$FIND" -eq 1 ]; then
  echo "REVIEW: findings above come from our own checks, not SAP indicators of compromise. Tighten the settings, and for audit hits confirm each event with the system owner. If an event is not explained, isolate the system, keep the logs, and rotate privileged and RFC credentials."
  exit 4
fi
[ -n "$prof$sec$reg$aud" ] && echo "CONFIG: no findings for our checks. Absence is not proof of safety; apply the Notes regardless."
exit "$rc"
