文件内容
cve/cve-scan.sh
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# cve-scan.sh — External CVE source integration
#
# Usage: bash cve/cve-scan.sh --client <name> --server <name>
#
# Writes results to:
# cve/<client>/<server>/scan-results/YYYY-MM-DD.md
# cve/<client>/<server>/advisories/<CVE-ID>.md
# cve/.cache/ (shared cache)
#
# Methods (tries in order, each independent):
# 1. CISA KEV catalog — known exploited vulns [CURL] ✅
# 2. OSV.dev — open source package matching [CURL] ✅
# 3. NVD API — keyword / CVE search [CURL] ⚠️ rate limited
# 4. Web portal URLs — for agent browser fallback [BROWSER]
#
# RULE: One source failing never breaks others.
# Cloudflare block → skip, don't retry.
# Rate limit → sleep 6s, retry once, then skip.
# ============================================================
export PATH="/usr/local/bin:/usr/bin:/bin"
# ────────────────────────────────────────────────────────────
# Parse arguments
# ────────────────────────────────────────────────────────────
CLIENT=""
SERVER=""
while [ $# -gt 0 ]; do
case "$1" in
--client) CLIENT="$2"; shift 2 ;;
--server) SERVER="$2"; shift 2 ;;
*) echo "Unknown: $1" >&2; exit 1 ;;
esac
done
if [ -z "$CLIENT" ] || [ -z "$SERVER" ]; then
echo "Usage: bash cve/cve-scan.sh --client <name> --server <name>" >&2
exit 1
fi
SCAN_DATE=$(date +%F)
BASE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCAN_DIR="$BASE_DIR/cve"
# Per-client/per-server output
RESULTS_DIR="$SCAN_DIR/$CLIENT/$SERVER/scan-results"
ADVISORIES_DIR="$SCAN_DIR/$CLIENT/$SERVER/advisories"
CACHE_DIR="$SCAN_DIR/.cache"
mkdir -p "$RESULTS_DIR" "$ADVISORIES_DIR" "$CACHE_DIR"
# Colors
RED='\033[0;31m'; ORANGE='\033[0;33m'; YELLOW='\033[1;33m'
GREEN='\033[0;32m'; BLUE='\033[0;34m'; NC='\033[0m'
NVD_API_KEY="${NVD_API_KEY:-}"
# All log/info messages go to STDERR so they DON'T get captured
# when function output is assigned to a variable via $(fn)
log() { echo -e "$1" >&2; }
critical() { log "${RED}[CRITICAL]${NC} $*"; }
high() { log "${ORANGE}[HIGH]${NC} $*"; }
medium() { log "${YELLOW}[MEDIUM]${NC} $*"; }
low() { log "${BLUE}[LOW]${NC} $*"; }
info() { log "${GREEN}[INFO]${NC} $*"; }
skip() { log "${YELLOW}[SKIP]${NC} $*"; }
# ────────────────────────────────────────────────────────────
# Helper: safe_curl — fetch with skip logic
# ────────────────────────────────────────────────────────────
safe_curl() {
local url="$1"
local output_file="$2"
local source_name="$3"
local max_time="${4:-15}"
local response
response=$(curl -sfSL --connect-timeout 10 --max-time "$max_time" \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
local http_code
http_code=$(echo "$response" | tail -1)
local body
body=$(echo "$response" | sed '$d')
if [ -z "$body" ]; then
skip "$source_name: network error (timeout or unreachable)"
return 1
fi
if echo "$body" | grep -qi "Attention Required\|Cloudflare\|cf-wrapper\|cf-error-details" 2>/dev/null; then
skip "$source_name: blocked by Cloudflare — use browser tool instead"
return 1
fi
if [ "$http_code" = "429" ]; then
skip "$source_name: rate limited (HTTP 429) — sleeping 6s then retry once"
sleep 6
response=$(curl -sfSL --connect-timeout 10 --max-time "$max_time" \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "429" ] || [ -z "$body" ]; then
skip "$source_name: rate limited again — skipping"
return 1
fi
fi
if [ "$http_code" != "200" ]; then
skip "$source_name: HTTP $http_code"
return 1
fi
echo "$body" > "$output_file"
return 0
}
# ────────────────────────────────────────────────────────────
# Helper: safe_curl_post — POST version with skip logic
# ────────────────────────────────────────────────────────────
safe_curl_post() {
local url="$1"
local data="$2"
local output_file="$3"
local source_name="$4"
local max_time="${5:-30}"
local response
response=$(curl -sfSL --connect-timeout 10 --max-time "$max_time" \
-X POST -H "Content-Type: application/json" \
-d "$data" \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
local http_code
http_code=$(echo "$response" | tail -1)
local body
body=$(echo "$response" | sed '$d')
if [ -z "$body" ]; then
skip "$source_name: network error"
return 1
fi
if echo "$body" | grep -qi "Attention Required\|Cloudflare\|cf-wrapper" 2>/dev/null; then
skip "$source_name: blocked by Cloudflare"
return 1
fi
if [ "$http_code" != "200" ]; then
skip "$source_name: HTTP $http_code"
return 1
fi
echo "$body" > "$output_file"
return 0
}
# ────────────────────────────────────────────────────────────
# Helper: check_jq
# ────────────────────────────────────────────────────────────
check_jq() {
if ! command -v jq &>/dev/null; then
medium "jq not installed — install: apt-get install jq"
return 1
fi
return 0
}
# ────────────────────────────────────────────────────────────
# Method 1: CISA KEV Catalog [CURL] ✅
# ────────────────────────────────────────────────────────────
scan_cisa_kev() {
info "Method 1: CISA KEV Catalog..."
local kev_cache="$CACHE_DIR/kev-catalog.json"
local kev_age=0
local kev_findings=0
if [ -f "$kev_cache" ]; then
kev_age=$(( $(date +%s) - $(stat -c %Y "$kev_cache") ))
fi
local use_cached=false
if [ -f "$kev_cache" ] && [ "$kev_age" -lt 21600 ]; then
use_cached=true
fi
if [ "$use_cached" = false ]; then
if safe_curl \
"https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" \
"$kev_cache" "CISA KEV" 30; then
info "CISA KEV: catalog downloaded (cached for 6h)"
elif [ -f "$kev_cache" ]; then
medium "CISA KEV: using cached version (${kev_age}s old)"
else
skip "CISA KEV: no data available"
echo "SKIP"
return 1
fi
fi
local total_kev
total_kev=$(jq '.vulnerabilities | length' "$kev_cache" 2>/dev/null || echo 0)
info "CISA KEV: $total_kev total vulns in catalog"
local linux_kev
linux_kev=$(jq '[.vulnerabilities[] | select(.vendorProject == "Linux")]' "$kev_cache" 2>/dev/null || echo "[]")
local linux_count
linux_count=$(echo "$linux_kev" | jq 'length' 2>/dev/null || echo 0)
if [ "$linux_count" -gt 0 ]; then
critical "CISA KEV: $linux_count Linux vulnerabilities with KNOWN EXPLOITS!"
local kev_items_file="$CACHE_DIR/kev-linux-items.json"
echo "$linux_kev" > "$kev_items_file"
local item_count urgent_count=0
item_count=$(jq 'length' "$kev_items_file")
for i in $(seq 0 $((item_count - 1))); do
local item cve_id desc due_date ransomware flag urgent_flag
item=$(jq -c ".[$i]" "$kev_items_file")
cve_id=$(echo "$item" | jq -r '.cveID')
desc=$(echo "$item" | jq -r '.vulnerabilityName')
due_date=$(echo "$item" | jq -r '.dueDate')
ransomware=$(echo "$item" | jq -r '.knownRansomwareCampaignUse')
flag=""
[ "$ransomware" = "Known" ] && flag=" [RANSOMWARE]"
urgent_flag=""
local due_epoch now_epoch days_left
due_epoch=$(date -d "$due_date" +%s 2>/dev/null || echo 0)
now_epoch=$(date +%s)
if [ "$due_epoch" -gt 0 ]; then
days_left=$(( (due_epoch - now_epoch) / 86400 ))
if [ "$days_left" -lt 30 ] && [ "$days_left" -ge 0 ]; then
urgent_flag=" [URGENT: ${days_left}d left]"
elif [ "$days_left" -lt 0 ]; then
urgent_flag=" [OVERDUE: $(( -days_left ))d ago]"
fi
fi
critical "[KEV]$flag$urgent_flag $cve_id | $desc | due: $due_date"
kev_findings=$((kev_findings + 1))
if [ -n "$urgent_flag" ]; then
urgent_count=$((urgent_count + 1))
cat > "$CACHE_DIR/urgent-${cve_id}.alert" << URGEOF
URGENT KEV: $cve_id | $desc | due: $due_date${ransomware:+ | RANSOMWARE}
[Client: $CLIENT] [Server: $SERVER]
Apply mitigations IMMEDIATELY. Due date ${days_left:+- ${days_left}d remaining}.
URGEOF
fi
# Write advisory to per-server path
cat > "$ADVISORIES_DIR/$cve_id.md" << ADVEOF
# $cve_id
**Client:** $CLIENT
**Server:** $SERVER
**Source:** CISA KEV Catalog
**Flag:** KEV${ransomware:+ | RANSOMWARE}
**Due Date:** $due_date
**Scan Date:** $SCAN_DATE
## Description
$desc
## Action Required
Apply mitigations per vendor instructions by $due_date.
## Raw Data
\`\`\`json
$(echo "$item" | jq '.')
\`\`\`
ADVEOF
done
else
info "CISA KEV: no Linux-specific KEV entries"
fi
echo "$kev_findings"
}
# ────────────────────────────────────────────────────────────
# Method 2: OSV.dev — Query installed packages [CURL] ✅
# ────────────────────────────────────────────────────────────
scan_osv_dev() {
info "Method 2: OSV.dev query..."
local packages_file="$CACHE_DIR/$CLIENT-$SERVER-installed-packages.txt"
local osv_results="$RESULTS_DIR/$SCAN_DATE-osv.json"
local findings=0
# First try per-server cached packages (from SSH MCP snapshot)
if [ -f "$RESULTS_DIR/installed-packages.txt" ]; then
cp "$RESULTS_DIR/installed-packages.txt" "$packages_file"
info "OSV.dev: using per-server installed packages cache"
# Fallback: use cached file from audit-runner pre-check
elif [ -f "$SCAN_DIR/.cache/installed-packages.txt" ]; then
cp "$SCAN_DIR/.cache/installed-packages.txt" "$packages_file"
info "OSV.dev: using global installed packages cache"
# Last resort: run dpkg-query locally (should not happen in normal flow)
elif command -v dpkg-query &>/dev/null; then
dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null | head -100 > "$packages_file"
info "OSV.dev: ran dpkg-query locally (fallback)"
elif command -v rpm &>/dev/null; then
rpm -qa --queryformat '%{NAME}\t%{VERSION}\n' 2>/dev/null | head -100 > "$packages_file"
info "OSV.dev: ran rpm locally (fallback)"
else
skip "OSV.dev: no package manager found (dpkg/rpm)"
return 1
fi
if [ ! -s "$packages_file" ]; then
skip "OSV.dev: no packages found"
return 1
fi
local ecosystem="Debian"
if [ -f /etc/os-release ]; then
. /etc/os-release
case "${ID:-}" in
ubuntu) ecosystem="Ubuntu" ;;
alpine) ecosystem="Alpine" ;;
rhel|centos|rocky) ecosystem="Red Hat" ;;
suse|opensuse*) ecosystem="SUSE" ;;
arch) ecosystem="" ;;
esac
fi
[ -z "$ecosystem" ] && skip "OSV.dev: unsupported distro for ecosystem match" && return 1
local batch_queries="{\"queries\":["
local first=true
local count=0
while IFS=$'\t' read -r pkg ver; do
[ -z "$pkg" ] || [ -z "$ver" ] && continue
ver="${ver#*:}"; ver="${ver%-*}"; ver="${ver%%+*}"
[ -z "$ver" ] && continue
[ "$first" = false ] && batch_queries+=","
first=false
batch_queries+="{\"package\":{\"name\":\"$pkg\",\"ecosystem\":\"$ecosystem\"},\"version\":\"$ver\"}"
count=$((count + 1))
done < "$packages_file"
batch_queries+="]}"
[ "$count" -eq 0 ] && skip "OSV.dev: no valid packages to query" && return 1
info "OSV.dev: querying $count packages..."
if ! safe_curl_post \
"https://api.osv.dev/v1/querybatch" \
"$batch_queries" \
"$osv_results" \
"OSV.dev" 60; then
skip "OSV.dev: batch query failed"
return 1
fi
local vuln_count
vuln_count=$(jq '[.results[] | select(.vulns != null)] | length' "$osv_results" 2>/dev/null || echo 0)
info "OSV.dev: $vuln_count packages with known vulnerabilities"
if [ "$vuln_count" -gt 0 ]; then
local osv_items_file="$CACHE_DIR/osv-results-items.json"
jq -c '.results[] | select(.vulns != null)' "$osv_results" 2>/dev/null > "$osv_items_file"
local item_count
item_count=$(wc -l < "$osv_items_file")
for i in $(seq 1 "$item_count"); do
local result pkg_name
result=$(sed -n "${i}p" "$osv_items_file")
pkg_name=$(echo "$result" | jq -r '.query.package.name // "unknown"')
local vuln_file="$CACHE_DIR/osv-vulns-${i}.json"
echo "$result" | jq -c '.vulns[]' 2>/dev/null > "$vuln_file"
local vuln_count_i
vuln_count_i=$(wc -l < "$vuln_file")
for j in $(seq 1 "$vuln_count_i"); do
local vuln cve_id severity desc alias_cve display_id
vuln=$(sed -n "${j}p" "$vuln_file")
[ -z "$vuln" ] && continue
cve_id=$(echo "$vuln" | jq -r '.id // ""')
desc=$(echo "$vuln" | jq -r '.summary // ""' | head -c 120)
severity=$(echo "$vuln" | jq -r '.database_specific.severity // ""')
alias_cve=$(echo "$vuln" | jq -r '.aliases[] | select(startswith("CVE-"))' 2>/dev/null | head -1 || echo "")
display_id="$cve_id"
[ -n "$alias_cve" ] && display_id="$alias_cve (aliased: $cve_id)"
findings=$((findings + 1))
case "$severity" in
CRITICAL|critical) critical "[OSV] $display_id | $pkg_name | $desc" ;;
HIGH|high) high "[OSV] $display_id | $pkg_name | $desc" ;;
MEDIUM|medium) medium "[OSV] $display_id | $pkg_name | $desc" ;;
*) low "[OSV] $cve_id | $pkg_name | $desc" ;;
esac
# Advisory for high+ to per-server path
case "$severity" in CRITICAL|critical|HIGH|high)
local aliases_str
aliases_str=$(echo "$vuln" | jq -r '.aliases // [] | join(", ")')
cat > "$ADVISORIES_DIR/${cve_id}.md" << ADVEOF
# $cve_id — $pkg_name
**Client:** $CLIENT
**Server:** $SERVER
**Severity:** $severity
**Source:** OSV.dev
**Package:** $pkg_name
**Scan Date:** $SCAN_DATE
**Description:** $desc
**Aliases:** $aliases_str
\`\`\`json
$(echo "$vuln" | jq '.')
\`\`\`
ADVEOF
;;
esac
done
rm -f "$vuln_file"
done
rm -f "$osv_items_file"
fi
echo "$findings"
}
# ────────────────────────────────────────────────────────────
# Method 3: NVD API — Cross-check [CURL] ⚠️ rate limited
# ────────────────────────────────────────────────────────────
scan_nvd_api() {
info "Method 3: NVD API..."
local packages=("linux" "openssl" "openssh" "sudo" "nginx" "curl" "bash" "glibc" "systemd" "docker")
local total_findings=0
for pkg in "${packages[@]}"; do
local url="https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=$pkg&resultsPerPage=3"
local nvd_cache="$CACHE_DIR/nvd-$pkg.json"
local response
if [ -n "$NVD_API_KEY" ]; then
response=$(curl -sfSL --connect-timeout 10 --max-time 20 \
-H "apiKey:$NVD_API_KEY" \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
else
response=$(curl -sfSL --connect-timeout 10 --max-time 20 \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
fi
local http_code body
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
if [ -z "$body" ]; then
skip "NVD/$pkg: network error"
continue
fi
if echo "$body" | grep -qi "Attention Required\|Cloudflare\|cf-wrapper" 2>/dev/null; then
skip "NVD/$pkg: blocked by Cloudflare — skipping all NVD queries"
break
fi
if [ "$http_code" = "429" ]; then
skip "NVD/$pkg: rate limited — sleeping 6s"
sleep 6
if [ -n "$NVD_API_KEY" ]; then
response=$(curl -sfSL --connect-timeout 10 --max-time 20 \
-H "apiKey:$NVD_API_KEY" \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
else
response=$(curl -sfSL --connect-timeout 10 --max-time 20 \
-w "\n%{http_code}" "$url" 2>/dev/null || true)
fi
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')
[ -z "$body" ] && continue
fi
if [ "$http_code" != "200" ]; then
skip "NVD/$pkg: HTTP $http_code"
continue
fi
echo "$body" > "$nvd_cache"
local vuln_count
vuln_count=$(jq '.totalResults // 0' "$nvd_cache" 2>/dev/null || echo 0)
[ "$vuln_count" -eq 0 ] && continue
total_findings=$((total_findings + vuln_count))
info "NVD: $vuln_count CVEs for '$pkg'"
local nvd_vulns_file="$CACHE_DIR/nvd-vulns-${pkg}.json"
jq -c '.vulnerabilities[] | select(.cve.metrics.cvssMetricV31 != null)' "$nvd_cache" 2>/dev/null > "$nvd_vulns_file"
local vuln_count_nvd
vuln_count_nvd=$(wc -l < "$nvd_vulns_file" 2>/dev/null || echo 0)
for k in $(seq 1 "$vuln_count_nvd"); do
local item cve_id score severity
item=$(sed -n "${k}p" "$nvd_vulns_file")
[ -z "$item" ] && continue
cve_id=$(echo "$item" | jq -r '.cve.id // ""')
score=$(echo "$item" | jq -r '.cve.metrics.cvssMetricV31[0].cvssData.baseScore // "0"')
severity=$(echo "$item" | jq -r '.cve.metrics.cvssMetricV31[0].cvssData.baseSeverity // ""')
case "$severity" in
CRITICAL) critical "[NVD] $cve_id | $pkg | CVSS:$score" ;;
HIGH) high "[NVD] $cve_id | $pkg | CVSS:$score" ;;
esac
done
rm -f "$nvd_vulns_file"
[ -z "$NVD_API_KEY" ] && sleep 6
done
echo "$total_findings"
}
# ────────────────────────────────────────────────────────────
# Method 4: Web portal URLs for agent browser fallback
# ────────────────────────────────────────────────────────────
print_portal_urls() {
cat >&2 << 'URLS'
--- Web Portal URLs (agent: use browser tool, NOT curl) ---
CISA KEV (Linux filtered):
[BROWSER] https://www.cisa.gov/known-exploited-vulnerabilities-catalog?f%5B0%5D=vendor_project%3ALinux
NVD Search — Linux CVEs:
[BROWSER] https://nvd.nist.gov/vuln/search/results?query=linux&search_type=all&queryType=phrase
⚠️ Cloudflare blocks curl — only works in browser
OSV.dev — Debian vulns:
[BROWSER] https://osv.dev/list?ecosystem=Debian
Direct API endpoints (CURL — always work):
[CURL] CISA KEV: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
[CURL] OSV.dev: POST https://api.osv.dev/v1/querybatch
[CURL] NVD API: https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=linux&resultsPerPage=5
URLS
}
# ────────────────────────────────────────────────────────────
# Summary Report
# ────────────────────────────────────────────────────────────
write_report() {
local kev_findings="${1:-0}"
local osv_findings="${2:-0}"
local nvd_findings="${3:-0}"
kev_findings=$(echo "$kev_findings" | grep -oP '^\d+' || echo "0")
osv_findings=$(echo "$osv_findings" | grep -oP '^\d+' || echo "0")
nvd_findings=$(echo "$nvd_findings" | grep -oP '^\d+' || echo "0")
local kev_status="OK"
local osv_status="OK"
local nvd_status="OK"
[ "$kev_findings" = "0" ] && kev_status="OK (no matches)"
[ "$osv_findings" = "0" ] && osv_status="SKIPPED (no dpkg/rpm)"
[ "$nvd_findings" = "0" ] && nvd_status="OK (no findings)"
local report_file="$RESULTS_DIR/$SCAN_DATE.md"
local advisory_count
advisory_count=$(ls "$ADVISORIES_DIR"/*.md 2>/dev/null | wc -l)
local urgent_files=()
while IFS= read -r -d '' f; do urgent_files+=("$f"); done < <(find "$CACHE_DIR" -name 'urgent-*.alert' -print0 2>/dev/null || true)
local urgent_count=${#urgent_files[@]}
cat > "$report_file" << EOF
# CVE Scan — $SCAN_DATE
**Client:** $CLIENT
**Server:** $SERVER
## External Sources Summary
| Source | Status | Findings |
|--------|--------|----------|
| CISA KEV (known exploited) | $kev_status | $kev_findings |
| OSV.dev (open source DB) | $osv_status | $osv_findings |
| NVD API (keyword search) | $nvd_status | $nvd_findings |
## Advisory Count: $advisory_count
EOF
if [ "$urgent_count" -gt 0 ]; then
printf "\n## 🔥 URGENT — Due Within 30 Days (or Overdue)\n\`\`\`\n" >> "$report_file"
for uf in "${urgent_files[@]}"; do
cat "$uf" >> "$report_file"
done
printf "\`\`\`\n" >> "$report_file"
fi
if [ "$advisory_count" -gt 0 ]; then
echo -e "\n## Advisories Generated" >> "$report_file"
echo '```' >> "$report_file"
ls "$ADVISORIES_DIR"/*.md 2>/dev/null >> "$report_file"
echo '```' >> "$report_file"
fi
cat >> "$report_file" << EOF
---
_Scan: cve-scan.sh | Client: $CLIENT | Server: $SERVER | Date: $SCAN_DATE | Sources: CISA KEV + OSV.dev + NVD API_
EOF
echo >&2 ""
info "Report: $report_file"
}
# ────────────────────────────────────────────────────────────
# Main
# ────────────────────────────────────────────────────────────
main() {
info "=== External CVE Scan — $SCAN_DATE | Client: $CLIENT | Server: $SERVER ==="
info "jq: $(check_jq && echo 'yes' || echo 'no')"
info "NVD API key: $([ -n "$NVD_API_KEY" ] && echo 'set' || echo 'not set (rate limited)')"
echo >&2 ""
local kev_result="SKIP"
local osv_result="SKIP"
local nvd_result="SKIP"
echo >&2 "---"
kev_result=$(scan_cisa_kev)
kev_result="${kev_result:-SKIP}"
echo >&2 ""
if check_jq; then
echo >&2 "---"
osv_result=$(scan_osv_dev)
osv_result="${osv_result:-SKIP}"
echo >&2 ""
fi
if check_jq; then
echo >&2 "---"
nvd_result=$(scan_nvd_api)
nvd_result="${nvd_result:-SKIP}"
echo >&2 ""
fi
echo >&2 "---"
print_portal_urls
echo >&2 ""
write_report "$kev_result" "$osv_result" "$nvd_result"
rm -f "$CACHE_DIR"/urgent-*.alert
>&2 echo ""
info "=== External CVE scan complete ==="
}
main "$@"