Files
vulncheck/app/integrations/nessus_client.py
T
vulncheck 9cb9051854 fix(cvss): clamp out-of-range scanner values to None at extraction
Tester saw cvss_score=-1 in the DB plus cpr_score=-9.3 in the UI for
some CVEs. CPR math is purely multiplicative, so a negative CPR can
only come from a negative CVSS. Source confirmed: Wazuh-Indexer emits
-1 as a placeholder when it could not score a package, and we passed
it through to the DB unchanged. The Nessus plugin extractor had the
same gap.

Both clients now validate the extracted base_score with a
'0.0 <= f <= 10.0' guard and return None on anything outside the
CVSSv3 spec range. Downstream CPR + priority calculations already
handle None correctly (calculate_cpr_score returns None when either
cvss_score or epss_score is missing). No DB clean-up required —
new syncs overwrite the bad values.

For existing -1 rows, ops can wipe them once:
    UPDATE vulnerabilities SET cvss_score = NULL
    WHERE cvss_score < 0 OR cvss_score > 10;
2026-05-18 11:29:48 +02:00

529 lines
20 KiB
Python

"""
Tenable Nessus API Client
Imports vulnerability findings from Nessus Professional / Essentials /
Tenable.io scans. Nessus uses static API keys (no token refresh), so the
client is simpler than the Wazuh equivalent but still implements:
- Retry with exponential backoff on transient errors
- Strict 429 / rate-limit handling
- Optional SSL verification (default on)
- Per-host iteration helpers
API reference: https://docs.tenable.com/nessus/Content/API.htm
Generate API keys in Nessus UI: My Account → API Keys → Generate.
"""
from __future__ import annotations
import logging
import re
import time
from typing import Any, Dict, List, Optional
import httpx
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
logger = logging.getLogger(__name__)
# Regex fallback used when a Nessus plugin lists no CVE in its `cve` field
# but mentions CVE IDs in the plugin_output / description text.
_CVE_REGEX = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE)
class NessusAPIError(Exception):
"""Base error for Nessus API problems."""
class NessusAuthenticationError(NessusAPIError):
"""Bad API keys or expired."""
class NessusRateLimitError(NessusAPIError):
"""Nessus returned 429."""
class NessusClient:
"""
Thin wrapper around the Nessus REST API.
Auth: `X-ApiKeys: accessKey=<a>; secretKey=<s>` header on every call.
No login/logout round-trip required.
"""
def __init__(
self,
base_url: str,
access_key: str,
secret_key: str,
verify_ssl: bool = True,
timeout: float = 30.0,
):
if not base_url or not access_key or not secret_key:
raise ValueError("Nessus base_url + access_key + secret_key required")
self.base_url = base_url.rstrip("/")
self.access_key = access_key
self.secret_key = secret_key
self.verify_ssl = verify_ssl
self.client = httpx.Client(
base_url=self.base_url,
verify=verify_ssl,
timeout=timeout,
headers={
"X-ApiKeys": f"accessKey={access_key}; secretKey={secret_key}",
"Accept": "application/json",
},
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
)
# ------------------------------------------------------------------
# Context manager support so callers can `with NessusClient(...) as c:`
# and trust connections get torn down even on exception.
# ------------------------------------------------------------------
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def close(self):
try:
self.client.close()
except Exception:
pass
# ------------------------------------------------------------------
# Core request helper with retry
# ------------------------------------------------------------------
@retry(
retry=retry_if_exception_type(
(httpx.TimeoutException, httpx.ConnectError, NessusRateLimitError)
),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
reraise=True,
)
def _post(self, path: str, body: Optional[dict] = None) -> Any:
try:
resp = self.client.post(path, json=body or {})
except httpx.HTTPError as e:
logger.warning("Nessus POST error %s: %s", path, e)
raise
if resp.status_code == 401:
raise NessusAuthenticationError(
"Nessus API key rejected (HTTP 401). Regenerate keys in Nessus UI."
)
if resp.status_code == 429:
retry_after = resp.headers.get("Retry-After", "?")
logger.warning("Nessus 429 rate limited (Retry-After=%s)", retry_after)
raise NessusRateLimitError("Nessus 429")
if resp.status_code == 404:
raise NessusAPIError(f"Nessus 404 on POST {path}")
if not 200 <= resp.status_code < 300:
raise NessusAPIError(
f"Nessus {resp.status_code} on POST {path}: {resp.text[:200]}"
)
if resp.content:
try:
return resp.json()
except ValueError:
return {}
return {}
@retry(
retry=retry_if_exception_type(
(httpx.TimeoutException, httpx.ConnectError, NessusRateLimitError)
),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
reraise=True,
)
def _get(self, path: str, params: Optional[dict] = None) -> Any:
try:
resp = self.client.get(path, params=params or {})
except httpx.HTTPError as e:
logger.warning("Nessus request error %s: %s", path, e)
raise
if resp.status_code == 401:
raise NessusAuthenticationError(
"Nessus API key rejected (HTTP 401). Regenerate keys in Nessus UI."
)
if resp.status_code == 429:
# Honor Retry-After when present
retry_after = resp.headers.get("Retry-After", "?")
logger.warning("Nessus 429 rate limited (Retry-After=%s)", retry_after)
raise NessusRateLimitError("Nessus 429")
if resp.status_code == 404:
raise NessusAPIError(f"Nessus 404 on {path}")
if not 200 <= resp.status_code < 300:
raise NessusAPIError(
f"Nessus {resp.status_code} on {path}: {resp.text[:200]}"
)
try:
return resp.json()
except ValueError:
raise NessusAPIError(f"Nessus non-JSON response on {path}")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def ping(self) -> dict:
"""Quick auth + connectivity probe used by the admin Test button."""
data = self._get("/server/properties")
return {
"server_version": data.get("server_version"),
"feed": data.get("feed"),
"loaded_plugin_set": data.get("loaded_plugin_set"),
}
def list_scans(self) -> List[dict]:
"""Return Nessus scans visible to the API keys."""
data = self._get("/scans")
return data.get("scans") or []
def launch_scan(
self,
scan_id: int,
alt_targets: Optional[List[str]] = None,
) -> dict:
"""
Launch a Nessus scan.
Pass ``alt_targets`` to override the scan's configured targets and
limit it to specific IPs / hostnames (e.g. for a post-patch rescan
of a single asset). Nessus returns ``{"scan_uuid": ""}`` on success.
``POST /scans/{scan_id}/launch`` body: ``{"alt_targets": ["ip1", "ip2"]}``
Quirk handling: Nessus 10.x (especially Essentials) sometimes accepts
the launch and then drops the TCP connection without sending a
response — httpx surfaces this as ``RemoteProtocolError``. When that
happens we poll the scan's status; if it flipped to ``running`` /
``pending`` the launch did succeed and we return a synthetic OK.
"""
body: Dict[str, Any] = {}
if alt_targets:
body["alt_targets"] = alt_targets
logger.info(
"Launching Nessus scan %s (alt_targets=%s)", scan_id, alt_targets
)
# Nessus Essentials is known to drop the connection on launch POSTs
# carrying `alt_targets` — the launch is silently rejected without a
# proper HTTP error. We try once with alt_targets; if the connection
# drops AND the scan didn't actually start, we surface a helpful
# error that points to the Essentials limitation.
attempted_alt_targets = bool(alt_targets)
def _verify_started() -> Optional[dict]:
"""GET the scan and return a synthetic OK if it's running now."""
time.sleep(2)
try:
scan = self.get_scan(scan_id)
info = scan.get("info") or {}
status = str(info.get("status", "")).lower()
if status in ("running", "pending", "resuming"):
logger.info(
"Nessus scan %s is %s — launch succeeded despite dropped connection.",
scan_id, status,
)
return {
"scan_uuid": info.get("uuid"),
"status": status,
"verified_by_status_check": True,
}
logger.warning(
"Nessus scan %s status is '%s' after dropped launch — launch did not take effect.",
scan_id, status,
)
except Exception as inner:
logger.warning(
"Nessus status verify after dropped launch failed: %s", inner
)
return None
try:
return self._post(f"/scans/{scan_id}/launch", body)
except httpx.RemoteProtocolError as e:
logger.warning(
"Nessus launch %s: connection dropped without response "
"(likely already accepted): %s — verifying via status check.",
scan_id, e,
)
verified = _verify_started()
if verified is not None:
return verified
# Connection dropped AND scan didn't start. On Essentials this
# almost always means alt_targets was the culprit. Hint loudly.
if attempted_alt_targets:
raise NessusAPIError(
f"Nessus refused the launch of scan {scan_id} with "
f"alt_targets={alt_targets} (the connection was dropped "
"without an HTTP error). This is a known limitation on "
"Nessus Essentials — targeted host scans via the API "
"aren't supported by the free edition. Workarounds: "
"(1) launch the scan manually from the Nessus UI, then "
"click \"Sync Data (Nessus)\" in VulnCheck to import "
"results, or (2) upgrade to Nessus Professional / "
"Tenable.io which support API-driven launches."
) from e
raise NessusAPIError(
f"Nessus dropped the connection on launch of scan {scan_id} "
"and the scan did not enter a running state. Check the scan "
"configuration in Nessus, or try launching it from the UI."
) from e
def get_scan(self, scan_id: int, history_id: Optional[int] = None) -> dict:
"""Return scan details — includes hosts[] with host_id, hostname, vulnerability counts."""
params = {"history_id": history_id} if history_id else None
return self._get(f"/scans/{scan_id}", params=params)
def get_scan_hosts(self, scan_id: int, history_id: Optional[int] = None) -> List[dict]:
"""Convenience: return just the hosts list from a scan detail call."""
scan = self.get_scan(scan_id, history_id)
return scan.get("hosts") or []
def get_host(
self,
scan_id: int,
host_id: int,
history_id: Optional[int] = None,
) -> dict:
"""
Return host detail with `vulnerabilities[]` (plugin-id list) and `info` block.
Each vulnerability item has: plugin_id, plugin_name, severity, count, vuln_index, hostname.
"""
params = {"history_id": history_id} if history_id else None
return self._get(f"/scans/{scan_id}/hosts/{host_id}", params=params)
def get_plugin_output(
self,
scan_id: int,
host_id: int,
plugin_id: int,
history_id: Optional[int] = None,
) -> dict:
"""
Return per-plugin findings on a host. Used when we need the CVE list
and plugin metadata (description / solution / risk_factor) — not
cheap, so callers should batch carefully.
"""
params = {"history_id": history_id} if history_id else None
return self._get(
f"/scans/{scan_id}/hosts/{host_id}/plugins/{plugin_id}",
params=params,
)
# ------------------------------------------------------------------
# CVE extraction
# ------------------------------------------------------------------
@staticmethod
def extract_cves(plugin_payload: dict) -> List[str]:
"""
Pull CVE IDs from a plugin payload. Modern Nessus puts CVEs under
``pluginattributes.ref_information.ref[?].values.value`` where the
ref entry has ``name == "cve"``. Older versions had a flat
``pluginattributes.cve`` field. We check both, then fall back to a
regex over description / outputs as last resort.
"""
cves: set = set()
info = plugin_payload.get("info") or {}
attrs = (
info.get("plugindescription", {})
.get("pluginattributes", {})
)
# Path 1 (legacy): flat `cve` field directly on pluginattributes.
cve_field = attrs.get("cve")
if isinstance(cve_field, list):
for entry in cve_field:
if isinstance(entry, str):
cves.add(entry.upper())
elif isinstance(cve_field, str):
cves.add(cve_field.upper())
# Path 2 (modern): ref_information.ref[?].values.value where name=="cve".
# Layout (observed on Nessus 10.x):
# ref_information.ref = [
# {"name": "cve", "values": {"value": ["CVE-2026-8090", ...]}},
# {"name": "bid", "values": {"value": [...]}}, ...
# ]
ref_info = attrs.get("ref_information") or {}
ref_list = ref_info.get("ref") if isinstance(ref_info, dict) else None
if isinstance(ref_list, list):
for ref in ref_list:
if not isinstance(ref, dict):
continue
if str(ref.get("name", "")).lower() != "cve":
continue
values = ref.get("values") or {}
value_field = values.get("value") if isinstance(values, dict) else None
if isinstance(value_field, list):
for v in value_field:
if isinstance(v, str):
cves.add(v.upper())
elif isinstance(value_field, str):
cves.add(value_field.upper())
# Path 3 (last resort): regex over description and outputs.
text_blobs = []
desc = attrs.get("description")
if isinstance(desc, str):
text_blobs.append(desc)
for output in plugin_payload.get("outputs") or []:
ot = output.get("plugin_output")
if isinstance(ot, str):
text_blobs.append(ot)
for blob in text_blobs:
for match in _CVE_REGEX.findall(blob):
cves.add(match.upper())
return sorted(cves)
# ------------------------------------------------------------------
# Plugin-payload helpers
# ------------------------------------------------------------------
@staticmethod
def plugin_severity_to_label(severity: int) -> str:
"""
Nessus severity int → VulnerabilitySeverity name.
Nessus scale: 0=Info, 1=Low, 2=Medium, 3=High, 4=Critical.
"""
mapping = {0: "none", 1: "low", 2: "medium", 3: "high", 4: "critical"}
return mapping.get(severity, "none")
# ------------------------------------------------------------------
# Pluginattributes accessor (used by all extractors below)
# ------------------------------------------------------------------
@staticmethod
def _plugin_attrs(plugin_payload: dict) -> dict:
return (
(plugin_payload.get("info") or {})
.get("plugindescription", {})
.get("pluginattributes", {})
)
@staticmethod
def plugin_vpr_score(plugin_payload: dict) -> Optional[float]:
"""
Tenable VPR (Vulnerability Priority Rating, 0-10) from the plugin.
Modern Nessus puts ``vpr_score`` directly on ``pluginattributes``.
Some payloads have it as a string ("6.7"), some as a number.
"""
attrs = NessusClient._plugin_attrs(plugin_payload)
v = attrs.get("vpr_score")
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
@staticmethod
def plugin_exploit_available(plugin_payload: dict) -> Optional[bool]:
"""
Returns True/False if Nessus knows of a public exploit, or None when
the field isn't present. Looks on pluginattributes directly and under
``vuln_information`` (location varies between Nessus versions).
"""
attrs = NessusClient._plugin_attrs(plugin_payload)
candidates = [attrs.get("exploit_available")]
vuln_info = attrs.get("vuln_information") or {}
if isinstance(vuln_info, dict):
candidates.append(vuln_info.get("exploit_available"))
for v in candidates:
if v is None:
continue
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().lower() == "true"
return None
@staticmethod
def plugin_exploit_maturity(plugin_payload: dict) -> Optional[str]:
"""``exploit_code_maturity`` from Nessus: Unproven | Proof-of-Concept |
Functional | High (matches our existing exploit_maturity column)."""
attrs = NessusClient._plugin_attrs(plugin_payload)
v = attrs.get("exploit_code_maturity")
if isinstance(v, str) and v.strip():
return v.strip()
return None
@staticmethod
def plugin_description(plugin_payload: dict) -> Optional[str]:
"""Plain-text description (`description` on pluginattributes)."""
attrs = NessusClient._plugin_attrs(plugin_payload)
v = attrs.get("description")
return v if isinstance(v, str) and v.strip() else None
@staticmethod
def plugin_solution(plugin_payload: dict) -> Optional[str]:
"""Vendor solution / remediation guidance from the Nessus plugin."""
attrs = NessusClient._plugin_attrs(plugin_payload)
v = attrs.get("solution")
return v if isinstance(v, str) and v.strip() else None
@staticmethod
def plugin_see_also(plugin_payload: dict) -> List[str]:
"""``see_also`` URL list — vendor advisories, MITRE links, etc."""
attrs = NessusClient._plugin_attrs(plugin_payload)
v = attrs.get("see_also")
if isinstance(v, list):
return [str(u) for u in v if isinstance(u, str)]
if isinstance(v, str):
# Some plugins return newline-separated URLs in a single string.
return [line.strip() for line in v.splitlines() if line.strip()]
return []
@staticmethod
def plugin_cvss(plugin_payload: dict) -> Optional[float]:
"""
Best-effort CVSS extraction — prefers v3.x base score over v2.
Modern Nessus (10.x+) groups CVSS fields under a ``risk_information``
sub-dict on ``pluginattributes``. Older versions had them flat on
``pluginattributes`` directly. Check both shapes so we don't miss
the score on either format.
"""
attrs = NessusClient._plugin_attrs(plugin_payload)
risk_info = attrs.get("risk_information") or {}
if not isinstance(risk_info, dict):
risk_info = {}
# v3 first, then v2 — prefer the modern score.
for key in ("cvss3_base_score", "cvss_base_score"):
# Try the modern (risk_information) layout first, then flat fallback.
for source in (risk_info, attrs):
v = source.get(key)
if v is None:
continue
try:
f = float(v)
except (TypeError, ValueError):
continue
# Clamp to the CVSS spec range. Some plugins emit junk
# like -1 or 99 for un-scored vulns; treat those as
# "no score" so the downstream CPR math stays non-
# negative and within [0, 100].
if 0.0 <= f <= 10.0:
return f
return None