#!/usr/bin/env bash
# php2shell-check.sh
# Prüft installierte PHP-Versionen (Debian/Ubuntu) auf CVE-2026-109618 / CVE-2026-109619,
# patcht nur, wenn das Update die Lücke tatsächlich schließt, und bietet sonst
# Dienste stoppen / Shutdown / Weiterlaufen an.
#
# Quellen der Fix-Versionen: USN-8948-1/-2, DSA-6574-1, DLA-4848-1, php.net (21.10.2026)
#
# WICHTIG: Vor dem Patchen möglichst einen VM-Snapshot oder ein Backup anlegen.
#   - Cloud/VM:   Snapshot in der Provider-Konsole (vor dem Start dieses Scripts)
#   - LVM:        lvcreate --snapshot ...
#   - sonst:      mindestens /etc und laufende Konfiguration sichern
# Das Script fragt vor jedem systemverändernden Schritt nach (Upgrade, Neustart,
# Dienste stoppen, Shutdown).

set -uo pipefail
export LC_ALL=C   # apt-/dpkg-Ausgaben unabhängig von der Systemsprache parsen

CVE_A="CVE-2026-109618"
CVE_B="CVE-2026-109619"
LOG="/var/log/php2shell-check-$(date +%Y%m%d-%H%M%S).log"

# Upstream-Releases mit Fix (gilt für jedes Paket, das diese Upstream-Version enthält, z.B. sury)
declare -A UPSTREAM_FIX=( [8.2]=8.2.35 [8.3]=8.3.36 [8.4]=8.4.27 [8.5]=8.5.12 )

# Distro-Backports (Fix in alter Upstream-Version eingebaut).
# [VERIFIZIERT] = gegen USN-8948-1/-2 abgeglichen (21.10.2026).
# [UNGEPRÜFT]   = Version noch nicht gegen offizielle Advisory verifiziert -> vor Verlass
#                 auf diesen Eintrag mit `apt-cache policy <pkg>` bzw. der USN/DSA prüfen.
# Sicherheitsnetz: Sagt die Tabelle "no" oder greift sie nicht, wird als verwundbar
# behandelt und zusätzlich das Changelog nach beiden CVE-IDs durchsucht (siehe unten).
declare -A DISTRO_FIX=(
  [ubuntu:trusty:php5]="5.5.9+dfsg-1ubuntu4.29+esm18"        # [UNGEPRÜFT]
  [ubuntu:xenial:php7.0]="7.0.33-0ubuntu0.16.04.16+esm22"    # [UNGEPRÜFT]
  [ubuntu:bionic:php7.2]="7.2.24-0ubuntu0.18.04.17+esm14"    # [UNGEPRÜFT]
  [ubuntu:focal:php7.4]="7.4.3-4ubuntu2.29+esm5"             # [UNGEPRÜFT]
  [ubuntu:jammy:php8.1]="8.1.2-1ubuntu2.28"                  # [VERIFIZIERT] USN-8948-1
  [ubuntu:noble:php8.3]="8.3.6-0ubuntu0.24.04.13"           # [VERIFIZIERT] USN-8948-1
  [ubuntu:resolute:php8.5]="8.5.4-0ubuntu1.5"               # [UNGEPRÜFT]
  [debian:trixie:php8.4]="8.4.27-1~deb13u1"                 # [UNGEPRÜFT]
  [debian:bookworm:php8.2]="8.2.35-1~deb12u1"               # [UNGEPRÜFT]
)

# ---------------------------------------------------------------- Hilfsfunktionen
log()    { echo "$*" | tee -a "$LOG"; }
# vlog(): ausführliche Detailausgabe (immer an; eine Stelle, falls man es später dimmen will)
vlog()   { echo "    $*" | tee -a "$LOG"; }
rule()   { log "------------------------------------------------------------------------"; }
ver_ge() { dpkg --compare-versions "$1" ge "$2"; }
ver_gt() { dpkg --compare-versions "$1" gt "$2"; }
ver_lt() { dpkg --compare-versions "$1" lt "$2"; }

# Upstream-Anteil einer Paketversion: Epoch und Debian-Revision entfernen
upstream_of() { local v="${1#*:}"; echo "${v%-*}"; }

ask_yes() {
  local ans
  read -r -p "$1 [j/N] " ans
  [[ "$ans" =~ ^([jJyY]|ja|Ja|JA)$ ]]
}

# yes / no / unknown anhand der Versionstabellen
check_version() {
  local src="$1" ver="$2" branch thr key fix up
  branch="${src#php}"
  up="$(upstream_of "$ver")"
  thr="${UPSTREAM_FIX[$branch]:-}"
  if [[ -n "$thr" ]] && ver_ge "$up" "$thr"; then echo yes; return; fi
  key="${DISTRO_ID}:${CODENAME}:${src}"
  fix="${DISTRO_FIX[$key]:-}"
  # Tabelle nur anwenden, wenn es ein Build derselben Upstream-Version ist (schließt Fremd-Repos aus)
  if [[ -n "$fix" && "$up" == "$(upstream_of "$fix")" ]]; then
    if ver_ge "$ver" "$fix"; then echo yes; else echo no; fi
    return
  fi
  echo unknown
}

# Fallback: Changelog des installierten Pakets nach beiden CVE-IDs durchsuchen
changelog_installed() {
  local pkg="$1" f
  for f in "/usr/share/doc/$pkg/changelog.Debian.gz" "/usr/share/doc/$pkg/changelog.Debian"; do
    if [[ -r "$f" ]]; then
      if zgrep -q "$CVE_A" "$f" && zgrep -q "$CVE_B" "$f"; then echo yes; else echo no; fi
      return
    fi
  done
  echo unknown
}

# Fallback: Changelog der Kandidaten-Version online abrufen
changelog_candidate() {
  local pkg="$1" ver="$2" txt
  txt="$(timeout 60 apt-get changelog -q "${pkg}=${ver}" 2>/dev/null)" || { echo unknown; return; }
  [[ -z "$txt" ]] && { echo unknown; return; }
  if grep -q "$CVE_A" <<<"$txt" && grep -q "$CVE_B" <<<"$txt"; then echo yes; else echo no; fi
}

status_of() { # src pkg version installed|candidate
  local r
  r="$(check_version "$1" "$3")"
  if [[ "$r" == unknown ]]; then
    if [[ "$4" == installed ]]; then r="$(changelog_installed "$2")"
    else r="$(changelog_candidate "$2" "$3")"; fi
  fi
  echo "$r"
}

running_php_units() {
  if [[ -d /run/systemd/system ]]; then
    systemctl list-units --type=service --state=running --no-legend --plain \
      'php*-fpm.service' 'apache2.service' 2>/dev/null | awk '{print $1}'
  else
    local s
    for s in php5-fpm apache2; do
      service "$s" status >/dev/null 2>&1 && echo "$s"
    done
  fi
}

svc() { # action unit
  if [[ -d /run/systemd/system ]]; then systemctl "$1" "$2"; else service "${2%.service}" "$1"; fi
}

# ---------------------------------------------------------------- Vorbedingungen
if [[ $EUID -ne 0 ]]; then
  echo "Bitte als root ausführen (sudo $0)." >&2
  exit 1
fi

if [[ ! -r /etc/os-release ]]; then
  echo "/etc/os-release fehlt, Distro nicht erkennbar." >&2
  exit 1
fi
# shellcheck disable=SC1091
. /etc/os-release
DISTRO_ID="${ID:-}"
CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
if [[ -z "$CODENAME" ]] && command -v lsb_release >/dev/null 2>&1; then
  CODENAME="$(lsb_release -cs 2>/dev/null)"
fi

case "$DISTRO_ID" in
  debian|ubuntu) ;;
  *)
    if [[ "${ID_LIKE:-}" == *debian* || "${ID_LIKE:-}" == *ubuntu* ]]; then
      echo "Hinweis: Derivat ($DISTRO_ID). Distro-Tabelle greift nicht, Prüfung über Upstream-Version und Changelog."
    else
      echo "Nur Debian/Ubuntu (und Derivate) werden unterstützt." >&2
      exit 1
    fi
    ;;
esac

: > "$LOG" || { echo "Kann $LOG nicht schreiben." >&2; exit 1; }

# ---------------------------------------------------------------- Start / Backup-Hinweis
rule
log "php2shell-Check $(date -Is) auf $(hostname) - ${PRETTY_NAME:-$DISTRO_ID} (${CODENAME:-unbekannt})"
log "Geprüfte Lücken: $CVE_A, $CVE_B (kritisch, unauth. RCE)"
log "Logdatei: $LOG"
rule
log ""
log "ABLAUF:"
log "  1. Paketlisten aktualisieren (apt-get update)   [nur Lesen, verändert kein PHP]"
log "  2. Installierte PHP-Versionen prüfen             [nur Lesen]"
log "  3. Falls patchbar: Upgrade                       [FRAGT VORHER NACH]"
log "  4. PHP-Dienste neu starten                       [FRAGT VORHER NACH]"
log "  5. Rest-Lücken: Dienste stoppen / Shutdown       [FRAGT VORHER NACH]"
log ""
log "BACKUP-/SNAPSHOT-HINWEIS:"
log "  Lege VOR dem Patchen möglichst einen Snapshot bzw. ein Backup an:"
log "    - Cloud/VM : Snapshot in der Provider-Konsole"
log "    - LVM      : lvcreate --snapshot ..."
log "    - sonst    : mindestens /etc sichern"
log "  Ein PHP-Upgrade ist in der Regel unkritisch, aber ein Snapshot erlaubt"
log "  sofortigen Rollback, falls eine Anwendung nach dem Update zickt."
log ""
if ! ask_yes "Verstanden. Prüfung jetzt starten (apt-get update + Analyse)?"; then
  log "Abgebrochen auf Wunsch des Nutzers. Es wurde nichts verändert. Log: $LOG"
  exit 0
fi

# ---------------------------------------------------------------- 1. Paketlisten
rule
log "[1/5] Aktualisiere Paketlisten (apt-get update) ..."
if ! apt-get update -qq >>"$LOG" 2>&1; then
  log "FEHLER: apt-get update fehlgeschlagen. Ohne aktuelle Paketlisten keine verlässliche"
  log "        Aussage möglich. Abbruch (es wurde nichts verändert)."
  exit 1
fi
vlog "Paketlisten aktuell."

# ---------------------------------------------------------------- 2. Bestandsaufnahme
rule
log "[2/5] Nehme installierte PHP-Pakete auf ..."
declare -A SRC_PKGS=()
while IFS='|' read -r pkg src status; do
  [[ "$status" == *" installed" ]] || continue
  src="${src%% *}"
  [[ -z "$src" ]] && src="$pkg"
  [[ "$src" =~ ^php[0-9]+(\.[0-9]+)?$ ]] || continue
  SRC_PKGS[$src]+="$pkg "
done < <(dpkg-query -W -f='${Package}|${Source}|${Status}\n' 2>/dev/null)

PATCHABLE=()   # Quellpakete, die per Update gefixt werden
VULN=()        # Verwundbar ohne Fix über apt
OKLIST=()

if [[ ${#SRC_PKGS[@]} -eq 0 ]]; then
  log "Keine PHP-Interpreter-Pakete über dpkg installiert."
else
  vlog "Gefundene PHP-Quellpakete: ${!SRC_PKGS[*]}"
fi

printf '\n%-8s %-34s %-34s %s\n' "PAKET" "INSTALLIERT" "KANDIDAT" "ERGEBNIS" | tee -a "$LOG"
for src in $(printf '%s\n' "${!SRC_PKGS[@]}" | sort -V); do
  minv=""; minpkg=""; minc=""; mincpkg=""
  for pkg in ${SRC_PKGS[$src]}; do
    v="$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null)"
    c="$(apt-cache policy "$pkg" 2>/dev/null | awk '/Candidate:/{print $2; exit}')"
    [[ -z "$c" || "$c" == "(none)" ]] && c="$v"
    if [[ -z "$minv" ]] || ver_lt "$v" "$minv"; then minv="$v"; minpkg="$pkg"; fi
    if [[ -z "$minc" ]] || ver_lt "$c" "$minc"; then minc="$c"; mincpkg="$pkg"; fi
  done

  inst="$(status_of "$src" "$minpkg" "$minv" installed)"
  if [[ "$minc" == "$minv" ]]; then
    cand="$inst"
  else
    cand="$(status_of "$src" "$mincpkg" "$minc" candidate)"
  fi

  if [[ "$inst" == yes ]]; then
    res="OK, gepatcht"; OKLIST+=("$src")
  elif [[ "$cand" == yes ]] && ver_gt "$minc" "$minv"; then
    res="VERWUNDBAR, Update behebt die Lücke"; PATCHABLE+=("$src")
  elif [[ "$inst" == unknown && "$cand" == unknown ]]; then
    res="UNBEKANNT, als verwundbar behandelt"; VULN+=("$src")
  else
    res="VERWUNDBAR, kein Fix über apt verfügbar"; VULN+=("$src")
  fi
  printf '%-8s %-34s %-34s %s\n' "$src" "$minv" "$minc" "$res" | tee -a "$LOG"
  vlog "Detail $src: installiert=$inst, kandidat=$cand (Pakete: ${SRC_PKGS[$src]})"
done

# PHP-Binaries, die nicht aus einem Paket stammen (z.B. selbst kompiliert)
declare -A SEEN=()
FOREIGN_VULN=0
while read -r cmd; do
  while read -r p; do
    real="$(readlink -f "$p")"
    [[ -n "${SEEN[$real]:-}" ]] && continue
    SEEN[$real]=1
    dpkg -S "$real" >/dev/null 2>&1 && continue
    pv="$("$real" -r 'echo PHP_VERSION;' 2>/dev/null)" || continue
    br="$(echo "$pv" | cut -d. -f1,2)"
    thr="${UPSTREAM_FIX[$br]:-}"
    if [[ -n "$thr" ]] && ver_ge "$pv" "$thr"; then
      printf '%-8s %-34s %-34s %s\n' "fremd" "$real ($pv)" "-" "OK, gepatcht" | tee -a "$LOG"
    else
      printf '%-8s %-34s %-34s %s\n' "fremd" "$real ($pv)" "-" "VERWUNDBAR, nicht über apt patchbar" | tee -a "$LOG"
      FOREIGN_VULN=1
    fi
  done < <(type -ap "$cmd" 2>/dev/null)
done < <(compgen -c | grep -E '^php([0-9]+(\.[0-9]+)?)?$' | sort -u)
[[ $FOREIGN_VULN -eq 1 ]] && VULN+=("fremd")
echo | tee -a "$LOG"

# ---------------------------------------------------------------- 3. Update
rule
if [[ ${#PATCHABLE[@]} -gt 0 ]]; then
  log "[3/5] Patchbar über Update: ${PATCHABLE[*]}"
  log ""
  log "ERINNERUNG: Jetzt wäre der richtige Zeitpunkt für einen Snapshot/Backup,"
  log "            falls noch nicht geschehen."
  log "Das Upgrade ändert installierte Pakete und startet anschließend (nach Rückfrage)"
  log "die PHP-Dienste neu."
  if ask_yes "Kritischer Schritt: Update jetzt durchführen?"; then
    pkgs=()
    for src in "${PATCHABLE[@]}"; do
      # shellcheck disable=SC2206
      pkgs+=( ${SRC_PKGS[$src]} )
    done

    log "Betroffene Pakete: ${pkgs[*]}"
    log "Versionen vor dem Update (für Rollback):"
    dpkg-query -W -f='  ${Package} ${Version}\n' "${pkgs[@]}" | tee -a "$LOG"

    log "Simuliere Update (apt-get -s, verändert noch nichts) ..."
    sim="$(apt-get -s install --only-upgrade "${pkgs[@]}" 2>&1)"
    sim_rc=$?
    if [[ $sim_rc -ne 0 ]]; then
      log "FEHLER: Simulation fehlgeschlagen, kein Update durchgeführt:"
      echo "$sim" | tee -a "$LOG"
      VULN+=("${PATCHABLE[@]}")
    elif grep -q '^Remv ' <<<"$sim"; then
      log "ABBRUCH: Das Update würde Pakete ENTFERNEN (potenziell gefährlich):"
      grep '^Remv ' <<<"$sim" | tee -a "$LOG"
      log "Es wurde nichts verändert. Bitte manuell prüfen."
      VULN+=("${PATCHABLE[@]}")
    else
      log "Simulation ok, keine Paket-Entfernungen. Geplante Änderungen:"
      grep -E '^(Inst|Conf) ' <<<"$sim" | sed 's/^/    /' | tee -a "$LOG"
      if ask_yes "Kritischer Schritt: Diese Änderungen wirklich installieren?"; then
        DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade \
          -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold \
          "${pkgs[@]}" 2>&1 | tee -a "$LOG"
        if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
          log "FEHLER: Update fehlgeschlagen."
          VULN+=("${PATCHABLE[@]}")
        else
          # Dienste neu starten, damit keine alten Worker weiterlaufen
          mapfile -t units < <(running_php_units)
          log ""
          if [[ ${#units[@]} -eq 0 ]]; then
            log "Keine laufenden php-fpm/apache2-Dienste gefunden, kein Neustart nötig."
          else
            log "Laufende PHP-Dienste: ${units[*]}"
            log "Ohne Neustart laufen alte, verwundbare PHP-Worker im Speicher weiter."
            if ask_yes "Kritischer Schritt: Diese Dienste jetzt neu starten?"; then
              for u in "${units[@]}"; do
                log "Starte neu: $u"
                svc restart "$u" 2>&1 | tee -a "$LOG"
              done
            else
              log "WARNUNG: Neustart übersprungen. Alte PHP-Worker bleiben bis zum nächsten"
              log "         Neustart/Reboot verwundbar. Bitte zeitnah manuell neu starten."
            fi
          fi

          # Ergebnis verifizieren - ALLE Binärpakete der Quelle, nicht nur das erste
          log ""
          log "Verifiziere installierte Versionen ..."
          for src in "${PATCHABLE[@]}"; do
            allok=1
            for pkg in ${SRC_PKGS[$src]}; do
              nv="$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null)"
              st="$(status_of "$src" "$pkg" "$nv" installed)"
              vlog "$pkg -> $nv : $st"
              [[ "$st" == yes ]] || allok=0
            done
            if [[ $allok -eq 1 ]]; then
              log "Verifiziert: $src ist jetzt gepatcht."
            else
              log "WARNUNG: $src nach Update NICHT (vollständig) als gepatcht erkannt."
              VULN+=("$src")
            fi
          done

          # Prozesse, die noch alte PHP-Binaries/Libs im Speicher haben
          stale=""
          for d in /proc/[0-9]*; do
            exe="$(readlink "$d/exe" 2>/dev/null)" || continue
            if [[ "$exe" == *php*"(deleted)" ]] || grep -qs 'libphp.*(deleted)' "$d/maps"; then
              stale+="  PID ${d#/proc/}: $exe"$'\n'
            fi
          done
          if [[ -n "$stale" ]]; then
            log ""
            log "Diese Prozesse nutzen noch die ALTE PHP-Version (neu starten oder rebooten):"
            printf '%s' "$stale" | tee -a "$LOG"
          fi
        fi
      else
        log "Installation abgelehnt. Es wurde nichts verändert."
        VULN+=("${PATCHABLE[@]}")
      fi
    fi
  else
    log "Update abgelehnt. Es wurde nichts verändert."
    VULN+=("${PATCHABLE[@]}")
  fi
else
  log "[3/5] Kein über apt patchbares verwundbares PHP-Paket gefunden."
fi

# VULN deduplizieren (kann durch mehrfaches Anhängen Duplikate enthalten)
if [[ ${#VULN[@]} -gt 0 ]]; then
  mapfile -t VULN < <(printf '%s\n' "${VULN[@]}" | awk 'NF && !seen[$0]++')
fi

# ---------------------------------------------------------------- 4./5. Verbleibende Lücken
rule
if [[ ${#VULN[@]} -eq 0 ]]; then
  log "[OK] Alle über dpkg/PATH gefundenen PHP-Installationen sind gepatcht."
  log "Nicht geprüft: PHP in Docker-Containern (separat patchen). Log: $LOG"
  exit 0
fi

log "[4/5] Weiterhin verwundbar: ${VULN[*]}"
log "Für diese Installationen gibt es keinen (funktionierenden) apt-Fix auf diesem System."
echo "Optionen:"
echo "  1) PHP-Dienste stoppen (php*-fpm, apache2; startet nach Reboot wieder)"
echo "  2) System herunterfahren"
echo "  3) Auf eigene Gefahr weiterlaufen lassen"
read -r -p "Auswahl [1-3]: " choice
case "$choice" in
  1)
    mapfile -t units < <(running_php_units)
    if [[ ${#units[@]} -eq 0 ]]; then
      log "Keine laufenden php-fpm/apache2-Dienste gefunden."
    else
      log "Laufend: ${units[*]}"
      log "Achtung: apache2 zu stoppen beendet auch alle Nicht-PHP-Seiten auf diesem Apache."
      if ask_yes "Kritischer Schritt: Diese Dienste stoppen?"; then
        for u in "${units[@]}"; do
          log "Stoppe: $u"
          svc stop "$u" 2>&1 | tee -a "$LOG"
        done
      else
        log "Abgebrochen, Dienste laufen weiter."
      fi
    fi
    ;;
  2)
    log "[5/5] Shutdown gewählt."
    log "Achtung: Bei einem Remote-Server ist der Rechner danach nur über die"
    log "         Provider-Konsole wieder startbar."
    if ask_yes "Kritischer Schritt: System JETZT herunterfahren?"; then
      log "Fahre herunter."
      shutdown -h now
    else
      log "Shutdown abgebrochen."
    fi
    ;;
  *)
    log "Läuft auf eigene Gefahr weiter."
    ;;
esac
log "Nicht geprüft: PHP in Docker-Containern (separat patchen). Log: $LOG"
