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.
3674 lines
152 KiB
Python
3674 lines
152 KiB
Python
"""
|
||
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"
|
||
}
|