#!/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: broccoli2shell
#
# Collects the facts the "Broccoli2Shell" check plug-in (MKP) needs to decide whether the
# Brotli decoders on this host are affected by CVE-2026-111497 ("Broccoli2Shell"), an
# out-of-bounds write in Google's C Brotli decoder: the system library libbrotlidec,
# Node.js, the Python packages Brotli and brotlicffi, and the .NET runtime. 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 "?":
#
#     <<<broccoli2shell:sep(124)>>>
#     plugin|<plug-in version>
#     lib|<path>|<version>|<package>|<package version>|<CVEs>|<processes>
#     node|<path>|<version>|<linked>|<package>|<package version>|<CVEs>|<processes>
#     python|<site-packages>|<name>|<version>|<linked>|<package>|<package version>|<CVEs>|<processes>
#     dotnet|<root>|<version>|<package>|<package version>|<CVEs>|<processes>
#     stale|<pid>|<process name>|<deleted path>
#
# <path> of a "lib" line is the library's real directory plus its soname
# (libbrotlidec.so.1), so that it stays the same across version updates. <linked> is that
# path of the system libbrotlidec a Node.js binary (directly or through libnode) or a
# Python extension module is linked against, "-" if it carries its own decoder. <CVEs> are
# the CVE IDs named in the package changelog (comma-separated, empty if none, "?" if there
# is no readable changelog). <processes> are the host processes that have the file mapped,
# as <name>:<count>,... "stale" lines are host processes that still have a deleted - i.e.
# replaced by an update - decoder 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/broccoli2shell, and make it
# executable, or deploy it via the agent bakery rule "Broccoli2Shell agent plug-in". It
# reads large binaries and the memory map of every process, so do not run it on every
# agent query.

CMK_VERSION="1.0.1"

# Every CVE ID the server-side check knows about; only these are taken from changelogs.
CVE_PATTERN='CVE-2026-111497'

LC_ALL=C
export LC_ALL

# Split command substitutions at newlines only, so that paths with spaces stay whole.
IFS='
'

# Files that contain a Brotli decoder, as they appear in /proc/<pid>/maps.
DECODER_RE='/[^ ]*/(libbrotlidec[^/ ]*|libnode\.so[^/ ]*|node|nodejs|node-[0-9]+|_brotli[^/ ]*\.so|_brotlicffi[^/ ]*\.so|libSystem\.IO\.Compression\.Native\.so)( \(deleted\))?$'

run_limited() {
    if command -v timeout >/dev/null 2>&1; then timeout 15 "$@"; else "$@"; fi
}

# "<pid> <path>[ (deleted)]" for every decoder file 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= have of their own.
mapped_decoders() {
    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 "$DECODER_RE" "$maps" 2>/dev/null | sort -u | sed "s|^|$pid |"
    done
}

# Mapped files that still exist, without the pid.
mapped_paths() {
    printf '%s\n' "$MAPPED" | grep -v ' (deleted)$' | sed 's/^[0-9]* //'
}

# Name of process $1: the name of its executable - not comm, which Node.js renames to
# "MainThread". Any local user can name an executable, so only plain characters are kept:
# a newline in the name would otherwise start a section of that user's choosing.
process_name() {
    n=$(readlink "/proc/$1/exe" 2>/dev/null)
    n=${n% (deleted)}
    n=${n##*/}
    [ -n "$n" ] || n=$(cat "/proc/$1/comm" 2>/dev/null)
    n=$(printf '%s' "$n" | tr -cd 'A-Za-z0-9._+-')
    echo "${n:-?}"
}

# "<name>:<count>,..." of the host processes that have the file $1 mapped.
users_of() {
    printf '%s\n' "$MAPPED" | while IFS=' ' read -r pid path; do
        [ "$path" = "$1" ] && process_name "$pid"
    done | sort | uniq -c | sed 's/^ *\([0-9]*\) \(.*\)$/\2:\1/' | tr '\n' ',' | sed 's/,$//'
}

first_file() {
    for f in "$@"; do
        [ -f "$f" ] && { echo "$f"; return; }
    done
}

is_elf() {
    [ "$(dd if="$1" bs=1 skip=1 count=3 2>/dev/null)" = "ELF" ]
}

# 0 = the file $1 and its directory belong to root and are writable by no one else. Only
# such a binary is ever run: a node binary a user could have put somewhere (nvm in a home
# directory, the application directory of a service user) is only read, never executed
# as root.
root_only() {
    for f in "$1" "${1%/*}"; do
        s=$(stat -L -c '%u %a' "$f" 2>/dev/null) || return 1
        [ "${s%% *}" = 0 ] || return 1
        case "${s#* }" in
            *[2367]? | *[2367]) return 1 ;;
        esac
    done
    return 0
}

# Real path of the shared library with soname $2 as the dynamic loader finds it for the
# ELF file $1: in a lib directory next to it (conda environments and bundled applications
# use an RPATH like $ORIGIN/../lib), else via the loader cache, else in the default
# directories (musl has no cache). Empty if not found.
resolve_lib() {
    d=${1%/*}
    for _ in 1 2 3 4; do
        for f in "$d/$2" "$d/lib/$2" "$d/lib64/$2"; do
            [ -e "$f" ] && { readlink -f "$f"; return; }
        done
        d=${d%/*}
        [ -n "$d" ] || break
    done
    # "	libbrotlidec.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libbrotlidec.so.1"; the
    # 64-bit entry wins over a 32-bit multiarch one.
    c=$( (ldconfig -p || /sbin/ldconfig -p) 2>/dev/null | grep -F " $2 (")
    f=$(printf '%s\n' "$c" | grep 64 | head -n 1)
    [ -n "$f" ] || f=$(printf '%s\n' "$c" | head -n 1)
    if [ -n "$f" ] && [ -e "${f##*=> }" ]; then
        readlink -f "${f##*=> }"
        return
    fi
    for f in /lib64/"$2" /usr/lib64/"$2" /lib/*/"$2" /usr/lib/*/"$2" /lib/"$2" \
        /usr/local/lib/"$2" /usr/lib/"$2"; do
        [ -e "$f" ] && { readlink -f "$f"; return; }
    done
}

# DT_NEEDED names of the ELF file $1 that matter here, read as strings from the dynamic
# string table without running the dynamic loader. The whole file is searched: that table
# can sit anywhere (1.3 MB into the official node binary, 58 MB into Fedora's libnode). The
# fixed-string search comes first, because busybox grep -o with a regular expression takes
# minutes on a 120 MB binary (grep -F about 10 s, GNU grep well under a second).
needed_libs() {
    grep -aF -e libbrotlidec.so. -e libnode.so. "$1" 2>/dev/null \
        | grep -aoE 'lib(brotlidec|node)\.so\.[0-9]+' | sort -u
}

# Real path of the libbrotlidec the ELF file $1 is linked against - directly, or through
# the libnode.so a distribution's Node.js consists of. "-" if it is not linked against one
# (it carries its own decoder), "?" if it is, but the library was not found.
linked_decoder() {
    needed=$(needed_libs "$1")
    consumer=$1
    dec=$(printf '%s\n' "$needed" | grep '^libbrotlidec' | head -n 1)
    libnode=$(printf '%s\n' "$needed" | grep '^libnode' | head -n 1)
    if [ -z "$dec" ] && [ -n "$libnode" ]; then
        consumer=$(resolve_lib "$1" "$libnode")
        [ -n "$consumer" ] || { echo "?"; return; }
        dec=$(needed_libs "$consumer" | grep '^libbrotlidec' | head -n 1)
    fi
    [ -n "$dec" ] || { echo "-"; return; }
    lib=$(resolve_lib "$consumer" "$dec")
    echo "${lib:-?}"
}

# Item of the libbrotlidec file $1: its real directory and soname (libbrotlidec.so.1).
lib_item() {
    r=$(readlink -f "$1")
    case "${r##*/}" in
        libbrotlidec.so.[0-9]*)
            m=${r##*/libbrotlidec.so.}
            echo "${r%/*}/libbrotlidec.so.${m%%.*}"
            ;;
        *) echo "$r" ;;
    esac
}

# 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
        # "libbrotli1:amd64: /usr/lib/x86_64-linux-gnu/libbrotlidec.so.1.0.9"
        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/lib/libbrotlidec.so.1.1.1 is owned by brotli-libs-1.1.1-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/,$//'
}

# Real paths of Node.js binaries: the usual install locations of distributions, the
# official tarballs, version managers and the VS Code server, plus whatever node binary a
# host process runs.
node_candidates() {
    for p in \
        /usr/bin/node /usr/bin/nodejs /usr/bin/node-[0-9]* /usr/local/bin/node \
        /usr/local/bin/nodejs /opt/*/bin/node /opt/*/*/bin/node \
        /usr/local/lib/nodejs/*/bin/node /usr/local/n/versions/node/*/bin/node \
        /usr/local/nvm/versions/node/*/bin/node \
        /root/.nvm/versions/node/*/bin/node /home/*/.nvm/versions/node/*/bin/node \
        /root/.volta/tools/image/node/*/bin/node /home/*/.volta/tools/image/node/*/bin/node \
        /root/.local/share/fnm/node-versions/*/installation/bin/node \
        /home/*/.local/share/fnm/node-versions/*/installation/bin/node \
        /root/.asdf/installs/nodejs/*/bin/node /home/*/.asdf/installs/nodejs/*/bin/node \
        /root/.vscode-server/bin/*/node /home/*/.vscode-server/bin/*/node \
        /root/.vscode-server/cli/servers/*/server/node \
        /home/*/.vscode-server/cli/servers/*/server/node
    do
        [ -f "$p" ] && readlink -f "$p"
    done
    mapped_paths | grep -E '/(node|nodejs|node-[0-9]+)$'
}

# Node.js version (x.y.z) of the binary $1. Only a root-owned binary is run, with an empty
# environment (no NODE_OPTIONS) and --version only. Otherwise - and as a fallback - the
# release URL compiled into official builds is read.
node_version() {
    v=""
    if root_only "$1"; then
        v=$(run_limited env -i "$1" --version 2>/dev/null \
            | sed -n 's/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1)
    fi
    if [ -z "$v" ]; then
        v=$(grep -aF 'https://nodejs.org/download/release/v' "$1" 2>/dev/null \
            | grep -aoE 'https://nodejs\.org/download/release/v[0-9]+\.[0-9]+\.[0-9]+/' \
            | head -n 1 | sed 's|.*/v||; s|/$||')
    fi
    echo "${v:-?}"
}

# site-packages directories: system, /usr/local, /opt (virtual environments, conda),
# per-user installs, pipx and uv tools, Checkmk sites, plus wherever a host process has
# loaded a Brotli extension module from.
site_dirs() {
    for d in \
        /usr/lib/python3/dist-packages /usr/lib/python3*/site-packages \
        /usr/lib/python3*/dist-packages /usr/lib64/python3*/site-packages \
        /usr/local/lib/python3*/site-packages /usr/local/lib/python3*/dist-packages \
        /usr/local/lib64/python3*/site-packages \
        /opt/*/lib/python3*/site-packages /opt/*/*/lib/python3*/site-packages \
        /opt/*/*/*/lib/python3*/site-packages /opt/rh/*/root/usr/lib*/python3*/site-packages \
        /srv/*/lib/python3*/site-packages /srv/*/*/lib/python3*/site-packages \
        /root/.local/lib/python3*/site-packages /home/*/.local/lib/python3*/site-packages \
        /root/.local/share/pipx/venvs/*/lib/python3*/site-packages \
        /home/*/.local/share/pipx/venvs/*/lib/python3*/site-packages \
        /root/.local/pipx/venvs/*/lib/python3*/site-packages \
        /home/*/.local/pipx/venvs/*/lib/python3*/site-packages \
        /root/.local/share/uv/tools/*/lib/python3*/site-packages \
        /home/*/.local/share/uv/tools/*/lib/python3*/site-packages \
        /root/*conda*/lib/python3*/site-packages /home/*/*conda*/lib/python3*/site-packages \
        /omd/versions/*/lib/python3*/site-packages
    do
        [ -d "$d" ] && readlink -f "$d"
    done
    mapped_paths | sed -nE 's|^(.*)/brotlicffi/_brotlicffi[^/]*\.so$|\1|p; s|^(.*)/_brotli[^/]*\.so$|\1|p'
}

# One "python" line per Brotli / brotlicffi distribution in the site-packages directory
# $1, read from its metadata - nothing is imported or run.
python_packages() {
    for meta in "$1"/[Bb]rotli-*.dist-info "$1"/[Bb]rotli-*.egg-info \
        "$1"/brotlicffi-*.dist-info "$1"/brotlicffi-*.egg-info; do
        [ -e "$meta" ] || continue
        f=$meta
        if [ -d "$meta" ]; then
            f="$meta/METADATA"
            [ -f "$f" ] || f="$meta/PKG-INFO"
        fi
        name=$(sed -n 's/^Name: *//p' "$f" 2>/dev/null | head -n 1 | tr -d '\r|' \
            | tr '[:upper:]' '[:lower:]')
        version=$(sed -n 's/^Version: *//p' "$f" 2>/dev/null | head -n 1 | tr -d '\r|')
        case "$name" in
            brotli) ext=$(first_file "$1"/_brotli*.so) ;;
            brotlicffi) ext=$(first_file "$1"/brotlicffi/_brotlicffi*.so) ;;
            *) continue ;;
        esac
        linked="?"
        users=""
        if [ -n "$ext" ]; then
            ext=$(readlink -f "$ext")
            linked=$(linked_decoder "$ext")
            users=$(users_of "$ext")
            package_facts "$ext"
        else
            package_facts "$meta"
        fi
        case "$linked" in
            "-" | "?") ;;
            *) linked=$(lib_item "$linked") ;;
        esac
        echo "python|$1|$name|${version:-?}|$linked|$PKG|$PKG_VERSION|$CVES|$users"
    done
}

# .NET installations (directories with shared/Microsoft.NETCore.App) in the standard
# locations, per-user installs, plus the ones a host process has loaded the native
# compression library from.
dotnet_roots() {
    for d in \
        /usr/share/dotnet /usr/lib/dotnet /usr/lib64/dotnet /usr/local/share/dotnet \
        /usr/local/dotnet /usr/local/lib/dotnet /opt/dotnet /opt/microsoft/dotnet* \
        /root/.dotnet /home/*/.dotnet
    do
        [ -d "$d/shared/Microsoft.NETCore.App" ] && readlink -f "$d"
    done
    mapped_paths \
        | sed -nE 's|^(.*)/shared/Microsoft\.NETCore\.App/[^/]+/libSystem\.IO\.Compression\.Native\.so$|\1|p'
}

lib_candidates() {
    for p in \
        /lib/libbrotlidec.so* /lib64/libbrotlidec.so* /lib/*/libbrotlidec.so* \
        /usr/lib/libbrotlidec.so* /usr/lib64/libbrotlidec.so* /usr/lib/*/libbrotlidec.so* \
        /usr/local/lib/libbrotlidec.so* /usr/local/lib64/libbrotlidec.so* \
        /opt/*/lib/libbrotlidec.so* /root/*conda*/lib/libbrotlidec.so* \
        /home/*/*conda*/lib/libbrotlidec.so*
    do
        [ -f "$p" ] && echo "$p"
    done
    # The libraries Node.js and the Python extensions are linked against, wherever they live.
    printf '%s\n' "$CONSUMERS" | awk -F '|' '$1 == "node" { print $4 } $1 == "python" { print $5 }' \
        | grep -vxE '[?-]'
    mapped_paths | grep -E '/libbrotlidec[^/]*$'
}

node_lines() {
    for bin in $(node_candidates | no_separator | sort -u); do
        is_elf "$bin" || continue
        version=$(node_version "$bin")
        linked=$(linked_decoder "$bin")
        case "$linked" in
            "-" | "?") ;;
            *) linked=$(lib_item "$linked") ;;
        esac
        package_facts "$bin"
        echo "node|$bin|$version|$linked|$PKG|$PKG_VERSION|$CVES|$(users_of "$bin")"
    done
}

# Paths a user can choose (home directories, /tmp) may contain the field separator; such
# a candidate is skipped, and in a "stale" line it is replaced.
no_separator() {
    grep -v -e '^$' -e '|'
}

MAPPED=$(mapped_decoders)
CONSUMERS=$(
    node_lines
    for site in $(site_dirs | no_separator | sort -u); do python_packages "$site"; done
)

echo "<<<broccoli2shell:sep(124)>>>"
# Only lines of the known kinds leave the plug-in: whatever slips through into a value can
# never start a section of its own.
{
    echo "plugin|$CMK_VERSION"
    [ -n "$CONSUMERS" ] && printf '%s\n' "$CONSUMERS"

    for root in $(dotnet_roots | no_separator | sort -u); do
        for native in "$root"/shared/Microsoft.NETCore.App/*/libSystem.IO.Compression.Native.so; do
            [ -f "$native" ] || continue
            version=${native%/*}
            version=${version##*/}
            package_facts "$native"
            echo "dotnet|$root|$version|$PKG|$PKG_VERSION|$CVES|$(users_of "$native")"
        done
    done

    for p in $(lib_candidates | no_separator | sort -u); do
        is_elf "$p" && echo "$(lib_item "$p")|$p"
    done | sort -t '|' -k 1,1 -u | while IFS='|' read -r item path; do
        # The soname link points to the library the loader uses; a leftover file of another
        # version in the same directory does not count.
        [ -e "$item" ] && path=$(readlink -f "$item")
        path=$(readlink -f "$path")
        case "${path##*/}" in
            libbrotlidec.so.[0-9]*.[0-9]*.[0-9]*) version=${path##*/libbrotlidec.so.} ;;
            *) version="?" ;;
        esac
        package_facts "$path"
        echo "lib|$item|$version|$PKG|$PKG_VERSION|$CVES|$(users_of "$path")"
    done

    printf '%s\n' "$MAPPED" | sed -n 's/ (deleted)$//p' | tr '|' '?' \
        | while IFS=' ' read -r pid path; do
            echo "stale|$pid|$(process_name "$pid")|$path"
        done
} | grep -E '^(plugin|lib|node|python|dotnet|stale)\|'

exit 0
