Files
vulncheck/app/services/vuln_override_service.py
T
vulncheck 3074820737 feat: CVSS score override service with CISA Vulnrichment support
- Add VulnOverrideService to correct erroneous Wazuh CVSS scores (e.g. 10.0 placeholder)
- Add CISA Vulnrichment integration for verified CVSSv3.1 scores and SSVC exploitation status
- Add new API endpoints:
  - GET /override/check - find incorrect scores
  - POST /override/nessus/{asset_id} - override from Nessus
  - POST /override/vulnrichment - override from CISA (all CVEs)
  - GET /override/stats - score error statistics
- Add exploitation_status (SSVC: none/poc/active/widespread) and exploitation_source columns
- Add 'Correct CVSS' button in vulnerabilities frontend
- Add SSVC badge display in vulnerability table
- Update Vulnerability model and types

Fixes: CVE-2026-8390 incorrect CVSS 10.0 → 7.3 HIGH
2026-05-14 20:36:51 +02:00

643 lines
24 KiB
Python

"""
Vulnerability Score Override Service
Korrigiert fehlerhafte CVSS-Scores und Severity-Werte aus Wazuh mit den
verifizierten Daten aus Nessus/Vulnrichment JSON.
Problem: Wazuh liefert manchmal falsche CVSS-Werte (z.B. 10.0 für CVE-2026-8390)
während die korrekten Werte (z.B. 7.3 HIGH) aus Nessus/Vulnrichment stammen.
Funktionsweise:
1. Lädt verifizierte CVE-Daten aus Nessus Plugin-Output
2. Vergleicht mit bestehenden Datenbankeinträgen
3. Überschreibt fehlerhafte Wazuh-Werte mit korrekten Nessus/Vulnrichment-Werten
4. Aktualisiert auch Severity und Exploitation-Status (SSVC)
"""
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional, Set, Tuple
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.vulnerability import (
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStatus,
)
from app.models.asset import Asset
logger = logging.getLogger(__name__)
# SSVC Exploitation Status
class SSVCExploitationStatus(str):
"""SSVC Exploitation Kategorien (Draft NIST SP 500-299)"""
NONE = "none" # Keine Aktivitäten bekannt
POC = "poc" # Proof-of-Concept vorhanden
ACTIVE = "active" # Aktive Ausnutzung
WIDESPREAD = "widespread" # Weit verbreitete Ausnutzung
@dataclass
class VerifiedCVEData:
"""Container für verifizierte CVE-Daten aus einer verlässlichen Quelle"""
cve_id: str
cvss_score: Optional[float] = None
cvss_vector: Optional[str] = None
severity: Optional[str] = None # critical, high, medium, low, none
exploitation_status: Optional[str] = None # none, poc, active, widespread
epss_score: Optional[float] = None
exploit_available: Optional[bool] = None
exploit_maturity: Optional[str] = None # Unproven, PoC, Functional, High
vpr_score: Optional[float] = None
description: Optional[str] = None
references: Optional[List[str]] = None
source: str = "unknown" # nessus, nvd, vulrighment, cisa
class VulnOverrideService:
"""
Service zum Abgleich und Korrektur von CVE-Scores.
Verwendet verifizierte Daten aus:
- Nessus Plugin Output (plugin_cvss, exploit_available, etc.)
- CISA Vulnrichment JSON Feed
- NVD (als Fallback)
Wichtig: Die Korrektur priorisiert folgende Reihenfolge:
1. Nessus (direkte Scan-Daten, höchste Vertrauenswürdigkeit)
2. CISA Vulnrichment (offizielle govt. Quelle)
3. NVD (offizielle Quelle)
"""
def __init__(self, db: Session):
self.db = db
def _severity_from_cvss(self, cvss_score: Optional[float]) -> VulnerabilitySeverity:
"""Konvertiert CVSS-Score zu Severity-Enum"""
if cvss_score is None:
return VulnerabilitySeverity.none
if cvss_score >= 9.0:
return VulnerabilitySeverity.critical
elif cvss_score >= 7.0:
return VulnerabilitySeverity.high
elif cvss_score >= 4.0:
return VulnerabilitySeverity.medium
elif cvss_score > 0:
return VulnerabilitySeverity.low
return VulnerabilitySeverity.none
def _is_wazuh_placeholder_score(self, score: Optional[float]) -> bool:
"""
Erkennt fehlerhafte Wazuh-Platzhalter-Scores.
Wazuh liefert manchmal 10.0 als Platzhalter, wenn der echte
CVSS-Score unbekannt oder nicht verfügbar ist.
"""
if score is None:
return False
# 10.0 ist ein bekannter Platzhalter-Wert in Wazuh
# Echte CVSS-Werte gehen nur bis 10.0, aber 10.0 selbst ist selten
# Besonders bei plausiblen Scores wie 7.3 ist 10.0 offensichtlich falsch
return score == 10.0
def _is_score_discrepancy(
self,
wazuh_score: Optional[float],
verified_score: Optional[float],
threshold: float = 1.0
) -> bool:
"""
Prüft ob eine signifikante Diskrepanz zwischen Scores besteht.
Args:
wazuh_score: Aktueller Score in DB (von Wazuh)
verified_score: Korrekter Score aus verifizierter Quelle
threshold: Minimale Differenz für Korrektur (Standard: 1.0)
"""
if wazuh_score is None and verified_score is None:
return False
if wazuh_score is None or verified_score is None:
return True # Einer ist None, der andere hat einen Wert
diff = abs(wazuh_score - verified_score)
return diff >= threshold
def load_nessus_verified_data(
self,
asset_id: int,
scan_id: int,
client
) -> Dict[str, VerifiedCVEData]:
"""
Lädt verifizierte CVE-Daten direkt aus Nessus Plugin Output.
Args:
asset_id: Asset-DB-ID
scan_id: Nessus Scan-ID
client: NessusClient Instance
Returns:
Dict[cve_id -> VerifiedCVEData] mit allen verifizierten CVEs
"""
verified: Dict[str, VerifiedCVEData] = {}
try:
hosts = client.get_scan_hosts(scan_id)
for host in hosts:
host_id = host.get("host_id")
if not host_id:
continue
# Asset-Matching prüfen
asset = self.db.query(Asset).filter(
Asset.id == asset_id
).first()
if not asset:
continue
# Host-Details abrufen
try:
host_detail = client.get_host(scan_id, host_id)
except Exception:
continue
for vuln_entry in host_detail.get("vulnerabilities") or []:
plugin_id = vuln_entry.get("plugin_id")
if not plugin_id:
continue
# Plugin-Details abrufen
try:
plugin_payload = client.get_plugin_output(
scan_id, host_id, plugin_id
)
except Exception:
continue
# CVEs aus Plugin extrahieren
cve_list = client.extract_cves(plugin_payload)
if not cve_list:
continue
# Scores aus Plugin extrahieren
cvss_score = client.plugin_cvss(plugin_payload)
vpr_score = client.plugin_vpr_score(plugin_payload)
exploit_avail = client.plugin_exploit_available(plugin_payload)
exploit_mat = client.plugin_exploit_maturity(plugin_payload)
description = client.plugin_description(plugin_payload)
see_also = client.plugin_see_also(plugin_payload)
for cve_id in cve_list:
if cve_id not in verified: # Nur erster Fund zählt
verified[cve_id] = VerifiedCVEData(
cve_id=cve_id,
cvss_score=cvss_score,
severity=self._severity_from_cvss(cvss_score).value,
exploitation_status=self._map_exploit_maturity(exploit_mat),
exploit_available=exploit_avail,
exploit_maturity=exploit_mat,
vpr_score=vpr_score,
description=description,
references=see_also,
source="nessus"
)
except Exception as e:
logger.error(f"Fehler beim Laden Nessus-Daten für Asset {asset_id}: {e}")
return verified
def _map_exploit_maturity(self, maturity: Optional[str]) -> str:
"""Konvertiert Nessus exploit_code_maturity zu SSVC exploitation status"""
if not maturity:
return SSVCExploitationStatus.NONE
maturity_lower = maturity.lower()
if maturity_lower in ("unproven", "poc", "proof-of-concept"):
return SSVCExploitationStatus.POC
elif maturity_lower in ("functional", "active"):
return SSVCExploitationStatus.ACTIVE
elif maturity_lower == "high":
return SSVCExploitationStatus.WIDESPREAD
return SSVCExploitationStatus.NONE
def load_cisa_vulnrichment_data(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""
Lädt verifizierte CVE-Daten aus dem CISA Vulnrichment JSON Feed.
CISA Vulnrichment enthält korrekte CVSSv3.1 Base Scores und SSVC-Daten.
Feed: https://www.cisa.gov/sites/default/files/feeds/vulnrichment.json
Args:
cve_ids: Liste von CVE-IDs zum Abrufen
Returns:
Dict[cve_id -> VerifiedCVEData]
"""
verified: Dict[str, VerifiedCVEData] = {}
try:
import httpx
# Vulnrichment API (neuerer Feed mit SSVC-Daten)
# Alternativ: direkter JSON-Download
url = "https://www.cisa.gov/sites/default/files/feeds/vulnrichment.json"
with httpx.Client(timeout=30.0) as client:
response = client.get(url)
response.raise_for_status()
data = response.json()
# Durchsuche Feeds nach匹配 CVE-IDs
for item in data.get("vulnerabilities", []):
cve_id = item.get("cveID", "").upper()
if cve_id not in cve_ids:
continue
# CVSSv3.1 Base Score
cvss_v31 = item.get("cvssV3_1", {})
base_score = cvss_v31.get("baseScore")
base_severity = cvss_v31.get("baseSeverity", "").lower()
# SSVC Daten
ssvc = item.get("ssvc", {})
exploitation = ssvc.get("exploitation", "").lower()
verified[cve_id] = VerifiedCVEData(
cve_id=cve_id,
cvss_score=base_score,
severity=base_severity,
exploitation_status=exploitation,
source="vulnrichment"
)
except Exception as e:
logger.error(f"Fehler beim Laden CISA Vulnrichment-Daten: {e}")
return verified
def apply_overrides(
self,
asset_id: int,
verified_data: Dict[str, VerifiedCVEData],
dry_run: bool = False
) -> dict:
"""
Wendet Overrides auf Vulnerabilities an.
Args:
asset_id: Asset-DB-ID
verified_data: Dict mit verifizierten CVE-Daten
dry_run: Wenn True, nur simulieren ohne DB-Änderungen
Returns:
Stats-Dict mit Anzahl der Änderungen
"""
stats = {
"checked": 0,
"updated": 0,
"skipped_no_change": 0,
"skipped_placeholder": 0,
"errors": 0,
"changes": [] # Liste der durchgeführten Änderungen
}
vulns = self.db.query(Vulnerability).filter(
Vulnerability.asset_id == asset_id,
Vulnerability.cve_id.in_(list(verified_data.keys()))
).all()
for vuln in vulns:
stats["checked"] += 1
cve_id = vuln.cve_id
if cve_id not in verified_data:
continue
verified = verified_data[cve_id]
try:
changes = self._apply_single_override(vuln, verified, dry_run)
if changes["has_changes"]:
if dry_run:
stats["skipped_no_change"] += 1 # Dry run zählt als "keine Änderung"
else:
stats["updated"] += 1
stats["changes"].append({
"cve_id": cve_id,
"vuln_id": vuln.id,
"fields_changed": changes["fields"],
"old_values": changes["old"],
"new_values": changes["new"]
})
else:
stats["skipped_no_change"] += 1
except Exception as e:
stats["errors"] += 1
logger.error(f"Override für {cve_id} fehlgeschlagen: {e}")
if not dry_run:
self.db.commit()
return stats
def _apply_single_override(
self,
vuln: Vulnerability,
verified: VerifiedCVEData,
dry_run: bool = False
) -> dict:
"""
Wendet ein Override auf eine einzelne Vulnerability an.
Returns:
Dict mit has_changes, fields, old, new
"""
changes = {
"has_changes": False,
"fields": [],
"old": {},
"new": {}
}
# 1. CVSS-Score prüfen und ggf. korrigieren
wazuh_score = vuln.cvss_score
verified_score = verified.cvss_score
# Nur korrigieren wenn:
# - Wazuh einen Platzhalter liefert (10.0)
# - ODER signifikante Diskrepanz besteht (>1.0 Differenz)
should_override_score = (
self._is_wazuh_placeholder_score(wazuh_score) or
self._is_score_discrepancy(wazuh_score, verified_score)
)
if should_override_score and verified_score is not None:
if verified_score != wazuh_score:
changes["fields"].append("cvss_score")
changes["old"]["cvss_score"] = wazuh_score
changes["new"]["cvss_score"] = verified_score
if not dry_run:
vuln.cvss_score = verified_score
changes["has_changes"] = True
# 2. Severity korrigieren basierend auf korrektem CVSS
if verified_score is not None:
correct_severity = self._severity_from_cvss(verified_score)
if vuln.severity != correct_severity:
changes["fields"].append("severity")
changes["old"]["severity"] = vuln.severity.value
changes["new"]["severity"] = correct_severity.value
if not dry_run:
vuln.severity = correct_severity
changes["has_changes"] = True
# 3. Exploitation-Status (SSVC) aktualisieren
if verified.exploitation_status:
current_exploit = getattr(vuln, 'exploitation_status', None)
if current_exploit != verified.exploitation_status:
changes["fields"].append("exploitation_status")
changes["old"]["exploitation_status"] = current_exploit
changes["new"]["exploitation_status"] = verified.exploitation_status
if not dry_run:
vuln.exploitation_status = verified.exploitation_status
changes["has_changes"] = True
# 4. Exploit-Available-Flag aktualisieren
if verified.exploit_available is not None:
if vuln.exploit_available != verified.exploit_available:
changes["fields"].append("exploit_available")
changes["old"]["exploit_available"] = vuln.exploit_available
changes["new"]["exploit_available"] = verified.exploit_available
if not dry_run:
vuln.exploit_available = verified.exploit_available
changes["has_changes"] = True
# 5. Exploit-Maturity aktualisieren
if verified.exploit_maturity:
if vuln.exploit_maturity != verified.exploit_maturity:
changes["fields"].append("exploit_maturity")
changes["old"]["exploit_maturity"] = vuln.exploit_maturity
changes["new"]["exploit_maturity"] = verified.exploit_maturity
if not dry_run:
vuln.exploit_maturity = verified.exploit_maturity
changes["has_changes"] = True
# 6. VPR-Score aktualisieren (Tenable)
if verified.vpr_score is not None:
if vuln.nessus_vpr_score != verified.vpr_score:
changes["fields"].append("nessus_vpr_score")
changes["old"]["nessus_vpr_score"] = vuln.nessus_vpr_score
changes["new"]["nessus_vpr_score"] = verified.vpr_score
if not dry_run:
vuln.nessus_vpr_score = verified.vpr_score
changes["has_changes"] = True
return changes
def correct_all_vulnerabilities(
self,
dry_run: bool = False
) -> dict:
"""
Korrigiert alle Vulnerabilities in der Datenbank.
Verwendet CISA Vulnrichment als primäre Korrekturquelle.
Args:
dry_run: Wenn True, nur simulieren
Returns:
Aggregierte Stats für alle Korrekturen
"""
# Alle offenen CVEs sammeln
vulns = self.db.query(Vulnerability).filter(
Vulnerability.status == VulnerabilityStatus.open
).all()
cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and not v.cve_id.startswith("NESSUS-")))
if not cve_ids:
return {"checked": 0, "updated": 0, "message": "Keine CVEs zum Korrigieren"}
# Vulnrichment-Daten laden
verified_data = self.load_cisa_vulnrichment_data(cve_ids)
# Override anwenden pro Asset
asset_ids = list(set(v.asset_id for v in vulns))
total_stats = {
"checked": 0,
"updated": 0,
"skipped_no_change": 0,
"skipped_placeholder": 0,
"errors": 0,
"assets_processed": len(asset_ids),
"changes": []
}
for asset_id in asset_ids:
asset_vulns = [v for v in vulns if v.asset_id == asset_id]
asset_verified = {
cve_id: verified_data[cve_id]
for cve_id in (v.cve_id for v in asset_vulns)
if cve_id in verified_data
}
if not asset_verified:
continue
stats = self.apply_overrides(asset_id, asset_verified, dry_run)
total_stats["checked"] += stats["checked"]
total_stats["updated"] += stats["updated"]
total_stats["skipped_no_change"] += stats["skipped_no_change"]
total_stats["skipped_placeholder"] += stats["skipped_placeholder"]
total_stats["errors"] += stats["errors"]
total_stats["changes"].extend(stats["changes"])
return total_stats
def find_incorrect_scores(self) -> List[dict]:
"""
Findet alle Vulnerabilities mit wahrscheinlich falschen Scores.
Erkennt:
- Platzhalter-Scores (10.0)
- Unplausible Kombinationen (CVSS 10 + LOW Severity)
Returns:
Liste von Vulnerabilities mit Diskrepanzen
"""
incorrect = []
# 1. Alle mit Score 10.0 (Platzhalter)
placeholder_vulns = self.db.query(Vulnerability).filter(
Vulnerability.cvss_score == 10.0
).all()
for vuln in placeholder_vulns:
correct_severity = self._severity_from_cvss(7.3) # Typischer Korrekturwert
if vuln.severity == VulnerabilitySeverity.critical:
incorrect.append({
"vuln_id": vuln.id,
"cve_id": vuln.cve_id,
"asset_id": vuln.asset_id,
"current_score": vuln.cvss_score,
"current_severity": vuln.severity.value,
"likely_correct_score": None, # Muss aus externer Quelle ermittelt werden
"likely_correct_severity": None,
"issue": "placeholder_score_10",
"recommendation": "Fetch from CISA Vulnrichment or NVD"
})
# 2. Alle mit CVSS > 9 aber LOW/NONE Severity
misaligned = self.db.query(Vulnerability).filter(
Vulnerability.cvss_score >= 9.0,
Vulnerability.severity.in_([
VulnerabilitySeverity.low,
VulnerabilitySeverity.none,
VulnerabilitySeverity.medium
])
).all()
for vuln in misaligned:
incorrect.append({
"vuln_id": vuln.id,
"cve_id": vuln.cve_id,
"asset_id": vuln.asset_id,
"current_score": vuln.cvss_score,
"current_severity": vuln.severity.value,
"issue": "severity_mismatch",
"recommendation": "CVSS >= 9.0 sollte CRITICAL sein"
})
return incorrect
def correct_vulnerability_scores(
db: Session,
cve_ids: Optional[List[str]] = None,
asset_ids: Optional[List[int]] = None,
dry_run: bool = False
) -> dict:
"""
Hauptfunktion zum Korrigieren von CVE-Scores.
Args:
db: Datenbank-Session
cve_ids: Optionale CVE-Filterliste
asset_ids: Optionale Asset-Filterliste
dry_run: Simulation ohne DB-Änderungen
Returns:
Statistik-Dict mit Korrekturdetails
"""
service = VulnOverrideService(db)
query = db.query(Vulnerability).filter(
Vulnerability.status == VulnerabilityStatus.open
)
if cve_ids:
query = query.filter(Vulnerability.cve_id.in_(cve_ids))
if asset_ids:
query = query.filter(Vulnerability.asset_id.in_(asset_ids))
vulns = query.all()
if not vulns:
return {"message": "Keine Vulnerabilities zum Korrigieren", "updated": 0}
# Sammle alle CVE-IDs für Vulnrichment-Abfrage
all_cve_ids = list(set(v.cve_id for v in vulns if v.cve_id and not v.cve_id.startswith("NESSUS-")))
if not all_cve_ids:
return {"message": "Keine echten CVEs zum Korrigieren", "updated": 0}
# Lade verifizierte Daten
verified_data = service.load_cisa_vulnrichment_data(all_cve_ids)
if not verified_data:
logger.warning("Keine verifizierten Daten von CISA Vulnrichment erhalten")
return {
"message": "CISA Vulnrichment-Daten nicht verfügbar",
"checked": len(vulns),
"updated": 0
}
# Gruppiere nach Asset
asset_groups: Dict[int, List[str]] = {}
for vuln in vulns:
if vuln.cve_id in verified_data:
if vuln.asset_id not in asset_groups:
asset_groups[vuln.asset_id] = []
asset_groups[vuln.asset_id].append(vuln.cve_id)
total_stats = {
"checked": 0,
"updated": 0,
"errors": 0,
"changes": []
}
for asset_id, cve_list in asset_groups.items():
asset_verified = {
cve_id: verified_data[cve_id]
for cve_id in cve_list
if cve_id in verified_data
}
stats = service.apply_overrides(asset_id, asset_verified, dry_run)
total_stats["checked"] += stats["checked"]
total_stats["updated"] += stats["updated"]
total_stats["errors"] += stats["errors"]
total_stats["changes"].extend(stats["changes"])
return total_stats