#!/usr/bin/env bash
# check-oracle-ebs.sh - STRICTLY READ-ONLY, OFFLINE checker for Oracle E-Business Suite CVE-2026-101767
#   (Oracle Application Object Library, component Core; Oracle Critical Patch Update October 2026).
# Affected (Oracle CPU October 2026 risk matrix): Oracle Application Object Library, supported versions
#   12.2.3-12.2.15. CVSS 3.1 9.8, remotely exploitable without authentication over HTTP.
# Fixed: Oracle October 2026 Critical Patch Update (Rev 1, 2026-October-20). Oracle's public text gives no patch
#   number; the patch for each release is listed in My Oracle Support Note KA923. Oracle's CSAF file gives the
#   Oracle bug ID 39800355 for this CVE. CISA KEV: not listed (catalog 2026.10.27).
#
# What it does (read-only, offline): reads the files you give it. It never connects to EBS, the database or any
# URL (it never sends a request to /OA_HTML/AppsTCFServer), sends no network traffic, never writes, moves or
# deletes a file, never executes a found file and contains no exploit payloads.
#
# Usage:
#   bash check-oracle-ebs.sh --release 12.2.12 --patch-list ad_bugs.csv
#   bash check-oracle-ebs.sh --context-file $CONTEXT_FILE --url-fw url_fw.conf --access-log access_log
#   options:
#     --release VER        EBS release (FND_PRODUCT_GROUPS.release_name, e.g. 12.2.12; see sql/check_patch.sql)
#     --context-file FILE  AutoConfig context file (XML), read for s_apps_version and s_applptmp
#     --patch-list FILE    text/CSV export of AD_BUGS.bug_number (and/or AD_APPLIED_PATCHES.patch_name)
#     --fix-patch NUM      patch number YOU confirmed in My Oracle Support Note KA923 (repeatable or comma list)
#     --url-fw FILE        EBS URL firewall config (url_fw.conf), repeatable
#     --access-log FILE    OHS/Apache access log, common/combined format, repeatable, .gz ok
#     --trusted CIDR       IPv4 CIDR or exact IP allowed to use AppsTCFServer, repeatable or comma list
#     --applptmp DIR       list files in the APPLPTMP directory (read-only, not followed into symlinks)
#     -h|--help
#
# Verdicts / exit codes: PATCHED 0 | LIKELY VULNERABLE 1 | UNKNOWN 2 | error 3
#   INDICATORS FOUND (served POSTs to /OA_HTML/AppsTCFServer from untrusted IPs, any served request naming
#   GanttDataServer, script/executable files in APPLPTMP) forces exit 1 even when the release is PATCHED,
#   because patched does not mean clean. URL-firewall findings and probe counts are informational only.
set -u
export LC_ALL=C

CVE="CVE-2026-101767"
BUGID="39800355"
REL=""; CTX=""; PLIST=""; FIXES=(); URLFW=(); ALOGS=(); TRUSTED=(); APPLPTMP=""
IND=0
MAXLIST=${EBS_MAX_LIST:-200}

say() { printf '%s\n' "$*"; }
hdr() { printf '\n==== %s ====\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 3; }
usage() { sed -n '2,/^set -u/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; }

status_block() {
  hdr "KEV status and hunt reminder"
  say "CISA KEV: NOT LISTED - $CVE is not in the CISA KEV catalog (catalog 2026.10.27, checked 28 Oct 2026"
  say "  13:22 GMT). No CISA due date applies. Re-check the KEV feed; patch now regardless."
  say "Exploitation: not confirmed by Oracle or CISA. Public research reports exploitation attempts began on"
  say "  27 Oct 2026 (unverified by Oracle/CISA)."
  say "Oracle: apply the October 2026 Critical Patch Update (released 20 Oct 2026) to EBS 12.2.3-12.2.15, as listed"
  say "  in My Oracle Support Note KA923. Oracle's public text gives no patch number: confirm yours in KA923 and"
  say "  re-run with --fix-patch. Until patched, remove AppsTCFServer from the url_fw.conf allowlist and limit"
  say "  the web tier to trusted networks."
  say "PATCHED DOES NOT MEAN CLEAN: an instance exposed before patching may already be compromised. Review"
  say "  access logs for /OA_HTML/AppsTCFServer, files in APPLPTMP and OA_HTML, shells spawned by the web tier"
  say "  JVM, concurrent manager or database, and new host-type concurrent programs (see README.md)."
}

splitc() { printf '%s\n' "$1" | tr ',' '\n' | grep -v '^$'; }
while [ $# -gt 0 ]; do
  case "$1" in
    -h|--help) usage; exit 0 ;;
    --release) [ $# -ge 2 ] || die "--release needs a value"; REL=$2; shift 2 ;;
    --context-file) [ $# -ge 2 ] || die "--context-file needs a file"; CTX=$2; shift 2 ;;
    --patch-list) [ $# -ge 2 ] || die "--patch-list needs a file"; PLIST=$2; shift 2 ;;
    --fix-patch) [ $# -ge 2 ] || die "--fix-patch needs a number"; while IFS= read -r x; do FIXES+=("$x"); done < <(splitc "$2"); shift 2 ;;
    --url-fw) [ $# -ge 2 ] || die "--url-fw needs a file"; URLFW+=("$2"); shift 2 ;;
    --access-log) [ $# -ge 2 ] || die "--access-log needs a file"; ALOGS+=("$2"); shift 2 ;;
    --trusted) [ $# -ge 2 ] || die "--trusted needs a CIDR or IP"; while IFS= read -r x; do TRUSTED+=("$x"); done < <(splitc "$2"); shift 2 ;;
    --applptmp) [ $# -ge 2 ] || die "--applptmp needs a directory"; APPLPTMP=$2; shift 2 ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
done
if [ -z "$REL$CTX$PLIST$APPLPTMP" ] && [ ${#URLFW[@]} -eq 0 ] && [ ${#ALOGS[@]} -eq 0 ]; then
  die "nothing to check: give at least one of --release, --context-file, --patch-list, --url-fw, --access-log, --applptmp (see --help)"
fi
for f in "$CTX" "$PLIST" "${URLFW[@]}" "${ALOGS[@]}"; do
  [ -z "$f" ] && continue
  { [ -f "$f" ] && [ -r "$f" ]; } || die "cannot read $f"
done
if [ -n "$APPLPTMP" ]; then { [ -d "$APPLPTMP" ] && [ -r "$APPLPTMP" ] && [ -x "$APPLPTMP" ]; } || die "cannot read directory $APPLPTMP"; fi
for x in "${FIXES[@]}"; do printf '%s' "$x" | grep -Eq '^[0-9]{5,10}$' || die "--fix-patch must be a patch number (digits only): $x"; done
for x in "${TRUSTED[@]}"; do
  printf '%s' "$x" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[12][0-9]|3[0-2]))?$|^[0-9A-Fa-f:]+$' || die "--trusted must be an IPv4 CIDR or IP (IPv6: exact address): $x"
done

listed=0
report() { IND=$((IND+1)); listed=$((listed+1)); [ "$listed" -le "$MAXLIST" ] && say "[!] INDICATOR $1"; }

# ---------------- release ----------------
hdr "EBS release"
CTX_VER=""; CTX_TMP=""; CTX_NAME=""
ctx_var() { tr -d '\r' < "$CTX" | tr '\n' ' ' | grep -Eo "oa_var=\"$1\"[^>]*>[^<]*<" | head -n1 | sed -E 's/.*>([^<]*)<$/\1/; s/^[[:space:]]+//; s/[[:space:]]+$//'; }
if [ -n "$CTX" ]; then
  CTX_VER=$(ctx_var s_apps_version); CTX_TMP=$(ctx_var s_applptmp); CTX_NAME=$(ctx_var s_contextname)
  say "Context file: $CTX"
  say "  s_contextname: ${CTX_NAME:-not found}"
  say "  s_apps_version: ${CTX_VER:-not found}"
  say "  s_applptmp: ${CTX_TMP:-not found}$([ -n "$CTX_TMP" ] && [ -z "$APPLPTMP" ] && printf ' (pass --applptmp to list it; not scanned automatically)')"
fi
SRC=""
if [ -n "$REL" ]; then SRC="--release"; V=$REL
elif [ -n "$CTX_VER" ]; then SRC="context file s_apps_version"; V=$CTX_VER
else V=""; fi
say "Release used: ${V:-none}${SRC:+ (from $SRC)}"

# ---------------- patch list ----------------
PL_TOKENS=""; PL_N=0; BUG_SEEN=0; FIX_SEEN=""
if [ -n "$PLIST" ]; then
  hdr "Patch list: $PLIST"
  PL_TOKENS=$(tr -d '\r"'"'" < "$PLIST" | tr ',;|\t ' '[\n*]' | grep -E '^[0-9]{5,10}(:[0-9.A-Za-z]+)?$' | sed 's/:.*//' | sort -u)
  PL_N=$(printf '%s\n' "$PL_TOKENS" | grep -c . )
  say "Distinct bug/patch numbers read: $PL_N"
  if printf '%s\n' "$PL_TOKENS" | grep -qx "$BUGID"; then BUG_SEEN=1; fi
  if [ "$PL_N" -gt 0 ]; then
    if [ $BUG_SEEN -eq 1 ]; then say "Oracle bug $BUGID (Oracle's CSAF bug ID for $CVE): PRESENT in the list"
    else say "Oracle bug $BUGID (Oracle's CSAF bug ID for $CVE): not in the list"; fi
  fi
  for x in "${FIXES[@]}"; do
    if printf '%s\n' "$PL_TOKENS" | grep -qx "$x"; then FIX_SEEN="$FIX_SEEN $x"; say "Confirmed fix patch $x (from --fix-patch): PRESENT"
    else say "Confirmed fix patch $x (from --fix-patch): not in the list"; fi
  done
fi
if [ ${#FIXES[@]} -gt 0 ] && [ -n "$PLIST" ]; then
  if [ -n "$FIX_SEEN" ]; then PSTAT="PRESENT (patch${FIX_SEEN} from --fix-patch)"; else PSTAT="NOT FOUND (--fix-patch ${FIXES[*]})"; fi
else
  PSTAT="UNKNOWN (Oracle's public CPU text gives no patch number; confirm the patch for your release in My Oracle Support Note KA923 and re-run with --fix-patch and --patch-list)"
fi
say "Patch presence: $PSTAT"

# ---------------- verdict ----------------
hdr "Verdict"
VERDICT="UNKNOWN"; WHY="no EBS release supplied (use --release with FND_PRODUCT_GROUPS.release_name, or --context-file)"
if [ -n "$V" ]; then
  if printf '%s' "$V" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
    MA=${V%%.*}; r=${V#*.}; MI=${r%%.*}; PT=${r#*.}
    if [ "$MA" -eq 12 ] && [ "$MI" -eq 2 ]; then
      if [ "$PT" -eq 0 ] && [ "$SRC" != "--release" ]; then
        VERDICT="UNKNOWN"; WHY="the context file only records the base release 12.2.0 (ASSUMPTION: s_apps_version is not updated by release update packs); give --release from FND_PRODUCT_GROUPS.release_name"
      elif [ "$PT" -lt 3 ]; then
        VERDICT="LIKELY VULNERABLE"; WHY="$V is below 12.2.3, the lowest release Oracle lists for the October 2026 CPU, so no CPU patch applies; upgrade to 12.2.3 or later and apply the CPU"
      elif [ "$PT" -le 15 ]; then
        if [ ${#FIXES[@]} -gt 0 ] && [ -n "$PLIST" ]; then
          if [ -n "$FIX_SEEN" ]; then VERDICT="PATCHED"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15, and the fix patch you confirmed in KA923 (${FIX_SEEN# }) is in the patch list"
          else VERDICT="LIKELY VULNERABLE"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15 and the fix patch you confirmed in KA923 (${FIXES[*]}) is not in the patch list"; fi
        elif [ ${#FIXES[@]} -gt 0 ]; then
          VERDICT="UNKNOWN"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15; --fix-patch was given but no --patch-list to check it against"
        elif [ -n "$PLIST" ] && [ "$PL_N" -gt 0 ] && [ $BUG_SEEN -eq 0 ]; then
          VERDICT="LIKELY VULNERABLE"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15 and Oracle bug $BUGID is not in the patch list (ASSUMPTION: the CPU patch records this bug in AD_BUGS; patch presence itself is UNKNOWN, confirm in KA923)"
        elif [ -n "$PLIST" ] && [ $BUG_SEEN -eq 1 ]; then
          VERDICT="UNKNOWN"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15; Oracle bug $BUGID is in the patch list, which suggests the fix is applied, but Oracle publishes no patch number to confirm it - check KA923 and re-run with --fix-patch"
        elif [ -n "$PLIST" ]; then
          VERDICT="UNKNOWN"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15, but the patch list contains no bug or patch numbers"
        else
          VERDICT="UNKNOWN"; WHY="$V is in Oracle's affected range 12.2.3-12.2.15; patch presence is UNKNOWN without a patch list - export AD_BUGS (sql/check_patch.sql) and confirm the patch in KA923"
        fi
      else
        VERDICT="PATCHED"; WHY="$V is newer than Oracle's affected range 12.2.3-12.2.15 (confirm in KA923 that the October 2026 CPU content is included)"
      fi
    elif { [ "$MA" -eq 12 ] && [ "$MI" -le 1 ]; } || [ "$MA" -eq 11 ]; then
      VERDICT="UNKNOWN"; WHY="$V is not a supported release in Oracle's October 2026 CPU and Oracle does not assess it; no CPU patch exists for it. Treat it as exposed, restrict AppsTCFServer and plan an upgrade to 12.2"
    else
      VERDICT="UNKNOWN"; WHY="unrecognised EBS release '$V'"
    fi
  else
    VERDICT="UNKNOWN"; WHY="could not parse release '$V' (expected e.g. 12.2.12)"
  fi
fi
say "Oracle E-Business Suite ${V:-release unknown}: VERDICT: $VERDICT - $WHY"

# ---------------- URL firewall (informational) ----------------
for f in "${URLFW[@]}"; do
  hdr "URL firewall: $f (informational)"
  nrules=0; allow=0; broad=0; blocked=0; commented=0; ln=0
  while IFS= read -r line || [ -n "$line" ]; do
    ln=$((ln+1)); line=${line%$'\r'}
    t=$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//')
    case "$t" in
      \#*) if printf '%s' "$t" | grep -Eqi 'AppsTCFServer|tcf\.SocketServer'; then commented=$((commented+1)); say "line $ln: commented out (not active): $t"; fi; continue ;;
    esac
    printf '%s' "$t" | grep -Eqi '^RewriteRule[[:space:]]' || continue
    nrules=$((nrules+1))
    pat=$(printf '%s' "$t" | awk '{print $2}'); act=$(printf '%s' "$t" | awk '{print $3}'); flg=$(printf '%s' "$t" | awk '{print $4}')
    isf=0; printf '%s' "$flg" | grep -Eqi '(^|[[,])(F|forbidden)([],]|$)' && isf=1
    if printf '%s' "$pat" | grep -Eqi 'AppsTCFServer|SocketServer'; then
      if [ $isf -eq 1 ]; then blocked=$((blocked+1)); say "line $ln: AppsTCFServer explicitly forbidden: $t"
      else allow=$((allow+1)); say "[!] URL-FW RISK line $ln: AppsTCFServer / SocketServer is ALLOWED: $t"; fi
    elif [ $isf -eq 0 ] && [ "$act" = "-" ] && printf '%s' "$pat" | grep -Eq '^\^?/OA_HTML/?(\.\*|\.\+)?\$?$|^\^?/?\.\*\$?$'; then
      broad=$((broad+1)); say "[~] URL-FW WARN line $ln: broad allow rule also lets AppsTCFServer through: $t"
    fi
  done < "$f"
  if [ "$nrules" -eq 0 ]; then say "No RewriteRule lines: this does not look like an EBS url_fw.conf; nothing checked."
  elif [ "$allow" -eq 0 ] && [ "$broad" -eq 0 ]; then say "URL-FW OK: no active rule allows /OA_HTML/AppsTCFServer ($nrules rules read)."
  else say "Until patched, remove (comment out) these rules in the AutoConfig template and rerun AutoConfig (see README.md)."; fi
  : "$blocked" "$commented"
done

# ---------------- access logs ----------------
readlog() { case "$1" in *.gz) zcat -- "$1" 2>/dev/null ;; *) cat -- "$1" 2>/dev/null ;; esac; }
TR_LIST=$(printf '%s,' "${TRUSTED[@]}")
for L in "${ALOGS[@]}"; do
  hdr "Access log: $L (common/combined log format assumed)"
  [ ${#TRUSTED[@]} -eq 0 ] && say "No --trusted networks given: every client IP is treated as untrusted."
  res=$(readlog "$L" | awk -v trusted="$TR_LIST" '
    function ip2n(ip,   a) { if (split(ip, a, ".") != 4) return -1; return ((a[1]*256 + a[2])*256 + a[3])*256 + a[4] }
    function istrusted(ip,   i, n, p, net, bits, size, x) {
      for (i = 1; i <= nt; i++) {
        if (T[i] == ip) return 1
        n = split(T[i], p, "/"); if (n != 2) continue
        x = ip2n(ip); net = ip2n(p[1]); bits = p[2] + 0
        if (x < 0 || net < 0) continue
        size = 2 ^ (32 - bits)
        if (int(x / size) == int(net / size)) return 1
      }
      return 0
    }
    BEGIN { nt = split(trusted, T, ","); if (T[nt] == "") nt-- }
    {
      ip = $1
      if (!match($0, /"[A-Z]+ [^ "]+/)) next
      req = substr($0, RSTART + 1, RLENGTH - 1); rest = substr($0, RSTART + RLENGTH)
      sp = index(req, " "); m = substr(req, 1, sp - 1); u = substr(req, sp + 1)
      lu = tolower(u)
      gantt = (lu ~ /ganttdataserver/)
      if (lu !~ /^(https?:\/\/[^\/]+)?\/oa_html\/appstcfserver([\/?;]|$)/ && !gantt) next
      st = "-"; if (match(rest, /" [0-9][0-9][0-9]/)) st = substr(rest, RSTART + 2, 3)
      tr = istrusted(ip) ? "T" : "U"
      print (gantt ? "G" : "R") "\t" NR "\t" ip "\t" m "\t" st "\t" tr "\t" u
    }')
  tot=0; posts=0; tposts=0; probes=0; other=0; ng=0
  while IFS=$'\t' read -r typ ln ip m st tr u; do
    [ -z "$typ" ] && continue
    served=0; case "$st" in 2??|-) served=1 ;; esac
    if [ "$typ" = G ]; then
      ng=$((ng+1))
      if [ $served -eq 1 ]; then report "access log line $ln: $ip $m $u -> $st (request naming GanttDataServer)"
      else say "[~] probe (not served, status $st) line $ln: $ip $m $u (request naming GanttDataServer)"; probes=$((probes+1)); fi
      continue
    fi
    tot=$((tot+1))
    if [ "$m" = POST ]; then
      posts=$((posts+1))
      if [ "$tr" = T ]; then tposts=$((tposts+1)); continue; fi
      if [ $served -eq 1 ]; then report "access log line $ln: $ip POST $u -> $st (POST to AppsTCFServer from an untrusted IP)"
      else say "[~] probe (not served, status $st) line $ln: $ip POST $u"; probes=$((probes+1)); fi
    else
      other=$((other+1))
      [ "$tr" = U ] && say "[~] review line $ln: $ip $m $u -> $st (non-POST request to AppsTCFServer from an untrusted IP)"
    fi
  done <<< "$res"
  say "Requests to /OA_HTML/AppsTCFServer: $tot (POST: $posts, of which from trusted networks: $tposts; other methods: $other); requests naming GanttDataServer: $ng; refused probes: $probes"
  [ $((tot + ng)) -eq 0 ] && say "No requests to /OA_HTML/AppsTCFServer and none naming GanttDataServer."
  if [ $((tot + ng)) -gt 0 ]; then
    say "  Top sources:"; printf '%s\n' "$res" | awk -F'\t' 'NF{c[$3]++} END{for(i in c) print c[i], i}' | sort -k1,1rn -k2,2 | head -n 5 | sed 's/^/    /'
  fi
done

# ---------------- APPLPTMP ----------------
if [ -n "$APPLPTMP" ]; then
  hdr "APPLPTMP: $APPLPTMP (read-only listing)"
  D=${APPLPTMP%/}; [ -z "$D" ] && D=/
  EXT_RE='^(sh|bash|ksh|csh|py|pl|rb|php|jsp|jspx|class|jar|war|ear|so|elf|bin|exe|ps1)$'
  n=0
  while IFS= read -r -d '' f; do
    n=$((n+1)); rel=${f#"$D/"}; name=${f##*/}; reasons=""
    if [ -L "$f" ]; then report "$rel - symbolic link in APPLPTMP (not followed)"; continue; fi
    lname=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')
    case "$lname" in *.*) ext=${lname##*.} ;; *) ext="" ;; esac
    printf '%s' "$ext" | grep -Eq "$EXT_RE" && reasons="$reasons; script/executable/Java extension .$ext"
    [ -x "$f" ] && reasons="$reasons; executable permission bit set"
    mh=$(od -An -tx1 -N4 -- "$f" 2>/dev/null | tr -d ' \n')
    case "$mh" in 2321*) reasons="$reasons; starts with '#!' (script)" ;; 7f454c46) reasons="$reasons; ELF binary" ;; 504b0304) printf '%s' "$ext" | grep -Eq '^(jar|war|ear|zip)$' || reasons="$reasons; ZIP/JAR content under a .${ext:-<none>} name" ;; cafebabe) reasons="$reasons; Java class file" ;; esac
    [ -n "$reasons" ] && report "$rel -${reasons#;}"
  done < <(find "$D" -mindepth 1 \( -type f -o -type l \) -print0 2>/dev/null | sort -z)
  say "Files examined: $n (files were read, never executed)"
  say "APPLPTMP normally holds PL/SQL and concurrent-request temporary output; baseline before acting on volume alone."
fi
[ "$listed" -gt "$MAXLIST" ] && say "(only the first $MAXLIST indicators are listed; total $IND)"

# ---------------- summary ----------------
hdr "Summary"
case "$VERDICT" in PATCHED) ec=0 ;; "LIKELY VULNERABLE") ec=1 ;; *) ec=2 ;; esac
say "release verdict=$VERDICT patch presence=${PSTAT%% *} indicators=$IND"
if [ "$IND" -gt 0 ]; then
  say "VERSION VERDICT: $VERDICT"
  say "OVERALL: INDICATORS FOUND ($IND) - treat the instance as possibly compromised (version verdict: $VERDICT)"
  say "  Isolate it, keep web, application and database logs, rotate APPS, SYSADMIN and other credentials (see README.md)."
  ec=1
else
  say "OVERALL: $VERDICT"
fi
status_block
exit "$ec"
