#!/usr/bin/env bash
# check-gitlab-110264.sh - READ-ONLY checker for GitLab CE/EE (self-managed)
#   CVE-2026-110264  Integer overflow in regular expression compiler (Onigmo), unauthenticated RCE, CVSS 10.0
#   CVE-2026-15723   OmniAuth-to-LDAP auto-linking account takeover (SAML + LDAP auto-link), CVSS 8.2
#   CVE-2026-112212  OAuth/OIDC discovery cache poisoning, CVSS 7.5
# Source: https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-1-released/ (21 Oct 2026)
# CVE-2026-110264 fixed in: 19.5.1, 19.4.3, 19.3.6, 19.2.9, 19.1.9, 19.0.10, 18.11.13 (affected: all from 7.0)
# CVE-2026-15723 / 112212 fixed ONLY in 19.5.1, 19.4.3, 19.3.6 (the backports do not list them).
# This script never writes, restarts or changes anything. Run as root for full config/log visibility.
#
# Exit codes: 0 = every GitLab found PATCHED for CVE-2026-110264 (or no GitLab found)
#             1 = at least one LIKELY VULNERABLE
#             2 = none vulnerable, but at least one UNKNOWN
#             3 = nothing vulnerable/unknown, but the log hunt found entries to review
# Env: GITLAB_ROOT=/        prefix for files (testing / offline mounted disk)
#      LOG_LINES=200000     max lines read from the tail of each log
#      USE_RAKE=1           also try 'gitlab-rake gitlab:env:info' (slow, starts Rails)
#      CHECK_DOCKER=1       inspect local containers/images (set 0 to skip)
set -u
export LC_ALL=C

R=${GITLAB_ROOT:-}; R=${R%/}
LOG_MAX_LINES=${LOG_LINES:-200000}
VULN=0; UNK=0; PATCHED=0; SUSP=0; FOUND=0

say() { printf '%s\n' "$*"; }
hdr() { printf '\n==== %s ====\n' "$*"; }
ver_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ]; }

# $1 = X.Y.Z -> PATCHED | VULNERABLE(<fix) | PATCHED_NEWER | NOT_AFFECTED
verdict_110264() {
  local v=$1 mm fixed
  mm=$(printf '%s' "$v" | cut -d. -f1,2)
  case "$mm" in
    19.5) fixed=19.5.1 ;; 19.4) fixed=19.4.3 ;; 19.3) fixed=19.3.6 ;; 19.2) fixed=19.2.9 ;;
    19.1) fixed=19.1.9 ;; 19.0) fixed=19.0.10 ;; 18.11) fixed=18.11.13 ;;
    *)
      if ver_ge "$v" 19.6.0; then echo PATCHED_NEWER; return; fi
      if ver_ge "$v" 7.0.0; then echo "VULNERABLE(no fix on this branch; upgrade to >=18.11.13)"; return; fi
      echo NOT_AFFECTED; return ;;
  esac
  if ver_ge "$v" "$fixed"; then echo PATCHED; else echo "VULNERABLE(<$fixed)"; fi
}
# CVE-2026-15723: affected 10.1 before 19.3.6, 19.4 before 19.4.3, 19.5 before 19.5.1
verdict_15723() {
  local v=$1 mm
  mm=$(printf '%s' "$v" | cut -d. -f1,2)
  case "$mm" in
    19.5) ver_ge "$v" 19.5.1 && echo PATCHED || echo VULNERABLE; return ;;
    19.4) ver_ge "$v" 19.4.3 && echo PATCHED || echo VULNERABLE; return ;;
    19.3) ver_ge "$v" 19.3.6 && echo PATCHED || echo VULNERABLE; return ;;
  esac
  if ver_ge "$v" 19.6.0; then echo PATCHED
  elif ver_ge "$v" 10.1.0; then echo VULNERABLE
  else echo NOT_AFFECTED; fi
}
verdict_112212() { # affected 18.3 before 19.3.6, 19.4 < 19.4.3, 19.5 < 19.5.1
  local v=$1
  if ver_ge "$v" 18.3.0; then verdict_15723 "$v"; else echo NOT_AFFECTED; fi
}

assess() { # $1=label $2=version-string
  local label=$1 raw=$2 v uv ed
  FOUND=$((FOUND+1))
  hdr "$label"
  v=$(printf '%s' "$raw" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
  ed=$(printf '%s' "$raw" | grep -Eio 'gitlab-(ce|ee)|-(ce|ee)' | head -n1 | tr -d '-' | sed 's/gitlab//')
  say "Raw version: ${raw:-<none>}  Edition: ${ed:-unknown}"
  if [ -z "$v" ]; then
    say "VERDICT CVE-2026-110264: UNKNOWN - could not parse a version"; UNK=$((UNK+1)); return
  fi
  uv=$(verdict_110264 "$v")
  case "$uv" in
    PATCHED|PATCHED_NEWER) say "VERDICT CVE-2026-110264: PATCHED - $v is at/above the fixed release for its branch"; PATCHED=$((PATCHED+1)) ;;
    NOT_AFFECTED) say "VERDICT CVE-2026-110264: PATCHED - $v predates 7.0 (outside stated range; still EOL)"; PATCHED=$((PATCHED+1)) ;;
    *) say "VERDICT CVE-2026-110264: LIKELY VULNERABLE - $v is $uv"; VULN=$((VULN+1)) ;;
  esac
  say "CVE-2026-15723 (SAML+LDAP auto-link) by version: $(verdict_15723 "$v")"
  say "CVE-2026-112212 (OAuth/OIDC cache poisoning) by version: $(verdict_112212 "$v")"
  case "$v" in 19.4.2|19.3.5|19.2.8) say "NOTE: $v is the 12 Oct CVE-2026-97229 fix level; it is still below the 21 Oct fix." ;; esac
}

# ---------- 1. Omnibus / source install ----------
hdr "GitLab installs on this host"
MAN="$R/opt/gitlab/version-manifest.txt"
if [ -r "$MAN" ]; then
  assess "Omnibus ($MAN)" "$(head -n1 "$MAN")"
elif [ -r "$R/opt/gitlab/embedded/service/gitlab-rails/VERSION" ]; then
  assess "Omnibus (gitlab-rails/VERSION)" "$(cat "$R/opt/gitlab/embedded/service/gitlab-rails/VERSION")"
elif [ -r "$R/home/git/gitlab/VERSION" ]; then
  assess "Source install (/home/git/gitlab/VERSION)" "$(cat "$R/home/git/gitlab/VERSION")"
elif [ "${USE_RAKE:-0}" = 1 ] && command -v gitlab-rake >/dev/null 2>&1; then
  assess "gitlab-rake gitlab:env:info" "$(timeout 300 gitlab-rake gitlab:env:info 2>/dev/null | awk '/^GitLab information/{f=1} f&&/^Version:/{print $2; exit}')"
elif [ -e "$R/opt/gitlab" ]; then
  assess "Omnibus (/opt/gitlab present, version unreadable)" ""
else
  say "No Omnibus or source GitLab install found on the host filesystem."
fi

# ---------- 2. Docker ----------
DOCKER=""
if [ "${CHECK_DOCKER:-1}" = 1 ] && [ -z "$R" ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then DOCKER=docker; fi
if [ -n "$DOCKER" ]; then
  hdr "Docker containers and images"
  while read -r cid img; do
    [ -n "$cid" ] || continue
    m=$(docker exec "$cid" head -n1 /opt/gitlab/version-manifest.txt 2>/dev/null)
    [ -z "$m" ] && m=$(printf '%s' "$img" | sed -n 's/.*gitlab-\(c\|e\)e:\(.*\)/gitlab-\1e \2/p')
    assess "container $cid ($img)" "$m"
  done < <(docker ps --format '{{.ID}} {{.Image}}' 2>/dev/null | grep -Ei 'gitlab/gitlab-(ce|ee)|gitlab-(ce|ee)')
  imgs=$(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -Ei 'gitlab/gitlab-(ce|ee):')
  [ -n "$imgs" ] && say "Local GitLab images (informational; remove or upgrade any below the fix):" && printf '%s\n' "$imgs" | while read -r i; do
    t=${i##*:}; v=$(printf '%s' "$t" | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+')
    if [ -n "$v" ]; then say "  $i -> $(verdict_110264 "$v")"; else say "  $i -> tag not versioned (latest/nightly): check manually"; fi
  done
elif [ "${CHECK_DOCKER:-1}" = 1 ] && [ -z "$R" ] && command -v docker >/dev/null 2>&1; then
  say "(docker present but not accessible; run as root or a docker-group user to include containers)"
fi

# ---------- 3. CVE-2026-15723 configuration ----------
hdr "CVE-2026-15723 configuration (gitlab.rb)"
RB="$R/etc/gitlab/gitlab.rb"
if [ -r "$RB" ]; then
  act=$(grep -Ev '^[[:space:]]*#' "$RB")
  ldap=no; saml=no; autolink=no
  printf '%s\n' "$act" | grep -Eq "ldap_enabled'?\]?[[:space:]]*=[[:space:]]*true" && ldap=yes
  printf '%s\n' "$act" | grep -Eq "omniauth_providers" && printf '%s\n' "$act" | grep -Eq "name[\"']?[[:space:]]*(=>|:)[[:space:]]*[\"':]+saml" && saml=yes
  printf '%s\n' "$act" | grep -Eq "omniauth_auto_link_ldap_user'?\]?[[:space:]]*=[[:space:]]*true" && autolink=yes
  say "LDAP enabled: $ldap | SAML OmniAuth provider: $saml | omniauth_auto_link_ldap_user: $autolink"
  if [ "$ldap$saml$autolink" = yesyesyes ]; then
    say "[!] SAML + LDAP auto-linking are both enabled: exposed to CVE-2026-15723 unless on 19.5.1 / 19.4.3 / 19.3.6 or later."
  else
    say "SAML + LDAP auto-link precondition not met in gitlab.rb (Helm/values or env overrides are not checked)."
  fi
else
  say "No readable /etc/gitlab/gitlab.rb (not Omnibus, not root, or config lives in Helm values)."
fi

# ---------- 4. Log hunt ----------
hdr "Log hunt (read-only)"
readlog() { case "$1" in *.gz) zcat "$1" 2>/dev/null | tail -n "$LOG_MAX_LINES" ;; *) tail -n "$LOG_MAX_LINES" "$1" 2>/dev/null ;; esac; }
L="$R/var/log/gitlab"
PAT_FRAME='org-ruby|headline\.rb|regexp_helper\.rb|regexp_matcher\.rb|match_at|onig_search_gpos|onig_compile|compile_tree|compile_length_tree|rb_str_sub_bang'
PAT_CRASH='\[BUG\]|Segmentation fault|Bus Error|SIGSEGV|SIGBUS'
any=0
for f in "$L"/puma/puma_stderr.log* "$L"/puma/current "$L"/sidekiq/current "$L"/sidekiq/*.s "$L"/gitlab-workhorse/current "$L"/gitlab-workhorse/*.s "$R"/home/git/gitlab/log/puma.stderr.log; do
  { [ -f "$f" ] && [ -r "$f" ]; } || continue
  any=1
  c=$(readlog "$f" | grep -Eci "$PAT_CRASH")
  o=$(readlog "$f" | grep -A25 -F '[BUG]' | grep -Eci "$PAT_FRAME")
  k=$(readlog "$f" | grep -Fc 'A worker terminated, shutting down the cluster')
  if [ "${c:-0}" -gt 0 ] || [ "${o:-0}" -gt 0 ] || [ "${k:-0}" -gt 0 ]; then
    SUSP=$((SUSP+1))
    say "[!] $f : crash markers=$c, Onigmo/org-ruby frames after [BUG]=$o, sidekiq cluster kills=$k"
    readlog "$f" | grep -Ei "$PAT_CRASH|$PAT_FRAME|A worker terminated" | tail -n 5 | sed 's/^/      /'
  fi
done
for f in "$L"/gitlab-rails/exceptions_json.log*; do
  { [ -f "$f" ] && [ -r "$f" ]; } || continue
  any=1
  t=$(readlog "$f" | grep -F 'Timeout::Error' | grep -F 'OtherMarkup' | grep -Ec '"extra.file_name":"[^"]*\.org"')
  [ "${t:-0}" -gt 0 ] && { SUSP=$((SUSP+1)); say "[!] $f : Org-mode render timeouts (OtherMarkup on .org) = $t (GitLab TLDR 'Org mode render error burst' signal)"; }
done
for f in "$L"/gitlab-rails/production_json.log* "$L"/gitlab-rails/api_json.log*; do
  { [ -f "$f" ] && [ -r "$f" ]; } || continue
  any=1
  a=$(readlog "$f" | grep -E '"controller":"Admin::UsersController"|"meta.caller_id":"(POST /api/:version/users|PUT /api/:version/users/:id)"' | grep -Ec '"status":(201|200|302)')
  [ "${a:-0}" -gt 0 ] && say "[i] $f : $a user create/update requests via Admin UI or users API - review for new admins (informational)"
done
[ "$any" = 0 ] && say "No readable GitLab logs under $L."
# kernel segfaults of ruby/puma/sidekiq
k=""
if [ -z "$R" ] && command -v journalctl >/dev/null 2>&1; then
  k=$(journalctl -k --no-pager -q --since "-14 days" 2>/dev/null | grep -Ei 'segfault' | grep -Ei 'ruby|puma|sidekiq|workhorse' | tail -n 5)
fi
for f in "$R"/var/log/kern.log "$R"/var/log/messages "$R"/var/log/syslog; do
  [ -r "$f" ] && k="$k$(readlog "$f" | grep -Ei 'segfault' | grep -Ei 'ruby|puma|sidekiq|workhorse' | tail -n 5)"
done
[ -n "$k" ] && { SUSP=$((SUSP+1)); say "[!] kernel segfaults of GitLab processes:"; printf '%s\n' "$k" | sed 's/^/      /'; }
[ "$SUSP" -eq 0 ] && say "No crash, Onigmo/org-ruby or Org-render-timeout entries found. Absence of log entries does NOT prove no exploitation (GitLab: a successful exploit can be quiet and not crash)."

# ---------- 5. summary ----------
hdr "Summary"
say "GitLab instances: $FOUND | PATCHED: $PATCHED | LIKELY VULNERABLE: $VULN | UNKNOWN: $UNK | log findings: $SUSP"
if [ "$VULN" -gt 0 ]; then say "OVERALL: LIKELY VULNERABLE - upgrade now (19.5.1 / 19.4.3 / 19.3.6 / 19.2.9 / 19.1.9 / 19.0.10 / 18.11.13). There is no workaround."; exit 1
elif [ "$UNK" -gt 0 ]; then say "OVERALL: UNKNOWN - confirm version manually (Admin > Help, or /help)"; exit 2
elif [ "$SUSP" -gt 0 ]; then say "OVERALL: PATCHED, but review the log findings above"; exit 3
elif [ "$FOUND" -eq 0 ]; then say "OVERALL: PATCHED (no GitLab found)"; exit 0
else say "OVERALL: PATCHED"; exit 0; fi
