#!/usr/bin/env bash
# =============================================================================
#  Telecu Cloud  ·  Security Operations
#  CVE-2026-31431 (Copy Fail) — Linux kernel LPE detection
#  Support:  https://soporte.telecu.cloud
#  GIGAIPNET S.A.S. B.I.C.  (Telecu Cloud)
# =============================================================================
#
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 GIGAIPNET S.A.S. B.I.C. (Telecu Cloud)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# cve-2026-31431-check.sh -- "Copy Fail" detector
#
# Decides whether a Linux host is vulnerable to CVE-2026-31431, an LPE in the
# kernel's algif_aead AEAD socket interface (authencesn template). Considers
# all three remediation paths:
#   * Generic mitigation: initcall_blacklist=algif_aead_init on /proc/cmdline
#   * Patched vendor kernel (CloudLinux/AlmaLinux/RHEL family + upstream)
#   * KernelCare livepatch (CloudLinux KCARE patch K20260430_07 or newer)
# Plus a functional AF_ALG bind probe that proves whether the vulnerable
# code path is reachable from an unprivileged user on this host right now.
#
# Usage:
#   bash cve-2026-31431-check.sh           # human-readable
#   bash cve-2026-31431-check.sh --quiet   # one-line verdict, for batch sweeps
#
# Exit codes:
#   0 = PATCHED or MITIGATED (safe)
#   1 = VULNERABLE
#   2 = UNKNOWN (no Python/Perl available, or insufficient permissions)
#
# References:
#   https://blog.cloudlinux.com/cve-2026-31431-copy-fail-mitigation-and-patches
#   https://cert.europa.eu/publications/security-advisories/2026-005/
#   https://www.sysdig.com/blog/cve-2026-31431-copy-fail-linux-kernel-flaw-lets-local-users-gain-root-in-seconds

QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1

say()  { [ "$QUIET" -eq 0 ] && printf '%s\n' "$*"; return 0; }
hr()   { say "----------------------------------------"; }

VERDICTS=()
add_verdict() { VERDICTS+=("$1"); }

KERNEL=$(uname -r)
ARCH=$(uname -m)
DISTRO_ID=unknown; DISTRO_VER=unknown; PRETTY_NAME=
if [ -r /etc/os-release ]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    DISTRO_ID=${ID:-unknown}
    DISTRO_VER=${VERSION_ID:-unknown}
fi

say "============================================================"
say "  Telecu Cloud  ·  Security Operations"
say "  CVE-2026-31431 (Copy Fail) — Linux kernel LPE detection"
say "  Support: https://soporte.telecu.cloud"
say "============================================================"
say "Host:    $(hostname 2>/dev/null || echo unknown)"
say "Kernel:  $KERNEL ($ARCH)"
say "Distro:  ${PRETTY_NAME:-$DISTRO_ID $DISTRO_VER}"
say "Date:    $(date -u +%FT%TZ)"
hr

# [1] Generic mitigation: initcall_blacklist=algif_aead_init on kernel cmdline.
# This is the only reliable workaround on RHEL-family kernels where
# CONFIG_CRYPTO_USER_API_AEAD=y (algif_aead is built into the kernel image).
say "[1] Kernel command-line mitigation (initcall_blacklist)"
CMDLINE=$(cat /proc/cmdline 2>/dev/null || true)
if printf '%s' "$CMDLINE" | grep -q 'initcall_blacklist=[^ ]*algif_aead_init'; then
    say "    OK: initcall_blacklist=algif_aead_init present in /proc/cmdline"
    add_verdict "MITIGATED:initcall_blacklist"
else
    say "    not applied (no initcall_blacklist=algif_aead_init in /proc/cmdline)"
fi
hr

# [2] KernelCare livepatch.
# CloudLinux ships the fix as livepatch K20260430_07 (and later K2026* IDs).
say "[2] KernelCare livepatch"
if command -v kcarectl >/dev/null 2>&1; then
    KC_INFO=$(kcarectl --patch-info 2>/dev/null; kcarectl --info 2>/dev/null)
    if printf '%s' "$KC_INFO" | grep -qE 'K2026(04(3[0-9])_(0[7-9]|[1-9][0-9])|0[5-9][0-9]{2}|[1-9][0-9]{3})'; then
        say "    OK: KernelCare livepatch covering CVE-2026-31431 detected"
        add_verdict "PATCHED:kernelcare_livepatch"
    else
        say "    kcarectl present but no Copy-Fail livepatch level (K20260430_07+) detected"
        say "    --- kcarectl output (for review) ---"
        printf '%s\n' "$KC_INFO" | sed 's/^/    /'
    fi
else
    say "    kcarectl not installed (KernelCare not in use)"
fi
hr

# [3] Patched vendor kernel.
# Per CloudLinux blog and AlmaLinux ELS:
#   el8/CL8/CL7h: 4.18.0-553.121.1.el8_10
#   el9/CL9     : 5.14.0-611.49.2.el9_7
#   el10/CL10   : 6.12.0-124.52.2.el10_1
# Note: CL7 (CloudLinux 7) is NOT affected per CL advisory.
# Upstream mainline reference: 7.0, 6.19.12, 6.18.22 contain the fix.
# Ubuntu/Debian/SUSE specific patched versions are not encoded here -- we
# fall back to the functional probe in section [5].
say "[3] Patched-kernel version comparison"
case "$DISTRO_ID" in
    cloudlinux|almalinux|rocky|rhel|ol|centos)
        case "$DISTRO_VER" in
            7*)
                if [ "$DISTRO_ID" = "cloudlinux" ]; then
                    say "    CloudLinux 7 is NOT affected (per vendor advisory)"
                    add_verdict "PATCHED:not_affected_cl7"
                    FIX=""
                else
                    FIX=""
                    say "    No patched-kernel data for el7-family distro $DISTRO_ID"
                fi
                ;;
            8*)  FIX="4.18.0-553.121.1"  ;;
            9*)  FIX="5.14.0-611.49.2"   ;;
            10*) FIX="6.12.0-124.52.2"   ;;
            *)   FIX="" ;;
        esac
        if [ -n "$FIX" ]; then
            CMP=$(rpm --eval "%{lua:print(rpm.vercmp('$KERNEL','$FIX'))}" 2>/dev/null)
            case "$CMP" in
                0|1)
                    say "    OK: running kernel $KERNEL >= patched $FIX"
                    add_verdict "PATCHED:vendor_kernel"
                    ;;
                -1)
                    say "    running kernel $KERNEL is OLDER than patched $FIX"
                    ;;
                *)
                    say "    rpm vercmp inconclusive (kernel=$KERNEL, fix=$FIX)"
                    ;;
            esac
        fi
        ;;
    ubuntu|debian|linuxmint|pop|raspbian|kali|devuan)
        # Debian/Ubuntu: per-vendor patched versions vary too much to encode.
        # Instead grep the installed kernel package's changelog for the CVE
        # id, the "Copy Fail" name, or the upstream fix commit hashes. If any
        # appear, the running kernel ships the documented fix.
        IMG_PKG=""
        if command -v dpkg-query >/dev/null 2>&1; then
            if dpkg-query -W "linux-image-$KERNEL" >/dev/null 2>&1; then
                IMG_PKG="linux-image-$KERNEL"
            else
                # Fallback: enumerate linux-image-* packages and pick the one
                # owning /boot/vmlinuz-$KERNEL (covers oddly-named flavours).
                IMG_PKG=$(dpkg-query -W -f='${Package}\n' 'linux-image-*' 2>/dev/null | while read -r p; do
                    dpkg-query -L "$p" 2>/dev/null | grep -qx "/boot/vmlinuz-$KERNEL" && { printf '%s' "$p"; break; }
                done)
            fi
        fi
        if [ -z "$IMG_PKG" ]; then
            say "    cannot identify the linux-image package for kernel $KERNEL"
            say "    rely on the functional probe and a manual changelog review"
        else
            say "    linux-image package: $IMG_PKG"
            FIX_REGEX='CVE-2026-31431|copy.?fail|fafe0fa2995a|a664bf3d603d'
            HIT=""
            CHGLOG_PATH=""
            for f in "/usr/share/doc/$IMG_PKG/changelog.Debian.gz" \
                     "/usr/share/doc/$IMG_PKG/changelog.gz" \
                     "/usr/share/doc/$IMG_PKG/changelog"; do
                [ -r "$f" ] || continue
                CHGLOG_PATH="$f"
                case "$f" in
                    *.gz) HIT=$(zgrep -aiE "$FIX_REGEX" "$f" 2>/dev/null | head -5) ;;
                    *)    HIT=$(grep  -aiE "$FIX_REGEX" "$f" 2>/dev/null | head -5) ;;
                esac
                [ -n "$HIT" ] && break
            done
            if [ -z "$HIT" ] && command -v apt >/dev/null 2>&1; then
                # Network fallback (slow, requires repo metadata)
                HIT=$(apt -qq changelog "$IMG_PKG" 2>/dev/null | grep -aiE "$FIX_REGEX" | head -5)
                [ -n "$HIT" ] && CHGLOG_PATH="apt changelog $IMG_PKG"
            fi
            if [ -n "$HIT" ]; then
                say "    OK: vendor changelog references the fix:"
                printf '%s\n' "$HIT" | sed 's/^/        /'
                say "    (source: $CHGLOG_PATH)"
                add_verdict "PATCHED:vendor_changelog"
            elif [ -n "$CHGLOG_PATH" ]; then
                say "    no CVE-2026-31431 / fix-commit reference in $CHGLOG_PATH"
                say "    -> running kernel does NOT advertise the documented fix"
            else
                say "    no readable changelog for $IMG_PKG; rely on functional probe"
            fi
        fi
        ;;
    sles|opensuse*|suse)
        say "    No embedded patched-version table for SUSE; rely on probe."
        ;;
    *)
        say "    Distro $DISTRO_ID not in patched-version table; rely on probe."
        ;;
esac
hr

# [4] modprobe blacklist.
# This works ONLY if algif_aead is a loadable module. On RHEL-family stock
# kernels CONFIG_CRYPTO_USER_API_AEAD=y, so the module is built in and a
# modprobe blacklist is a no-op -- per the CloudLinux advisory.
say "[4] modprobe blacklist (only effective if module is loadable)"
BUILTIN=0
LOADABLE=0
MODFILE=""
if [ -r "/lib/modules/$KERNEL/modules.builtin" ] && \
   grep -q '/algif_aead\.ko' "/lib/modules/$KERNEL/modules.builtin" 2>/dev/null; then
    BUILTIN=1
fi
if command -v modinfo >/dev/null 2>&1; then
    MODFILE=$(modinfo -F filename algif_aead 2>/dev/null || true)
    case "$MODFILE" in
        ""|"name")
            : ;;
        "(builtin)")
            BUILTIN=1 ;;
        *)
            [ -e "$MODFILE" ] && LOADABLE=1 ;;
    esac
fi
if [ "$BUILTIN" -eq 1 ]; then
    say "    algif_aead is BUILT-IN (CONFIG_CRYPTO_USER_API_AEAD=y)"
    say "    -> any /etc/modprobe.d blacklist for algif_aead is a no-op"
elif [ "$LOADABLE" -eq 1 ]; then
    say "    algif_aead is a loadable module ($MODFILE)"
else
    say "    algif_aead not present in this kernel build (neither built-in nor loadable)"
fi
BLACKLIST_FILE=$(grep -rls -- 'algif_aead' /etc/modprobe.d /etc/modprobe.conf 2>/dev/null | head -1 || true)
if [ -n "$BLACKLIST_FILE" ]; then
    say "    blacklist directive found in: $BLACKLIST_FILE"
    if [ "$LOADABLE" -eq 1 ]; then
        if lsmod 2>/dev/null | awk '{print $1}' | grep -qx algif_aead; then
            say "    WARN: blacklisted but algif_aead is currently loaded -- run: rmmod algif_aead"
        else
            say "    OK: module loadable, blacklisted, and not currently loaded"
            add_verdict "MITIGATED:modprobe_blacklist"
        fi
    elif [ "$BUILTIN" -eq 1 ]; then
        say "    blacklist has no effect -- module is built-in"
    fi
else
    say "    no algif_aead entry under /etc/modprobe.d"
fi
hr

# [5] Functional AF_ALG AEAD reachability probe.
# Authoritative for "can an unprivileged process reach the vulnerable
# code path right now". Does NOT distinguish vulnerable vs. patched code,
# so the verdict combines this with sections [2] and [3].
say "[5] Functional AF_ALG AEAD reachability probe"
# Try interpreters in order until one returns a definitive answer.
# Python's tuple-form AF_ALG bind only works on 3.6+, so on older systems
# (e.g. Debian 9 stretch w/ python3.5) we report bind_unsupported and the
# wrapper falls through to perl, which packs sockaddr_alg manually.
PROBE="SKIP"
PROBE_VIA=""

probe_via_python () {
    "$1" - <<'PY' 2>/dev/null
import socket, sys
AF_ALG = getattr(socket, "AF_ALG", 38)
SOCK_SEQPACKET = getattr(socket, "SOCK_SEQPACKET", 5)
try:
    s = socket.socket(AF_ALG, SOCK_SEQPACKET, 0)
except OSError as e:
    print("AF_ALG_UNAVAILABLE:errno=%d" % e.errno); sys.exit(0)
except Exception as e:
    print("AF_ALG_UNAVAILABLE:%s" % type(e).__name__); sys.exit(0)
try:
    s.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
    print("AEAD_REACHABLE")
except OSError as e:
    print("AEAD_BIND_FAILED:errno=%d" % e.errno)
except (TypeError, ValueError):
    print("BIND_UNSUPPORTED")
finally:
    try: s.close()
    except: pass
PY
}

probe_via_perl () {
    # struct sockaddr_alg = u16 family + u8[14] type + u32 feat + u32 mask + u8[64] name = 88 bytes
    perl - <<'PL' 2>/dev/null
use strict; use warnings; use Socket;
my $AF_ALG = 38;
my $SOCK_SEQPACKET = 5;
socket(my $s, $AF_ALG, $SOCK_SEQPACKET, 0) or do { my $e=$!+0; print "AF_ALG_UNAVAILABLE:errno=$e\n"; exit 0 };
my $sa = pack('S a14 L L a64', $AF_ALG, 'aead', 0, 0, 'authencesn(hmac(sha256),cbc(aes))');
if (bind($s, $sa)) { print "AEAD_REACHABLE\n" } else { my $e=$!+0; print "AEAD_BIND_FAILED:errno=$e\n" }
close($s);
PL
}

is_definitive () {
    case "$1" in
        AEAD_REACHABLE|AEAD_BIND_FAILED:*|AF_ALG_UNAVAILABLE:*) return 0 ;;
        *) return 1 ;;
    esac
}

for entry in "python3:probe_via_python python3" \
             "python:probe_via_python python" \
             "perl:probe_via_perl"; do
    bin=${entry%%:*}
    fn=${entry#*:}
    command -v "$bin" >/dev/null 2>&1 || continue
    R=$($fn)
    if is_definitive "$R"; then
        PROBE="$R"; PROBE_VIA="$bin"; break
    fi
done

REACHABLE=-1
case "$PROBE" in
    AEAD_REACHABLE)
        say "    REACHABLE (via $PROBE_VIA): bind to authencesn(hmac(sha256),cbc(aes)) succeeded"
        say "    -> vulnerable surface is exposed unless kernel itself is patched"
        REACHABLE=1
        ;;
    AEAD_BIND_FAILED:*)
        say "    blocked (via $PROBE_VIA): $PROBE"
        say "    -> AEAD interface registration is gone (probably initcall blacklist)"
        add_verdict "MITIGATED:aead_unreachable"
        REACHABLE=0
        ;;
    AF_ALG_UNAVAILABLE:*)
        say "    blocked (via $PROBE_VIA): $PROBE"
        say "    -> AF_ALG itself unreachable (CONFIG_CRYPTO_USER_API=n or sandbox)"
        add_verdict "MITIGATED:af_alg_unreachable"
        REACHABLE=0
        ;;
    SKIP|*)
        say "    skipped: no working interpreter (need python3>=3.6, python, or perl)"
        REACHABLE=-1
        ;;
esac
hr

# [6] Final verdict.
# Priority: PATCHED > MITIGATED > (probe-only signal) > UNKNOWN.
HAS_PATCHED=0
HAS_MITIGATED=0
for v in ${VERDICTS[@]+"${VERDICTS[@]}"}; do
    case "$v" in
        PATCHED:*)   HAS_PATCHED=1   ;;
        MITIGATED:*) HAS_MITIGATED=1 ;;
    esac
done

say "=== VERDICT ==="
if [ "$HAS_PATCHED" -eq 1 ]; then
    STATUS="PATCHED"
    EXIT=0
elif [ "$HAS_MITIGATED" -eq 1 ]; then
    STATUS="MITIGATED"
    EXIT=0
elif [ "$REACHABLE" -eq 1 ]; then
    STATUS="VULNERABLE"
    EXIT=1
else
    STATUS="UNKNOWN"
    EXIT=2
fi

if [ "$QUIET" -eq 1 ]; then
    detail=""
    for v in ${VERDICTS[@]+"${VERDICTS[@]}"}; do detail="$detail,$v"; done
    printf '%s host=%s kernel=%s distro=%s%s\n' \
        "$STATUS" "$(hostname 2>/dev/null)" "$KERNEL" "$DISTRO_ID-$DISTRO_VER" "$detail"
else
    say "$STATUS"
    if [ ${#VERDICTS[@]} -gt 0 ]; then
        say "Controls in place:"
        for v in "${VERDICTS[@]}"; do say "  - $v"; done
    fi
    if [ "$STATUS" = "VULNERABLE" ]; then
        say ""
        say "Mitigation options for this host:"
        case "$DISTRO_ID" in
            cloudlinux|almalinux|rocky|rhel|ol|centos|fedora)
                say "  A) Generic (initcall blacklist, requires reboot):"
                say "       grubby --update-kernel=ALL --args=\"initcall_blacklist=algif_aead_init\" && reboot"
                ;;
            ubuntu|debian|linuxmint|pop|raspbian|kali|devuan)
                say "  A) Generic (initcall blacklist, requires reboot):"
                say "       sed -i 's|^GRUB_CMDLINE_LINUX=\"\\(.*\\)\"|GRUB_CMDLINE_LINUX=\"\\1 initcall_blacklist=algif_aead_init\"|' /etc/default/grub"
                say "       update-grub && reboot"
                ;;
            *)
                say "  A) Generic (initcall blacklist): add 'initcall_blacklist=algif_aead_init' to the kernel cmdline via your bootloader config, then reboot."
                ;;
        esac
        if [ "$LOADABLE" -eq 1 ]; then
            say "  B) Modprobe blacklist (algif_aead is loadable on this host -- a real fix here):"
            say "       echo 'install algif_aead /bin/false' > /etc/modprobe.d/disable-algif-aead.conf"
            if lsmod 2>/dev/null | awk '{print $1}' | grep -qx algif_aead; then
                say "       rmmod algif_aead   # currently loaded"
            fi
        fi
        say "  C) Install the vendor patched kernel (preferred long-term)."
        say "Re-run this script after applying any option to confirm."
    fi
    say ""
    say "============================================================"
    say "  Need help?  Open a ticket at https://soporte.telecu.cloud"
    say "  Telecu Cloud  ·  GIGAIPNET S.A.S. B.I.C."
    say "============================================================"
fi
exit $EXIT
