The asset sync fetched active agents only, and the sync-driven reconcile
inactivates every agent id the sync does not report. Since INACTIVE assets are
hidden from every CVE view, a host that was merely switched off lost all its
findings at the next sync — while Wazuh itself kept listing the agent and its
vulnerabilities. Holiday, sick leave or a spare laptop in a drawer looked
exactly like a decommissioned machine.
Root cause confirmed: not the 30-day soft-inactive window (which never got a
chance to apply here), but the connection-state filter feeding seen_ids.
- sync fetches ALL agents, paginated; disconnected ones stay in seen_ids
- last_seen now carries Wazuh's lastKeepAlive instead of "when we synced",
so there is a real stamp to measure against
- per-agent status comes from status_for_last_seen(): ACTIVE inside the
window, INACTIVE outside — replaces the binary connected/disconnected test
- reconcile_asset_lifecycle uses max(last_scan, last_seen): a host that keeps
checking in but cannot be scanned while offline no longer ages out early
- window default 30 -> 42 days, one setting for both paths; migration 044
rewrites only rows still holding the old default
- agents registered but never connected are skipped instead of imported
tests/test_asset_grace_window.py covers the window edges, UTC keepalive
parsing, the newest-stamp reconcile, and guards the sync against a
status filter being reintroduced.
Two Wazuh CVEs landed on every host running the agent, a Windows agent on
4.14.5 among them, and neither describes anything installed there.
CVE-2026-67308 is shell injection in Wazuh's GitHub Actions workflows —
the CI, not the product — bounded by the fixing commit. The commit-hash
guard already rejected that bound on the affected entry, but the record
states the same commit twice, and the "unaffected" half feeds the
inverse-record rule (Mozilla's "X and up are fixed"), which never checked
its bound. The hash walked back in there and "44" outranked every 4.x.
CVE-2026-67307 lets an enrolled agent spoof cluster attribution in
inventory-sync messages. Wazuh's own advisory names 5.0.0-beta1, fixed in
5.0.0-beta3; the CNA record says "0 → < 5.0.0-beta3", which read literally
covers everything ever shipped. A prerelease upper bound names the
prerelease train, so an open floor becomes that train's start: 4.x below
it and the finished 5.0.0 above it are out, the betas between stay in.
Both are auto-closed on the next app-scan run.
The lifecycle export does carry the classic client ("Microsoft Teams" /
release "Classic Teams Client", ends 2024-07-01), but Teams follows the
Modern policy: that date lives in "Release End Date" while the parser
reads the Extended/Retirement columns, which are empty on every Modern
row. So the row is dropped and a host still running classic Teams
produced no finding at all.
Reading Release End Date instead is not an option — for a supported
product it marks the end of a superseded release (every Windows 10
version row has one), which would report supported products as EOL.
So the date is fixed in code and matched on the two inventory names that
mean classic: "Microsoft Teams classic", and "Teams Machine-Wide
Installer" only at version 1.x, since the new client's bootstrapper
writes an entry of the same name with a 5-digit build.
The new client lists as plain "Microsoft Teams" and is now refused by
the export matcher outright: that string is the listing name for BOTH
releases, so the day Microsoft fills a retirement date on the classic
row, a current Teams would have read as CRITICAL EOL.
An "Actively Exploited" row that only ENISA lists rendered as a bare CVE
id with no text: the EUVD parser kept the date, the exploited flag and the
EUVD-ID and threw away everything that says WHAT is affected. CISA rows
looked fine because CISA ships vendor/product/name — so the gap only showed
on the EU-only entries, exactly the ones nobody can recognise by number.
ENISA does carry it, nested: enisaIdProduct[].product.name (+ its vendor)
and a free-text description. Parse those, merge them like the other fields,
and let the KEV catalog fall back to them when CISA has nothing — CISA
still wins where both list a CVE, because its short vuln NAME beats a
paragraph on a one-line row. No CVE is dropped or filtered by any of this.
Caches written before these fields are refetched once, same as the pre-
`exploited` ones: an entry with no product would keep the blank rows for
another 24h. Descriptions are clipped to 300 chars — the cache is a single
JSON blob in a Setting row, and ~1700 full ENISA texts add ~0.5 MB to
every read for text no row ever shows.
Detail view now links the ENISA EUVD record under External References
whenever we know the EUVD-ID, so the EU-authoritative page and the
corresponding EUVD-ID are one click away instead of copy-paste.
starlette was never pinned here — fastapi 0.115.6 held it below 0.42,
which is exactly where its 14 advisories live. So the starlette fix is a
fastapi upgrade (0.141.1), not a starlette one; pinning starlette
directly would only have fought the resolver.
passlib 1.7.4 has been unmaintained since 2020 and cannot initialise
against bcrypt 5 at all — it raises before it hashes anything. bcrypt is
now called directly, which drops the last reason to hold a crypto
primitive three majors back. The 72-byte truncation passlib did silently
is done explicitly, otherwise every existing hash stops verifying and
every long password turns a login into a 500.
lxml goes to 6.1.1 for its own sake: it parses Microsoft HTML in
m365_service, not the advisory feeds — those run through stdlib
ElementTree.
The CSV upload read the whole body into memory with no cap, while feed
downloads have had one for a while. Same 16 MB limit, same shape.
OSV against the pinned set: 62 advisories → 0.
Actively-exploited CVEs were shown from CISA KEV only, on a dashboard
somebody had to be looking at. ENISA's EUVD exploited catalog was already
being fetched for enrichment but never surfaced as a KEV source.
- kev_service merges CISA KEV + ENISA EUVD (exploited only — an EU-Critical
entry is a priority list, not an exploitation claim) into one entry per
CVE carrying both sources.
- Inventory impact is counted honestly: active assets only (matching the
vulnerabilities list), open findings apart from remediated ones, and an
asset with both an open and a closed row counted once, as open. That gap
is what made the badge say 64 where the filtered view listed 62.
- kev_alert_service mails the CVEs with open findings here — hostnames, IPs,
counts, sources — once per CVE, again when more systems are affected.
Hourly, plus straight after each threat-intel refresh.
- Dashboard pins what is open here above the newest listings; Advisories
gains source filters, an alert panel and its config.
EUVD entries now carry an explicit `exploited` flag; caches predating it
cannot answer the question for an entry that is also EU-Critical, so they
are treated as stale and refetched once.
Two defects on the same pair of findings — CVE-2026-6727 (MSRC only) and
CVE-2026-70304 (cross-confirmed), both detected 12.08. and both still wrong
on 14.08.
No Remediation block: the CVRF ingest ran weekly (Sun 04:40) while the scans
that create the findings run nightly, and the ingest only stores remediations
for CVE ids already in the vulnerabilities table. A Patch-Tuesday CVE
therefore waited up to seven days for its KB. The nightly MSRC scan now
chains a two-month catch-up ingest after itself, so the CVEs it (and the
app scan before it) just created get their fixes the same night; Sunday keeps
doing the full 18-month backfill.
Wrong description: msrc_scan_service creates its rows with a placeholder
("MSRC reports ... is fixed in build ...") because the fixed-build index
carries no flaw text, and the app-scan backfill only fills an EMPTY
description — so for a CVE no other scanner ever saw, the placeholder was
permanent. The monthly document does carry the text, in the Note titled
"Description"; the ingest now writes it over the placeholder, and only over
the placeholder. Existing rows heal on the next run, no migration.
While there: _list_recent_docs now sorts the /updates index by
CurrentReleaseDate before taking the tail. At 18 months a misplaced entry was
survivable; at two it is not. build_product_index reuses it instead of
slicing the raw index, which also stops non-monthly entries getting through.
The python entry in both registries matched any name containing the word,
so PyPI/distro modules resolved as CPython: "python-dotenv 1.1.1" was
compared against CPython's ranges and flagged with every CVE up to 2.7.15
(CVE-2017-1000158, CVSS 9.8). Same for python-dateutil, python3-pip,
pythonnet.
The name now has to END at the interpreter, optionally with its release
("python3", "python3.11", "Python 3.13.7 (64-bit)"); a trailing "-" or
letter means a different product on a different version line.
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.
None of the three had a cvelistV5 registry entry, so resolve() returned
None and the path never looked at them — while NVD carries no CPE and no
affected block for any of the reported CVEs, so the CPE path could not see
them either. Fifteen CVEs on live hosts had no source at all.
Vim CVE-2026-73070/-73072/-73074/-73076/-73077/-73078 on 9.0.0969.
The bound is stated inside the version field ("< 9.2.0842"),
the operator form the range parser already reads.
VS Code CVE-2026-47285/-58650/-59113/-69278/-69306/-69320/-70335/-70336
on 1.132.0, all stated as 1.0.0 .. <1.132.1.
CPython CVE-2025-15366. The Windows installer reports an MSI build
(3.13.7150.0) while the real version sits in the name — read as
a build it is ABOVE every 3.13.x bound, so no range ever
matched. name_ver takes the version from the name, which is
what the CPE registry entry already does for Python.
Kept narrow so nothing new is over-matched: the names are anchored
(Neovim, Visual Studio Community 2022 and the Python launcher do not
resolve) and the pairs are exact, so Microsoft's VS Code EXTENSIONS —
their own products, with their own version lines — stay out.
Both paths get the products: the cvelistV5 entries cover what NVD has not
enriched, and the two new CPE registry rows cover the older records NVD
did enrich (every pre-2020 Vim record names vendor "n/a" and is not
matchable from the CNA block) and cross-confirm the fresh ones later.
Index key bumped to v25 so the cached index is rebuilt with the new
products instead of serving the old one for another 26 hours.
Two halves of the same blind spot, both on a stock AutoCAD LT 2026
(build 25.1.60.0):
MISSED. The cvelistV5 registry never carried year_ver, so that path
compared the MSI build 25.1.60.0 against "2026.0.0 lessThan 2026.1.2"
and matched nothing. It is the only path that can see these CVEs at
all — CVE-2026-7405, -7406 (ADSK-SA-2026-0012) and -17550 sit at NVD
with vulnStatus "Received" and zero cpeMatch entries, so the CPE path
is blind to them by construction. Four confirmed CVEs were invisible.
NEVER CLEARS. The year came from the product name and the build was
thrown away, so every 2026 install scanned as a bare "2026" — below
every 2026.x fix bound forever. A host patched to 2026.1.2 keeps all
seven of its autocad_lt findings with no version it could reach to
drop them. Three of those seven carry a bare-year CPE at NVD with no
range at all.
Both are fixed by resolving the build to the release Autodesk actually
bounds by: 25.1.60.0 -> 2026.0.0, 25.1.172.0 -> 2026.1.2. The table is
keyed by the build's major.minor, which has to agree with the year in
the name — that one check keeps it off products numbered some other
way, so Navisworks Freedom 2025 (22.0.1411.23) still falls back to the
bare year instead of landing on an AutoCAD row.
Hypothesis that held: the year was doing two jobs it cannot do at
once — pick the release line, and say where inside it the host sits.
"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 scheduled Wazuh scan read the asset list straight from the database
and asked Wazuh about whatever wazuh_agent_id each row already carried.
It never called get_agents(), so it could not learn about an agent id
that had changed.
A host that dropped off and re-registered comes back with a NEW agent
id. The row keeps the old one forever: every vulnerability lookup and
the nightly EOL sweep query an agent that no longer exists, so the asset
shows no CVEs and no EOL entries, and it never flips back from INACTIVE
to ACTIVE either — reconcile_asset_lifecycle only revives on a fresh
last_scan, and the failing lookup never writes one.
Pressing "Sync Data (Wazuh)" fixed it because the button calls
/assets/sync_wazuh first, which matches by hostname when the agent id
misses, rewrites the id and sets the status. The scheduled job now makes
the same call before it reads the asset list. A failing asset sync is
logged and does not abort the vulnerability scan.
30 items per feed was a constant. It is now the setting
`advisory_feeds_max_items`, editable in the feed configuration panel, applied
to every feed and taking effect on the next refresh.
The range is 1–500 rather than unbounded, and that ceiling is load-bearing:
every item lands in the cached JSON of ONE settings row that the page reads
in full, so "no limit" means a row that grows forever and a page that stops
rendering. 500 per feed is a few hundred KB per source — a quarter of the BSI
advisories in one sitting, and still a page that loads.
The value is written through the generic settings API, which stores whatever
a text field produced, so the read path treats it as untrusted: "", "abc",
"50 ", '"120"', 0, -5, 999999, "inf", true, None and an unreachable database
all resolve to something usable — junk falls back to 30, out-of-range is
clamped — and it never raises, because a feed run must not fail over its own
limit. The editor rejects the same values up front with the bounds named,
rather than saving something that quietly becomes a different number.
Also bounds the feed download at 16 MB. The item limit is the operator's to
raise; the size of a stranger's response is not.
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.
Replays a real Exchange inventory through the actual state machine — upsert,
supersede, reconcile, against SQLite — three times, and asserts what the
tester asked for: the Subscription-Edition host carries no Exchange finding,
the 2016 host carries exactly one, and sweeps 2 and 3 write no status change
at all.
Verified to have teeth: it fails on the build the flapping audit log came
from (companion packages claim releases, findings supersede each other 14×
per run) and on yesterday's, where the add-on token list still let
"Exchange Server 2010 MAPI Client and CDO" through — 4 flips per run.
Offline and pinned: the endoflife.date catalogue is inlined, so the test
asserts our behaviour rather than what the feed says this week.
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.
4 of Notepad++'s 18 advisories and 10 of Wazuh's 60 have only a GHSA id. They
were skipped, on the reasoning that the pipeline keys on CVE numbers — which
made them invisible to every source we have, since NVD and cvelistV5 are keyed
on CVE numbers by definition. A published flaw with a version range is a
finding whether or not a number was ever assigned.
The GHSA id stands in as the identifier, the way NESSUS-PLUGIN- and EOL- ids
already do in this schema, and it fits the column with room to spare.
What such a finding does not get is threat intel. KEV, EPSS and the CVSS
enrichment all look up a CVE number and skip anything else on their own
(enrichment_service filters on the prefix), so it simply carries none — honest,
since none exists for it. The detail page swaps the NVD and CVE.org links,
which would 404, for the advisory itself.
Measured against the versions in the test environment, this adds nothing
today: at Wazuh 4.14.1 and Notepad++ 8.9.5 every matching advisory happens to
have a CVE id. The CVE-less ones are on other release lines (5.0.0-beta). So
this is coverage for the case rather than a fix for a current gap.
The same run shows the component filter doing its job: at 4.14.1 the agent
gets 1 finding and the manager 13, from the same 60 advisories.
Three things from one screenshot, and the second one explains the first
report.
THE FIX FLOOR ONLY WORKED ON ONE PATH
2a831e4 derived ">26.5.2.2" from a lessThanOrEqual bound, but only in
cvelistV5. Node.js comes from there and reads "not announced — need newer than
24.14.0"; the Acrobat extension comes from NVD, where the same fact is spelled
versionEndIncluding, and read a bare "not announced". Derived on both paths
now, with the same rules: a real fix version wins, a commit hash is neither.
THE ADVISORIES NEVER PASSED THEIR SUMMARY ON
The GitHub summary was cached and then dropped, so those findings displayed
only the title we generate ("Wazuh Agent 4.14.2 — CVE-2026-44901") and nothing
about the flaw. Worse than cosmetic: the manager/agent prose check in _upsert
reads that text, and with nothing to read it could not do its job.
A SUBSYSTEM FALLBACK FOR ADVISORIES WITHOUT A PACKAGE
11 of wazuh/wazuh's 60 name no usable package ("Wazuh v4.14.1"), leaving the
component filter nothing to work with. The title usually says it anyway: the
cluster and its DAPI are how manager nodes talk to each other, an agent has
neither, while syscheckd and logcollector run on the endpoint. Used only when
the package field is silent, and only when exactly one side is named — a title
mentioning both is left to the version ranges.
Verified live: CVE-2026-44901 (manager) is dropped on agents and kept on
managers, CVE-2026-49392 (agent) the other way round. Notepad++ is untouched —
its advisories name no component, so nothing is filtered there.
Still open, and worth a decision: 4 of Notepad++'s 18 advisories and 10 of
Wazuh's 60 carry no CVE id at all. They are skipped, because the rest of the
pipeline keys on CVE numbers for enrichment, KEV, EPSS and cross-scanner
dedup. Covering them means giving a finding a GHSA id instead.
The pagination guard stopped the fetch on a 403 but not the build: execution
fell through to _store, which wrote the truncated index and a fresh timestamp,
so a partial answer was served as authoritative for the full 24h TTL —
the same failure the guard was written against, one level up.
Nothing is stored now, and the last complete index is returned. Returning an
empty one would be worse than stale: every repo-advisory finding would go
untouched, which the reconcile reads as resolved. _stored() is the raw read,
load_index() keeps the TTL check on top of it.
Two more from the same review:
Commit hashes could still reach a fix version. _load_via_nvd harvests the same
NVD bounds as the scanners into FixCandidates and had no is_version check, so
"before 44bf114d2f49" survived on that path after both scanners rejected it.
It imports the scanner's rule rather than carrying a third copy.
A repo that answers with no advisories is recorded as such again; one that
never answered is not. The earlier `if not payload: continue` collapsed both
into "never checked".
The non-manager package rule is now NON_MANAGER_PKG_RE, one constant that
_upsert and the test share — the test compiled its own copy of the pattern and
would have stayed green if the production one changed. Covered by a new check
that stubs a 403 and asserts nothing is written.
The guard named wazuh-agent only, so CVE-2026-25769 — cluster-protocol code
execution between master and worker — moved to the next package along and sat
on a dashboard host instead. A dashboard takes part in that protocol no more
than an agent does, and neither does the indexer.
The rule now covers agent, dashboard and indexer; manager and server keep
everything, since that is what the CVEs are about.
This is the fallback for NVD and cvelistV5, which have no component field. The
GitHub advisories state it outright and are matched on that — but this
particular CVE names its package "Wazuh v4.14.1" there, which identifies no
component, so the prose check is what has to catch it.
The tester pointed at the right field. NVD files agent, manager and dashboard
under one CPE and cannot express which is meant, so a manager flaw landed on
every endpoint. The GitHub advisories say it outright, in
vulnerabilities[].package.name: 41 of wazuh/wazuh's entries name
"wazuh-manager", 13 name the agent, one the dashboard, and two describe the
project's CI rather than any product.
That is stated fact, and it beats the prose heuristic on both counts.
It covers the direction the heuristic could not: CVE-2026-49392 is agent-only
and stayed open on the manager, because reading "this is only about the agent"
out of prose is far harder than spotting manager wording. The component field
answers both directions at once.
And it removes CVE-2026-67308 properly. That one is about GitHub Actions
workflows, so no amount of manager/agent wording applies — its component is
"wazuh/wazuh (CI workflows)", which corresponds to nothing installed anywhere
and now lands on no host.
Deliberately one-sided in the safe direction: an advisory that names no
component, or a package shape we do not recognise, keeps its finding. Only a
stated mismatch drops one.
The prose heuristic stays for NVD and cvelistV5, which have no such field.
Cache key bumped so the component is available immediately rather than after
the 24h TTL.
The hash rule went into cvelistV5 only, so the same CVE could still arrive
through NVD after the other path had stopped producing it — the tester still
saw CVE-2026-67308 on a Wazuh agent, bounded by "before 44bf114d2f49" and
advising an upgrade to an upstream commit.
The rule now lives in the CPE scanner, where both paths use it, and applies to
every NVD bound as well as to the fix version. cvelistV5 delegates rather than
keeping its own copy, so the two cannot drift apart and let a CVE back in
through whichever is laxer. Pinned by a test asserting both agree.
Note for the tester's case: this particular CVE is about Wazuh's GitHub
Actions workflows, so the manager-only filter does NOT catch it — that one
keys on cluster and manager wording, and this text mentions neither. It is the
hash rule that removes it, on every host.
Wazuh files agent, manager and indexer under one CPE, so every server-side CVE
landed on every endpoint running the agent. CVE-2026-25769 is the clearest
case: code execution "on the master node", reached "from a worker node" over
the cluster protocol. An agent speaks 1514/1515 to a manager and is not part
of that protocol at all — the finding sat on a client workstation.
Findings whose description names the server side and nothing else are dropped
when the package is wazuh-agent. The check is deliberately timid: any text
that also mentions the agent is kept, because a flaw in how the manager
handles agent input is a real risk on both sides, and missing a genuine agent
CVE is the more expensive error.
It lives in _upsert, where all three sources converge, so NVD, cvelistV5 and
the GitHub advisories are covered by one rule. Findings already stored close
themselves on the next scan: the CVE is no longer re-detected, so the existing
reconcile marks it patched.
CVE-2026-67308 is a shell-injection flaw in Wazuh's GitHub Actions workflows —
their CI, not the product — and the record bounds it with the commit that
fixed it: "before 44bf114d2f49". Read as digits that becomes (44, 114, 2, 49),
which every installed 4.x sits below, so it landed on every Wazuh host as a
critical finding about a pull-request workflow nobody runs. The fix field then
advised upgrading to an upstream commit.
Bounds that are hashes are dropped where ranges are built, so an entry left
without a usable bound is skipped entirely rather than matching everything
below a number that was never a version.
The test for "is this a version" has to be narrow, because plenty of real ones
carry letters: a hash is hex, seven characters or more, with no separator.
Checkmk's 2.4.0p12 and 5.0.0-beta1 have separators; Teams' 25060212643 is all
digits. Only things like 44bf114d2f49 and deadbeef fail it.
Verified against the pair the tester reported: CVE-2026-67308 now yields no
range at all, while CVE-2026-25770 (3.9.0 up to 4.14.3, a genuine privilege
escalation) still matches his 4.14.1 and stops at 4.14.3. Index key to v24.
Three defects the tester found within minutes of the GitHub source going live.
A COMMIT HASH IS NOT A FIX VERSION
Wazuh answers some advisories with "44bf114d2f49" instead of a release, and it
was passed through verbatim: the finding told him to upgrade to an upstream
commit. Only a dotted-numeric build is stored now. A fixed_version is written
once and never revised, so a wrong one outlives the finding — empty is
recoverable, wrong is not.
THE ADVISORIES NEVER REACHED A LINUX AGENT
This path read the raw version field. Fine for Notepad++, wrong for anything
shipped as an rpm/deb: a Wazuh agent reports "4.14.1-1" and comparing that
against "4.14.6" matched nothing. The advisories therefore only ever landed on
the cluster node, whose version is already clean, and quietly skipped every
agent — which is most of the estate. It now resolves the version the same way
the other scanners do.
NO IP ON AN ALL-IN-ONE NODE
A single-node install reports no address, because there is no cluster to
address, so the asset had an empty IP column. The configured API URL points at
exactly that machine and is used as the fallback — only when there is one node,
where the inference actually holds.
The tester was right that NVD alone leaves a hole, and it is bigger than
expected. Checked live against the repo: Wazuh 4.14.5 — the newest release NVD
knows any bound for — is affected by 10 GitHub advisories, three of them
critical, all fixed in 4.14.6. A host on 4.14.5 looks clean to NVD and to
cvelistV5 and is not. Several advisories carry no CVE id at all, so nothing
keyed on CVE numbers can ever see them.
No new service: github_repo_advisory_service already does exactly this for
Notepad++, so Wazuh is one line in its repo map, covering the agent, manager,
server, indexer and dashboard packages plus the synthesised "wazuh-server"
name from the cluster-node scan. The API needs no token for public advisories.
Its range parser did need one addition. It was written against Notepad++'s
free-text ("<= v8.9.6.4", "old versions - 8.8.1") and fell through to the
"below patched" rule for GitHub's own ">= 4.3.0" form. That happened to give
the right answer for 4.x, but it ignores the floor — a 3.x install would have
inherited an advisory that starts at 4.0.0. The form is now parsed properly,
with the optional upper bound.
The cluster API reports "v4.14.1" on some builds. The sync strips the prefix,
but an asset synced before that still carries it — and _clean_version rejects
the entire string over that one letter, so the node resolved fine and was then
never actually scanned. Stripped at the point of use as well, so a stored
value that is not clean no longer costs the check.
Detecting wazuh-agent covered the hosts that run one. Master and worker nodes
run none — they inventory other machines and nobody inventories them — so they
were never assets at all, and the heaviest Wazuh CVEs are precisely the ones
on the server. The tester has master and worker on 4.14.1 and saw nothing.
The cluster API reports its own version without an agent being involved, over
the session that already fetches agents, so no new credentials and no new
integration: /cluster/nodes during the Wazuh sync, falling back to
/manager/info on a standalone install where the cluster endpoint is off.
Each node becomes an asset with a "node:<name>" id. The scan reads that prefix
and, instead of asking syscollector for a package list that cannot exist,
takes the node's own version as its inventory — which then runs through the
same registry, ranges and reconcile as everything else. A node with no version
reported contributes nothing rather than a guess.
Dashboard and indexer are unaffected: those do carry agents and were already
covered by the wazuh-agent detection.
The scanner watches every other product on these hosts and was blind to the
one doing the watching. NVD carries 54 CVEs for Wazuh, several of them remote
code execution on the manager, and neither detection path resolved the name at
all — a Wazuh 4.14.5 server reported nothing about itself.
Two things had to line up.
The CPE names: NVD files 41 of the 54 under wazuh:wazuh, while the dashboard
and the older Kibana app have their own names and their own CVEs, so the
dashboard entry queries all three. cvelistV5 names the product per component
and states ranges as ">= 4.2.0, < 4.7.2".
The version: these ship as rpm/deb, so syscollector reports "4.14.5-1".
_clean_version rejects that outright — deliberately, to keep distro packages
out of the scanner — which is what made the components invisible even once the
names matched. A pkg_ver flag trims the packaging release suffix, leaving the
upstream version the advisories talk about. Both paths share
_effective_version, so both registries carry the flag; without it on the
cvelistV5 side that path still dropped the version.
Helper packages are excluded by name (wazuh-agent-config-helper and friends
are not the agent). Index key to v23; diagnose_scan gains a check for the
trimmed version, so the container can be verified directly.
A disabled extension was skipped before any lookup, on the reasoning that code
which does not execute is not a live risk. Wrong call: the vulnerable code is
still in the user's profile, and a click, a policy push or a profile sync puts
it back in the browser. Latent, not absent.
It also made the tool inconsistent with itself. Wazuh reports every installed
Linux kernel, including ones the machine has never booted, and nobody argues
those should be hidden for not running right now — same situation, same answer.
The finding carries the state in its name ("… [disabled]") so whoever picks it
up can weigh it, and enabled/disabled are tracked separately, so an extension
present in one profile and switched off in another shows both.
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.
"Veeam Explorer for Microsoft Teams" 13.3.2.3 collected four Teams CVEs whose
bounds are 25122.x — Veeam's own software, matched purely because its name
contains "Microsoft Teams". Checking the rest of the registry against the same
shape found four more: Chrome findings on "Citrix Workspace app for Google
Chrome", Firefox findings on "Kaspersky Plugin for Mozilla Firefox", and so on.
Backup tools, plugins and connectors are all named this way.
The product being described stands FIRST, so a match that lands only in the
"…for <product>" tail is dropped. Matching itself still runs on the full name,
which matters: the Firefox entry rejects "Firefox for iOS" itself (WebKit, not
Gecko, different CVEs), and trimming the name before matching hid that from it
— caught by test_mobile_browsers.
Teams is anchored to the start of the name as well, so a product that merely
mentions it later cannot match even without a "for". Both scanner paths share
the rule, so they cannot disagree about the same install.
Adobe writes CVE-2026-48294 as "lessThanOrEqual 26.5.2.2", so no fixed build
exists to show and the finding read "not announced" — which hid something the
tester rightly pointed out twice: if 26.5.2.2 and older are affected, anything
above it is not. That is a valid statement, and an admin can act on "newer
than 26.5.2.2" even when the vendor named no build.
Stored as ">26.5.2.2" and rendered as "not announced — need newer than
26.5.2.2". The prefix carries both facts at once, which is why it is not
written as a bare version: fixed_version also drives the "patch available"
badge, and lessThanOrEqual means the CNA named no patched release, often
because none has shipped. has_fix reads the prefix and does not claim one
exists. A later record with a real lessThan replaces the floor.
No schema change: the existing column carries it, and nothing compares
fixed_version numerically (only equality, in has_fix).
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.
why_match on the tester's host reported 0 matching NVD rules for
CVE-2025-49731, yet the finding kept reappearing — so it was not the CPE path
producing it, it was cvelistV5, which had no shape check.
The record names three builds: 1.0.0.2025112902 (Android), 7.10.1 (iOS) and
the MSRC build 25060212643. A host on the current 26183.1903.4892.4448
compares below every one of them, so a fully patched machine kept a Teams CVE
open. The MSRC scanner already refused to compare across build shapes (that is
what the "Teams build shapes are not compared" check covers); this path did
not, and it re-created the finding on every scan, which is why it survived the
reconcile — a CVE detected this run is never stale.
Same rule as Adobe, so the strict list is now a named set rather than a prefix
test on one key. The old Teams numbering (1.6.00.4472) shares its shape with
the mobile bounds and still compares normally, so genuinely outdated classic
clients are unaffected.
Index key bumped to v22; diagnose_scan gains a check for this exact host.
Three findings from the tester, all reproducible.
EMPTY INVENTORY CLOSED EVERYTHING
The auto-resolve rule was documented as "only safe when we had a real
inventory this run (packages non-empty)", but the code only ever checked the
asset status. A Wazuh agent that re-registers under a NEW id keeps its old
id's syscollector data until it rescans, so /syscollector/<new-id>/packages
answers 200 with an empty list — a live, healthy asset reporting no software.
Every app-scan-only finding on it then counted as "no longer detected" and was
closed, only to be reopened by the next scan that saw the inventory again.
That is the flapping behind several of the "was closed, now open again"
reports.
An asset that HAS an inventory source and returns nothing from it is a fetch
we cannot trust, not a host running no software. Its findings are now left
alone and the skip is logged and counted (resolve_skipped_no_inventory). The
asset matching itself was fine — the sync falls back to the hostname and
carries the new agent id over.
ASSETS WITH NOTIFICATIONS COULD NOT BE DELETED
notification_logs and ai_analyses referenced vulnerabilities with no ON DELETE
rule, so deleting an asset failed with a ForeignKeyViolation once anything had
been mailed about one of its findings — which is why some assets deleted fine
and others returned 500. Migration 040 gives each the rule that fits: the
notification log keeps its row with the reference cleared (it records that a
mail WAS sent, which stays true), the AI analysis goes with the finding it
describes.
DELL RVTOOLS AND VEEAM ONE
Both are cvelistV5-only — DSA-2026-325 (CVE-2026-64993) and the six CVEs in
Veeam KB4892 are all still awaiting NVD enrichment, so the CPE path is blind
to them. Veeam's advisory covers the SERVER only ("Veeam ONE 13.0.2.6723 and
all earlier version 13 builds", fixed in 13.1.0.7034), so the name pattern
excludes the client and console — otherwise every workstation with the console
installed would be flagged.
Dell states its bound as "4.8.1 or later", which is prose, not a version. The
comparison survived it by accident (only digits are read) but the sentence was
carried through and shown as the fixed version, so _clean_bound trims exactly
that shape and leaves anything else verbatim.
Index key bumped to v21.
Adobe files three different things under the same acrobat / acrobat_reader
CPEs, and nothing in the record separates them except how the version is
written:
desktop Acrobat/Reader 26.001.21771 3 parts
Acrobat Reader T5 in Edge 127.0.2651.105 4 parts, Chromium numbering
browser extension 26.7.1.0 4 parts, its own numbering
CVE-2024-41879 says "Acrobat Reader versions 127.0.2651.105 and earlier" and
carries cpe:microsoft:edge as its second configuration — it is the PDF engine
built into Edge, not the desktop application. Compared as plain numbers
26 < 127, so it was reported on every desktop Acrobat AND on the extension.
CVE-2024-20721, -20709 and -39379 (bound 120.0.2210.91) did the same, as did
the CVE-2026-479xx block, which is why the extension work looked like it had
regressed: those findings were never extension CVEs to begin with.
A bound only counts now when it is written in the same shape as the install:
same number of parts, and the leading number on the same side of 100 (which
is what separates Chromium's three digits from the extension's two). Both
sides must agree, otherwise the bound describes a different product and the
two are not comparable — every caller already drops a match it cannot compare.
Scoped to Adobe on both paths (NVD reads it off the CPE criteria, cvelistV5
off the curated key), because part counts differ legitimately elsewhere:
"8.6" against "8.6.1" must keep matching.
The genuine extension finding CVE-2026-48294 (up to 26.5.2.2) is unaffected,
and so are real desktop CVEs — both pinned in tests/test_adobe_product_split.py.
The July-2026 Node releases have no NVD configuration at all — CVE-2026-56846
and -56848 sit there with no CPE — so the CPE path is structurally blind to
them and a host on 24.13.1 was reported clean. cvelistV5 carries both records
in full, but the scanner had no registry entry for Node, so it never looked.
The bound logic was already right (both were in diagnose_scan since the
inclusive-bound fix); nothing ever fed it.
vendor "nodejs" / product "node", one entry per release line with
version == lessThanOrEqual, so each line keeps its major as the floor and
"up to 24.18.0" cannot swallow an already-patched 22.x host.
Index key bumped to v20 (new entry → rebuild). diagnose_scan gains four checks:
both Adobe schemes, Autodesk years untouched, and Node reaching this path.
Acrobat/Reader DC shipped as 2019.010.20098 until the 2020 release and as
20.001.30005 / 26.001.21771 after it — the same build, spelled two ways. The
sources disagree on which to use: NVD states the pre-2020 bounds without the
century (19.010.20098), cvelistV5 with it, so the two forms meet in every
comparison. Read as plain numbers 26 < 2019, and every current Acrobat fell
inside every pre-2020 Adobe CVE: CVE-2019-7819 was open on 18 hosts, including
an Acrobat Reader DC that had already been patched past the fix.
_vtuple now folds the four-digit form onto the short one. The pattern is narrow
enough to identify itself — three parts, a 2000-2099 leader, a three-digit
track, a four/five-digit build — so Autodesk's 2026 and 2026.0.0 are untouched.
Folding preserves order inside the old scheme and runs on both sides of every
comparison, so it can only change the outcome where the two schemes meet.
Also moves a finding's package_name/version columns onto a surviving product
when the row they named is pruned: they were written once at creation and never
revisited, so the list view kept showing "Asian Language And Spelling
Dictionaries Support For Adobe Acrobat Reader" while the detail page listed
only "Adobe Acrobat (64-bit) 26.001.21662" for the same finding.
Reported-by: wazuh/wazuh#29960 (vendor version schema change)
Browser extensions were a complete blind spot, and an awkward one: nothing
inventories them, because syscollector lists applications, not what runs
inside a browser. The Acrobat extension ships its own CVEs — CVE-2026-48294,
Chrome, up to 26.5.2.2 — which went nowhere, or worse, landed on the desktop
application whose CPE NVD shares with it (fixed in c88eb45).
IT Hygiene (Wazuh 4.14+) writes them to the indexer, which we already read for
vulnerabilities, so the client just needed a second index. Missing index or
unconfigured indexer returns an empty list — the feature is optional and must
not take a scan down with it.
Three things this gets right, all learned the hard way:
Extensions have their OWN registry, keyed by STORE ID rather than name. The
displayed name is localised and follows marketing ("Adobe Acrobat: PDF edit,
convert, sign tools"), while the id is what the browser installs under. Chrome
and Edge are separate keys because their version schemes are unrelated —
25.5.4.1 against 26.7.1.0 means nothing across stores.
target_sw is matched against the BROWSER, not the OS. That single field is
what tells the extension's CVEs apart from the application's, which is exactly
what went wrong when it was read from the wrong CPE position.
A DISABLED extension is skipped. The code sits on disk but does not execute,
so reporting it would misstate the risk — the tester's estate has one, on
Chrome, at 23.8.1.0.
On that estate this finds the Chrome install at 25.5.4.1 as affected by
CVE-2026-48294. Edge at 26.7.1.0 is not: no CVE names the Edge extension at
all — every "Acrobat for Edge" record carries browser versions and means the
engine built into Edge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A record whose `version` equals its upper bound was treated the same way
regardless of WHICH bound it is, and the two mean different things.
version == lessThan is a zero-width, impossible range. Chrome emits it, NVD
reads it as an open floor, and that is right: a Chrome CVE fixed in
151.0.7922.72 does affect 150.x. Unchanged.
version == lessThanOrEqual is inclusive, so the entry names ONE release line.
Node states CVE-2026-56846 as two entries — 24.18.0 and 22.23.1 — and its
description says "affects Node.js 24.x and 22.x". Opening the floor made "up
to 24.18.0" swallow everything older: a host on 22.23.2, already patched and
covered by the OTHER entry, matched through the 24.x range, and so did 20.x,
which is not affected at all. The major version is now kept as the floor,
confining each entry to its own line.
This also closes the false negative that surfaced it: Node 24.13.1 was
reported unaffected because the entry was read as the single version 24.18.0
rather than the line it describes.
Index key bumped to v19 — cached ranges carry the old floors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App-scan findings showed only the title we generate — "Adobe Acrobat (64-bit)
26.001.21691 — CVE-2026-48294" — which says what MATCHED, not what the
vulnerability is. The CNA's own text was sitting unused in both sources. It is
now taken from the cvelistV5 record and from NVD's descriptions block,
whichever path found the CVE, and backfilled onto existing rows that have none
(fill-only, so a source with better text keeps the last word).
Second: iOS browser builds were resolving to the desktop CPEs.
org.mozilla.ios.Firefox matched mozilla:firefox, which is wrong for the same
reason iOS Chrome was already excluded — Apple mandates WebKit there, so the
Gecko and Blink CVEs behind those ranges do not apply to it. Android is
unaffected by this: org.mozilla.firefox and com.android.chrome share engine,
version numbers and fixes with desktop, and NVD keeps them under one CPE with
a neutral target_sw, so they stay mapped as before. Verified: no separate
firefox_for_android CPE exists (0 CVEs).
Firefox ESR keeps its existing exclusion — NVD tracks it as its own
firefox_esr CPE.
Index key bumped to v18; cached entries carry no description.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The platform filter read field 9 of the CPE — sw_edition — instead of field
10, target_sw. Field 9 is almost always "*", so every match looked
platform-neutral, the filter accepted everything, and it has had no effect
since it was written. The Firefox-for-iOS case its docstring describes was
never actually caught.
What this let through is the false-positive wave the tester reported. NVD
files the Acrobat BROWSER EXTENSIONS under the same product name as the
desktop application, distinguished only by target_sw:
cpe:2.3:a:adobe:acrobat:*:*:*:*:*:edge:*:* up to 126.0.2592.81
cpe:2.3:a:adobe:acrobat:*:*:*:*:*:chrome:*:*
and their versions are BROWSER versions. Desktop Acrobat 26.001.21771
compares below 126.0.2592.81, so every host with Acrobat installed collected
the extension's CVEs — CVE-2026-48294, CVE-2024-39379, CVE-2024-20721,
CVE-2024-20709. These are real vulnerabilities in a real product; they were
simply attributed to the wrong artefact, one we do not inventory at all.
Cache prefix bumped to v3: rows cached under the broken filter contain those
matches and would keep serving them.
Detecting the extensions properly is a separate feature — it needs a browser
extension inventory, which syscollector does not provide today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DWG TrueView and Navisworks Freedom were blind spots — viewers, installed far
more widely than the authoring tools and just as exposed, since both parse
untrusted CAD files (CVE-2025-1658/-1659/-1660 are DWFX parsing bugs).
cvelistV5 is not optional for Autodesk: the three CVEs from ADSK-SA-2026-0009
(CVE-2026-16463, -16465, -17550) carry no NVD data at all, so the CPE path
cannot see them at any point. Both paths are wired up for all four products.
Two version notations occur and both compare correctly as numbers: older
records say "2026 .. <2026.1", newer ones "2027.0.0 .. <2027.1.0". AutoCAD and
AutoCAD LT are listed separately in the records but carry IDENTICAL ranges,
verified across CVE-2025-5046, -8894 and CVE-2026-17550; kept apart in case
they ever diverge.
Language packs are the trap here. They ship as separate inventory entries with
the SAME version as the application — "AutoCAD LT 2022 Language Pack" at
24.1.51.0, and Navisworks Freedom 2025 has twelve of them across Deutsch,
Italiano, Français, Português, Español, Korean and more. Each would have
collected the full CVE set of its product, exactly like Adobe's dictionary
pack. Excluded, along with Material Library and Single Sign On Component.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>