- nessus_sync: new findings now store plugin_name as package_name so the Vulns table's PACKAGE column shows the affected software for Nessus-only rows (was blank). Merge path backfills package_name / title on existing rows that were created before this fix. - vulnerabilities router: sort_by='source' maps to first_detected_by, so users can group rows by scanner from the table header. - Vulns page: Source column header is now clickable with the same hover-style and SortArrow as the other sortable columns.
391 lines
16 KiB
Python
391 lines
16 KiB
Python
"""
|
|
Nessus → VulnCheck sync service.
|
|
|
|
Imports scan findings from Tenable Nessus and merges them onto existing
|
|
Wazuh-sourced vulnerabilities using `(cve_id, asset_id)` as the dedup key.
|
|
Non-CVE Nessus findings (EOL, compliance, weak ciphers, etc.) are stored
|
|
under a pseudo-CVE id `NESSUS-PLUGIN-{plugin_id}` so they show up in the
|
|
same Vulns list and inherit the existing workflow.
|
|
|
|
Asset matching cascade:
|
|
1. assets.nessus_host_uuid (pinned after first match)
|
|
2. assets.hostname (case-insensitive)
|
|
3. assets.ip_address (string equality)
|
|
4. If no match and config.auto_create_assets is True → create new asset
|
|
|
|
Backfill rule:
|
|
When a Nessus scan finishes, any Vuln that previously listed `nessus`
|
|
in `sources` but is missing from this scan run has `nessus` removed.
|
|
If `sources` becomes empty AND status == open, mark as patched.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import List, Optional, Tuple
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.integrations.nessus_client import NessusClient
|
|
from app.models.asset import Asset, AssetSource, AssetStatus
|
|
from app.models.setting import Setting
|
|
from app.models.vulnerability import (
|
|
Vulnerability,
|
|
VulnerabilitySeverity,
|
|
VulnerabilityStatus,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SETTING_KEY = "nessus_config"
|
|
SOURCE_NAME = "nessus"
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# Config loader
|
|
# ---------------------------------------------------------------
|
|
def load_nessus_config(db: Session) -> Optional[dict]:
|
|
row = db.query(Setting).filter(Setting.key == SETTING_KEY).first()
|
|
if not row or not row.value:
|
|
return None
|
|
try:
|
|
return json.loads(row.value)
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("nessus_config is not valid JSON")
|
|
return None
|
|
|
|
|
|
def _build_client(config: dict) -> NessusClient:
|
|
return NessusClient(
|
|
base_url=config["base_url"],
|
|
access_key=config["access_key"],
|
|
secret_key=config["secret_key"],
|
|
verify_ssl=bool(config.get("verify_ssl", True)),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# Asset matching
|
|
# ---------------------------------------------------------------
|
|
def _find_or_create_asset(
|
|
db: Session,
|
|
host_info: dict,
|
|
auto_create: bool,
|
|
) -> Tuple[Optional[Asset], str]:
|
|
"""
|
|
Returns (asset, match_method) where match_method is one of
|
|
'uuid', 'hostname', 'ip', 'created', 'skipped'.
|
|
"""
|
|
host_uuid = host_info.get("host_uuid") or host_info.get("uuid")
|
|
hostname = (host_info.get("hostname") or host_info.get("host-fqdn") or "").strip()
|
|
ip = (
|
|
host_info.get("host_ip")
|
|
or host_info.get("host-ip")
|
|
or host_info.get("ip")
|
|
or ""
|
|
).strip()
|
|
|
|
# 1) Pinned UUID
|
|
if host_uuid:
|
|
a = db.query(Asset).filter(Asset.nessus_host_uuid == host_uuid).first()
|
|
if a:
|
|
return a, "uuid"
|
|
|
|
# 2) Hostname (case-insensitive)
|
|
if hostname:
|
|
a = (
|
|
db.query(Asset)
|
|
.filter(Asset.hostname.ilike(hostname))
|
|
.first()
|
|
)
|
|
if a:
|
|
if host_uuid and not a.nessus_host_uuid:
|
|
a.nessus_host_uuid = host_uuid
|
|
return a, "hostname"
|
|
|
|
# 3) IP
|
|
if ip:
|
|
a = db.query(Asset).filter(Asset.ip_address == ip).first()
|
|
if a:
|
|
if host_uuid and not a.nessus_host_uuid:
|
|
a.nessus_host_uuid = host_uuid
|
|
return a, "ip"
|
|
|
|
# 4) Auto-create if configured
|
|
if auto_create and (hostname or ip):
|
|
a = Asset(
|
|
hostname=hostname or ip,
|
|
ip_address=ip or None,
|
|
nessus_host_uuid=host_uuid,
|
|
source=AssetSource.MANUAL, # not wazuh-sourced
|
|
status=AssetStatus.ACTIVE,
|
|
)
|
|
db.add(a)
|
|
db.flush()
|
|
logger.info("Nessus sync: auto-created asset %s (%s)", a.hostname, a.ip_address)
|
|
return a, "created"
|
|
|
|
return None, "skipped"
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# Severity helpers
|
|
# ---------------------------------------------------------------
|
|
_SEV_RANK = {
|
|
VulnerabilitySeverity.none: 0,
|
|
VulnerabilitySeverity.low: 1,
|
|
VulnerabilitySeverity.medium: 2,
|
|
VulnerabilitySeverity.high: 3,
|
|
VulnerabilitySeverity.critical: 4,
|
|
}
|
|
|
|
|
|
def _max_severity(a: VulnerabilitySeverity, b: VulnerabilitySeverity) -> VulnerabilitySeverity:
|
|
return a if _SEV_RANK.get(a, 0) >= _SEV_RANK.get(b, 0) else b
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# Core sync
|
|
# ---------------------------------------------------------------
|
|
def run_nessus_sync(
|
|
db: Session,
|
|
scan_ids: Optional[List[int]] = None,
|
|
) -> dict:
|
|
"""
|
|
Pull findings from Nessus for the requested scans (or all configured
|
|
default_scan_ids) and merge them into VulnCheck.
|
|
|
|
Returns a stats dict suitable for logging + API response.
|
|
"""
|
|
config = load_nessus_config(db)
|
|
if not config:
|
|
raise RuntimeError("nessus_config not set in settings")
|
|
|
|
target_scan_ids: List[int] = (
|
|
scan_ids
|
|
or list(config.get("default_scan_ids") or [])
|
|
)
|
|
auto_create = bool(config.get("auto_create_assets", False))
|
|
|
|
stats = {
|
|
"scans_processed": 0,
|
|
"hosts_synced": 0,
|
|
"hosts_skipped": 0,
|
|
"vulns_created": 0,
|
|
"vulns_merged": 0,
|
|
"vulns_unchanged": 0,
|
|
"vulns_marked_patched": 0,
|
|
"unmatched_hosts": [],
|
|
"errors": [],
|
|
}
|
|
|
|
newly_created_vuln_ids: List[int] = []
|
|
|
|
with _build_client(config) as client:
|
|
# Auto-discover scans if no explicit IDs and no defaults
|
|
if not target_scan_ids:
|
|
scans = client.list_scans()
|
|
target_scan_ids = [int(s["id"]) for s in scans if s.get("id")]
|
|
|
|
for scan_id in target_scan_ids:
|
|
try:
|
|
stats["scans_processed"] += 1
|
|
scan_detail = client.get_scan(scan_id)
|
|
except Exception as e:
|
|
stats["errors"].append(f"scan {scan_id}: {e}")
|
|
logger.warning("Nessus sync: scan %s failed: %s", scan_id, e)
|
|
continue
|
|
|
|
hosts = scan_detail.get("hosts") or []
|
|
logger.info("Nessus sync: scan %s has %d hosts", scan_id, len(hosts))
|
|
|
|
for host_entry in hosts:
|
|
host_id = host_entry.get("host_id")
|
|
if host_id is None:
|
|
continue
|
|
|
|
# Pull rich host detail
|
|
try:
|
|
host = client.get_host(scan_id, host_id)
|
|
except Exception as e:
|
|
stats["errors"].append(f"scan {scan_id} host {host_id}: {e}")
|
|
continue
|
|
|
|
host_info = host.get("info") or {}
|
|
# `info` carries hostname/host-fqdn/host-ip; merge with summary entry
|
|
merged_info = {**host_entry, **host_info}
|
|
|
|
asset, match_method = _find_or_create_asset(db, merged_info, auto_create)
|
|
if asset is None:
|
|
stats["hosts_skipped"] += 1
|
|
stats["unmatched_hosts"].append({
|
|
"hostname": merged_info.get("hostname") or merged_info.get("host-fqdn"),
|
|
"ip": merged_info.get("host_ip") or merged_info.get("host-ip"),
|
|
"scan_id": scan_id,
|
|
})
|
|
continue
|
|
stats["hosts_synced"] += 1
|
|
|
|
# Track cves we observe on THIS asset in THIS sync run.
|
|
# Used for backfill (drop nessus source on disappeared findings).
|
|
seen_cves_for_asset: set = set()
|
|
|
|
for vuln_entry in host.get("vulnerabilities") or []:
|
|
plugin_id = vuln_entry.get("plugin_id")
|
|
severity_int = vuln_entry.get("severity") or 0
|
|
severity_label = NessusClient.plugin_severity_to_label(severity_int)
|
|
try:
|
|
severity = VulnerabilitySeverity(severity_label)
|
|
except ValueError:
|
|
severity = VulnerabilitySeverity.none
|
|
|
|
# Pull detailed plugin payload for CVE list + CVSS
|
|
try:
|
|
plugin_payload = client.get_plugin_output(scan_id, host_id, plugin_id)
|
|
except Exception as e:
|
|
logger.debug("Nessus plugin %s fetch failed: %s", plugin_id, e)
|
|
plugin_payload = {}
|
|
|
|
cve_list = NessusClient.extract_cves(plugin_payload) if plugin_payload else []
|
|
cvss = NessusClient.plugin_cvss(plugin_payload) if plugin_payload else None
|
|
|
|
if not cve_list:
|
|
# No CVE → pseudo-CVE so non-CVE findings (EOL/Compliance) are visible
|
|
cve_list = [f"NESSUS-PLUGIN-{plugin_id}"]
|
|
|
|
plugin_name = vuln_entry.get("plugin_name") or ""
|
|
|
|
for cve_id in cve_list:
|
|
seen_cves_for_asset.add(cve_id)
|
|
existing = (
|
|
db.query(Vulnerability)
|
|
.filter(
|
|
Vulnerability.cve_id == cve_id,
|
|
Vulnerability.asset_id == asset.id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if existing:
|
|
changed = existing.add_source(SOURCE_NAME)
|
|
# Severity escalation: take the higher of the two
|
|
new_sev = _max_severity(existing.severity, severity)
|
|
if new_sev != existing.severity:
|
|
existing.severity = new_sev
|
|
changed = True
|
|
if cvss and (existing.cvss_score or 0) < cvss:
|
|
existing.cvss_score = cvss
|
|
changed = True
|
|
if plugin_id and existing.nessus_plugin_id != str(plugin_id):
|
|
existing.nessus_plugin_id = str(plugin_id)
|
|
changed = True
|
|
# Backfill package_name / title if Wazuh didn't supply them.
|
|
# Especially relevant for older Nessus-only findings
|
|
# that were created before plugin_name was stored.
|
|
if plugin_name and not existing.package_name:
|
|
existing.package_name = plugin_name[:255]
|
|
changed = True
|
|
if plugin_name and not existing.title:
|
|
existing.title = plugin_name[:500]
|
|
changed = True
|
|
# If previously marked patched but Nessus sees it again → reopen
|
|
if existing.status == VulnerabilityStatus.patched:
|
|
existing.status = VulnerabilityStatus.open
|
|
existing.patched_at = None
|
|
changed = True
|
|
if changed:
|
|
stats["vulns_merged"] += 1
|
|
else:
|
|
stats["vulns_unchanged"] += 1
|
|
continue
|
|
|
|
# Create new (Nessus-only finding).
|
|
# Use a savepoint so an IntegrityError (duplicate race) only
|
|
# rolls back this single insert — not the entire sync transaction.
|
|
try:
|
|
new_vuln = Vulnerability(
|
|
cve_id=cve_id,
|
|
asset_id=asset.id,
|
|
cvss_score=cvss,
|
|
severity=severity,
|
|
status=VulnerabilityStatus.open,
|
|
title=plugin_name[:500] if plugin_name else None,
|
|
# plugin_name is the affected-software label Nessus uses
|
|
# ("Mozilla Firefox < 150.0.2", "KB5087539: ..."). Mirror
|
|
# it into package_name so the Vulns table's PACKAGE
|
|
# column isn't blank for Nessus-only findings.
|
|
package_name=plugin_name[:255] if plugin_name else None,
|
|
package_version=None,
|
|
detected_at=datetime.now(),
|
|
sources=json.dumps([SOURCE_NAME]),
|
|
first_detected_by=SOURCE_NAME,
|
|
nessus_plugin_id=str(plugin_id) if plugin_id else None,
|
|
)
|
|
with db.begin_nested():
|
|
db.add(new_vuln)
|
|
db.flush()
|
|
newly_created_vuln_ids.append(new_vuln.id)
|
|
stats["vulns_created"] += 1
|
|
except IntegrityError:
|
|
# Race: another request already inserted the same (cve_id, asset_id).
|
|
# Savepoint was rolled back; outer transaction is intact.
|
|
logger.debug(
|
|
"Nessus sync: duplicate insert race for %s on asset %s",
|
|
cve_id, asset.id,
|
|
)
|
|
continue
|
|
|
|
# Backfill: any vuln on this asset that PREVIOUSLY had `nessus`
|
|
# in sources but is not in seen_cves_for_asset → drop nessus.
|
|
# If sources empties out → patched.
|
|
stale_nessus_vulns = (
|
|
db.query(Vulnerability)
|
|
.filter(
|
|
Vulnerability.asset_id == asset.id,
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
Vulnerability.sources.contains('"nessus"'),
|
|
)
|
|
.all()
|
|
)
|
|
for v in stale_nessus_vulns:
|
|
if v.cve_id in seen_cves_for_asset:
|
|
continue
|
|
v.remove_source(SOURCE_NAME)
|
|
if not v.source_list:
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
stats["vulns_marked_patched"] += 1
|
|
|
|
asset.last_scan = datetime.now()
|
|
|
|
db.commit()
|
|
|
|
# Best-effort enrichment + notification (re-use Wazuh path)
|
|
if newly_created_vuln_ids:
|
|
try:
|
|
from app.services.email_service import dispatch_new_vuln_notifications
|
|
from app.services.enrichment_service import enrich_vulnerabilities
|
|
|
|
fresh = (
|
|
db.query(Vulnerability)
|
|
.filter(Vulnerability.id.in_(newly_created_vuln_ids))
|
|
.all()
|
|
)
|
|
try:
|
|
enrich_vulnerabilities(db, fresh)
|
|
except Exception as e:
|
|
logger.warning("Nessus sync: enrichment failed (non-fatal): %s", e)
|
|
|
|
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
|
|
except Exception as e:
|
|
logger.warning("Nessus sync: post-processing failed: %s", e)
|
|
|
|
logger.info(
|
|
"Nessus sync done: %d hosts, %d created, %d merged, %d patched, %d unmatched",
|
|
stats["hosts_synced"], stats["vulns_created"], stats["vulns_merged"],
|
|
stats["vulns_marked_patched"], len(stats["unmatched_hosts"]),
|
|
)
|
|
return stats
|