#!/bin/sh
# cve -- CVE notification daemon for dunst + claude triage + email alerting
# Install: ln -sf ~/dev/omniscient/scripts/cve ~/.local/bin/cve
#
# Two cron modes:
#   0 */1 * * * ~/.local/bin/cve --deep     # hourly: claude triage + email on affected
#   0 */6 * * * ~/.local/bin/cve --scan     # every 6h: dunst notifications only
#
# Manual:
#   cve                    # interactive: dunst + claude triage
#   cve --deep             # hourly mode: claude triage, email if affected, no dunst
#   cve --scan             # 6h mode: dunst notifications only, no claude
#   cve --list             # print recent CVEs to stdout
#   cve --triage           # show all triage results
#   cve --history          # show notification log

# no set -e: jq may exit non-zero on individual CVEs with bad data

# ============================================================================
# CONFIG
# ============================================================================

CVE_DIR="$HOME/.local/share/cve"
CVE_DB="$CVE_DIR/seen.json"
CVE_LOG="$CVE_DIR/cve.log"
CVE_TRIAGE="$CVE_DIR/triage"
PACKAGES_CACHE="$CVE_DIR/packages.txt"
KERNEL_VER="$(uname -r)"
EMAIL_TO="krisyotam@gmail.com"

# how many hours back to check (overridden per mode below)
HOURS_BACK=4

# severity filter: LOW, MEDIUM, HIGH, CRITICAL
MIN_SEVERITY="HIGH"

# NVD API (no key needed for low-rate, but add one for higher limits)
# Get a key at https://nvd.nist.gov/developers/request-an-api-key
NVD_API_KEY=""
NVD_BASE="https://services.nvd.nist.gov/rest/json/cves/2.0"

# CISA KEV (Known Exploited Vulnerabilities) -- actively exploited in the wild
CISA_KEV="https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"

# ============================================================================
# INIT
# ============================================================================

mkdir -p "$CVE_DIR" "$CVE_TRIAGE"

# initialize seen db if missing
if [ ! -f "$CVE_DB" ]; then
    echo '{"seen":[],"triaged":[]}' > "$CVE_DB"
fi

log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M')" "$1" >> "$CVE_LOG"; }
notify() {
    urgency="${2:-normal}"
    dunstify -u "$urgency" -t 15000 -a "CVE Monitor" "CVE Alert" "$1" 2>/dev/null || true
}

# ============================================================================
# DEPENDENCY INVENTORY
# ============================================================================

DEPS_CACHE="$CVE_DIR/deps.txt"
DEPS_NAMES="$CVE_DIR/deps-names.txt"
DEPS_SCRIPT="$HOME/.local/bin/deps"

# populate deps cache once per run
refresh_deps() {
    if [ -x "$DEPS_SCRIPT" ]; then
        "$DEPS_SCRIPT" > "$DEPS_CACHE" 2>/dev/null
    else
        pacman -Q 2>/dev/null | awk '{printf "%s\t%s\tpacman\n", $1, $2}' > "$DEPS_CACHE"
    fi
    # extract just lowercased names for fast matching
    awk -F'\t' '{print tolower($1)}' "$DEPS_CACHE" | sort -u > "$DEPS_NAMES"
}

# check if a CVE's affected products match any local dep
# sets MATCHED_PKGS with "name version (source)" lines
# returns 0 if match found, 1 otherwise
matches_local() {
    _ml_vuln="$1"
    MATCHED_PKGS=""

    # extract CPE product names from configurations
    _ml_products=$(printf '%s' "$_ml_vuln" | jq -r '
        [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] |
        map(split(":") | if length > 4 then .[3] + "\n" + .[4] else empty end) |
        unique | join("\n")
    ' 2>/dev/null | tr '[:upper:]' '[:lower:]' | grep -v '^$' | sort -u)

    if [ -n "$_ml_products" ]; then
        _ml_matches=""
        echo "$_ml_products" | while IFS= read -r prod; do
            [ -z "$prod" ] && continue
            grep -iF "$prod" "$DEPS_CACHE" 2>/dev/null | awk -F'\t' '{printf "%s %s (%s)\n", $1, $2, $3}'
        done | sort -u > "$CVE_DIR/.matched_tmp" 2>/dev/null

        if [ -s "$CVE_DIR/.matched_tmp" ]; then
            MATCHED_PKGS=$(cat "$CVE_DIR/.matched_tmp")
            rm -f "$CVE_DIR/.matched_tmp"
            return 0
        fi
        rm -f "$CVE_DIR/.matched_tmp"
    fi

    return 1
}

# legacy: still needed for claude triage prompt
refresh_packages() {
    cp "$DEPS_CACHE" "$PACKAGES_CACHE" 2>/dev/null || refresh_deps
}

# ============================================================================
# CVE FETCHING
# ============================================================================

fetch_nvd_to_file() {
    outfile="$1"
    now_utc=$(date -u '+%Y-%m-%dT%H:%M:%S.000')
    past_utc=$(date -u -d "-${HOURS_BACK} hours" '+%Y-%m-%dT%H:%M:%S.000' 2>/dev/null || \
               date -u -v-${HOURS_BACK}H '+%Y-%m-%dT%H:%M:%S.000' 2>/dev/null || \
               echo "")

    [ -z "$past_utc" ] && { log "Cannot compute date offset"; return 1; }

    url="${NVD_BASE}?pubStartDate=${past_utc}&pubEndDate=${now_utc}"

    # fetch to temp, sanitize through python (NVD returns invalid escapes), write to outfile
    fetch_tmp=$(mktemp "$CVE_DIR/fetch.XXXXXX")
    if [ -n "$NVD_API_KEY" ]; then
        curl -sf --max-time 30 -H "apiKey: $NVD_API_KEY" "$url" -o "$fetch_tmp" 2>/dev/null || { log "NVD fetch failed"; rm -f "$fetch_tmp"; return 1; }
    else
        curl -sf --max-time 30 "$url" -o "$fetch_tmp" 2>/dev/null || { log "NVD fetch failed"; rm -f "$fetch_tmp"; return 1; }
    fi

    # sanitize JSON (NVD sometimes has invalid escape sequences)
    python3 -c "import sys,json; json.dump(json.load(open(sys.argv[1])),open(sys.argv[2],'w'))" "$fetch_tmp" "$outfile" 2>/dev/null || {
        # fallback: just copy raw and hope jq handles it
        cp "$fetch_tmp" "$outfile"
    }
    rm -f "$fetch_tmp"
}

fetch_cisa_kev_to_file() {
    outfile="$1"
    fetch_tmp=$(mktemp "$CVE_DIR/kevfetch.XXXXXX")
    curl -sf --max-time 60 "$CISA_KEV" -o "$fetch_tmp" 2>/dev/null || { log "CISA KEV fetch failed"; rm -f "$fetch_tmp"; return 1; }
    python3 -c "
import sys,json
with open(sys.argv[1]) as f:
    raw = f.read()
# strip control chars that break jq
clean = ''.join(c if ord(c) >= 32 or c in '\n\r\t' else ' ' for c in raw)
d = json.loads(clean)
with open(sys.argv[2],'w') as out:
    json.dump(d, out, ensure_ascii=True)
" "$fetch_tmp" "$outfile" 2>/dev/null || cp "$fetch_tmp" "$outfile"
    rm -f "$fetch_tmp"
}

# ============================================================================
# PARSE + FILTER
# ============================================================================

parse_nvd() {
    raw="$1"
    [ -z "$raw" ] && return

    echo "$raw" | jq -r '
        .vulnerabilities[]? |
        {
            id: .cve.id,
            description: (.cve.descriptions[]? | select(.lang == "en") | .value),
            severity: (.cve.metrics.cvssMetricV31[]?.cvssData.baseSeverity // .cve.metrics.cvssMetricV30[]?.cvssData.baseSeverity // "UNKNOWN"),
            score: (.cve.metrics.cvssMetricV31[]?.cvssData.baseScore // .cve.metrics.cvssMetricV30[]?.cvssData.baseScore // 0),
            published: .cve.published,
            references: [.cve.references[]?.url] | join(" ")
        }
    ' 2>/dev/null || true
}

parse_cisa_new() {
    raw="$1"
    today=$(date '+%Y-%m-%d')
    yesterday=$(date -d '-1 day' '+%Y-%m-%d' 2>/dev/null || date -v-1d '+%Y-%m-%d' 2>/dev/null || echo "$today")

    echo "$raw" | jq -r --arg today "$today" --arg yesterday "$yesterday" '
        .vulnerabilities[]? |
        select(.dateAdded >= $yesterday) |
        {
            id: .cveID,
            description: .shortDescription,
            severity: "CRITICAL-KEV",
            score: 10,
            published: .dateAdded,
            references: .notes
        }
    ' 2>/dev/null || true
}

is_seen() {
    _is_id="$1"
    jq -r --arg id "$_is_id" '.seen[] | select(. == $id)' "$CVE_DB" 2>/dev/null | grep -q .
}

mark_seen() {
    _mk_id="$1"
    _mk_tmp=$(mktemp "$CVE_DIR/tmp.XXXXXX")
    jq --arg id "$_mk_id" '.seen += [$id] | .seen |= unique' "$CVE_DB" > "$_mk_tmp" && mv "$_mk_tmp" "$CVE_DB"
}

# ============================================================================
# TRIAGE WITH CLAUDE
# ============================================================================

triage_cve() {
    cveid="$1"
    desc="$2"
    severity="$3"
    score="$4"

    triage_file="$CVE_TRIAGE/${cveid}.json"
    [ -f "$triage_file" ] && return 0

    refresh_packages

    prompt="You are a security triage assistant. Analyze this CVE against the system inventory below.

CVE: $cveid
Severity: $severity (Score: $score)
Description: $desc

SYSTEM INVENTORY:
$(cat "$PACKAGES_CACHE")

TASK:
1. Determine if this CVE affects ANY package, service, kernel version, or language runtime on this system.
2. If YES: respond with EXACTLY this JSON (no markdown, no backticks):
   {\"affected\": true, \"package\": \"<name>\", \"installed_version\": \"<ver>\", \"action\": \"<what to do>\", \"urgency\": \"immediate|soon|monitor\"}
3. If NO: respond with EXACTLY this JSON:
   {\"affected\": false, \"reason\": \"<why not>\"}

Be conservative -- if uncertain, say affected. Better a false positive than a missed vuln."

    result=$(echo "$prompt" | claude -p --model haiku 2>/dev/null) || { log "Claude triage failed for $cveid"; return 1; }

    # extract JSON from response (claude might wrap it)
    json=$(echo "$result" | grep -o '{.*}' | head -1)
    [ -z "$json" ] && { log "No JSON in triage response for $cveid"; return 1; }

    echo "$json" > "$triage_file"

    # check if affected
    affected=$(echo "$json" | jq -r '.affected' 2>/dev/null)
    if [ "$affected" = "true" ]; then
        pkg=$(echo "$json" | jq -r '.package // "unknown"' 2>/dev/null)
        action=$(echo "$json" | jq -r '.action // "investigate"' 2>/dev/null)
        urgency=$(echo "$json" | jq -r '.urgency // "monitor"' 2>/dev/null)

        # notify via dunst with critical urgency
        notify "$cveid AFFECTS $pkg -- $action" "critical"
        log "AFFECTED: $cveid -> $pkg ($urgency)"

        # email alert
        email_body="CVE ALERT: $cveid affects your system

Severity: $severity (Score: $score)
Package: $pkg
Action: $action
Urgency: $urgency

Description: $desc

Triage output:
$json

-- CVE Monitor (omniscient)"

        echo "$email_body" | "$HOME/.local/bin/email" -s "CVE ALERT: $cveid affects $pkg [$urgency]" "$EMAIL_TO" 2>/dev/null || log "Email failed for $cveid"

        return 0
    fi

    log "NOT AFFECTED: $cveid"
    return 0
}

# ============================================================================
# MAIN MODES
# ============================================================================

# mode: "deep" = claude triage + email (hourly cron)
#       "scan" = dunst only (6h cron)
#       "interactive" = both dunst + claude (manual run)
do_check() {
    mode="${1:-interactive}"

    # set time window per mode (with overlap buffer)
    case "$mode" in
        deep)        HOURS_BACK=2;  use_dunst=false; use_claude=true ;;
        scan)        HOURS_BACK=8;  use_dunst=true;  use_claude=false ;;
        interactive) HOURS_BACK=4;  use_dunst=true;  use_claude=true ;;
    esac

    log "Starting CVE check (mode=$mode, hours_back=$HOURS_BACK, min_severity=$MIN_SEVERITY)"

    # build local deps inventory for matching
    refresh_deps
    dep_count=$(wc -l < "$DEPS_NAMES" | tr -d ' ')
    log "Loaded $dep_count local deps for matching"

    new_count=0
    notify_count=0
    nvd_tmp=$(mktemp "$CVE_DIR/nvd.XXXXXX")
    nvd_raw_file=$(mktemp "$CVE_DIR/raw.XXXXXX")

    # fetch from NVD directly to file (too large for shell variables)
    fetch_nvd_to_file "$nvd_raw_file"

    if [ -s "$nvd_raw_file" ] && jq -e '.vulnerabilities' "$nvd_raw_file" >/dev/null 2>&1; then
        total=$(jq '.totalResults // 0' "$nvd_raw_file" 2>/dev/null)
        log "NVD returned $total CVEs"

        # pre-filter: only CRITICAL severity from NVD
        jq -c '
            .vulnerabilities[]? |
            . as $v |
            ($v.cve.metrics.cvssMetricV40[0]?.cvssData.baseSeverity //
             $v.cve.metrics.cvssMetricV31[0]?.cvssData.baseSeverity //
             $v.cve.metrics.cvssMetricV30[0]?.cvssData.baseSeverity //
             "UNKNOWN") as $sev |
            if ($sev == "CRITICAL" or $sev == "HIGH") then $v
            else empty end
        ' "$nvd_raw_file" 2>/dev/null > "$nvd_tmp"

        filtered=$(wc -l < "$nvd_tmp" | tr -d ' ')
        log "After severity filter: $filtered CVEs (HIGH+CRITICAL)"

        while IFS= read -r vuln; do
            cveid=$(printf '%s' "$vuln" | jq -r '.cve.id' 2>/dev/null)
            [ -z "$cveid" ] || [ "$cveid" = "null" ] && continue

            if is_seen "$cveid"; then
                continue
            fi

            severity=$(printf '%s' "$vuln" | jq -r '(.cve.metrics.cvssMetricV40[0]?.cvssData.baseSeverity // .cve.metrics.cvssMetricV31[0]?.cvssData.baseSeverity // .cve.metrics.cvssMetricV30[0]?.cvssData.baseSeverity // "UNKNOWN")' 2>/dev/null)
            score=$(printf '%s' "$vuln" | jq -r '(.cve.metrics.cvssMetricV40[0]?.cvssData.baseScore // .cve.metrics.cvssMetricV31[0]?.cvssData.baseScore // .cve.metrics.cvssMetricV30[0]?.cvssData.baseScore // 0)' 2>/dev/null)
            desc=$(printf '%s' "$vuln" | jq -r '[.cve.descriptions[]? | select(.lang == "en") | .value][0] // ""' 2>/dev/null)

            mark_seen "$cveid"
            new_count=$((new_count + 1))

            # check if this CVE affects local system
            is_local=false
            if matches_local "$vuln"; then
                is_local=true
            fi

            short_desc=$(printf '%.120s' "$desc")

            # build affected line from matched packages
            affected_line=""
            if [ "$is_local" = "true" ] && [ -n "$MATCHED_PKGS" ]; then
                affected_line=$(echo "$MATCHED_PKGS" | tr '\n' ', ' | sed 's/, $//')
            fi

            # notify logic: only CRITICAL or locally-matching CVEs
            if [ "$severity" = "CRITICAL" ] && [ "$is_local" = "true" ]; then
                if [ "$use_dunst" = "true" ]; then
                    notify "LOCAL: $cveid ($score) CRITICAL\n$short_desc\naffected: $affected_line" "critical"
                fi
                if [ "$use_claude" = "true" ]; then
                    triage_cve "$cveid" "$desc" "$severity" "$score"
                fi
                notify_count=$((notify_count + 1))
                log "CRITICAL+LOCAL: $cveid ($score) [$affected_line]"
            elif [ "$is_local" = "true" ]; then
                if [ "$use_dunst" = "true" ]; then
                    notify "LOCAL: $cveid ($score) $severity\n$short_desc\naffected: $affected_line" "normal"
                fi
                if [ "$use_claude" = "true" ]; then
                    triage_cve "$cveid" "$desc" "$severity" "$score"
                fi
                notify_count=$((notify_count + 1))
                log "LOCAL MATCH: $cveid ($severity $score) [$affected_line]"
            elif [ "$severity" = "CRITICAL" ]; then
                # critical but not local: dunst only in interactive, skip in scan
                if [ "$mode" = "interactive" ] && [ "$use_dunst" = "true" ]; then
                    notify "$cveid ($score) CRITICAL\n$short_desc" "low"
                    notify_count=$((notify_count + 1))
                fi
                log "CRITICAL (not local): $cveid ($score)"
            else
                # HIGH but not local: silent, just log
                log "SKIPPED (not local): $cveid ($severity $score)"
            fi
        done < "$nvd_tmp"
    fi

    rm -f "$nvd_tmp" "$nvd_raw_file"

    # CISA KEV: actively exploited in the wild, always notify + triage
    kev_raw_file=$(mktemp "$CVE_DIR/kevraw.XXXXXX")
    fetch_cisa_kev_to_file "$kev_raw_file"
    if [ -s "$kev_raw_file" ] && jq -e '.vulnerabilities' "$kev_raw_file" >/dev/null 2>&1; then
        today=$(date '+%Y-%m-%d')
        yesterday=$(date -d '-1 day' '+%Y-%m-%d' 2>/dev/null || echo "$today")

        kev_tmp=$(mktemp "$CVE_DIR/kev.XXXXXX")
        jq -c --arg yd "$yesterday" '.vulnerabilities[]? | select(.dateAdded >= $yd)' "$kev_raw_file" 2>/dev/null > "$kev_tmp"

        while IFS= read -r vuln; do
            cveid=$(printf '%s' "$vuln" | jq -r '.cveID' 2>/dev/null)
            [ -z "$cveid" ] || [ "$cveid" = "null" ] && continue

            if is_seen "KEV-$cveid"; then
                continue
            fi

            desc=$(printf '%s' "$vuln" | jq -r '.shortDescription' 2>/dev/null)
            kev_vendor=$(printf '%s' "$vuln" | jq -r '.vendorProject // ""' 2>/dev/null | tr '[:upper:]' '[:lower:]')
            kev_product=$(printf '%s' "$vuln" | jq -r '.product // ""' 2>/dev/null | tr '[:upper:]' '[:lower:]')
            mark_seen "KEV-$cveid"

            short_desc=$(printf '%.120s' "$desc")

            # match KEV vendor/product against local deps
            kev_affected=""
            for _kp in $kev_vendor $kev_product; do
                [ -z "$_kp" ] && continue
                _kp_hit=$(grep -iF "$_kp" "$DEPS_CACHE" 2>/dev/null | awk -F'\t' '{printf "%s %s (%s)\n", $1, $2, $3}' | sort -u)
                [ -n "$_kp_hit" ] && kev_affected="${kev_affected:+$kev_affected, }$(echo "$_kp_hit" | tr '\n' ', ' | sed 's/, $//')"
            done

            if [ -n "$kev_affected" ]; then
                notify "CISA KEV: $cveid (ACTIVELY EXPLOITED)\n$short_desc\naffected: $kev_affected" "critical"
            else
                notify "CISA KEV: $cveid (ACTIVELY EXPLOITED)\n$short_desc\naffected: none detected" "critical"
            fi
            log "CISA KEV: $cveid [${kev_affected:-no local match}]"
            notify_count=$((notify_count + 1))

            triage_cve "$cveid" "$desc" "CRITICAL-KEV" "10"
        done < "$kev_tmp"
        rm -f "$kev_tmp"
    fi
    rm -f "$kev_raw_file"

    log "Check complete ($mode). Processed: $new_count, Notified: $notify_count"
}

do_list() {
    echo "=== Recent CVEs (last ${HOURS_BACK}h, severity >= $MIN_SEVERITY) ==="
    list_tmp=$(mktemp "$CVE_DIR/list.XXXXXX")
    fetch_nvd_to_file "$list_tmp"
    if [ -s "$list_tmp" ]; then
        jq -r '
            .vulnerabilities[]? |
            (.cve.metrics.cvssMetricV40[0]?.cvssData.baseSeverity // .cve.metrics.cvssMetricV31[0]?.cvssData.baseSeverity // .cve.metrics.cvssMetricV30[0]?.cvssData.baseSeverity // "?") as $sev |
            (.cve.metrics.cvssMetricV40[0]?.cvssData.baseScore // .cve.metrics.cvssMetricV31[0]?.cvssData.baseScore // .cve.metrics.cvssMetricV30[0]?.cvssData.baseScore // "?") as $score |
            "\(.cve.id) [\($sev)] \($score) - \([.cve.descriptions[]? | select(.lang == "en") | .value][0][:100] // "")"
        ' "$list_tmp" 2>/dev/null | sort -t'[' -k2 -r
    fi
    rm -f "$list_tmp"
    echo
    echo "=== Triaged (affects this system) ==="
    for f in "$CVE_TRIAGE"/*.json; do
        [ -f "$f" ] || continue
        affected=$(jq -r '.affected' "$f" 2>/dev/null)
        if [ "$affected" = "true" ]; then
            cveid=$(basename "$f" .json)
            pkg=$(jq -r '.package' "$f" 2>/dev/null)
            urgency=$(jq -r '.urgency' "$f" 2>/dev/null)
            printf '%s -> %s [%s]\n' "$cveid" "$pkg" "$urgency"
        fi
    done
}

do_triage() {
    echo "[*] Running triage on all unseen CVEs..."
    for f in "$CVE_TRIAGE"/*.json; do
        [ -f "$f" ] || continue
        cveid=$(basename "$f" .json)
        echo "  Triaged: $cveid -> $(jq -r 'if .affected then "AFFECTED: \(.package)" else "safe" end' "$f" 2>/dev/null)"
    done
}

do_history() {
    if [ -f "$CVE_LOG" ]; then
        tail -50 "$CVE_LOG"
    else
        echo "No history yet."
    fi
}

show_help() {
    cat << 'HELP'
cve -- CVE notification daemon

Usage:
  cve              Interactive: dunst notifications + claude triage + email
  cve --deep       Hourly mode: claude triage only, email if affected (no dunst)
  cve --scan       6-hour mode: dunst notifications only (no claude, saves tokens)
  cve --list       Print recent CVEs and triage results
  cve --triage     Show all triage results
  cve --history    Show notification log
  cve --help       This help

Cron setup:
  0 */1 * * * ~/.local/bin/cve --deep      # hourly: claude triage + email
  0 */6 * * * ~/.local/bin/cve --scan      # every 6h: dunst notifications

Config: edit variables at top of this script
Data:   ~/.local/share/cve/
HELP
}

# ============================================================================
# DISPATCH
# ============================================================================

case "${1:-}" in
    --deep)    do_check deep ;;
    --scan)    do_check scan ;;
    --list)    do_list ;;
    --triage)  do_triage ;;
    --history) do_history ;;
    --help|-h) show_help ;;
    "")        do_check interactive ;;
    *)         echo "Unknown option: $1"; show_help; exit 1 ;;
esac
