fix(nessus): an expired licence is a failed sync, not COMPLETED · 8 ERR

Field case: the Nessus licence expired and every scan answered
"Nessus 403 on /scans/157: {"error":"Expired license."}". Sync Health showed
an amber "8 ERR" card, the run was COMPLETED, and nobody got a mail, while
not one scan had been imported. The Wazuh API returning 500 goes red and
mails.

Cause: the Nessus sync never called fail_if_source_broken(); neither the
scheduler nor POST /nessus/sync asked whether the source itself was down.
Each scan error was caught and listed, and the run closed as completed. It
could not have caught it anyway: scans_processed was counted before
get_scan(), so it read 8 with 0 scans read, and "nothing synced + errors"
never matched.

scans_processed now counts scans actually read, and both callers pass it
to fail_if_source_broken(). Every scan failing is a failed run with the
usual mail (one per source per 24h); one unreadable scan among readable
ones stays completed with errors. The button returns 502 instead of
"Nessus sync completed"; HTTPException passes the catch-all so it is not
rewrapped as 500. The failure text says "scans" for Nessus.
This commit is contained in:
2026-10-09 11:04:27 +02:00
parent 81bafda04a
commit 25f706948a
6 changed files with 116 additions and 4 deletions
+8
View File
@@ -2059,6 +2059,14 @@ a manual sync hit the same 500.
*source*: authentication, `Server error '5xx'`, connection/DNS/SSL, "Wazuh
sync failed", missing config. A single agent's timeout is not in it.
**Nessus** follows the same rules, with scans in place of agents: the
scheduler and `POST /nessus/sync` call `fail_if_source_broken(run,
scans_processed, errors)`, and `scans_processed` counts only the scans that were
actually read. Until 2026-10 neither did. An expired licence (`Nessus 403 on
/scans/157: {"error":"Expired license."}` on every scan) showed as
`COMPLETED · 8 ERR` with an amber card and no mail, while not one scan had been
imported. One unreadable scan among readable ones stays `completed` with errors.
## The other half of the same source: a wrong INDEXER password
The rules above only see errors that reach a caller — and the indexer read
+7 -1
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.services.sync_run_service import record_sync_run
from app.services.sync_run_service import fail_if_source_broken, record_sync_run
from app.database import get_db
from app.integrations.nessus_client import (
NessusAPIError,
@@ -156,6 +156,10 @@ def trigger_nessus_sync(
with record_sync_run("nessus", "manual") as run:
stats = run_nessus_sync(db, scan_ids=payload.scan_ids)
run.stats.update(stats)
fail_if_source_broken(run, stats["scans_processed"], stats["errors"])
if run.error:
# The button must go red instead of "Nessus sync completed".
raise HTTPException(status_code=502, detail=run.error)
return {"message": "Nessus sync completed", **stats}
except RuntimeError as e:
# config missing
@@ -174,6 +178,8 @@ def trigger_nessus_sync(
raise HTTPException(504, f"Nessus request timed out: {e}")
except NessusAPIError as e:
raise HTTPException(502, f"Nessus API error: {e}")
except HTTPException:
raise
except Exception as e:
logger.exception("Nessus sync crashed")
raise HTTPException(500, f"Nessus sync failed: {e}")
+1
View File
@@ -66,6 +66,7 @@ def execute_scheduled_scan(schedule_id: int):
with record_sync_run("nessus", "scheduled") as run:
stats = run_nessus_sync(db)
run.stats.update(stats)
fail_if_source_broken(run, stats["scans_processed"], stats["errors"])
logger.info(f"Scheduled Nessus sync done: {stats}")
except Exception as e:
logger.error(f"Scheduled Nessus sync failed: {e}")
+3 -1
View File
@@ -427,12 +427,14 @@ def run_nessus_sync(
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
# Counts scans actually read: 0 with errors (expired licence,
# every scan 403) is a failed run, see fail_if_source_broken.
stats["scans_processed"] += 1
hosts = scan_detail.get("hosts") or []
logger.info("Nessus sync: scan %s has %d hosts", scan_id, len(hosts))
+3 -2
View File
@@ -119,13 +119,14 @@ def fail_if_source_broken(handle: SyncRunHandle, triggered: int, errors: list,
if phase_failures:
handle.fail("; ".join(phase_failures))
return
unit = "scans" if handle.source == "nessus" else "agents"
if triggered == 0 and errors:
handle.fail(f"all {len(errors)} agents failed — {errors[0]}")
handle.fail(f"all {len(errors)} {unit} failed — {errors[0]}")
return
hit = source_failure(errors)
if hit:
handle.fail(f"{SOURCES.get(handle.source, handle.source)} API failing mid-run "
f"({triggered} agents synced, {len(errors)} errors) — {hit}")
f"({triggered} {unit} synced, {len(errors)} errors) — {hit}")
def fail_if_nothing_synced(handle: SyncRunHandle, triggered: int, errors: list) -> None:
+94
View File
@@ -606,3 +606,97 @@ def test_indexer_answering_nothing_for_everyone_is_a_failed_run(sql, monkeypatch
assert "every agent returned 0 CVEs" in run["error_message"]
assert len(sent) == 1
s.close()
# ---- 2026-10-08: Nessus licence expired, every scan answered 403 ----------
#
# run_nessus_sync caught each "Nessus 403 on /scans/157: {"error":"Expired
# license."}" per scan and returned; nobody asked whether the source itself
# was broken. Ledger: COMPLETED · 8 ERR, amber card, no mail — while not one
# scan had been read.
def _nessus_scheduled(sql, monkeypatch, get_scan):
import app.scheduler as sched
from app.models.scan_schedule import ScanSchedule, ScheduleInterval
from app.models.setting import Setting
from app.services import email_service, nessus_sync
monkeypatch.setattr(sched, "SessionLocal", sql)
sent = []
monkeypatch.setattr(email_service, "send_email",
lambda db, to, subject, body: (sent.append((to, subject, body)) or (True, "OK")))
s = sql()
s.add(Setting(key=svc.SETTING_RECIPIENTS, value="ops@example.com"))
s.add(Setting(key=nessus_sync.SETTING_KEY, value="x")) # configured, for sync_health
s.add(ScanSchedule(name="nessus", interval=ScheduleInterval.EVERY_HOUR, scanner_type="nessus", enabled=True))
s.commit()
schedule_id = s.query(ScanSchedule).first().id
s.close()
class Client:
def __enter__(self): return self
def __exit__(self, *a): return False
def get_scan(self, scan_id): return answer(scan_id)
answer = get_scan
monkeypatch.setattr(nessus_sync, "_build_client", lambda config: Client())
monkeypatch.setattr(nessus_sync, "load_nessus_config",
lambda db: {"default_scan_ids": [157, 165, 170]})
sched.execute_scheduled_scan(schedule_id)
s = sql()
(run,) = svc.list_runs(s, 10)
health = {h["source"]: h["state"] for h in svc.sync_health(s)}["nessus"]
s.close()
return run, health, sent
def test_nessus_expired_licence_on_every_scan_is_a_failed_run(sql, monkeypatch):
from app.integrations.nessus_client import NessusAPIError
def expired(scan_id):
raise NessusAPIError(f'Nessus 403 on /scans/{scan_id}: {{"error":"Expired license."}}')
run, health, sent = _nessus_scheduled(sql, monkeypatch, expired)
assert run["status"] == "failed", run
assert "Expired license" in run["error_message"]
assert run["stats"]["scans_processed"] == 0
assert health == "failed"
assert len(sent) == 1 and "Nessus" in sent[0][1]
def test_nessus_one_unreadable_scan_stays_completed_with_errors(sql, monkeypatch):
from app.integrations.nessus_client import NessusAPIError
def one_gone(scan_id):
if scan_id == 165:
raise NessusAPIError("Nessus 404 on /scans/165")
return {"hosts": []}
run, health, sent = _nessus_scheduled(sql, monkeypatch, one_gone)
assert run["status"] == "completed", run
assert run["stats"]["scans_processed"] == 2
assert health == "warning"
assert sent == []
def test_nessus_button_with_expired_licence_goes_red(sql, monkeypatch):
from fastapi import HTTPException
from app.integrations.nessus_client import NessusAPIError
from app.routers import nessus as nessus_router
from app.services import nessus_sync
class Client:
def __enter__(self): return self
def __exit__(self, *a): return False
def get_scan(self, scan_id):
raise NessusAPIError(f'Nessus 403 on /scans/{scan_id}: {{"error":"Expired license."}}')
monkeypatch.setattr(nessus_sync, "_build_client", lambda config: Client())
monkeypatch.setattr(nessus_sync, "load_nessus_config", lambda db: {"default_scan_ids": [157]})
s = sql()
with pytest.raises(HTTPException) as e:
nessus_router.trigger_nessus_sync(nessus_router.NessusSyncRequest(), db=s, current_user=None)
assert e.value.status_code == 502 and "Expired license" in e.value.detail
(run,) = svc.list_runs(s, 10)
assert run["status"] == "failed"
s.close()