#!/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\": \"\", \"installed_version\": \"\", \"action\": \"\", \"urgency\": \"immediate|soon|monitor\"} 3. If NO: respond with EXACTLY this JSON: {\"affected\": false, \"reason\": \"\"} 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