#!/usr/bin/env bash ########################################################################################## #### cisa-kev-monitor.sh — Monitor CISA Known Exploited Vulnerabilities catalog #### #### Polls the KEV JSON feed, detects new entries, alerts via email/Slack/Telegram #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./cisa-kev-monitor.sh #### #### ./cisa-kev-monitor.sh --filter linux,kernel #### #### ./cisa-kev-monitor.sh --telegram --filter linux #### #### #### #### See --help for all options. #### ########################################################################################## set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── KEV_URL="https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" STATE_DIR="${KEV_STATE_DIR:-${HOME:-/tmp}/.cisa-kev-monitor}" STATE_FILE="$STATE_DIR/known-cves.txt" FILTER_KEYWORDS="${KEV_FILTER:-}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # Notification channels SMTP_TO="${KEV_SMTP_TO:-}" SMTP_FROM="${KEV_SMTP_FROM:-cisa-kev-monitor@$(hostname -f 2>/dev/null || echo localhost)}" SLACK_WEBHOOK="${KEV_SLACK_WEBHOOK:-}" TELEGRAM_BOT_TOKEN="${KEV_TELEGRAM_BOT_TOKEN:-}" TELEGRAM_CHAT_ID="${KEV_TELEGRAM_CHAT_ID:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME NEW_CVES=() NEW_COUNT=0 TOTAL_COUNT=0 START_TIME="" # ── Colors ──────────────────────────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "$COLOR" == "never" ]]; then return; fi if [[ "$COLOR" == "auto" && ! -t 1 ]]; then return; fi RED="\033[0;31m" GREEN="\033[0;32m" YELLOW="\033[0;33m" BLUE="\033[0;34m" BOLD="\033[1m" DIM="\033[2m" RESET="\033[0m" } # ── Helpers ─────────────────────────────────────────────────────────── die() { printf "%b\n" "${RED}[ERROR]${RESET} $*" >&2; exit 1; } log_info() { printf "%b\n" "${GREEN}[INFO]${RESET} $*"; } log_warn() { printf "%b\n" "${YELLOW}[WARN]${RESET} $*"; } log_verbose() { [[ "$VERBOSE" == "true" ]] && printf "%b\n" "${DIM}[DEBUG]${RESET} $*" || true; } usage() { cat <&1 | logger -t kev-monitor EOF exit 0 } # ── Dependency Check ────────────────────────────────────────────────── check_deps() { local missing=() for cmd in curl jq; do if ! command -v "$cmd" &>/dev/null; then missing+=("$cmd") fi done if [[ ${#missing[@]} -gt 0 ]]; then die "Missing required commands: ${missing[*]}" fi } # ── Argument Parsing ────────────────────────────────────────────────── DRY_RUN="false" LIST_MODE="false" LIST_NEW_DAYS="" STATS_MODE="false" RESET_MODE="false" NOTIFY_EMAIL="false" NOTIFY_SLACK="false" NOTIFY_TELEGRAM="false" parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --filter) FILTER_KEYWORDS="${2:?--filter requires keywords}"; shift 2 ;; --email) SMTP_TO="${2:?--email requires an address}"; NOTIFY_EMAIL="true"; shift 2 ;; --slack) SLACK_WEBHOOK="${2:?--slack requires a webhook URL}"; NOTIFY_SLACK="true"; shift 2 ;; --telegram) NOTIFY_TELEGRAM="true"; shift ;; --list) LIST_MODE="true"; shift ;; --list-new) LIST_NEW_DAYS="${2:?--list-new requires days}"; shift 2 ;; --stats) STATS_MODE="true"; shift ;; --state-dir) STATE_DIR="${2:?--state-dir requires a path}"; STATE_FILE="$STATE_DIR/known-cves.txt"; shift 2 ;; --reset) RESET_MODE="true"; shift ;; --dry-run) DRY_RUN="true"; shift ;; --verbose) VERBOSE="true"; shift ;; --no-color) COLOR="never"; shift ;; --help) usage ;; *) die "Unknown option: $1" ;; esac done if [[ "$NOTIFY_TELEGRAM" == "true" ]]; then [[ -z "$TELEGRAM_BOT_TOKEN" ]] && die "KEV_TELEGRAM_BOT_TOKEN not set" [[ -z "$TELEGRAM_CHAT_ID" ]] && die "KEV_TELEGRAM_CHAT_ID not set" fi } # ── Fetch KEV Feed ──────────────────────────────────────────────────── fetch_kev() { log_verbose "Fetching KEV catalog from CISA..." local tmpfile tmpfile=$(mktemp) if ! curl -sS --max-time 30 --retry 2 -o "$tmpfile" "$KEV_URL" 2>/dev/null; then rm -f "$tmpfile" die "Failed to fetch KEV catalog from $KEV_URL" fi # Validate JSON if ! jq empty "$tmpfile" 2>/dev/null; then rm -f "$tmpfile" die "Invalid JSON received from KEV feed" fi echo "$tmpfile" } # ── Filter Entries ──────────────────────────────────────────────────── filter_entries() { local json_file="$1" if [[ -z "$FILTER_KEYWORDS" ]]; then jq -r '.vulnerabilities[]' "$json_file" return fi # Build jq filter from comma-separated keywords local jq_filter="" IFS=',' read -ra keywords <<< "$FILTER_KEYWORDS" for kw in "${keywords[@]}"; do kw=$(echo "$kw" | xargs) # trim whitespace kw_lower=$(echo "$kw" | tr '[:upper:]' '[:lower:]') if [[ -n "$jq_filter" ]]; then jq_filter="$jq_filter or" fi jq_filter="$jq_filter ((.vendorProject // \"\" | ascii_downcase | contains(\"$kw_lower\")) or (.product // \"\" | ascii_downcase | contains(\"$kw_lower\")) or (.shortDescription // \"\" | ascii_downcase | contains(\"$kw_lower\")) or (.vulnerabilityName // \"\" | ascii_downcase | contains(\"$kw_lower\")))" done jq -r ".vulnerabilities[] | select($jq_filter)" "$json_file" } # ── Initialize State ───────────────────────────────────────────────── init_state() { mkdir -p "$STATE_DIR" if [[ "$RESET_MODE" == "true" && -f "$STATE_FILE" ]]; then rm -f "$STATE_FILE" log_info "State file reset" fi if [[ ! -f "$STATE_FILE" ]]; then log_info "First run — initializing state file" return 1 fi return 0 } # ── Format CVE for Display ──────────────────────────────────────────── format_cve_text() { local cve="$1" local cve_id vendor product name date_added desc due_date ransomware cve_id=$(echo "$cve" | jq -r '.cveID') vendor=$(echo "$cve" | jq -r '.vendorProject') product=$(echo "$cve" | jq -r '.product') name=$(echo "$cve" | jq -r '.vulnerabilityName') date_added=$(echo "$cve" | jq -r '.dateAdded') desc=$(echo "$cve" | jq -r '.shortDescription') due_date=$(echo "$cve" | jq -r '.dueDate') ransomware=$(echo "$cve" | jq -r '.knownRansomwareCampaignUse') printf "%b%s%b — %s\n" "$BOLD" "$cve_id" "$RESET" "$name" printf " Vendor: %s / %s\n" "$vendor" "$product" printf " Added: %s\n" "$date_added" printf " Due: %s\n" "$due_date" printf " Ransomware: %s\n" "$ransomware" printf " %s\n" "$desc" printf " NVD: https://nvd.nist.gov/vuln/detail/%s\n" "$cve_id" echo "" } # ── Format CVE for Notifications ────────────────────────────────────── format_cve_plain() { local cve="$1" local cve_id vendor product name date_added desc cve_id=$(echo "$cve" | jq -r '.cveID') vendor=$(echo "$cve" | jq -r '.vendorProject') product=$(echo "$cve" | jq -r '.product') name=$(echo "$cve" | jq -r '.vulnerabilityName') date_added=$(echo "$cve" | jq -r '.dateAdded') desc=$(echo "$cve" | jq -r '.shortDescription') echo "$cve_id — $name" echo "Vendor: $vendor / $product" echo "Added: $date_added" echo "$desc" echo "https://nvd.nist.gov/vuln/detail/$cve_id" echo "" } format_cve_telegram() { local cve="$1" local cve_id vendor product name date_added desc ransomware cve_id=$(echo "$cve" | jq -r '.cveID') vendor=$(echo "$cve" | jq -r '.vendorProject') product=$(echo "$cve" | jq -r '.product') name=$(echo "$cve" | jq -r '.vulnerabilityName') date_added=$(echo "$cve" | jq -r '.dateAdded') desc=$(echo "$cve" | jq -r '.shortDescription' | head -c 200) ransomware=$(echo "$cve" | jq -r '.knownRansomwareCampaignUse') local emoji="🔴" [[ "$ransomware" == "Known" ]] && emoji="🔴🛑" echo "${emoji} ${cve_id} — ${name}" echo "📦 ${vendor} / ${product}" echo "📅 Added: ${date_added}" [[ "$ransomware" == "Known" ]] && echo "💀 Known ransomware use" echo "" echo "${desc}..." echo "" echo "🔗 NVD" } # ── Notification: Email ─────────────────────────────────────────────── send_email() { local subject="$1" local body="$2" if ! command -v sendmail &>/dev/null && ! command -v msmtp &>/dev/null; then log_warn "No sendmail or msmtp found — skipping email" return fi local mailer="sendmail" command -v msmtp &>/dev/null && mailer="msmtp" { echo "From: $SMTP_FROM" echo "To: $SMTP_TO" echo "Subject: $subject" echo "Content-Type: text/plain; charset=utf-8" echo "" echo "$body" } | "$mailer" -t "$SMTP_TO" log_verbose "Email sent to $SMTP_TO" } # ── Notification: Slack ─────────────────────────────────────────────── send_slack() { local text="$1" # Truncate for Slack's 3000 char limit text=$(echo "$text" | head -c 2900) local payload payload=$(jq -n --arg text "$text" '{text: $text}') curl -sS --max-time 10 -X POST \ -H "Content-Type: application/json" \ -d "$payload" \ "$SLACK_WEBHOOK" >/dev/null 2>&1 log_verbose "Slack notification sent" } # ── Notification: Telegram ──────────────────────────────────────────── send_telegram() { local text="$1" # Telegram message limit is 4096 chars text=$(echo "$text" | head -c 4000) curl -sS --max-time 10 -X POST \ "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ -d "parse_mode=HTML" \ -d "disable_web_page_preview=true" \ --data-urlencode "text=$text" >/dev/null 2>&1 log_verbose "Telegram notification sent" } # ── Notify All Channels ────────────────────────────────────────────── notify() { local count=${#NEW_CVES[@]} local filter_label="" [[ -n "$FILTER_KEYWORDS" ]] && filter_label=" (filter: $FILTER_KEYWORDS)" # Build plain text body local plain_body="" plain_body+="CISA KEV Monitor — $count new CVE(s) detected${filter_label}" plain_body+=$'\n\n' for cve_json in "${NEW_CVES[@]}"; do plain_body+=$(format_cve_plain "$cve_json") plain_body+=$'\n' done # Build Telegram body local tg_body="" tg_body+="🚨 CISA KEV — ${count} new CVE(s)${filter_label}" tg_body+=$'\n\n' for cve_json in "${NEW_CVES[@]}"; do tg_body+=$(format_cve_telegram "$cve_json") tg_body+=$'\n' done if [[ "$DRY_RUN" == "true" ]]; then log_warn "DRY-RUN — would send notifications to:" [[ "$NOTIFY_EMAIL" == "true" ]] && echo " Email: $SMTP_TO" [[ "$NOTIFY_SLACK" == "true" ]] && echo " Slack: (webhook configured)" [[ "$NOTIFY_TELEGRAM" == "true" ]] && echo " Telegram: chat $TELEGRAM_CHAT_ID" return fi [[ "$NOTIFY_EMAIL" == "true" ]] && send_email "CISA KEV: $count new CVE(s)${filter_label}" "$plain_body" [[ "$NOTIFY_SLACK" == "true" ]] && send_slack "$plain_body" [[ "$NOTIFY_TELEGRAM" == "true" ]] && send_telegram "$tg_body" } # ── Mode: Stats ─────────────────────────────────────────────────────── run_stats() { local json_file json_file=$(fetch_kev) local total last_updated total=$(jq '.vulnerabilities | length' "$json_file") last_updated=$(jq -r '.catalogVersion' "$json_file") local last_7d last_30d local cutoff_7d cutoff_30d cutoff_7d=$(date -u -d "7 days ago" '+%Y-%m-%d' 2>/dev/null || date -u -v-7d '+%Y-%m-%d' 2>/dev/null) cutoff_30d=$(date -u -d "30 days ago" '+%Y-%m-%d' 2>/dev/null || date -u -v-30d '+%Y-%m-%d' 2>/dev/null) last_7d=$(jq --arg d "$cutoff_7d" '[.vulnerabilities[] | select(.dateAdded >= $d)] | length' "$json_file") last_30d=$(jq --arg d "$cutoff_30d" '[.vulnerabilities[] | select(.dateAdded >= $d)] | length' "$json_file") local ransomware_known ransomware_known=$(jq '[.vulnerabilities[] | select(.knownRansomwareCampaignUse == "Known")] | length' "$json_file") echo "" printf "%bCISA KEV Catalog Statistics%b\n" "$BOLD" "$RESET" echo "Catalog version: $last_updated" echo "Total CVEs: $total" echo "Last 7 days: $last_7d" echo "Last 30 days: $last_30d" echo "Ransomware use: $ransomware_known" if [[ -n "$FILTER_KEYWORDS" ]]; then local filtered filtered=$(filter_entries "$json_file" | jq -s 'length') echo "Matching filter: $filtered (keywords: $FILTER_KEYWORDS)" fi rm -f "$json_file" } # ── Mode: List New ──────────────────────────────────────────────────── run_list_new() { local days="$1" local json_file json_file=$(fetch_kev) local cutoff cutoff=$(date -u -d "$days days ago" '+%Y-%m-%d' 2>/dev/null || date -u -v-"${days}d" '+%Y-%m-%d' 2>/dev/null) echo "" printf "%bCISA KEV — entries added in the last %s days%b\n\n" "$BOLD" "$days" "$RESET" local count=0 while IFS= read -r entry; do [[ -z "$entry" ]] && continue local date_added date_added=$(echo "$entry" | jq -r '.dateAdded') if [[ "$date_added" > "$cutoff" || "$date_added" == "$cutoff" ]]; then format_cve_text "$entry" count=$((count + 1)) fi done < <(filter_entries "$json_file" | jq -c '.') log_info "$count entries found" rm -f "$json_file" } # ── Mode: List ──────────────────────────────────────────────────────── run_list() { local json_file json_file=$(fetch_kev) echo "" printf "%bCISA KEV — all matching entries%b\n" "$BOLD" "$RESET" [[ -n "$FILTER_KEYWORDS" ]] && echo "Filter: $FILTER_KEYWORDS" echo "" local count=0 while IFS= read -r entry; do [[ -z "$entry" ]] && continue format_cve_text "$entry" count=$((count + 1)) done < <(filter_entries "$json_file" | jq -c '.') log_info "$count entries" rm -f "$json_file" } # ── Mode: Monitor ───────────────────────────────────────────────────── run_monitor() { local json_file json_file=$(fetch_kev) TOTAL_COUNT=$(jq '.vulnerabilities | length' "$json_file") # Initialize state on first run if ! init_state; then jq -r '.vulnerabilities[].cveID' "$json_file" | sort > "$STATE_FILE" local init_count init_count=$(wc -l < "$STATE_FILE") log_info "Initialized with $init_count CVEs. Future runs will detect new entries." rm -f "$json_file" return fi # Extract current CVE IDs local current_cves current_cves=$(mktemp) jq -r '.vulnerabilities[].cveID' "$json_file" | sort > "$current_cves" # Find new CVEs not in state file local new_ids new_ids=$(comm -13 "$STATE_FILE" "$current_cves") if [[ -z "$new_ids" ]]; then log_info "No new KEV entries (catalog: $TOTAL_COUNT CVEs)" rm -f "$current_cves" "$json_file" return fi # Collect new CVE details, applying filter while IFS= read -r cve_id; do [[ -z "$cve_id" ]] && continue local cve_json cve_json=$(jq -c --arg id "$cve_id" '.vulnerabilities[] | select(.cveID == $id)' "$json_file") # Apply filter if set if [[ -n "$FILTER_KEYWORDS" ]]; then local matches="false" IFS=',' read -ra keywords <<< "$FILTER_KEYWORDS" for kw in "${keywords[@]}"; do kw=$(echo "$kw" | xargs | tr '[:upper:]' '[:lower:]') if echo "$cve_json" | tr '[:upper:]' '[:lower:]' | grep -q "$kw"; then matches="true" break fi done [[ "$matches" == "false" ]] && continue fi NEW_CVES+=("$cve_json") format_cve_text "$cve_json" done <<< "$new_ids" NEW_COUNT=${#NEW_CVES[@]} # Update state file with all current CVEs mv "$current_cves" "$STATE_FILE" if [[ $NEW_COUNT -eq 0 ]]; then local total_new total_new=$(echo "$new_ids" | wc -w) log_info "No new entries matching filter (${total_new} new total, $TOTAL_COUNT in catalog)" rm -f "$json_file" return fi log_info "$NEW_COUNT new KEV entry/entries matching filter" # Send notifications if [[ "$NOTIFY_EMAIL" == "true" || "$NOTIFY_SLACK" == "true" || "$NOTIFY_TELEGRAM" == "true" ]]; then notify fi rm -f "$json_file" } # ── Entry Point ─────────────────────────────────────────────────────── main() { START_TIME=$(date +%s) setup_colors parse_args "$@" check_deps if [[ "$STATS_MODE" == "true" ]]; then run_stats elif [[ -n "$LIST_NEW_DAYS" ]]; then run_list_new "$LIST_NEW_DAYS" elif [[ "$LIST_MODE" == "true" ]]; then run_list else run_monitor fi local elapsed=$(( $(date +%s) - START_TIME )) log_verbose "Completed in ${elapsed}s" } main "$@"