".NET" normalises to "net" — _normalise_name strips the dot — so the
curated "dotnet" / "dotnetframework" slug keys could never match a real
inventory entry ("Microsoft .NET Runtime - 8.0.29 (x64)" normalises to
"microsoftnetruntime8029x64"). Every .NET and .NET Framework install was
skipped: no finding for the dead releases, none for the live ones either.
A prefix key cannot repair it — "microsoftnet" would also swallow
"Microsoft Network Monitor" — so both products are matched by regex, kept
tight enough that "Microsoft Visual Studio .NET 2003" and "Microsoft .NET
Micro Framework Porting Kit" keep falling through to the Microsoft export.
Second defect behind the first: modern .NET reports an MSI build in the
version field (9.0.18 ships as 72.72.55158), so no release prefix-matches
it even with the slug fixed. The release comes from the display name, the
same way app_cve_scanner_service already handles these products.
Developer-side packs (Targeting / Developer Pack, Reference Assemblies)
are excluded: they carry the runtime's name with an old version number and
are build inputs, not an installed runtime.
And the source rule: for these two products endoflife.date decides alone.
Microsoft's export carries no .NET row today and its lifecycle page agrees
with endoflife.date on .NET 9 (2026-11-11), but a future export row must
not be able to retire a supported release early.
Verified against the reported inventory: .NET 8/9 -> EOL SOON (2026-11-10),
.NET 10 -> supported to 2028-11-14, Desktop Runtime 6.0.36 -> EOL.
A host running Exchange Server Subscription Edition — fully supported —
still lists "Microsoft Exchange Server 2007 Standard Anti-Spam Filter
Updates", "Microsoft Exchange 2007 Enterprise Rules Updates", a dozen
language packs and a Lync bootstrapper in its inventory. Every one of
those matched a product: the Exchange ones resolved to the msexchange
slug with the year pulled straight out of the name, so the tester's SE
host carried CRITICAL EOL-MSEXCHANGE-2007 findings for a product that is
not installed, and "Microsoft Lync Server 2013, Bootstrapper
Prerequisites Installer Package" — which ships with an Exchange install
— was read as a Lync 2013 server.
The flapping followed from that. msexchange is a single-release slug, so
the 2007, 2010 and 2016 rows superseded each other in turn, one status
flip per add-on per run, filling the change history with alternating
open/patched entries while nothing on the host changed.
is_component_package() is consulted by BOTH resolvers, so a component is
refused whichever source would have matched it. The real product entries
sit in the same inventory ("Microsoft Exchange Server", "… 2016
Cumulative Update 23"), so the true 2016 finding survives — it is now
attributed to the server instead of to a Greek language pack. Findings
already open close themselves on the next sweep: the package is gone
from the inventory, which the reconcile reads as uninstalled.
Second bug in the same area: MS-lifecycle findings were keyed
EOL-MS-LIFECYCLE-{release}, and that export says "Original Release" for
almost everything. Every MS-lifecycle product on a host therefore shared
ONE row, whose title, package and description were overwritten by
whichever package the sweep touched last — the tester's Lync 2013
finding kept turning into Visual C++ 2012 and back, with a status change
logged each time. Keyed per product now; rows written under the old
shared id are closed by the revalidate pass with their own reason rather
than left as duplicates.
Firefox and Chrome ship every four weeks, and the previous version stops
receiving patches the day the next one lands. By endoflife.date's definition
every install except the very newest is end-of-life — the tester's host moved
Firefox 150 to 152 and the finding came straight back as EOL-FIREFOX-152 at
CVSS 9.0, on its way to critical (9.8) as the day counter climbed.
That put "one release behind on a browser" above Exchange Server 2016, which
really is abandoned. Both were HIGH, and the browser was overtaking it purely
by age.
The finding stays — an unpatched browser is worth knowing about — but it is
capped at medium for these two, and the days-past-EOL escalation no longer
applies to them: on a four-week cycle that counter measures release cadence,
not growing risk. The text says so too ("pending update, not an abandoned
product") and names the current release. For these products the CVE scan is
the precise signal anyway; it says which hole is open rather than only that
the version is stale.
Scoped to a named set, so anything on a real support lifecycle — Windows,
Exchange, Office, SQL Server, Node, Java — keeps the full weight.
EXCHANGE SERVER 2016 STILL LOOKED SUPPORTED
Fixing the slug was necessary but not sufficient. endoflife.date names the
msexchange releases 2016 / 2019 / subscription while syscollector reports
15.1.2507.6, so prefix-matching a version against a year can never hit — the
same shape as Office and SharePoint, which is why those are already year-keyed.
Exchange is now too.
That alone still misses the plain "Microsoft Exchange Server" entry sitting
next to "…2016 Cumulative Update 23" in the same inventory: same install, no
year in the name to key on. So a build number is also matched against the
major.minor of each release's latest build — 15.1 IS 2016, 15.0 is 2013.
Deliberately returns nothing when two releases share it: 2019 and Subscription
Edition are both 15.2, and guessing would put a supported SE host on a release
that went end-of-support in 2025.
UNINSTALLED SOFTWARE KEPT ITS EOL FINDING
Supersede closes the old release when a newer one appears, which is why
Chrome 150 -> 151 has always worked. It cannot cover an uninstall: nothing
newer shows up, so nothing supersedes the finding. Since supersede was
restricted to single-release products, side-by-side software had no route out
at all.
The inventory answers it — a product the scan did not see is not installed —
but only when there IS one. An empty package list is a failed or not-yet-
populated fetch, never proof that a machine runs no software; closing on it is
exactly the mistake the app scan made with a re-registered Wazuh agent, so the
same precondition applies here.
Only findings this scan owns are retracted, leaving mobile/Intune EOL findings
alone. Wired into both the GUI check and the nightly job so the two agree,
counted as eol_uninstalled_closed.
Supersede assumed "a product runs exactly one release per asset". True for a
browser or an OS, false for most Windows components: the tester's host carries
Visual C++ 2008, 2010, 2012, 2013, 2015 and 2022 redistributables at the same
time, all genuinely installed and several genuinely EOL.
So the two halves of the EOL check fought each other inside a single scan.
Processing the 2008 entry closed the 2013 finding as "superseded — asset moved
to a newer release"; processing 2013 reopened it and closed 2008; and so on
for every pair, every night. The change history filled with 50 alternating
rows, the audit log with hundreds, and the "newer release" a 2013 product was
supposedly superseded by was 2008 — five years older. Nothing on the host had
changed at any point.
Supersede now applies only to products a device runs one of (browsers, the OS,
Exchange, SharePoint). For anything installed side by side, an older release
still being present is the normal state, not a leftover, and its finding stays
open — which is also the correct answer, because that software really is
installed and really is end-of-life.
Not addressed here: nothing closes an EOL finding when a side-by-side product
is actually uninstalled. That needs the EOL check to reconcile against the
inventory the way the app scan does, rather than inferring removal from the
presence of a different release.
The product mapping was hand-written and never checked against the actual
catalogue, so a quarter of it was wrong. Every EOL run asked for ms-office,
exchange-server, java, visual-cpp, adobe-acrobat, apache and iis, and got 404
back each time — visible in the log as "not in catalog", invisible everywhere
else. The scan simply found no lifecycle data and moved on, which is why
Microsoft Exchange Server 2016 came out looking supported.
Checked against the live list (462 products) rather than guessed:
exchange-server -> msexchange (the reported Exchange 2016 case)
ms-office -> office
apache -> apache-http-server
java -> oracle-jdk
The other three do not exist there at all and never will: endoflife.date has
no record for Adobe Acrobat, the Visual C++ redistributables, or IIS. Their 18
mapping entries are removed rather than left to 404 on every run — IIS ships
with Windows Server and is covered by that entry, the other two depend on the
Microsoft export or a vendor page. Same reasoning as the existing Edge note.
tools/check_eol_slugs.py verifies the whole mapping against the live catalogue
and exits non-zero when a slug disappears, so this cannot rot again unnoticed.
endoflife.date carries no record for Edge — the 'microsoft-edge' slug 404s,
and no entry in their catalogue matches 'edge' at all — so every EOL check
spent a request to find that out.
Nothing is lost: Edge follows the Modern Lifecycle Policy and has no
end-of-life date while it stays current, which makes 'is this version too old'
a patch question. The CVE scan already answers that one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1) The Edge→'Azure Stack Edge' false positive survived every nightly run.
c3cd62c added a reconcile, but it lives in run_eol_for_packages while
the EOL-check button has its own loop and never called it — so a
finding produced by a since-corrected name match stayed open forever.
New revalidate_ms_lifecycle_findings() re-asks the resolver for every
OPEN EOL-MS-LIFECYCLE finding and closes the ones that no longer
match, independent of which path ran. Called at the end of the EOL
check; audited with the host name.
2) The endless GET .../lifecycle/products/export/ run: the in-process
memo was only set on SUCCESS, so once the fetch or the xlsx-link
lookup failed, every one of the thousands of scanned packages
re-fetched the page — which is also why the UI eventually gave up with
'Backend connection failed' while the backend kept going. Failures are
now memoised too (negative cache), so a broken source costs one
request per hour instead of one per package.
3) EOL dialog text was stale: it named only endoflife.date and said
nothing about the MS-lifecycle export, the built-in exotics, the OS
check, auto-closing, or that Intune-only devices are covered elsewhere.
4) CVSS-correction dialog: one numbered source per line, as requested.
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).
run_eol_for_packages only ever ADDED findings; _supersede_old_eol fires
only when a replacement is created. So after the Edge→Azure-Stack-Edge
name-match fix, the wrong 'Microsoft Edge = EOL' finding stayed open
forever (no replacement is produced for a now-unmatched product).
Add a reconcile scoped to the EOL-MS-LIFECYCLE- prefix (package path
only; OS-level endoflife.date findings use other slugs and are
untouched): resolve open MS-lifecycle findings the current run didn't
re-produce. Guarded on a non-empty package list so a failed inventory
read can't mass-close.
Adopts the endoflife.date/Wazuh EOL-risk model: a product that has been
past end-of-life for 1000+ days is now CRITICAL (cvss 9.8) rather than a
flat HIGH — the longer it runs unpatched, the higher the standing risk.
Titles carry the age ('EOL 1500d'). EOL findings also cite the control
failure (PCI-DSS 6.3.3, NIST 800-53 CM-8, HIPAA 164.312(a)(1)) so audit
reports can reference it. EOL-SOON / EOAS tiers unchanged.
Neither the MS-lifecycle export nor the slug lookup knew SharePoint, so
Foundation/Server installs never produced an EOL finding — tester hit this on a
SharePoint Foundation 2013 box (EOL since 2023-04-11) with 2016/2019 going EOL
2026-07-14. endoflife.date does track it as 'sharepoint'; two gaps had to close:
- add the slug aliases (Server / Enterprise Server / Foundation / Designer)
- mark it year-keyed: releases are years ('2016') while the install reports a
build ('16.0.5556.1005'), so prefix-matching the version against a cycle can
never hit — the year comes from the product name (same shape as Office, and
worse: 2016, 2019 and Subscription Edition ALL report 16.0.x, so the version
alone cannot even tell the releases apart)
Subscription Edition is still supported and is skipped by the existing
wrapper-token guard, so it can't produce a false EOL. Dates verified against
the live endoflife.date API.
Two Chrome-on-mobile issues:
1. Duplicate EOL findings (EOL-CHROME-149 AND -150 on one iPad running only
150). A product runs one release per asset, so upsert_eol_vulnerability now
supersedes any other open EOL-<slug>-* on the same asset (marks it patched +
audit-logs it). Generic — also cleans up OS major upgrades (Server 2016->2019
etc.). Fixes the tester's 'why two EOL' question.
2. Android Chrome went undetected: Intune reports it by package id
'com.android.chrome', which didn't match the 'google chrome' registry regex.
Add it to both scanners (curated NVD-CPE + cvelistV5). Android shares Chrome's
version numbers and security fixes with Desktop (per Google), so the same
google:chrome ranges apply. Bare 'Chrome' (iOS, WebKit-backed) stays
unmatched by design.
Intune managed devices often run without a Wazuh agent, so their installed
software was invisible to EOL/M365 detection. Now the Intune sync reads
each device's detectedApps (Graph $expand=detectedApps) and runs them
through the existing detection.
- eol_service.run_eol_for_packages(db, asset, packages): source-agnostic
per-package EOL (endoflife.date → MS-lifecycle/exotics fallback), same
precedence as the eol-check endpoint.
- m365_service.run_m365_for_packages(db, asset, packages): source-agnostic
M365-Apps CVE detection (build-vs-channel) + real-metric correction.
- intune_service: detectedApps enrichment now on by default (toggle
"Detected apps (EOL/M365)" in the Intune settings card).
Findings are CVE-level, so OSV/MSRC/Ubuntu remediation enrichment and the
normal EPSS/KEV/date enrichment apply automatically. No migration.
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 batch 2026-06-03 (screenshot).
#2 "Newly Published" table jumbled after a Nessus sync. Cause: the
published_date sort used COALESCE(published_date, detected_at).
Nessus imports old CVEs (2014-2023) with published_date NULL +
detected_at=today, so COALESCE made every freshly-imported old CVE
rank as "published today" and flood the top in arbitrary id order.
Now sorts by real published_date with nulls-LAST — null-date rows
sink instead of masquerading as newest.
#1 Adobe Reader EOL finding showed as EOL-NESSUS-56213 instead of a
product slug. Nessus plugin 56213 names the product "Adobe Reader"
(no "Acrobat"), so the acrobat-prefixed slug keys never matched.
Added adobereader / acrobatreader → adobe-acrobat aliases.
#3 (asset INACTIVE not set after a reduced-scope Nessus sync) — the
reconcile_missing_from_sync path + AssetSource.NESSUS re-tagging +
migration 028 already handle this in current dev; tester deploy is
likely behind. Needs: deploy latest + alembic upgrade, then re-test.
If still broken, the "asset sync-reconcile (nessus): N ACTIVE assets
have no nessus_host_uuid pinned" log line distinguishes upstream
(unpinned) from logic.
- _PRODUCT_SLUGS extended with visual-cpp aliases (vcredist,
microsoftvisualc..., msvcr, msvcp) so 2005/2008/2010 etc. flow
through to endoflife.date's visual-cpp product.
- _WRAPPER_TOKENS adds nativeclient, setupsupportfiles, setup,
premium, clicktorun, subscription — these name a tracked product
but have a different lifecycle, so they were producing false EOL
matches (e.g. SQL Server 2008 R2 Native Client inheriting SQL
Server's EOL date).
Fixes feedback 2026-06-02 #6 (Coverage Gap missing EOL entries).
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.
'Veeam Explorer for PostgreSQL 13' (v13.1.5.2) was matched to the
postgresql endoflife slug because resolve_product_slug did an
unanchored substring scan ('postgresql' appears in the name) and the
tool's own version 13.x prefix-matched PostgreSQL release 13 -> bogus
HIGH 'EOL-POSTGRESQL-13' finding. The version is Veeam's, not Postgres'.
Two guards: a _WRAPPER_TOKENS denylist (veeam, explorer-for, connector,
odbc, jdbc, driver, ...) that skips name-dropping tools outright, and
the substring scan is now prefix-anchored so a product name must START
with a known key. 'Microsoft SQL Server 2016 Express' and the Office
year match still resolve; '... for PostgreSQL' no longer does.
eol_service: Office (and other year-keyed products) report a build
number from Wazuh syscollector (16.0.4266.1001) while endoflife.date
keys releases by year (name/label '2016'). The numeric prefix match in
_pick_release never matched, so Office 2016 EOL went undetected. Add a
year-from-product-name resolver (_YEAR_KEYED_SLUGS, _extract_year,
_pick_release_by_year) used before the numeric fallback. SQL Server and
other numeric-versioned products are unaffected.
dashboard: long EOL pseudo-CVE ids (EOL-WINDOWS-SERVER-2016) plus
whitespace-nowrap forced the 'Newly EOL/EOS' widget wider than its grid
column, scrolling CPR/Flags out of view. CVE cell now break-all.
assets: 9 columns at px-6 padding (~432px) overflowed max-w-7xl and
clipped the Actions column. Reduced table padding to px-3.
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.
Tester report: EOL check 500'd with
duplicate key value violates unique constraint 'ix_settings_key'
Key (key)=(eol_cache_mssqlserver) already exists.
Cause: a single asset has many SQL-Server-related packages (engine,
setup bootstrap, VSS writer, …) all mapping to the same slug
'mssqlserver'. _cache_get returned None for each in quick succession
(autoflush quirk on this session), each path added a Setting row,
the eventual flush hit the unique constraint.
Fix
- _PROCESS_MEMO dict in module scope — first fetch per slug per
backend-process is the only one that talks to httpx + the DB. All
subsequent lookups for the same slug return the in-RAM dict.
- _cache_put rewritten as PostgreSQL ON CONFLICT DO UPDATE upsert
(sqlalchemy.dialects.postgresql.insert) so even races between
concurrent requests can't duplicate-key.
- Failed cache write rolls back its own attempt instead of poisoning
the outer transaction.
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.