mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
1163 lines
42 KiB
Python
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")
|