COALESCE(published_date, detected_at) made every EOL row ride the
detected_at fallback to the top of the "Newly Published" list — a
tester-reported false chronological order. Skip rows whose cve_id
starts with EOL- or NESSUS-PLUGIN- when sort_by=published_date and the
caller did not explicitly search=EOL-.
Fixes feedback 2026-06-02 #5a.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
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.
Plan E — extend endoflife.date detection from installed packages to
the OS itself. Tester confirmed via PowerShell that windows-server
2008-r2 is EOL on endoflife.date; Wazuh syscollector packages don't
carry the OS so we read asset.operating_system + os_version.
Service (eol_service.py)
- resolve_os_to_eol(os_name, os_version) maps Wazuh OS strings to
(endoflife_slug, release_codename):
"Microsoft Windows Server 2008 R2" → (windows-server, 2008-r2)
"...Server 2016 Datacenter" → (windows-server, 2016)
"Microsoft Windows 10 Pro" → (windows, 10)
build >= 22000 → (windows, 11)
"Ubuntu" 22.04.3 LTS → (ubuntu, 22.04)
Debian / RHEL / CentOS → numeric major
- check_os_eol() matches the codename against release.name (exact,
then prefix) — NOT the numeric _pick_release used for packages,
because OS releases use codenames not versions.
Endpoint + scheduler
- /vulnerabilities/eol-check now runs the OS check per asset BEFORE
the package loop (so an asset whose package fetch errors still
gets its OS evaluated). New stat: os_eol_findings.
- eol_check_nightly mirrors the same OS-first logic.
OS EOL findings reuse the EOL-* pseudo-CVE machinery: severity HIGH
(EOL) / MEDIUM (EOAS), cve_id EOL-WINDOWS-SERVER-2008-R2, fixed_version
= latest supported release.
Four small UI/UX wins from tester feedback bundled.
A) Dashboard VIEW ALL deep-links
- Each widget passes a viewAllHref matching its filter:
Recent Critical → /vulnerabilities?in_any_catalog=true&sort_by=updated_at
Newly Published → /vulnerabilities?sort_by=published_date
Newly EOL/EOS → /vulnerabilities?search=EOL-&sort_by=detected_at
- "VIEW ALL >" now lands the operator on the matching filtered view
instead of the unfiltered firehose.
B) EOL + Public-Exploit filter toggles on vulns list
- New `EOL` checkbox (purple) pushes `search=EOL-` to backend so the
list scopes to endoflife pseudo-CVEs.
- New `PUBL-EXPL` checkbox (orange) client-side-filters the current
page to rows with exploit_db_count > 0 OR pocs_github_count > 0
OR metasploit_module_count > 0. NOTE: total count becomes the
filtered-page length while the toggle is on — backend filter
param can land in a follow-up if needed.
C) Priority breakdown shows public-exploit catalog counts
- Vuln-detail "Priority Score Breakdown" gets three new informational
rows under the headline exploit_bonus:
↳ Metasploit modules N
↳ Exploit-DB entries N
↳ GitHub PoCs N
Tooltip: "Already counted in exploit_bonus via max() — listed for
transparency". Addresses tester confusion that only the
highest-weighted source appeared in the breakdown.
- TypeScript PriorityBreakdown type extended with the three counts
and the three new exploit_source labels.
D) Nessus-only patched → PENDING_VERIFICATION loop
- update_vulnerability_status only triggers verify_patch_with_rescan
when wazuh actually reports the vuln (wazuh in source_list).
Nessus-only rows (sources=["nessus"]) skip the rescan path and
mark PATCHED directly — they cannot be re-verified by Wazuh's
syscollector since the agent doesn't know that CVE exists.
Tester report: "Bei nessus only scan bleibt es im PENDING
VERIFICATION stehen. Workaround: bulk-patch".
Plan M — close the "exploit-db is only a link, not in the score"
gap. Three new enrichment sources mapped per-CVE, written to
dedicated columns, displayed as badges, weighted in the priority
breakdown.
Schema (migration 025)
- vulnerabilities.exploit_db_ids (JSON list) + exploit_db_count
- vulnerabilities.pocs_github_urls (JSON list) + pocs_github_count
- vulnerabilities.metasploit_modules (JSON list) + metasploit_module_count
- vulnerabilities.exploit_intel_updated_at
- partial indexes on the *_count columns where > 0 for fast
"show me CVEs with exploits" filters.
Service (app/services/exploit_intel_service.py)
- fetch_exploit_db_cve_map(): downloads gitlab.com/exploit-database/
exploitdb/files_exploits.csv (cached 24h), parses CVE refs from
the `codes` column → {cve: [edb_id, ...]}.
- fetch_pocs_github_cve_map(): walks nomi-sec/PoC-in-GitHub yearly
folders via the GitHub contents API, builds {cve: [repo_url, ...]}.
- fetch_metasploit_cve_map(): downloads rapid7's
modules_metadata_base.json, extracts CVE refs per module.
- refresh_all_exploit_intel(): bulk writer, supports only_open + per-
source toggles, marks/clears counts so a removed reference doesn't
leave a stale flag behind.
Priority weighting
- New exploit signals folded into the existing max() block of
exploit_bonus:
metasploit_module_count > 0 → 4.5
exploit_db_count > 0 → 4.0
pocs_github_count > 0 → 2.5
exploit_source label expanded with metasploit / exploit_db /
poc_github so the breakdown UI shows which source drove the score.
API + Scheduler
- POST /api/v1/vulnerabilities/exploit-intel/refresh (editor) —
on-demand pull with `only_open`, `fetch_pocs`, `fetch_msf` flags.
- New nightly job at 03:45 UTC (between vulnrichment 03:00 + URS
04:00 so the score boost lands before URS recomputes).
Frontend
- Three list-row badges (MSF n / EDB n / PoC n) coloured red →
orange → yellow, ordered by weight, with tooltips.
- Vuln-detail page gets a "Public Exploit Catalogs" card under
Threat Intelligence — Metasploit module paths, clickable EDB-id
links to www.exploit-db.com, GitHub PoC links.
- "Exploit Intel" toolbar button next to EOL Check.
Out of scope (Plan N or later):
- GHSA integration (GitHub Security Advisory Database).
- Nuclei template count.
- Per-source toggles in Settings UI.
Three tester asks bundled (D dropped — current EOL scoring fine).
A) Status tooltip on vulns list
- Backend: _attach_last_change() bulk-loads the latest
VULNERABILITY_UPDATED audit per page in one query (O(N), not N+1)
and exposes it as `last_change: {timestamp, username, old_status,
new_status, reason, source}` on the response.
- Frontend: hover on the Status badge shows the audit line plus a
small inline source tag underneath ("admin" / "bulk" /
"auto·nessus" / "auto·wazuh") so the operator can distinguish
manual patches from scanner auto-patches at a glance, without
opening the detail page.
B) EOL / EOS dashboard widget
- Dashboard grid expanded from 2 → 3 columns.
- New "Newly EOL / EOS" widget fed by
GET /vulnerabilities?status=active&search=EOL- sorted by detection.
- Same row component as the existing widgets so style + click-
through behaviour matches.
C) Header text clarification
- "Recent Critical CVEs" subtitle now reads
"sorted by local re-score" instead of the ambiguous
"sorted by last update" — caller was confused that it wasn't
using upstream CVE-5 dateUpdated.
D) EOL scoring (noted — no change)
- Pseudo-CVEs already pick CVSS 9.0 (EOL/no patches coming) or
6.0 (out-of-active-support, security patches still flow). priority
+ cpr derived from those via refresh_scores().
Out of scope (separate plans):
- Plan M: search_vulns integration (GHSA / exploit-db / PoC-in-
GitHub / Metasploit / Nuclei) for richer enrichment.
- Plan N: direct exploit-db badge + score weighting.
Self-signed Wazuh certs are the norm — sync + rescan default to
verify_ssl=False. EOL check was overriding to True which broke
every get_packages() call with CERTIFICATE_VERIFY_FAILED.
500 on /vulnerabilities/eol-check — JSONDecodeError because the
raw settings row holds a Fernet-encrypted ciphertext, not bare
JSON. Other Wazuh callers (sync, rescan) use read_setting_value()
which decrypts first. Mine bypassed it.
Also fix WazuhClient kwargs: real signature is
WazuhClient(base_url=, username=, password=, indexer_url=, ...).
My code passed api_url= which was silently dropped → connect
attempt with None base_url would have failed even after the JSON
fix.
Same fix applied to scheduler.eol_check_nightly.
Tester request: close the unsupported-software gap Wazuh has vs
Nessus plugin 64784 (Microsoft SQL Server Unsupported Version
Detection). PowerShell prototype against endoflife.date API
confirmed the approach.
Backend
- New app/services/eol_service.py
* Hand-curated product slug map (Microsoft / Mozilla / Adobe /
runtimes / databases / Linux distros).
* resolve_product_slug() normalises Wazuh/Nessus product names and
matches against the map.
* fetch_product() hits api/v1/products/{slug} with 24h cache in
the settings table (negative-caches 404s).
* check_eol() picks the longest-prefix release for the installed
version, evaluates eolFrom / eoasFrom / eoesFrom against today.
* upsert_eol_vulnerability() writes one pseudo-CVE per (asset,
product, release) with cve_id of the form
EOL-{SLUG}-{RELEASE} so re-runs converge.
- Vulnerability.is_pseudo_cve also matches EOL-* now.
- Vulnerability.is_eol_finding new property + API field.
API + scheduler
- POST /vulnerabilities/eol-check (editor) — optional asset_id query,
walks syscollector packages, upserts EOL pseudo-CVEs. Returns
stats {assets_scanned, packages_checked, products_unmapped,
eol_findings_total, eol_findings_new, errors[]}.
- New scheduler job eol_check_nightly at 03:15 UTC.
Frontend
- Vulnerability type gains is_eol_finding.
- List page renders purple "EOL" badge instead of amber "NON-CVE"
for endoflife rows.
Out of scope (later iteration):
- OS-level EOL (Windows / Ubuntu releases) — would parse Asset.os
+ os_version separately.
- Per-package detail (VulnerabilityPackage child rows) for EOL
pseudo-CVEs — current row already carries name/version/latest.
- Settings-UI for the product-slug mapping table.
Tester report: comment/reason field on the status-change modal is
optional → manual patches via single-select OR multi-select bulk
update produced audit-log entries with no rationale, making it
impossible to distinguish them from scanner auto-patches.
Backend
- _auto_reason(new_status, username) helper builds
"Manually set to {status} by {username} on {YYYY-MM-DD HH:MM UTC}"
whenever the operator submitted no comment.
- update_vulnerability_status now falls back to auto_reason when
both `reason` and `defer_reason` are blank.
- bulk_update_vulnerabilities gains a `reason` field on
BulkVulnerabilityUpdateRequest. Falls back to auto_reason for the
status component. Emits a per-vuln log_vulnerability_change entry
(source="manual_bulk") so each row's Change History panel shows
WHO bulk-changed it, plus the existing one-line summary audit row
for the admin firehose.
Net effect
- Bulk-patch 50 CVEs → 50 per-vuln audit entries tagged "manual_bulk"
+ 1 summary entry. Audit log + Vuln-detail Change History both
populated.
- Single-patch with empty comment → entry is now
"Manually set to patched by alice on 2026-05-27 11:39 UTC"
instead of bare status diff.
- Scanner auto-patch entries unchanged (source="nessus_sync" /
"wazuh_sync" — already distinct).
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 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 asked whether the existing 'All Statuses' filter pulling in
FP / accepted / deferred rows was intended. It was, but the SOC
workflow always starts from 'show me what still needs action'.
Backend (GET /vulnerabilities ?status=...)
- 'active' (NEW) → open + pending_verification + patch_failed
- 'closed' (NEW) → patched + false_positive + accepted_risk + deferred
- 'all' → no filter
- literal enum → unchanged
- absent param → defaults to 'active' (was 'open')
Frontend
- Dropdown is now optgroup'd: Groups / Active states / Closed states.
- Default state changed from 'open' to 'active' so the list shows
the full actionable surface at first load.
- Send the literal value to the backend (including 'all') instead of
treating 'all' as "omit param".
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.
Tester reported CVE-2023-48795 hitting PuTTY 0.73 AND WinSCP 6.1.2
on the same host — both joined into a single
`vulnerabilities.package_name` string ("PuTTY..., WinSCP...") with
ONE `fixed_version`. Per-package fix tracking impossible.
Schema (migration 023)
- New table `vulnerability_packages` (vuln_id, package_name,
package_version, fixed_version, source, first_detected_at,
last_seen_at). Unique on (vuln_id, package_name).
- Backfill creates one child per existing vuln carrying the joined
string verbatim (no comma-split — joined names may contain commas).
- ON DELETE CASCADE + ORM passive_deletes so asset deletes propagate.
Sync (Wazuh)
- sync_agent_vulnerabilities collects per-package rows in a dict
keyed by package_name and upserts VulnerabilityPackage children
after the parent insert/update.
- last_seen_at updated each sync; future enhancement can prune
packages Wazuh stopped reporting (mirror parent-row stale logic).
Override service
- _apply_single_override propagates Vulnrichment/NVD/cvelistV5
fixed_version into every child package row that has none AND
whose installed_version differs from the proposed fix
(Ghostscript inclusive-bound case).
API
- VulnerabilityResponse gains `packages: List[PackageInfo]` and
`has_fix_any: bool`. _build_vuln_response emits per-package data.
- Forward refs resolved with model_rebuild().
Frontend
- VulnerabilityPackage type + Vulnerability.packages + has_fix_any
- PATCH AVAILABLE badge on list page now uses has_fix_any +
fixed_version != installed_version check (no more false positives
when supposed fix equals affected version).
- Detail page renders a per-package table (Installed / Fixed in /
FIX indicator / source tag) when child rows exist, falls back to
legacy single-package summary for pseudo-CVEs.
No breaking changes — parent columns (`package_name`,
`package_version`, `fixed_version`) stay for legacy queries. Nessus
sync still writes only the parent row; per-package Nessus support
can land as a follow-up.
Tester showed CVE-2024-29506 with 'Exploit (ssvc) +2.0' in the
Priority Breakdown (so exploitation_status='poc' in DB) yet no
POC pill on the vulns-list main page next to KEV/EUVD.
Root cause: VulnerabilityResponse Pydantic schema declared
exploitation_status and exploitation_source, but
_build_vuln_response dict never populated them. Pydantic silently
nulled both → API always returned exploitation_status=null → the
frontend pill guard `vuln.exploitation_status && != 'none'` was
always false → no badge rendered. The SSVC fields ssvc_technical_
impact + ssvc_automatable WERE in the response (added in 70840e0),
which is why TI-TOTAL and AUTO pills did render — but the simpler
POC/ACTIVE/WIDESPREAD pill from the original SSVC enrichment never
showed.
Two lines added to _build_vuln_response. Also adds exploitation_
source to the Pydantic schema so the frontend can show 'corrected
via vulnrichment' vs 'via nvd' / 'cvelistv5' tooltips down the
line.
Pure response-shape fix — no DB or migration. After deploy +
hard-reload tester sees POC pills next to KEV/EUVD on every CVE
that Vulnrichment classified as poc/active/widespread.
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.
Colleague asked for more than 5 entries and proposed splitting the
Recent Vulnerabilities table into two focused widgets so 'what
changed risk recently' and 'what just got published' don't fight
for the same 5 rows.
Backend:
- sort_map gains 'updated_at' (Vulnerability.updated_at) so the
Critical widget can sort by 'risk-relevant DB write timestamp'
— picks up Vulnrichment corrections, KEV listings, manual status
changes etc.
Frontend (page.tsx):
- new renderVulnWidget() helper renders a compact 6-column table
(CVE / Sev / CVSS / PRIO / CPR / Flags) instead of the previous
wide 7-column one. Used twice on the dashboard:
'Recent Critical CVEs' — CVSS ≥ 8 OR KEV OR EUVD, top 10,
sort_by=updated_at desc
'Newly Published CVEs' — top 10, sort_by=published_date desc
- Flags column surfaces KEV / EUVD / SSVC status (WIDESP/ACTIVE/POC)
pills inline so the operator can see actionable indicators at a
glance without opening the detail page.
- Side-by-side on desktop (lg:grid-cols-2), stacked on mobile.
Both widgets dedup by cve_id client-side and pull a wider window
(80 entries) so 10 distinct CVEs land even when a hot CVE hits many
assets. Click on a row jumps to the filtered Vulns list.
Increased from 5 → 10 entries per widget (configurable via the loop
guard if 15 is preferred — set both to 15 and the limit=80 fetch
still covers worst case).
Three colleague-reported issues addressed in one commit.
A) 'Sync Data (Wazuh)' button left no entry in /scans history
while the Nessus button did (since commit 4bca41e). Mirrored the
pattern: run_wazuh_vulnerability_sync now opens a per-agent Scan
row (scan_type=WAZUH, status RUNNING→COMPLETED/FAILED) before
fetching vulns and closes it after the source-aware backfill,
recording vulnerabilities_found = len(active_cves). Skip-backfill
branch records 0 + error_message explaining why.
B) Vulns-list SSVC badge only fired for exploitation_status != 'none'.
~99% of Vulnrichment-curated CVEs have exploitation_status='none'
(CISA flags 'no known exploitation' for most), so colleague's
495 SSVC-source-pinned CVEs showed zero badges. Added two more
pills surfacing the actually-interesting SSVC dimensions:
TI-TOTAL ssvc_technical_impact='total' (attacker → full takeover)
AUTO ssvc_automatable='yes' (reliable mass exploitation)
These render alongside the existing POC/ACTIVE/WIDESPREAD pill.
C) Compliance refresh appears to ignore disconnected agents — likely
not a code filter but Wazuh returning empty /sca/{agent_id}
responses for offline agents. Added per-asset logging when
policies_synced=0 and an assets_no_data counter in the stats so
the operator can see how many agents Wazuh actually has SCA data
for. No code change to the filter — disconnected agents are still
queried, just transparently reported as data-less if Wazuh has
nothing on them.
CISA Vulnrichment scores each CVE on three SSVC decision points:
Exploitation, Technical Impact, Automatable. We were already
persisting Exploitation (exploitation_status), but the other two
were parsed and thrown away — exactly the signal the tester wanted
to use to spot 'attacker takes total control + mass-exploitable'
CVEs at a glance.
Adds:
- Migration 013 (idempotent): two new nullable + indexed columns
ssvc_technical_impact VARCHAR(16) -- partial | total
ssvc_automatable VARCHAR(8) -- yes | no
- Model: matching SQLAlchemy columns on Vulnerability.
- vuln_override_service:
* VerifiedCVEData gains both fields.
* _parse_vulnrichment_record extracts both from the SSVC
'options' list (alongside Exploitation).
* _apply_single_override writes them when present, so the same
'Correct CVSS' run also fills the SSVC enrichment.
- /api/v1/vulnerabilities response (VulnerabilityResponse +
_build_vuln_response): exposes both fields.
- Frontend types + detail page: new SSVC sub-block under Detection
Sources card renders Technical Impact + Automatable with red
emphasis for 'total' and 'yes' (the high-risk values).
Frontend list column for these will follow once we have CPR bonus
weighting (next commit), so the operator sees the score uplift
alongside the badge in one motion.
The synchronous /override/vulnrichment endpoint blocks the browser
request for the full duration of the correction — 23 minutes on the
tester's instance, well past every reasonable client timeout. Result:
'backend connection failed' modal and zero feedback about whether
the work actually ran.
New endpoints (sync one kept untouched for single-CVE backward compat):
POST /override/vulnrichment/start
Returns {job_id, status: 'queued', poll_url} immediately.
Spawns a daemon thread that does the heavy lift.
GET /override/vulnrichment/status/{job_id}
Returns the live snapshot: status, stage, total, done,
updated/checked/not_found, error if any.
GET /override/vulnrichment/jobs
Recent jobs ringbuffer for an admin overview.
State lives in an in-memory dict (override_jobs.py) — survives
requests, not backend restarts. That's fine; a restart would have
killed the worker thread anyway and the user re-clicks. Ringbuffer
caps at 50 entries, oldest finished gets evicted.
Worker owns its own SessionLocal() so it does not collide with the
trigger request's DB session. Stages narrate what's currently
happening ('downloading vulnrichment ZIP snapshot', 'fetching N
CVEs from github raw', etc.) so the frontend can show a meaningful
progress message even though we don't get per-CVE callbacks from
inside correct_vulnerability_scores.
Frontend modal that polls the status endpoint comes in the next commit.
The previous cve_id sort used Postgres lex collation, which placed
CVE-2026-8401 above CVE-2026-35440 because '8' > '3' character-wise.
Tester reported the correct expectation: the higher numeric suffix
should win regardless of length.
Two fixes:
1. sort_by=cve_id now splits the ID on '-' and casts year + number to
int, then orders numerically. Pseudo-CVEs (NESSUS-PLUGIN-*) drop
to the end and tiebreak on the raw string, so the column is still
sortable in a mixed dataset.
2. New sort_by=published_date uses coalesce(published_date,
detected_at) — semantically the most defensible "newest CVE" sort,
since CVE-IDs are not chronological (assigned in batches by CNAs).
Falls back to detected_at when CISA enrichment has not stamped a
real published_date yet.
The dashboard 'Recent Vulnerabilities' widget switches to
sort_by=published_date — what users actually mean by 'newest'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
Tester reported the VPR score never showed up on the vuln detail page
even though the list view rendered it after the previous commit.
Root cause: the detail endpoint declares response_model=
VulnerabilityDetailResponse → Pydantic strips any dict keys not
listed on the parent VulnerabilityResponse schema. _build_vuln_response
correctly emitted sources/cross_confirmed/first_detected_by/
nessus_plugin_id/nessus_vpr_score/exploitation_status, but the schema
silently dropped them. The list endpoint has no response_model so it
passed through untouched — which is why the badge appeared there but
not in detail.
Adds the missing fields to VulnerabilityResponse so detail + PATCH
responses now include them. is_pseudo_cve and assigned_group_name
were also missing and are added in the same pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mass-patch guard from 662742f copied the variable name from
run_wazuh_vulnerability_sync() (where the var is wazuh_vulns) into
sync_agent_vulnerabilities() — but that function uses raw_vulns
internally. Result: every scheduled per-agent rescan crashed with
'NameError: name wazuh_vulns is not defined' since yesterday.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
- Bulk sync was calling map_severity(score) without wazuh_severity
fallback — vulns without a CVSS score got severity=none instead of
using Wazuh's own severity string (High/Medium/Low) as fallback.
Per-agent sync (sync_agent_vulnerabilities) already did this correctly.
- mfa/setup, mfa/activate, mfa/disable had no rate limiting.
With a stolen JWT an attacker could brute-force the password on
mfa/disable (no per-user lockout tracks POST /auth/mfa/* attempts).
Added: setup=5/min, activate=10/min, disable=5/min.
The bulk sync endpoint (POST /api/v1/vulnerabilities/sync) duplicates
the same logic as sync_agent_vulnerabilities and had the same bugs:
- db.rollback() on IntegrityError replaced with db.begin_nested()
savepoint — full rollback was losing all prior inserts for an agent
- New vulns now set sources='["wazuh"]' and first_detected_by='wazuh'
- existing.add_source('wazuh') on merge path for cross-confirmation
- Wazuh no longer reopens accepted_risk/false_positive/deferred vulns
(patched → open, but other manual statuses preserved)
- Patching logic is now source-aware: remove 'wazuh' from sources,
mark patched only when source_list is empty. Nessus-only findings
are not touched.
- Enrichment query scoped to newly_created_vuln_ids instead of
'all vulns without enrichment_updated_at' which could be thousands
Three bugs mirrored from the Nessus sync review:
1. db.rollback() on IntegrityError replaced with db.begin_nested()
savepoint — the full rollback was wiping all previous inserts in the
same agent sync run when a race-duplicate was encountered.
2. New Wazuh vulns now set sources='["wazuh"]' and
first_detected_by='wazuh' explicitly. Previously only the SQLAlchemy
Python default fired (sources) and first_detected_by was always NULL
for Wazuh-created entries after migration 010.
3. Wazuh patching logic now source-aware: instead of bulk-marking every
open vuln not in active_cves as PATCHED (which silently wiped
Nessus-only findings), we now iterate wazuh-sourced open vulns,
remove 'wazuh' from sources, and only flip status=patched when
source_list is empty. Nessus-only findings are untouched.
Also: existing.add_source('wazuh') called on merge path so
cross-confirmation is tracked when Wazuh finds a Nessus-only CVE.
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.
- 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.
Backend (app/routers/vulnerabilities.py):
- _build_vuln_response includes sources[], cross_confirmed,
first_detected_by, nessus_plugin_id, is_pseudo_cve
- New query params on GET /api/v1/vulnerabilities:
?source=wazuh|nessus|manual — filter by scanner attribution
?cross_confirmed=true — only multi-scanner findings
Implementation uses SQL LIKE on the sources JSON text (rows with a
comma in the JSON list have >=2 sources, sufficient for our scale).
- New endpoints:
PATCH /api/v1/vulnerabilities/{id}/false-positive
PATCH /api/v1/vulnerabilities/{id}/unmark-false-positive
Both audit-logged. False-positive marks set
notification_suppressed=true automatically (no SLA-breach spam).
Frontend:
- frontend/types/index.ts: sources, cross_confirmed, first_detected_by,
nessus_plugin_id, is_pseudo_cve fields
- frontend/app/vulnerabilities/page.tsx:
* Filter bar gains a Source dropdown (all/wazuh/nessus/manual) and
a Cross-confirmed checkbox
* New 'Source' column between Status and Assigned-To with per-scanner
badges (Wazuh green, Nessus purple, manual gray), ✓×2 emerald
badge when cross-confirmed, NON-CVE amber badge for pseudo-CVE
(Nessus EOL / Compliance / Cipher findings)
* NoSymbolIcon button in Actions: click → prompt for reason →
PATCH /false-positive. Re-click on a false-positive vuln reverts
back to open.
This makes the Wazuh ∪ Nessus merged view actionable: admins see which
findings have multi-scanner confirmation and can mark Nessus-only false
alarms without affecting Wazuh-confirmed entries.
Replaces the per-CVE inline email send during Wazuh sync with a batched
digest dispatcher that runs once at the end of the sync.
- Old behavior (now opt-in via notification_mode=single): one email per
CVE per recipient. A 100-CVE sync with 3 assignees = 300 mails, often
triggering SMTP rate limits (Gmail/Outlook/Proton ~20/min).
- New default (notification_mode=digest): one email per recipient with
a styled HTML table listing every CVE relevant to them (severity
counts at the top, sorted rows below, button to dashboard). 100 CVEs
× 3 assignees collapse to 3 mails.
Implementation:
- email_service.dispatch_new_vuln_notifications(db, new_vulns):
resolves recipients per vuln (vuln.assigned > group > asset.assigned
> asset.groups), applies notification_min_severity, groups by email,
and sends. Logs one NotificationLog per recipient.
- email_service.send_new_vulnerability_digest() renders new
DEFAULT_DIGEST_TEMPLATE (responsive HTML, severity badges, table).
- email_service.get_notification_mode() reads notification_mode setting.
- Sync paths (run_wazuh_vulnerability_sync, sync_agent_vulnerabilities)
now accumulate newly_created_vuln_ids and call the dispatcher once
after the loop instead of mailing inline.
- Settings UI gains a 'Delivery Mode' dropdown next to the severity
threshold.
EUVD (EU Vulnerability Database, ENISA) integration as second
authoritative catalog alongside CISA KEV. EU-Compliance use cases
benefit from a non-US source; CVEs confirmed by both catalogs get
the highest priority via score stacking.
Two ENISA endpoints are merged into one cached map (24h TTL):
- /exploitedvulnerabilities (analogous to CISA KEV)
- /criticalvulnerabilities (ENISA Critical flag)
Priority-Score formula:
- Exploit-Signal now triggered by KEV OR EUVD listing
- Catalog-Bonus stacking: KEV +10, EUVD +10, KEV-ransomware +5,
EU-Critical +3. A CVE in both catalogs adds +20 base.
CPR Score (Cybersecurity Priority Risk = CVSS x EPSS x 10) added
as separate metric next to Priority, per JacquesKruger/EPSS-Server
convention. Calculated on-the-fly, no DB column needed.
New API filters: euvd_only, eu_critical, in_any_catalog,
in_both_catalogs. Setting toggle enrichment_euvd_enabled (default true).
Frontend: new EUVD column (blue badge, EU-CRIT sub-badge), CPR column
with mini-bar, four catalog filter checkboxes. Detail page splits
threat intel into CISA KEV / ENISA EUVD / EPSS sections; breakdown
shows EUVD bonus row and CPR score with both-catalogs hint.
Add a settings-page dropdown (Critical / High+ / Medium+ / Low+) that
controls which severities trigger a 'new vulnerability' email during
Wazuh sync. Stored in the existing settings KV table under
'notification_min_severity', default 'critical' (no behaviour change
on upgrade).
Backend:
- email_service.should_notify_for_severity(db, severity) reads the
threshold from settings and compares using a severity rank.
- Both sync paths in vulnerabilities.py now delegate to the helper
instead of the hard-coded 'critical only' check (the comment
already lied about 'CRITICAL or HIGH').
Frontend:
- New control in the Notification Settings section, persists via the
generic /api/v1/settings/{key} PUT endpoint.
Risk score now pulls from multiple threat intel sources instead of
only AI/CVSS data:
- EPSS (FIRST.org) — probability of exploitation in next 30 days
- CISA KEV — known actively exploited vulnerabilities (with ransomware flag)
- Existing Wazuh exploit flags as fallback
Adds DB columns (epss_score, epss_percentile, kev_listed, kev_*,
enrichment_sources, enrichment_updated_at), an enrichment_service
with cached KEV catalog (24h TTL in settings table) and batched
EPSS lookups, manual + bulk + KEV-refresh endpoints, automatic
enrichment after Wazuh sync, and a daily scheduler job to refresh
scores.
Frontend gets KEV badges, EPSS column with percentile, KEV-only +
EPSS-min filters, a "Refresh Threat Intel" button, and a priority
score breakdown card on the detail page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add Wazuh severity string as fallback when CVSS score is missing,
preventing critical vulns from being classified as "none"
- Fix solved-filter to check only the LATEST alert status per CVE
instead of filtering all CVEs that were ever solved (fixes re-emerged
vulns being incorrectly hidden)
- Try both score.base and score.base_score for indexer compatibility
- Add detailed sync logging (severity breakdown, new/updated counts)
- Show time alongside date for detected_at in frontend and PDF reports