#!/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/dev/omniscient/scripts/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" | 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