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.
A manual create under Assets writes ASSET_CREATED, a sync-created asset
only reached the backend log ("IGEL sync: auto-created asset TX-11777"):
searching the audit log for "created" found users, never assets.
Every sync create site now calls asset_lifecycle.created, the create
counterpart of rename: one ASSET_CREATED entry per new asset, "Asset
created by <source> sync: <hostname>", new_value JSON with hostname and
source. Covered: Wazuh agents and manager nodes, Nessus, vCenter, IGEL,
Intune, Netdisco and the container imports (source = scanner). Defender
never creates. Assets created before this stay without an entry.
Nach c73d062, 40e9c29, 0936cf4 und a8961ec fuehrte ein Name an vier Stellen
trotzdem auf das Asset eines anderen Geraets — Findings am falschen Host:
1. `hostname.ilike(name)` ohne Escaping. `_` ist in LIKE ein Platzhalter,
"PC_LAB" fand "PC-LAB". Betraf Nessus, Netdisco, vCenter, IGEL, Intune
(match_by_hostname) und Defender.
2. Mehrere gleichnamige Assets ohne Pin der Quelle: die Stufe nahm das mit
der kleinsten id. Zwei "srv01" in zwei Domaenen, Nessus scannt das zweite
und schreibt aufs erste; Netdisco vertauscht beide dauerhaft.
3. Defender dasselbe ganz ohne Schutz. Seit Intune gleichnamige Geraete als
eigene Assets fuehrt, gibt es die Namensvettern dort wirklich; eine
Maschine ohne aadDeviceId landete auf dem erstbesten.
4. Der Wazuh-Cluster-Node (Werksname "node01") uebernahm ueber den Namen das
Asset eines echten Agents und ueberschrieb dessen Agent-ID — Flip-Flop mit
dem Agent-Sync bei jedem Lauf.
Dazu wies das manuelle Anlegen einen vergebenen Hostnamen mit 400 ab, das
zweite Geraet liess sich nicht fuehren — dieselbe Annahme, die c73d062 fuer
die IP-Adresse schon gestrichen hat.
Die Ursache war in allen Faellen dieselbe Annahme: bleibt nach dem Pin-Guard
mehr als ein Kandidat, sei der erste schon richtig. services/asset_matching
.pick_one entscheidet jetzt: ein Kandidat ist der Treffer, bei mehreren die
Adresse, die die Quelle meldet; entscheidet sie nicht, gibt es keinen Treffer
und die Quelle legt ein eigenes Asset an. Eine sichtbare Dublette statt einer
Vermischung. Wazuh-Agent-Stufe und Defender gehen durch dieselbe Regel; der
Re-Image-Fall bei Defender (ein Asset, zwei Maschinen) und die
Neuregistrierung bei Wazuh bleiben unberuehrt, dort gibt es nur einen
Kandidaten. Der Cluster-Node nimmt nur Assets ohne Agent-ID.
Bestehende vermischte Zeilen loest das nicht auf; tools/find_merged_assets.py
bleibt der Weg dorthin.
Dieselbe Annahme wie bei der geteilten IP-Adresse (c73d062), eine Stufe
hoeher: jede Sync-Match-Kette faellt nach ihrem stabilen Identifier auf
`Asset.hostname.ilike(name)` zurueck, und diese Stufe war bedingungslos.
Zwei APC-PDUs heissen ab Werk beide "RackPDU" — ein Name, den niemand
aendert, bei manchen Geraeten mangels Zugriff auch gar nicht aendern kann.
Die erste legt das Asset an und pinnt ihre netdisco_device_ip darauf, die
zweite findet es ueber den Namen, ueberschreibt Pin und Adresse. Im Inventar
steht ein Geraet, und welche der beiden Adressen es fuehrt, entscheidet die
Reihenfolge des Laufs.
Ein Name ist kein Identifikator. services/asset_matching.match_by_hostname
laesst ihn nur noch matchen, wenn kein anderes Geraet derselben Quelle den
Namen bereits belegt: traegt ein gleichnamiges Asset schon einen anderen
netdisco_device_ip / nessus_host_uuid / vmware_uuid / igel_unit_id, ist es
ein Namensvetter und kein Treffer. Ein Asset ohne Pin bleibt matchbar —
sonst stirbt der Fall, fuer den die Stufe existiert: Wazuh legt einen Host
an, Netdisco findet dasselbe Geraet ueber den Namen. Alle vier Ketten
gehen jetzt durch dieselbe Regel, inklusive der FQDN-Praefix-Stufe.
Anders als beim IP-Fall loesen sich bestehende Zeilen von selbst auf: das
zweite Geraet faellt beim naechsten Lauf durch die Namensstufe und bekommt
seine eigene. Rueckwirkend geteilt wird die Historie nicht.
Ein Stack ist der Gegenfall und bleibt EIN Asset. VSF-Mitglieder melden
sich je Chassis unter demselben Namen und derselben Adresse — ein logisches
Geraet, eine Firmware, ein CVE-Satz; drei Assets waeren dreifache Findings
und eine falsche Zahl im Dashboard. Was fehlte: der Lauf schrieb nur die
zuletzt gemeldete Seriennummer ans Asset. Der Netdisco-Sync sammelt sie
jetzt je Asset ein ("serials SG01TQ, SG02HY, SG03J6"), damit am Geraet
steht, aus wie vielen Chassis es besteht.
Nebeneffekt, bewusst in Kauf genommen: ein Switch, der seinen Namen behaelt
und eine neue Management-Adresse bekommt, forkt auf eine zweite Zeile, die
alte faellt per Reconcile auf INACTIVE. Eine sichtbare Dublette ist besser
als zwei Geraete auf einer asset_id, die sich Status und Hostnamen
gegenseitig ueberschreiben.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jede Sync-Match-Kette (Netdisco, Nessus, vCenter, IGEL) endete bedingungslos
auf `Asset.ip_address == ip`. Ein abgeschalteter Wazuh-Agent und ein UniFi AP
teilten sich eine Adresse: der Netdisco-Sync fand darueber das Wazuh-Asset,
pinnte seine netdisco_device_ip darauf und beide Geraete hingen an derselben
Zeile. Ab da benannte Netdisco sie um, Wazuh setzte sie per Keepalive-Urteil
auf INACTIVE, Netdisco wieder auf ACTIVE — Deaktivierung und Reaktivierung
derselben asset_id bei jedem Lauf, dauerhaft.
Eine Adresse ist kein Identifikator. Gastnetze, VPN-Pools, VLAN-Segmente und
recycelte DHCP-Leases vergeben dieselbe IP mehrfach; ein Bestand, in dem das
nicht vorkommen darf, ist eine Annahme ueber das Netz des Kunden, keine ueber
unsere Daten. services/asset_matching.match_by_ip laesst die Adresse nur noch
matchen, wenn sie eindeutig ist und kein Name widerspricht. Der Fall, fuer den
die Stufe existiert — ein ESXi, den vCenter nur unter seiner Adresse fuehrt und
den Nessus danach scannt — traegt auf einer Seite gar keinen Namen und
konsolidiert weiter.
Dieselbe Annahme steckte im manuellen Anlegen: create_asset wies eine bereits
vergebene IP mit 400 ab, der Operator konnte die betroffenen Systeme also nicht
einmal von Hand fuehren. Der Check faellt weg.
Der Fix verhindert neue Merges, loest aber keine bestehende Zeile auf — dort
haelt jede Quelle inzwischen ihren eigenen Pin. tools/find_merged_assets.py
findet die Kandidaten am Status-Flip-Flop im Audit-Log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M365 Apps fixes are not published to NVD and Wazuh's detector never sees
them: the single public record of "CVE-X is fixed in build Y" is the
Microsoft Learn security-updates page this check parses. The finding cited
NVD, CVE.org, Exploit-DB and the EUVD — catalogues that either know nothing
about the M365 fix or, for a fresh CVE, hold no record at all — but not the
page the detection actually came from. The operator could read the claim and
had no way to check it.
Every M365 finding now carries that page under External References, deep-
linked to the monthly section that lists its CVE.
- The anchor is read off the page, never guessed. _parse_security_page
collects the real h2/h3 ids and keeps the one that matches the date
heading, trying both the padded and the unpadded day (Microsoft writes
"December 09, 2025" and "June 9, 2026" and slugifies each verbatim). 94 of
95 sections currently resolve; a section with no id of its own falls back
to the plain page URL instead of a fragment that lands nowhere.
- detect_missing_cves reports cve_anchors alongside missing_cves, so the CVE
points at the section that first published its fix, not at the newest one
that happens to re-list it.
- Both entry points pass it (nightly run_m365_check and the Intune
detectedApps path), and the upsert re-attaches on every run, so findings
raised before this pick the link up on the next pass.
references is a shared column, so the cross-source contract applies to it as
much as to sources: Vulnerability.add_reference() appends and dedupes, the
M365 check replaces only its own earlier link, and the two writers that used
to fill the column only while it was empty (Nessus see_also, the app-CVE
scanner's vendor bulletin) now merge instead — otherwise an M365 reference
on the same row would have cost the host its vendor advisory.
Migration 051 fills in the rows the check will not re-raise (already closed,
or on a host that no longer reports an M365 install) with the plain page URL,
touching only rows m365_check raised and only where nothing is set.
In the UI the link renders as "MS365 Apps Security Updates" rather than a
bare learn.microsoft.com hostname.
Three iPhones flipped inactive → active every day and the audit log's only
word on it was "(seen again by a source sync)" — the one line that cannot be
told apart from an Intune revive, a Defender TVM revive, or anything else.
Two defects behind the one symptom.
The nameless message came from the nightly TIME-based reconcile, which reacts
to `last_scan`/`last_seen` rather than to a sync run, and those stamps carried
no source, so no message could ever name one. Every sync that stamps them now
records itself in `last_seen_source` (migration 049) and the reactivation entry
quotes it: "seen again by the intune sync — time-based reconcile: last_scan=…,
last_seen=… is inside the 42d window". Rows stamped before the migration say
"unknown source (stamp predates source tracking)" — `assets.source` is the
source that CREATED the asset, not the one that last saw it, and a confident
wrong name in a revisionssicher log is worse than an honest gap. The
event-driven revives (Wazuh, Nessus, Intune, vCenter, IGEL) already named
themselves and are unchanged.
The flap itself: the phones were deleted in Intune, so the Intune reconcile
deactivated them on every sync, and hours later the nightly reconcile revived
them on the stamp the sync BEFORE the deletion had written. That stamp was
already there when the asset was deactivated, so it is not evidence of
anything — the reconcile now requires a stamp newer than the most recent
ASSET_DEACTIVATED entry, the same rule reconcile_missing_from_sync already
follows in the other direction. Fail-open: if that lookup errors the reconcile
behaves as before.
Also surfaced, read-only, in the asset edit dialog next to the VMware build:
Entra device ID, Intune device ID, Defender machine ID, Wazuh agent ID. The
Entra ID is the identifier that survives an Intune deletion while Defender TVM
keeps reporting the same device under another name — it was in the database and
in the CSV export all along, just nowhere on screen, which is exactly where an
operator needs it to exclude the right machine in Defender.
Not done: writing the ID into the Description field. Description is free-form
operator text and a sync overwriting it would clobber what someone typed there.
Comments across the codebase credited one individual by role and, in places,
described that person's own machines: which SQL Server versions a host ran,
which devices were enrolled, what a particular dashboard showed, how many
findings sat open on which server. In a public repository that reads as a
profile of someone's unpatched estate.
The observations are why the code looks the way it does, so they stay. Every
CVE id, version, build number, count and date is preserved, as are the
verbatim quotes that motivated specific sort and filter rules — only the
attribution changes, to "field report", "observed", "a host". A local
variable in tests/test_autodesk_year.py was renamed for the same reason; its
value and every assertion around it are byte-identical.
PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of
organisation this was built for, and a support section pointing at an
internal team, both replaced with neutral wording.
Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one
swaps. detect_changes reports 104 touched symbols and zero affected execution
flows, and all 55 test scripts pass. Nothing here needs re-testing.
1) FALSE NEGATIVE (regression from the Wazuh-override I added earlier):
_inventory_confirms_fixed compared installed vs fixed_version without
checking they belong to the same servicing branch. Multi-stream CVEs
store only ONE stream's fix, so the compare was meaningless and closed
still-live findings — tester: MySQL 8.0.23 vs stored fix 7.6.34 (8.0.23
sits inside the separate 8.0.0–8.0.42 affected range), Suricata 8.0.0 vs
stored fix 7.0.12 (inside 8.0.0–8.0.1). Both were auto-resolved wrongly,
then reopened by the next Wazuh sync. Now the majors must match — same
lesson as the Windows build-line guard: only compare within one release
line. Edge/Notepad++ single-stream cases still close as before.
2) patched → open was never audited. Every scanner reopened findings
inline, so only the positive direction (open → patched) appeared in the
audit log and per-CVE Change History — the tester watched CVEs silently
flip back to open with no record. New audit_events.reopen_if_patched()
does the transition AND writes the status-change row; all 11 reopen
sites (wazuh, nessus x2, defender, app-scan, msrc, m365, eol,
mobile-eol, android) now go through it. Verified the resulting status is
identical to the old branch logic for every status value — the only
change is that the row now gets written.
3) Silence the pydantic v2 import warning (orm_mode → from_attributes).
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).
Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).
Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
README lines derived from it (git clone target dir, tree listing) — a real
repo rename is a manual Gitea-side step (Settings → repository name) the
user would need to do themselves, and existing clones would need
`git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
accounts that still exist under the old name; renaming the text alone
wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
vulncheck/...) — tied to GitNexus's own index name for this repo, not our
branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
(vulnmanager-*) — explicit user decision: infra naming carries real
deploy/data risk on an already-running instance and isn't part of the
product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
— invisible internal CSS variable name, renaming it would touch ~270
className occurrences for zero user-visible benefit.
Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
Tester: a CVE newly created by a sync appeared in the vuln list but left
NO initial audit entry ("new CVE detected on asset X at ...") — the audit
trail only began with the first status change (open -> patched etc.).
Confirmed: not intentional, simply never built; fails the revisionssicher
requirement.
- Migration 031: ALTER TYPE auditeventtype ADD VALUE
'VULNERABILITY_DETECTED' (idempotent, autocommit block).
- New app/services/audit_events.py: audit_new_vulnerabilities() writes one
System/Auto event per newly created finding:
"New finding detected: CVE-X on <hostname> (severity=..., source=...)".
- Wired into every creation path:
* Wazuh full sync (run_wazuh_vulnerability_sync) -> source=wazuh
* Wazuh per-agent sync (sync_agent_vulnerabilities, covers the
scheduler loop + per-asset rescan) -> source=wazuh
* Nessus sync (incl. Nessus EOL pseudo-vulns via
newly_created_vuln_ids) -> source=nessus
* endoflife.date upsert -> source=eol_check
* M365 Apps upsert -> source=m365_check
Full-sync and per-agent paths are independent (no double events).
- Best-effort: audit failure never breaks a sync.
Migration 031 required: alembic upgrade head.
Tester: a cross-confirmed (Wazuh + Nessus) CVE showed the generic
remediation "Install the patches listed below." instead of the specific
Nessus plugin solution ("Upgrade to Paessler PRTG ... 18.2.40.1683 or
later", plugin 277614).
A CVE can match several Nessus plugins on one host; a catch-all plugin's
generic solution was overwriting / blocking the real plugin's fix because
the precedence relied on a title-based specificity heuristic.
Now remediation precedence is text-based: a non-generic solution always
replaces an empty/generic one, and a generic solution never overwrites a
specific one. _is_generic_remediation() flags catch-all phrases
("install the patches listed below", "apply the appropriate patch",
"n/a", "no known fix", ...). Existing wrong rows self-heal on the next
Nessus sync.
Tester: Nessus already provides a per-finding remediation ("solution");
show it instead of burying it in the description blob.
- Migration 030: add vulnerabilities.remediation (TEXT).
- Nessus sync stores the plugin solution in the new remediation column
(was appended to description); description now holds the synopsis only.
- API exposes remediation; CVE detail renders a "🛠️ Remediation (via
scanner)" section below Affected Package when present.
Migration 030 required: alembic upgrade head.
Tester screenshots — four issues:
1) Dashboard/assets wasted huge left/right gutters. Dropped the
max-w-[1800px] cap on both pages -> content uses full available width
(minus AppShell padding).
2) After a Nessus re-sync, a finding still showed BOTH the legacy
EOL-NESSUS-{plugin_id} row AND the new slug-named row (e.g. Adobe
Reader -> EOL-ADOBE-ACROBAT-...). The slug alias already resolves, but
the old plugin-id row was never removed. _upsert_nessus_eol now deletes
the legacy EOL-NESSUS-{plugin_id} row for the asset whenever the plugin
resolves to a real product slug. Re-sync homogenises existing data.
3) "Newly Published" still looked unsorted: until the NVD published_date
backfill drains, most rows have published_date NULL and were ordered by
id (meaningless). Added a secondary sort on the CVE's own year+sequence
so "newest CVE number first" holds even before backfill. Bumped the
no-key NVD backfill cap 60 -> 150/run so dates fill faster.
4) Assets "Assigned To" select truncated to "Unas..." — widened
maxWidth 140 -> 200px (minWidth 120).
Follow-up to the published_date backfill. The NVD CVE API sleeps up to
6.5s per request without an API key, so running the backfill inline on
interactive / sync paths could hang them for minutes.
- NVD backfill now only runs in the nightly enrich_all_open_vulnerabilities
job (and single-vuln enrich, <=1 lookup). Disabled on the "Refresh
Threat Intel" button, the Wazuh full + per-agent syncs, and the Nessus
sync — all pass use_nvd_dates=False to stay fast.
- Per-run lookup cap is now key-aware: 60/run without a key (~6.5 min),
1500/run with a key (~18 min) — avoids a no-key install stalling the
nightly job for the better part of an hour.
- Document NVD_API_KEY in .env.example (free key → ~10x faster fill,
picked up automatically via env_file).
Tester on alembic 028 + rebuilt, asset INACTIVE still never set after
a reduced-scope Nessus sync.
Root cause: reconcile keyed on nessus_host_uuid. The single host in the
reduced scan had no host_uuid in its Nessus host_info, so the seen-uuid
set came back EMPTY → the fail-open guard ("empty set = upstream maybe
failed, don't deactivate anything") skipped the whole reconcile → the
dropped hosts from the previous scan stayed ACTIVE.
Fix: track the matched asset.id of every host touched this sync
(seen_asset_ids) and reconcile on that instead of the uuid set. A
uuid-less host still contributes its id, so the seen-set is non-empty
and the dropped assets get inactivated.
New reconcile_nessus_by_seen_ids():
candidate = ACTIVE AND (source==NESSUS OR nessus_host_uuid NOT NULL)
AND id NOT IN seen_asset_ids → INACTIVE
revive = same set, INACTIVE, id IN seen_asset_ids → ACTIVE
fail-open only when seen_asset_ids is truly empty (no host matched).
Logs "Nessus sync reconcile: N seen, M inactivated, K candidates" so
the next report is self-diagnosing.
Old uuid-keyed reconcile_missing_from_sync kept for the Wazuh path /
back-compat.
New helper _slug_pseudo_cve resolves the plugin_name to an
endoflife.date slug via eol_service.resolve_product_slug, then emits
EOL-{SLUG}-{VERSION|P####} instead of EOL-NESSUS-{plugin_id}. Office
variants keep the year-based EOL-MS-OFFICE-YYYY id for the language-
pack dedup. Legacy _office_pseudo_cve retained as a fallback when slug
resolution fails.
Detection Sources panel (nessus + plugin_id) is untouched — the
plugin id remains queryable from the vuln.nessus_plugin_id column.
Fixes feedback 2026-06-02 #2b, #4. Existing rows still need a one-off
backfill (out of scope here).
Tester feedback round 2026-06-01 — 8 issues grouped into 4 fixes.
**A. Sync-driven INACTIVE reconciliation** (highest priority)
- AssetSource.NESSUS enum value added; pre-existing MANUAL assets with
a nessus_host_uuid re-tagged in 027 migration.
- New `reconcile_missing_from_sync()` helper in asset_lifecycle flips
ACTIVE assets of a given source (WAZUH/NESSUS) to INACTIVE when their
id (wazuh_agent_id or nessus_host_uuid) is missing from the latest
sync. Vice-versa reactivates INACTIVE assets that re-appear.
- Hooked from nessus_sync.run_nessus_sync (after per-host loop, before
commit) and from POST /api/v1/assets/sync_wazuh (after the agents
loop). Both return assets_inactivated/assets_reactivated counts in
their response.
- INACTIVE-asset CVEs now hidden from list_vulnerabilities and
get_dashboard_statistics by default (new
`include_inactive_assets` query param opts back in).
- AuditEventType gains ASSET_DEACTIVATED + ASSET_REACTIVATED so the
transitions are filterable in the audit UI.
**B. EOL pseudo-CVE naming for Nessus + endoflife.date**
- nessus_sync: new `_office_pseudo_cve()` returns
EOL-MS-OFFICE-{YEAR} for Office variants, else EOL-NESSUS-{pid}.
`_normalise_office_pkg()` collapses MUI/Proofing strings to "MS
Office". Title suffix "OSX MUI..." trimmed for Office.
- nessus_sync: end-of-pass sweep collapses pre-existing
EOL-NESSUS-{pid} Office rows into the unified EOL-MS-OFFICE-{year}
anchor (status=patched + audit).
- eol_service: _PRODUCT_SLUGS now maps microsoftoffice[proofing/osxmui
/osxmuigerman/formac] -> ms-office. _YEAR_KEYED_SLUGS includes
ms-office. _pseudo_cve_id produces EOL-MS-OFFICE-{year} for free.
- eol_service: existing-row branch bumps detected_at so the
sort_by=detected_at widget ranks freshest finding first.
**C. Asset view icon asymmetry + dashboard panel fix**
- assets/page.tsx: coverage-gap icon now shows for any asset with
wazuh_agent_id OR nessus_host_uuid.
- nessus_sync._find_or_create_asset: backfills ip_address on existing
assets (UUID / hostname match) so the "Launch targeted Nessus scan"
button (already gated only on asset.ip_address) lights up.
- frontend types + backend AssetResponse expose nessus_host_uuid +
source.
- page.tsx CVE-id cell: `break-all` -> `whitespace-nowrap` so the
free L/R dashboard space isn't wasted on character-wrapped ids.
**D. Assets table sortable columns**
- list_assets: new sort_by / sort_order with whitelist-driven ORDER BY.
Last-scan sort uses nulls_last(). Joins Policy/User only for those
columns. Unknown sort_by falls back to hostname asc.
- assets/page.tsx: sortBy/sortOrder state, handleSort toggler, SortArrow
indicator, clickable headers with cursor-pointer + hover style, and
a useEffect re-fetch on change. Mirrors the pattern in
vulnerabilities/page.tsx.
**Out of scope (flagged)**
- `delete_asset` remains a hard delete with cascade. The user's
"previously-deleted asset not re-created by Scan+Sync" expectation is
intentional design — the auto-create gate (auto_create_assets) and
the new INACTIVE flip cover the rest of the use cases.
- DECOMMISSIONED AssetStatus is still unreachable through automation.
- Vulnerability-count column in assets table stays non-sortable (it
is a Python-side count, not a SQL column).
**Migration**
- 027_add_nessus_source_and_audit_events.py: ALTER TYPE for both
enums + UPDATE backfill. Run `alembic upgrade head` once on prod.
The endoflife.date EOL sweep only covers Wazuh agents
(scheduler filters wazuh_agent_id IS NOT NULL) and Nessus's own
'Unsupported Version Detection' plugins (64784 SQL Server, 56998/97085
Office, ...) carry no CVE, so they were dropped by the non-CVE skip in
nessus_sync. Result: Nessus-only / synced hosts had zero EOL coverage.
Re-admit the Unsupported-Version plugin family (detected via
unsupported_by_vendor=true or the plugin name) as EOL pseudo-vulns with
cve_id EOL-NESSUS-{plugin_id}. The EOL- prefix makes is_eol_finding true
so they appear in the dashboard Newly EOL/EOS widget next to
endoflife.date findings. Severity/CVSS/installed+fixed version come from
the plugin itself. Dedup key (cve_id, asset_id); the EOL id is added to
the per-run seen set so the source-backfill doesn't immediately patch
the row it just created. New 'eol_created' stat in the sync summary.
Other non-CVE plugins (compliance/cipher/info) stay skipped.
Tester report: "Same CVE shows different CVSS depending on sort
order (PRIO vs CPR desc)". Cause: per-(cve_id, asset_id) rows drift
apart over time — override service touched some assets but not the
ones detected later by a fresh scan.
Strategy
- Define _CANONICAL_FIELDS = CVE-intrinsic columns that should NEVER
differ between sibling rows of the same CVE:
cvss_score, cvss_vector, severity,
exploitation_status, exploitation_source,
ssvc_technical_impact, ssvc_automatable.
- fixed_version EXPLICITLY excluded — Plan I (multi-stream picker)
writes per-package fixes that legitimately differ between hosts
running different release streams (Firefox ESR 115 vs 140 vs 150).
Propagation paths
- propagate_canonical_to_siblings(db, vuln):
every override touch now mirrors the new canonical values onto
all other vuln rows sharing the cve_id. Refreshes their
priority_score + cpr_score so sort order converges.
- apply_canonical_from_siblings(db, vuln):
every fresh sync insert (Wazuh + Nessus) pulls the freshest
sibling's canonical values so the new row starts at the correct
CVSS instead of Wazuh's 10.0 placeholder.
Backfill endpoint
- POST /vulnerabilities/canonicalize-cve-metadata (editor) — one-off
pass over existing data. For every CVE with > 1 row, pick the
source-pinned / most-recent vuln and propagate. Cheap; one query
per distinct CVE.
What this fixes in practice
- New Wazuh agent reports an already-overridden CVE → row starts with
the canonical 7.3 score, not 10.0.
- Override re-run on one asset propagates the corrected CVSS to all
10 hosts that share the CVE.
- Sort-by-PRIO / sort-by-CPR now keeps all instances of a CVE
adjacent in the listing.
Tester reported CVE-2026-8948 (Mozilla Firefox < 151.0) detail view
showed INSTALLED: — and FIXED IN: not announced, even though the
Nessus plugin output clearly carried:
Path : C:\Program Files\Mozilla Firefox
Installed version : 150.0.3
Fixed version : 151.0
Root cause: per-host outputs live in
plugin_payload["outputs"][i]["plugin_output"]
NOT in the info dict that plugin_fixed_version() was scanning. So
only `info.solution` (and the rarely-set `info.plugin_output`) ever
reached the regex.
Fix
- plugin_fixed_version() now also scans every entry in
plugin_payload["outputs"][i]["plugin_output"] — same regex set.
Firefox solution "Upgrade to Mozilla Firefox version 151.0 or
later." already matched via the multi-word regex from 26df8d8,
but the per-host "Fixed version : 151.0" line is a more reliable
exact match.
- New plugin_installed_version() pulls "Installed version : X" from
the per-host outputs.
- nessus_sync writes installed_version into Vulnerability.package_
version on insert (was None) AND backfills it on existing rows
where the column is empty.
Tester asked for "echte Revisionssicherheit" — WHO patched WHAT,
WHEN, WHY — and reported that Nessus auto-patched CVEs (rows that
disappeared from a follow-up Nessus scan) were not visible in any
status anymore: not OPEN, not PATCHED, just gone-feeling.
Backend
- VulnerabilityUpdateRequest gains a status-agnostic `reason: str`
field (defer_reason kept for legacy callers).
- log_vulnerability_change() now persists a JSON payload in
audit_logs.new_value: {status, reason, source, cve_id}. user_id
is nullable so automated transitions are clearly marked.
- Both auto-patch paths now write audit-log entries:
* Nessus stale_nessus_vulns loop → "Nessus rescan {scan_id} no
longer reports this CVE on {hostname}"
* Wazuh stale_wazuh loop (both per-agent + full-sync) → same
pattern with the agent_id.
- /audit/logs accepts resource_type + resource_id filters and lets
editors query a single resource (admins still see everything).
old_value + new_value added to the response so the frontend can
parse the JSON payload.
Frontend
- Status-change modal sends `reason` (status-agnostic) instead of
always overwriting defer_reason. Deferred status keeps the legacy
fields too.
- Vuln detail page renders a "Change History" panel below Timeline:
per-entry old→new transition, source tag (manual / nessus_sync /
wazuh_sync), free-text reason, username + timestamp. Nessus auto-
patched CVEs now have a clear paper trail.
No migration — audit_logs schema already had old_value / new_value
columns; we're just populating them with structured JSON now.
Tester reported "Spaltensortierung gilt nur teilweise für die
aktuell angezeigten CVES." Cause: priority/cpr were computed in
Python from calculate_priority_breakdown() at response time. The
list endpoint paginated FIRST in SQL by an inaccurate proxy then
re-sorted only the current page by the real score → page 1 looked
fine, pages 2+ were out of order.
Schema (migration 024)
- Add indexed columns `priority_score` + `cpr_score` (FLOAT) to
vulnerabilities.
Model
- `Vulnerability.refresh_scores()` recomputes + persists both
columns. Cheap, no I/O.
Write paths now call refresh_scores():
- Wazuh sync (sync_agent_vulnerabilities) — both insert + update branches
- Nessus sync (run_nessus_sync) — both merge + create branches
- Enrichment (refresh_threat_intel_enrichment) — per-vuln after EPSS/KEV/EUVD
- Override service (_apply_single_override) — after the source pin
Sort
- /vulnerabilities?sort_by=priority|cpr now SQL-sorts on the
materialised columns (NULLs last on desc, first on asc, with
id tiebreaker). Whole filter result is in correct order across
pages.
Backfill
- POST /vulnerabilities/recompute-scores (editor) — one-off pass
over all rows. Run once after the upgrade so existing data
picks up scores. Nightly Vulnrichment + URS jobs would converge
the rest naturally.
Status filter UX (open question to operator)
- Tester also wondered if "All Statuses" should include closed
states (false_positive / accepted_risk / deferred). Current
behaviour matches the dropdown label literally and is left
unchanged here — separate decision.
Two colleague-reported issues addressed in one commit.
A) PATCH AVAILABLE badge never lit up
Schema column, API field, and frontend pill were all in place
since commit 0e83548, but no sync code ever wrote fixed_version
into the DB. Result: column NULL for every row, badge silent.
Wazuh sync (query_vulnerabilities_from_indexer):
- Tries vulnerability.fix → vulnerability.fixed_version → package.fix
- Falls back to regex over vulnerability.condition / package.condition
("Package less than X.Y.Z" pattern, common in Wazuh 4.x feeds)
- Emits fixed_version on every result dict
Both router paths (run_wazuh_vulnerability_sync and
sync_agent_vulnerabilities) now persist it on create AND backfill
it on update when the column was previously NULL.
Nessus sync (NessusClient.plugin_fixed_version):
- Reads vuln_information.fixed_version / fix_version when present
(Windows MS-Bulletin plugins always carry it)
- Falls back to regex over solution + plugin_output text:
"Fixed version : X", "Upgrade to ... version X", "Upgrade to X"
nessus_sync writes it on new vulns and backfills empty existing
rows on merge.
B) Duplicate assets — Nessus FQDN vs Wazuh short hostname
_find_or_create_asset matched hostnames with strict .ilike(),
so 'host01.umgebung.local' (Nessus) never matched 'host01'
(Wazuh) → second asset created, scoring split, dashboard misleading.
Match order now:
1) nessus_host_uuid pin
2) exact hostname.ilike(full_form)
3) short form (everything before the first '.') against
Asset.hostname
4) reverse — asset stored as FQDN, Nessus reports short →
Asset.hostname.ilike('{short}.%')
5) IP exact
6) auto-create with the SHORT hostname so the next Wazuh sync
consolidates onto the same row
Existing duplicate rows stay — operator can merge via SQL or
delete the Nessus-only asset once everything points at the
Wazuh asset via nessus_host_uuid pinning.
Plan F — adds the official MITRE/CVE.org cvelistV5 feed as the third
stage of the CVSS-correction cascade. The cascade now resolves in
order:
1. CISA Vulnrichment (ZIP > 25 CVEs, raw otherwise)
primary — CISA-curated, CVSSv3 + SSVC
2. NVD REST API (https://services.nvd.nist.gov/rest/json/cves/2.0)
fallback for ≤100 missing CVEs — rate-limit-bounded
3. cvelistV5 GitHub archive (557 MB ZIP, cached 12h on disk)
backstop for everything Vulnrichment + NVD couldn't see;
covers every published CVE (~250k+)
cvelistV5 uses the same CVE-5 JSON shape Vulnrichment does
(containers.cna.metrics / containers.adp.metrics) so the existing
_parse_vulnrichment_record handles both without code duplication.
Parsed records get source='cvelistv5' so the operator can tell where
the correction came from.
Disk cache /tmp/vulncheck-cvelistv5-cache.zip with 12h TTL keeps
re-runs cheap — first call downloads 557 MB, next call within 12h
skips download and walks the cache file (~30s vs ~2 min).
Nessus sync's override-lock extended to recognise all three
authoritative sources (vulnrichment / nvd / cvelistv5) so a
cvelistV5-corrected score doesn't get clobbered by the next Nessus
plugin-bundle.
After deploy, 'Correct CVSS' will now find scores for essentially
every real CVE — only the most recent (hours-old) CVEs without any
public CVSS yet stay uncorrected.
Tester reported Nessus-only assets had no OS info in VulnCheck even
though Nessus reports it on every scanned host (plugin 11936 'OS
Identification' is included by default). The data was sitting in the
host's 'info' dict but we ignored it.
When a host is processed, asset.operating_system now gets populated
from the first non-empty value of:
info['operating-system']
info['operating_system']
info['os']
Nessus occasionally returns a list of guesses ("Microsoft Windows
Server 2022 Standard\nMicrosoft Windows Server 2019 Datacenter"); we
take the first line. Truncated to 255 chars to fit the column.
Defensive: only fills when asset.operating_system is currently null.
Wazuh-sourced assets already carry structured agent OS data and we
deliberately do not clobber that with Nessus prose — Wazuh tends to
be more accurate for the host where the agent runs.
No schema change — operating_system column has existed since the
initial migration.
Tester noticed that scheduled + manual Nessus syncs left no trace in
the Scan-Jobs page — only Wazuh autoscan was visible there. Made
it impossible to verify Nessus scheduler runs after the fact.
Changes:
- New enum value ScanType.NESSUS + idempotent migration 014.
- run_nessus_sync (services/nessus_sync.py) now opens a Scan row
per host at the top of the per-host loop:
scan_type = NESSUS
status = RUNNING → COMPLETED / FAILED
started_at = now
asset_id = matched VulnCheck asset
and closes it after the backfill, setting
vulnerabilities_found = len(seen_cves_for_asset)
completed_at = now
The skip-backfill defensive branch (host returned 0 findings)
marks the row COMPLETED with an explanatory error_message so the
operator sees the run happened but produced nothing.
- Scheduler path inherits this for free — app/scheduler.py already
calls run_nessus_sync for scanner_type='nessus' schedules.
- Existing /scans/summary endpoint groups consecutive Scan rows of
the same type into a "run", so the UI shows one entry per Nessus
sync (covering all hosts).
Migration required on deploy:
docker compose exec backend alembic upgrade head # 013 → 014
Tester reported CVE-2026-34480 (Apache Log4j XmlLayout) showed up in
VulnCheck with title 'Patch Report' and a generic 'remote host is
missing one or more security patches' description instead of the
real Log4j plugin's CVE-specific text. Root cause: Nessus emits two
plugins per CVE in this case — a catch-all 'Patch Report' plugin and
the CVE-specific scanner plugin. First-write-wins in the merge path,
so whichever Nessus returned first locked in the bad metadata.
Adds a _is_specific() heuristic — a plugin name is 'specific' to a
CVE if the CVE-ID appears in its plugin_name. The merge path now
overwrites existing title / package_name / description /
nessus_plugin_id when:
- the existing row's title does NOT mention the CVE-ID
- AND the incoming plugin's name DOES
That promotes the proper Log4j plugin over 'Patch Report' regardless
of import order. Plugins that came in the right order first are not
disturbed because their existing title already passes _is_specific.
Tester reported that after running 'Correct CVSS' on CVE-2026-8390
(score dropped 10.0 → 7.3 from Vulnrichment), the next Nessus sync
silently overwrote it with the plugin-level 10.0 again. Root cause:
the Nessus merge path applies a 'Nessus wins' rule unconditionally on
CVSS and severity, so any manual Vulnrichment correction had a
shelf-life of one scan.
Two-part fix:
- vuln_override_service._apply_single_override now stamps
vulnerability.exploitation_source = 'vulnrichment' whenever it
writes cvss_score or severity. Existing column from migration 010
was previously unused for this purpose.
- nessus_sync.run_nessus_sync merge path checks
existing.exploitation_source == 'vulnrichment' and skips its CVSS +
severity override in that case. All other fields (sources list,
plugin_id, VPR score, descriptions, status reopen) still merge
normally — only the authoritative score stays pinned.
No schema change required.
Closes 10 findings from the automated security scan (1 critical, 4 high,
5 medium). Operator action required before redeploy — see deploy notes
in chat or README.DEV.md.
Critical:
- TOTP/LDAP Fernet key (AUTH_PROVIDER_CRYPTO_KEY) is now env-only.
Removed the DB fallback that co-located the key with the ciphertext
it protects.
High:
- Rate limiter no longer trusts X-Forwarded-For from arbitrary peers.
TRUSTED_PROXY_CIDRS gates which direct peers may rewrite the client
IP, and ProxyHeadersMiddleware trusted_hosts is narrowed from "*"
to FORWARDED_ALLOW_IPS.
- TOTP codes are single-use within their 90s validation window.
In-memory replay cache keyed on (user_id, code).
- JWTs carry a jti claim; logout revokes both access and refresh JTIs,
refresh rotates (revokes the presented token), and get_current_user
rejects any revoked JTI. In-memory store with TTL = token exp.
- Sensitive setting values (wazuh_config, smtp_config, nessus_config)
are encrypted at rest with an enc:v1: prefix. All read sites go
through read_setting_value(); legacy plaintext rows still readable
until next write. GET responses redact secret subfields so admins
cannot accidentally exfiltrate stored credentials.
Medium:
- Email template rendering HTML-escapes all dynamic values. The "rows"
variable is whitelisted as pre-escaped HTML. Severity CSS class is
whitelisted to prevent attribute breakout via crafted package data.
- Request logging redacts sensitive query parameters (token, password,
code, mfa_token, ...). Validation-error handler no longer logs or
returns the offending request body.
- /health returns only {"status":"healthy"} — environment and version
no longer leak to unauthenticated callers.
- SETUP_ADMIN_TOKEN comparison uses hmac.compare_digest.
- Settings PUT denylists auth_provider_crypto_key (env-only) and
refuses to store the "***set***" redaction placeholder back into
protected configs.
After fixing an unreachable Wazuh manager, a sync run came back with an
empty agent vulnerability list (indexer warming up) and the
source-aware backfill happily marked 1500+ findings as patched because
every CVE was 'no longer reported'. Same vector exists in Nessus sync
when a host's response is partial.
Guard added in all three sync paths:
- run_wazuh_vulnerability_sync (bulk per-agent loop)
- sync_agent_vulnerabilities (per-agent rescan)
- run_nessus_sync (per-host loop)
If active_cves AND raw findings list are both empty for the
agent/host, log a warning, touch last_scan, and continue — backfill
is skipped. A scanner reporting a host as truly clean from one
moment to the next is far rarer than an API hiccup, so the default
must be 'don't wipe history'.
Recovery for vulns already wiped today (sources=[], status=patched,
recent patched_at) is handled with a one-off UPDATE; the README's
verification block stays valid post-fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When a CVE already exists on an asset (e.g. seen by Wazuh first) and
Nessus reports its own CVSS, the Nessus value now wins instead of the
previous max-merge behaviour. Fixes the common case where Wazuh assigns
a placeholder 10.0 but Nessus has the accurate per-plugin score (e.g.
7.4). Falls back to keeping the existing value when Nessus has no CVSS
or severity, so we never wipe valid Wazuh data.
Adds scores_overridden counter to sync stats + log line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- plugin_cvss() now uses _plugin_attrs() helper (eliminates duplicate
inline path extraction — single place to update if Nessus changes payload)
- non_cve_skipped initialised to 0 in stats dict so the field is always
present in the sync response even when every finding has a CVE
- vuln detail page: show title + description separately instead of
title || description (description was silently hidden when title existed)
- vuln detail page: add "Detection Sources" card — source badges (WAZUH /
NESSUS), cross-confirmed indicator, first_detected_by, Nessus plugin ID
(links to tenable.com/plugins), and Tenable VPR score with colour coding
Nessus plugin payloads carry more than just severity + CVE — we now
extract and store everything actionable:
- nessus_vpr_score (new column, migration 011): Tenable's VPR rating
(0-10), independent from our own priority_score
- exploit_available (existing boolean): True when Nessus knows of a
public exploit; only escalates, never overwrites Wazuh-confirmed True
- exploit_maturity (existing string): Unproven / PoC / Functional / High
- description: prose description + 'Solution:' section, only set when
empty so hand-written notes survive
- references: see_also URL list as JSON, only when existing is null
Create path sets all fields directly; merge path backfills empties so
existing Nessus findings get enriched on the next sync. API response
adds nessus_vpr_score; frontend Vulnerability type mirrors it.
Per product decision, only CVE-tagged Nessus findings are imported.
EOL / Compliance / Cipher / informational plugins without a real CVE
are now skipped (counted in stats.non_cve_skipped).
Adds POST /api/v1/vulnerabilities/nessus/cleanup-pseudo-cves to remove
existing NESSUS-PLUGIN-* rows from earlier syncs.
- 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.
- scheduler.py: asset.assigned_group_id does not exist (M2M removed it).
Replace with asset.groups iteration so SLA breach mails reach group
members without crashing.
- nessus_sync.py: db.rollback() on IntegrityError wiped the entire sync
transaction. Switch to db.begin_nested() savepoint so only the
duplicate insert is discarded, all other changes survive.
Phase 2 — full Nessus → VulnCheck import path.
app/integrations/nessus_client.py:
- NessusClient with X-ApiKeys auth, httpx + tenacity retry, 429-aware
- list_scans / get_scan / get_host / get_plugin_output
- extract_cves() pulls structured cve[] field + regex fallback over
plugin description/output (handles plugins that mention CVEs only in
text)
- plugin_severity_to_label() maps Nessus 0-4 → VulnerabilitySeverity
- plugin_cvss() prefers v3 base score over v2
app/services/nessus_sync.py:
- run_nessus_sync(db, scan_ids?) iterates configured / requested scans
- Asset matching cascade: nessus_host_uuid (pinned) → hostname →
ip_address → optional auto-create when nessus_config.auto_create_assets
- Per-CVE: if existing (cve, asset) found → add_source('nessus'),
escalate severity (max), bump CVSS if higher, set plugin_id, reopen
if previously patched. Else create new with sources=['nessus'].
- Non-CVE findings (EOL/Compliance/Ciphers) stored under pseudo-CVE
NESSUS-PLUGIN-{plugin_id}
- Backfill: vulns previously tagged 'nessus' that don't appear in this
scan run get 'nessus' dropped from sources; status flips to patched
only when sources list empties (i.e. no scanner sees the finding
anymore — never delete Wazuh data based on Nessus absence)
- Reuses dispatch_new_vuln_notifications + enrich_vulnerabilities so
Nessus-discovered CVEs flow through the existing digest mail + EPSS/
KEV/EUVD enrichment paths
app/routers/nessus.py:
- POST /api/v1/vulnerabilities/nessus/test auth probe
- GET /api/v1/vulnerabilities/nessus/scans list visible scans
- POST /api/v1/vulnerabilities/nessus/sync trigger sync
app/scheduler.py:
- execute_scheduled_scan() now routes by ScanSchedule.scanner_type:
'wazuh' → existing agent loop, 'nessus' → run_nessus_sync()
- Backwards compat: schedules default scanner_type='wazuh' via DB
default from migration 010
app/main.py: register nessus router.
No frontend yet — Phase 3 adds settings UI + sync button. The API is
already usable via curl after Phase 2 deploy + alembic upgrade head.