secubox-deb/packages/secubox-cve-triage/api/main.py
2026-07-18 12:04:46 +02:00

1163 lines
42 KiB
Python

"""SecuBox CVE Triage - Vulnerability Management System
Scans installed packages, queries NVD API, prioritizes CVEs by severity and exploitability.
Features:
- Package inventory from dpkg/apt
- NVD API integration for CVE lookups
- CVSS-based severity scoring
- Exploitability assessment (EPSS integration)
- Remediation tracking
"""
import os
import re
import json
import time
import logging
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, List, Any
from enum import Enum
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field
import httpx
from secubox_core.auth import require_jwt
from secubox_core.config import get_config
# Configuration
CONFIG_PATH = Path("/etc/secubox/cve-triage.toml")
DATA_DIR = Path("/var/lib/secubox/cve-triage")
CACHE_DIR = DATA_DIR / "cache"
PACKAGES_FILE = DATA_DIR / "packages.json"
CVES_FILE = DATA_DIR / "cves.json"
TRIAGE_FILE = DATA_DIR / "triage.json"
# NVD API (free tier: 5 requests per 30 seconds without API key)
NVD_API_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
EPSS_API_BASE = "https://api.first.org/data/v1/epss"
# External CVE Feeds
CISA_KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
VULNCHECK_KEV_URL = "https://api.vulncheck.com/v3/index/initial-access" # Requires API key
NUCLEI_CVES_URL = "https://raw.githubusercontent.com/projectdiscovery/nuclei-templates/main/cves.json"
EXPLOITDB_API = "https://www.exploit-db.com/search"
# Cache files for feeds
KEV_CACHE_FILE = DATA_DIR / "kev_cache.json"
RECENT_CVE_CACHE = DATA_DIR / "recent_cves.json"
TRENDING_CACHE = DATA_DIR / "trending.json"
app = FastAPI(title="SecuBox CVE Triage", version="1.0.0")
logger = logging.getLogger("secubox.cve-triage")
class Severity(str, Enum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
NONE = "none"
class TriageStatus(str, Enum):
NEW = "new"
INVESTIGATING = "investigating"
CONFIRMED = "confirmed"
MITIGATED = "mitigated"
FALSE_POSITIVE = "false_positive"
ACCEPTED_RISK = "accepted_risk"
class Package(BaseModel):
name: str
version: str
architecture: Optional[str] = None
source: Optional[str] = None # dpkg, pip, npm, etc.
installed_at: Optional[str] = None
class CVEEntry(BaseModel):
cve_id: str
description: str
severity: Severity
cvss_score: Optional[float] = None
cvss_vector: Optional[str] = None
epss_score: Optional[float] = None # Exploit Prediction Scoring System
published: str
modified: str
affected_packages: List[str] = []
references: List[str] = []
cwe_ids: List[str] = []
class TriageEntry(BaseModel):
cve_id: str
status: TriageStatus = TriageStatus.NEW
severity_override: Optional[Severity] = None
notes: str = ""
assigned_to: Optional[str] = None
created_at: str
updated_at: str
remediation_deadline: Optional[str] = None
class TriageUpdate(BaseModel):
status: Optional[TriageStatus] = None
severity_override: Optional[Severity] = None
notes: Optional[str] = None
assigned_to: Optional[str] = None
remediation_deadline: Optional[str] = None
class CVETriageManager:
"""Manages CVE scanning, triage, and remediation."""
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.cache_dir = data_dir / "cache"
self.packages_file = data_dir / "packages.json"
self.cves_file = data_dir / "cves.json"
self.triage_file = data_dir / "triage.json"
self._ensure_dirs()
self._load_data()
self.nvd_api_key = os.environ.get("NVD_API_KEY")
def _ensure_dirs(self):
self.data_dir.mkdir(parents=True, exist_ok=True)
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _load_data(self):
"""Load cached data."""
self.packages: Dict[str, Package] = {}
self.cves: Dict[str, CVEEntry] = {}
self.triage: Dict[str, TriageEntry] = {}
if self.packages_file.exists():
try:
data = json.loads(self.packages_file.read_text())
self.packages = {k: Package(**v) for k, v in data.items()}
except Exception:
pass
if self.cves_file.exists():
try:
data = json.loads(self.cves_file.read_text())
self.cves = {k: CVEEntry(**v) for k, v in data.items()}
except Exception:
pass
if self.triage_file.exists():
try:
data = json.loads(self.triage_file.read_text())
self.triage = {k: TriageEntry(**v) for k, v in data.items()}
except Exception:
pass
def _save_data(self):
self.packages_file.write_text(json.dumps(
{k: v.model_dump() for k, v in self.packages.items()}, indent=2
))
self.cves_file.write_text(json.dumps(
{k: v.model_dump() for k, v in self.cves.items()}, indent=2
))
self.triage_file.write_text(json.dumps(
{k: v.model_dump() for k, v in self.triage.items()}, indent=2
))
def scan_packages(self) -> List[Package]:
"""Scan installed packages from various sources."""
packages = []
# dpkg packages (Debian/Ubuntu)
try:
result = subprocess.run(
["dpkg-query", "-W", "-f=${Package}|${Version}|${Architecture}\n"],
capture_output=True,
text=True,
timeout=30
)
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|")
if len(parts) >= 2:
pkg = Package(
name=parts[0],
version=parts[1],
architecture=parts[2] if len(parts) > 2 else None,
source="dpkg"
)
packages.append(pkg)
self.packages[pkg.name] = pkg
except Exception as e:
logger.warning(f"dpkg scan failed: {e}")
# Python packages (pip)
try:
result = subprocess.run(
["pip3", "list", "--format=json"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
pip_packages = json.loads(result.stdout)
for item in pip_packages:
pkg = Package(
name=f"python-{item['name'].lower()}",
version=item["version"],
source="pip"
)
packages.append(pkg)
self.packages[pkg.name] = pkg
except Exception as e:
logger.warning(f"pip scan failed: {e}")
# npm packages (Node.js)
try:
result = subprocess.run(
["npm", "list", "-g", "--json", "--depth=0"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
npm_data = json.loads(result.stdout)
for name, info in npm_data.get("dependencies", {}).items():
pkg = Package(
name=f"npm-{name.lower()}",
version=info.get("version", "unknown"),
source="npm"
)
packages.append(pkg)
self.packages[pkg.name] = pkg
except Exception as e:
logger.warning(f"npm scan failed: {e}")
self._save_data()
return packages
async def query_debian_security(self, package_name: str) -> List[CVEEntry]:
"""Query Debian Security Tracker for CVEs."""
cves = []
try:
async with httpx.AsyncClient() as client:
# Debian Security Tracker JSON API
response = await client.get(
f"https://security-tracker.debian.org/tracker/data/json",
timeout=30.0
)
if response.status_code == 200:
data = response.json()
pkg_data = data.get(package_name, {})
for cve_id, cve_info in pkg_data.items():
if not cve_id.startswith("CVE-"):
continue
# Get severity from urgency
urgency = cve_info.get("urgency", "")
severity = Severity.MEDIUM
if urgency in ["high", "unimportant"]:
severity = Severity.HIGH if urgency == "high" else Severity.LOW
elif urgency == "low":
severity = Severity.LOW
cve = CVEEntry(
cve_id=cve_id,
description=cve_info.get("description", "")[:500],
severity=severity,
published=datetime.utcnow().isoformat() + "Z",
modified=datetime.utcnow().isoformat() + "Z",
affected_packages=[package_name],
references=[]
)
# Check if fixed
releases = cve_info.get("releases", {})
for release, release_info in releases.items():
if release_info.get("status") == "resolved":
cve.references.append(f"Fixed in {release}: {release_info.get('fixed_version', 'unknown')}")
cves.append(cve)
self.cves[cve_id] = cve
except Exception as e:
logger.warning(f"Debian security query failed: {e}")
return cves
def get_patch_suggestions(self, cve_id: str) -> Dict[str, Any]:
"""Get patch/upgrade suggestions for a CVE."""
cve = self.cves.get(cve_id)
if not cve:
return {"error": "CVE not found"}
suggestions = []
for pkg_name in cve.affected_packages:
pkg = self.packages.get(pkg_name)
if not pkg:
continue
suggestion = {
"package": pkg_name,
"current_version": pkg.version,
"actions": []
}
# Check for apt upgrade
if pkg.source == "dpkg":
try:
result = subprocess.run(
["apt-cache", "policy", pkg_name],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Parse candidate version
for line in result.stdout.split("\n"):
if "Candidate:" in line:
candidate = line.split(":")[-1].strip()
if candidate != pkg.version:
suggestion["actions"].append({
"type": "upgrade",
"command": f"apt-get install {pkg_name}={candidate}",
"target_version": candidate
})
except Exception:
pass
# Check for pip upgrade
elif pkg.source == "pip":
suggestion["actions"].append({
"type": "upgrade",
"command": f"pip3 install --upgrade {pkg_name.replace('python-', '')}",
"target_version": "latest"
})
# Check for npm upgrade
elif pkg.source == "npm":
suggestion["actions"].append({
"type": "upgrade",
"command": f"npm update -g {pkg_name.replace('npm-', '')}",
"target_version": "latest"
})
if suggestion["actions"]:
suggestions.append(suggestion)
return {
"cve_id": cve_id,
"severity": cve.severity.value,
"suggestions": suggestions
}
async def query_nvd(self, keyword: str) -> List[CVEEntry]:
"""Query NVD API for CVEs matching a keyword."""
cves = []
headers = {}
if self.nvd_api_key:
headers["apiKey"] = self.nvd_api_key
try:
async with httpx.AsyncClient() as client:
response = await client.get(
NVD_API_BASE,
params={"keywordSearch": keyword, "resultsPerPage": 50},
headers=headers,
timeout=30.0
)
if response.status_code == 200:
data = response.json()
for vuln in data.get("vulnerabilities", []):
cve_data = vuln.get("cve", {})
cve_id = cve_data.get("id", "")
# Extract CVSS score
cvss_score = None
cvss_vector = None
severity = Severity.NONE
metrics = cve_data.get("metrics", {})
for cvss_version in ["cvssMetricV31", "cvssMetricV30", "cvssMetricV2"]:
if cvss_version in metrics:
cvss_data = metrics[cvss_version][0].get("cvssData", {})
cvss_score = cvss_data.get("baseScore")
cvss_vector = cvss_data.get("vectorString")
severity_str = cvss_data.get("baseSeverity", "").lower()
if severity_str in ["critical", "high", "medium", "low"]:
severity = Severity(severity_str)
break
# Extract description
descriptions = cve_data.get("descriptions", [])
description = ""
for desc in descriptions:
if desc.get("lang") == "en":
description = desc.get("value", "")
break
# Extract CWEs
cwe_ids = []
for weakness in cve_data.get("weaknesses", []):
for desc in weakness.get("description", []):
if desc.get("value", "").startswith("CWE-"):
cwe_ids.append(desc["value"])
# Extract references
references = [
ref.get("url", "")
for ref in cve_data.get("references", [])[:10]
]
cve = CVEEntry(
cve_id=cve_id,
description=description[:500],
severity=severity,
cvss_score=cvss_score,
cvss_vector=cvss_vector,
published=cve_data.get("published", ""),
modified=cve_data.get("lastModified", ""),
references=references,
cwe_ids=cwe_ids,
affected_packages=[keyword]
)
cves.append(cve)
self.cves[cve_id] = cve
except Exception as e:
logger.warning(f"NVD query failed: {e}")
return cves
async def get_epss_scores(self, cve_ids: List[str]) -> Dict[str, float]:
"""Get EPSS scores for CVEs."""
scores = {}
if not cve_ids:
return scores
try:
async with httpx.AsyncClient() as client:
response = await client.get(
EPSS_API_BASE,
params={"cve": ",".join(cve_ids[:100])},
timeout=15.0
)
if response.status_code == 200:
data = response.json()
for item in data.get("data", []):
cve_id = item.get("cve")
epss = float(item.get("epss", 0))
scores[cve_id] = epss
if cve_id in self.cves:
self.cves[cve_id].epss_score = epss
except Exception as e:
logger.warning(f"EPSS query failed: {e}")
return scores
async def scan_for_vulnerabilities(self) -> List[CVEEntry]:
"""Scan all packages for known vulnerabilities."""
all_cves = []
# Scan critical packages
critical_packages = [
"openssl", "linux", "glibc", "openssh", "nginx", "apache2",
"python3", "nodejs", "curl", "wget", "git"
]
for pkg_name in critical_packages:
if pkg_name in self.packages:
cves = await self.query_nvd(pkg_name)
all_cves.extend(cves)
# Rate limit for NVD API
await self._rate_limit()
self._save_data()
return all_cves
async def _rate_limit(self):
"""Rate limit for NVD API (5 requests per 30 seconds without key)."""
if not self.nvd_api_key:
import asyncio
await asyncio.sleep(6)
async def fetch_cisa_kev(self) -> List[CVEEntry]:
"""Fetch CISA Known Exploited Vulnerabilities catalog."""
cves = []
cache_file = self.data_dir / "kev_cache.json"
# Check cache (refresh if older than 6 hours)
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
cache_time = datetime.fromisoformat(cache_data.get("fetched_at", "2000-01-01").rstrip("Z"))
if (datetime.utcnow() - cache_time).total_seconds() < 21600: # 6 hours
for item in cache_data.get("vulnerabilities", []):
if item["cveID"] in self.cves:
continue
cve = CVEEntry(
cve_id=item["cveID"],
description=item.get("shortDescription", "")[:500],
severity=Severity.CRITICAL, # KEV = actively exploited
published=item.get("dateAdded", ""),
modified=datetime.utcnow().isoformat() + "Z",
affected_packages=[item.get("vendorProject", ""), item.get("product", "")],
references=[f"https://nvd.nist.gov/vuln/detail/{item['cveID']}"]
)
cves.append(cve)
self.cves[cve.cve_id] = cve
return cves
except Exception:
pass
try:
async with httpx.AsyncClient() as client:
response = await client.get(CISA_KEV_URL, timeout=30.0)
if response.status_code == 200:
data = response.json()
# Cache the response
data["fetched_at"] = datetime.utcnow().isoformat() + "Z"
cache_file.write_text(json.dumps(data, indent=2))
for item in data.get("vulnerabilities", []):
cve = CVEEntry(
cve_id=item["cveID"],
description=item.get("shortDescription", "")[:500],
severity=Severity.CRITICAL, # KEV = actively exploited
published=item.get("dateAdded", ""),
modified=datetime.utcnow().isoformat() + "Z",
affected_packages=[item.get("vendorProject", ""), item.get("product", "")],
references=[f"https://nvd.nist.gov/vuln/detail/{item['cveID']}"],
cwe_ids=[]
)
cves.append(cve)
self.cves[cve.cve_id] = cve
self._save_data()
logger.info(f"Fetched {len(cves)} CVEs from CISA KEV")
except Exception as e:
logger.warning(f"CISA KEV fetch failed: {e}")
return cves
async def fetch_recent_nvd(self, days: int = 7) -> List[CVEEntry]:
"""Fetch recently published CVEs from NVD."""
cves = []
headers = {}
if self.nvd_api_key:
headers["apiKey"] = self.nvd_api_key
# Calculate date range
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
try:
async with httpx.AsyncClient() as client:
response = await client.get(
NVD_API_BASE,
params={
"pubStartDate": start_date.strftime("%Y-%m-%dT00:00:00.000"),
"pubEndDate": end_date.strftime("%Y-%m-%dT23:59:59.999"),
"resultsPerPage": 100
},
headers=headers,
timeout=60.0
)
if response.status_code == 200:
data = response.json()
for vuln in data.get("vulnerabilities", []):
cve_data = vuln.get("cve", {})
cve_id = cve_data.get("id", "")
if cve_id in self.cves:
continue
# Extract CVSS score
cvss_score = None
cvss_vector = None
severity = Severity.NONE
metrics = cve_data.get("metrics", {})
for cvss_version in ["cvssMetricV31", "cvssMetricV30", "cvssMetricV2"]:
if cvss_version in metrics:
cvss_data = metrics[cvss_version][0].get("cvssData", {})
cvss_score = cvss_data.get("baseScore")
cvss_vector = cvss_data.get("vectorString")
severity_str = cvss_data.get("baseSeverity", "").lower()
if severity_str in ["critical", "high", "medium", "low"]:
severity = Severity(severity_str)
break
# Extract description
descriptions = cve_data.get("descriptions", [])
description = ""
for desc in descriptions:
if desc.get("lang") == "en":
description = desc.get("value", "")
break
cve = CVEEntry(
cve_id=cve_id,
description=description[:500],
severity=severity,
cvss_score=cvss_score,
cvss_vector=cvss_vector,
published=cve_data.get("published", ""),
modified=cve_data.get("lastModified", ""),
references=[ref.get("url", "") for ref in cve_data.get("references", [])[:5]],
cwe_ids=[],
affected_packages=[]
)
cves.append(cve)
self.cves[cve_id] = cve
self._save_data()
logger.info(f"Fetched {len(cves)} recent CVEs from NVD")
except Exception as e:
logger.warning(f"NVD recent fetch failed: {e}")
return cves
async def fetch_trending_cves(self) -> List[Dict[str, Any]]:
"""Get trending/high-profile CVEs based on EPSS scores."""
trending = []
# Get CVEs with highest EPSS scores (most likely to be exploited)
cve_ids = list(self.cves.keys())[:200] # Limit batch size
if not cve_ids:
return trending
try:
async with httpx.AsyncClient() as client:
response = await client.get(
EPSS_API_BASE,
params={"cve": ",".join(cve_ids[:100])},
timeout=15.0
)
if response.status_code == 200:
data = response.json()
epss_data = data.get("data", [])
# Sort by EPSS score descending
epss_data.sort(key=lambda x: float(x.get("epss", 0)), reverse=True)
for item in epss_data[:20]: # Top 20 trending
cve_id = item.get("cve")
epss_score = float(item.get("epss", 0))
percentile = float(item.get("percentile", 0))
if cve_id in self.cves:
self.cves[cve_id].epss_score = epss_score
cve = self.cves[cve_id]
trending.append({
"cve_id": cve_id,
"epss_score": epss_score,
"percentile": percentile,
"severity": cve.severity.value,
"cvss_score": cve.cvss_score,
"description": cve.description[:200]
})
self._save_data()
except Exception as e:
logger.warning(f"Trending fetch failed: {e}")
return trending
async def search_exploitdb(self, cve_id: str) -> List[Dict[str, Any]]:
"""Search Exploit-DB for public exploits."""
exploits = []
try:
async with httpx.AsyncClient() as client:
# Use the Exploit-DB API (public search)
response = await client.get(
f"https://www.exploit-db.com/search",
params={"cve": cve_id.replace("CVE-", "")},
headers={"Accept": "application/json"},
timeout=15.0
)
# Note: Exploit-DB doesn't have a public JSON API, so this is a placeholder
# In production, use their data files or alternative sources
except Exception as e:
logger.debug(f"ExploitDB search skipped: {e}")
return exploits
def get_feed_summary(self) -> Dict[str, Any]:
"""Get summary of all CVE feeds."""
kev_count = 0
kev_cache = self.data_dir / "kev_cache.json"
if kev_cache.exists():
try:
data = json.loads(kev_cache.read_text())
kev_count = len(data.get("vulnerabilities", []))
except Exception:
pass
# Count by source
recent_7d = sum(1 for c in self.cves.values()
if c.published and
(datetime.utcnow() - datetime.fromisoformat(c.published.rstrip("Z"))).days <= 7)
high_epss = sum(1 for c in self.cves.values()
if c.epss_score and c.epss_score > 0.5)
return {
"total_cves": len(self.cves),
"kev_catalog_size": kev_count,
"recent_7_days": recent_7d,
"high_epss_count": high_epss,
"by_severity": {
"critical": sum(1 for c in self.cves.values() if c.severity == Severity.CRITICAL),
"high": sum(1 for c in self.cves.values() if c.severity == Severity.HIGH),
"medium": sum(1 for c in self.cves.values() if c.severity == Severity.MEDIUM),
"low": sum(1 for c in self.cves.values() if c.severity == Severity.LOW)
}
}
def create_triage(self, cve_id: str) -> TriageEntry:
"""Create a triage entry for a CVE."""
if cve_id not in self.cves:
raise ValueError(f"CVE {cve_id} not found")
now = datetime.utcnow().isoformat() + "Z"
entry = TriageEntry(
cve_id=cve_id,
status=TriageStatus.NEW,
created_at=now,
updated_at=now
)
self.triage[cve_id] = entry
self._save_data()
return entry
def update_triage(self, cve_id: str, update: TriageUpdate) -> TriageEntry:
"""Update a triage entry."""
if cve_id not in self.triage:
raise ValueError(f"Triage entry for {cve_id} not found")
entry = self.triage[cve_id]
if update.status:
entry.status = update.status
if update.severity_override:
entry.severity_override = update.severity_override
if update.notes is not None:
entry.notes = update.notes
if update.assigned_to is not None:
entry.assigned_to = update.assigned_to
if update.remediation_deadline:
entry.remediation_deadline = update.remediation_deadline
entry.updated_at = datetime.utcnow().isoformat() + "Z"
self._save_data()
return entry
def get_prioritized_cves(self) -> List[Dict[str, Any]]:
"""Get CVEs prioritized by severity and exploitability."""
prioritized = []
for cve_id, cve in self.cves.items():
triage = self.triage.get(cve_id)
# Calculate priority score
score = 0
if cve.cvss_score:
score += cve.cvss_score * 10 # Max 100
if cve.epss_score:
score += cve.epss_score * 50 # Exploitability weight
# Severity multiplier
severity_mult = {
Severity.CRITICAL: 2.0,
Severity.HIGH: 1.5,
Severity.MEDIUM: 1.0,
Severity.LOW: 0.5,
Severity.NONE: 0.1
}
score *= severity_mult.get(cve.severity, 1.0)
prioritized.append({
"cve": cve,
"triage": triage,
"priority_score": round(score, 2)
})
# Sort by priority score descending
prioritized.sort(key=lambda x: x["priority_score"], reverse=True)
return prioritized
def get_stats(self) -> Dict[str, Any]:
"""Get CVE triage statistics."""
by_severity = {}
by_status = {}
for cve in self.cves.values():
by_severity[cve.severity.value] = by_severity.get(cve.severity.value, 0) + 1
for triage in self.triage.values():
by_status[triage.status.value] = by_status.get(triage.status.value, 0) + 1
critical_untriaged = sum(
1 for cve in self.cves.values()
if cve.severity == Severity.CRITICAL and cve.cve_id not in self.triage
)
return {
"total_packages": len(self.packages),
"total_cves": len(self.cves),
"triaged": len(self.triage),
"by_severity": by_severity,
"by_status": by_status,
"critical_untriaged": critical_untriaged
}
# Global instance
manager = CVETriageManager(DATA_DIR)
# ============================================================================
# API Endpoints
# ============================================================================
@app.get("/status")
async def status():
"""Public status endpoint."""
stats = manager.get_stats()
return {
"module": "cve-triage",
"status": "ok",
"version": "1.0.0",
"total_cves": stats["total_cves"],
"critical_untriaged": stats["critical_untriaged"]
}
@app.get("/health")
async def health():
"""Health check."""
return {"status": "healthy"}
@app.get("/stats", dependencies=[Depends(require_jwt)])
async def get_stats():
"""Get CVE triage statistics."""
return manager.get_stats()
@app.post("/scan/packages", dependencies=[Depends(require_jwt)])
async def scan_packages():
"""Scan installed packages."""
packages = manager.scan_packages()
return {"packages": len(packages)}
@app.get("/packages", dependencies=[Depends(require_jwt)])
async def list_packages():
"""List installed packages."""
return {"packages": list(manager.packages.values())}
@app.post("/scan/vulnerabilities", dependencies=[Depends(require_jwt)])
async def scan_vulnerabilities(background_tasks: BackgroundTasks):
"""Scan for vulnerabilities (async)."""
cves = await manager.scan_for_vulnerabilities()
return {"cves_found": len(cves)}
@app.get("/cves", dependencies=[Depends(require_jwt)])
async def list_cves(severity: Optional[str] = None):
"""List known CVEs."""
cves = list(manager.cves.values())
if severity:
cves = [c for c in cves if c.severity.value == severity]
return {"cves": cves, "count": len(cves)}
@app.get("/cves/{cve_id}", dependencies=[Depends(require_jwt)])
async def get_cve(cve_id: str):
"""Get CVE details."""
cve = manager.cves.get(cve_id)
if not cve:
raise HTTPException(status_code=404, detail="CVE not found")
triage = manager.triage.get(cve_id)
return {"cve": cve, "triage": triage}
@app.post("/query", dependencies=[Depends(require_jwt)])
async def query_nvd(keyword: str):
"""Query NVD for CVEs."""
cves = await manager.query_nvd(keyword)
return {"cves": cves, "count": len(cves)}
@app.get("/prioritized", dependencies=[Depends(require_jwt)])
async def get_prioritized():
"""Get prioritized CVE list."""
return {"cves": manager.get_prioritized_cves()}
@app.post("/triage/{cve_id}", dependencies=[Depends(require_jwt)])
async def create_triage(cve_id: str):
"""Create triage entry for a CVE."""
try:
entry = manager.create_triage(cve_id)
return {"status": "created", "triage": entry}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.put("/triage/{cve_id}", dependencies=[Depends(require_jwt)])
async def update_triage(cve_id: str, update: TriageUpdate):
"""Update triage entry."""
try:
entry = manager.update_triage(cve_id, update)
return {"status": "updated", "triage": entry}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.get("/triage", dependencies=[Depends(require_jwt)])
async def list_triage(status: Optional[str] = None):
"""List triage entries."""
entries = list(manager.triage.values())
if status:
entries = [e for e in entries if e.status.value == status]
return {"triage": entries, "count": len(entries)}
@app.post("/epss", dependencies=[Depends(require_jwt)])
async def fetch_epss():
"""Fetch EPSS scores for known CVEs (batched for large sets)."""
cve_ids = list(manager.cves.keys())
total_updated = 0
# Process in batches of 100
for i in range(0, len(cve_ids), 100):
batch = cve_ids[i:i + 100]
scores = await manager.get_epss_scores(batch)
total_updated += len(scores)
manager._save_data()
return {"updated": total_updated, "total_cves": len(cve_ids)}
@app.post("/scan/debian/{package_name}", dependencies=[Depends(require_jwt)])
async def scan_debian_security(package_name: str):
"""Query Debian Security Tracker for a package."""
cves = await manager.query_debian_security(package_name)
return {"cves": cves, "count": len(cves)}
@app.get("/cves/{cve_id}/patch", dependencies=[Depends(require_jwt)])
async def get_patch_suggestions(cve_id: str):
"""Get patch/upgrade suggestions for a CVE."""
result = manager.get_patch_suggestions(cve_id)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.get("/cves/search", dependencies=[Depends(require_jwt)])
async def search_cves(
query: Optional[str] = None,
severity: Optional[str] = None,
limit: int = 50,
offset: int = 0
):
"""Search CVEs with pagination."""
cves = list(manager.cves.values())
# Filter by severity
if severity:
cves = [c for c in cves if c.severity.value == severity]
# Filter by query (search in description and CVE ID)
if query:
query_lower = query.lower()
cves = [c for c in cves if query_lower in c.cve_id.lower() or query_lower in c.description.lower()]
# Sort by CVSS score descending
cves.sort(key=lambda c: c.cvss_score or 0, reverse=True)
total = len(cves)
paginated = cves[offset:offset + limit]
return {
"cves": paginated,
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + len(paginated) < total
}
@app.delete("/cves/{cve_id}", dependencies=[Depends(require_jwt)])
async def delete_cve(cve_id: str):
"""Remove a CVE from the database."""
if cve_id not in manager.cves:
raise HTTPException(status_code=404, detail="CVE not found")
del manager.cves[cve_id]
if cve_id in manager.triage:
del manager.triage[cve_id]
manager._save_data()
return {"status": "deleted"}
# ============================================================================
# External Feed Endpoints
# ============================================================================
@app.post("/feeds/kev", dependencies=[Depends(require_jwt)])
async def fetch_kev_feed():
"""Fetch CISA Known Exploited Vulnerabilities catalog."""
cves = await manager.fetch_cisa_kev()
return {"source": "CISA KEV", "fetched": len(cves), "total": len(manager.cves)}
@app.post("/feeds/recent", dependencies=[Depends(require_jwt)])
async def fetch_recent_feed(days: int = 7):
"""Fetch recently published CVEs from NVD."""
cves = await manager.fetch_recent_nvd(days)
return {"source": "NVD Recent", "days": days, "fetched": len(cves), "total": len(manager.cves)}
@app.get("/feeds/trending", dependencies=[Depends(require_jwt)])
async def get_trending():
"""Get trending CVEs by EPSS score."""
trending = await manager.fetch_trending_cves()
return {"trending": trending, "count": len(trending)}
@app.get("/feeds/summary", dependencies=[Depends(require_jwt)])
async def get_feed_summary():
"""Get summary of all CVE feeds."""
return manager.get_feed_summary()
@app.post("/feeds/sync-all", dependencies=[Depends(require_jwt)])
async def sync_all_feeds():
"""Sync all external CVE feeds."""
results = {}
# Fetch CISA KEV
kev_cves = await manager.fetch_cisa_kev()
results["kev"] = len(kev_cves)
# Fetch recent NVD
recent_cves = await manager.fetch_recent_nvd(7)
results["recent_7d"] = len(recent_cves)
# Update EPSS scores
cve_ids = list(manager.cves.keys())
epss_scores = await manager.get_epss_scores(cve_ids[:100])
results["epss_updated"] = len(epss_scores)
manager._save_data()
return {
"status": "synced",
"results": results,
"total_cves": len(manager.cves)
}
@app.get("/kev", dependencies=[Depends(require_jwt)])
async def list_kev_cves():
"""List CVEs from CISA KEV (actively exploited)."""
# Filter CVEs that are in KEV (severity=critical and from KEV source)
kev_cache = manager.data_dir / "kev_cache.json"
kev_ids = set()
if kev_cache.exists():
try:
data = json.loads(kev_cache.read_text())
kev_ids = {v["cveID"] for v in data.get("vulnerabilities", [])}
except Exception:
pass
kev_cves = [c for c in manager.cves.values() if c.cve_id in kev_ids]
kev_cves.sort(key=lambda c: c.published or "", reverse=True)
return {"cves": kev_cves, "count": len(kev_cves), "source": "CISA KEV"}
# ============================================================================
# WAF Product-Absent Probe Generator (wafgen)
# ============================================================================
# Handlers are plain `def`, not `async def`: gather_present() shells out to
# dpkg, generate() does blocking file I/O over the vendored Nuclei subset, and
# write_category() is a blocking atomic write. This module is imported
# in-process by the secubox aggregator, which runs ONE shared event loop for
# ~110 modules — a blocking call inside an `async def` handler would freeze
# the whole board. Plain `def` handlers run in FastAPI's threadpool instead.
@app.get("/waf-rules", dependencies=[Depends(require_jwt)])
def waf_rules_preview():
"""Dry-run: what would be generated (kept + rejections). Writes nothing."""
from .wafgen.generate import generate
from .wafgen.inventory import gather_present
from .wafgen.emit import existing_patterns
present, complete = gather_present()
kept, rejected = generate(
Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete,
existing=existing_patterns(Path("/etc/secubox/waf/waf-rules.json")),
)
return {
"present_count": len(present),
"inventory_complete": complete,
"kept": [
{"cve": c.cve, "vendor": c.vendor, "product": c.product, "path": c.path}
for c in kept
],
"rejected": [{"file": n, "reason": r} for n, r in rejected],
}
@app.post("/waf-rules/generate", dependencies=[Depends(require_jwt)])
def waf_rules_generate():
"""Apply: write product_absent_probes (detect mode). Refuses if the
presence inventory is incomplete (fail-safe)."""
from .wafgen.generate import generate
from .wafgen.inventory import gather_present
from .wafgen.emit import existing_patterns, write_category
present, complete = gather_present()
if not complete:
raise HTTPException(
status_code=409,
detail="presence inventory incomplete — refusing (fail-safe)",
)
kept, rejected = generate(
Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete,
existing=existing_patterns(Path("/etc/secubox/waf/waf-rules.json")),
)
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
write_category(Path("/etc/secubox/waf/waf-rules.json"), kept, now=now)
return {
"success": True,
"written": len(kept),
"mode": "detect",
"rejected": len(rejected),
}
# ============================================================================
# Startup
# ============================================================================
@app.on_event("startup")
async def startup():
"""Initialize on startup."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
logger.info("CVE Triage started")