Four findings from one estate, and three of them are the same shape: the scan
had nothing to compare, so it said nothing, and nothing reads as clean.
* IMI GET /firmwares/ answers HTTP 404 on UMS 12 ("No endpoint GET
/umsapi/v3/firmwares/"). The documented URL has no trailing slash. The
firmware table is the ONLY place a device's version string lives, so the
slash cost every version in the estate at once.
* IGEL OS 12 states its base-system build inline — 12.6.0+2 — and
_clean_version's dotted-numeric rule threw the whole string away. No
version, no scan: eleven devices on 12.6.0+2 showed zero CVEs while
sitting inside the range of both current ISNs. Semver excludes build
metadata from precedence and every IGEL bound is written without it
(12.7.6, 12.8.3), so the suffix is dropped, not rejected. 12.9.0+3 stays
clean, which is the correct answer and not a miss — 12.9.0 IS the fix.
* The UMS server's own version came back empty and its build not at all,
so the server asset carried no version and Test read "UMS (build ?)".
Nothing failed: serverstatus answers 200 either way. The documented IMI v3
keys are tried first, then the spellings UMS 12 has been seen to use, and
a payload that carries none of them now logs the keys it did carry — the
next rename should cost one log line, not an estate.
The fourth is not a bug but the question the CVEs are a footnote to.
IGEL OS 11 stops receiving security fixes on 2026-06-30. No CVE feed will ever
state that, because "unpatchable from here on" is not a CVE, and
endoflife.date carries no IGEL product at all — so the dates are transcribed
from IGEL's own Knowledge Base.
The mapping is the part that needed care, because IGEL's two terms are not the
two this codebase already has, and taken the obvious way round they invert:
EOL - End of Life no further ENHANCEMENTS; security fixes still ship.
OS 11 hit this in April 2023 when OS 12 launched,
and stayed patched for three more years. -> eoasFrom,
informational, LOW.
EOM - End of Maintenance "no updates, no security and bug fixes." -> eolFrom,
a real finding.
The 2025-12-31 that circulates for OS 11 is IGEL's original date; the vendor
page now states 30th June, 2026. Third-party migration write-ups still carry
the old one. The vendor's page wins.
OS 12 gets an entry with no EOM, because IGEL has published none and an
invented date would be the only unsourced one on the page. It raises nothing.
UMS 6 (EOM 2023-10-31) does — that one is 1037 days past.
A migrated device has no other way out of its old finding: a thin client has
no software inventory, so the EOL sweep's own reconcile never reaches it. Both
slugs are single-release, and the pass supersedes explicitly when the line it
now runs is one that raises nothing.
Findings say "IGEL product lifecycle", not endoflife.date. A row that named a
source which has never heard of the product sends an operator to a page that
cannot confirm or refute it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Tester report: EOL-SAMSUNG-GALAXY-TAB-galaxy-tab-a8 and
EOL-SAMSUNG-MOBILE-galaxy-xcover5 flipping patched -> open -> patched in the
audit log, seconds apart, every sync.
Both events come from one run over one device. check_device raises the
device-model EOL finding through the normal EOL upsert, so it is stored with
first_detected_by=eol_check like any other. The detectedApps sweep in the same
loop iteration then reconciles the asset's EOL findings against the app
inventory it just read — and a phone model is in no app inventory, so the
finding reads as "no longer installed" and is closed again. The device ended
every sync with its EOL hidden, which is the false negative behind the report.
reconcile_eol_findings already has the channel for this (seen_ids, used for
the OS-level EOL row); the mobile check simply never fed it. It does now.
Its docstring claimed mobile findings were out of reach of the reconcile,
which is what kept the bug invisible — corrected.
Detection itself was right both times, and stays right: Tab A8 (SM-X205) is
EOL since 2026-02-21 (high), XCover5 (SM-G525F) is only past active support
since 2024-03-12 and still gets security patches, so it is the low
"end-of-active-support" finding, not EOL.
Existing rows heal on the next sync (reopen_if_patched).
A hypervisor runs no agent. Wazuh cannot reach it, Intune does not know it,
and Nessus only sees it if someone scoped a credentialed scan at it — so the
machines whose compromise takes every VM on them down were the ones with no
vulnerability coverage at all.
The connector reads the vCenter appliance and every ESXi host in its
inventory over the vSphere SOAP API (pyVmomi): hostname, management IP,
hardware model, product version and BUILD. One PropertyCollector pass, so a
500-host estate is one round trip. Read-only rights are enough.
The build is the point. NVD carries nothing usable — CVE-2026-47876 and
CVE-2026-59310 both sit there with no configuration — and the bounds
cvelistV5 does state are build identifiers, not versions:
ESX 8.0 lessThan "ESXi80U3k-25595708"
vCenter 8.0 lessThan "8.0 U3k"
_is_version rejects both, so _ranges_from_affected dropped the entries and
every vSphere CVE was invisible. They are now indexed verbatim and resolved
to build numbers: ESXi bounds carry one inline, vCenter bounds name a release
whose build comes from Broadcom KB 326316 (seeded in full, re-read weekly,
merged never replaced). A bound that resolves to no build produces no
verdict, and neither does a host with no build recorded.
Comparing builds alone is wrong in both directions. CVE-2025-22224 names two
fixes for the 8.0 line at once — U3d (24585383) for 8.0.3 and U2d (24585300)
for 8.0.2 — so the U2d host is patched despite the higher number existing.
And vCenter 8.0 U2f shipped four days AFTER the U3k fix on the older update
line, with a higher build, because Broadcom ships async patches there. So the
decision is scoped to the update line first, then still checked against the
build.
What must never resolve is asserted in the tests: the same advisories file
"VMware Cloud Foundation (vCenter Server)" and "vSphere Foundation" with the
SUITE's version numbers, and matching those would compare a vCenter 8.0.3
appliance against a VCF 5.x range.
EOL comes from endoflife.date (esxi / vcenter), keyed by major line — which
is also the honest granularity, one end-of-support date per line. 7.0 ended
2025-10-02, 6.7/6.5 in 2022. Both slugs are single-release, so an upgrade
retires the old finding. The OS patterns are shared with the CVE pass so a
machine cannot be a hypervisor for its CVEs and something else for its dates.
Checked against all 189 vSphere CVE records currently in cvelistV5: 64 bounds
indexed, none unresolvable, and every current release comes out clean while
each one behind gets its own line's fix named.
Migration 045 adds AssetSource.VCENTER, assets.vmware_uuid (the pin) and
assets.vmware_build.
Tomcat had no entry in either registry, so neither path ever looked at it:
resolve() returned None on the cvelistV5 side and there was no CPE row on the
NVD side. A Windows install ("Apache Tomcat 9.0 Tomcat9 (remove only)",
9.0.107) was invisible to both — every Tomcat CVE, on every host, had no
source at all.
The reference case is CVE-2026-34486, a regression in the fix for
CVE-2026-29146 that lets the EncryptInterceptor be bypassed. The ASF names
three EXACT versions there (11.0.20, 10.1.53, 9.0.116) and nothing else, which
_ranges_from_affected already reads as three closed [v, v] ranges — so 9.0.107
is correctly NOT flagged for it, while the older ranges it genuinely sits
inside (CVE-2026-29146: 9.0.0.M1 .. <=9.0.115) do match.
The floors are what keep the release lines apart. Every ASF record is stated
per line, and each floor is that line's first milestone, written either
"9.0.0.M1" or "11.0.0-M1" — _vtuple reads both as 9.0.0.1 / 11.0.0.1, so a 9.0
host can never fall inside the 11.0.18 bound. Vendor appears both as "Apache
Software Foundation" and as plain "Apache" (CVE-2020-1938), so both are paired.
Kept narrow: the name is anchored and the satellites are excluded. Tomcat
Connectors (mod_jk), Tomcat Native and TomEE are separate products with their
own version lines, and comparing an mod_jk 1.2.x against a 9.0.x range would
be nonsense.
EOL: the slug had to go ABOVE "apache" in the map. The resolver walks it in
insertion order and takes the first anchored hit, so "Apache Tomcat 9.0 …"
starts with "apache" and every Tomcat was being checked against the Apache
HTTP Server lifecycle — a different product on a 2.4.x version line that no
Tomcat build can ever match. endoflife.date keys the releases by line, which
the reported version prefixes directly: 9.0 is supported until 2027-03-31 (so
the host above gets no EOL finding), 8.5 died 2024-03-31, 8.0 in 2018, 7 in
2021.
Index key bumped to v26 so the cached index is rebuilt with the new product
instead of serving the old one for another 26 hours.
The tester's host runs SQL Server 2008 R2 and SQL Server 2012, both years past
end of life, and every EOL finding on it was retracted overnight as "MS
lifecycle no longer matches this installed product (re-evaluated — not
end-of-life)". Nine critical findings to zero, with the software still
installed.
Two causes, both of them a name the sweep refused to look at:
1. The listing had to PREFIX the inventory name (the SAP-GUI fix), and none of
this host's evidence is shaped that way. A Microsoft update names its parent
product AFTER the connector: "Service Pack 1 for SQL Server 2008 R2
(KB2528583)", "Hotfix 7493 für SQL Server 2012", "SQL Server Browser for SQL
Server 2012". Every component that names the product as a prefix is an
add-on the sweep already refuses (Native Client, RsFx Driver, Setup), so the
updates were the only evidence left, and they matched nothing.
What follows "for" may now claim a listing — but only when Microsoft is the
vendor. That is what tells the two cases apart: SAP's client is vendor "SAP
SE", a service pack for SQL Server is "Microsoft Corporation". An unknown
vendor changes nothing, the strict prefix rule stands, and an operating-
system listing is never claimed by a package (an app "for Windows 10" is not
Windows; the OS has its own EOL path).
2. A package with no version was skipped before any source saw it — and the
engine's own entry, "Microsoft SQL Server 2012", carries no version at all.
The name-only sources need none, so a versionless entry now reaches them;
endoflife.date, which resolves the release FROM the version, still does not
get a vote on one.
The vendor is stored on the finding, because the nightly revalidation re-asks
the resolver from the ROW, not from the inventory. Without it every finding
would be re-judged under a stricter rule than the one that created it and close
the same night it appeared — the open→patched flap, with nothing changed on the
host. Findings written before this change carry no vendor, so revalidation
leaves those names alone rather than closing them on an answer it cannot have.
Verified against the live export: all nine SQL Server findings return with
Microsoft's own dates (2019-07-09 and 2022-07-12), and the SAP GUI, Edge,
ODBC/OLE DB drivers, Teams, Store for Business, Skype for Business and Windows
Admin Center controls stay clean.
Intune lists published apps as "Microsoft Access 2010 / 1.0 / Delivered by
Citrix". The CVE scanner drops those rows because the version is a
placeholder, and the EOL sweep reused that same filter — so two products
years past end-of-support produced no finding at all.
The version is the untrustworthy half, not the name. A stub is now kept and
restricted to the name-only sources (the Microsoft lifecycle export and the
hardcoded exotics); the endoflife.date path, which derives the release from
the installed version, is skipped for it. A stub named just "Firefox" still
matches nothing, which is what the filter was written for.
"SAP GUI for Windows 8.00 64bit (Patch 17)" was reported CRITICAL, EOL for
3866 days, security support ended 2016-01-12 — Windows 8's date, on a client
SAP shipped in 2023 and supports until 2027.
Cause: the MS-lifecycle resolver matched a listing anywhere inside the product
name. Normalisation turns the name into "sap gui for windows 8 00 64bit patch
17", which contains the "Windows 8" listing verbatim (the dot in "8.00" becomes
a space, so the version starts with the token "8"). Every "<vendor> for
<Microsoft product>" install could inherit a Microsoft end-of-life date the
same way. The listing must now be a token-aligned prefix of the product name;
verified against the live export, this drops the SAP row and keeps every true
finding (Exchange 2016, SQL Server 2014/2019, Lync 2013, SharePoint 2016,
Windows Server 2016, System Center 2012 R2, Report Viewer 2012).
The same finding also cited the wrong source: description and source badge read
"endoflife.date" while the id read EOL-MS-LIFECYCLE-…, so the row named a
source that had never seen the product. Both now come from the slug.
Open false positives close themselves on the next EOL check —
revalidate_ms_lifecycle_findings re-asks the resolver per finding.
The EOL package loop existed three times — the eol-check endpoint, the
nightly job, and run_eol_for_packages — and had drifted the way copies do:
only the endpoint filtered published-app stubs out of the inventory, so the
nightly job could raise findings from placeholder versions the button would
never produce; the flap guard added yesterday had to be threaded into each
copy by hand; and the regression test could only cover a re-implementation
of the loop, not the loop itself.
Now all three callers go through run_eol_for_packages, which returns its
counters, and the test drives the same function production does. What the
callers legitimately differ in became two arguments:
reconcile — retract findings this sweep did not re-confirm. Only for a
caller holding the WHOLE inventory. Intune's detectedApps is partial, so
on a co-managed host it would retract the Wazuh sweep's findings and the
next Wazuh run would reopen them — the same flap, one layer up.
seen_ids — findings the caller produced itself (the OS-level EOL row),
which the package list can never re-confirm. The test asserts both
directions, so a caller that forgets to seed it fails rather than quietly
reopening every OS finding each night.
Inventory filtering moved into the sweep, where no caller can forget it.
Office, SharePoint and Exchange take their release from the year in the
inventory NAME, so every companion package that happens to carry a year
claimed that release was installed: "Microsoft Office 2007 Primary Interop
Assemblies", "Microsoft Exchange Server 2010 MAPI Client and CDO",
"Microsoft SharePoint 2013 Client Components SDK". Each produced a CRITICAL
EOL finding for a product the host does not run — the tester's Exchange SE
host, fully supported, carried an EOL-MSEXCHANGE-2007.
Naming the add-ons one at a time is a race nobody wins; there is always
another rollup. What separates the product from its companions is what
FOLLOWS the year: the product name ends there, or continues with an edition
or update qualifier ("2016 Cumulative Update 23", "Professional Plus 2016 -
de-de"). Any other word after the year belongs to a different product that
merely names this one. Both resolvers consult the same rule, so a companion
is refused whichever source would have matched it.
Second half of the same report: the status loop. These slugs are
single-release, so each companion superseded the others' finding once per
sweep — 2007 closes 2016, 2016 reopens and closes 2007, every run, with
nothing changing on the host. Supersede now leaves alone any finding the
same sweep already confirmed: the sweep saw both products in one inventory,
so neither replaced the other. Dropping the companions removes the cause;
this removes the mechanism.
The real entries are untouched — "Microsoft Exchange Server 2016 Cumulative
Update 23" and the plain "Microsoft Exchange Server" both still resolve to
release 2016 (EOL 2025-10-14), so the host's genuine finding stands.
".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.