#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
php2shell_scan.py - PHP2Shell (CVE-2026-109618, CVE-2026-109619) exposure scanner.

Finds every PHP installation on a host, works out its version, decides whether it is
vulnerable, checks the interim mitigations and prints the update to apply.

Built for locked-down networks:
  * Python 3.6+ standard library only. No pip, no network access, no telemetry.
    (RHEL 8 without python3: run it with /usr/libexec/platform-python.)
  * Read-only. Nothing on the host is changed.
  * Discovered binaries are NOT executed unless you pass --exec. Versions come from the
    package manager or from the version string embedded in the binary.
  * Distribution backports are detected offline from local package changelogs.
  * The advisory data is embedded; override it with --advisory FILE.json for new releases.

Exit codes: 0 = nothing vulnerable found, 1 = vulnerable PHP found,
            2 = vulnerable PHP that serves web requests (act now), 4 = scanner error.

Usage:
  sudo python3 php2shell_scan.py                    # human-readable report
  sudo python3 php2shell_scan.py --json report.json # plus machine-readable output
  sudo python3 php2shell_scan.py --deep --containers
  python3 php2shell_scan.py --help
"""

from __future__ import print_function

import argparse
import fnmatch
import glob
import gzip
import io
import json
import os
import platform
import re
import shutil
import socket
import stat
import subprocess
import sys
import time

SCRIPT_VERSION = "1.0.0"

# ---------------------------------------------------------------------------------------------
# Advisory data (php.net security announcement and Huntress write-up, 21 October 2026).
# ---------------------------------------------------------------------------------------------
ADVISORY = {
    "id": "PHP2Shell",
    "published": "2026-10-21",
    "cves": ["CVE-2026-109618", "CVE-2026-109619"],
    "ghsa": ["GHSA-w8hw-w9fx-j79w", "GHSA-6wmf-4rv5-p3rp"],
    "fixed": {"8.5": "8.5.12", "8.4": "8.4.27", "8.3": "8.3.36", "8.2": "8.2.35"},
    # Branches older than this get no upstream fix (minimal source backports exist for 7.4-8.1).
    "oldest_supported": "8.2",
    "eol_notes": {"8.2": "PHP 8.2 reaches end of life on 2026-12-31."},
    "sources": [
        "https://www.php.net/index.php#2026-10-21-5",
        "https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities",
    ],
}

WEB_KINDS = ("fpm", "cgi", "apache-module", "litespeed", "frankenphp", "embedded-server")
SKIP_FS_TYPES = {
    "proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "pstore", "bpf",
    "tracefs", "debugfs", "securityfs", "configfs", "fusectl", "mqueue", "hugetlbfs",
    "autofs", "binfmt_misc", "rpc_pipefs", "nsfs", "efivarfs", "squashfs",
    # Network file systems: scanning them can be slow and hits other hosts' data.
    "nfs", "nfs4", "cifs", "smbfs", "smb3", "fuse.sshfs", "glusterfs", "ceph", "9p",
}
BINARY_NAME_RE = re.compile(
    r"^(php(-fpm|-cgi)?(\d+(\.\d+)?)?|php-fpm\d*(\.\d+)?|php\d*-(fpm|cgi)|lsphp\d*|frankenphp|"
    r"libphp[\d.]*\.so|libphp[\d.]*\.so\.[\d.]+|mod_php\d*\.so|php\.exe|php-cgi\.exe|php\d*ts\.dll)$",
    re.IGNORECASE,
)
SEARCH_GLOBS_UNIX = [
    "/usr/bin/php*", "/usr/sbin/php*", "/usr/local/bin/php*", "/usr/local/sbin/php*",
    "/usr/local/php*/bin/php*", "/usr/local/php*/sbin/php*", "/opt/*/bin/php*", "/opt/*/sbin/php*",
    "/opt/remi/php*/root/usr/bin/php*", "/opt/remi/php*/root/usr/sbin/php*",
    "/opt/rh/*php*/root/usr/bin/php*", "/opt/rh/*php*/root/usr/sbin/php*",
    "/opt/cpanel/ea-php*/root/usr/bin/php*", "/opt/cpanel/ea-php*/root/usr/sbin/php*",
    "/opt/plesk/php/*/bin/php*", "/opt/plesk/php/*/sbin/php*",
    "/usr/local/lsws/lsphp*/bin/lsphp*", "/usr/local/lsws/lsphp*/bin/php*",
    "/usr/local/bin/frankenphp", "/usr/bin/frankenphp",
    "/usr/lib/apache2/modules/libphp*.so", "/usr/lib64/httpd/modules/libphp*.so",
    "/usr/lib/httpd/modules/libphp*.so", "/etc/httpd/modules/libphp*.so",
    "/usr/local/apache*/modules/libphp*.so", "/opt/*/modules/libphp*.so",
    "/opt/bitnami/php/bin/php*", "/opt/lampp/bin/php*", "/Applications/MAMP/bin/php/*/bin/php*",
    "/opt/homebrew/bin/php*", "/opt/homebrew/Cellar/php*/*/bin/php*", "/opt/homebrew/Cellar/php*/*/sbin/php*",
    "/usr/local/Cellar/php*/*/bin/php*", "/usr/local/Cellar/php*/*/sbin/php*",
]
SEARCH_GLOBS_WINDOWS = [
    r"C:\php*\php*.exe", r"C:\Program Files\PHP*\**\php*.exe", r"C:\Program Files (x86)\PHP*\**\php*.exe",
    r"C:\xampp\php\php*.exe", r"C:\wamp*\bin\php\*\php*.exe", r"C:\laragon\bin\php\*\php*.exe",
    r"C:\inetpub\**\php*.exe", r"C:\tools\php*\php*.exe",
]
VERSION_STRING_RE = re.compile(rb"(?<![0-9A-Za-z.])([578])\.(\d{1,2})\.(\d{1,3})(?=[\x00\-+~a-zA-Z ])")


# ---------------------------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------------------------
def run(cmd, timeout=20, env=None):
    """Run a command, return (rc, stdout). Never raises; rc 127 means 'not available'."""
    try:
        p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, universal_newlines=True,
                           timeout=timeout, env=env)
        return p.returncode, p.stdout
    except (OSError, subprocess.SubprocessError, ValueError):
        return 127, ""


def which(name):
    return shutil.which(name)


def vtuple(v):
    parts = re.findall(r"\d+", v or "")
    return tuple(int(x) for x in parts[:3]) + (0,) * (3 - len(parts[:3]))


def branch_of(v):
    t = vtuple(v)
    return "{}.{}".format(t[0], t[1]) if t[0] else ""


def upstream_from_pkg_version(v):
    """'1:8.3.6-0ubuntu0.24.04.5' -> '8.3.6'; '8.4.22-1+clp-noble' -> '8.4.22'; '8.2.31' -> '8.2.31'."""
    if not v:
        return ""
    v = v.split(":", 1)[-1]
    m = re.match(r"(\d+\.\d+\.\d+)", v)
    return m.group(1) if m else ""


def is_root():
    return hasattr(os, "geteuid") and os.geteuid() == 0


def read_text(path, limit=4 * 1024 * 1024):
    try:
        opener = gzip.open if path.endswith(".gz") else io.open
        with opener(path, "rt", encoding="utf-8", errors="replace") as fh:
            return fh.read(limit)
    except (OSError, IOError, EOFError):
        return ""


def safe_realpath(p):
    try:
        return os.path.realpath(p)
    except OSError:
        return p


def human_list(items):
    return ", ".join(items) if items else "-"


# ---------------------------------------------------------------------------------------------
# Host facts
# ---------------------------------------------------------------------------------------------
def os_release():
    info = {"id": platform.system().lower(), "id_like": "", "version_id": "", "pretty": platform.platform()}
    text = read_text("/etc/os-release") or read_text("/usr/lib/os-release")
    for line in text.splitlines():
        if "=" in line:
            k, v = line.split("=", 1)
            v = v.strip().strip('"')
            if k == "ID":
                info["id"] = v
            elif k == "ID_LIKE":
                info["id_like"] = v
            elif k == "VERSION_ID":
                info["version_id"] = v
            elif k == "PRETTY_NAME":
                info["pretty"] = v
    return info


def detect_panels():
    panels = []
    checks = [
        ("CloudPanel", ["/home/clp", "/usr/bin/clpctl"]), ("cPanel", ["/usr/local/cpanel"]),
        ("Plesk", ["/usr/local/psa", "/opt/psa"]), ("DirectAdmin", ["/usr/local/directadmin"]),
        ("CyberPanel", ["/usr/local/CyberCP"]), ("ISPConfig", ["/usr/local/ispconfig"]),
        ("Virtualmin/Webmin", ["/etc/webmin"]), ("LiteSpeed", ["/usr/local/lsws"]),
    ]
    for name, paths in checks:
        if any(os.path.exists(p) for p in paths):
            panels.append(name)
    return panels


def mounts_to_skip():
    skip = set()
    for line in read_text("/proc/mounts").splitlines():
        parts = line.split()
        if len(parts) >= 3 and (parts[2] in SKIP_FS_TYPES or parts[2].startswith("fuse.")):
            skip.add(parts[1])
    return skip


# ---------------------------------------------------------------------------------------------
# Package managers
# ---------------------------------------------------------------------------------------------
PHP_PKG_RE = re.compile(
    r"^(php\d*(\.\d+)?|php\d*(\.\d+)?-(cli|fpm|cgi|common|litespeed)|libapache2-mod-php\d*(\.\d+)?|"
    r"php\d*-php-(cli|fpm|common|litespeed)|ea-php\d+-php-(cli|fpm|common|litespeed)|mod_php\d*|"
    r"php\d+(-(cli|fpm|cgi|common|litespeed|apache2))?|plesk-php\d+-(cli|fpm|cgi))$"
)


def packages_dpkg():
    if not which("dpkg-query"):
        return []
    rc, out = run(["dpkg-query", "-W", "-f=${Package}\t${Version}\t${Status}\n"])
    pkgs = []
    for line in out.splitlines():
        parts = line.split("\t")
        if len(parts) < 3 or "installed" not in parts[2].split():
            continue
        name, ver = parts[0], parts[1]
        if PHP_PKG_RE.match(name):
            pkgs.append({"manager": "dpkg", "name": name, "version": ver, "upstream": upstream_from_pkg_version(ver)})
    return pkgs


def packages_rpm():
    if not which("rpm"):
        return []
    rc, out = run(["rpm", "-qa", "--qf", "%{NAME}\t%{EPOCH}\t%{VERSION}\t%{RELEASE}\n"], timeout=60)
    pkgs = []
    for line in out.splitlines():
        parts = line.split("\t")
        if len(parts) < 4:
            continue
        name, epoch, ver, rel = parts
        if PHP_PKG_RE.match(name):
            full = "{}-{}".format(ver, rel) if epoch in ("(none)", "") else "{}:{}-{}".format(epoch, ver, rel)
            pkgs.append({"manager": "rpm", "name": name, "version": full, "upstream": upstream_from_pkg_version(ver)})
    return pkgs


def packages_apk():
    if not which("apk"):
        return []
    rc, out = run(["apk", "info", "-v"])
    pkgs = []
    for line in out.splitlines():
        m = re.match(r"^(php\d*(?:-(?:cli|fpm|cgi|common|apache2|litespeed))?)-(\d+\.\d+\.\d+[^\s]*)$", line.strip())
        if m:
            pkgs.append({"manager": "apk", "name": m.group(1), "version": m.group(2), "upstream": upstream_from_pkg_version(m.group(2))})
    return pkgs


def package_owner(path, manager):
    if manager == "dpkg":
        rc, out = run(["dpkg", "-S", path])
        if rc == 0 and ":" in out:
            return out.split(":", 1)[0].split(",")[0].strip()
    elif manager == "rpm":
        rc, out = run(["rpm", "-qf", "--qf", "%{NAME}\n", path])
        if rc == 0 and out.strip() and "not owned" not in out:
            return out.strip().splitlines()[0]
    elif manager == "apk":
        rc, out = run(["apk", "info", "--who-owns", path])
        m = re.search(r"owned by (\S+)", out)
        if m:
            return re.sub(r"-\d.*$", "", m.group(1))
    return ""


def repo_candidate(pkg, manager):
    """Newest version the configured repositories (or internal mirror) offer, from the local cache only."""
    if manager == "dpkg" and which("apt-cache"):
        rc, out = run(["apt-cache", "policy", pkg], timeout=20)
        m = re.search(r"Candidate:\s*(\S+)", out)
        if m and m.group(1) != "(none)":
            return m.group(1)
    elif manager == "rpm":
        tool = which("dnf") or which("yum")
        if tool:
            rc, out = run([tool, "-C", "-q", "list", "--available", "--showduplicates", pkg], timeout=40)
            vers = re.findall(r"^\S+\s+(\S+)\s+\S+$", out, re.M)
            if vers:
                return sorted(vers, key=lambda v: vtuple(upstream_from_pkg_version(v)))[-1]
    return ""


def changelog_cves(pkg, manager, cves):
    """CVE ids found in the package's local changelog: offline evidence of a vendor backport."""
    text = ""
    if manager == "dpkg":
        names = [pkg]
        m = re.match(r"^(php\d*(?:\.\d+)?)", pkg)
        if m:
            names.append(m.group(1) + "-common")
        for n in names:
            for f in ("/usr/share/doc/{}/changelog.Debian.gz".format(n), "/usr/share/doc/{}/changelog.gz".format(n)):
                text += read_text(f, limit=16 * 1024 * 1024)
    elif manager == "rpm":
        rc, out = run(["rpm", "-q", "--changelog", pkg], timeout=30)
        text = out
    return sorted({c for c in cves if c in text})


# ---------------------------------------------------------------------------------------------
# Discovery of binaries and running processes
# ---------------------------------------------------------------------------------------------
def classify_kind(path):
    b = os.path.basename(path).lower()
    if "fpm" in b:
        return "fpm"
    if "cgi" in b:
        return "cgi"
    if b.startswith("lsphp"):
        return "litespeed"
    if b.startswith("frankenphp"):
        return "frankenphp"
    if b.startswith(("libphp", "mod_php")) or b.endswith(".dll"):
        return "apache-module"
    return "cli"


def candidate_files(extra_roots, deep, include_overlay, deadline):
    found = set()
    pats = SEARCH_GLOBS_WINDOWS if os.name == "nt" else SEARCH_GLOBS_UNIX
    for pat in pats:
        for p in glob.glob(pat, recursive=True):
            if os.path.isfile(p) and BINARY_NAME_RE.match(os.path.basename(p)):
                found.add(p)
    roots = list(extra_roots)
    if deep:
        roots.append("C:\\" if os.name == "nt" else "/")
    skip = mounts_to_skip() if os.name != "nt" else set()
    skip_prefixes = ["/proc", "/sys", "/dev", "/run", "/snap"]
    if not include_overlay:
        skip_prefixes += ["/var/lib/docker", "/var/lib/containers", "/var/lib/containerd", "/var/lib/kubelet/pods"]
    for root in roots:
        for dirpath, dirnames, filenames in os.walk(root, topdown=True, onerror=lambda e: None, followlinks=False):
            if time.time() > deadline:
                return found, True
            dirnames[:] = [d for d in dirnames
                           if os.path.join(dirpath, d) not in skip
                           and not any(os.path.join(dirpath, d).startswith(s) for s in skip_prefixes)]
            for fn in filenames:
                if BINARY_NAME_RE.match(fn):
                    p = os.path.join(dirpath, fn)
                    if os.path.isfile(p) and not os.path.islink(p):
                        found.add(p)
    return found, False


def version_from_binary(path, max_bytes=96 * 1024 * 1024):
    """Read the embedded PHP version string without executing the file."""
    counts = {}
    try:
        with open(path, "rb") as fh:
            tail = b""
            read = 0
            while read < max_bytes:
                chunk = fh.read(4 * 1024 * 1024)
                if not chunk:
                    break
                read += len(chunk)
                data = tail + chunk
                for m in VERSION_STRING_RE.finditer(data):
                    v = "{}.{}.{}".format(int(m.group(1)), int(m.group(2)), int(m.group(3)))
                    counts[v] = counts.get(v, 0) + 1
                tail = data[-32:]
    except (OSError, IOError):
        return "", "unreadable"
    plausible = {v: c for v, c in counts.items() if vtuple(v)[0] in (5, 7, 8) and vtuple(v)[1] <= 6}
    if not plausible:
        return "", "not-found"
    best = sorted(plausible.items(), key=lambda kv: (-kv[1], vtuple(kv[0])))[0][0]
    return best, "binary-strings"


def version_from_exec(path, allow_any):
    try:
        st = os.stat(path)
    except OSError:
        return ""
    if not allow_any and os.name != "nt" and (st.st_uid != 0 or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
        return ""  # refuse to run files a non-root user could have planted
    args = [path, "version"] if os.path.basename(path).startswith("frankenphp") else [path, "-v"]
    rc, out = run(args, timeout=8, env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"})
    m = re.search(r"PHP (\d+\.\d+\.\d+)", out)
    return m.group(1) if m else ""


FPM_MASTER_RE = re.compile(r"php-fpm[\d.]*: master process \((\S+)\)")


def running_processes():
    """Map of real path -> {'pids', 'roles', 'deleted'} from /proc (Linux only).

    Without root, /proc/<pid>/exe of other users' processes is unreadable. The command line still is,
    so FPM masters are matched by the version in their config path ("hint:fpm:8.4") and their pool
    workers through the parent pid."""
    procs = {}
    limited = False
    if not os.path.isdir("/proc/self"):
        return procs, True
    masters = {}
    for pid in os.listdir("/proc"):
        if not pid.isdigit():
            continue
        base = "/proc/" + pid
        try:
            exe = os.readlink(base + "/exe")
        except OSError:
            limited = True
            exe = ""
        cmd = read_text(base + "/cmdline", limit=8192).replace("\x00", " ").strip()
        if not exe and cmd.startswith("php-fpm"):
            m = FPM_MASTER_RE.search(cmd)
            if m:
                vm = re.search(r"(\d+\.\d+)", m.group(1))
                hint = "hint:fpm:" + (vm.group(1) if vm else "?")
                masters[pid] = hint
                e = procs.setdefault(hint, {"pids": [], "roles": set(), "deleted": False})
                e["pids"].append(int(pid)); e["roles"].add("fpm")
            elif cmd.startswith("php-fpm") and ": pool " in cmd:
                stat_line = read_text(base + "/stat", limit=1024)
                ppid = stat_line.rsplit(")", 1)[-1].split()[1] if ")" in stat_line else ""
                if ppid in masters:
                    procs[masters[ppid]]["pids"].append(int(pid))
            continue
        name = os.path.basename(exe.replace(" (deleted)", "")) if exe else (cmd.split(" ")[0] if cmd else "")
        entries = []
        if exe and BINARY_NAME_RE.match(os.path.basename(exe.replace(" (deleted)", ""))):
            role = classify_kind(exe)
            if role == "cli" and re.search(r"(^|\s)-S\s", cmd):
                role = "embedded-server"
            entries.append((exe.replace(" (deleted)", ""), role, exe.endswith("(deleted)")))
        if name in ("apache2", "httpd", "httpd.worker", "httpd.event", "lshttpd", "litespeed"):
            maps = read_text(base + "/maps", limit=2 * 1024 * 1024)
            for lib in set(re.findall(r"(\S*(?:libphp|mod_php)[\w.]*\.so[\w.]*)", maps)):
                entries.append((lib.replace(" (deleted)", ""), "apache-module", "(deleted)" in lib))
        for path, role, deleted in entries:
            rp = safe_realpath(path)
            e = procs.setdefault(rp, {"pids": [], "roles": set(), "deleted": False})
            e["pids"].append(int(pid))
            e["roles"].add(role)
            e["deleted"] = e["deleted"] or deleted
    return procs, limited


def containers():
    out_list = []
    for engine in ("docker", "podman"):
        if not which(engine):
            continue
        rc, out = run([engine, "ps", "--format", "{{.ID}}\t{{.Image}}\t{{.Names}}"], timeout=20)
        if rc != 0:
            out_list.append({"engine": engine, "error": "not permitted or daemon unavailable"})
            continue
        for line in out.splitlines():
            parts = line.split("\t")
            if len(parts) < 3:
                continue
            cid, image, name = parts
            rc2, env_json = run([engine, "inspect", "--format", "{{json .Config.Env}}", cid], timeout=20)
            version = ""
            try:
                for kv in json.loads(env_json or "[]") or []:
                    if kv.startswith("PHP_VERSION="):
                        version = kv.split("=", 1)[1]
            except ValueError:
                pass
            out_list.append({"engine": engine, "id": cid, "image": image, "name": name, "version": version})
    return out_list


# ---------------------------------------------------------------------------------------------
# Configuration (interim mitigations)
# ---------------------------------------------------------------------------------------------
def parse_ini(text, into):
    for raw in text.splitlines():
        line = raw.split(";", 1)[0].strip()
        if not line or line.startswith("[") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        k, v = k.strip(), v.strip().strip('"').strip("'")
        m = re.match(r"php_(?:admin_)?(?:value|flag)\[([^\]]+)\]", k)
        if m:
            k = m.group(1)
        into[k] = v
    return into


def ini_candidates(branch, kind, path):
    v, vnd = branch, branch.replace(".", "")
    sapi = {"fpm": "fpm", "cgi": "cgi", "apache-module": "apache2", "litespeed": "litespeed"}.get(kind, "cli")
    roots = []
    # Debian/Ubuntu (incl. CloudPanel, ondrej/sury)
    roots.append(("/etc/php/{}/{}/php.ini".format(v, sapi), "/etc/php/{}/{}/conf.d/*.ini".format(v, sapi),
                  "/etc/php/{}/fpm/pool.d/*.conf".format(v) if sapi == "fpm" else None))
    # Remi SCL / RH SCL
    roots.append(("/etc/opt/remi/php{}/php.ini".format(vnd), "/etc/opt/remi/php{}/php.d/*.ini".format(vnd),
                  "/etc/opt/remi/php{}/php-fpm.d/*.conf".format(vnd) if sapi == "fpm" else None))
    # cPanel EA-PHP
    roots.append(("/opt/cpanel/ea-php{}/root/etc/php.ini".format(vnd), "/opt/cpanel/ea-php{}/root/etc/php.d/*.ini".format(vnd),
                  "/opt/cpanel/ea-php{}/root/etc/php-fpm.d/*.conf".format(vnd) if sapi == "fpm" else None))
    # Plesk
    roots.append(("/opt/plesk/php/{}/etc/php.ini".format(v), "/opt/plesk/php/{}/etc/php.d/*.ini".format(v),
                  "/opt/plesk/php/{}/etc/php-fpm.d/*.conf".format(v) if sapi == "fpm" else None))
    # RHEL / Fedora / SUSE / Alpine default
    roots.append(("/etc/php.ini", "/etc/php.d/*.ini", "/etc/php-fpm.d/*.conf" if sapi == "fpm" else None))
    roots.append(("/etc/php{}/php.ini".format(vnd), "/etc/php{}/conf.d/*.ini".format(vnd),
                  "/etc/php{}/php-fpm.d/*.conf".format(vnd) if sapi == "fpm" else None))
    # Source builds: <prefix>/lib/php.ini or <prefix>/etc/php.ini
    prefix = os.path.dirname(os.path.dirname(path))
    roots.append((os.path.join(prefix, "lib", "php.ini"), os.path.join(prefix, "etc", "conf.d", "*.ini"),
                  os.path.join(prefix, "etc", "php-fpm.d", "*.conf") if sapi == "fpm" else None))
    for main, confd, pools in roots:
        if os.path.isfile(main):
            return main, sorted(glob.glob(confd)), sorted(glob.glob(pools)) if pools else []
    return "", [], []


def mitigation_status(branch, kind, path):
    main, confd, pools = ini_candidates(branch, kind, path)
    base = {}
    files = []
    for f in ([main] if main else []) + confd:
        parse_ini(read_text(f), base)
        files.append(f)

    def evaluate(cfg):
        upl = cfg.get("session.upload_progress.enabled", "1").lower() in ("0", "off", "false", "no", "")
        mem = cfg.get("memory_limit", "128M").strip() == "-1"
        res = {"upload_progress_disabled": upl, "memory_limit_unlimited": mem,
               "post_max_size": cfg.get("post_max_size", "8M (default)"),
               "upload_max_filesize": cfg.get("upload_max_filesize", "2M (default)")}
        if branch == "8.5":
            res["fatal_error_backtraces_off"] = cfg.get("fatal_error_backtraces", "1").lower() in ("0", "off", "false", "no")
        return res

    result = {"config_found": bool(main), "files": files, "global": evaluate(base), "pools": {}}
    for pf in pools:
        cfg = dict(base)
        parse_ini(read_text(pf), cfg)
        result["pools"][os.path.basename(pf)] = evaluate(cfg)
        result["files"].append(pf)
    return result


# ---------------------------------------------------------------------------------------------
# Assessment
# ---------------------------------------------------------------------------------------------
def assess(version, evidence, advisory):
    if not version:
        return "UNKNOWN", "Version could not be determined."
    br = branch_of(version)
    fixed = advisory["fixed"].get(br)
    cves = advisory["cves"]
    backported = all(c in evidence for c in cves)
    if fixed:
        if vtuple(version) >= vtuple(fixed):
            return "FIXED", "Upstream {} or later.".format(fixed)
        if backported:
            return "PATCHED-BACKPORT", "Package changelog lists {}.".format(", ".join(cves))
        if evidence:
            return "VULNERABLE", "Changelog lists only {}; the other fix is missing.".format(", ".join(evidence))
        return "VULNERABLE", "Below {} and no backport evidence.".format(fixed)
    newest = max(advisory["fixed"].keys(), key=vtuple)
    if vtuple(br) > vtuple(newest):
        return "UNKNOWN", "Branch newer than the advisory; check the vendor."
    if backported:
        return "PATCHED-BACKPORT", "End-of-life branch with vendor backport ({}).".format(", ".join(cves))
    return "VULNERABLE-EOL", "End-of-life branch: no upstream fix."


def severity(status, web, running):
    if status.startswith("VULNERABLE"):
        if web and running:
            return "CRITICAL"
        if web:
            return "HIGH"
        return "MEDIUM"
    if status == "UNKNOWN":
        return "REVIEW"
    return "OK"


def update_advice(inst, osinfo, panels, advisory):
    """One recommendation string per installation."""
    pkg, mgr, ver = inst.get("package"), inst.get("package_manager"), inst.get("package_version", "")
    br = inst.get("branch", "")
    target = advisory["fixed"].get(br)
    ids = (osinfo.get("id", "") + " " + osinfo.get("id_like", "")).lower()
    restart = "then restart every PHP worker (FPM pools, Apache, LiteSpeed)."
    if inst["status"] in ("FIXED", "PATCHED-BACKPORT"):
        return "No update needed for this advisory."
    if inst["status"] == "VULNERABLE-EOL":
        return ("Upgrade to a supported branch ({}+), or get a backported package from your vendor/ELS provider. "
                "Until then apply the interim mitigations and keep it off the internet (VPN/WAF).").format(advisory["oldest_supported"])
    if inst.get("source") == "container":
        return "Rebuild the image on a fixed base tag (PHP {}), redeploy, and verify PHP_VERSION inside the image.".format(target or "fixed")
    if mgr == "dpkg":
        origin = "CloudPanel repository" if "+clp" in ver else "ondrej/sury repository" if "sury" in ver or "deb.sury" in ver else "distribution security repository"
        return ("apt-get update && apt-get install --only-upgrade {} (from the {}; target {} or a vendor build whose changelog lists both CVEs), {}"
                .format(pkg or "php{}-*".format(br), origin, target, restart))
    if mgr == "rpm":
        if pkg and pkg.startswith("ea-php"):
            return "Update EA-PHP via WHM > EasyApache 4 or 'yum update {}*' (target {}), {}".format(pkg.split("-php")[0] + "-php", target, restart)
        if pkg and re.match(r"php\d+-php", pkg or ""):
            return "dnf upgrade '{}-*' (Remi SCL; target {}), {}".format(pkg.split("-php")[0] + "-php", target, restart)
        tool = "dnf" if which("dnf") else "yum"
        return "{} upgrade 'php*' (target {} or a vendor build whose changelog lists both CVEs; check RHSA/ALSA/RLSA), {}".format(tool, target, restart)
    if mgr == "apk":
        return "apk update && apk upgrade {} (target {}), {}".format(pkg or "php*", target, restart)
    if "Plesk" in panels and "/opt/plesk/php/" in inst.get("path", ""):
        return "Update via Plesk: Tools & Settings > Updates, or 'plesk installer --select-product-id plesk --upgrade-installed-components', {}".format(restart)
    if "LiteSpeed" in panels and inst.get("kind") == "litespeed":
        return "Update the lsphp{} packages from the LiteSpeed repository (target {}), {}".format(br.replace(".", ""), target, restart)
    if "darwin" in ids or sys.platform == "darwin":
        return "brew upgrade php (target {}); development machines are low risk unless they serve the network.".format(target)
    return ("Not managed by a package manager (source build or vendor bundle): rebuild from PHP {} source or install the vendor's "
            "updated bundle, {}".format(target, restart))


# ---------------------------------------------------------------------------------------------
# Main scan
# ---------------------------------------------------------------------------------------------
def scan(args, advisory):
    started = time.time()
    osinfo = os_release()
    panels = detect_panels()
    notes = []
    if os.name != "nt" and not is_root():
        notes.append("Not running as root: other users' processes and some config files may be invisible.")

    pkgs = packages_dpkg() + packages_rpm() + packages_apk()
    managers = sorted({p["manager"] for p in pkgs})
    pkg_by_name = {p["name"]: p for p in pkgs}

    files, timed_out = candidate_files(args.path, args.deep, args.include_overlay, started + args.max_seconds)
    if timed_out:
        notes.append("Deep scan stopped after {}s (--max-seconds); results may be incomplete.".format(args.max_seconds))
    procs, proc_limited = running_processes() if not args.no_processes else ({}, False)
    if proc_limited:
        notes.append("Some processes could not be inspected (permissions).")
    for rp in procs:
        if os.path.isfile(rp):
            files.add(rp)

    installs = {}
    for f in sorted(files):
        rp = safe_realpath(f)
        if rp in installs:
            installs[rp]["aliases"].append(f)
            continue
        kind = classify_kind(rp)
        inst = {"path": rp, "aliases": [f] if f != rp else [], "kind": kind, "source": "filesystem",
                "package": "", "package_manager": "", "package_version": "", "version": "", "version_source": ""}
        for mgr in managers:
            owner = package_owner(rp, mgr)
            if owner:
                inst["package"], inst["package_manager"] = owner, mgr
                p = pkg_by_name.get(owner)
                if p:
                    inst["package_version"], inst["version"], inst["version_source"] = p["version"], p["upstream"], "package"
                break
        if not inst["version"]:
            v, src = version_from_binary(rp)
            inst["version"], inst["version_source"] = v, src if v else ""
        if args.exec_binaries:
            v = version_from_exec(rp, args.exec_any)
            if v:
                if inst["version"] and v != inst["version"]:
                    notes.append("{}: package says {}, binary reports {}.".format(rp, inst["version"], v))
                inst["version"], inst["version_source"] = v, "exec"
        installs[rp] = inst

    # Packages whose binaries were not found on disk (e.g. pruned paths) are still reported.
    seen_pkgs = {i["package"] for i in installs.values() if i["package"]}
    for p in pkgs:
        if re.search(r"(-common|^php\d*(\.\d+)?)$", p["name"]) and p["name"] not in seen_pkgs:
            continue  # meta/common packages are covered by their binaries
        if p["name"] not in seen_pkgs:
            installs["pkg:" + p["name"]] = {"path": "(package only)", "aliases": [], "kind": classify_kind(p["name"]),
                                            "source": "package", "package": p["name"], "package_manager": p["manager"],
                                            "package_version": p["version"], "version": p["upstream"], "version_source": "package"}

    if args.containers:
        for c in containers():
            if "error" in c:
                notes.append("{}: {}".format(c["engine"], c["error"]))
                continue
            installs["ctr:" + c["id"]] = {"path": "{}:{} ({})".format(c["engine"], c["name"], c["image"]), "aliases": [],
                                          "kind": "container", "source": "container", "package": "", "package_manager": "",
                                          "package_version": "", "version": upstream_from_pkg_version(c["version"]),
                                          "version_source": "container-env" if c["version"] else ""}

    results = []
    for key, inst in installs.items():
        inst["branch"] = branch_of(inst["version"])
        evidence = changelog_cves(inst["package"], inst["package_manager"], advisory["cves"]) if inst["package"] else []
        inst["backport_evidence"] = evidence
        pr = procs.get(inst["path"]) or (procs.get("hint:fpm:" + inst["branch"]) if inst["kind"] == "fpm" else None) or {}
        inst["running"] = bool(pr)
        inst["pids"] = sorted(pr.get("pids", []))[:20]
        roles = set(pr.get("roles", set()))
        inst["running_deleted_binary"] = bool(pr.get("deleted"))
        inst["serves_web"] = inst["kind"] in WEB_KINDS or bool(roles & set(WEB_KINDS)) or inst["kind"] == "container"
        inst["status"], inst["reason"] = assess(inst["version"], evidence, advisory)
        inst["severity"] = severity(inst["status"], inst["serves_web"], inst["running"] or inst["kind"] == "container")
        if inst["branch"] in advisory.get("eol_notes", {}):
            inst["note"] = advisory["eol_notes"][inst["branch"]]
        if inst["source"] != "container" and inst["branch"] and inst["status"] != "FIXED":
            inst["mitigations"] = mitigation_status(inst["branch"], inst["kind"], inst["path"])
        inst["recommendation"] = update_advice(inst, osinfo, panels, advisory)
        if inst["package"] and inst["status"] == "VULNERABLE":
            cand = repo_candidate(inst["package"], inst["package_manager"])
            fixed_v = advisory["fixed"].get(inst["branch"], "")
            inst["repo_candidate"] = cand
            inst["repo_has_fix"] = bool(cand) and vtuple(upstream_from_pkg_version(cand)) >= vtuple(fixed_v)
            if cand and not inst["repo_has_fix"]:
                inst["recommendation"] += (" NOTE: the repository/mirror currently offers only {} - the fix is not available yet "
                                           "(refresh the package cache or mirror; apply the interim mitigations meanwhile).".format(cand))
        if inst["running_deleted_binary"]:
            inst["reason"] += " Running process uses a deleted binary: restart it to load the update."
        results.append(inst)

    order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "REVIEW": 3, "OK": 4}
    results.sort(key=lambda i: (order.get(i["severity"], 9), i["branch"], i["path"]))

    # Packages to fetch from an internal mirror in air-gapped environments.
    to_update = sorted({i["package"] for i in results if i["package"] and i["status"] in ("VULNERABLE",)})
    return {
        "scanner": {"name": "php2shell_scan", "version": SCRIPT_VERSION, "duration_s": round(time.time() - started, 1)},
        "advisory": {k: advisory[k] for k in ("id", "published", "cves", "fixed", "sources")},
        "host": {"hostname": socket.getfqdn(), "os": osinfo.get("pretty"), "kernel": platform.release(),
                 "panels": panels, "package_managers": managers, "root": is_root(), "label": args.label},
        "notes": notes,
        "installations": results,
        "packages_to_update": to_update,
        "summary": {
            "total": len(results),
            "critical": sum(1 for i in results if i["severity"] == "CRITICAL"),
            "vulnerable": sum(1 for i in results if i["status"].startswith("VULNERABLE")),
            "fixed": sum(1 for i in results if i["status"] in ("FIXED", "PATCHED-BACKPORT")),
            "unknown": sum(1 for i in results if i["status"] == "UNKNOWN"),
        },
    }


# ---------------------------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------------------------
class Colour(object):
    def __init__(self, on):
        self.on = on

    def __call__(self, text, code):
        return "\033[{}m{}\033[0m".format(code, text) if self.on else text


def print_report(rep, colour):
    c = colour
    sev_col = {"CRITICAL": "1;31", "HIGH": "31", "MEDIUM": "33", "REVIEW": "36", "OK": "32"}
    h = rep["host"]
    print(c("PHP2Shell exposure scan", "1") + "  ({} {}, advisory {} {})".format(
        rep["scanner"]["name"], rep["scanner"]["version"], rep["advisory"]["id"], rep["advisory"]["published"]))
    print("Host     : {}{}".format(h["hostname"], " [{}]".format(h["label"]) if h["label"] else ""))
    print("OS       : {} (kernel {})".format(h["os"], h["kernel"]))
    print("Panels   : {}".format(human_list(h["panels"])))
    print("Packages : {}   root: {}".format(human_list(h["package_managers"]), "yes" if h["root"] else "no"))
    print("Fixed in : " + ", ".join("{}".format(v) for k, v in sorted(rep["advisory"]["fixed"].items(), key=lambda kv: vtuple(kv[0]), reverse=True)))
    for n in rep["notes"]:
        print(c("Note     : " + n, "33"))
    print()
    if not rep["installations"]:
        print("No PHP installation found. Use --deep and --containers for a wider search.")
        return
    for i in rep["installations"]:
        print("{}  {}".format(c("[{}]".format(i["severity"]), sev_col.get(i["severity"], "0")), c(i["path"], "1")))
        print("    PHP {:<10} kind: {:<14} status: {}".format(i["version"] or "?", i["kind"], i["status"]))
        print("    version from: {:<14} package: {}".format(i["version_source"] or "-", "{} {}".format(i["package"], i["package_version"]).strip() or "-"))
        print("    running: {:<5} web: {:<5} {}".format("yes" if i["running"] else "no", "yes" if i["serves_web"] else "no",
                                                         "pids: " + ",".join(map(str, i["pids"])) if i["pids"] else ""))
        print("    why: " + i["reason"])
        if i.get("repo_candidate"):
            print("    repository offers: {} ({})".format(i["repo_candidate"], "contains the fix" if i.get("repo_has_fix") else c("fix not available yet", "33")))
        if i.get("note"):
            print("    note: " + i["note"])
        m = i.get("mitigations")
        if m:
            g = m["global"]
            print("    interim mitigations ({}): upload_progress off={} memory_limit=-1={} post_max_size={}".format(
                "config found" if m["config_found"] else "no config found",
                "yes" if g["upload_progress_disabled"] else c("no", "33"),
                "yes" if g["memory_limit_unlimited"] else c("no", "33"), g["post_max_size"]))
            for pool, pg in sorted(m["pools"].items()):
                print("      pool {:<28} upload_progress off={} memory_limit=-1={}".format(
                    pool, "yes" if pg["upload_progress_disabled"] else "no", "yes" if pg["memory_limit_unlimited"] else "no"))
        print("    do: " + i["recommendation"])
        print()
    s = rep["summary"]
    print(c("Summary", "1") + ": {} installation(s), {} vulnerable ({} critical), {} fixed, {} unknown.".format(
        s["total"], s["vulnerable"], s["critical"], s["fixed"], s["unknown"]))
    if rep["packages_to_update"]:
        print("Packages to update (fetch from your internal mirror if the host is offline):")
        print("    " + " ".join(rep["packages_to_update"]))
    if s["vulnerable"]:
        print("Interim mitigations reduce exposure but are not a fix; only updating is. Restart workers after any change.")
    print("Sources: " + " | ".join(rep["advisory"]["sources"]))


def load_advisory(path):
    adv = dict(ADVISORY)
    if path:
        with io.open(path, "r", encoding="utf-8") as fh:
            user = json.load(fh)
        for k in ("id", "published", "cves", "ghsa", "fixed", "oldest_supported", "eol_notes", "sources"):
            if k in user:
                adv[k] = user[k]
    return adv


def main(argv=None):
    ap = argparse.ArgumentParser(description="PHP2Shell (CVE-2026-109618 / CVE-2026-109619) exposure scanner. Read-only, offline.")
    ap.add_argument("--json", metavar="FILE", help="write the full result as JSON ('-' for stdout)")
    ap.add_argument("--deep", action="store_true", help="walk the whole local file system (skips pseudo and network file systems)")
    ap.add_argument("--path", action="append", default=[], metavar="DIR", help="extra directory to search (repeatable)")
    ap.add_argument("--containers", action="store_true", help="read PHP_VERSION of running Docker/Podman containers (no exec into containers)")
    ap.add_argument("--include-overlay", action="store_true", help="also search container storage (/var/lib/docker etc.) during --deep")
    ap.add_argument("--exec", dest="exec_binaries", action="store_true", help="run '<binary> -v' to confirm versions (root-owned, non-writable files only)")
    ap.add_argument("--exec-any", action="store_true", help="with --exec, also run binaries not owned by root (not recommended)")
    ap.add_argument("--no-processes", action="store_true", help="do not inspect running processes")
    ap.add_argument("--max-seconds", type=int, default=600, help="time limit for --deep (default 600)")
    ap.add_argument("--advisory", metavar="FILE", help="JSON file overriding the embedded advisory data (for newer releases)")
    ap.add_argument("--label", default="", help="free-text label for this host in reports (e.g. 'prod-dmz-web01')")
    ap.add_argument("--no-color", action="store_true", help="plain output")
    ap.add_argument("--quiet", action="store_true", help="no human-readable report (use with --json)")
    ap.add_argument("--version", action="version", version="%(prog)s " + SCRIPT_VERSION)
    args = ap.parse_args(argv)

    try:
        advisory = load_advisory(args.advisory)
        rep = scan(args, advisory)
    except KeyboardInterrupt:
        return 4
    except Exception as exc:  # never leave automation without a result
        sys.stderr.write("php2shell_scan: error: {}\n".format(exc))
        return 4

    if args.json:
        data = json.dumps(rep, indent=2, sort_keys=False, default=list)
        if args.json == "-":
            print(data)
        else:
            with io.open(args.json, "w", encoding="utf-8") as fh:
                fh.write(data)
    if not args.quiet and args.json != "-":
        print_report(rep, Colour(sys.stdout.isatty() and not args.no_color and os.name != "nt"))

    if rep["summary"]["critical"]:
        return 2
    if rep["summary"]["vulnerable"]:
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
