Two tester findings.
1. Windows Server 2016 wrongly flagged as EOL (HIGH) though it still
gets monthly CUs until 2027-01. Cause: is_eoas (active-support
ended 2022) was treated the same as is_eol → MEDIUM/critical-ish
finding even while security patches still flow.
New three-tier model in _build_eol_status():
- is_eol : security support ENDED (eolFrom past, no ESU) →
HIGH, cvss 9.0, title "EOL".
- is_eol_soon : eolFrom within EOL_SOON_DAYS (90) but future →
MEDIUM, cvss 5.5, title "EOL SOON (Nd)".
- is_eoas only: mainstream support ended, security patches still
flow → LOW, cvss 3.0, title "end-of-active-support".
Server 2016 (eoas 2022, eol 2027-01) now → LOW today, flips to
MEDIUM "EOL SOON" ~90d before Jan 2027, HIGH after.
_days_until() handles eolFrom given as a bool (endoflife quirk).
Both check_eol + check_os_eol share _build_eol_status now.
2. Nessus "Scan + Import" failed with "Server disconnected without
sending a response" on launch. That's the Nessus Essentials (free)
API-launch limitation — it drops the connection and the scan never
enters 'running'. The job now catches the launch NessusAPIError: if
the scan already has completed/imported results it imports those
(flagged via job.launch_warning + a clear stage message); otherwise
it fails with an actionable hint ("this edition may not allow
API-triggered scans — launch in the Nessus UI then Sync now").
eol-check loops (router + scheduler) updated to also surface
is_eol_soon findings.
374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""
|
|
In-memory job tracker for long-running CVSS-override runs.
|
|
|
|
The CISA Vulnrichment override against a full DB takes minutes (even
|
|
after the ZIP-snapshot speedup) — the browser's HTTP client times
|
|
out long before the work completes, leaving the user with a
|
|
'backend connection failed' modal and no way to know whether the
|
|
correction actually finished.
|
|
|
|
This module decouples the trigger from the work:
|
|
|
|
POST /override/vulnrichment/start → spawn thread, return job_id
|
|
GET /override/vulnrichment/status/{job_id} → progress + stats
|
|
|
|
State lives in a module-global dict so it survives the request but
|
|
not a backend restart. That's acceptable: a restart aborts any
|
|
in-flight job anyway, and the user just re-clicks.
|
|
|
|
Thread safety: dict assignment is atomic in CPython, and only the
|
|
worker thread mutates a given job's fields after creation. The
|
|
status reader returns whatever it sees — slightly stale reads are
|
|
fine for a 2-second poll loop.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import SessionLocal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# job_id (str) → state dict
|
|
_jobs: Dict[str, Dict[str, Any]] = {}
|
|
# Cap the in-memory ring so a misbehaving caller can't OOM us.
|
|
_MAX_JOBS = 50
|
|
|
|
|
|
def _prune_oldest() -> None:
|
|
if len(_jobs) <= _MAX_JOBS:
|
|
return
|
|
# Drop the oldest finished job. Finished = has finished_at.
|
|
finished = [
|
|
(jid, j.get("finished_at"))
|
|
for jid, j in _jobs.items()
|
|
if j.get("finished_at")
|
|
]
|
|
if not finished:
|
|
return
|
|
finished.sort(key=lambda t: t[1])
|
|
_jobs.pop(finished[0][0], None)
|
|
|
|
|
|
def start_vulnrichment_job(
|
|
user_id: int,
|
|
cve_ids: Optional[List[str]] = None,
|
|
asset_ids: Optional[List[int]] = None,
|
|
dry_run: bool = False,
|
|
) -> str:
|
|
"""Spawn a worker thread, return its job_id immediately."""
|
|
job_id = str(uuid.uuid4())
|
|
_jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"type": "vulnrichment",
|
|
"status": "queued", # queued | running | completed | failed
|
|
"user_id": user_id,
|
|
"dry_run": dry_run,
|
|
"started_at": datetime.now().isoformat(),
|
|
"finished_at": None,
|
|
"stage": "starting", # human-readable progress label
|
|
"total": 0,
|
|
"done": 0,
|
|
"updated": 0,
|
|
"checked": 0,
|
|
"not_found": 0,
|
|
"error": None,
|
|
"result": None,
|
|
}
|
|
_prune_oldest()
|
|
|
|
thread = threading.Thread(
|
|
target=_run_vulnrichment_job,
|
|
args=(job_id, cve_ids, asset_ids, dry_run),
|
|
daemon=True,
|
|
name=f"override-{job_id[:8]}",
|
|
)
|
|
thread.start()
|
|
return job_id
|
|
|
|
|
|
def get_job(job_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Return a snapshot of the job state, or None if unknown."""
|
|
job = _jobs.get(job_id)
|
|
if not job:
|
|
return None
|
|
# Return a copy so the caller can't mutate the live state.
|
|
return dict(job)
|
|
|
|
|
|
def list_jobs(limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""Most-recent-first list of jobs (for an admin overview)."""
|
|
snapshot = list(_jobs.values())
|
|
snapshot.sort(key=lambda j: j.get("started_at") or "", reverse=True)
|
|
return [dict(j) for j in snapshot[:limit]]
|
|
|
|
|
|
def _run_vulnrichment_job(
|
|
job_id: str,
|
|
cve_ids: Optional[List[str]],
|
|
asset_ids: Optional[List[int]],
|
|
dry_run: bool,
|
|
) -> None:
|
|
"""Worker thread body. Owns its own DB session."""
|
|
# Import lazily to avoid a circular import at module load.
|
|
from app.services.vuln_override_service import (
|
|
VulnOverrideService,
|
|
correct_vulnerability_scores,
|
|
)
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
|
|
job = _jobs[job_id]
|
|
job["status"] = "running"
|
|
db: Session = SessionLocal()
|
|
try:
|
|
# Stage 1: figure out target CVE set so progress has a known total
|
|
job["stage"] = "selecting target CVEs"
|
|
query = db.query(Vulnerability).filter(
|
|
Vulnerability.status == VulnerabilityStatus.open
|
|
)
|
|
if cve_ids:
|
|
query = query.filter(Vulnerability.cve_id.in_(cve_ids))
|
|
if asset_ids:
|
|
query = query.filter(Vulnerability.asset_id.in_(asset_ids))
|
|
vulns = query.all()
|
|
unique = sorted({
|
|
v.cve_id for v in vulns
|
|
if v.cve_id and not v.cve_id.startswith("NESSUS-")
|
|
})
|
|
job["total"] = len(unique)
|
|
|
|
if not unique:
|
|
job["status"] = "completed"
|
|
job["stage"] = "no CVEs to correct"
|
|
job["finished_at"] = datetime.now().isoformat()
|
|
job["result"] = {"message": "no real CVEs in scope", "updated": 0}
|
|
return
|
|
|
|
# Stage 2: fetch (ZIP snapshot for large batches, else per-CVE raw)
|
|
if len(unique) > VulnOverrideService._ZIP_FALLBACK_THRESHOLD:
|
|
job["stage"] = (
|
|
f"downloading vulnrichment ZIP snapshot (~249 MB) for {len(unique)} CVEs"
|
|
)
|
|
else:
|
|
job["stage"] = f"fetching {len(unique)} CVEs from github raw"
|
|
|
|
# Stage 3: apply overrides via existing helper (does the heavy lift)
|
|
# We don't get per-CVE callbacks from inside the helper, so we
|
|
# surface "done" only after it returns. The two stages above
|
|
# already tell the user what's currently happening.
|
|
result = correct_vulnerability_scores(
|
|
db,
|
|
cve_ids=unique,
|
|
asset_ids=asset_ids,
|
|
dry_run=dry_run,
|
|
)
|
|
job["done"] = job["total"]
|
|
job["checked"] = result.get("checked", 0)
|
|
job["updated"] = result.get("updated", 0)
|
|
job["not_found"] = result.get("not_found", 0)
|
|
job["result"] = result
|
|
job["status"] = "completed"
|
|
job["stage"] = (
|
|
f"done — {job['updated']} updated, "
|
|
f"{job['not_found']} not in vulnrichment feed"
|
|
)
|
|
except Exception as e:
|
|
logger.exception("override job %s failed", job_id)
|
|
job["status"] = "failed"
|
|
job["error"] = str(e)
|
|
job["stage"] = f"failed: {e}"
|
|
finally:
|
|
job["finished_at"] = datetime.now().isoformat()
|
|
db.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Nessus scan → poll → auto-import job (Plan F)
|
|
# ----------------------------------------------------------------------
|
|
|
|
# How long to wait for a launched scan to finish before giving up.
|
|
_NESSUS_POLL_INTERVAL_SEC = 15
|
|
_NESSUS_POLL_TIMEOUT_SEC = 3600 # 1h — large network scans can run long
|
|
|
|
|
|
def start_nessus_scan_import_job(
|
|
user_id: int,
|
|
asset_id: Optional[int] = None,
|
|
scan_id: Optional[int] = None,
|
|
alt_targets: Optional[List[str]] = None,
|
|
) -> str:
|
|
"""Spawn a worker: launch a Nessus scan, poll until done, then import.
|
|
|
|
asset_id optional — when set, the asset's IP is the alt_target and
|
|
only that scan's results for that host get re-synced. When omitted,
|
|
the configured scan runs against its own scope and the full sync
|
|
imports everything.
|
|
"""
|
|
job_id = str(uuid.uuid4())
|
|
_jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"type": "nessus_scan_import",
|
|
"status": "queued",
|
|
"user_id": user_id,
|
|
"asset_id": asset_id,
|
|
"scan_id": scan_id,
|
|
"started_at": datetime.now().isoformat(),
|
|
"finished_at": None,
|
|
"stage": "starting",
|
|
"nessus_status": None,
|
|
"polls": 0,
|
|
"error": None,
|
|
"result": None,
|
|
}
|
|
_prune_oldest()
|
|
thread = threading.Thread(
|
|
target=_run_nessus_scan_import_job,
|
|
args=(job_id, asset_id, scan_id, alt_targets),
|
|
daemon=True,
|
|
name=f"nessus-scan-{job_id[:8]}",
|
|
)
|
|
thread.start()
|
|
return job_id
|
|
|
|
|
|
def _run_nessus_scan_import_job(
|
|
job_id: str,
|
|
asset_id: Optional[int],
|
|
scan_id: Optional[int],
|
|
alt_targets: Optional[List[str]],
|
|
) -> None:
|
|
"""Worker: launch scan → poll status → run_nessus_sync on completion."""
|
|
import time as _time
|
|
|
|
from app.integrations.nessus_client import (
|
|
NessusClient, NessusAPIError, NessusAuthenticationError,
|
|
)
|
|
from app.services.nessus_sync import load_nessus_config, run_nessus_sync
|
|
from app.models.asset import Asset
|
|
|
|
job = _jobs[job_id]
|
|
job["status"] = "running"
|
|
db: Session = SessionLocal()
|
|
try:
|
|
config = load_nessus_config(db)
|
|
if not config:
|
|
raise RuntimeError("Nessus is not configured (Settings → Tenable Nessus)")
|
|
|
|
targets = alt_targets
|
|
if asset_id and not targets:
|
|
asset = db.query(Asset).filter(Asset.id == asset_id).first()
|
|
if not asset:
|
|
raise RuntimeError(f"Asset {asset_id} not found")
|
|
if not asset.ip_address:
|
|
raise RuntimeError(f"Asset '{asset.hostname}' has no IP — cannot target")
|
|
targets = [asset.ip_address]
|
|
|
|
with NessusClient(
|
|
base_url=config["base_url"],
|
|
access_key=config["access_key"],
|
|
secret_key=config["secret_key"],
|
|
verify_ssl=bool(config.get("verify_ssl", True)),
|
|
) as client:
|
|
# Resolve scan_id if not given.
|
|
resolved_scan_id = scan_id
|
|
if not resolved_scan_id:
|
|
defaults = config.get("default_scan_ids") or []
|
|
if defaults:
|
|
resolved_scan_id = int(defaults[0])
|
|
else:
|
|
available = client.list_scans()
|
|
if not available:
|
|
raise RuntimeError("No Nessus scans visible for these API keys")
|
|
resolved_scan_id = int(available[0]["id"])
|
|
job["scan_id"] = resolved_scan_id
|
|
|
|
job["stage"] = f"launching scan {resolved_scan_id}"
|
|
skip_poll = False
|
|
status = "pending"
|
|
from app.integrations.nessus_client import NessusAPIError as _NErr
|
|
try:
|
|
client.launch_scan(scan_id=resolved_scan_id, alt_targets=targets)
|
|
except _NErr as e:
|
|
# Nessus Essentials (free) often refuses API-triggered
|
|
# launches — drops the connection, scan never enters
|
|
# 'running'. If completed results already exist, import
|
|
# them (flagged); else fail with an actionable message.
|
|
job["launch_warning"] = str(e)
|
|
pre_status = ""
|
|
try:
|
|
pre_status = client.get_scan_status(resolved_scan_id)
|
|
except Exception:
|
|
pass
|
|
if pre_status in ("completed", "imported"):
|
|
job["stage"] = (
|
|
f"launch not accepted (Nessus Essentials?) — importing "
|
|
f"existing '{pre_status}' results for scan {resolved_scan_id}"
|
|
)
|
|
job["nessus_status"] = pre_status
|
|
status = pre_status
|
|
skip_poll = True
|
|
else:
|
|
raise RuntimeError(
|
|
f"Could not launch scan {resolved_scan_id} via the Nessus API "
|
|
f"(no existing results to import). This Nessus edition "
|
|
f"(likely Essentials/free) may not allow API-triggered scans — "
|
|
f"launch it in the Nessus UI, then use 'Sync now'. ({e})"
|
|
)
|
|
|
|
# Poll until terminal status (unless we're importing existing).
|
|
if not skip_poll:
|
|
deadline = _time.monotonic() + _NESSUS_POLL_TIMEOUT_SEC
|
|
terminal = {"completed", "imported", "canceled", "aborted", "empty"}
|
|
while _time.monotonic() < deadline:
|
|
_time.sleep(_NESSUS_POLL_INTERVAL_SEC)
|
|
job["polls"] += 1
|
|
try:
|
|
status = client.get_scan_status(resolved_scan_id)
|
|
except Exception as e:
|
|
logger.warning("nessus poll %s status error: %s", resolved_scan_id, e)
|
|
continue
|
|
job["nessus_status"] = status
|
|
job["stage"] = f"scan {resolved_scan_id} status: {status} (poll #{job['polls']})"
|
|
if status in terminal:
|
|
break
|
|
else:
|
|
raise RuntimeError(
|
|
f"Scan {resolved_scan_id} did not finish within "
|
|
f"{_NESSUS_POLL_TIMEOUT_SEC // 60} min (last status: {status})"
|
|
)
|
|
|
|
if status in ("canceled", "aborted"):
|
|
raise RuntimeError(f"Scan {resolved_scan_id} ended as '{status}' — no import")
|
|
|
|
# Scan done → import results (own session inside run_nessus_sync).
|
|
job["stage"] = f"importing results from scan {resolved_scan_id}"
|
|
sync_stats = run_nessus_sync(db, scan_ids=[resolved_scan_id])
|
|
db.commit()
|
|
job["result"] = sync_stats
|
|
job["status"] = "completed"
|
|
job["stage"] = (
|
|
f"done — {sync_stats.get('vulns_created', 0)} created, "
|
|
f"{sync_stats.get('vulns_merged', 0)} merged, "
|
|
f"{sync_stats.get('vulns_marked_patched', 0)} patched"
|
|
)
|
|
except (NessusAPIError, NessusAuthenticationError) as e:
|
|
logger.exception("nessus scan-import job %s API error", job_id)
|
|
job["status"] = "failed"
|
|
job["error"] = str(e)
|
|
job["stage"] = f"nessus API error: {e}"
|
|
except Exception as e:
|
|
logger.exception("nessus scan-import job %s failed", job_id)
|
|
job["status"] = "failed"
|
|
job["error"] = str(e)
|
|
job["stage"] = f"failed: {e}"
|
|
finally:
|
|
job["finished_at"] = datetime.now().isoformat()
|
|
db.close()
|