#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Jasper Nuyens <jnuyens@linuxbe.com>
# cve-watch.sh — companion to ModuleJail. Polls upstream CVE feeds for
# newly-published Linux kernel module vulnerabilities and emits a report.
#
# Designed for cron. Stays silent when there are no findings.
# Sources:
#   1. NVD 2.0 API     (keyword search: "kernel module")
#   2. linux-cve-announce Atom feed (official Linux kernel CNA)
set -eu

LC_ALL=C
export LC_ALL

VERSION='0.1.0'
EX_OK=0
EX_USAGE=64
EX_SOFTWARE=70
EX_OSERR=71

WINDOW_HOURS=7
MAIL_TO=''
NVD_API='https://services.nvd.nist.gov/rest/json/cves/2.0'
LORE_FEED='https://lore.kernel.org/linux-cve-announce/new.atom'
UA="cve-watch.sh/$VERSION (+https://github.com/jnuyens/modulejail)"

usage() {
    cat <<EOF
cve-watch.sh $VERSION — watch for new Linux kernel module CVEs

Usage: cve-watch.sh [--window HOURS] [--mail ADDRESS] [--help] [--version]

Queries NVD 2.0 and the linux-cve-announce Atom feed for CVEs published
within the last WINDOW_HOURS, filters for Linux kernel module relevance,
dedupes by CVE ID, and emits a human-readable report.

Stays silent when there are no findings.

Options:
  --window HOURS    Lookback window (default: 7; intended for a 6h cron
                    with 1h overlap to absorb boundary jitter)
  --mail ADDRESS    Send the report by mail(1) to ADDRESS with a
                    meaningful subject. Without this, the report goes
                    to stdout (and cron will mail it via MAILTO=).
  -h, --help        Show this help
  --version         Print version

Exit codes:
   0  OK (zero or more findings — report emitted on stdout/mail or silent)
  64  Usage error
  70  Software error (parse failure)
  71  OS error (both upstream sources failed)
EOF
}

# --- arg parse ---
while [ $# -gt 0 ]; do
    case "$1" in
        --window)        WINDOW_HOURS="${2?missing value for --window}"; shift 2 ;;
        --window=*)      WINDOW_HOURS="${1#*=}"; shift ;;
        --mail)          MAIL_TO="${2?missing value for --mail}"; shift 2 ;;
        --mail=*)        MAIL_TO="${1#*=}"; shift ;;
        -h|--help)       usage; exit $EX_OK ;;
        --version)       echo "cve-watch.sh $VERSION"; exit $EX_OK ;;
        --)              shift; break ;;
        *)               printf 'cve-watch.sh: unknown argument: %s\n' "$1" >&2
                         usage >&2
                         exit $EX_USAGE ;;
    esac
done

case "$WINDOW_HOURS" in
    ''|*[!0-9]*) printf 'cve-watch.sh: --window must be a positive integer\n' >&2; exit $EX_USAGE ;;
esac
[ "$WINDOW_HOURS" -gt 0 ] || { printf 'cve-watch.sh: --window must be > 0\n' >&2; exit $EX_USAGE; }

# --- prereqs ---
for t in curl jq python3 date mktemp; do
    command -v "$t" >/dev/null 2>&1 || { printf 'cve-watch.sh: missing required tool: %s\n' "$t" >&2; exit $EX_OSERR; }
done

WORKDIR="$(mktemp -d -t cve-watch.XXXXXX)"
trap 'rm -rf "$WORKDIR"' EXIT INT TERM

# --- time window (UTC, ISO 8601 without timezone suffix, which NVD expects) ---
NOW="$(date -u +%Y-%m-%dT%H:%M:%S)"
SINCE="$(date -u -d "${WINDOW_HOURS} hours ago" +%Y-%m-%dT%H:%M:%S)"

# --- query NVD ---
NVD_OUT="$WORKDIR/nvd.json"
NVD_ERR="$WORKDIR/nvd.err"
nvd_ok=1
if ! curl -sS --max-time 30 -A "$UA" \
        --data-urlencode 'keywordSearch=kernel module' \
        --data-urlencode "pubStartDate=${SINCE}.000" \
        --data-urlencode "pubEndDate=${NOW}.000" \
        -G "$NVD_API" -o "$NVD_OUT" 2>"$NVD_ERR"; then
    nvd_ok=0
fi
if [ "$nvd_ok" -eq 1 ] && ! jq -e . "$NVD_OUT" >/dev/null 2>&1; then
    nvd_ok=0
    echo 'NVD response was not valid JSON' >"$NVD_ERR"
fi

# --- query lore.kernel.org ---
LORE_OUT="$WORKDIR/lore.atom"
LORE_ERR="$WORKDIR/lore.err"
lore_ok=1
if ! curl -sS --max-time 30 -A "$UA" "$LORE_FEED" -o "$LORE_OUT" 2>"$LORE_ERR"; then
    lore_ok=0
fi
if [ "$lore_ok" -eq 1 ] && ! grep -q '<feed' "$LORE_OUT" 2>/dev/null; then
    lore_ok=0
    echo 'lore.kernel.org response did not look like an Atom feed' >"$LORE_ERR"
fi

# Both sources dead → noisy error so cron mails it (real outage worth knowing about)
if [ "$nvd_ok" -eq 0 ] && [ "$lore_ok" -eq 0 ]; then
    printf 'cve-watch.sh: BOTH upstream sources failed at %sZ\n' "$NOW" >&2
    printf '  NVD:  %s\n' "$(head -2 "$NVD_ERR" 2>/dev/null | tr '\n' ' ')" >&2
    printf '  lore: %s\n' "$(head -2 "$LORE_ERR" 2>/dev/null | tr '\n' ' ')" >&2
    exit $EX_OSERR
fi

# --- parse, filter, dedupe via python (Atom parsing is fragile without a real XML parser) ---
REPORT="$WORKDIR/report.txt"
if ! python3 - "$NVD_OUT" "$LORE_OUT" "$nvd_ok" "$lore_ok" "$SINCE" "$NOW" "$WINDOW_HOURS" >"$REPORT" 2>"$WORKDIR/py.err" <<'PY'
import json
import re
import sys
from datetime import datetime, timezone
from xml.etree import ElementTree as ET

nvd_path, lore_path, nvd_ok, lore_ok, since_s, now_s, window_hours = sys.argv[1:]

since = datetime.strptime(since_s, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
now = datetime.strptime(now_s, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)

LINUX_RX = re.compile(r"\blinux\b", re.IGNORECASE)
MODULE_RX = re.compile(r"\b(module|driver|loadable)\b", re.IGNORECASE)
# Exclude other-vendor kernels that happen to match "kernel module"
NEGATIVE_RX = re.compile(
    r"\b(windows|macos|mac os x|cisco ios|fortios|junos|solaris|aix|freebsd|netbsd|openbsd|esxi|vmware kernel)\b",
    re.IGNORECASE,
)
CVE_ID_RX = re.compile(r"CVE-\d{4}-\d{4,}")

items = {}  # CVE-ID -> dict

# ---- NVD ----
if nvd_ok == "1":
    try:
        with open(nvd_path, "r", encoding="utf-8") as f:
            nvd = json.load(f)
        for entry in nvd.get("vulnerabilities", []):
            cve = entry.get("cve") or {}
            cid = cve.get("id")
            if not cid:
                continue
            descs = cve.get("descriptions") or []
            en = next((d.get("value", "") for d in descs if d.get("lang") == "en"), "")
            if not en:
                continue
            if NEGATIVE_RX.search(en):
                continue
            if not (LINUX_RX.search(en) and MODULE_RX.search(en)):
                continue
            items.setdefault(cid, {
                "sources": set(),
                "desc": en,
                "published": cve.get("published", ""),
                "nvd_link": f"https://nvd.nist.gov/vuln/detail/{cid}",
                "lore_link": "",
            })["sources"].add("NVD")
    except Exception as e:
        print(f"NVD parse error: {e}", file=sys.stderr)

# ---- lore.kernel.org ----
if lore_ok == "1":
    try:
        ns = {"atom": "http://www.w3.org/2005/Atom"}
        root = ET.parse(lore_path).getroot()
        for entry in root.findall("atom:entry", ns):
            title_el = entry.find("atom:title", ns)
            updated_el = entry.find("atom:updated", ns)
            link_el = entry.find("atom:link", ns)
            id_el = entry.find("atom:id", ns)
            summary_el = entry.find("atom:summary", ns)
            content_el = entry.find("atom:content", ns)

            title = (title_el.text or "") if title_el is not None else ""
            updated = (updated_el.text or "") if updated_el is not None else ""
            link = ""
            if link_el is not None:
                link = link_el.attrib.get("href", "") or ""
            if not link and id_el is not None:
                link = id_el.text or ""

            body = ""
            if summary_el is not None and summary_el.text:
                body = summary_el.text
            elif content_el is not None and content_el.text:
                body = content_el.text

            try:
                upd_dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
            except Exception:
                continue
            if not (since <= upd_dt <= now):
                continue

            haystack = f"{title}\n{body}"
            if NEGATIVE_RX.search(haystack):
                continue
            if not MODULE_RX.search(haystack):
                continue

            m = CVE_ID_RX.search(title) or CVE_ID_RX.search(body)
            if not m:
                continue
            cid = m.group(0)

            it = items.setdefault(cid, {
                "sources": set(),
                "desc": title.strip(),
                "published": updated,
                "nvd_link": f"https://nvd.nist.gov/vuln/detail/{cid}",
                "lore_link": link,
            })
            it["sources"].add("lore")
            if not it["lore_link"]:
                it["lore_link"] = link
    except Exception as e:
        print(f"lore parse error: {e}", file=sys.stderr)

# ---- emit ----
if not items:
    sys.exit(0)  # silent on no hits

src_used = []
if nvd_ok == "1":
    src_used.append("NVD")
if lore_ok == "1":
    src_used.append("linux-cve-announce")
sources_used = " + ".join(src_used) if src_used else "(none)"
degraded = " [DEGRADED: one source unavailable]" if (nvd_ok == "0" or lore_ok == "0") else ""

print(f"New Linux kernel module CVE(s) detected: {len(items)}{degraded}")
print()
print(f"Window:    {since_s}Z .. {now_s}Z  ({window_hours}h lookback)")
print(f"Sources:   {sources_used}")
print(f"Checked:   {now_s} UTC")
print()
print("───── Findings ─────────────────────────────────────────────")
print()

for cid in sorted(items.keys()):
    it = items[cid]
    src_str = "+".join(sorted(it["sources"]))
    pub = it["published"][:19] if it["published"] else "(unknown date)"
    desc = " ".join(it["desc"].split())
    if len(desc) > 360:
        desc = desc[:357] + "..."
    print(f"  {cid}  ({pub}, source: {src_str})")
    print(f"    {desc}")
    print(f"    NVD: {it['nvd_link']}")
    if it.get("lore_link"):
        print(f"    CNA: {it['lore_link']}")
    print()

print("───── Triage ───────────────────────────────────────────────")
print()
print("  For each finding above, check whether the affected module is")
print("  loaded on hosts in your fleet:")
print()
print("    ssh <host> lsmod | grep -i <module-name>")
print()
print("  If LOADED:    ModuleJail keeps it — host needs the upstream patch.")
print("  If UNLOADED:  ModuleJail blacklists it on the next run.")
PY
then
    py_status=$?
    printf 'cve-watch.sh: python parser failed (exit %d)\n' "$py_status" >&2
    cat "$WORKDIR/py.err" >&2
    exit $EX_SOFTWARE
fi

# Silent when there's nothing to say
[ -s "$REPORT" ] || exit $EX_OK

# --- emit: mail if --mail given, stdout otherwise ---
if [ -n "$MAIL_TO" ]; then
    # Subject derived from the report's leading summary line
    SUBJECT_BODY="$(head -1 "$REPORT")"
    SUBJECT="[ModuleJail CVE] ${SUBJECT_BODY}"
    mail -s "$SUBJECT" "$MAIL_TO" <"$REPORT"
else
    cat "$REPORT"
fi

exit $EX_OK
