#!/bin/sh
# Copyright (C) 2026 Christian Wirtz <doc@snowheaven.de>
# License: GPL-2.0-only, see LICENSE in the repository root.
#
# Checkmk agent plug-in: php2shell
#
# Collects the facts the "PHP2Shell" check plug-in (MKP) needs to decide whether the PHP
# installations on this host are affected by CVE-2026-109618 / CVE-2026-109619
# ("PHP2Shell"). It only reports facts - which versions are fixed and which state that
# means is decided on the Checkmk server, so a new fix only needs an updated .mkp, not a
# new agent plug-in on every host.
#
# Output, "|"-separated, unknown values are "?":
#
#     <<<php2shell:sep(124)>>>
#     plugin|<plug-in version>
#     php|<path>|<sapi>|<version>|<package>|<package version>|<CVEs>|<ini values>
#     stale|<pid>|<process name>|<deleted path>
#
# <ini values> are session.upload_progress.enabled|memory_limit|fatal_error_backtraces from
# this SAPI's php.ini ("-" if the directive does not exist). <CVEs> are the PHP2Shell CVE
# IDs named in the package changelog (comma-separated, empty if none, "?" if there is no
# readable changelog). "stale" lines are host processes that
# still have a deleted - i.e. replaced by an update - PHP binary or libphp mapped.
#
# Installation: copy this file to the agent plug-in directory of the monitored host with
# a cache interval, e.g. /usr/lib/check_mk_agent/plugins/3600/php2shell, and make it
# executable, or deploy it via the agent bakery rule "PHP2Shell agent plug-in". It runs
# every PHP binary twice and reads the memory map of every process, so do not run it on
# every agent query.

CMK_VERSION="1.1.2"

# Every CVE ID the server-side check knows about; only these are taken from changelogs.
CVE_PATTERN='CVE-2026-(109618|109619|115560)'

LC_ALL=C
export LC_ALL

TIMEOUT=""
command -v timeout >/dev/null 2>&1 && TIMEOUT="timeout 15"

# "<pid> <path>[ (deleted)]" for every PHP binary or libphp mapped by a host process.
# Processes in containers are skipped: their paths refer to the container's own root
# file system and would be matched against unrelated host files of the same name. They
# are recognized by a different root directory (device:inode) - not by readlink, which
# shows "/" for them too, and not by the mount namespace, which services with
# PrivateTmp= (e.g. php-fpm.service) have of their own.
mapped_php_objects() {
    host_root=$(stat -L -c %d:%i / 2>/dev/null)
    for maps in /proc/[0-9]*/maps; do
        pid=${maps#/proc/}
        pid=${pid%/maps}
        root=$(stat -L -c %d:%i "/proc/$pid/root/" 2>/dev/null)
        [ -z "$root" ] || [ "$root" = "$host_root" ] || continue
        grep -oE '/[^ ]*/(php|libphp|lsphp|mod_php)[^/ ]*( \(deleted\))?$' "$maps" 2>/dev/null \
            | sort -u | sed "s|^|$pid |"
    done
}

candidates() {
    for p in \
        /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php /usr/local/bin/php[0-9]* \
        /usr/bin/php-cgi* /usr/local/bin/php-cgi* \
        /usr/sbin/php-fpm* /usr/local/sbin/php-fpm* \
        /opt/remi/php*/root/usr/bin/php /opt/remi/php*/root/usr/bin/php-cgi \
        /opt/remi/php*/root/usr/sbin/php-fpm \
        /opt/plesk/php/*/bin/php /opt/plesk/php/*/bin/php-cgi /opt/plesk/php/*/sbin/php-fpm \
        /opt/cpanel/ea-php*/root/usr/bin/php /opt/cpanel/ea-php*/root/usr/bin/php-cgi \
        /opt/cpanel/ea-php*/root/usr/sbin/php-fpm \
        /usr/local/lsws/lsphp*/bin/lsphp /usr/local/lsws/fcgi-bin/lsphp* \
        /usr/lib/apache2/modules/libphp*.so /usr/lib64/apache2/mod_php*.so \
        /usr/lib/httpd/modules/libphp*.so /usr/lib64/httpd/modules/libphp*.so \
        /usr/lib/libphp*.so /usr/lib64/libphp*.so /usr/local/lib/libphp*.so \
        /usr/bin/frankenphp /usr/local/bin/frankenphp
    do
        [ -f "$p" ] || continue
        f=$(readlink -f "$p")
        # Only ELF binaries and libraries: names like /usr/bin/php74-phar (Remi) also match
        # the patterns but are PHP scripts, and this plug-in must never run PHP code.
        [ "$(dd if="$f" bs=1 skip=1 count=3 2>/dev/null)" = "ELF" ] && echo "$f"
    done
    # libphp from a non-standard location: whatever libphp a running process has loaded.
    printf '%s\n' "$MAPPED" | sed -nE 's/^[0-9]+ (.*\/(libphp|mod_php)[^/]*\.so)$/\1/p'
}

# 0 = a running Apache (apache2, httpd) has the library $1 loaded.
loaded_by_apache() {
    printf '%s\n' "$MAPPED" | while read -r pid path; do
        [ "$path" = "$1" ] || continue
        case "$(cat "/proc/$pid/comm" 2>/dev/null)" in
            apache2 | httpd*) echo yes ;;
        esac
    done | grep -q yes
}

sapi_of() {
    case "${1##*/}" in
        php-fpm*) echo fpm ;;
        php-cgi*) echo cgi ;;
        lsphp*) echo litespeed ;;
        frankenphp*) echo frankenphp ;;
        mod_php*) echo apache2handler ;;
        libphp*)
            # The same library name is mod_php for Apache and the embed SAPI that NGINX Unit
            # (unit-php) and FrankenPHP load - told apart by location or by who loaded it.
            case "$1" in
                */apache2/* | */httpd/*) echo apache2handler ;;
                *) if loaded_by_apache "$1"; then echo apache2handler; else echo embed; fi ;;
            esac
            ;;
        *) echo cli ;;
    esac
}

# Libraries and FrankenPHP are never executed: a library cannot be, and FrankenPHP is a
# web server that does not know the PHP command line options.
not_executable() {
    case "$1" in
        apache2handler | embed | frankenphp) return 0 ;;
    esac
    return 1
}

# PHP version (x.y.z) of binary/library $1 with SAPI $2.
php_version() {
    v=""
    if ! not_executable "$2"; then
        v=$($TIMEOUT "$1" -v 2>/dev/null \
            | sed -n 's/^PHP \([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1)
    fi
    # Fallback, and the only way for a library or FrankenPHP: every SAPI carries its version
    # in the compiled-in "X-Powered-By: PHP/<version>" header string.
    if [ -z "$v" ]; then
        v=$(grep -aoE 'X-Powered-By: PHP/[0-9]+\.[0-9]+\.[0-9]+' "$1" 2>/dev/null \
            | head -n 1 | sed 's|.*/||')
    fi
    echo "${v:-?}"
}

# Local value of ini directive $1 from the phpinfo() output in $INFO. Works for both the
# text ("name => local => master") and the HTML table output (php-cgi). "-" means the
# directive does not exist (extension not loaded, or older PHP), "?" that PHP could not
# be asked at all.
ini_value() {
    [ -n "$INFO" ] || { echo "?"; return; }
    v=$(printf '%s\n' "$INFO" | sed -n "s/<[^>]*>/ /g; s/=>/ /g; s/^ *$1  *\([^ ]*\).*/\1/p" \
        | head -n 1)
    echo "${v:--}"
}

# Sets PKG, PKG_VERSION and CVES for the file $1.
package_facts() {
    PKG="?"
    PKG_VERSION="?"
    CVES="?"
    if command -v dpkg-query >/dev/null 2>&1; then
        # "php8.3-cli: /usr/bin/php8.3" or "libapache2-mod-php8.3:amd64: /usr/lib/..."
        p=$(dpkg-query -S "$1" 2>/dev/null | sed -n 's/^\([^:, ]*\)[^ ]*: \/.*/\1/p' | head -n 1)
        if [ -n "$p" ]; then
            PKG=$p
            PKG_VERSION=$(dpkg-query -W -f='${Version}' "$p" 2>/dev/null | head -n 1)
            # Debian derivatives often drop /usr/share/doc (minimal and container images).
            if [ -r "/usr/share/doc/$p/changelog.Debian.gz" ]; then
                CVES=$(gzip -dc "/usr/share/doc/$p/changelog.Debian.gz" 2>/dev/null | cve_list)
            fi
            return
        fi
    fi
    if command -v rpm >/dev/null 2>&1; then
        p=$(rpm -qf --qf '%{NAME}|%{VERSION}-%{RELEASE}\n' "$1" 2>/dev/null | head -n 1)
        case "$p" in
            *"|"*)
                PKG=${p%%|*}
                PKG_VERSION=${p#*|}
                CVES=$(rpm -q --changelog "$PKG" 2>/dev/null | cve_list)
                return
                ;;
        esac
    fi
    if command -v apk >/dev/null 2>&1; then
        # "/usr/bin/php83 is owned by php83-8.3.36-r0" - Alpine packages carry no changelog.
        p=$(apk info --who-owns "$1" 2>/dev/null | sed -n 's/.* is owned by //p' | head -n 1)
        [ -n "$p" ] && PKG=$p
    fi
}

cve_list() {
    grep -oE "$CVE_PATTERN" | sort -u | tr '\n' ',' | sed 's/,$//'
}

MAPPED=$(mapped_php_objects)

echo "<<<php2shell:sep(124)>>>"
echo "plugin|$CMK_VERSION"

for bin in $(candidates | grep -vE '/(php-config|phpize|phpdbg)[^/]*$' | sort -u); do
    sapi=$(sapi_of "$bin")
    version=$(php_version "$bin" "$sapi")
    # A dynamically linked FrankenPHP (official Docker image) runs the libphp.so it names,
    # which is reported on its own; only a static build carries its own PHP.
    [ "$sapi" = "frankenphp" ] && grep -aq 'libphp\.so' "$bin" && continue
    INFO=""
    # A library cannot be asked; its php.ini (and any php_admin_* override in the web
    # server configuration) stays unknown.
    not_executable "$sapi" || INFO=$($TIMEOUT "$bin" -i 2>/dev/null)
    package_facts "$bin"
    ini="$(ini_value session.upload_progress.enabled)|$(ini_value memory_limit)"
    ini="$ini|$(ini_value fatal_error_backtraces)"
    echo "php|$bin|$sapi|$version|$PKG|$PKG_VERSION|$CVES|$ini"
done

printf '%s\n' "$MAPPED" | sed -n 's/ (deleted)$//p' | while read -r pid path; do
    comm=$(tr -d '|' <"/proc/$pid/comm" 2>/dev/null)
    echo "stale|$pid|${comm:-?}|$path"
done

exit 0
