文件预览

cve-scan.sh

查看 Linux Security Guardian 技能包中的文件内容。

文件内容

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 "$@"