Files
vulncheck/app/routers/vulnerabilities.py
T
vulncheck 9ca0be24f0 feat(scans): a sync that dies at login is now a failed run, on screen and by mail
A Wazuh API that refused every login for a day was visible only in the
container log. Every scan-jobs row is per asset, and a sync that fails at
authentication never reaches an asset — so /scans showed a clean history of
COMPLETED runs, the manual button said "Sync Failed — check the logs", and
nobody was told.

Ledger: every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL; button
or scheduler) is one sync_runs row with start, end, status, stats and the
error that stopped it, written from its own session so the record never
depends on the job's transaction. A run where every agent failed the same
way counts as failed, not as "0 triggered". Rows left running by a restart
are closed at startup.

Visible: Scan Jobs gets a Sync Health strip (per source: state, last run,
last success, error) and a Sync Runs table; the dashboard shows a banner
while a source is failed or stale (stale follows the schedule interval, a
weekly Nessus is not stale after six days); the Sync Failed modal shows the
real reason, and the sync endpoints return it instead of "check server
logs".

Mail: a failed run mails the sync-alert recipients (own setting, else the
notification defaults) with the error and the last successful sync, i.e.
how long the coverage gap already is — one mail per source per 24h while
it keeps failing, logged as SYNC_FAILURE in the notification log.
2026-09-03 18:11:24 +02:00

3674 lines
152 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Vulnerability Management Router
Endpoints for CVE management, prioritization, and AI analysis.
"""
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import desc, asc, func, and_
from sqlalchemy.exc import IntegrityError
from pydantic import BaseModel, field_validator
import json
import os
import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models.user import User
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
from app.models.asset import Asset, AssetStatus
from app.models.ai_analysis import AIAnalysis
from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor
from app.integrations.wazuh_client import get_wazuh_client, WazuhClient
from app.models.setting import Setting
from app.integrations.infomaniak_ai_client import InfomaniakAIClient, get_infomaniak_ai_client, AIAnalysisService
from app.models.ai_report import AIReport
from app.services.enrichment_service import (
enrich_vulnerabilities,
enrich_vulnerability_by_id,
enrich_all_open_vulnerabilities,
fetch_kev_catalog,
EnrichmentError,
)
from app.services.vuln_override_service import (
correct_vulnerability_scores,
VulnOverrideService,
)
from app.services import github_repo_advisory_service as repo_adv
from app.services.override_jobs import (
start_vulnrichment_job,
get_job as get_override_job,
list_jobs as list_override_jobs,
)
# Settings key for Nessus configuration
SETTING_KEY = "nessus_config"
router = APIRouter(prefix="/api/v1/vulnerabilities", tags=["Vulnerabilities"])
# ============================================
# Pydantic Schemas
# ============================================
class VulnerabilityResponse(BaseModel):
id: int
cve_id: str
asset_id: int
asset_hostname: str
cvss_score: Optional[float]
severity: VulnerabilitySeverity
status: VulnerabilityStatus
title: Optional[str]
package_name: Optional[str]
package_vendor: Optional[str] = None
package_version: Optional[str]
fixed_version: Optional[str]
exploitable: bool
exploit_available: bool
detected_at: datetime
priority_score: float
priority_breakdown: Optional[dict] = None
cpr_score: Optional[float] = None
epss_score: Optional[float] = None
epss_percentile: Optional[float] = None
kev_listed: bool = False
kev_ransomware_use: bool = False
kev_date_added: Optional[datetime] = None
euvd_listed: bool = False
euvd_critical: bool = False
euvd_date_added: Optional[datetime] = None
euvd_id: Optional[str] = None
enrichment_sources: Optional[str] = None
enrichment_updated_at: Optional[datetime] = None
# Multi-scanner attribution (Nessus integration)
sources: List[str] = []
cross_confirmed: bool = False
first_detected_by: Optional[str] = None
nessus_plugin_id: Optional[str] = None
nessus_vpr_score: Optional[float] = None
exploitation_status: Optional[str] = None
exploitation_source: Optional[str] = None # vulnrichment | nvd | cvelistv5 | nessus
ssvc_technical_impact: Optional[str] = None # partial | total
ssvc_automatable: Optional[str] = None # yes | no
is_pseudo_cve: bool = False
is_eol_finding: bool = False # endoflife.date pseudo-CVE
# Plan M — public-exploit catalogs
exploit_db_count: int = 0
exploit_db_ids: Optional[List[str]] = None
pocs_github_count: int = 0
pocs_github_urls: Optional[List[str]] = None
metasploit_module_count: int = 0
metasploit_modules: Optional[List[str]] = None
exploit_intel_updated_at: Optional[datetime] = None
# Latest VULNERABILITY_UPDATED audit row for this vuln. Used by the
# list page to render a hover-tooltip on the Status badge so the
# operator can tell at a glance who patched / FP-marked / auto-patched
# a row without opening the detail page.
last_change: Optional[dict] = None
assigned_user_id: Optional[int] = None
assigned_user_name: Optional[str] = None
assigned_group_id: Optional[int] = None
assigned_group_name: Optional[str] = None
deferred_until: Optional[datetime] = None
defer_reason: Optional[str] = None
notification_suppressed: bool = False
# Per-package detail (1:N child rows). Empty for pseudo-CVEs.
packages: List["PackageInfo"] = []
has_fix_any: bool = False # at least one package has a real fix
class Config:
from_attributes = True
class PackageInfo(BaseModel):
package_name: str
package_vendor: Optional[str] = None
package_version: Optional[str] = None
fixed_version: Optional[str] = None
source: Optional[str] = None
has_fix: bool = False
class Config:
from_attributes = True
class AssignRequest(BaseModel):
user_id: Optional[int] = None
group_id: Optional[int] = None
class DeferRequest(BaseModel):
deferred_until: datetime
reason: str
class VulnerabilityDetailResponse(VulnerabilityResponse):
description: Optional[str]
cvss_vector: Optional[str]
exploit_maturity: Optional[str]
published_date: Optional[datetime]
last_modified_date: Optional[datetime]
patched_at: Optional[datetime]
references: Optional[str]
github_advisory_url: Optional[str] = None
remediation: Optional[str] = None
cwe_id: Optional[str]
ai_analysis: Optional[dict]
# Resolve forward ref — VulnerabilityResponse declares `packages: List["PackageInfo"]`
# above the PackageInfo class itself.
VulnerabilityResponse.model_rebuild()
VulnerabilityDetailResponse.model_rebuild()
class VulnerabilityUpdateRequest(BaseModel):
status: VulnerabilityStatus
# `reason` is status-agnostic — captures WHO/HOW for the audit trail
# regardless of which status the row moves to (patched via WSUS,
# FP because vendor confirmed N/A, accepted_risk per ITSM ticket).
# `defer_reason` kept for backward compatibility on existing UI.
reason: Optional[str] = None
defer_reason: Optional[str] = None
deferred_until: Optional[datetime] = None
@field_validator('status', mode='before')
@classmethod
def normalize_status(cls, v):
if isinstance(v, str):
return v.lower()
return v
class BulkVulnerabilityUpdateRequest(BaseModel):
vulnerability_ids: List[int]
assigned_user_id: Optional[int] = None
assigned_group_id: Optional[int] = None
status: Optional[VulnerabilityStatus] = None
# Optional reason — when blank, backend auto-fills with
# "Manually set to {status} by {username} on {datetime}" so the
# audit trail always tells WHO + WHEN even on the bulk-action path.
reason: Optional[str] = None
@field_validator('status', mode='before')
@classmethod
def normalize_status(cls, v):
if isinstance(v, str):
return v.lower()
return v
class VulnerabilityFilterParams(BaseModel):
severity: Optional[VulnerabilitySeverity] = None
status: Optional[VulnerabilityStatus] = None
exploitable: Optional[bool] = None
asset_id: Optional[int] = None
search: Optional[str] = None # Suche in CVE-ID, Package-Name, Title
class DashboardStatsResponse(BaseModel):
total_vulnerabilities: int
open_vulnerabilities: int
critical_count: int
high_count: int
medium_count: int
low_count: int
exploitable_count: int
avg_cvss_score: float
affected_assets: int
total_assets: int
scanned_assets: int
severity_history: List[dict]
oldest_vulnerability_days: int
# ============================================
# Helper-Funktionen
# ============================================
def _auto_reason(new_status, user_name: str) -> str:
"""Fallback reason string when the user submitted no comment.
Field report: bulk + single status changes accepted blank
comments → audit trail couldn't distinguish manual patches from
scanner auto-patches. Auto-stamp ensures every manual change
still carries who/when in the audit log.
"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
label = new_status.value if hasattr(new_status, "value") else str(new_status)
return f"Manually set to {label} by {user_name} on {ts}"
def log_vulnerability_change(
db: Session,
user_id: Optional[int],
vuln_id: int,
old_status,
new_status,
reason: Optional[str] = None,
cve_id: Optional[str] = None,
source: Optional[str] = None,
hostname: Optional[str] = None,
):
"""Audit-Log entry for a vulnerability status change.
`user_id=None` for automated transitions (Nessus / Wazuh sync
auto-patching when a CVE disappears from the scan result).
`reason` is a free-text rationale persisted as JSON in new_value
alongside the new status — readable by the audit-log UI.
`source` tags WHERE the change came from
(`manual` / `nessus_sync` / `wazuh_sync` / `verify_patch_rescan`).
`hostname` names the affected asset in the description so the entry can
be attributed and FOUND by host in the audit-log search (observed: reopen
rows said which CVE but not which system).
"""
old_val = old_status.value if hasattr(old_status, "value") else str(old_status)
new_val = new_status.value if hasattr(new_status, "value") else str(new_status)
if not hostname:
# The parameter has been here from the start, but the automated paths
# (EOL supersede/reconcile, app-scan resolve) never passed it, so their
# rows named the CVE and not the machine — unusable in the audit view
# unless you exported the CSV and read new_value. Look it up rather
# than thread it through every caller; these are single-row writes and
# the id is indexed.
try:
hostname = (db.query(Asset.hostname)
.join(Vulnerability, Vulnerability.asset_id == Asset.id)
.filter(Vulnerability.id == vuln_id)
.scalar())
except Exception:
hostname = None
new_payload = json.dumps({
"status": new_val,
"reason": reason,
"source": source or "manual",
"cve_id": cve_id,
"hostname": hostname,
})
audit_log = AuditLog(
user_id=user_id,
event_type=AuditEventType.VULNERABILITY_UPDATED,
event_description=(
f"Vulnerability {cve_id or ''} status changed: {old_val}{new_val}"
+ (f" on {hostname}" if hostname else "")
+ (f" ({reason})" if reason else "")
)[:500],
resource_type="vulnerability",
resource_id=str(vuln_id),
old_value=old_val,
new_value=new_payload,
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
def _sanitize_wazuh_fix(pkg_name, pkg_version, fix):
"""Drop a Wazuh-reported fix build that belongs to a DIFFERENT Windows
release. Wazuh CTI sometimes attaches e.g. 6.2.9200.26079 (Server 2012) to
an OS finding on a Server 2025 host (10.0.26100.x); a fix on another build
line says nothing about this host — better no fix than misinformation (the
MSRC remediation panel supplies the correct per-branch KB anyway)."""
if not (fix and pkg_name and "microsoft windows" in str(pkg_name).lower()):
return fix
def _bl(v):
try:
t = [int(x) for x in str(v).split(".")]
return tuple(t[:3]) if len(t) >= 4 else None
except (ValueError, TypeError):
return None
inst_bl, fix_bl = _bl(pkg_version), _bl(fix)
if inst_bl and fix_bl and inst_bl != fix_bl:
return None
return fix
def _build_vuln_response(vuln: Vulnerability) -> dict:
"""Build a vulnerability response dict with assigned user info"""
breakdown = vuln.calculate_priority_breakdown()
# Per-package detail (1:N). Cheaper than a JOIN in queries that
# already loaded the parent — SQLAlchemy's default-lazy load is
# one extra round-trip per vuln. Fine for detail; list endpoints
# may want a joinedload upstream later.
pkgs = getattr(vuln, "packages", []) or []
# Provenance must never be blank: rows written before per-package source
# tracking (and any path that only fills the parent summary) fall back to
# the finding's own sources, so "via …" is present in EVERY constellation.
fallback_src = ",".join(vuln.source_list or []) or vuln.first_detected_by
packages_payload = [
{
"package_name": p.package_name,
"package_version": p.package_version,
"fixed_version": p.fixed_version,
"source": p.source or fallback_src,
"has_fix": p.has_fix,
}
for p in pkgs
]
has_fix_any = any(p.has_fix for p in pkgs) if pkgs else bool(
vuln.fixed_version and vuln.fixed_version != vuln.package_version
)
if not packages_payload and vuln.package_name:
# Parent-summary-only finding (OS-level rows, pseudo-CVEs, legacy data):
# render it through the same per-package view instead of a separate
# block that carried no source line at all.
packages_payload = [{
"package_name": vuln.package_name,
"package_version": vuln.package_version,
"fixed_version": vuln.fixed_version,
"source": fallback_src,
"has_fix": has_fix_any,
}]
# NVD and cvelistV5 carry nothing for a lot of these ids, and the GLOBAL
# GitHub advisory DB does not have them either — github.com/advisories/
# GHSA-8c6v-7g3w-prrq is a 404 while wazuh/wazuh's own advisory page
# serves it. So when the affected product maps to a repo that publishes
# its own advisories, link to THAT repo's advisory search; it is the only
# page where the record actually exists. Search URL rather than a deep
# link because the GHSA id is not persisted on the finding.
gh_repo = repo_adv.resolve_repo(
vuln.package_name or (packages_payload[0]["package_name"] if packages_payload else ""))
return {
"id": vuln.id,
"cve_id": vuln.cve_id,
"asset_id": vuln.asset_id,
"asset_hostname": vuln.asset.hostname if vuln.asset else "Unknown",
"cvss_score": vuln.cvss_score,
"severity": vuln.severity,
"status": vuln.status,
"title": vuln.title,
"package_name": vuln.package_name,
"package_vendor": vuln.package_vendor,
"package_version": vuln.package_version,
"fixed_version": vuln.fixed_version,
"exploitable": vuln.exploitable,
"exploit_available": vuln.exploit_available,
"detected_at": vuln.detected_at,
"priority_score": breakdown["total"],
"priority_breakdown": breakdown,
"cpr_score": breakdown.get("cpr"),
"epss_score": vuln.epss_score,
"epss_percentile": vuln.epss_percentile,
"kev_listed": vuln.kev_listed,
"kev_ransomware_use": vuln.kev_ransomware_use,
"kev_date_added": vuln.kev_date_added,
"euvd_listed": vuln.euvd_listed,
"euvd_critical": vuln.euvd_critical,
"euvd_date_added": vuln.euvd_date_added,
"euvd_id": vuln.euvd_id,
"enrichment_sources": vuln.enrichment_sources,
"enrichment_updated_at": vuln.enrichment_updated_at,
# Multi-scanner attribution (Nessus integration)
"sources": vuln.source_list,
"cross_confirmed": vuln.cross_confirmed,
"first_detected_by": vuln.first_detected_by,
"nessus_plugin_id": vuln.nessus_plugin_id,
"nessus_vpr_score": vuln.nessus_vpr_score,
# SSVC decision points from CISA Vulnrichment.
# exploitation_status was previously missing from _build_vuln_response
# — Pydantic schema declared the field but the dict never set it,
# so the frontend POC/ACTIVE/WIDESPREAD pill never fired even when
# the score breakdown showed +2.0 from Exploit (ssvc).
"exploitation_status": vuln.exploitation_status,
"exploitation_source": vuln.exploitation_source,
"ssvc_technical_impact": vuln.ssvc_technical_impact,
"ssvc_automatable": vuln.ssvc_automatable,
"is_pseudo_cve": vuln.is_pseudo_cve,
"is_eol_finding": vuln.is_eol_finding,
# Plan M — public-exploit catalogs. Parse JSON-stored lists into
# actual arrays for the frontend. Empty list when no exploit known.
"exploit_db_count": vuln.exploit_db_count or 0,
"exploit_db_ids": (json.loads(vuln.exploit_db_ids) if vuln.exploit_db_ids else None),
"pocs_github_count": vuln.pocs_github_count or 0,
"pocs_github_urls": (json.loads(vuln.pocs_github_urls) if vuln.pocs_github_urls else None),
"metasploit_module_count": vuln.metasploit_module_count or 0,
"metasploit_modules": (json.loads(vuln.metasploit_modules) if vuln.metasploit_modules else None),
"exploit_intel_updated_at": vuln.exploit_intel_updated_at,
"assigned_user_id": vuln.assigned_user_id,
"assigned_user_name": vuln.assigned_user.username if vuln.assigned_user else None,
"assigned_group_id": vuln.assigned_group_id,
"assigned_group_name": vuln.group.name if vuln.group else None,
"deferred_until": vuln.deferred_until,
"defer_reason": vuln.defer_reason,
"notification_suppressed": vuln.notification_suppressed,
"packages": packages_payload,
"has_fix_any": has_fix_any,
# Fields for DetailResponse (will be None if not detail)
"description": getattr(vuln, 'description', None),
"cvss_vector": getattr(vuln, 'cvss_vector', None),
"exploit_maturity": getattr(vuln, 'exploit_maturity', None),
"published_date": getattr(vuln, 'published_date', None),
"last_modified_date": getattr(vuln, 'last_modified_date', None),
"patched_at": getattr(vuln, 'patched_at', None),
"references": getattr(vuln, 'references', None),
"github_advisory_url": (
f"https://github.com/{gh_repo}/security/advisories?query={vuln.cve_id}"
if gh_repo and vuln.cve_id else None),
"remediation": getattr(vuln, 'remediation', None),
"cwe_id": getattr(vuln, 'cwe_id', None),
}
# ============================================
# Endpoints
# ============================================
@router.post("/bulk-update")
async def bulk_update_vulnerabilities(
update_data: BulkVulnerabilityUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Updates multiple vulnerabilities at once (assignment, status)"""
vulnerabilities = db.query(Vulnerability).filter(Vulnerability.id.in_(update_data.vulnerability_ids)).all()
if not vulnerabilities:
raise HTTPException(status_code=404, detail="No vulnerabilities found")
update_fields = {}
if update_data.assigned_user_id is not None:
update_fields["assigned_user_id"] = update_data.assigned_user_id if update_data.assigned_user_id != -1 else None
# Clear group when assigning user (mutually exclusive)
update_fields["assigned_group_id"] = None
if update_data.assigned_group_id is not None:
update_fields["assigned_group_id"] = update_data.assigned_group_id if update_data.assigned_group_id != -1 else None
# Clear user when assigning group (mutually exclusive)
update_fields["assigned_user_id"] = None
if update_data.status is not None:
update_fields["status"] = update_data.status
if not update_fields:
raise HTTPException(status_code=400, detail="No update fields specified")
count = 0
now = datetime.now()
# Status change in this bulk action? Used to drive per-vuln audit
# entries so each row carries who/when/source even in bulk flow.
new_status = update_fields.get("status")
audit_reason = (
update_data.reason
or (_auto_reason(new_status, current_user.username) if new_status else None)
)
status_change_log: list[tuple[int, str, str, str]] = [] # (vuln_id, cve_id, old, new)
for vuln in vulnerabilities:
for field, value in update_fields.items():
# If setting status to PATCHED, also set patched_at logic
if field == "status" and value == VulnerabilityStatus.patched:
if vuln.status != VulnerabilityStatus.patched:
vuln.patched_at = now
if field == "status" and vuln.status != value:
old_val = vuln.status.value if hasattr(vuln.status, "value") else str(vuln.status)
new_val = value.value if hasattr(value, "value") else str(value)
status_change_log.append((vuln.id, vuln.cve_id, old_val, new_val))
setattr(vuln, field, value)
count += 1
db.commit()
# Per-vuln audit entries so each row's Change History panel shows
# WHO bulk-changed it + WHY (auto-reason when no comment supplied).
for vuln_id, cve_id, old_val, new_val in status_change_log:
try:
log_vulnerability_change(
db, current_user.id, vuln_id, old_val, new_val,
reason=audit_reason, cve_id=cve_id, source="manual_bulk",
)
except Exception as e:
logger.warning("bulk audit log failed for vuln_id=%s: %s", vuln_id, e)
# Summary audit entry kept for the admin overview page.
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.VULNERABILITY_UPDATED,
event_description=(
f"Bulk update performed for {count} vulnerabilities"
+ (f" (status → {new_status.value})" if new_status else "")
+ (f" — reason: {audit_reason}" if audit_reason else "")
)[:500],
resource_type="vulnerability",
resource_id="multiple",
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return {"message": f"{count} vulnerabilities successfully updated", "count": count}
@router.get("")
async def list_vulnerabilities(
severity: Optional[VulnerabilitySeverity] = Query(None, description="Filter nach Severity"),
status: Optional[str] = Query(None, description="Filter nach Status (enum value or 'all')"),
exploitable: Optional[bool] = Query(None, description="Nur exploitable CVEs"),
kev_only: Optional[bool] = Query(None, description="Nur CISA-KEV-gelistete CVEs"),
euvd_only: Optional[bool] = Query(None, description="Nur ENISA EUVD-gelistete CVEs"),
eu_critical: Optional[bool] = Query(None, description="Nur ENISA-Critical-CVEs"),
in_any_catalog: Optional[bool] = Query(None, description="CVE in mind. einem Catalog (KEV oder EUVD)"),
in_both_catalogs: Optional[bool] = Query(None, description="CVE in BEIDEN Catalogs (KEV und EUVD)"),
epss_min: Optional[float] = Query(None, description="Minimaler EPSS-Score (0.0-1.0)"),
source: Optional[str] = Query(None, description="Filter by scanner source: wazuh, nessus, manual"),
finding_type: Optional[str] = Query(None, description="Special finding group: 'mobile' = mobile device EOL/EOS + Android patch-level staleness"),
cross_confirmed: Optional[bool] = Query(None, description="Only CVEs reported by 2+ scanners"),
asset_id: Optional[int] = Query(None, description="Filter nach Asset"),
search: Optional[str] = Query(None, description="Suche in CVE-ID, Package, Title"),
include_inactive_assets: bool = Query(False, description="Include CVEs on INACTIVE/DECOMMISSIONED assets (off by default)"),
distinct_cve: bool = Query(False, description="Collapse per-asset duplicates to one row per CVE-ID (dashboard 'newest N distinct CVEs' widgets)"),
with_total: bool = Query(True, description="Compute the total row count. The dashboard widgets only render items, and with distinct_cve the count is a second full window-function pass over the table — pass false to skip it (`total` comes back null)."),
sort_by: str = Query("priority", description="Sortierung: priority, cvss, detected_at"),
sort_order: str = Query("desc", description="Reihenfolge: asc, desc"),
limit: int = Query(100, le=1000),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Liste aller Vulnerabilities mit Filterung und Sortierung
Sortier-Optionen:
- priority: Nach berechnetem Priority-Score (CVSS + Exploitability + Age)
- cvss: Nach CVSS-Score
- detected_at: Nach Erkennungsdatum
"""
# Base Query mit OUTER JOIN zu Assets (damit Vulns auch ohne Asset-Match angezeigt werden)
query = db.query(Vulnerability).outerjoin(Asset)
# Hide CVEs on INACTIVE / DECOMMISSIONED assets by default — an asset
# no longer in any synced scan should not contribute to dashboards.
# Orphan vulnerabilities (asset_id NULL) are still returned.
if not include_inactive_assets:
query = query.filter(
(Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE)
)
# Filter anwenden
if severity:
query = query.filter(Vulnerability.severity == severity)
# Status filter — supports literal enum values + two convenience groups.
# Default (no value) = active states only (open + pending_verification +
# patch_failed). 'all' returns every status. 'closed' returns operator-
# closed states (false_positive + accepted_risk + deferred + patched).
_ACTIVE_STATES = (
VulnerabilityStatus.open,
VulnerabilityStatus.pending_verification,
VulnerabilityStatus.patch_failed,
)
_CLOSED_STATES = (
VulnerabilityStatus.patched,
VulnerabilityStatus.false_positive,
VulnerabilityStatus.accepted_risk,
VulnerabilityStatus.deferred,
)
if status == 'all':
pass # no filter
elif status == 'active':
query = query.filter(Vulnerability.status.in_(_ACTIVE_STATES))
elif status == 'closed':
query = query.filter(Vulnerability.status.in_(_CLOSED_STATES))
elif status:
# literal enum value (open / patched / false_positive / ...)
query = query.filter(Vulnerability.status == status)
else:
# Default — active states only (SOC workflow default)
query = query.filter(Vulnerability.status.in_(_ACTIVE_STATES))
if exploitable is not None:
query = query.filter(Vulnerability.exploitable == exploitable)
if kev_only:
query = query.filter(Vulnerability.kev_listed == True)
if euvd_only:
query = query.filter(Vulnerability.euvd_listed == True)
if eu_critical:
query = query.filter(Vulnerability.euvd_critical == True)
if in_any_catalog:
query = query.filter(
(Vulnerability.kev_listed == True) | (Vulnerability.euvd_listed == True)
)
if in_both_catalogs:
query = query.filter(
Vulnerability.kev_listed == True,
Vulnerability.euvd_listed == True,
)
if epss_min is not None:
query = query.filter(Vulnerability.epss_score >= epss_min)
# Source filter (sources column is a JSON list stored as text).
# `wazuh_only` would be too brittle, use string contains.
if source:
src = source.strip().lower()
if src in {"wazuh", "nessus", "manual", "app-scan", "defender", "intune"}:
query = query.filter(Vulnerability.sources.contains(f'"{src}"'))
if finding_type == "mobile":
# Mobile device findings: vendor EOL/EOS (EOL- pseudo-CVE on a phone/
# tablet slug) + Android patch-level staleness. One group for the
# dashboard "Mobile Security" widget + its View All.
query = query.filter(
Vulnerability.cve_id.like("ANDROID-PATCH-%")
| Vulnerability.cve_id.like("EOL-IPHONE-%")
| Vulnerability.cve_id.like("EOL-IPAD-%")
| Vulnerability.cve_id.like("EOL-SAMSUNG-MOBILE-%")
| Vulnerability.cve_id.like("EOL-SAMSUNG-GALAXY-TAB-%")
)
if cross_confirmed:
# Pragmatic: rows with at least one comma in the JSON list have >=2 sources.
# `["wazuh"]` has no comma, `["wazuh","nessus"]` does.
query = query.filter(Vulnerability.sources.contains(','))
if asset_id:
query = query.filter(Vulnerability.asset_id == asset_id)
if search:
import re
# Parse search terms, respecting quoted strings
# Example: '!"Mozilla Thunderbird" kernel' -> ['!"Mozilla Thunderbird"', 'kernel']
term_pattern = re.compile(r'!?"[^"]+"|[^\s]+')
terms = term_pattern.findall(search.strip())
for term in terms:
is_negation = term.startswith('!')
clean_term = term[1:] if is_negation else term
# Remove surrounding quotes if present
if clean_term.startswith('"') and clean_term.endswith('"'):
clean_term = clean_term[1:-1]
if not clean_term:
continue
search_pattern = f"%{clean_term}%"
if is_negation:
# Negation (Exclusion)
query = query.filter(
~((Vulnerability.cve_id.ilike(search_pattern)) |
(Vulnerability.package_name.ilike(search_pattern)) |
(Vulnerability.title.ilike(search_pattern)))
)
else:
# Normal Search (Inclusion)
query = query.filter(
(Vulnerability.cve_id.ilike(search_pattern)) |
(Vulnerability.package_name.ilike(search_pattern)) |
(Vulnerability.title.ilike(search_pattern))
)
# Collapse per-asset duplicates to one row per CVE-ID. Without this a CVE
# sitting on many assets fills the page with identical-CVE rows, so the
# dashboard's client-side dedup starves and a "newest 10" widget showed
# only ~4 (field feedback). Keep the newest-published (then most-recently-
# detected) representative per CVE; composes with every sort_by below.
if distinct_cve:
from sqlalchemy import func as _wf
_rn = _wf.row_number().over(
partition_by=Vulnerability.cve_id,
order_by=(
Vulnerability.published_date.desc().nullslast(),
Vulnerability.detected_at.desc().nullslast(),
Vulnerability.id.desc(),
),
).label("rn")
_sub = query.with_entities(Vulnerability.id.label("vid"), _rn).subquery()
_keep_ids = db.query(_sub.c.vid).filter(_sub.c.rn == 1)
query = query.filter(Vulnerability.id.in_(_keep_ids))
# Get total count before pagination. Callers that only render the page
# itself (dashboard widgets) skip it — with distinct_cve the count re-runs
# the whole window-function pass, doubling the query for a number nobody
# displays.
total_count = query.count() if with_total else None
# Eager-load what _build_vuln_response touches on every row. Lazily these
# were three extra SELECTs per item — a 12-row dashboard widget spent 36
# round trips after its one real query, and the dashboard fires seven such
# widgets. Applied after the distinct_cve subquery is built, so the window
# pass above stays a plain id projection.
query = query.options(
selectinload(Vulnerability.asset).selectinload(Asset.groups),
selectinload(Vulnerability.group),
selectinload(Vulnerability.packages),
)
# Sortierung
# Supported sort_by values map to SQL columns.
# `priority` is special (needs Python-side breakdown), `cpr` is computed,
# `published_date` and `cve_id` fall through to dedicated branches below.
sort_map = {
"cvss": Vulnerability.cvss_score,
"cvss_score": Vulnerability.cvss_score,
"detected_at": Vulnerability.detected_at,
"published_date": Vulnerability.published_date,
"updated_at": Vulnerability.updated_at, # DB-write timestamp — "recently changed risk"
"epss": Vulnerability.epss_score,
"epss_score": Vulnerability.epss_score,
"kev": Vulnerability.kev_listed,
"kev_listed": Vulnerability.kev_listed,
"euvd": Vulnerability.euvd_listed,
"euvd_listed": Vulnerability.euvd_listed,
"cve_id": Vulnerability.cve_id,
"severity": Vulnerability.severity,
"status": Vulnerability.status,
# Source: groups rows by which scanner first reported the finding.
# Cross-confirmed rows fall into the bucket of whichever saw it first.
"source": Vulnerability.first_detected_by,
"first_detected_by": Vulnerability.first_detected_by,
# Asset is already outer-joined above, so these need no extra join.
"host": Asset.hostname,
"hostname": Asset.hostname,
"package": Vulnerability.package_name,
"package_name": Vulnerability.package_name,
# Assignee sorts by name, not by id — an id order looks random to a
# user. Needs its own outer join, added only for this sort.
"assigned_to": User.username,
"assigned_user": User.username,
}
order_fn = desc if sort_order == "desc" else asc
def _python_sort_and_build(vulns, key_fn):
ordered = sorted(vulns, key=key_fn, reverse=(sort_order == "desc"))
return [_build_vuln_response(v) for v in ordered]
if sort_by == "priority":
# Sort by materialised priority_score (migration 024). NULLs go
# last on desc, first on asc. Sync/enrich/override paths keep
# the column current via Vulnerability.refresh_scores().
if sort_order == "desc":
query = query.order_by(
Vulnerability.priority_score.is_(None),
desc(Vulnerability.priority_score),
desc(Vulnerability.id),
)
else:
query = query.order_by(
asc(Vulnerability.priority_score),
asc(Vulnerability.id),
)
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
if sort_by == "cpr":
# Sort by materialised cpr_score (migration 024). Old SQL proxy
# `cvss * epss` was wrong vs the real `CVSS×10×0.6 + EPSS%×0.4`
# formula and only sorted the current page anyway.
if sort_order == "desc":
query = query.order_by(
Vulnerability.cpr_score.is_(None),
desc(Vulnerability.cpr_score),
desc(Vulnerability.id),
)
else:
query = query.order_by(
asc(Vulnerability.cpr_score),
asc(Vulnerability.id),
)
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
if sort_by == "published_date":
# "Newly Published" = sort by the CVE's OFFICIAL published_date.
#
# The old COALESCE(published_date, detected_at) fallback was wrong:
# a Nessus sync imports old CVEs (2014-2023) with published_date
# NULL but detected_at=today, so COALESCE made every freshly-
# imported old CVE look like it was "published today" and flooded
# the top of the list in arbitrary id order (reported: "komisch
# durcheinander"). Now rows WITHOUT a real published_date sink to
# the bottom (nulls-last) instead of masquerading as newest.
search_eol = (search or "").upper().startswith("EOL")
if not search_eol and finding_type != "mobile":
query = query.filter(
~Vulnerability.cve_id.like("EOL-%"),
~Vulnerability.cve_id.like("NESSUS-PLUGIN-%"),
)
# Secondary key: until the NVD published_date backfill drains, most
# rows have published_date NULL. Ordering those by id is meaningless
# (reported: "danach wieder NICHT richtig sortiert"). Fall back to the
# CVE's own year+sequence so "newest CVE number first" still holds.
from sqlalchemy import func as _sf, case as _case, Integer as _Int
_yr = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 2), '')
_nm = _sf.nullif(_sf.split_part(Vulnerability.cve_id, '-', 3), '')
_yr_i = _case((_yr.op('~')('^[0-9]+$'), _sf.cast(_yr, _Int)), else_=None)
_nm_i = _case((_nm.op('~')('^[0-9]+$'), _sf.cast(_nm, _Int)), else_=None)
if sort_order == "desc":
query = query.order_by(
Vulnerability.published_date.is_(None), # nulls last
desc(Vulnerability.published_date),
desc(_yr_i), desc(_nm_i),
desc(Vulnerability.id),
)
else:
query = query.order_by(
Vulnerability.published_date.is_(None),
asc(Vulnerability.published_date),
asc(_yr_i), asc(_nm_i),
asc(Vulnerability.id),
)
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
if sort_by == "cve_id":
# Natural-numeric sort: lex on "CVE-2026-8401" sorts BEFORE
# "CVE-2026-35440" because "8" > "3" character-wise. Cast the
# numeric suffix to int so 35440 wins.
# Pseudo-CVEs (NESSUS-PLUGIN-*) fall back to lex at the end.
from sqlalchemy import func as sa_func, case
# Postgres: split_part(cve_id, '-', 2)::int as year, ('', 3)::int as num
# Pseudo-CVEs ("NESSUS-PLUGIN-123456") have non-numeric year segment;
# NULLIF + regexp catches the cast safely.
year_part = sa_func.nullif(sa_func.split_part(Vulnerability.cve_id, '-', 2), '')
num_part = sa_func.nullif(sa_func.split_part(Vulnerability.cve_id, '-', 3), '')
# Cast only when both segments are pure digits (defensive vs pseudo-CVEs)
year_int = case(
(year_part.op('~')('^[0-9]+$'), sa_func.cast(year_part, __import__('sqlalchemy').Integer)),
else_=None,
)
num_int = case(
(num_part.op('~')('^[0-9]+$'), sa_func.cast(num_part, __import__('sqlalchemy').Integer)),
else_=None,
)
if sort_order == "desc":
query = query.order_by(
year_int.is_(None), # pseudo-CVEs to the end
desc(year_int),
desc(num_int),
desc(Vulnerability.cve_id), # tiebreak for pseudo
)
else:
query = query.order_by(asc(year_int), asc(num_int), asc(Vulnerability.cve_id))
vulnerabilities = query.offset(offset).limit(limit).all()
items_resp = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, items_resp)
return {"items": items_resp, "total": total_count}
sort_column = sort_map.get(sort_by, Vulnerability.cvss_score)
if sort_by in ("assigned_to", "assigned_user"):
query = query.outerjoin(User, Vulnerability.assigned_user_id == User.id)
# NULL handling: put NULLs last on desc, first on asc (sensible UX)
if sort_order == "desc":
query = query.order_by(sort_column.is_(None), desc(sort_column))
else:
query = query.order_by(asc(sort_column))
vulnerabilities = query.offset(offset).limit(limit).all()
results = [_build_vuln_response(v) for v in vulnerabilities]
_attach_last_change(db, vulnerabilities, results)
return {"items": results, "total": total_count}
def _attach_last_change(db: Session, vulns, results: list[dict]) -> None:
"""Bulk-load latest VULNERABILITY_UPDATED audit row per vuln and
glue a `last_change` dict onto each response item.
Used by the list endpoint so the status badge can render a
hover-tooltip with WHO/WHEN/HOW for the most recent state move
(manual edit, scanner auto-patch, bulk-patch).
One query for the whole page — O(N) rows, not N+1.
"""
if not vulns:
return
try:
from sqlalchemy import func as _func, and_ as _and
ids = [str(v.id) for v in vulns]
subq = (
db.query(
AuditLog.resource_id,
_func.max(AuditLog.timestamp).label("latest_ts"),
)
.filter(
AuditLog.resource_type == "vulnerability",
AuditLog.resource_id.in_(ids),
AuditLog.event_type == AuditEventType.VULNERABILITY_UPDATED,
)
.group_by(AuditLog.resource_id)
.subquery()
)
latest_logs = (
db.query(AuditLog)
.join(subq, _and(
AuditLog.resource_id == subq.c.resource_id,
AuditLog.timestamp == subq.c.latest_ts,
))
.filter(AuditLog.resource_type == "vulnerability")
.all()
)
by_vid: dict[str, AuditLog] = {}
for a in latest_logs:
# If same vuln got multiple entries at the EXACT same
# timestamp (rare — bulk update), keep the highest id.
existing = by_vid.get(a.resource_id)
if existing is None or a.id > existing.id:
by_vid[a.resource_id] = a
for vuln, item in zip(vulns, results):
a = by_vid.get(str(vuln.id))
if not a:
continue
parsed = None
if a.new_value and a.new_value.startswith("{"):
try:
parsed = json.loads(a.new_value)
except json.JSONDecodeError:
parsed = None
item["last_change"] = {
"timestamp": a.timestamp.isoformat() if a.timestamp else None,
"username": a.user.username if a.user else "System/Auto",
"old_status": a.old_value,
"new_status": (parsed or {}).get("status") if isinstance(parsed, dict) else None,
"reason": (parsed or {}).get("reason") if isinstance(parsed, dict) else None,
"source": (parsed or {}).get("source") if isinstance(parsed, dict) else "manual",
}
except Exception as e:
logger.warning("last_change preload failed: %s", e)
@router.get("/ai-prioritization")
def get_ai_prioritization(
limit: int = Query(20, le=50),
severity: Optional[VulnerabilitySeverity] = Query(None, description="Nach Severity filtern"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
ai_client: InfomaniakAIClient = Depends(get_infomaniak_ai_client)
):
"""
Generiert KI-basierte Patch-Empfehlungen
"""
ai_service = AIAnalysisService(db, ai_client)
try:
recommendations = ai_service.get_priority_recommendations(limit=limit, severity=severity)
# Save Report
if "recommendations" in recommendations and recommendations["recommendations"]:
# Extract data safely
strategy = recommendations.get("global_strategy", "No strategy provided.")
recs_json = json.dumps(recommendations.get("recommendations", []))
report = AIReport(
created_by_id=current_user.id,
global_strategy=strategy,
recommendations=recs_json
)
db.add(report)
db.commit()
return recommendations
except Exception as e:
logger.error(f"AI prioritization failed: {e}")
raise HTTPException(
status_code=500,
detail="AI prioritization failed. Check server logs for details."
)
@router.get("/ai-history")
async def get_ai_history(
limit: int = 10,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Liefert die letzten AI-Scan-Berichte zurück
"""
reports = db.query(AIReport).order_by(desc(AIReport.created_at)).limit(limit).all()
result = []
for r in reports:
try:
recs = json.loads(r.recommendations)
except (json.JSONDecodeError, TypeError):
recs = []
result.append({
"id": r.id,
"created_at": r.created_at,
"global_strategy": r.global_strategy,
"recommendations_count": len(recs),
"recommendations": recs # Optional: full data
})
return result
@router.get("/reports/dashboard", response_model=DashboardStatsResponse)
async def get_dashboard_statistics(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Dashboard-Statistiken für Frontend
Liefert aggregierte Daten:
- Anzahl Vulnerabilities pro Severity
- Offene vs. Geschlossene
- Exploitable-Count
- Durchschnittlicher CVSS-Score
- Betroffene Assets
- Älteste offene Vulnerability
"""
# One aggregate pass instead of eighteen COUNTs. Every number below is the
# same scan of vulnerabilities-joined-to-active-assets, so it is computed
# with FILTER clauses in a single round trip; the seven-day history is a
# second grouped query instead of one COUNT per day. Orphan vulnerabilities
# (asset_id NULL) are kept, INACTIVE / DECOMMISSIONED assets excluded.
from datetime import timedelta, date as _date
_active_scope = (Asset.id.is_(None)) | (Asset.status == AssetStatus.ACTIVE)
_open = Vulnerability.status == VulnerabilityStatus.open
def _n(expr):
return func.count().filter(expr)
agg = (
db.query(
func.count().label("total"),
_n(_open).label("open"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.critical)).label("critical"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.high)).label("high"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.medium)).label("medium"),
_n(and_(_open, Vulnerability.severity == VulnerabilitySeverity.low)).label("low"),
_n(and_(_open, Vulnerability.exploit_available == True)).label("exploitable"),
func.avg(Vulnerability.cvss_score).filter(_open).label("avg_cvss"),
func.count(func.distinct(Vulnerability.asset_id)).filter(_open).label("affected_assets"),
func.min(Vulnerability.detected_at).filter(_open).label("oldest"),
)
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(_active_scope)
.one()
)
oldest_days = (datetime.now() - agg.oldest).days if agg.oldest else 0
# Verlauf der letzten 7 Tage (Erkennungsdatum) — one grouped query.
now = datetime.now()
window_start = datetime.combine((now - timedelta(days=6)).date(), datetime.min.time())
day_col = func.date(Vulnerability.detected_at)
# SQLite hands func.date() back as a 'YYYY-MM-DD' string, Postgres as a
# date — normalise so the lookup below matches on both.
counts_by_day = {
(_date.fromisoformat(d) if isinstance(d, str) else d): c
for d, c in db.query(day_col, func.count())
.outerjoin(Asset, Vulnerability.asset_id == Asset.id)
.filter(_active_scope, _open, Vulnerability.detected_at >= window_start)
.group_by(day_col)
.all()
}
severity_history = []
for i in range(6, -1, -1):
day_date = (now - timedelta(days=i)).date()
severity_history.append({
"day": day_date.strftime("%a"),
"count": counts_by_day.get(day_date, 0),
})
asset_counts = db.query(
func.count().label("total"),
func.count().filter(Asset.last_scan.isnot(None)).label("scanned"),
).one()
total_vulns = agg.total
open_vulns_count = agg.open
critical_count = agg.critical
high_count = agg.high
medium_count = agg.medium
low_count = agg.low
exploitable_count = agg.exploitable
avg_cvss = agg.avg_cvss or 0.0
affected_assets = agg.affected_assets or 0
total_assets = asset_counts.total
scanned_assets = asset_counts.scanned
return {
"total_vulnerabilities": total_vulns,
"open_vulnerabilities": open_vulns_count,
"critical_count": critical_count,
"high_count": high_count,
"medium_count": medium_count,
"low_count": low_count,
"exploitable_count": exploitable_count,
"avg_cvss_score": round(avg_cvss, 2),
"affected_assets": affected_assets,
"total_assets": total_assets,
"scanned_assets": scanned_assets,
"severity_history": severity_history,
"oldest_vulnerability_days": oldest_days
}
@router.get("/{vuln_id}", response_model=VulnerabilityDetailResponse)
async def get_vulnerability_detail(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Detail-Ansicht einer Vulnerability inkl. KI-Analyse (falls vorhanden)
"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Vulnerability not found"
)
# Hole KI-Analyse falls vorhanden
ai_analysis = None
if vuln.ai_analysis:
ai_analysis = {
"analysis_text": vuln.ai_analysis.analysis_text,
"threat_level": vuln.ai_analysis.threat_level,
"exploits_found": vuln.ai_analysis.exploits_found,
"workarounds": vuln.ai_analysis.workarounds,
"remediation_steps": vuln.ai_analysis.remediation_steps,
"confidence_score": vuln.ai_analysis.confidence_score,
"analysis_timestamp": vuln.ai_analysis.analysis_timestamp
}
resp = _build_vuln_response(vuln)
resp["ai_analysis"] = ai_analysis
# Upgrade the repo-advisory search URL to the advisory itself when the
# cached index knows the GHSA id for this finding. `load_index` only reads
# a fresh cache — never build it here, a page view must not wait on (or
# burn rate limit for) a GitHub fetch. Stale/absent cache keeps the search
# URL, which lands on the same record one click later.
gh_repo = repo_adv.resolve_repo(vuln.package_name or "")
if gh_repo:
try:
entries = (repo_adv.load_index(db) or {}).get(gh_repo) or []
ghsa = next((e["ghsa"] for e in entries if e.get("ghsa")
and vuln.cve_id in (e.get("cve"), e.get("ghsa"))), None)
if ghsa:
resp["github_advisory_url"] = \
f"https://github.com/{gh_repo}/security/advisories/{ghsa}"
except Exception:
pass # search URL already set — a broken cache must not 500 the page
return resp
@router.patch("/{vuln_id}", response_model=VulnerabilityResponse)
async def update_vulnerability_status(
vuln_id: int,
update_data: VulnerabilityUpdateRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor) # Editor/Admin-Berechtigung erforderlich
):
"""
Ändert Status einer Vulnerability
Patch-Verifizierungs-Workflow:
1. User markiert CVE als "patched"
2. Status wird auf "pending_verification" gesetzt
3. Wazuh Syscollector-Scan wird getriggert (Background-Task)
4. Nach Scan-Completion: Automatische Verifizierung
"""
try:
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Vulnerability not found"
)
old_status = vuln.status
new_status = update_data.status
# Status-Änderung
vuln.status = new_status
# Persist reason in the matching column when it makes sense.
# `reason` is the new status-agnostic field; `defer_reason` is
# kept for the deferred-status legacy UI.
if update_data.defer_reason is not None:
vuln.defer_reason = update_data.defer_reason
if update_data.deferred_until is not None:
vuln.deferred_until = update_data.deferred_until
# The audit-trail reason — accept either field for back-compat.
# If the operator left both blank, auto-stamp "Manually set to X
# by Y on Z" so the audit trail clearly tags this as a manual
# action (vs scanner auto-patch) even without a user comment.
audit_reason = (
update_data.reason
or update_data.defer_reason
or _auto_reason(new_status, current_user.username)
)
# Wenn als "patched" markiert: Rescan triggern NUR wenn
# Wazuh die vuln auch wirklich reportet. Nessus-only rows
# (sources=["nessus"]) können nicht durch einen wazuh-syscollector
# rescan verifiziert werden — der wazuh agent kennt das CVE nicht
# → row blieb ewig in PENDING_VERIFICATION (field report).
if new_status == VulnerabilityStatus.patched:
wazuh_can_verify = (
vuln.asset
and vuln.asset.wazuh_agent_id
and "wazuh" in (vuln.source_list or [])
)
if wazuh_can_verify:
vuln.status = VulnerabilityStatus.pending_verification
vuln.patched_at = datetime.now()
background_tasks.add_task(
verify_patch_with_rescan,
vuln_id,
vuln.asset.wazuh_agent_id
)
else:
# Nessus-only or no wazuh agent → mark PATCHED directly.
vuln.patched_at = datetime.now()
db.commit()
db.refresh(vuln)
# Audit-Log — includes reason + cve_id for the revisionssicher view
log_vulnerability_change(
db, current_user.id, vuln_id, old_status, vuln.status,
reason=audit_reason, cve_id=vuln.cve_id, source="manual",
)
return _build_vuln_response(vuln)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating vulnerability {vuln_id}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal Server Error: {str(e)}"
)
def verify_patch_with_rescan(
vuln_id: int,
agent_id: str
):
"""
Snychroner Background-Task für Patch-Verifizierung (läuft in eigenem Thread)
1. Triggert Syscollector-Scan
2. Wartet auf Completion (max. 10 Min)
3. Re-fetcht Vulnerabilities
4. Prüft ob CVE noch vorhanden
5. Aktualisiert Status
"""
from app.database import get_db_context
from app.models.setting import Setting
import json
with get_db_context() as db:
try:
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
logger.error("Patch verification aborted: Wazuh configuration missing")
return
config = json.loads(raw_wazuh)
wazuh = WazuhClient(
base_url=config.get("api_url"),
username=config.get("username"),
password=config.get("password"),
indexer_url=config.get("indexer_url"),
indexer_username=config.get("indexer_username"),
indexer_password=config.get("indexer_password"),
verify_ssl=config.get("verify_ssl", True)
)
# 1. Trigger Rescan
try:
wazuh.trigger_syscollector_scan(agent_id)
except Exception as e:
logger.error(f"Could not trigger Syscollector scan for agent {agent_id}: {e}")
# Wir machen trotzdem weiter, evtl. lief gerade schon einer
# 2. Warte auf Scan-Completion (blockiert nur diesen Thread, nicht den Server)
scan_completed = wazuh.wait_for_scan_completion(
agent_id,
timeout=600, # 10 Min
check_interval=30
)
if not scan_completed:
# Timeout: Markiere als Patch-Failed
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if vuln:
vuln.status = VulnerabilityStatus.patch_failed
db.commit()
return
# 3. Re-fetch Vulnerabilities
current_vulns = wazuh.get_vulnerabilities(agent_id)
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
return
# 4. Prüfe ob CVE noch in Wazuh-Daten
cve_still_exists = any(v.get("cve") == vuln.cve_id for v in current_vulns)
old_status = vuln.status
if cve_still_exists:
# Patch fehlgeschlagen
vuln.status = VulnerabilityStatus.patch_failed
_reason = f"Patch verification rescan: CVE still reported by Wazuh on agent {agent_id}"
else:
# Patch erfolgreich verifiziert
vuln.status = VulnerabilityStatus.patched
vuln.patched_at = datetime.now()
_reason = f"Patch verification rescan: CVE no longer reported by Wazuh on agent {agent_id}"
# Revisionssicher: record the verify outcome (feeds audit log +
# per-CVE Change History). Background task → user_id=None.
if vuln.status != old_status:
try:
log_vulnerability_change(
db, None, vuln.id, old_status, vuln.status,
reason=_reason, cve_id=vuln.cve_id, source="verify_patch_rescan",
)
except Exception as e:
logger.warning("audit log for patch-verify failed (vuln_id=%s): %s", vuln.id, e)
db.commit()
except Exception as e:
logger.error(f"Error in verify_patch_with_rescan: {e}")
# Bei Fehler: Markiere als Patch-Failed
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if vuln:
vuln.status = VulnerabilityStatus.patch_failed
db.commit()
@router.post("/{vuln_id}/analyze")
def analyze_vulnerability_with_ai(
vuln_id: int,
force_refresh: bool = Query(False, description="Ignore cache"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
ai_client: InfomaniakAIClient = Depends(get_infomaniak_ai_client)
):
"""
Triggert KI-Analyse für eine Vulnerability
Nutzt Infomaniak AI API mit Web-Search für:
- Exploit-Detection
- Threat-Intelligence
- Workarounds
- Remediation-Steps
"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Vulnerability not found"
)
# Service-Layer für DB-Integration
ai_service = AIAnalysisService(db, ai_client)
try:
analysis = ai_service.get_or_create_analysis(vuln_id, force_refresh)
# Audit-Log
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.AI_ANALYSIS_REQUESTED,
event_description=f"AI analysis requested for {vuln.cve_id}",
resource_type="vulnerability",
resource_id=str(vuln_id),
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return {
"analysis_text": analysis.analysis_text,
"threat_level": analysis.threat_level,
"exploits_found": analysis.exploits_found,
"workarounds": analysis.workarounds,
"remediation_steps": analysis.remediation_steps,
"threat_intel_sources": analysis.threat_intel_sources,
"confidence_score": analysis.confidence_score,
"model_version": analysis.model_version,
"analysis_timestamp": analysis.analysis_timestamp,
"cached": not force_refresh
}
except Exception as e:
logger.error(f"AI analysis failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI analysis failed. Check server logs for details."
)
@router.post("/{vuln_id}/enrich")
def enrich_single_vulnerability(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Trigger EPSS + CISA KEV enrichment for one vulnerability."""
try:
stats = enrich_vulnerability_by_id(db, vuln_id)
except EnrichmentError as e:
raise HTTPException(status_code=404, detail=str(e))
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.VULNERABILITY_UPDATED,
event_description=f"Threat intel enrichment for {vuln.cve_id if vuln else vuln_id}",
resource_type="vulnerability",
resource_id=str(vuln_id),
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return {"stats": stats, "vulnerability": _build_vuln_response(vuln) if vuln else None}
class BulkEnrichRequest(BaseModel):
vulnerability_ids: Optional[List[int]] = None
only_open: bool = True
@router.post("/enrich/bulk")
# Sync def → worker threadpool (off the event loop); EPSS/KEV enrichment
# over many CVEs no longer freezes the web GUI.
def bulk_enrich_vulnerabilities(
payload: BulkEnrichRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Bulk enrich vulnerabilities with EPSS + CISA KEV.
- If `vulnerability_ids` is provided, only those are enriched.
- Otherwise enriches every open vulnerability (or all if only_open=false).
"""
if payload.vulnerability_ids:
vulns = db.query(Vulnerability).filter(
Vulnerability.id.in_(payload.vulnerability_ids)
).all()
else:
q = db.query(Vulnerability)
if payload.only_open:
q = q.filter(Vulnerability.status == VulnerabilityStatus.open)
vulns = q.all()
if not vulns:
return {"stats": {"epss_updated": 0, "kev_marked": 0, "kev_cleared": 0, "total": 0}}
# NVD date backfill is rate-limited (up to 6.5s/CVE without a key) and
# would hang this synchronous request for minutes. It runs in the
# nightly enrichment job instead; the manual button stays fast.
stats = enrich_vulnerabilities(db, vulns, use_nvd_dates=False)
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.VULNERABILITY_UPDATED,
event_description=f"Bulk threat intel enrichment for {stats['total']} CVEs",
resource_type="vulnerability",
resource_id="multiple",
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return {"stats": stats}
# Guard so a double-click / impatient retry doesn't stack concurrent
# 600 MB ZIP walks. Module-level flag is fine — single backend process.
_DATE_BACKFILL_RUNNING = False
def _run_date_backfill_threaded() -> dict:
"""Date-only enrichment with its own DB session (runs in a detached
thread). Fills published_date/last_modified_date for every CVE row
that lacks a published_date, via the cvelistV5 ZIP/raw cascade (NVD
fallback). No EPSS/KEV/EUVD work — just dates."""
global _DATE_BACKFILL_RUNNING
from app.database import SessionLocal
db = SessionLocal()
try:
vulns = (
db.query(Vulnerability)
.filter(
Vulnerability.published_date.is_(None),
Vulnerability.cve_id.like("CVE-%"),
)
.all()
)
if not vulns:
logger.info("Date backfill: nothing to do (no undated CVEs)")
return {"total": 0, "nvd_dates_set": 0}
stats = enrich_vulnerabilities(
db, vulns,
use_epss=False, use_kev=False, use_euvd=False, use_nvd_dates=True,
)
logger.info("Date backfill done: %s", stats)
return stats
except Exception as e:
logger.error("Date backfill failed: %s", e)
return {"error": str(e)}
finally:
db.close()
_DATE_BACKFILL_RUNNING = False
@router.post("/dates/backfill", status_code=202)
async def backfill_cve_dates(
current_user: User = Depends(RequireEditor),
):
"""Kick off an on-demand CVE published/last-modified backfill.
Fire-and-forget: launches the date-only fill (cvelistV5 ZIP/raw → NVD
fallback) in a detached background thread and returns 202 immediately,
so a slow ZIP walk over thousands of CVEs can't trip the reverse-proxy
request timeout (was returning 503). Watch progress in the logs:
docker compose logs -f backend | grep -E "CVE dates:|Date backfill"
No NVD API key required — cvelistV5 is the primary, keyless source.
"""
global _DATE_BACKFILL_RUNNING
if _DATE_BACKFILL_RUNNING:
return {"status": "already_running",
"detail": "A date backfill is already in progress."}
_DATE_BACKFILL_RUNNING = True
import threading
threading.Thread(target=_run_date_backfill_threaded, daemon=True).start()
return {"status": "started",
"detail": "Date backfill started in the background. "
"The Newly-Published order updates as it completes "
"(watch the backend logs)."}
@router.post("/enrich/kev/refresh")
def refresh_kev_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Force-refresh the cached CISA KEV catalog from CISA feed."""
try:
kev_map = fetch_kev_catalog(db, force_refresh=True)
except EnrichmentError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"kev_entries": len(kev_map), "refreshed_at": datetime.now().isoformat()}
@router.patch("/{vuln_id}/assign")
async def assign_vulnerability(
vuln_id: int,
assign_data: AssignRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Assigns a user to a vulnerability"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found")
if assign_data.user_id is not None:
user = db.query(User).filter(User.id == assign_data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if assign_data.group_id is not None:
from app.models.group import Group
group = db.query(Group).filter(Group.id == assign_data.group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
vuln.assigned_user_id = assign_data.user_id
vuln.assigned_group_id = assign_data.group_id
db.commit()
db.refresh(vuln)
return _build_vuln_response(vuln)
@router.patch("/{vuln_id}/defer")
async def defer_vulnerability(
vuln_id: int,
defer_data: DeferRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Defers a vulnerability with a target date and reason"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found")
old_status = vuln.status
vuln.status = VulnerabilityStatus.deferred
vuln.deferred_until = defer_data.deferred_until
vuln.defer_reason = defer_data.reason
vuln.notification_suppressed = True
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
return _build_vuln_response(vuln)
@router.patch("/{vuln_id}/undefer")
async def undefer_vulnerability(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Removes deferral, resets to OPEN"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")
old_status = vuln.status
vuln.status = VulnerabilityStatus.open
vuln.deferred_until = None
vuln.defer_reason = None
vuln.notification_suppressed = False
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
return _build_vuln_response(vuln)
@router.patch("/{vuln_id}/suppress-notifications")
async def toggle_notification_suppression(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Toggles notification suppression for a vulnerability"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")
vuln.notification_suppressed = not vuln.notification_suppressed
db.commit()
db.refresh(vuln)
return _build_vuln_response(vuln)
class FalsePositiveRequest(BaseModel):
reason: Optional[str] = None
@router.patch("/{vuln_id}/false-positive")
async def mark_false_positive(
vuln_id: int,
payload: FalsePositiveRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""
Mark a vulnerability as false positive. Bookmark used after manual
review (e.g. confirming a Nessus finding is a false alarm). Sets
status=false_positive and suppresses further notifications.
"""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found")
old_status = vuln.status
vuln.status = VulnerabilityStatus.false_positive
vuln.notification_suppressed = True
if payload.reason:
vuln.defer_reason = payload.reason # reuse existing field for context
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
return _build_vuln_response(vuln)
@router.patch("/{vuln_id}/unmark-false-positive")
async def unmark_false_positive(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Revert a false-positive marking back to open."""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found")
if vuln.status != VulnerabilityStatus.false_positive:
raise HTTPException(status_code=400, detail="Not currently marked as false positive")
old_status = vuln.status
vuln.status = VulnerabilityStatus.open
vuln.notification_suppressed = False
vuln.defer_reason = None
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
return _build_vuln_response(vuln)
@router.post("/sync/wazuh")
def sync_vulnerabilities_from_wazuh(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Synchronizes vulnerabilities from Wazuh (synchronous)
Executes sync immediately and returns statistics.
"""
try:
result = run_wazuh_vulnerability_sync(db)
return result
except HTTPException:
raise # already carries the real reason and status
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def run_wazuh_vulnerability_sync(db: Session) -> dict:
"""Synchronous Wazuh vulnerability sync with statistics return.
Recorded in the sync-run ledger as the "vulnerabilities" phase."""
from app.services.sync_run_service import record_sync_run
with record_sync_run("wazuh") as run:
run.stats.setdefault("phase", "vulnerabilities")
result = _run_wazuh_vulnerability_sync(db)
run.stats.update({k: v for k, v in result.items() if k != "message"})
return result
def _run_wazuh_vulnerability_sync(db: Session) -> dict:
# 1. Get Wazuh Config from DB (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
raise HTTPException(status_code=400, detail="Wazuh configuration not found. Please configure Wazuh in Settings.")
try:
config = json.loads(raw_wazuh)
api_url = config.get("api_url")
username = config.get("username")
password = config.get("password")
indexer_url = config.get("indexer_url")
indexer_username = config.get("indexer_username")
indexer_password = config.get("indexer_password")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid Wazuh configuration format.")
if not all([api_url, username, password]):
raise HTTPException(status_code=400, detail="Incomplete Wazuh configuration. Please configure API URL, username and password.")
wazuh = WazuhClient(
base_url=api_url,
username=username,
password=password,
indexer_url=indexer_url,
indexer_username=indexer_username,
indexer_password=indexer_password,
verify_ssl=bool(config.get("verify_ssl", True))
)
stats = {
"agents_synced": 0,
"vulns_created": 0,
"vulns_updated": 0,
"vulns_patched": 0,
"total_vulns_from_wazuh": 0
}
# Agents that came back empty, plus how many CVEs the run saw in total —
# the pair that decides whether an empty answer is a clean host or an
# unreachable API. Judged after the loop, not inside it.
run_stats: dict = {}
# IDs of vulns created during this sync — handed to the digest dispatcher
# after the full sync completes (one summary email per recipient instead
# of one email per CVE).
newly_created_vuln_ids: list[int] = []
try:
# Hole alle aktiven Agents
agents = wazuh.get_agents(status="active")
for agent in agents:
agent_id = agent["id"]
# Hole oder erstelle Asset
asset = db.query(Asset).filter(
Asset.wazuh_agent_id == agent_id
).first()
if not asset:
continue # Skip if asset not in DB
# Refresh OS name/build from the agent record. Only the separate
# ASSET sync did this before, so a host running just the vuln sync
# + app scan kept a stale os_version — and scan_asset_os judged
# Windows-OS CVEs against the OLD build forever (observed: Server
# 2016 host on .9339 = the fix build, findings stuck open showing
# installed .9140).
os_info = agent.get("os") or {}
if os_info.get("name"):
asset.operating_system = os_info["name"]
if os_info.get("version"):
asset.os_version = os_info["version"]
# Record a Scan-Jobs entry per agent so the manual "Sync
# Data (Wazuh)" button shows up in /scans the same way
# the Nessus sync does (commit 4bca41e). Closed out at
# the bottom of the agent loop.
from app.models.scan import Scan, ScanType, ScanStatus
scan_row = Scan(
asset_id=asset.id,
scan_type=ScanType.WAZUH,
status=ScanStatus.RUNNING,
started_at=datetime.now(),
)
db.add(scan_row)
# Get vulns from Wazuh
wazuh_vulns = wazuh.get_vulnerabilities(agent_id)
stats["total_vulns_from_wazuh"] += len(wazuh_vulns)
# 1. Deduplicate Wazuh vulns by CVE ID for this asset
unique_cves = {}
for vuln_data in wazuh_vulns:
cve_id = vuln_data.get("cve")
if not cve_id:
continue
if cve_id not in unique_cves:
unique_cves[cve_id] = {
"data": vuln_data,
"packages": set(),
"versions": set(),
"vendor": None,
}
pkg_name = vuln_data.get("name")
pkg_version = vuln_data.get("version")
if pkg_name:
unique_cves[cve_id]["packages"].add(pkg_name)
if pkg_version:
unique_cves[cve_id]["versions"].add(pkg_version)
# First non-empty vendor for this CVE (one package is the norm;
# gives Wazuh findings the same Vendor column as app-scan/Intune/
# Defender — homogeneous display).
if not unique_cves[cve_id]["vendor"]:
_v = (vuln_data.get("vendor") or "").strip()
if _v:
unique_cves[cve_id]["vendor"] = _v[:255]
active_cves = set(unique_cves.keys())
# 2. Process unique CVEs
for cve_id, cve_info in unique_cves.items():
vuln_data = cve_info["data"]
# Merge package strings
merged_packages = ", ".join(sorted(cve_info["packages"]))
merged_versions = ", ".join(sorted(cve_info["versions"]))
# Truncate to fit DB columns
if len(merged_packages) > 250:
merged_packages = merged_packages[:247] + "..."
if len(merged_versions) > 95:
merged_versions = merged_versions[:92] + "..."
cvss_data = vuln_data.get("cvss", {})
cvss3 = cvss_data.get("cvss3", {})
score = cvss3.get("base_score")
wazuh_severity = vuln_data.get("severity")
# Check if exists
existing = db.query(Vulnerability).filter(
Vulnerability.cve_id == cve_id,
Vulnerability.asset_id == asset.id
).first()
if existing:
# `changed` tracks whether this run actually altered the
# row. Before, vulns_updated counted ONLY a CVSS-score
# change, so a sync that reopened findings and refreshed
# versions still reported "Updated: 0" (seen in the field).
changed = False
# Track wazuh as a source; reopen if Wazuh sees it again
# after it was marked patched.
existing.add_source("wazuh")
from app.services.audit_events import reopen_if_patched
# Reopen (audited) when it was patched; otherwise force back to
# open unless an operator deliberately froze the status.
if reopen_if_patched(
db, existing,
reason="Wazuh reports this CVE on the host again",
source="wazuh_sync"):
stats["vulns_reopened"] = stats.get("vulns_reopened", 0) + 1
changed = True
elif existing.status not in (
VulnerabilityStatus.pending_verification,
VulnerabilityStatus.accepted_risk,
VulnerabilityStatus.false_positive,
VulnerabilityStatus.deferred,
):
if existing.status != VulnerabilityStatus.open:
changed = True
existing.status = VulnerabilityStatus.open
# Update package info (merged)
if (existing.package_name != merged_packages
or existing.package_version != merged_versions):
changed = True
existing.package_name = merged_packages
existing.package_version = merged_versions
if cve_info.get("vendor") and not existing.package_vendor:
existing.package_vendor = cve_info["vendor"]
changed = True
# Backfill fixed_version when missing (older syncs
# didn't extract it; new syncs do).
fv = _sanitize_wazuh_fix(merged_packages, merged_versions,
vuln_data.get("fixed_version"))
if fv and not existing.fixed_version:
existing.fixed_version = fv
changed = True
elif (existing.fixed_version and _sanitize_wazuh_fix(
merged_packages, merged_versions,
existing.fixed_version) is None):
# Scrub a stale cross-build-line fix stored by a
# pre-sanitize sync (e.g. 6.2.9200.x Server-2012 build
# left on a 10.0.26100 Server-2025 host). Fill-only
# backfill never corrected these. MSRC panel supplies
# the right per-branch KB.
existing.fixed_version = None
changed = True
# Update score/severity if changed
if score and existing.cvss_score != score:
existing.cvss_score = score
existing.severity = map_severity(score, wazuh_severity)
changed = True
if changed:
stats["vulns_updated"] += 1
else:
stats["vulns_confirmed"] = stats.get("vulns_confirmed", 0) + 1
else:
# Create new — savepoint so IntegrityError (race duplicate)
# only rolls back this insert, not the whole sync.
try:
severity = map_severity(score, wazuh_severity)
vuln = Vulnerability(
cve_id=cve_id,
asset_id=asset.id,
cvss_score=score,
severity=severity,
status=VulnerabilityStatus.open,
title=vuln_data.get("title"),
package_name=merged_packages,
package_version=merged_versions,
package_vendor=cve_info.get("vendor"),
fixed_version=_sanitize_wazuh_fix(
merged_packages, merged_versions,
vuln_data.get("fixed_version")),
detected_at=datetime.now(),
sources='["wazuh"]',
first_detected_by="wazuh",
)
with db.begin_nested():
db.add(vuln)
db.flush()
stats["vulns_created"] += 1
newly_created_vuln_ids.append(vuln.id)
except IntegrityError:
# Savepoint rolled back; outer transaction intact.
logger.debug(f"Skipping duplicate CVE {cve_id} for asset {asset.id}")
continue
# Source-aware backfill: drop "wazuh" from vulns Wazuh no longer
# reports. Only mark patched when ALL sources are gone so Nessus-
# only findings are not accidentally closed here.
#
# Safety: when Wazuh returns 0 vulns for an agent (transient API
# glitch, indexer warming up after a manager restart, etc.) the
# naive backfill below would mark every existing finding patched.
# Skip in that case — a real "everything fixed" state is
# vanishingly rare and not worth the risk of wiping the inventory.
if not active_cves and not wazuh_vulns:
# Deferred, not skipped: whether this emptiness is real can
# only be judged once the run is over — see
# reconcile_empty_agents(). This is the collective sync behind
# the "Sync Data" button, which is the one an operator reaches
# for after patching a host (observed: a Windows host Wazuh
# reported clean kept all 348 OS findings open).
run_stats.setdefault("empty_agents", []).append(asset.id)
logger.warning(
"Wazuh returned 0 vulns for asset %s — deferring backfill "
"to avoid mass-patching from a transient empty response.",
asset.hostname,
)
stats["agents_synced"] += 1
scan_row.status = ScanStatus.COMPLETED
scan_row.completed_at = datetime.now()
scan_row.vulnerabilities_found = 0
scan_row.error_message = "Wazuh returned 0 vulns (skipped backfill)"
continue
stale_wazuh = (
db.query(Vulnerability)
.filter(
Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"wazuh"'),
)
.all()
)
for v in stale_wazuh:
if v.cve_id in active_cves:
continue
v.remove_source("wazuh")
if not v.source_list:
old_st = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
stats["vulns_patched"] += 1
try:
log_vulnerability_change(
db, None, v.id, old_st, v.status,
reason=f"Wazuh full sync: agent {agent_id} no longer reports this CVE on {asset.hostname}",
cve_id=v.cve_id,
source="wazuh_sync",
)
except Exception as e:
logger.warning("audit log for wazuh full-sync auto-patch failed (vuln_id=%s): %s", v.id, e)
stats["agents_synced"] += 1
# Close out the per-agent Scan row so the Scan-Jobs page
# shows the manual sync run alongside Nessus runs.
scan_row.status = ScanStatus.COMPLETED
scan_row.completed_at = datetime.now()
scan_row.vulnerabilities_found = len(active_cves)
db.commit()
wazuh.close()
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per
# new finding (audit trail no longer starts at the first status
# change only).
if newly_created_vuln_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, newly_created_vuln_ids, source="wazuh")
db.commit()
except Exception as e:
logger.warning(f"Wazuh sync: detected-audit failed (non-fatal): {e}")
# Best-effort enrichment of newly created vulns (by ID, not a
# broad scan of all un-enriched rows which could be very slow).
if newly_created_vuln_ids:
try:
new_vulns = db.query(Vulnerability).filter(
Vulnerability.id.in_(newly_created_vuln_ids)
).all()
if new_vulns:
# Skip NVD date backfill here — keeps the sync fast;
# the nightly enrichment job dates these rows.
enrich_stats = enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
stats["enrichment"] = enrich_stats
except Exception as e:
logger.warning(f"Wazuh sync: enrichment of new vulns failed: {e}")
# Dispatch ONE digest email per recipient containing all new vulns
# above the configured severity threshold. Replaces the per-CVE
# inline send that used to spam SMTP at the rate of 1 mail per CVE
# per recipient. Mode override via setting `notification_mode`.
if newly_created_vuln_ids:
try:
from app.services.email_service import dispatch_new_vuln_notifications
fresh_vulns = db.query(Vulnerability).filter(
Vulnerability.id.in_(newly_created_vuln_ids)
).all()
notif_stats = dispatch_new_vuln_notifications(db, fresh_vulns)
stats["notifications"] = notif_stats
logger.info(
f"Wazuh sync notifications: {notif_stats.get('emails_sent', 0)} sent, "
f"{notif_stats.get('emails_failed', 0)} failed, "
f"{notif_stats.get('recipients', 0)} recipients"
)
except Exception as e:
logger.warning(f"Wazuh sync: new-vuln notification dispatch failed: {e}")
# Now the run is over, so the deferred agents can be judged.
try:
run_stats["cves_seen"] = stats.get("total_vulns_from_wazuh", 0)
closed = reconcile_empty_agents(db, run_stats)
if closed:
stats["vulns_patched"] = stats.get("vulns_patched", 0) + closed
except Exception as e:
logger.error("Wazuh sync: empty-agent reconcile failed: %s", e)
return {
"message": (
f"Synced {stats['agents_synced']} agents, "
f"{stats.get('total_vulns_from_wazuh', 0)} CVE reports. "
f"Created: {stats['vulns_created']}, "
f"Updated: {stats['vulns_updated']}, "
f"Reopened: {stats.get('vulns_reopened', 0)}, "
f"Confirmed: {stats.get('vulns_confirmed', 0)}, "
f"Patched: {stats['vulns_patched']}"
),
**stats
}
except Exception as e:
db.rollback()
wazuh.close()
logger.error(f"Wazuh sync failed: {e}")
raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {e}")
async def sync_wazuh_vulnerabilities(db_session: Optional[Session] = None):
"""Background-Task for Wazuh-Sync (for Scheduler)"""
from app.database import SessionLocal
db = db_session if db_session is not None else SessionLocal()
try:
result = run_wazuh_vulnerability_sync(db)
logger.info(f"Scheduled Sync: {result['message']}")
except Exception as e:
logger.error(f"Scheduled Sync Error: {e}")
finally:
if db_session is None:
db.close()
def reconcile_empty_agents(db: Session, run_stats: dict) -> int:
"""Close Wazuh findings on agents that returned NO CVEs this run.
Deferred out of the per-agent sync because the decision needs the whole
run: an empty answer is only trustworthy once some OTHER agent has proven
the API is answering. If every agent came back empty, that is an outage
pattern, not an estate that patched itself overnight — leave it alone.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
empty = run_stats.get("empty_agents") or []
if not empty:
return 0
if not run_stats.get("cves_seen"):
logger.warning("Wazuh sync: every agent returned 0 CVEs — treating as an "
"outage, not closing anything (%d agents affected)", len(empty))
return 0
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id.in_(empty),
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"wazuh"'))
.all())
closed = 0
for v in rows:
v.remove_source("wazuh")
if v.source_list:
continue # another scanner still reports it — not ours to close
old_st = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
closed += 1
try:
log_vulnerability_change(
db, None, v.id, old_st, v.status,
reason="Wazuh no longer reports any CVE on this host",
cve_id=v.cve_id, source="wazuh_sync")
except Exception as e:
logger.warning("audit log for empty-agent patch failed (vuln_id=%s): %s", v.id, e)
db.commit()
logger.info("Wazuh sync: %d finding(s) closed on %d agent(s) that reported no CVEs",
closed, len(empty))
return closed
def sync_agent_vulnerabilities(db: Session, wazuh: WazuhClient, agent_id: str,
asset: Asset, run_stats: Optional[dict] = None):
"""Sync vulnerabilities for a single agent from Wazuh.
`run_stats` collects what only the whole run knows: how many CVEs the API
returned overall, and which agents came back empty. Pass it to let
reconcile_empty_agents() close findings on hosts that are genuinely clean;
omit it and an empty answer is treated as untrustworthy, as before."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.vulnerability_package import VulnerabilityPackage
from datetime import datetime
logger.info(f"Sync: Fetching vulnerabilities for agent {agent_id} ({asset.hostname})...")
raw_vulns = wazuh.get_vulnerabilities(agent_id)
logger.info(f"Sync: Agent {agent_id} has {len(raw_vulns)} raw vulnerability entries.")
if run_stats is not None:
run_stats["cves_seen"] = run_stats.get("cves_seen", 0) + len(raw_vulns)
# 1. Deduplicate by CVE ID + collect per-package detail.
# `pkg_rows` is a dict keyed by package_name so we keep one row per
# (cve, package) pair across the agent. fixed_version comes straight
# from Wazuh (often empty — backfill via override service).
unique_cves: dict = {}
for vuln_data in raw_vulns:
cve_id = vuln_data.get("cve")
if not cve_id:
continue
if cve_id not in unique_cves:
unique_cves[cve_id] = {
"data": vuln_data,
"pkg_rows": {}, # name → {version, fixed}
"packages": set(), # legacy summary
"versions": set(), # legacy summary
}
pkg_name = vuln_data.get("name")
pkg_version = vuln_data.get("version")
pkg_fix = vuln_data.get("fixed_version")
pkg_fix = _sanitize_wazuh_fix(pkg_name, pkg_version, pkg_fix)
if pkg_name:
unique_cves[cve_id]["packages"].add(pkg_name)
existing_pkg = unique_cves[cve_id]["pkg_rows"].get(pkg_name)
if existing_pkg is None:
unique_cves[cve_id]["pkg_rows"][pkg_name] = {
"version": pkg_version,
"fixed": pkg_fix,
}
else:
# Prefer first non-empty fix
if pkg_fix and not existing_pkg.get("fixed"):
existing_pkg["fixed"] = pkg_fix
if pkg_version:
unique_cves[cve_id]["versions"].add(pkg_version)
active_cves = set(unique_cves.keys())
logger.info(f"Sync: Agent {agent_id} has {len(active_cves)} unique CVEs after dedup.")
# Log severity breakdown from Wazuh data
severity_counts = {}
for cve_id_tmp, cve_info_tmp in unique_cves.items():
sev = (cve_info_tmp["data"].get("severity") or "unknown").lower()
severity_counts[sev] = severity_counts.get(sev, 0) + 1
logger.info(f"Sync: Agent {agent_id} Wazuh severity breakdown: {severity_counts}")
new_count = 0
updated_count = 0
# Track new vuln IDs so we can send a single digest mail per recipient
# at the end of this agent sync instead of one mail per CVE.
new_vuln_ids: list[int] = []
def _upsert_packages(vuln_id: int, rows: dict, sync_time: datetime) -> None:
"""Upsert one VulnerabilityPackage row per (vuln, package).
Updates last_seen_at for existing entries so the next stale-
package pass can prune ones Wazuh stopped reporting."""
if not rows:
return
existing_pkgs = {
p.package_name: p
for p in db.query(VulnerabilityPackage)
.filter(VulnerabilityPackage.vulnerability_id == vuln_id)
.all()
}
for pkg_name, info in rows.items():
row = existing_pkgs.get(pkg_name)
if row is None:
row = VulnerabilityPackage(
vulnerability_id=vuln_id,
package_name=pkg_name[:255],
package_version=(info.get("version") or None),
fixed_version=(info.get("fixed") or None),
source="wazuh",
first_detected_at=sync_time,
last_seen_at=sync_time,
)
db.add(row)
else:
row.last_seen_at = sync_time
# Only fill fixed_version when empty (override service
# may have written a better value from Vulnrichment).
if info.get("fixed") and not row.fixed_version:
row.fixed_version = info["fixed"][:100]
if info.get("version") and info["version"] != row.package_version:
row.package_version = info["version"][:100]
if not row.source:
row.source = "wazuh"
# 2. Process each unique CVE
for cve_id, cve_info in unique_cves.items():
vuln_data = cve_info["data"]
# Merge package strings (legacy parent-row summary)
merged_packages = ", ".join(sorted(cve_info["packages"]))
merged_versions = ", ".join(sorted(cve_info["versions"]))
# Truncate to fit DB columns
if len(merged_packages) > 250:
merged_packages = merged_packages[:247] + "..."
if len(merged_versions) > 95:
merged_versions = merged_versions[:92] + "..."
score = vuln_data.get("cvss", {}).get("cvss3", {}).get("base_score")
wazuh_severity = vuln_data.get("severity")
# Check if already exists
existing = db.query(Vulnerability).filter(
Vulnerability.cve_id == cve_id,
Vulnerability.asset_id == asset.id
).first()
if existing:
# Update existing entry — track wazuh as a source and reopen
# if Wazuh sees a previously-patched vuln again.
existing.add_source("wazuh")
from app.services.audit_events import reopen_if_patched
# Reopen (audited) when it was patched; otherwise force back to
# open unless an operator deliberately froze the status.
if not reopen_if_patched(
db, existing,
reason="Wazuh reports this CVE on the host again",
source="wazuh_sync") and existing.status not in (
VulnerabilityStatus.pending_verification,
VulnerabilityStatus.accepted_risk,
VulnerabilityStatus.false_positive,
VulnerabilityStatus.deferred,
):
existing.status = VulnerabilityStatus.open
existing.package_name = merged_packages
existing.package_version = merged_versions
fv = vuln_data.get("fixed_version")
if fv and not existing.fixed_version:
existing.fixed_version = fv
if score:
existing.cvss_score = score
existing.severity = map_severity(score, wazuh_severity)
_upsert_packages(existing.id, cve_info["pkg_rows"], datetime.now())
existing.refresh_scores()
updated_count += 1
else:
# Create new — use savepoint so an IntegrityError (duplicate race)
# only rolls back this single insert, not the entire sync transaction.
try:
severity = map_severity(score, wazuh_severity)
vuln = Vulnerability(
cve_id=cve_id,
asset_id=asset.id,
cvss_score=score,
severity=severity,
status=VulnerabilityStatus.open,
title=vuln_data.get("title"),
package_name=merged_packages,
package_version=merged_versions,
fixed_version=vuln_data.get("fixed_version"),
detected_at=datetime.now(),
sources='["wazuh"]',
first_detected_by="wazuh",
)
with db.begin_nested():
db.add(vuln)
db.flush()
_upsert_packages(vuln.id, cve_info["pkg_rows"], datetime.now())
# Inherit canonical CVE metadata from existing siblings
# on other assets so the new row doesn't keep Wazuh's
# placeholder 10.0 when an override-pinned value exists.
try:
from app.services.vuln_override_service import apply_canonical_from_siblings
apply_canonical_from_siblings(db, vuln)
except Exception as e:
logger.warning("canonical-inherit on new wazuh vuln %s failed: %s", vuln.cve_id, e)
vuln.refresh_scores()
new_count += 1
new_vuln_ids.append(vuln.id)
logger.info(f"Sync: New vulnerability {cve_id} on {asset.hostname} - severity={severity.value}, cvss={score}, wazuh_severity={wazuh_severity}")
except IntegrityError:
# Savepoint rolled back; outer transaction intact.
logger.debug(f"Skipping duplicate CVE {cve_id} for asset {asset.id}")
continue
# 3. Source-aware backfill: remove "wazuh" from sources for any open vuln
# on this asset that Wazuh no longer reports. Only mark as PATCHED when
# ALL sources are gone — Nessus-only findings must not be patched here.
#
# Safety: when Wazuh returns 0 vulns for an agent (transient API outage,
# indexer warm-up after a manager restart, etc.) the loop below would
# mass-patch the entire history for the agent. Skip in that case —
# genuinely "everything fixed" is so rare that the false-positive risk
# is not worth taking.
if not active_cves and not raw_vulns:
# An empty answer means one of two opposite things — the host really is
# clean, or Wazuh could not answer — and this function cannot tell them
# apart on its own. Hand it to the caller: a run in which OTHER agents
# returned CVEs proves the API is healthy, so the emptiness is real.
# Without that, a fully patched host kept every finding it ever had
# (observed: Wazuh showed 0 CVEs for a domain controller while 348 sat open here).
if run_stats is not None:
run_stats.setdefault("empty_agents", []).append(asset.id)
logger.warning(
"Wazuh returned 0 vulns for agent %s (%s) — deferring the backfill "
"to avoid mass-patching from a transient empty response.",
agent_id, asset.hostname,
)
asset.last_scan = datetime.now()
asset.last_seen_source = "wazuh"
db.commit()
return new_count, updated_count, new_vuln_ids
stale_wazuh = (
db.query(Vulnerability)
.filter(
Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"wazuh"'),
)
.all()
)
for v in stale_wazuh:
if v.cve_id in active_cves:
continue # still seen by Wazuh — keep source
v.remove_source("wazuh")
if not v.source_list:
old_st = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
try:
log_vulnerability_change(
db, None, v.id, old_st, v.status,
reason=f"Wazuh agent {agent_id} no longer reports this CVE on {asset.hostname}",
cve_id=v.cve_id,
source="wazuh_sync",
)
except Exception as e:
logger.warning("audit log for wazuh auto-patch failed (vuln_id=%s): %s", v.id, e)
# Update asset last_scan
asset.last_scan = datetime.now()
asset.last_seen_source = "wazuh"
db.commit()
logger.info(f"Sync: Completed for agent {agent_id} ({asset.hostname}): {new_count} new, {updated_count} updated, {len(active_cves)} total active CVEs")
# Revisionssicher: initial VULNERABILITY_DETECTED audit event per new
# finding. Written here (not in the callers) so the scheduler loop and
# the per-asset rescan endpoint are both covered.
if new_vuln_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_vuln_ids, source="wazuh")
db.commit()
except Exception as e:
logger.warning(f"Sync: detected-audit failed for agent {agent_id} (non-fatal): {e}")
# 4. Enrich newly created vulns with EPSS + KEV (best-effort, non-fatal)
if new_count > 0:
try:
new_vulns = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset.id,
Vulnerability.cve_id.in_(active_cves),
Vulnerability.enrichment_updated_at.is_(None)
).all()
if new_vulns:
# NVD date backfill deferred to the nightly job (rate-limited).
enrich_vulnerabilities(db, new_vulns, use_nvd_dates=False)
except Exception as e:
logger.warning(f"Sync: Enrichment skipped for agent {agent_id}: {e}")
# 5. Dispatch ONE digest mail per recipient for this agent's newly
# discovered vulns (replaces per-CVE inline send). When this helper
# is called from a multi-agent loop the caller may prefer to skip
# per-agent dispatch and instead aggregate IDs and send a single
# cross-agent digest at the very end — currently each per-agent call
# produces its own digest.
if new_vuln_ids:
try:
from app.services.email_service import dispatch_new_vuln_notifications
fresh_vulns = db.query(Vulnerability).filter(
Vulnerability.id.in_(new_vuln_ids)
).all()
dispatch_new_vuln_notifications(db, fresh_vulns)
except Exception as e:
logger.warning(f"Sync: notification dispatch failed for agent {agent_id}: {e}")
def map_severity(cvss_score: Optional[float], wazuh_severity: Optional[str] = None) -> VulnerabilitySeverity:
"""Mappt CVSS-Score zu Severity-Enum, mit Wazuh-Severity als Fallback"""
if cvss_score and cvss_score > 0:
if cvss_score >= 9.0:
return VulnerabilitySeverity.critical
elif cvss_score >= 7.0:
return VulnerabilitySeverity.high
elif cvss_score >= 4.0:
return VulnerabilitySeverity.medium
else:
return VulnerabilitySeverity.low
# Fallback: Wazuh's eigene Severity-Einstufung verwenden
if wazuh_severity:
severity_lower = wazuh_severity.strip().lower()
severity_map = {
"critical": VulnerabilitySeverity.critical,
"high": VulnerabilitySeverity.high,
"medium": VulnerabilitySeverity.medium,
"low": VulnerabilitySeverity.low,
}
mapped = severity_map.get(severity_lower)
if mapped:
logger.info(f"No CVSS score available, using Wazuh severity fallback: {wazuh_severity} -> {mapped.value}")
return mapped
return VulnerabilitySeverity.none
# ============================================
# CVSS Override Endpoints (CISA Vulnrichment)
# ============================================
class OverrideDryRunResponse(BaseModel):
"""Response for dry-run mode"""
message: str
would_be_updated: int
checked: int
placeholder_scores_found: int
changes_preview: List[dict]
class OverrideStatsResponse(BaseModel):
"""Response after override execution"""
message: str
updated: int
checked: int
errors: int
changes: List[dict]
@router.get("/override/check")
async def check_incorrect_scores(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Findet alle Vulnerabilities mit wahrscheinlich falschen Scores.
Erkennt:
- Platzhalter-Scores (10.0) von Wazuh
- Unplausible Kombinationen (CVSS 10 + LOW Severity)
Gibt eine Liste mit Empfehlungen zurück.
"""
service = VulnOverrideService(db)
incorrect = service.find_incorrect_scores()
return {
"total_found": len(incorrect),
"placeholder_scores": len([i for i in incorrect if i.get("issue") == "placeholder_score_10"]),
"severity_mismatches": len([i for i in incorrect if i.get("issue") == "severity_mismatch"]),
"details": incorrect
}
@router.post("/override/nessus/{asset_id}")
def override_from_nessus(
asset_id: int,
scan_id: Optional[int] = None,
dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Korrigiert CVSS-Scores und Severity-Werte basierend auf Nessus-Daten.
Verwendet Nessus Plugin Output für verifizierte Scores.
- **asset_id**: Asset-DB-ID
- **scan_id**: Nessus Scan-ID (optional, wird aus Config ermittelt)
- **dry_run**: Wenn True, nur simulieren ohne Änderungen
"""
# Load Nessus config (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_nessus = read_setting_value(db, SETTING_KEY)
if not raw_nessus:
raise HTTPException(
status_code=400,
detail="Nessus configuration not set. Please configure Nessus in Settings."
)
try:
config = json.loads(raw_nessus)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid Nessus configuration format.")
from app.integrations.nessus_client import NessusClient
try:
nessus_client = NessusClient(
base_url=config["base_url"],
access_key=config["access_key"],
secret_key=config["secret_key"],
verify_ssl=bool(config.get("verify_ssl", True)),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
service = VulnOverrideService(db)
try:
# Load verified data from Nessus
if scan_id:
verified_data = service.load_nessus_verified_data(asset_id, scan_id, nessus_client)
else:
# Use latest scan for this asset
from app.models.asset import Asset
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
# Get recent scan IDs
scans = nessus_client.list_scans()
if not scans:
raise HTTPException(status_code=404, detail="No Nessus scans found")
# Find scan for this asset's IP
target_scan_id = None
for scan in scans:
targets = scan.get("targets", "")
if asset.ip_address and asset.ip_address in targets:
target_scan_id = int(scan["id"])
break
if not target_scan_id:
target_scan_id = int(scans[0]["id"]) # Fallback to first scan
verified_data = service.load_nessus_verified_data(asset_id, target_scan_id, nessus_client)
# Apply overrides
if not verified_data:
return {
"message": "No verified CVE data found in Nessus for this asset",
"updated": 0,
"checked": 0
}
stats = service.apply_overrides(asset_id, verified_data, dry_run=dry_run)
# Audit log
if not dry_run and stats["updated"] > 0:
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.VULNERABILITY_UPDATED,
event_description=f"CVSS override applied from Nessus for {stats['updated']} vulnerabilities",
resource_type="vulnerability",
resource_id=f"asset:{asset_id}",
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return {
"message": "Dry run - no changes applied" if dry_run else "Override applied successfully",
**stats
}
finally:
nessus_client.close()
@router.post("/override/vulnrichment")
# Sync def → worker threadpool; the cvelistV5/Vulnrichment cascade (incl.
# ZIP download) runs off the event loop so the web GUI stays responsive.
def override_from_vulnrichment(
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs zum Korrigieren (leer = alle)"),
dry_run: bool = Query(True, description="Nur simulieren ohne DB-Änderungen"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Korrigiert CVSS-Scores und Severity-Werte basierend auf CISA Vulnrichment.
Verwendet den offiziellen CISA Vulnrichment JSON Feed für verifizierte
CVSSv3.1 Base Scores und SSVC-Exploitation-Status.
- **cve_ids**: Optionale CVE-Filterliste (leer = alle offenen CVEs)
- **asset_ids**: Optionale Asset-Filterliste (leer = alle Assets)
- **dry_run**: Wenn True, nur simulieren ohne Änderungen
**Beispiel für CVE-2026-8390:**
- Wazuh (falsch): CVSS 10.0, Severity CRITICAL
- Vulnrichment (korrekt): CVSS 7.3, Severity HIGH, Exploitation "none"
"""
result = correct_vulnerability_scores(
db,
cve_ids=cve_ids,
asset_ids=asset_ids,
dry_run=dry_run
)
# Audit log
if not dry_run and result.get("updated", 0) > 0:
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.VULNERABILITY_UPDATED,
event_description=f"CVSS override from CISA Vulnrichment: {result['updated']} vulnerabilities corrected",
resource_type="vulnerability",
resource_id="bulk:vulnrichment",
old_value=json.dumps({"method": "cisa_vulnrichment"}),
new_value=json.dumps({"updated": result["updated"], "changes": result.get("changes", [])[:5]}), # Limit to 5 for audit
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return result
@router.post("/override/vulnrichment/start")
async def start_vulnrichment_correction_job(
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"),
dry_run: bool = Query(False, description="Nur simulieren ohne DB-Änderungen"),
current_user: User = Depends(RequireEditor),
):
"""
Async-Variante des Vulnrichment-Overrides.
Spawnt einen Background-Thread und liefert sofort eine ``job_id``
zurück. Fortschritt + Endergebnis via
``GET /override/vulnrichment/status/{job_id}``.
Für Bulk-Korrekturen (Frontend "Correct CVSS" Button) verwenden —
der synchrone Endpoint blockiert die Browser-Verbindung und löst
bei großen Datenbanken den 23-Min-Hänger des Frontends aus.
"""
job_id = start_vulnrichment_job(
user_id=current_user.id,
cve_ids=cve_ids,
asset_ids=asset_ids,
dry_run=dry_run,
)
return {
"job_id": job_id,
"status": "queued",
"poll_url": f"/api/v1/vulnerabilities/override/vulnrichment/status/{job_id}",
}
@router.get("/override/vulnrichment/status/{job_id}")
async def get_vulnrichment_correction_status(
job_id: str,
current_user: User = Depends(get_current_user),
):
"""
Snapshot eines mit ``/override/vulnrichment/start`` gestarteten Jobs.
Felder:
- ``status``: queued | running | completed | failed
- ``stage``: human-readable progress label (z.B. "downloading ZIP")
- ``total`` / ``done``: numerische Progress-Hinweise
- ``updated`` / ``checked`` / ``not_found``: End-Stats wenn fertig
- ``error``: gesetzt bei status == "failed"
"""
job = get_override_job(job_id)
if not job:
raise HTTPException(
status_code=404,
detail=f"Job {job_id} not found (may have expired after backend restart)",
)
return job
@router.get("/override/vulnrichment/jobs")
async def list_vulnrichment_correction_jobs(
limit: int = Query(10, le=50),
current_user: User = Depends(get_current_user),
):
"""Most-recent-first list of override jobs (for an admin overview)."""
return list_override_jobs(limit=limit)
@router.post("/exploit-intel/refresh")
# Sync def → FastAPI runs it in a worker threadpool, off the event loop,
# so the catalog downloads don't freeze the web GUI.
def refresh_exploit_intel(
only_open: bool = Query(True, description="Limit to status=open"),
fetch_pocs: bool = Query(True, description="Also fetch PoC-in-GitHub (slow)"),
fetch_msf: bool = Query(True, description="Also fetch Metasploit modules"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Refresh exploit-intel columns from Exploit-DB / PoC-in-GitHub /
Metasploit. Sources cached on disk 24h."""
from app.services.exploit_intel_service import refresh_all_exploit_intel
stats = refresh_all_exploit_intel(
db, only_open=only_open, fetch_pocs=fetch_pocs, fetch_msf=fetch_msf,
)
return stats
# Background EOL job. The synchronous endpoint below still exists (API
# clients, small installs), but the GUI uses start/status: a full EOL check
# walks every asset's package list against endoflife.date + the MS lifecycle
# export and can outlive the browser's patience (observed: "Backend
# connection failed" while the backend was still working).
_EOL_JOB: dict = {"running": False, "stage": None, "done": 0, "total": 0,
"result": None, "error": None, "finished_at": None}
def _eol_job_runner(asset_id: Optional[int], user):
"""Run the EOL check on its own DB session, recording progress."""
import time as _time
from app.database import SessionLocal
db = SessionLocal()
try:
stats = run_eol_check(asset_id=asset_id, db=db, current_user=user)
_EOL_JOB["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
_EOL_JOB["error"] = None
_EOL_JOB["stage"] = "completed"
except Exception as e:
logger.exception("EOL background job failed")
_EOL_JOB["result"] = None
_EOL_JOB["error"] = str(e)
_EOL_JOB["stage"] = "failed"
finally:
db.close()
_EOL_JOB["running"] = False
_EOL_JOB["finished_at"] = _time.time()
@router.post("/eol-check/start", status_code=202)
def start_eol_check_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
current_user: User = Depends(RequireEditor),
):
"""Start the EOL check in the background; poll /eol-check/status."""
import threading
if _EOL_JOB["running"]:
return {"started": False, "message": "An EOL check is already running",
"poll_url": "/api/v1/vulnerabilities/eol-check/status"}
_EOL_JOB.update({"running": True, "stage": "starting", "done": 0, "total": 0,
"result": None, "error": None, "finished_at": None})
threading.Thread(target=_eol_job_runner, args=(asset_id, current_user),
daemon=True).start()
return {"started": True, "poll_url": "/api/v1/vulnerabilities/eol-check/status"}
@router.get("/eol-check/status")
def get_eol_check_status(current_user: User = Depends(get_current_user)):
"""Snapshot of the background EOL check (running / progress / result)."""
s = _EOL_JOB
return {
"running": s["running"],
"stage": s["stage"],
"done": s["done"],
"total": s["total"],
"result": s["result"],
"error": s["error"],
"finished_at": s["finished_at"],
"status": ("running" if s["running"]
else "failed" if s["error"]
else "completed" if s["result"] is not None else "idle"),
}
@router.post("/eol-check")
# Sync def → runs in a worker threadpool (off the event loop) so the
# per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI.
def run_eol_check(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Trigger endoflife.date EOL detection per installed package.
Closes the gap Wazuh has vs Nessus plugin 64784 (Microsoft SQL
Server Unsupported Version Detection): walks each installed
package from syscollector, resolves the product slug, and creates
one pseudo-CVE (cve_id starts with `EOL-`) per EOL or out-of-
active-support release on the asset.
Idempotent — re-runs upsert existing EOL rows rather than
duplicate them.
"""
from app.integrations.wazuh_client import WazuhClient
from app.auth.setting_crypto import read_setting_value
from app.services import eol_service
# Use read_setting_value (handles Fernet-encrypted values) +
# WazuhClient(base_url=, ...) signature to match the other Wazuh
# call sites (sync, rescan). Raw json.loads broke when the setting
# row was encrypted.
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
raise HTTPException(400, "Wazuh is not configured (settings.wazuh_config missing).")
try:
wazuh_config = json.loads(raw_wazuh)
except json.JSONDecodeError:
raise HTTPException(400, "Invalid Wazuh configuration format.")
api_url = wazuh_config.get("api_url")
username = wazuh_config.get("username")
password = wazuh_config.get("password")
if not all([api_url, username, password]):
raise HTTPException(400, "Incomplete Wazuh configuration (api_url/username/password missing).")
wazuh = WazuhClient(
base_url=api_url,
username=username,
password=password,
indexer_url=wazuh_config.get("indexer_url"),
indexer_username=wazuh_config.get("indexer_username"),
indexer_password=wazuh_config.get("indexer_password"),
# Default False matches the sync/rescan call sites — self-
# signed Wazuh certs are the norm in lab/SMB deploys.
verify_ssl=bool(wazuh_config.get("verify_ssl", True)),
)
q = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None))
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
assets = q.all()
if not assets:
return {"assets_scanned": 0, "eol_findings_total": 0}
stats = {
"assets_scanned": 0,
"packages_checked": 0,
"products_unmapped": 0,
"eol_findings_total": 0,
"eol_findings_new": 0,
"errors": [],
}
stats["os_eol_findings"] = 0
_EOL_JOB["total"] = len(assets)
_EOL_JOB["done"] = 0
_EOL_JOB["stage"] = f"scanning {len(assets)} asset(s)"
for asset in assets:
_EOL_JOB["done"] += 1
_EOL_JOB["stage"] = f"asset {_EOL_JOB['done']}/{_EOL_JOB['total']}: {asset.hostname or asset.id}"
# Every EOL finding this asset still justifies. What is missing from it
# at the end of the pass is software that has left the inventory.
seen_eol_ids: set[int] = set()
# OS-level EOL — independent of package fetch so even an asset
# whose syscollector package list errors still gets its OS
# checked (Windows Server 2008 R2 etc.).
try:
os_status = eol_service.check_os_eol(
db, asset.operating_system or "", asset.os_version or ""
)
if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
os_label = (asset.operating_system or "Operating System").strip()
os_vid, os_created = eol_service.upsert_eol_vulnerability(
db,
asset_id=asset.id,
product_name=os_label,
installed_version=(asset.os_version or os_status.release_name or "unknown"),
status=os_status,
)
if os_vid:
seen_eol_ids.add(os_vid)
stats["eol_findings_total"] += 1
stats["os_eol_findings"] += 1
if os_created:
stats["eol_findings_new"] += 1
except Exception as e:
logger.warning("OS-EOL check failed for asset %s: %s", asset.id, e)
try:
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception as e:
stats["errors"].append(f"asset {asset.id} ({asset.hostname}): {e}")
continue
stats["assets_scanned"] += 1
# One sweep implementation for every source (see run_eol_for_packages).
# reconcile=True: this is the host's whole inventory, so what the sweep
# did not re-confirm is uninstalled. seen_eol_ids carries the OS-level
# finding above, which the package list can never re-confirm.
res = eol_service.run_eol_for_packages(
db, asset, pkgs, reconcile=True, seen_ids=seen_eol_ids)
stats["packages_checked"] += res["packages_checked"]
stats["products_unmapped"] += res["unmapped"]
stats["eol_findings_total"] += res["findings"]
stats["eol_findings_new"] += res["new"]
if res["ms_lifecycle"]:
stats["ms_lifecycle_findings"] = (
stats.get("ms_lifecycle_findings", 0) + res["ms_lifecycle"])
if res["closed"]:
stats["eol_uninstalled_closed"] = (
stats.get("eol_uninstalled_closed", 0) + res["closed"])
db.commit()
# Re-check open MS-lifecycle findings and close the ones a since-corrected
# name match no longer produces (observed: Edge browser vs "Azure Stack Edge").
try:
stats["ms_lifecycle_closed"] = eol_service.revalidate_ms_lifecycle_findings(db)
except Exception as e:
logger.warning("MS-lifecycle revalidate failed: %s", e)
logger.info(
"EOL check done: %d assets, %d packages, %d EOL findings (%d new), "
"%d via MS-lifecycle/exotics, %d OS, %d unmapped",
stats.get("assets_scanned", 0), stats.get("packages_checked", 0),
stats.get("eol_findings_total", 0), stats.get("eol_findings_new", 0),
stats.get("ms_lifecycle_findings", 0), stats.get("os_eol_findings", 0),
stats.get("products_unmapped", 0),
)
return stats
@router.post("/m365-check")
# Sync def → runs in a worker threadpool (off the event loop) so the
# cvelistV5/MSRC metadata work doesn't freeze the web GUI.
def run_m365_check_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all wazuh-linked assets"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Detect Microsoft 365 Apps CVEs (Plan P).
M365 Apps security fixes are not in NVD and are invisible to Wazuh's
vulnerability detector. This parses the Microsoft 365 Apps security-
updates page, compares the installed build (from syscollector) against
the latest patched build for the matching update channel, and creates
real-CVE rows for every monthly update the host is behind on.
Idempotent — re-runs upsert existing rows.
"""
from app.integrations.wazuh_client import WazuhClient
from app.auth.setting_crypto import read_setting_value
from app.services import m365_service
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
raise HTTPException(400, "Wazuh is not configured (settings.wazuh_config missing).")
try:
wazuh_config = json.loads(raw_wazuh)
except json.JSONDecodeError:
raise HTTPException(400, "Invalid Wazuh configuration format.")
api_url = wazuh_config.get("api_url")
username = wazuh_config.get("username")
password = wazuh_config.get("password")
if not all([api_url, username, password]):
raise HTTPException(400, "Incomplete Wazuh configuration (api_url/username/password missing).")
wazuh = WazuhClient(
base_url=api_url,
username=username,
password=password,
indexer_url=wazuh_config.get("indexer_url"),
indexer_username=wazuh_config.get("indexer_username"),
indexer_password=wazuh_config.get("indexer_password"),
verify_ssl=bool(wazuh_config.get("verify_ssl", True)),
)
try:
return m365_service.run_m365_check(db, wazuh, asset_id=asset_id)
except m365_service.M365Error as e:
raise HTTPException(502, f"MS365 detection failed: {e}")
# Background app-scan job — same reason as the EOL check: a full run walks
# every asset's inventory against NVD/OSV and rebuilds the cvelistV5 and MSRC
# indexes on first use, which outlives the browser's HTTP patience. Operators
# saw "Backend connection failed" while the scan ran happily to completion.
_APPSCAN_JOB: dict = {"running": False, "stage": None, "done": 0, "total": 0,
"result": None, "error": None, "finished_at": None}
def _appscan_job_runner(asset_id: Optional[int]):
"""Run the app CVE scan on its own DB session, recording progress."""
import time as _time
from app.database import SessionLocal
from app.services import app_cve_scanner_service
db = SessionLocal()
try:
stats = app_cve_scanner_service.run_app_cve_scan(db, asset_id=asset_id)
_APPSCAN_JOB["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
_APPSCAN_JOB["error"] = None
_APPSCAN_JOB["stage"] = "completed"
except Exception as e:
logger.exception("App CVE scan background job failed")
_APPSCAN_JOB["result"] = None
_APPSCAN_JOB["error"] = str(e)
_APPSCAN_JOB["stage"] = "failed"
finally:
db.close()
_APPSCAN_JOB["running"] = False
_APPSCAN_JOB["finished_at"] = _time.time()
@router.post("/app-cve-scan/start", status_code=202)
def start_app_cve_scan_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
current_user: User = Depends(RequireEditor),
):
"""Start the app CVE scan in the background; poll /app-cve-scan/status."""
import threading
if _APPSCAN_JOB["running"]:
return {"started": False, "message": "An app CVE scan is already running",
"poll_url": "/api/v1/vulnerabilities/app-cve-scan/status"}
_APPSCAN_JOB.update({"running": True, "stage": "starting", "done": 0, "total": 0,
"result": None, "error": None, "finished_at": None})
threading.Thread(target=_appscan_job_runner, args=(asset_id,),
daemon=True).start()
return {"started": True, "poll_url": "/api/v1/vulnerabilities/app-cve-scan/status"}
@router.get("/app-cve-scan/status")
def get_app_cve_scan_status(current_user: User = Depends(get_current_user)):
"""Snapshot of the background app CVE scan (running / result)."""
s = _APPSCAN_JOB
return {
"running": s["running"],
"stage": s["stage"],
"result": s["result"],
"error": s["error"],
"finished_at": s["finished_at"],
"status": ("running" if s["running"]
else "failed" if s["error"]
else "completed" if s["result"] is not None else "idle"),
}
@router.post("/app-cve-scan")
# Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and
# must not freeze the web GUI. Kept for API clients; the GUI uses start/status.
def run_app_cve_scan_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all assets with software inventory"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Built-in app→CVE scanner (curated + precise).
Maps installed software (Wazuh syscollector packages + Intune
detectedApps) to real CVEs via OSV / NVD-CPE with an own version-range
check, so assets without a real scanner (Intune-only / mobile) still
get findings. Source 'app-scan'; cross-confirms with existing scanners
on the same (cve, asset). Idempotent.
"""
from app.services import app_cve_scanner_service
try:
return app_cve_scanner_service.run_app_cve_scan(db, asset_id=asset_id)
except Exception as e:
raise HTTPException(502, f"App CVE scan failed: {e}")
@router.post("/msrc-scan")
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
def run_msrc_scan_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
rebuild_index: bool = Query(False, description="Re-pull the MSRC CVRF documents first (slow; the nightly job does this)"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""MSRC fixed-build scan for Windows Server OS CVEs.
Compares the host's OS build against the FixedBuild MSRC publishes per CVE
and product, on the host's own servicing branch. Patch-level accurate and
available on Patch Tuesday — well before the CVE reaches the Wazuh CTI feed.
NVD/cvelistV5 cannot do this: they carry no fixed build for MS products.
Source 'msrc'; idempotent, and auto-resolves once a host catches up.
"""
from app.services import msrc_scan_service
try:
if rebuild_index:
msrc_scan_service.build_product_index(db)
return msrc_scan_service.run_msrc_scan(db, asset_id=asset_id)
except Exception as e:
raise HTTPException(502, f"MSRC scan failed: {e}")
@router.post("/suppress-false-positives")
# Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O).
def suppress_false_positives_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Auto-flag Wazuh findings whose installed version is provably outside
all cvelistV5 affected ranges for the matched product (loose-CPE false
positives, e.g. a SQL 2019 host carrying a 16.x/17.x-only CVE). Sets
status=false_positive (reversible via unmark). Conservative.
"""
from app.services import cvelistv5_scan_service
try:
return cvelistv5_scan_service.suppress_false_positives(db, asset_id=asset_id)
except Exception as e:
raise HTTPException(502, f"FP suppression failed: {e}")
@router.get("/ai-remediation/status")
async def ai_remediation_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Whether AI remediation is configured (key present). UI hides the
button when disabled."""
from app.services import ai_service
return {"enabled": ai_service.is_enabled(db)}
@router.post("/{vuln_id}/ai-remediation")
async def ai_remediation(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Generate OS-aware remediation guidance for a vulnerability via
OpenRouter. Runs OFF the event loop so the GUI stays responsive."""
import asyncio
from app.services import ai_service
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(404, "Vulnerability not found")
asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
os_name = None
if asset:
os_name = (asset.operating_system or "").strip() or None
if os_name and asset.os_version:
os_name = f"{os_name} {asset.os_version}".strip()
kwargs = dict(
cve_id=vuln.cve_id,
title=vuln.title,
description=vuln.description,
package=vuln.package_name,
installed=vuln.package_version,
fixed=vuln.fixed_version,
os_name=os_name,
scanner_remediation=getattr(vuln, "remediation", None),
)
try:
result = await asyncio.to_thread(ai_service.generate_remediation, db, **kwargs)
except ai_service.AIServiceError as e:
raise HTTPException(502, str(e))
return result
@router.get("/{vuln_id}/remediations")
async def get_vuln_remediations(
vuln_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remediation guidance for a finding from every source:
the per-row scanner solution (Nessus) + CVE-level external sources
(MSRC, later Ubuntu/CentOS) grouped by source. Pure DB read."""
from app.models.cve_remediation import CveRemediation
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise HTTPException(404, "Vulnerability not found")
# On-demand non-Windows enrichment for this CVE, cached into
# cve_remediations on first view: the matching vendor provider (Ubuntu
# USN / RHEL-family errata) when the OS is known + OSV.dev aggregator
# (Debian/SUSE/Alpine/Rocky/Alma/language ecosystems). MSRC is populated
# by its own background job, not here.
if vuln.cve_id and vuln.cve_id.upper().startswith("CVE-"):
try:
import asyncio
from app.services import linux_remediation_service as lrs
asset = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
os_name = (asset.operating_system if asset else None) or ""
is_windows = "windows" in os_name.lower()
# OSV is the always-run source; if it's not cached yet, enrich.
if not is_windows and not lrs.has_cached(db, vuln.cve_id, "osv"):
await asyncio.to_thread(lrs.enrich_cve_linux, db, vuln.cve_id, os_name)
db.commit()
# Windows: the nightly MSRC ingest is a whole-month batch at 03:50,
# so a finding created by an out-of-band sync (Wazuh pull at 16:15,
# manual scan) showed no Remediation block until the next morning.
# Fetch this one CVE from the Update Guide API instead of waiting.
if is_windows:
from app.services import msrc_service
has_msrc = db.query(CveRemediation.id).filter(
CveRemediation.cve_id == vuln.cve_id,
CveRemediation.source == "msrc",
).first()
if not has_msrc:
await asyncio.to_thread(msrc_service.enrich_cve_msrc, db, vuln.cve_id)
except Exception as e:
logger.debug("Linux/OSV remediation enrich failed for %s: %s", vuln.cve_id, e)
ext = (
db.query(CveRemediation)
.filter(CveRemediation.cve_id == vuln.cve_id)
.all()
)
groups: dict = {}
for r in ext:
g = groups.setdefault(r.source, {"source": r.source, "items": [], "fetched_at": None})
g["items"].append({
"kind": r.kind, "title": r.title, "detail": r.detail,
"kb": r.kb, "fixed_build": r.fixed_build, "url": r.url,
})
if r.fetched_at and (g["fetched_at"] is None or r.fetched_at > g["fetched_at"]):
g["fetched_at"] = r.fetched_at
# MSRC block tailoring for the affected host.
msrc = groups.get("msrc")
if msrc:
a = db.query(Asset).filter(Asset.id == vuln.asset_id).first()
is_m365 = (vuln.first_detected_by == "m365_check")
def _branch(v: str):
# build "10.0.26100.32690" → branch "26100", rev 32690
parts = [p for p in (v or "").split(".") if p.isdigit()]
return (parts[2], int(parts[3])) if len(parts) >= 4 else (None, None)
def _major(v):
# first numeric segment: "8.0.12" → "8", "18.6.3" → "18"
for p in (v or "").split("."):
if p.isdigit():
return p
return None
items = msrc["items"]
fixes = [i for i in items if i.get("kind") == "fix"]
others = [i for i in items if i.get("kind") != "fix"]
# Keep only the NEWEST KB per (build-branch + update-type). MS monthly
# updates are cumulative, so older revisions of the same line are
# superseded; distinct types (Security Update vs Security Hotpatch
# Update) are kept separately. Non-Windows product builds (.NET / Visual
# Studio / SQL have no 4-part Windows build → branch None) are keyed on
# the build itself so DIFFERENT products aren't collapsed into one
# (observed: a .NET finding showed a Visual Studio build because both had
# branch None).
# Collect ALL candidates per (build-branch + update-type) first — the
# host-aware pick below needs the full set. Collapsing to the newest
# rev up-front was wrong: one branch can carry SEVERAL revision
# sequences (10.0.26100 is Windows 11 24H2 at rev ~8xxx AND Windows
# Server 2025 at rev ~33xxx), so 'newest' handed a Win11 host the
# Server KB (observed: KB5099536 build .33158 suggested at .8655).
best: dict = {}
by_branch: dict = {}
for i in fixes:
build = i.get("fixed_build") or ""
br, rev = _branch(build)
if br is None:
key = ("nb", build or i.get("kb") or i.get("url") or i.get("title") or "", i.get("detail") or "")
best.setdefault(key, i)
else:
key = (br, i.get("detail") or "")
by_branch.setdefault(key, []).append((rev or 0, i))
if key not in best or (rev or 0) > (_branch(best[key].get("fixed_build") or "")[1] or 0):
best[key] = i
deduped = list(best.values())
# Windows-OS finding: narrow to the host's own build branch. Within the
# branch, pick per update-type the SMALLEST fix revision still above the
# installed one — that's the host's own servicing sequence (a 24H2 box
# at .8655 takes .8875, not Server 2025's .33158; a Server box at
# .32690 skips .8875 because it's below installed and takes .33158).
# MS servicing is cumulative, so smallest-above is the fixing update.
# If the host is past every candidate (already patched), fall back to
# the newest as reference. For Microsoft 365 Apps findings the MSRC KBs
# are MSI-version builds that never match the installed Click-to-Run
# channel build, so we don't branch-filter — we show the deduped set
# plus the actionable channel-build hint below.
host_branch, host_rev = _branch(a.os_version if a else "")
shown = deduped
if host_branch and not is_m365:
matched = []
for (br, _detail), cands in by_branch.items():
if br != host_branch:
continue
above = [c for c in cands if host_rev is not None and c[0] > host_rev]
pick = min(above)[1] if above else max(cands)[1]
matched.append(pick)
if matched:
shown = matched
# Non-Windows Microsoft product finding (.NET / SQL / Visual Studio all
# share one CVE across products, each with its own fix build): narrow
# to the build line whose major matches the installed package version,
# so a .NET 8.x finding shows the .NET 8.x fix — not a Visual Studio
# 18.x build. Fallback to the full set if nothing matches (never hide
# everything).
if not is_m365 and shown is deduped:
inst_major = _major(vuln.package_version)
if inst_major:
prod = [i for i in shown if _major(i.get("fixed_build")) == inst_major]
if prod:
shown = prod
shown.sort(key=lambda i: _branch(i.get("fixed_build") or "")[1] or 0, reverse=True)
# M365 Apps: the real fix is "update the Office channel to build X".
if is_m365 and vuln.fixed_version:
others = [{
"kind": "mitigation",
"title": "Update via Office channel",
"detail": (f"Update Microsoft 365 Apps to build {vuln.fixed_version} "
f"or later via the configured Office update channel "
f"(Click-to-Run). The KB list below is for perpetual/MSI "
f"Office versions and may not apply to this install."),
"kb": None, "fixed_build": None, "url": None,
}] + others
link = {
"kind": "advisory",
"title": "MSRC update guide",
"detail": None, "kb": None, "fixed_build": None,
"url": f"https://msrc.microsoft.com/update-guide/vulnerability/{vuln.cve_id}",
}
msrc["items"] = [link] + others + shown
return {
"scanner": getattr(vuln, "remediation", None),
"external": list(groups.values()),
}
# Last/current MSRC-refresh state so the GUI can poll for completion (the
# refresh is fire-and-forget 202). ponytail: module state assumes the single
# uvicorn worker we ship with; move to the DB if --workers is ever added.
_MSRC_REFRESH: dict = {"running": False, "result": None, "error": None, "finished_at": None}
def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
import time as _time
from app.database import SessionLocal
from app.services import msrc_service
db = SessionLocal()
try:
stats = msrc_service.refresh_msrc(db, months_back=months_back)
_MSRC_REFRESH["result"] = {k: v for k, v in (stats or {}).items() if k != "errors"}
_MSRC_REFRESH["error"] = None
except Exception as e:
_MSRC_REFRESH["result"] = None
_MSRC_REFRESH["error"] = str(e)
logger.error("MSRC refresh failed: %s", e)
finally:
db.close()
_MSRC_REFRESH["running"] = False
_MSRC_REFRESH["finished_at"] = _time.time()
@router.post("/msrc/refresh", status_code=202)
async def refresh_msrc_endpoint(
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
current_user: User = Depends(RequireEditor),
):
"""Kick off an MSRC CVRF ingest in the background (fire-and-forget).
Pulls the last N monthly Microsoft security-update documents and stores
per-CVE fixes (KB + build + link), workarounds, and mitigations for the
CVEs already in the DB. Returns 202 immediately — watch the logs
('MSRC refresh done')."""
if _MSRC_REFRESH["running"]:
return {"status": "already_running", "detail": "An MSRC refresh is already in progress."}
_MSRC_REFRESH.update({"running": True, "result": None, "error": None, "finished_at": None})
import threading
threading.Thread(target=_run_msrc_refresh_threaded, args=(months_back,), daemon=True).start()
return {"status": "started", "detail": "MSRC enrichment started in the background."}
@router.get("/msrc/refresh/status")
async def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
"""Poll target for the GUI: current/last MSRC-refresh state + stats."""
s = _MSRC_REFRESH
state = ("running" if s["running"] else "error" if s["error"]
else "done" if s["result"] is not None else "idle")
return {"state": state, "running": s["running"], "result": s["result"],
"error": s["error"], "finished_at": s["finished_at"]}
@router.post("/recompute-scores")
def recompute_priority_scores(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""One-off backfill of priority_score + cpr_score columns.
Migration 024 adds the columns but leaves them NULL. Forward
sync / enrich / override paths keep them current going forward.
Existing rows need this endpoint once after the upgrade. Cheap —
pure Python, ~5s for 15k rows.
"""
updated = 0
for vuln in db.query(Vulnerability).all():
try:
vuln.refresh_scores()
updated += 1
except Exception as e:
logger.warning("refresh_scores failed for vuln_id=%s: %s", vuln.id, e)
db.commit()
return {"updated": updated}
@router.post("/canonicalize-cve-metadata")
async def canonicalize_cve_metadata(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""One-off normalisation of canonical CVE fields across sibling rows.
Field report: "Same CVE shows different CVSS depending on sort
order." Cause: per-asset rows of the same CVE drifted apart over
time — override service touched some, not others.
For every distinct CVE in the DB, pick the freshest authoritative
source row (exploitation_source set, latest updated_at) and copy
its canonical fields (cvss_score, severity, exploitation_status,
SSVC, cvss_vector) onto all siblings. `fixed_version` is left
alone because Plan I picks per-package fixes that legitimately
differ between streams.
Cheap — single pass over the cve_id → vulns mapping. Forward
sync / enrich / override paths keep the canonical view current
going forward.
"""
from sqlalchemy import desc as _desc, nulls_last
from app.services.vuln_override_service import (
propagate_canonical_to_siblings, _CANONICAL_FIELDS,
)
cve_ids = [
r[0] for r in db.query(Vulnerability.cve_id).distinct().all()
if r[0]
]
cves_changed = 0
rows_changed = 0
for cve_id in cve_ids:
vulns = (
db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id)
.all()
)
if len(vulns) < 2:
continue
# Source-of-truth pick: pinned source first, then most recent.
vulns.sort(
key=lambda v: (v.exploitation_source is not None, v.updated_at or datetime.min),
reverse=True,
)
src = vulns[0]
# Skip when the source itself has no authoritative values to share.
if all(getattr(src, f, None) is None for f in _CANONICAL_FIELDS):
continue
touched = propagate_canonical_to_siblings(db, src)
if touched:
cves_changed += 1
rows_changed += touched
db.commit()
return {
"distinct_cves_scanned": len(cve_ids),
"cves_normalised": cves_changed,
"rows_changed": rows_changed,
}
@router.get("/override/stats")
async def get_override_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Gibt Statistiken über potenzielle Score-Fehler zurück.
Zeigt:
- Anzahl Platzhalter-Scores (10.0)
- Anzahl Severity-Fehlanpassungen
- Verteilung nach Severity-Level
"""
from sqlalchemy import func
# Count placeholder scores
placeholder_count = db.query(func.count(Vulnerability.id)).filter(
Vulnerability.cvss_score == 10.0
).scalar()
# Count severity mismatches (CVSS >= 9 but not CRITICAL)
mismatch_count = db.query(func.count(Vulnerability.id)).filter(
Vulnerability.cvss_score >= 9.0,
Vulnerability.severity != VulnerabilitySeverity.critical
).scalar()
# Severity distribution
severity_dist = db.query(
Vulnerability.severity,
func.count(Vulnerability.id)
).filter(
Vulnerability.status == VulnerabilityStatus.open
).group_by(Vulnerability.severity).all()
# CVSS distribution
cvss_ranges = [
("0.0-3.9", 0.0, 3.9),
("4.0-6.9", 4.0, 6.9),
("7.0-8.9", 7.0, 8.9),
("9.0-10.0", 9.0, 10.0),
]
cvss_dist = []
for label, low, high in cvss_ranges:
count = db.query(func.count(Vulnerability.id)).filter(
Vulnerability.cvss_score >= low,
Vulnerability.cvss_score < high if high < 10.0 else Vulnerability.cvss_score <= high
).scalar()
cvss_dist.append({"range": label, "count": count})
return {
"placeholder_scores_10": placeholder_count,
"severity_mismatches": mismatch_count,
"severity_distribution": {str(k): v for k, v in severity_dist},
"cvss_distribution": cvss_dist,
"recommendation": "Run POST /api/v1/vulnerabilities/override/vulnrichment with dry_run=false to correct values"
}