Intune reports Apple's marketing name, matched against endoflife.date's
release label after normalising. Apple names the first iPhone SE, iPad
Air, iPad mini and iPad Pro of each size without a generation ("iPad Pro
(9.7-inch)"), while endoflife adds "(1st generation)", so none of them
matched. Most of these models are already EOL (9.7"/12.9" Pro and SE
2025-03-31, 10.5" Pro 2026-09-17, Air 2023, mini 2019), so they raised no
finding at all. endoflife also files the 12.9" Pro 6th generation as
"iPad Pro (6th generation)" and the iPhone Air as "17 Air".
_APPLE_ALIASES maps these exact normalised names to the endoflife release,
resolved in _resolve_device like a Samsung row. Every Apple model name
from the iPhone 3G and iPad 2 on now finds its release in the live data;
names outside the table still go through the label match.
An SM-X216R showed the Tab A9+ end-of-support finding (active support
ended 2026-10-23, security 2027-10-23). SM-X216R and its Wi-Fi twin
SM-X210R are the 2025 re-release, which endoflife.date tracks as its own
release galaxy-tab-a9+2025, supported into 2031. The 2023 codes
(SM-X216B/U, SM-X210) share the base code, so the prefix match took the
2025 tablet for the 2023 one. Suffixed rows now sit above the base rows.
The wrong finding had a way out only through the detectedApps reconcile,
which does not run with detected apps off, an empty app list or a
co-managed device. A phone or tablet is one model, so the four mobile
slugs join _SINGLE_RELEASE_SLUGS, and a resolved model that raises no
finding of its own now supersedes any other model's finding on the asset.
Intune and Wazuh list "KeePassXC" 2.7.12 and CVE-2026-69150 never matched.
GitHub's CNA states it as "< 2.8.0-beta1" ("Prior to 2.8.0-beta1", a
use-after-free in the KeePass1 .kdb import every 2.7.x ships), and
_ranges_from_affected floored every prerelease bound at its own train, so
only the 2.8.0 betas were in range. That floor exists for Wazuh
CVE-2026-67307 ("< 5.0.0-beta3", written in beta1).
A bound at the train's first alpha or beta ("-beta1", "-beta.0", "-alpha")
cannot fix a flaw written in that train, so the floor stays open and the
bound becomes the release itself ("< 2.8.0"; _vcmp ranks 2.8.0 below
"2.8.0-beta1"). Later prereleases, "-alpha-3", commit suffixes and any rc
keep the floor. Over the current cvelistV5 ZIP 69 blocks read differently;
CVE-2026-69150 is the only one on a registry product. Index key bumped to
v63.
CVE-2024-21390 was open on an iPhone running Microsoft Authenticator 6.8.57,
the current App Store build. NVD files it as microsoft:authenticator with
target_sw * and versionEndExcluding 6.2401.0617. MSRC and cvelistV5 name no
platform either. The bound is an Android build (6.YYMM.NNNN), while iOS
numbers 6.N.N, so every iOS version compared below it and the finding could
never close.
authenticator_same_line drops a hit whose fix is on the other app's line,
judged by the asset's OS. Android builds before 6.2401.0617 keep the CVE.
The open iPhone finding closes on the next app scan through the normal
reconcile.
A name like "IntelliJ IDEA" or "IntelliJ IDEA CE" (the macOS app names)
did not resolve, since the Windows version field is a build
(262.8665.337) that no advisory bounds. Such a name now resolves and
takes the version field when it is a release (field_ver_re,
^20\d{2}\.\d+); a build is asked of no source. A release in the name
still wins over the field.
Intune lists "IntelliJ IDEA 2026.2.0.1" 262.8665.337 and "IntelliJ IDEA
2026.1.5" 261.27258.48 (JetBrains s.r.o.), and nothing matched
CVE-2026-64812 (< 2026.2, CVSS 10) or the 2026.2.x batch. The version
field is the build; both sources bound the release, so it comes from the
name, and a name without one does not resolve. Community and Ultimate
share jetbrains:intellij_idea.
Most JetBrains records are "0 .. < 2026.2.1". jetbrains_affected reads
the rest per YYYY.N line: several fixes in one lessThan (CVE-2026-41882:
"2024.3.7.1, 2025.1.7.1, ..., 2026.1.1") and every line floored at the
first (CVE-2024-37051: 2023.1 .. < 2023.2.7 caught a patched 2023.2.8).
A bound that opens its line ("2026.2" in CVE-2026-59792), EAP bounds
and build numbers ("<173") are dropped.
NVD lists CVE-2026-41882's five fixes as exact affected versions, has
CVE-2024-37051 without its 2023.1 floor and CVE-2025-32054 as
2024.2.4 .. < 2024.3. Exact IntelliJ entries are ignored, and, as for
PostgreSQL, a CVE the intellij-idea index holds is the CNA's alone.
Index key bumped to v62.
4cc61d1b taught the nightly loops to skip manager nodes but missed the
on-demand endpoints behind the asset row. The software list and the
coverage gap still asked Wazuh for /syscollector/node:<name>/packages:
4.x answers 400, 5 "no software inventory", and the operator got that
as a popup. Rescan answered "successfully completed" for a sync that
returns before doing anything.
Both package endpoints now read through one helper, _wazuh_packages(),
which returns node_inventory() for a node: the server version, the same
one package the app scan checks. So the buttons show real data instead
of an error. Rescan has nothing to do on a node: the button is hidden
there and the endpoint refuses with a pointer to the App CVE re-scan.
Intune lists "Intel(R) Rapid Storage Technology" 17.5.9.1040 (Intel
Corporation), and nothing matched CVE-2022-43456. NVD has it as
intel:rapid_storage_technology with one range per release line
(17.0 .. < 17.11.3.1010.2). The CNA writes vendor "n/a", several product
spellings ("Intel(R) RST software", ...), and all lines in one sentence:
"before versions 16.8.5.1014.5, 17.11.3.1010.2, 18.7.6.1011.2 and
19.5.2.1049.5".
intel_rst_affected turns each bound into "0 .. < bound" and line_floor
starts it at its major, as NVD does; a single bound keeps no floor, also
as on NVD. Two bounds on one major are dropped: CVE-2021-0104's
"18.0.0.640 and 18.1.0.24" are where NVD's ranges start, not end.
RSTe, the RST Application (CVE-2025-24327, own numbering) and the
"installation software" record are not matched. Index key bumped to v61.
Intune lists "Dell SupportAssist" 3.14.0.91 (Dell Inc.), and nothing
matched CVE-2024-52535 (symlink attack in the remediation component).
Both sources split it by edition: NVD as dell:supportassist_for_home_pcs
< 4.6.2 and dell:supportassist_for_business_pcs < 4.5.1, cvelistV5 as
"Dell / SupportAssist for Home PCs" and "... for Business PCs".
The editions share version numbers but not bounds, so the name decides:
plain "Dell SupportAssist" is Home, Business registers as "Dell
SupportAssist for Business PCs" (whole_name, so the companion rule keeps
it). Remediation, OS Recovery Plugin and SupportAssistAgent stay out.
Dell's newer records write the floor as "N/A". That reads as a version
which compares to nothing, so the cvelistV5 half never saw them. For the
two SupportAssist keys it is now read as 0, the way NVD reads the same
records; other keys keep the word-floor rule. Index key bumped to v60.
The confirm dialog listed Microsoft 365 Apps and endoflife.date as app-scan
sources. Neither runs in a full scan: M365 only runs on a single-asset scan
(the nightly chain runs its own M365 step first), and EOL has its own job.
The dialog now says so and leaves out the source list and the cvelistV5
download size, which changes over time.
Newer Aruba-branded 2930F/2930M answer the AOS-CX class in SNMP but boot
ProVision "WC.16.11.x" images. aruba_family took the slug's word for it and
treated them as AOS-CX, where a 16.x version matches nothing, so these
switches got no firmware CVEs at all. The version now decides: a ProVision
major (15/16) on a CX slug is "aruba-switch".
A new setting `mail_enabled` (Settings → Notifications, "Send emails",
default on) stops every mail TrueVuln sends: new-vulnerability
notifications, SLA breaches, KEV alerts and sync-failure alerts. send_email
refuses without it, and the alert jobs skip before they log a FAILED row per
recipient. Off drops the mail; nothing is queued for later. The SMTP test
mail (config_override) still goes out.
Intune lists the iOS app by its home-screen name, "Authenticator", with no
vendor: 6.8.57 on an iPhone, 6.8.56 on an iPad. No row matched it.
CVE-2026-26123 (< 6.8.40) and CVE-2026-41615 (< 6.8.47) name both apps:
NVD as microsoft:authenticator with target_sw iphone_os and android,
cvelistV5 as "Microsoft Authenticator for IOS" and "... for Android",
with no `platforms`.
MSRC's "for iOS" is the one app on iPhones and iPads, so _platform_ok now
reads NVD's iphone_os as holding for iPadOS too, and an iPad is given the
iOS fix. This applies to every iOS app, not only Authenticator. Each NVD
entry keeps its own target_sw, so the Android bound (6.8.57 < 6.2511.7533)
never reaches an Apple device, and CVE-2026-80097 stays Android-only.
Both scans go through authenticator_on_own_os: the Android package id on
Android, the iOS names on iOS/iPadOS from version 6 on, and nothing that
another vendor signs. Google Authenticator also appears as "Authenticator"
on iOS, on its own 4.x line. Index key bumped to v59.
From 01.11. every cvelistV5 index build died with "'list' object has
no attribute 'get'". The walk read every *.json under cves/, which also
holds delta.json and deltaLog.json (a 35 MB list). The vendor
pre-filter skipped both until the Git for Windows row (31.10.) curated
the vendor "git", which every "githubLink" in them contains. One file
killed the whole build. The same release moved the setting key to v58,
so there was no older index to load either.
The app scan then skipped every cvelistV5 pass but still reconciled,
so the app-scan findings only cvelistV5 had found were closed as
patched.
The walk now reads cves/**/CVE-*.json only (against the live ZIP: of
417,810 files, only the two delta files drop out). A record it cannot
read or that no branch expects is skipped with a warning naming it,
instead of costing the index. build_product_index raises when it has
neither a ZIP nor a cache, or when the walk yields nothing, rather than
returning {}. The app scan treats a missing index as no verdict and
skips its reconcile for the run (resolve_skipped_cve5_unavailable).
Master and worker nodes are assets with the pseudo agent id node:<name>.
After the vulnerability sync, the other per-agent reads still asked
Wazuh about them. On 5 each one raised "no software inventory" for
every node on every run: the SCA refresh, exposure, the EOL check (on
demand and nightly), the M365 check and the app scan's browser
extensions.
The extensions case did real harm. The failed fetch marked the node's
inventory incomplete, so its app-scan findings (the Wazuh version's
own CVEs) were never reconciled and could not close after an upgrade.
is_manager_node() now sits next to NODE_AGENT_PREFIX and
node_inventory(). Each of these loops skips a node before reading.
The app scan still checks the node's version as before.
Master and worker nodes are assets with the pseudo agent id node:<name>,
so the app scan can check their own version. The autoscan, the nightly
scan and the asset rescan passed that id on to Wazuh like an agent's.
On 4.x the indexer answered with nothing, and the node counted as an
empty agent. On 5 the "no inventory means no data" check raised for
every node on every scan ("Agent node:master has no software
inventory ..."). A node runs no agent, so the sync now returns before
asking Wazuh: no error, and no empty agent for the reconcile.
Agents that re-enroll on a fresh Wazuh 5 server get ids from 001 again.
The asset sync matched by agent id first, so the new 001 inherited the old
001's asset, findings and history.
The id now stops matching when the agent's name is a different host than
the asset's (case and FQDN vs short name tolerated) and its dateAdd is
later than the registration stored on the asset (new column
wazuh_registered_at, migration 066), or later than its last keepalive on
rows synced before 066. Either condition alone keeps the asset, so a
rename by another source and a migrated identity stay put. The old asset
is unlinked with an audit entry, and the agent goes through the hostname
stage. The vulnerability sync applies the same rule and skips such an
agent until the next asset sync.
Wazuh 5 is a new deployment, not an in-place upgrade, and it moved
everything the client read:
- /syscollector/*, /sca/* and /manager/* are gone from the API.
Inventory and SCA now exist only in the indexer.
- States documents carry the agent as wazuh.agent.id. The mappings are
dynamic: strict, so a term on agent.id matches nothing and raises
nothing.
- The indices lost their cluster suffix. wazuh-states-vulnerabilities-*
matched nothing on 5.
- There is no wazuh-alerts-* index.
The client finds the server version on its own, from GET / api_version.
If the manager API does not answer, it probes the indexer for the
unsuffixed 5 index (404 or 403 means 4.x), so the CVE sync keeps working
while the API is down, as it did on 4.x.
On 5 it queries the exact index names. The documented migration restores
4.x indices next to them as read-only history, and a pattern would read
that history too. Packages, ports and SCA are mapped back to the shapes
the 4.x API returned, so callers are unchanged. The SCA summary is
counted from the per-check documents.
A 5 server drops the inventory of every agent before 5.0, and a 5.x agent
has none until its first sync. For such an agent an empty answer is no
data, not a clean host, so it now raises per agent: its findings stay
open instead of being closed.
Also fixed, for 4.x as well:
- The fixed version was read from vulnerability.condition, which neither
mapping has. It is now parsed from vulnerability.scanner.condition,
and only "Package less than X" and "Fixed in X" name a fix.
- get_agent_by_id called GET /agents/{id}, which is in no API spec, so
"Verify patch" never saw the rescan. It now uses
GET /agents?agents_list=<id>.
- Removed get_vulnerability_summary and get_os_info. They had no callers
and their endpoints no longer exist.
Checked against wazuh/wazuh@v5.0.0-rc2 (spec.yaml) and
wazuh/wazuh-indexer-plugins@v5.0.0-rc2 (templates/states). 5.0.0 is not
GA yet.
Intune lists "KeePassXC" 2.7.12 (KeePassXC Team), and nothing matched
CVE-2026-118673 (out-of-bounds write in browser integration, >= 2.3.0,
< 2.8.0). NVD has not analysed the 2026 records yet and carries no CPE
for them, so the cvelistV5 row is the path that sees them: GitHub's CNA
writes vendor "keepassxreboot", ZDI vendor "KeePassXC" with one exact
version (CVE-2026-4158, 2.7.11). The NVD row asks keepassxc:keepassxc
for the analysed ones (CVE-2023-35866, CVE-2024-33900). Index key bumped
to v58.
Windows only, since a distro "keepassxc" hides backports. The name is
anchored: KeePass, KeePassDX and KeePassXC-Browser stay out.
CVE-2026-69150 ("< 2.8.0-beta1") does not reach 2.7.x on cvelistV5,
because a prerelease bound is floored at its own train; NVD's CPE will
carry it once analysed.
Intune lists Git for Windows as plain "Git" 2.38.1 (The Git Development
Community), and nothing matched: the old row asked NVD only for git:git,
and only for "Git for Windows" / "Git version control". NVD splits Git's
CVEs over three CPE names; at 2.38.1 git-scm:git holds 12 (CVE-2023-23946,
2.38.0 .. < 2.38.4), git:git 4 (CVE-2024-32002, < 2.39.4) and
git_for_windows_project:git_for_windows 5 (CVE-2023-29011, < 2.40.1).
The row now asks all three.
cvelistV5: GitHub's CNA writes one range per maintenance line into the
version field; core records are vendor "git", the Windows-only ones
"git-for-windows", both product "git". Index key bumped to v57.
_ranges_from_affected now reads GitHub's "= 2.45.0" as 2.45.0. Matching
was already exact (only digits are compared), but the "=" reached the
shown fix as ">= 2.45.0". This applies to every cvelistV5 product.
Windows only, since a distro "git" hides backports. The name is anchored:
GitHub Desktop, TortoiseGit, Git LFS and git-gui stay out.
Field case: Blender 5.0.0 (Intune) and 5.0.1 (Wazuh) got CVE-2026-60103
but none of CVE-2026-119718..119722, published 2026-10-30.
The same CNA (VulnCheck) wrote the new records as vendor "Blender
Foundation" / product "Blender", while CVE-2026-60103 says "blender" /
"blender". Only the latter was paired, so the five records passed the byte
prefilter and were dropped at pairing. A fresher nightly index would not
have changed that. NVD has CVE-2026-119722 "Awaiting Analysis" with no
configuration, so cvelistV5 is still the only structured source.
Each record states two lines, "0" .. lessThan "4.5.15" and "5.0.0" ..
lessThan "5.2.3"; they are matched per line, so 5.0.x is shown the 5.2.3
fix and the 4.5 LTS line the 4.5.15 one. All five IDs go through the real
index build in the test. Index key bumped to v56.
The bundled nginx met about half of the benchmark's Level 1 items: it
answered for any Host, kept the 60-65 s default timeouts, allowed every
method and 100 MB bodies everywhere, served config files 644 and sent no
nosniff, Referrer-Policy or CSP on Next.js pages.
Now nginx answers only for the names on the certificate (TLS_SERVER_NAMES,
the DASHBOARD_URL host, localhost, 127.0.0.1, built by a new .envsh) and
closes the connection for any other Host. No ssl_reject_handshake, since
browsers send no SNI for an IP. TLS 1.3 only, with session tickets back on
(4.1.11) and HSTS at two years. Timeouts are 10 s, methods are limited to
what frontend and API use, bodies are 1 MB except 100 MB under /api/.
/etc/nginx is 750/640, there is an error page without nginx branding,
dot paths return 404 and X-Powered-By is hidden. The CSP is added only
where the upstream sent none, so backend responses keep theirs.
Clients without TLS 1.3 and Host names outside the list stop working.
Both cases are documented in README and TROUBLESHOOTING. README.DEV.md
maps every recommendation, including the Level 2 items not taken and why.
Verified on the built image with nginx -t, the benchmark's curl audits
and the login page under the CSP in a browser.
MSRC files Edge CVEs in the US afternoon (26.10.2026 22:5x-23:5x UTC, 37
CVEs; 27.09. 23:2x; 30.09. 20:2x). The crons run on local time, so under
CEST 23:50 UTC is 01:50. That is after the 01:30 index build, and the 03:00
chain reused that build (6h), so those Edge CVEs reached hosts a night late.
app_cve_scan_nightly now rebuilds the MSRC fixed-build index before it
decides. With the per-revision CVRF cache, this downloads only the documents
revised since 01:30, and a failed rebuild keeps the 01:30 index. The MSRC
step reuses the new build.
The 01:00-02:45 UTC batch that revises half the documents every night is
not the cause. It is Azure Linux (azl3) and touches no product in the index.
Also repairs tests/test_msrc_edge_chromium_cve.py, which broke with
ad2abc63: its fake response had no status_code. It also no longer writes
into the real /tmp CVRF cache.
Microsoft revised 9 of 18 documents on 31.10.2026, not one or two, so the
six-day cache saves about half the nightly downloads. Detection does not
depend on the cap: the newest CVE revision in each document equalled the
revision /updates listed for it, so a revised CVE always misses the cache.
Intune lists the Webex App as "Webex" 41.10.0.20213 (Cisco Systems, Inc),
Wazuh the Meetings desktop app as "Cisco Webex Meetings" 42.12.0 (Cisco
Webex LLC), and no row matched either. NVD files the Webex App under
cisco:webex_teams (CVE-2022-20863, < 42.7) and Meetings under
cisco:webex_meetings (CVE-2026-20233, 56 exact versions incl. 42.12.0).
cvelistV5: Cisco states Meetings versions in short form ("42.12"), read as
exactly that version, and the Webex App as "Cisco Webex Teams". Its record
for CVE-2022-20863 is "n/a", so NVD alone carries that one. CVE-2020-3155
is webex_meetings:* with no bound on NVD and stays out, as every unbounded
match does: no upgrade could close it. Index key bumped to v55.
Windows and macOS only, since Intune names the iOS app plain "Webex" too.
Names are anchored; Productivity Tools, Meetings Server and the VDI app
stay out.
The asset list already returned each asset's open-finding count (the
CSV export's "Open Vulnerabilities"), but the table never showed it.
It now has its own column between Status and Exposure, with a tooltip.
Sorting is server-side, so it spans every page: a correlated COUNT of
OPEN findings, tie-broken by hostname. The images endpoint used the
same subquery inline; both now share _open_vuln_count(). Measured at
1200 assets / 500k findings: ~18 ms per page when sorting by the
column, against ~7 ms by hostname. The default view is unchanged.
Intune lists the Android app by its package id, "com.azure.authenticator"
6.2608.5658, and no row matched it. CVE-2026-80097 (improper
authentication, local EoP) is < 6.2606.4246 on both sources: NVD files
it under microsoft:authenticator with target_sw android, cvelistV5 as
"Microsoft / Microsoft Authenticator for Android" with no `platforms`.
Both scans skip the row on any asset whose OS is not Android, so NVD is
not asked for other hosts and the platform-less cvelistV5 record cannot
reach them. The name is anchored; the iOS app
(com.microsoft.azureauthenticator) stays out. Index key bumped to v54.
ZDI states CVE-2024-7565 as product "SoapUI", version "SoapUI 5.7.0". The
match was right, but the fix read ">SoapUI 5.7.0", and a product name with
digits would have been read as part of the version. A version that starts
with the record's own product name is now read without it.
Wazuh and Intune list "SoapUI 5.5.0" and "SoapUI 5.9.1" (SmartBear
Software), and no row matched it. NVD files open-source SoapUI under
smartbear:soapui with 4 CVEs; CVE-2019-12180 is "<= 5.5". NVD compares the
queried version itself and reads 5.5.0 as past 5.5, so a versioned
virtualMatchString answers nothing for 5.5.0. SoapUI is now asked for
without a version and _in_range decides, which also reads CVE-2024-7565's
typo ".5.7.0" as 5.7.0.
cvelistV5: MITRE's records are "n/a / n/a" (CVE-2019-12180 included), ZDI
states CVE-2024-7565 as "SoapUI 5.7.0", read as exactly 5.7.0. Index key
bumped to v53. Names are anchored, so ReadyAPI (formerly SoapUI Pro) stays
out.
An iPhone got CVE-2021-22879 (desktop client < 3.1.3) for Intune's
"Nextcloud 0 (35.2.1)". Two causes together: the desktop row matches the
plain name "Nextcloud", which Intune also gives the iOS and Android apps,
and Intune states iOS app versions as "build (version)", so the scan read
build 0, which sits below every bound.
The desktop CVEs (NVD nextcloud:desktop and the nextcloud/security-advisories
feed) now apply on Windows and macOS only, as FileZilla's do. Intune versions
of the form "<integer> (<dotted version>)" are read as the version in
brackets. Open findings on mobile devices close on the next app scan.
NVD names only filezilla-project:filezilla_client. FileZilla Pro is that
client with more protocols: its own version history uses the same numbers
since 3.25.2 and lists the same fixes (Terrapin in 3.66.4, P-521 in 3.67.0).
The shared name pattern now also matches "FileZilla Pro" and "FileZilla Pro
3.66.4". The Pro CLI and FileZilla Server stay out.
Wazuh and Intune list "FileZilla Client 3.46.3" and "FileZilla Client
3.19.0" (Tim Kosse) with no finding. The registry asked NVD for
filezilla:filezilla, a CPE NVD has never used. Every client CVE sits under
filezilla-project:filezilla_client: CVE-2019-5429 (< 3.41.0),
CVE-2023-48795 (Terrapin, < 3.66.4) and CVE-2024-31497 (PuTTY P-521,
< 3.67.0). filezilla-project:filezilla holds none. cvelistV5 has no usable
range for them (MITRE "n/a"; CVE-2019-5429 names only the fix).
- The CPE row now queries filezilla_client.
- One anchored name pattern, shared with the cvelistV5 row, matches
"FileZilla Client 3.46.3", "FileZilla 3.67.0" and bare "FileZilla".
FileZilla Server (own CPE, own 0.9.x/1.x numbering) and FileZilla Pro no
longer resolve to the client.
- Windows and macOS hosts only, on both paths: a distro package with a
clean version would hide backported fixes.
NVD writes "Chrome on Android" as an AND of the app (target_sw *) and a
non-vulnerable OS node, and the parser read it flat, as platform-neutral.
cvelistV5 carries no platforms for those records, only the text. So
CVE-2026-11163 (Chrome on Android < 149.0.7827.53) landed on every
Windows and Mac Chrome below the bound, and "Chrome on Windows" CVEs on
Macs.
The NVD parser now takes the running-on OS as the match's platform; an
OS it cannot place keeps the match neutral, as before. Where neither
target_sw nor the configuration names a platform, both paths read
Google's "Google Chrome on <OS>" and Mozilla's "only affects Firefox
for/on <OS>" from the description, and nothing looser.
Intune lists TeamViewer's shell-integration MSIX package "TeamViewer.Client
1.0.0.0" (publisher "CN=TeamViewer Germany GmbH, ...") beside the real
"TeamViewer 15.83.3.0". The unanchored name pattern took it for the client
and the 1.0.0.0 package version for a build below every fix, so the app-scan
reported CVE-2020-13699 on a patched host.
The shared inventory predicate (_is_citrix_shim, now _is_stub) also drops
any MSIX package at a placeholder version (1.0.0.0 and the other
teams_placeholder_version shapes). A package is recognised by its signer DN
as publisher (Intune) or its dotted identity name (Wazuh). Every scanner and
EOL already route through this predicate. A 1.0.0.0 on a plain ARP row
stays, because the number alone does not prove the row is fake. The
TeamViewer name patterns (NVD, cvelistV5, bulletins) also exclude
"TeamViewer.Client" whatever its version. The open finding closes on the
next app-CVE scan.
An operator who wants only Defender TVM CVEs on Intune devices had no way to
get that. Intune devices reach the app-CVE scan three ways: the Intune sync's
pass over detectedApps, the Defender sync feeding its software inventory into
the same pipeline, and the App-Scan job (nightly, data job, asset button)
fetching detectedApps for every Intune-inventory asset. Only the first read a
setting (`detected_apps`); the other two had none, so unticking it still left
app-scan findings on those devices.
New Intune setting `app_scan_intune` (default on, behaviour unchanged) gates
all three. Off: no detectedApps fetch in the App-Scan job, no app-CVE pass in
either sync. EOL, M365 and the iOS OS passes keep running, and a host with a
live Wazuh agent is still scanned from syscollector. Each run retracts the
app-scan source from package findings on ACTIVE Intune-inventory assets, so
the findings it left behind can close; a finding with another source (e.g.
defender) stays open, and the audit reason names the setting.
KB4902 (CVE-2026-58068, CVE-2025-64391) sits at NVD "Awaiting Analysis",
so cvelistV5 is the only structured source; the older agent CVEs
(CVE-2025-48982, CVE-2024-29853) are NVD's under
veeam:veeam_agent_for_windows, the only CPE NVD has for this agent.
- New veeam-agent-windows key and CPE, anchored to the agent row. The
"Redistributable" beside it is the installer payload Veeam keeps for
deploying the agent, not the agent that runs.
- "Veeam Agent for ..." reads as a companion of Windows and was cut down
to "Veeam", so the name never resolved. A whole_name flag on the entry
lets a pattern that spells out the full name skip that rule.
- The agent records write "0 .. < 13.1" for "all earlier version 13 and
6 builds"; the 0 floor becomes 6, the oldest line Veeam names. 13.1 is
build 13.1.0.544, the first 13.1 build, so the bound needs no build.
- CVE-2026-32996 (KB4852) is an agent flaw filed under Backup and
Replication and fixed by B&R 13.0.2.29 with no agent build; neither
product takes it.
- cvelistV5 index bumped to v52.
On 29.10.2026 at 04:34 the remediation catch-up made one call to
/cvrf/v3.0/updates, got 429 Too Many Requests and aborted, although both
documents it needed were cached by the 01:30 index build. MSRC documents no
quota, offers no API key (v3.0 is anonymous) and sends neither Retry-After nor
rate-limit headers, so the throttling has to be absorbed on our side.
- Every CVRF call goes through _get. It retries 429, 5xx and network errors
after 20/60/180 s (or Retry-After), and other 4xx raise at once.
MsrcUnavailable stops both document walks instead of hammering on.
- A failed /updates falls back to the last listing that worked. Documents
never leave /updates, so an index built from it cannot lose a month.
- CVRF documents stay cached per revision for 6 days instead of 4 h. The
01:30 build downloaded all 18 documents (20-30 MB each, uncompressed) every
night. Now it only downloads the revised ones.
- The on-demand Update Guide call no longer caches a 429/5xx as "CVE not in
MSRC" until restart.
- Failed documents in the ingest and scan errors are logged as warnings;
every caller dropped them from its log line.
The Technical Vulnerability Report loaded every finding as a full ORM row
and lazy-loaded its asset, user and group one by one, then built the whole
file in memory before the first byte went out. On a large estate that took
minutes, and the Reports page gave no sign anything was happening.
The export now selects only the printed columns in one joined query and
streams the CSV in blocks of 1000 rows. Same scope and columns as before.
The Reports page shows a spinner, then the MB received, and locks the other
download buttons while one runs; the four copied download branches are one.
The checkbox read "Detected apps (EOL/M365)", which suggested it decides
whether Intune assets get app-scanned at all. It only gates phase 2 of the
Intune sync (Sync now and the 02:10 nightly), and that phase also runs the
app-CVE pass, not just EOL and M365. The App-Scan data job and the nightly
app scan never read the flag: they still scan every asset, Wazuh agents via
syscollector and Intune-only devices via detectedApps. Label and tooltip now
say so; behaviour is unchanged.
KB4948 (CVE-2026-108140/-108141/-108142) and KB4951 (CVE-2026-107671)
sit at NVD "Awaiting Analysis", so cvelistV5 is the only structured
source until NVD enriches them; both paths now know the products.
- New veeam-br key and CPE veeam:veeam_backup_\&_replication, anchored to
the server row "Veeam Backup & Replication". The Console has its own
build line, and the Installer / Mount services run on every proxy and
repository.
- veeam-one now matches every component (Client, Monitor and Reporter
Server, ...) but the Agent: all carry the product build and take the
same patch, so the build decides. CPE veeam:one added.
- The NVD query encodes "&" as %26. Raw, it ended the parameter and NVD
answered 404, which was cached as "no CVEs".
- veeam_affected keeps only real build ranges from Veeam's records. The
older ones write a release where a build is meant ("13.0.1 .. < 13.0.1",
"0 .. <= 13.0.2"), which read literally hit every 12.x and miss the
build the KB names. Those are left to NVD.
- cvelistV5 index bumped to v51.
CPR needs CVSS and EPSS. A freshly published critical CVE has no EPSS yet,
so every CPR-ordered view put it below scored mediums. Priority always
exists (severity floor without CVSS) and already weighs KEV/EUVD, exploits,
asset criticality and age.
- Vulnerabilities list: default sort is priority again; priority ties are
broken by CPR instead of row id.
- Recent Critical widget: still selects the newest, ranks them by priority;
View all opens the list sorted by priority.
- New-vuln digest {{rows}}: ranked by priority, new Prio column before CPR.
Custom digest templates need a Prio header cell.
- SLA digest: Prio column, ranked by priority then hours overdue.
- Single new-vuln, manual-notify and SLA mails offer {{priority_score}} and
{{cpr_score}}; settings help and preview list them.
- PDF reports tie-break by CPR before CVSS; JEV triage puts unscored last.
- README.DEV records the rule and the deliberate exceptions (KEV alert,
Mobile widget, patch plan, URS).
Dockhand exports only images still present on a Docker host, so a
deleted image simply dropped out of the export. The sync treated that
silence as "leave alone", and the findings stayed open until the
time-based reconcile set the image INACTIVE six weeks later. An image
updated to a build with no findings left its old CVEs open the same way.
The sync now also reads GET /api/images, which lists every tag on the
host. A Dockhand-reported image whose tag is on no host any more loses
its dockhand source and goes INACTIVE; it reopens when pulled again.
An image still on a host but absent from the export is either clean or
not scanned yet, so it closes only after a nightly rescan that covered
every environment without a failure. Nothing is retracted when an
environment lists no images (unreachable or empty) or an image failed
to import. The retraction loop moved into container_scan_service.retract
so ingest_image and the Dockhand sync share it.
The postgres:18 image keeps its data in /var/lib/postgresql/18/docker and
refuses to start on a volume mounted at /var/lib/postgresql/data, so
compose mounts a new volume postgres18_data at /var/lib/postgresql. The
15 volume postgres_data is no longer declared: it is the rollback copy,
and 'docker compose down -v' would otherwise delete it.
tools/migrate_postgres_15_to_18.sh moves an existing install over: it
stops the stack, reads the old volume with a temporary 15 container,
dumps with pg_dump 18, restores into the new volume and compares every
table's row count, the sequences, roles and alembic_version, and the
login with POSTGRES_PASSWORD from .env over the network. It never
touches the old volume. The likely real-world order (new compose pulled,
18 already started on an empty database) is handled: that database is
moved aside inside the new volume, not deleted. The cluster is built in
18/migrating and moved to 18/docker only after the checks pass, so an
aborted run leaves nothing 'compose up' would start. A marker with the
old cluster's pg_control hash makes a re-run a no-op, and refuses when
the old cluster was used again after a rollback. An md5 hash of the app
user (deprecated in 18) is rewritten as SCRAM.
Dumps go to backups/, which .dockerignore now excludes, since the
backend build copies the whole directory into the image.
The README gets the operator steps under "Upgrading from PostgreSQL 15".
python:3.14-slim is the newest base the requirements install on with
--only-binary: 3.15 is out, but xmlsec (python3-saml) and pyyaml
(uvicorn[standard]) publish no cp315 wheels yet. The local test venv
already runs 3.14, so tests and image now match.
Pins without cp314 wheels moved: pydantic 2.10.5 -> 2.13.5 (pydantic-core
2.27 has none, 2.13.5 is what the tests run), psycopg2-binary 2.9.10 ->
2.9.13, sqlalchemy 2.0.36 -> 2.0.52 (2.0.36 fell back to the pure-Python
wheel, without the C extensions).
Python 3.13+ sets VERIFY_X509_STRICT in ssl.create_default_context(): an
internal CA without keyUsage, or a server certificate without Authority
Key Identifier, fails there although 3.11 accepted it. ca_trust_service
clears the flag for the process (create_default_context for httpx,
_create_default_https_context for pyVmomi and urllib), so integrations
against older internal PKIs keep working after the upgrade. The test
talks real TLS to a server whose legacy CA has neither extension.
The host link in Assets opens the findings list with asset_id=<host>,
and that filter matched only findings on the host asset itself. Dockhand
puts every finding on the image asset and links the image to its host
through container_placements, so the list named an image "on docker-01"
while the filter on docker-01 came back empty. The filter now also takes
the images placed on the host; the list and /by-package share it.
CVE-2026-113470 stayed open on a Windows host with Firefox 158.0.1,
although desktop got the fix in 158.0.1 (mfsa2026-111). Mozilla's
Android advisory mfsa2026-116 writes its fix as plain "Firefox 158.0.2"
in fixed_in and says "for Android" only in the title. The merge across
advisories takes the highest mainline fix, so 158.0.2 became the
desktop bound. The same shape made the Android-only CVE-2026-18809
(mfsa2026-73) hit every desktop Firefox below 153.0.3.
The parser now rewrites fixed_in of an advisory titled "for Android"
to "Firefox for Android X", which, like the iOS entries, is not a
mainline desktop fix. Rebuilt over all 2025/2026 advisories, only
these two CVEs change; the other 975 desktop bounds are identical.
Dockhand scans the TrueVuln images with Trivy and Grype. Both reported
critical findings that already had fixes, all in packages the images
control:
- PyJWT 2.13.0 -> 2.15.1, and pip is uninstalled after the requirements;
its vendored urllib3, msgpack and pkg_resources were the rest of the
backend's fixable findings and nothing runs pip at runtime.
- Next.js 16.3.1 -> 16.4.3, sharp 0.35.5, source-map-js 1.2.2. The
frontend builds on node:24-trixie-slim: bookworm keeps zlib and glibc
CVEs Debian will not fix there. The runner drops npm, npx and corepack
(server.js needs only node) and takes the Debian security updates.
- nginx takes the Alpine updates for curl and brotli.
Rescanned images built from this tree: no critical and no fixable
finding left in backend, frontend or nginx, in either engine. What
remains are Debian packages without a fix yet, Grype's Python 3.11
binary matches (fixes listed only for 3.13+), and gosu inside the
upstream postgres:15-alpine image.
Node 20 bundles the Brotli decoder hit by CVE-2026-111497 and is past
end of life, so no fixed release will come. Node 24.22.1 carries the
cherry-picked decoder fix.
CVE-2026-25792 was missed on Greenshot 1.3.312 (Intune) and 1.2.10.6
(Wazuh): neither registry knew the product. NVD files it under
getgreenshot:greenshot (<= 1.3.312), cvelistV5 as greenshot / greenshot
with the bound in the version field ("<= 1.3.312"). Name anchored so the
plugins stay out; Windows hosts only on both paths, since the flaw is in
the Windows build and the Mac app has its own numbering. cvelistV5 index
key bumped to v50 so the new key is indexed on the next scan.
The grouped view had one fixed order, so a product with a single high-CPR
CVE could not be brought to the top. The column headers now sort the groups
client-side (all groups are already loaded): package, findings, CVEs, hosts,
severity (criticals, then highs, ...), exploited, max priority and the new
max CPR. Groups without a score stay last either way. A third click restores
the default: exploited first, then summed priority.
Resetting Wazuh or Nessus in Settings left the dashboard banner red with
"last run failed", and the scheduled sync kept running and failing.
The reset stores encrypt_value(""), which is non-empty ciphertext, and
configured_sources() read the raw column, so every reset source still
counted as configured. It now reads the decrypted value. That one function
drives the Sync Health state, the scheduler's skip for unconfigured
scanners and the failure-mail skip, so all three follow the reset again.
The existing tests covered the reset and the health state separately; the
new one resets through update_setting and then asks sync_health.
Groups, policies, scan schedules, finding assignment and suppression, and
every sync, scan, refresh and connection-test button wrote no audit row,
so "who did this, and what changed" had no answer for them.
Records that change now write one row naming each field old -> new
(audit_trail.record_change; a create passes before={}, a delete after={},
nothing changed writes nothing), in the same transaction as the change.
Defer, undefer and false-positive also record the date, reason and
suppression they set or clear; an admin's email change is logged.
Buttons take the route dependency audited("What"): one row once the
endpoint returned, before the response goes out, so a refused or raising
call writes none and a failing audit write is a 500 rather than a gap. It
keeps the query parameters and only the named body fields, because request
bodies carry passwords: the test-mail recipient and SMTP host, the syslog
target, the AI provider and base URL, the Nessus asset and scan.
A guard test fails for any new POST/PUT/PATCH/DELETE route that does not
audit itself. No new event types, so no migration: groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, told apart by
resource_type and the description.
A review for revisionssicher logging asked that the trail answer who, when,
what exactly and from which client IP. Edit asset logged only "Asset
updated", asset and finding actions carried no IP (only logins did), a
deleted user's rows turned into "System/Auto", a manual decommission was
logged as a system action, and a settings change left no row at all.
Every audit row with a user_id now gets the actor's username, client IP and
user agent stamped on insert (before_insert listener, fed by an app-wide
dependency that runs after ProxyHeadersMiddleware), so no writer can forget
them. The username is a snapshot column (migration 065, backfilled), so it
survives delete_user. Rows without a user (sync, scheduler) stay as before.
Asset edits, single and bulk, record each changed field old -> new; a manual
status change carries the user. A settings PUT writes CONFIG_CHANGE with the
changed field names and old/new values whose secret subfields are masked for
every key (ai_config is unprotected and holds api_token). An unchanged
re-save writes nothing. The syslog message carries user=.
The findings list shows one row per CVE, so a host with 40 log4j CVEs reads
as 40 problems when it is one update. A "Group by package" chip on the
Vulnerabilities page now shows one row per product with its findings, CVEs,
hosts, severities, exploited CVEs and installed/fixed versions, under the
list's own filters, so with one asset selected it is that asset's affected
products at a glance.
GET /vulnerabilities/by-package aggregates in one GROUP BY with the patch
plan's key (package rows, else package_name, else the CVE id). The list's
filter block moves into _apply_list_filters so both views count the same
rows. A row click opens the flat list with ?package=<key>, which matches the
group's findings by their package rows; the search's ILIKE on the summary
column missed a finding's second product.
"Use auto-generated certificate" was a red text link next to the CA
download button and easy to miss. It now has the outline style of the
other reset buttons in Settings.
The confirmation also says that the reset replaces the uploaded
certificate and key, so going back to the PKI means uploading them again.
No copy is kept on purpose: a private key should not stay on disk once it
is no longer served.
CVE-2025-27462, -27463 and -27464 (XSA-468) are in the XenServer VM Tools
for Windows, but the record names only "Xen / Windows PV drivers / all"
and NVD left them Deferred with no configuration, so nothing bounded the
tools a Windows inventory lists ("XenServer VM Tools" / XenServer / 9.6.0).
Citrix's bulletin CTX692748 does: "All versions BEFORE 9.4.1", Linux guests
not affected.
The tools' "What's new" on docs.xenserver.com links each security release to
its bulletin (9.4.1 -> CTX692748), so it is the list, read the way XenCenter's
notes are; the fetch loop is now shared. A bulletin counts only if its VM
Tools bound is the release the notes link it from and its "Affected Versions"
names no other product; every CVE it lists is then the tools'. Severity comes
from the bulletin page's JSON, where Citrix now renders it.
Matched are "XenServer VM Tools" and "Citrix VM Tools" (before the 9.3.2
rebrand), whose version is the installer's, on Windows hosts only. The
Management Agent entries are not read: 9.4.2 ships agent 9.3.2.95. Findings
close when the tools update; a feed outage keeps the last parse. Known limit:
drivers updated through Windows Update alone leave the tools' entry old.
The docs told an own proxy on this host to use 127.0.0.1:3003. For a proxy
in a Docker container (Nginx Proxy Manager, OpenResty, Traefik) that is
the container itself, and the host's LAN IP no longer answers on 3003
since the frontend is bound to 127.0.0.1. The proxy then returns 502 with
"Connection refused ... upstream: http://<lan-ip>:3003/".
README, .env.example, the Compose comments and TROUBLESHOOTING now cover
it: join the proxy container to <project>_vulnmanager-net, target
vulnmanager-frontend:3000 (the container port, which clashes with nothing
on the host), and make the network permanent in the proxy's Compose file
so a recreated container keeps it. Troubleshooting gains an entry for the
502 and the dead port 3003 after the update, with the fix for each setup.
The backend never talks to the browser or the reverse proxy, only to the
Next.js container, and that proxy did not forward X-Forwarded-For.
FORWARDED_ALLOW_IPS defaulted to 127.0.0.1, which the frontend container
never is, so every audit row carried the container's Docker address and all
users shared one login rate-limit bucket. The limiter also parsed the header
a second time and trusted every private network, so an internal user could
pick a fresh rate-limit key per request with a forged header.
The Next.js proxy now forwards X-Forwarded-For. That was left out on purpose
while port 3003 was open, because Next.js keeps a client-supplied header.
Since the frontend is bound to 127.0.0.1, only nginx or the operator's proxy
reaches it, and those append the real peer.
The backend trusts the header from loopback and its own Docker subnet, read
from /proc/net/route, so no IP has to be configured and a recreated
container is still trusted. FORWARDED_ALLOW_IPS now adds proxies to that
(one on another host in front of nginx) instead of replacing it. uvicorn
takes the rightmost untrusted entry, so a forged entry in front never
counts. The rate limiter keys on that resolved address instead of parsing
the header itself. TRUST_PROXY_HEADERS defaults to true in Compose.
TrueVuln spoke plain HTTP only. HTTPS needed a foreign reverse proxy, the
hop behind it stayed unencrypted, and the frontend port 3003 was open on
every interface, so anyone on the network could read the vulnerability
data around the proxy.
A bundled nginx (Compose profile "tls", on by default via
COMPOSE_PROFILES) now terminates TLS 1.2/1.3 on 443: forward secrecy and
AEAD only, HSTS, port 80 only redirects. Frontend and backend are bound to
127.0.0.1. A reverse proxy on another host connects to nginx via HTTPS; one
on the same host may leave the profile out and use 127.0.0.1:3003.
On first start the backend creates a CA for the installation and issues
the server certificate from it for TLS_SERVER_NAMES, the DASHBOARD_URL host
and localhost, renewed in lifespan and nightly at 03:45. The CA carries
name constraints on exactly those names, because its key sits on the
vulnerability server and an unconstrained root in every browser would let
whoever steals it impersonate any site. A name it does not cover creates a
new CA, with an audit row. The CA lives in a backend-only volume; nginx
mounts only the certificate and key, and reloads itself when they change,
so nothing needs the Docker socket.
Admins can download the CA, or upload a certificate from the internal PKI
(PEM with chain, DER or PFX) under Settings > Security > HTTPS Certificate.
It is checked (key matches, chain complete, covers the DASHBOARD_URL host,
valid, not self-signed, RSA >= 2048 or ECDSA P-256/P-384, SHA-256+) and
served without a restart; renewal never overwrites it, a reset goes back to
the CA. The key stays in the volume (0600), never in the database or the
CONFIG_CHANGE audit rows.
The Next.js proxy now forwards request bodies as bytes: request.text()
mangled binary DER and PFX uploads.
Existing proxies on http://<host>:3003 from another machine must be
switched; README has the upgrade steps.
A pool member names the coordinator by IP in HOST_IS_SLAVE, while the
certificate carries names, so Verify SSL failed with "IP address mismatch"
whenever a member was configured. With Verify SSL on, the client now looks
the IP up in reverse DNS and connects by that name, provided the name
resolves back to the same IP. No PTR record, or one pointing elsewhere,
fails that entry without sending anything to the bare IP, and the next
configured host is tried. Without Verify SSL nothing changes.
An outage is not cached, so every further host on the same version asked
NVD again, four attempts with back-off each. 80 Aruba switches on two
firmware versions made 320 identical requests and about 40 minutes of
sleep in one Netdisco sync, against an NVD that was not answering. The
same applied to IGEL and the app scan (one Chrome build on many hosts).
lookup_cves now remembers a failed (product, version) in memory for
NVD_RETRY_AFTER (15 min). Further hosts on it raise _TransientNVD at
once and get the no-verdict they got anyway, so nothing closes and no
detection changes. Other versions are asked as before; after the window
or a restart the failed one is asked again. The same 80 switches now
make 8 requests.
Integrations behind a certificate from an internal PKI only worked with
"Verify SSL" off. httpx verifies against certifi, not the OS store, so
update-ca-certificates in the container changed nothing. httpx, pyVmomi
and ldap3 do honour SSL_CERT_FILE.
Admins can now upload root/intermediate CAs under Settings > Security >
Trusted CA Certificates (PEM, chain or DER). ca_trust_service writes
certifi (or the SSL_CERT_FILE the process started with) plus the
uploaded CAs into one bundle and points SSL_CERT_FILE at it. The next
connection picks it up without a restart, and the public roots stay
trusted. Leaf certificates, expired CAs and files containing a private
key are rejected.
Admin only, reads included, at /api/v1/trust-store/ca-certificates.
Every add and remove writes a CONFIG_CHANGE audit row. The CAs are
stored as JSON in the custom_ca_certificates setting, and the generic
settings PUT is denied for that key so nothing bypasses validation or
the audit trail. The bundle is rebuilt in lifespan before the scheduler
starts.
A configured pool host that did not answer was only logged and shown by the
connection test; the nightly sync passed over it silently and stayed green.
The run now lists each skipped entry with its reason as a warning. The run
still completes and sends no mail: the pool was reached. When no host
answers, the run fails and alerts as before.
When NVD answered 503/429 past the retries, lookup_cves returned [] -
the same value as "NVD lists no CVE for this version". The reconcile
that followed read the outage as patched: the Netdisco/IGEL/appliance
passes closed every CVE only NVD carries (the 2023 ArubaOS-Switch trio
on every switch), the app-scan reconcile closed every NVD-backed desktop
finding on the host, and the next healthy run reopened them all.
lookup_cves now raises _TransientNVD. Its three callers put
NVD_NO_VERDICT into the asset's touched set, and both reconciles that
read it (_resolve_stale_appliance, the app-scan one) skip that asset for
the run. An answered "no CVE" still closes.
Following HOST_IS_SLAVE needs the configured host to answer; when it is
the one that died (HA failover of the coordinator), nothing did. The host
field now takes several pool hosts, comma-separated, tried in order.
The next entry is tried only when no connection could be opened (refused,
connect timeout, TLS), the entry is not an address, or the coordinator a
member names cannot be reached (mid-failover a member can still name the
dead one). Once the login was sent, a read timeout or a refused login
stops the run: retrying would resend the credentials towards a lockout.
The connection test lists the entries it skipped as a warning.
The integration refused a pool member's address with "configure the pool
coordinator (X) instead", although HOST_IS_SLAVE already names X. The
coordinator role moves (HA failover, pool-designate-new-master), so a
fixed coordinator address breaks the test and the nightly sync after
every switch.
The client now follows HOST_IS_SLAVE to the named coordinator on every
login, once: a second HOST_IS_SLAVE means the pool is mid-transition and
the next sync retries. Any pool host works as the configured address. The
connection test reports the coordinator it reached. IPv6 coordinators are
bracketed.
The catalog refreshed only once it was a week old. Citrix ships a release
and the bulletin naming it as the fix on the same day, and cvelistV5 has
the CVE the next night; until the catalog knows that release's build, the
bound cannot be resolved and the CVE is held, not decided — for up to a
week on every host.
The TTL is now 20 hours, so each nightly index build re-reads the download
pages, while a Data-jobs rebuild later the same day reuses them.
CVE-2024-26203 was missed on Azure Data Studio 1.47.1: neither registry
knew the product. Both sources state the same range (1.0.0 .. <1.48.0),
NVD under microsoft:azure_data_studio, cvelistV5 as Microsoft / Azure
Data Studio. Name anchored so extensions "for Azure Data Studio" stay
out. cvelistV5 index key bumped to v49 so the new key is indexed on the
next scan.
Windows 11 24H2 Home/Pro went out of support on 2026-10-13, Enterprise and
Education run to 2027-10-12, LTSC years longer. endoflife.date carries one
release per feature update and edition class (11-24h2-w, 11-24h2-e,
11-24h2-e-lts, 11-24h2-iot-lts) with the build in `latest`. The OS check
matched codename "11" by prefix and landed every Windows 11 on the first
release in the list, 26H2 Enterprise: no Windows 11 was ever EOL.
The build now picks the feature update and the edition picks the release.
Wazuh names the edition ("Microsoft Windows 11 Pro"). Intune did not: only
the beta managedDevice carries skuNumber, so the device list is read from
beta with skuNumber in $select. It is the same paged call, so the number of
Graph requests does not change. Only a 400 from beta (property refused)
falls back to v1.0 without the edition; 429, 5xx and auth errors fail as
before, with no second read.
No edition known: the latest date on that build, LTSC included, so EOL is
never reported early. A build endoflife.date does not list yet, and a bare
"Windows" (Defender-managed servers; 10.0.26100 is also Server 2025), get
no verdict. An edition-less Intune name does not overwrite Wazuh's.
CVE-2026-42491 affects XenCenter, and neither NVD nor cvelistV5 knew the CVE
at all. Only Citrix's bulletin CTX696811 states it: "XenCenter versions
before 2026.4.0".
The bulletin list on support.citrix.com is a JavaScript app, but XenCenter's
static "What's new" page on docs.xenserver.com links every security release
to its bulletin, and the bulletin is served as HTML under
/external/article/<CTX>. A bulletin counts only if its bound is the release
the notes link it from and it names exactly one CVE: XenServer bulletins also
bundle host CVEs, and which of several is XenCenter's is prose.
The install reads "XenCenter 2026.4.1" with MSI version 26.4.1, read as
2026.4.1. A name and version naming different releases, and the Citrix
Hypervisor console (8.2.x), get no verdict: open findings are held, none is
opened. Windows hosts only. Merged into the cvelistV5 index under
"xencenter" on the nightly build, like Zoom's bulletins; findings are
app-scan findings and close when the host updates. A bulletin that is not
freshly parsed keeps its last parse.
A XenServer host runs no agent, so like ESXi it had no coverage. Unlike
vSphere, no public source can decide one: the Xen Project CNA writes
"consult Xen advisory XSA-517" with no version, XSA/NVD state upstream Xen
versions that XenServer's backported builds (4.17.7-4.xs8) do not follow,
Citrix bulletins name no fixed version, and software_version reads "8.4,
build stream" on every host, patched or not.
The pool coordinator does decide it. Its /updates lists per host the
updates synced but not applied, and every SecurityFix record names its CVEs
(verified against a live 8.4 pool). So a host carries a CVE exactly while
an update fixing it is pending on that host, and applying it closes the
finding. Only the "security fixes for the following issues" list counts,
not "upstream code changes for"; a list line off the format makes the
update unreadable rather than shorter.
Where the data cannot carry a verdict, none is given: a host with an
undescribed or unreadable pending update, or with no update data, keeps
its findings untouched; findings are not closed while XAPI still lists a
reboot or toolstack restart as pending (installed is not running); a stale
or never-synced pool is reported in the run's errors.
Findings carry their own source "xenserver", so the app scan's reconcile
(which closes every app-scan finding it did not re-detect, and a Dom0 with
a Wazuh agent has an inventory) cannot retract them. _upsert takes the
source as an optional keyword; every existing caller keeps "app-scan".
One connection per pool to the coordinator (XML-RPC + GET /updates, all
read-only), asset pin xen_host_uuid (migration 064), nightly at 02:55,
settings card, source filters, README.DEV section.
A Windows host on 25.7.3000.3034 (LTSR 2507.1 CU3) logged CVE-2026-84419
as "held, not decided" and CVE-2026-84420 not at all. Four causes:
- CVE-2026-84420 writes vendor "Citirx" with product "Citrix Workspace
app for Windows"; only "Citirx" + "Workspace app for Windows" was
indexed, so the record never reached the index.
- The bulletin writes "LTSR 2507 CU3 Hotfix 1" for the line every
download page calls 2507.1, and bound_release required the exact
release. An LTSR bound without a minor now names the LTSR on its
year.month; the one-build check still refuses two candidates.
- The "LTSR 2607 Hotfix 1" page (26.7.1.4) failed the build/title
agreement check, which put a hotfix in the third field only on a CU.
- The seed predated CR 2603.20, LTSR 2507.1 CU3 Hotfix 1 and LTSR 2607
Hotfix 1; they are added from the download pages.
CU3 now gets CVE-2026-84419 with fix 25.7.3001.2. CVE-2026-84420 is
correctly not affected there: its record names CU3 itself as the fix.
An iPad on iOS 26.5 had CVE-2026-86918 in Defender TVM and nowhere else.
NVD has no configuration for it yet (Awaiting Analysis), and Apple's
cvelistV5 record is one range per product, "iOS and iPadOS" 0 .. < 27,
with no 26.x backport. _same_train only lets a bound on the host's own
major answer, so a fix that exists only in 27 was dropped for every 26.x
host. Timing was not the cause: the record was published 20.10. 02:50
local, after the 01:30 index build, and was in the index from the next
night on.
scan_asset_os_apple now keeps the own-train rule and, only when a CVE has
no range on the host's train, accepts the bound on the next major
(major + 1). Older trains (iOS 18, macOS 15) still get no verdict from a
26/27-only record. Against today's cvelistV5 snapshot this adds 60 CVEs to
an iOS/iPadOS 26.5 host and 74 to macOS 26.5, all fixed only in 27, and
changes nothing for iOS 18.7 or macOS 15.7.
Wazuh and Intune list "UltraVnc" 1.3.8.1 and 1.2.2.2 (uvnc bvba), and no
row matched it. NVD files UltraVNC under uvnc:ultravnc, 37 CVEs, all ranges
but CVE-2026-3787 (= 1.6.4.0): the Kaspersky batch (CVE-2018-15361,
CVE-2019-8266, CVE-2019-8273, ...) < 1.2.2.3, CVE-2022-24750 < 1.3.8.1, and
the 2026 viewer/repeater batch (CVE-2026-7828 .. -7840) <= 1.8.2.2. Live,
NVD returns 35 of them for 1.2.2.2 and the 10 of the 2026 batch for 1.3.8.1.
No cvelistV5 row: the Kaspersky records name the fix, 1.2.2.3, as
"affected", and NVD has the uvnc records already. Names are anchored, so
Repeater, SC and the mirror driver stay out.
The Reset dialog promises that the scheduled sync stops running. The
inventory jobs (Intune, vCenter, IGEL, Netdisco, Dockhand) skip on a missing
config, but an enabled Wazuh or Nessus ScanSchedule kept firing after a
reset or when the integration was never set up: "Wazuh configuration
missing" / "nessus_config not set", a failed run, and a "Sync failed" mail
every 24 hours for an integration nobody uses.
execute_scheduled_scan now skips a scanner whose config is not set, without
a run row. notify_sync_failure sends nothing for a source without config,
whichever job or button reported the failure; it is the rule Sync Health
already uses for "unconfigured". A configured source that fails still mails
as before.
The container runs python:3.11-slim, and _query_nvd_cpe put
cpe.replace('\\', '%5C') inside an f-string. A backslash in an f-string
expression is a SyntaxError before Python 3.12, so importing
app_cve_scanner_service failed and uvicorn exited in a restart loop. The
local 3.14 venv accepted it, so the suite never saw it. The escape now runs
before the f-string; the query string is unchanged.
NVD left CVE-2026-2040 (updater LPE, ZDI-26-122) "Deferred" with no
configuration, and the ZDI record names only the tested 10.7.2.400, so no
host below it got the finding. PDF-XChange's security bulletin names
10.7.2.400 affected and 10.7.3.401 the fix. A registry row now states it as
0 .. < 10.7.3.401, the way NVD read the analysed ZDI records of this product
(CVE-2025-0901: < 10.4.1.389).
_cpe takes "advisories", the vendor-bulletin rows the extension registry
already reads through _advisory_cves, and scan_asset_packages merges them
after the NVD lookup. Each row decides its CVE alone, also once NVD analyses
it.
Intune lists "PDF-XChange Editor" 9.5.368.0 (Tracker Software Products
(Canada) Ltd.) and "PDF24 Creator 11.18.0" (geek software GmbH), and no row
matched either. NVD files the Editor under pdf-xchange:pdf-xchange_editor,
288 CVEs: most ZDI records as the one tested version (CVE-2023-40471:
9.5.368.0), the rest as ranges (CVE-2025-0901 < 10.4.1.389, CVE-2025-0903
< 10.4.2.390, CVE-2024-7352 <= 10.3.0.385). PDF24 Creator has one CVE,
CVE-2023-49147 (pdf24:pdf24_creator < 11.15.2), so 11.18.0 stays clean.
No cvelistV5 row: the 283 PDF-XChange records each name one tested version,
and NVD has all of them but CVE-2026-2040 (updater LPE, "Deferred", record
10.7.2.400 only; the vendor bulletin names the fix 10.7.3.401 and no floor).
Names are anchored, so Viewer, PRO, Lite, the printer drivers and
"Editor Plus" stay out.
NVD and the CNA disagree on Firebird. CVE-2025-65104 is "< 3.0.14" at NVD
and "< 4.0.0" at the CNA (every FB3 client, fixed by the FB4 client). A
fixed_version is never overwritten, so whichever scan ran first named the
fix. As for PostgreSQL, a CVE the cvelistV5 index holds is now the CNA's,
but only from its lowest floor up. Below it NVD still decides: the index
starts CVE-2026-28212's "< 3.0.14" at 3 (line_floor), while the CNA's text
and NVD both include 2.5. So 2.5 keeps it, and 3.x gets CVE-2025-24975
(ExtConnPool, 4.0 on) from neither source. Without a loaded index NVD is not
asked, so the Intune sync's scan leaves Firebird to the next full scan.
The GitHub CNA writes the whole range into the version field:
">= 4.0.0, < 4.0.7". _ranges_from_affected read that as the single version
(4,0,0,4,0,7), which no install equals, so every such line was lost: 60
Wazuh entries, 8 Vim, 1 Notepad++, 1 Wazuh dashboard and most of Firebird's.
It now reads ">= X, < Y", ">=X <Y" and ">= X, <= Y". An exclusive floor
("> X"), a floor with no ceiling or a bound that is no version ("5.0 beta1",
a commit hash) makes the whole entry unreadable. A partial answer would let
suppress_false_positives mark a finding on the dropped line as false.
Firebird gets a cvelistV5 row (FirebirdSQL / firebird) next to the NVD one,
sharing FIREBIRD_NAME_RE. The CNA also writes a bare "< X" per line:
CVE-2025-24975 says "< 6.0.0.609", "< 5.0.2.1610", "< 4.0.6.3183", and read
literally a patched 5.0.3 sits below 6.0.0.609. With "line_floor" such a
bound starts at its own major when the entry has more than one range. The
bounds carry the build counter, so this row compares the whole version.
Windows hosts only, and "4.0" names no build, as on the NVD row.
Intune lists "Firebird 2.0.7.13318 (win32)", Wazuh "Firebird 2.5.2.26540
(x64)", and a bundled install shows "Firebird-Server 4.0 x64 (<instance>)".
No row matched them. The CPE row firebirdsql:firebird brings NVD's per-line
ranges: CVE-2026-40342 and CVE-2026-28212 (< 3.0.14, 4.0.0 .. < 4.0.7,
5.0.0 .. < 5.0.4), CVE-2025-65104 (< 3.0.14) and CVE-2008-0467 (<= 2.0.3).
The fourth part of the version is the build counter, so only three parts
are compared (2.0.3.12981 is 2.0.3). A version of "4.0" names no build and
is skipped. Windows hosts only, like the TightVNC and PostgreSQL rows.
CVE-2008-0467 also has an entry limited to update "rc1" (<= 2.1), and read
as a plain range it caught the patched 2.0.7. _in_range now drops entries
whose update field is a prerelease (alpha/beta/rc/pre/preview) when the
installed version has no suffix. This applies to every product.
No cvelistV5 row: the GitHub CNA writes ">= 4.0.0, < 4.0.7" in the version
field, and _ranges_from_affected does not read that form.
Intune lists "Intel® PROSet/Wireless Software" 20.20.3 (Intel
Corporation), and no row matched it. NVD files the package under
intel:proset\/wireless_wifi, proset\/wireless (CVE-2018-12159, <= 20.50,
whose CNA record is "n/a / n/a"), proset\/wireless_software and
proset\/wireless_software_and_drivers. The CPE's backslash now goes to NVD
as %5C. Sent raw, NVD answers 400, and the product looked clean.
NVD left most 2023-2025 Intel records "Deferred" (CVE-2023-38417, -40536:
before 23.20), so a cvelistV5 key covers them. Intel writes a dozen
product spellings under vendor "n/a", so a pattern matches them, and only
names that say "software" and name no OS but Windows. Firmware, driver,
UEFI and Linux records are not the package. The bound is prose ("before
version 23.20"), and intel_proset_affected reads it as 0 .. < 23.20. Other
prose ("See references", "22.220 HF") is dropped, and NVD has those.
Installed names are anchored, so the Bluetooth package and Killer stay out.
The Microsoft 365 Apps security-updates page lists a build line per
perpetual edition ("Office 2024 Retail", "Office LTSC 2024 Volume
Licensed", same for 2021), but only "Microsoft 365 Apps" entries were
checked, so Office 2021 / 2024 hosts got no CVEs. A suite entry with
"LTSC" in its name is measured against the Volume Licensed line, any
other against Retail. The lines never share a major build, so a build
off its line is skipped instead of reading as behind on every section.
EOL: Office 2019 and 2021 (Retail and LTSC) already resolved as end of
support through endoflife.date; LTSC 2024 now lands on its own
"2024-ltsc" release instead of Retail 2024 (same date).
Wazuh and Intune list "TightVNC" 2.8.11.0 (GlavSoft LLC.), and no row
matched it. The CPE row tightvnc:tightvnc brings CVE-2021-42785
(<= 2.8.59) and CVE-2023-27830 (< 2.8.75) from NVD. CVE-2024-42049
(Server for Windows, < 2.8.84) has no NVD configuration and a CNA record
of "n/a / n/a"; only CISA's ADP container states it. A cvelistV5 row with
"adp": True now lets the index read ADP affected[] when no CNA entry of
the record names a curated product. Only tightvnc opts in, so no other
key changes. Windows hosts only on both paths: a distro "tightvnc" is the
Unix 1.3 line.
The PostgreSQL row asked cvelistV5 only, so a Windows server behind on
its minor release missed every CVE before 2024, when the PostgreSQL CNA
took over. NVD now adds those, under three rules. A CVE the cvelistV5
index holds for the key is the CNA's alone, because NVD keeps stale
ranges (CVE-2026-16238: 14.0 .. < 14.24 up to 18.0 .. < 18.5 against the
CNA's 18 .. < 18.6). An older hit counts only when its fix is on the
host's own major: CVE-2021-3393 is "< 11.11" with no floor, and the
advisory names 11, 12 and 13. Disputed records (CVE-2019-9193) are
dropped. Without a loaded index NVD is not asked for PostgreSQL, so the
Intune sync's scan leaves it to the next full scan.
The name also accepts the installer's "(x64)" / "x64" suffixes, and a
test pins that an 18.5 host's CVEs leave the touched set on 18.6-5.
Wazuh and Intune list "PostgreSQL 18" 18.6-5 (PostgreSQL Global
Development Group), the EDB installer, and PostgreSQL had no row. The
PostgreSQL CNA states each CVE per major: CVE-2026-16238 and -14676 are
"18" .. < "18.6", and versions before 18 are unaffected. NVD's
configuration for CVE-2026-16238 was not updated after the record was
corrected and still says 14.0 .. < 14.24 up to 18.0 .. < 18.5, so it
would flag every 14-17 server and clear an affected 18.5. PostgreSQL
therefore gets a cvelistV5 key only.
The installer build ("-5") is trimmed. Only lessThan ranges are indexed:
the Red Hat era records write prose or bare releases, and CVE-2019-10164
lists its fixes 10.9 and 11.4 as affected. The real index of 2026-10-20
holds 58 CVEs, all in the CNA's shape. Windows hosts only, since distro
packages hide backports. Names are anchored, so pgAdmin, the drivers and
EDB Postgres Advanced Server stay out.
The Microsoft 365 Apps security-updates page lists Visio and Project CVEs
in the same monthly section as the suite, under their own <h3>Visio</h3> /
<h3>Project</h3>, and MSRC files them under "Microsoft 365 Apps for
Enterprise". The check took every CVE of a section, so each host with
Microsoft 365 Apps got the Visio CVEs (CVE-2026-102855..102857 on
13.10.2026) whether Visio was installed or not, and a host with only Visio
or Project was never checked, because only "Microsoft 365 Apps" entries
started it.
Visio and Project (VisioProRetail, ProjectProRetail) are separate
Click-to-Run products that Microsoft requires on the suite's version and
update channel; selected, they appear as their own entries ("Microsoft
Visio - de-de"). The parser now records the CVEs listed only under Visio
or only under Project, and missing_for_packages gives such a CVE only to a
host with that app's entry, measured against its build on the suite's
channel. A CVE also listed under Word or "Office suite" stays with the
suite. MSI Visio 2016 and Visio/Project LTSC are not matched. The Wazuh and
the per-package path share the helper; stale Visio findings on hosts
without Visio close on the next run.
Wazuh and Intune list "DBeaver 26.2.2" (DBeaver Corp), and neither
product had a row. DBeaver gets dbeaver:dbeaver on NVD (CVE-2021-3836,
< 21.2.3) and a cvelistV5 key. CloudBeaver, the web server, gets its own
cvelistV5 key: CVE-2026-108742 and -108745 (0 .. <= 25.3.5) have no NVD
configuration. Both use one numbering, so each key pairs only its own
product and a desktop never gets a server CVE. Names are anchored to the
Community Edition.
One device's detectedApps answered 500 four times on 19.10.2026 at 03:02,
each after about 15 s, and the app scan skipped the device for the night:
no cvelistV5, MSRC, TeamViewer or repo-advisory detection, no reconcile.
The probe the same morning got its 107 apps in 0.2 s, so this was a Graph
outage longer than the ~75 s its own retries span, not a broken device.
A full run now puts a device whose Intune fetch failed at the end of the
queue and asks once more, at least 60 s after the failure. If it answers,
the device is scanned and reconciled as usual; if not, it is counted and
logged as before and nothing closes. The single-asset scan does not wait,
since that request is synchronous, and Wazuh fetches are unchanged.
M365 Apps findings carry their own source (microsoft365-apps), and under
the cross-source contract only the M365 detection retracts it. A host that
updated Office kept those findings open after "App scan" on its asset; they
closed only once the separate M365 Data job ran.
A run scoped to one asset now feeds the inventory it just read into
m365_service.run_m365_for_packages. The full scan skips that pass because the
nightly chain runs the M365 step right before it. The pass also skips a
partial inventory, for example the Intune fallback for a Wazuh host while the
Wazuh API is down, because it closes findings.
One device's detectedApps answered 500 four times in a row, each after
almost exactly 15 s (19.10.2026, 03:02). That is a server-side timeout on
that device's query, not a transient one, so retrying cannot help. Which
read still works for it decides the fix, and only a contrast shows that.
The probe now times v1.0 $expand, v1.0 and beta navigation, beta $expand,
$top=50 and $select for the device and for the most recently synced other
Windows device, and asks for the documented AppInvByDevice export filtered
to the device. A failing detectedApps no longer aborts the run.
A device re-imaged under a new name whose old Intune record nobody deleted
appears twice: two names, one serial. The re-enrolment dedupe keyed on
name + serial, so both records reached the asset's serial stage, each
pinned its own device id, Entra id and name, and the asset was renamed
back and forth up to twice per sync, ending on whichever record Graph
listed last, often the dead one.
The dedupe now keys on the serial (the name only when there is none) and
keeps the newest lastSyncDateTime. Defender had the same split: its
machine id pin is fill-only, so an asset that once pinned the old
install's machine kept it and that machine's CVEs kept findings open on
the live device. Machines behind one asset with different Entra device
ids are now different installs; only the most recently seen one counts,
and the pin follows it.
The 17.10 Netdisco run ended "1 ERR": the spelling guard from 89800c5c
found three HPE names no Aruba key knew. Two causes:
"HPE Aruba Networking Wireless Operating System (AOS-10 & AOS-8)"
(CVE-2026-23601, -23808, -23809) is the controller line, the same
product as CVE-2026-23812's "(AOS-8 & AOS-10)" with the generations in
the other order. The pattern only knew that order, so no controller
got these three CVEs. The aruba-os product_re now takes both.
"HPE/Hpe Aruba Networking InstantOS and Aruba Access Points running
ArubaOS 10" (CVE-2024-42393..42396) is Instant AP firmware, bounded at
8.10/8.12, which are controller build numbers too; HPE says controllers
are not affected. Mapping it would put findings on the wrong box, so it
goes into _ARUBA_KNOWN_UNMATCHED (lowercase covers both spellings).
The test builds these two records in an index of their own: their
ranges overlap CVE-2026-44857's builds that the shared fixture asserts on.
The four post-inventory jobs were separate crons (03:00 EOL, 03:10 M365,
03:20 app-CVE scan, 03:50 MSRC), each waiting on the Intune sync's lock.
On a night the sync overran (16.10.2026: 07:38-12:17) all four started the
moment it ended, in no order. The MSRC job's remediation catch-up then ran
before the app scan whose Edge findings it exists for, so those stayed
without a KB until the next night, and app scan and MSRC wrote Windows
findings on the same assets at once (different job slots, no mutual
exclusion).
Cause: every waiter polled the same lock and none waited for its
predecessor; the clock order only held when the sync finished before 03:00.
post_inventory_scans_nightly (03:00) now waits once, then runs EOL -> M365
-> app scan -> MSRC in turn. Each step keeps its own job slot and Intune
wait (a no-op unless a sync starts mid-chain); a step that raises costs
only itself.
The schedule comments in scheduler.py and the job tables in ARCHITECTURE.md
and README.DEV.md carried times from before several reschedules and called
them UTC; the cron times are the container's local time (TZ from TIMEZONE).
Both are corrected.
Test: tests/test_nightly_post_inventory_chain.py fails on the old code (four
separate crons, no chain) and passes now; the schedule asserts in
test_nightly_index_order and test_msrc_edge_chromium_cve point at the chain.
The full suite fails on the same 13 order-dependent tests with and without
this change.
APScheduler ran with its defaults: a 1 s misfire_grace_time, checked again
in the worker thread, on the event loop's default pool (min(32, cpus+4)
threads, shared with asyncio.to_thread). A job that queued for a free
thread, or came due while the loop was blocked for a second, was skipped
("Run time of job ... was missed by") and never caught up. On a night when
the Intune sync ran past noon and four jobs waited for it, the pool was
full and every job due after that was lost.
The scheduler now has its own 64-thread pool, with misfire_grace_time=None,
coalesce=True and max_instances=1. A late job runs late, and a backlog runs
once.
The nightly scans and the Data-jobs and sync buttons also ran the same code
with no lock between them, so two writers could hit the same findings
(deadlock, or a uq_vuln_cve_asset race aborting a run).
database.job_slot(name) is one advisory lock per kind, taken before the job
opens its Session. A second run waits for the first to finish and then
runs; after 6 h it starts anyway. It covers EOL, M365, app-CVE scan, MSRC
scan and refresh, exploit intel, Vulnrichment, the vuln-index rebuild, the
Wazuh vulnerability sync and Nessus. Different kinds still run in parallel.
Test: tests/test_job_concurrency.py fails on the old code (all 41
overlapping jobs dropped, 22 entry points without a lock) and passes now.
The full suite fails on the same 21 tests as before this change.
The 02:10 Intune sync (with Defender TVM) can outlive the 03:00 EOL,
03:10 M365, 03:20 app-CVE and 03:50 MSRC slots on a large estate (field
report 16.10.2026: 07:38-12:17 for 1639 assets). APScheduler ran them on
its thread pool alongside the sync: two writers on the same assets and
findings in different orders, and a scan of an inventory still being
refreshed.
Each of those jobs now waits until no sync holds the Intune advisory lock,
polling every 60 s for at most 2 h, then runs anyway with a warning so the
estate does not lose a night. The probe reads pg_locks instead of taking
the lock, so a sync starting at that moment is never turned away as
"already running". A GUI app scan does not wait; it logs a warning and
reports intune_sync_running in its stats.
The sync asked GET /machines/{id}/vulnerabilities once per machine. That
API allows 50 calls a minute and 1500 an hour, so a tenant with more
machines than that ran into 429 for every machine past the hourly budget.
The client gave up after its retries and those machines' CVEs were
neither written nor resolved, while the run took hours (field report:
1639 assets, Intune + Defender 07:38-12:17).
The (machine, CVE, product) pairs now come from the tenant-wide
/vulnerabilities/machinesVulnerabilities list, paged by $top/$skip so a
full page without a nextLink is not taken as the end. CVSS and
description, which that list lacks, are copied from existing findings of
the same CVE and looked up in the TVM catalogue only for CVEs new to the
estate (capped per run, fill-only on later runs). Every MDE call is paced
at one per 2.4 s, under both limits. The upsert reads an asset's findings
once per machine instead of once per CVE.
Simulated against the documented limits for 1639 machines: before, 62 min
of API time, 73 429s and 10 machines dropped; after, 4 API calls in steady
state, no 429, none dropped.
CVE-2024-26304, -33511 and -33512 reached no controller. HPE files the
2022-2024 Mobility records under "Aruba Mobility Conductor (formerly
Mobility Master); Aruba Mobility Controllers; WLAN Gateways and SD-WAN
Gateways managed by Aruba Central" (and "ArubaOS Wi-Fi Controllers and
Campus/Remote Access Points") and states each branch as prose, "ArubaOS
10.5.x.x: 10.5.1.0 and below". Neither the name nor the shape was read,
and NVD has not analysed those three. Both names now map to aruba-os, and
blocks with prose go through aruba_switch_entries (branch as floor). The
shared extractor took the sentence as a version and made it its own
bound, which the scan backstop dropped. A lookbehind keeps the SD-WAN
compound build ("8.7.0.0-2.3.0.8 and below") from becoming a 2.3 branch.
The guard: every index build collects HPE product names that look like
Aruba firmware but match no key, logs them, and the Netdisco sync puts
them on its run errors (Sync Health amber, no mail). The five names
reviewed and left out on purpose (four Instant/AOS-10 AP lines, the
9200/9000 series record NVD covers) are in _ARUBA_KNOWN_UNMATCHED, so it
is silent on today's corpus. The list lives in memory and is empty after
a restart until the next build.
Wazuh's vulnerability sync only asks active agents, so on a dual-enrolled
host whose agent died (field case asset #276, keepalive 2025-04-03) the
"wazuh" source was never retracted and those findings stayed open forever.
Since f528374e the app scan reads Intune for such a host; after it has read
Intune's whole list it now drops the "wazuh" source as well. A finding closes
only when no source is left, so CVEs that app-scan, MSRC, Defender or Nessus
still report stay open. Doing it in the same pass avoids closing findings
Intune still shows and then being blocked by Intune's 7-day reopen grace.
CVE-2026-76742 (AOS-S web-UI auth bypass, HPESBNW05156) was not detected
on ArubaOS-Switch 16.11.0016/0027/0029. HPE now names the product
"AOS-Switch (AOS-S)", which the anchored aruba-switch pattern rejected,
so the record never entered the cvelistV5 index; NVD still has it as
"Awaiting Analysis" with no CPE, so no source saw it. The same applied
to CVE-2026-76741..76749 and to other spellings: "ArubaOS (AOS)",
"HPE Aruba Networking AOS", "... Operating Systems (AOS-8 & AOS-10)",
"ArubaOS-Switch Devices" and a typo'd "HPE Aruba Networkign AOS-CX".
A second shape was dropped on purpose until now: lessThanOrEqual
"<=10.6.0.2" with the floor as prose in `version` (CVE-2024-42505,
CVE-2025-25042, -27080 and others, none with an NVD CPE).
aruba_affected() normalises it for the Aruba keys only; the shared
_clean_bound is unchanged, and the branch guard still confines an
entry whose floor cannot be read.
The NVD half now also queries arubanetworks:aos-cx and
aos-cx_firmware, under which NVD filed the 2021/2022 AOS-CX CVEs.
Checked against all 634 HPE cvelistV5 records: no non-switch or
non-controller product enters an Aruba key, and families never cross.
Field report 2026-10-16: asset #276 is still registered in Wazuh with a
last keepalive of 2025-04-03 while Intune reports it daily. Wazuh won the
inventory precedence whenever wazuh_agent_id was set, so the app-CVE scan
and the software view kept deciding from syscollector data frozen 18
months ago, and Intune's current detectedApps was never read. Findings
from that data could not close.
Each inventory now keeps its own stamp (assets.wazuh_last_seen,
intune_last_seen, migration 063). asset_inventory() hands a dual-enrolled
host to Intune when the keepalive is outside the grace window and Intune
checked in after it. A live agent keeps the precedence, so it does not
follow the order the syncs ran in. Rows without both stamps keep Wazuh.
run_app_cve_scan, the software view and the Intune sync's own retraction
use the same decision; a dead agent's browser extensions are not read.
Field report 2026-10-16: asset #276, whose Wazuh agent last kept alive on
2025-04-03 but which Intune reports daily, switched between ACTIVE and
INACTIVE with every sync. The Wazuh agent sync overwrote Intune's fresh
check-in in last_seen with the old keepalive and judged the status on it,
the nightly reconcile reads last_seen for Wazuh hosts and deactivated it
too, and the next Intune sync revived it.
last_seen now only moves forward (advance_last_seen), in the Wazuh agent
sync and in the Intune sync, so the newest proof of life from any source
decides. A Wazuh-only host past the grace window still ages out. The
Wazuh audit reason names the keepalive and who stamped last_seen.
"GPL Ghostscript" 10.05.1 (Artifex Software Inc.) got no CVE-2026-39919:
Ghostscript had no row on any path, and NVD has the record without a
configuration. cvelistV5 now pairs Artifex's three vendor spellings
(0 .. < 10.08.0), and NVD is asked for artifex:ghostscript.
Only the installer name is matched, so the distro package, the fonts
package, GhostPCL and GhostXPS stay out. Red Hat's records that write the
fixed release or prose into the version field are not read, and
gpl_ghostscript is not asked because its looser 2018 bounds would flag
9.24/9.25. On the full cvelistV5 ZIP the index gains 9 entries and every
other key is unchanged.
The nightly Jev triage committed every 50 CVEs, so its row locks on
`vulnerabilities` (all rows of each answered CVE, across hosts) stayed held
across up to 50 HTTP calls. A job writing the same rows in another order
closed a lock cycle; Postgres killed the triage (2026-10-16 06:05), which
rolled back answers already paid for and skipped the audit row.
Each answer is now committed before the next call. A database error stops
the run and is recorded in the audit row as "stopped: database: ...".
Field report 16.10.2026: Zoom Workplace (64-bit) 7.1.43453 got no finding
for CVE-2026-108184 (ZSB-26021, fixed in 7.1.9) or CVE-2026-108185
(ZSB-26022). The MSI writes major.minor.BUILD, and the release 7.1.5 sits
only in the ARP "Comments" ("7.1.5 (43453)"), which neither Wazuh nor
Intune reports. A build inside the fix's own minor cannot be placed, so
zoom_undecided gave it no verdict on purpose (the known MSI-build limit).
Zoom's download URLs name both: zoom.us/client/7.1.5.43453/,
zoom.us/download/vdi/7.0.13.27170/ (VDI Client and plugins share that
line). winget-pkgs keeps a manifest with that URL for every installer.
zoom_bulletin_service.build_builds reads them on the nightly cvelistV5
build into a stored build→release table (about 150 client and 40 VDI
builds), fetching only builds it does not know yet. Only a URL of the
manifest's own build counts: 2020's prod/5.2.42619.0804/ is build.date
and maps to unknown. zoom_release rewrites a Windows host's MSI version to
"7.1.5 (43453)", the EXE's spelling, before the NVD and cvelistV5 paths
compare, so the finding shows the release like the ARP comment.
No new false positives: a build the table does not know stays undecided
as before, and the table matches the field report's comments (7.1.43453 =
7.1.5, VDI 7.0.27050 = 7.0.11, 7.0.27170 = 7.0.13), rising with the build
in every minor. The field host's Universal Plugin 7.0.11 stays clear of
ZSB-26021 (the line names the VDI Client), and its VDI Client 7.0.13 is
the fix.
A Zoom finding decided by a bulletin said "Source: Zoom Security Bulletin
ZSB-26020" in its description, but External References had no link to
the bulletin (field report, CVE-2026-53420). Cause: the cvelistV5 scan
built the finding without refs, though the bulletin entry carries its ZSB
id. It now passes the bulletin URL, which _upsert merges into references
like TeamViewer's and IGEL's, so existing findings get it on the next
scan. Record-only entries get no link, and matching is unchanged. The
detail page labels the link "Zoom Security Bulletin".
The description stays fill-only: a finding that NVD or the CVE record
created first keeps that text without the "Source:" suffix.
MSRC states each SQL Server fix per release and servicing path, e.g.
CVE-2026-70189: 16.0.1210.3 on "SQL Server 2022 (GDR)", 16.0.4305.5 on
"(CU27)". The app scan's MSRC package pass now reads the build from
"Microsoft SQL Server 20xx Setup (...)", raised by the GDR/Hotfix (KB...)
entries, and compares it only against fixes on its own path (GDR and CU
are told apart by the build's third part). Per CVE the oldest fix on the
path decides: MSRC adds newer builds to a revised CVE (CVE-2026-32176)
without dropping the first ones.
No false positives by construction: Browser, RsFx and VSS Writer stay at
RTM and are never read, a Setup entry without the instance entry
"Microsoft SQL Server 20xx (64-bit)" is a leftover and skipped, and a
build that fits no path gets no verdict. The reconcile compares a fix on
the host's own path only, and now also retracts MSRC from rows another
scanner created first. Index key bumped to msrc_product_index_v3.
A failed exploited-search page (or critical-list request) left a partial
map that was stored and stamped for 24h. Enrichment clears euvd_listed on
every CVE missing from the catalog, so CVEs ENISA still lists were
un-listed. The paginated fetch now reports whether it reached the last
page; an incomplete fetch keeps the last complete cache and backs off.
Follow-up to the per-device vulnrichment fix: an audit of the backend found
more feeds pulled again by every caller.
cvelistV5 (~557 MB) had three downloaders: the index build, the CVSS
cascade's stage 3 and the date backfill. Callers arriving together each
pulled the whole ZIP, and after a failed download every later caller
retried at once. _ensure_zip is now the only one: a lock with a freshness
re-check after acquiring it, a zipfile check before the swap, and a 15 min
backoff after a failure (the on-disk ZIP still serves meanwhile).
Per-CVE lookups that keep missing are remembered for 6h: the M365 title
fetch on a cvelistV5 404 (MSRC publishes days before CVE.org), and NVD /
GHSA for CVEs they answered without a score. Rate limits and network
errors are not answers and are retried.
A failed or partial index build stores nothing, which keeps the cache
stale, so every per-asset scan and every per-sync enrichment rebuilt it
during an outage. The MFSA, repo-advisory and TeamViewer indexes and the
KEV / EUVD / VulnCheck refreshes now serve the stale copy for 1h after a
failure; the nightly and forced paths still rebuild. The app scan loads
the repo-advisory and TeamViewer indexes once per run, like the IGEL ISNs.
The repo-advisory index no longer falls back to {} when a build raises:
the reconcile would read every untouched finding as patched.
MSRC CVRF documents were downloaded by the 01:30 index build and again by
the 03:50 / Sunday ingest. Both now go through fetch_cvrf_doc, a disk
cache keyed by the revision date /updates lists (max 4h old), so a
revised document is still fetched again.
Field report 14.10.2026: every Intune device with Microsoft 365 Apps logged
"vulnrichment: downloading ZIP snapshot for 131 CVEs", minutes apart, each
a full 334 MB pull, followed by the same NVD and CVE.org per-CVE lookups.
Two causes. _load_via_zip_snapshot had no cache: a fresh tempdir per call.
And run_m365_for_packages (Intune and Defender, once per device) re-ran the
whole CVSS cascade and the title fetch for every CVE the device has, not
just for its new findings; the upsert also overwrote the real CVE title
with the placeholder, so the text had to be fetched again on every run.
The vulnrichment ZIP is now disk-cached for 6h, swapped in only once it is
a valid ZIP, and a failed refresh falls back to the stale snapshot. A new
M365 finding inherits CVSS, severity, SSVC and the real CVE text from a
sibling on another host, as the msrc, nessus and container scanners
already do. Only what that leaves open goes to the feeds. Existing findings
keep their real text; the nightly Correct-CVSS run still re-checks them.
Also: _download_cvelistv5_zip wrote to a shared .part file, so concurrent
downloads could interleave; it now uses a per-writer name like the others.
Field report 14.10.2026: CVE-2026-108184 (ZSB-26021) and CVE-2026-108185
(ZSB-26022) were not found. Zoom's records on cvelistV5 state
"version": "see references" and link the bulletin; NVD had both at
"Awaiting Analysis" with no configuration (so were CVE-2026-53413, -53415
and -53420). Neither source had a bound, so no host could be flagged.
zoom_bulletin_service reads the overview (newest 20) and each bulletin's
"Affected Products" lines for Zoom Workplace and its VDI Client / VDI
Plugins. "starting on version 7.0.0 and before version 7.2.0 and 7.1.9 in
their respective branches" becomes [7.0.0, 7.1.9): each lower fix closes
its own x.y branch. Several branches without a start floor at the lowest
one's x.y.0; a single "before X" is everything below. Rooms, the SDKs,
Contact Center and the mobile apps are other version lines and skipped; a
line with no bound leaves its key to the record and NVD.
The nightly cvelistV5 build merges it under the zoom / zoom-vdi keys. The
bulletin decides each CVE it bounds, also over a record's own range
(CVE-2026-53416: record "< 7.0.11", ZSB-26018 adds the 6.6.15 branch fix).
Its lines name the platform and client or plugin, so these entries also
reach macOS and Linux and skip the CVE-text heuristic. The MSI-build limit
is unchanged: 7.1.43453 gets no verdict on a 7.1.9 fix.
A bulletin that is not freshly parsed (unreachable, parsed empty, rolled
off the overview) keeps its last parse, so its findings do not close as
patched while NVD still has no bound. Index v48.
Field case: the inventory lists "GNU Privacy Guard" 2.4.8 (The GnuPG
Project); it got no CVE-2025-68972 (GnuPG through 2.4.8). GnuPG had no row
on any path.
New NVD row gnupg:gnupg. NVD has the record analysed and bounds each
branch; for 2.4.8 it returns CVE-2025-68972 and CVE-2025-68973.
No cvelistV5 row, although that was the first choice: MITRE's GnuPG
records write one range over all branches. CVE-2025-30258 is "0 .. < 2.5.5"
there (NVD: < 2.4.8 and 2.5.0 .. < 2.5.5) and the 2.5-only CVE-2026-24882
is "0 .. < 2.5.17" (NVD: 2.5.13 .. < 2.5.17); both would land on a 2.4.8
that is fixed or never affected. The name is anchored, so Gpg4win, which
carries its own version line, is not matched. Distro packages fail the
version cleaner and stay Wazuh's.
Field case: Intune lists "Blender" 5.0.0 and Wazuh "Blender" 5.0.1
(Blender Foundation); neither got CVE-2026-60103 (3.0.0 through 5.1.2).
Blender had no row on any path.
NVD has the record "Deferred" with no configuration, and its
blender:blender rows stop at 2022, so cvelistV5 is the only structured
source: blender / blender, "3.0.0" .. lessThanOrEqual "5.1.2". The fix is
shown as ">5.1.2". The record's "unaffected" entry is a git commit and is
ignored.
Only the vendor's spelling is paired; the 2022 records name the vendor
"n/a" and write prose into the version field. The name is anchored
("Blender", "Blender 4.2 LTS"), so launchers and add-ons stay out.
Windows and macOS hosts only: a distro package ("4.3.2+dfsg-2") cleans to
4.3.2 and says nothing about backports. Index key bumped to v47.
Field report 13.10.2026: CVE-2026-53412 (ZSB-26014) was not found on hosts
listing "Zoom Workplace (64-bit) 7.1.43453" and "Zoom VDI Universal
Plugin(64bit) 6.6.26830".
Cause 1, one CPE name. Every name containing "zoom" was asked as zoom:zoom,
the desktop client's old name. NVD has since filed it as zoom:meetings and
zoom:workplace_desktop, and the VDI client as vdi_windows_meeting_client(s),
virtual_desktop_infrastructure and workplace_virtual_desktop_infrastructure.
CVE-2026-53412 names only the new ones. Each row now asks every name. The
names are anchored, so Zoom Rooms, the Outlook plugin and the SDKs no longer
draw the client's CVEs.
Cause 2, the version hides the patch. Zoom's MSI writes major.minor.BUILD
(the plugin's 6.4.26620 is release 6.4.12); NVD's server-side match and
ours both read the build as a patch, so "6.6.0 .. < 6.6.15" never fired and
a floor like "> 5.14.14" passed every 5.14 build. NVD is now asked for x.y.0,
and a bound inside the install's own minor gets no verdict: no new finding,
but the CVE counts as touched so an open finding is not closed on no
evidence. Bounds in other minors still decide (6.6.22255 < 7.0.0). The EXE
form "6.0.11 (39959)" is unchanged.
Cause 3, client vs plugin. NVD files the VDI client and the VDI plugins
under one CPE. The CVE text decides: a plugin takes only CVEs that name a
VDI plugin, the client all but plugin-only ones.
cvelistV5: the old pairs used vendor "zoom", which no record does. Paired
now are the spellings with real bounds (CVE-2026-53416, "VDI Client and
Plugins" < 7.0.11, is still Awaiting Analysis at NVD); "see references",
"unspecified" floors and "before X" prose stay unpaired. Windows only.
The Wazuh sync only flipped the asset inactive; the stale wazuh_agent_id
kept the software view and the app-CVE scan asking Wazuh for an agent that
no longer exists, never Intune. A dual-registered asset whose agent is gone
from the agent list now loses the Wazuh link, so both fall through to
Intune; a returning agent is re-linked by hostname. Wazuh-only assets and
an empty agent list are left alone.
Field report 12.10.2026: a device known from Intune with an uppercase
name got a second asset as soon as its Wazuh agent reported the same name
in lowercase. Intune, Defender and Wazuh findings of one machine ended up
on two rows.
Cause: the Wazuh asset sync was the only source that compared hostnames
case-sensitively (Asset.hostname == name). Intune, Nessus, vCenter, IGEL,
Netdisco and Defender all match with ILIKE, which is why the other
direction (Wazuh first, Intune later) always merged.
The agent name stage and the cluster-node name stage now use the same
literal, case-insensitive compare (like_literal + ILIKE): every character
must match, only case is ignored. The cluster-node stage also goes through
pick_one, so several case variants are settled by the address or not at
all. Within Wazuh nothing changes: two agents that are both in the run
stay two assets, even when their names differ only in case. An agent
without a name skips the name stage.
Existing forks are not merged automatically. The lowercase row holds the
agent id; README.DEV describes how to heal it and how to find candidates.
Field report 11.10.2026: a Netdisco router (C1121X-8PLTEP, IOS XE 17.15.6)
carried CVE-2025-20363. Cisco's Software Checker and Cisco's own cvelistV5
record agree that 17.15.6 is not affected; the record's 17.15 entries stop
at 17.15.3.
Cause: the hit came from the NVD half alone. NVD writes this CVE's whole
IOS XE configuration as one range, 3.2.0sg .. 17.17.1 inclusive, which is
just Cisco's first and last listed release with every train in between.
17.15.6 sits inside it. The cvelistV5 half never matched, but its reconcile
folds the NVD half's hits in, so the finding stayed open.
Dropping NVD ranges for Cisco is not the fix. Measured against all 560 IOS XE
CVEs at NVD: 89 carry ranges, and on most of them Cisco's record enumerates
no IOS XE releases at all, so the range is the only data. Where Cisco does
enumerate, the range only adds releases Cisco did not list (CVE-2025-20196:
102, also CVE-2023-20235, CVE-2023-20198/20273).
scan_asset_os now skips an NVD hit when cisco_unlisted says Cisco's record
enumerates that family for the CVE and leaves the installed release out. The
nightly app scan and the Netdisco sync pass the cvelistV5 index they already
load; without one the NVD half decides alone, as before. The open finding is
closed by scan_asset_cisco's existing reconcile on the next pass.
Field reports 09./10.10.2026: on a Wazuh-only Server 2016 at Edge
152.0.4191.62 (fix 154.0.4258.37), 15 MSRC-only Edge CVEs went open ->
patched in the same second ("MSRC scan: ... is now at or past the fixed
build") and back to open on the next scan. Two Intune-only clients showed
the same flapping, plus a finding on a stale Edge build.
Cause 1: build_product_index skipped a CVRF document that failed to load
and stored the rest. The package reconcile reads "CVE not in the index" as
"host is past the fix", so it closed every CVE of the missing month; the
next complete build reopened them. A failed document now raises, which
keeps the cached index (ensure_index and the 01:30 refresh already handle
that). The reconcile also takes the inventory now and keeps a finding while
the product is still below the fixed build MSRC recorded on it, so a CVE
missing from the index for any other reason cannot close it either. An
update past the fix or an uninstall still closes it.
Cause 2: Intune lists Edge several times ("Microsoft Edge" 154.0.4258.62 +
152.0.4191.53, or 153.0.4234.32 next to the MSIX
"Microsoft.MicrosoftEdge.Stable" 154.0.4258.37). Edge Stable is one
machine-wide install and the extra entries are per-user registrations that
lag behind it, but each entry was scanned on its own and the stale one
flagged. Edge is now scanned once, at its newest build; the MSIX can raise
that build but never flags on its own.
Field case: the Nessus licence expired and every scan answered
"Nessus 403 on /scans/157: {"error":"Expired license."}". Sync Health showed
an amber "8 ERR" card, the run was COMPLETED, and nobody got a mail, while
not one scan had been imported. The Wazuh API returning 500 goes red and
mails.
Cause: the Nessus sync never called fail_if_source_broken(); neither the
scheduler nor POST /nessus/sync asked whether the source itself was down.
Each scan error was caught and listed, and the run closed as completed. It
could not have caught it anyway: scans_processed was counted before
get_scan(), so it read 8 with 0 scans read, and "nothing synced + errors"
never matched.
scans_processed now counts scans actually read, and both callers pass it
to fail_if_source_broken(). Every scan failing is a failed run with the
usual mail (one per source per 24h); one unreadable scan among readable
ones stays completed with errors. The button returns 502 instead of
"Nessus sync completed"; HTTPException passes the catch-all so it is not
rewrapped as 500. The failure text says "scans" for Nessus.
Field case: an Intune host lists "MSTeams 1.0.0.0" (CN=Microsoft
Corporation). It drew CVE-2023-4863, -29328, -29330, CVE-2022-21965 and
CVE-2025-53783, plus the Teams classic EOL.
1.0.0.0 names no build. It is the new client's MSIX package (Intune's
"MSTeams", Wazuh's WindowsApps\MSTeams_1.0.0.0_...) with no version read;
no desktop Teams shipped as 1.0.0.0 and classic was never MSIX. Four parts
under 100, so it passed the scheme check and sat below every classic and
Android bound. teams_placeholder_version (1.0.0.0, 1.0, 1, 0.0.0.0) is now
skipped by the cvelistV5 and NVD scans and by _teams_classic. 0a73eafb had
read it as classic; "MSTeams" now counts as the new client for EOL, like
"Microsoft Teams". Real classic builds (1.5.00.31168) keep both.
CVE-2025-53783 came through teams_panels: the NVD filter matched the
vendor:product token as a substring, so microsoft:teams also took
teams_panels and teams_phones (1.0.9x device builds), and Teams for
Android (1.0.0.2025...) got their CVE and the Panel fix 1.0.97.2025102203.
Teams now takes its CPE exactly. Other products keep the prefix match;
Adobe relies on it (acrobat_dc). NVD cache prefix v5.
Field report CVE-2026-106269 / CVE-2026-106211: first seen by Defender TVM,
the list showed package "Microsoft Windows 11" while the detail page showed
Google Chrome via app-scan.
Cause: d708a04 kept the OS fallback for machines with no row in the
SoftwareVulnerabilitiesByMachine export, meant for devices the export never
covers (iPhones). A fully patched Windows client has no rows either: nothing
was vulnerable at the last snapshot. When Chrome 155 shipped, the
per-machine call already listed its CVEs, the export did not, and the row got
the asset's OS. App-scan then cross-confirmed it, recorded Chrome as a
package row and left the parent name alone (fill-only).
The export covers every desktop OS, so a Windows, macOS or Linux asset never
falls back to its OS label; the package stays empty until the export names
it. iOS and Android keep the fallback.
Migration 062 repairs rows already written, with 057's rules. It also covers
old bare "Microsoft Windows" placeholders: since 2f6334e the asset reads
"Microsoft Windows 11"/"10", so neither 057 nor the sync's heal matched them.
Field case: clients moved to Intune without a Wazuh agent got no Windows
OS CVEs at all. Graph reports operatingSystem "Windows" and the build in
osVersion, never 10/11 or the edition. The sync stored a bare "Microsoft
Windows", and the cvelistV5 Windows pass skips a Windows it cannot place
in a family (_win_family), so these hosts were never scanned. The MSRC
OS pass covers Windows Server only.
_os_string now places the family from the build: 10.0.22000 and up is
Windows 11, 10.0.x below is Windows 10. The existing family-matched,
release-bounded pass does the rest and closes a finding once the build
reaches the fix. Edition does not matter for CVEs; Microsoft's records
are per release and architecture.
Only for devices whose managementAgent contains "mdm". Intune cannot
MDM-enrol Windows Server, but servers under Defender security settings
management are listed too, and 10.0.26100 is Server 2025 as well as
Windows 11 24H2. Those keep the bare label. managementAgent is now part
of the managedDevices $select.
Side effect: on hosts with both Wazuh and Intune, the 02:10 Intune sync
used to overwrite Wazuh's "Microsoft Windows 11 Pro" with the bare label,
which took the host out of the Windows OS pass until the next Wazuh sync.
It now writes "Microsoft Windows 11" and the host stays scannable.
Field case: an Intune host with "Microsoft Teams" 1.5.00.31168 and "Teams
Machine-Wide Installer" 1.5.0.31168 did not get CVE-2023-29328 / -29330
(Teams for Desktop, fixed 1.6.00.18681) from cvelistV5.
cvelistV5: teams is a strict-scheme key, so a bound in another build shape
is not compared. The check also ran over the floor, and Microsoft writes
the desktop floor as "1.0.0": three parts against 1.5.00.31168's four,
so every classic range was refused. It checks the upper bounds only now,
which also applies to adobe-acrobat (its HEAD tests still pass). The key
is skipped on a Mac: "Teams for Mac" is not paired, so it held only the
desktop's ranges.
NVD found both, but showed the macOS fix (1.6.00.17554, listed first).
_query_nvd_cpe now keeps the fix per target_sw, and scan_asset_packages
takes the host's own, else a platform-neutral entry's. That applies to
every NVD product. The Teams entries get the same upper-bound scheme check:
CVE-2023-24881 is "< 2.10.1" on NVD, Microsoft's record says 2.0.0 ..
< 2.10.1 and MSRC's only fix is 2.10.1, so it no longer lands on classic
1.x, where it could never close.
Names: Intune's "MSTeams" and a bare "Teams" match on both paths. The
Machine-Wide Installer stays out: it stages the client and keeps its own
version while the client updates itself; it keeps its EOL flag.
NVD cache prefix v4 (diagnose_scan follows) so the cached decisions are
redone on the next run.
Field case: an Intune host with "Java(TM) 6 Update 13" 6.0.130 and
"Java(TM) SE Development Kit 6 Update 13" 1.6.0.130 got nothing from the
app scan. Defender reports CVE-2013-1493 on the JDK. Not JDK-specific: the
JRE missed the same CVEs.
cvelistV5: the oracle-java key paired only "Oracle Java SE". Oracle filed
2018-2020 as "Java" ("Java SE: 6u201, 7u191, 8u181", lists split across
entries) and 2021-2024 as "Java SE JDK and JRE" ("Oracle Java SE:17.0.9"
next to GraalVM), so those years reached no Java install. Both are paired
now; _java_se_versions keeps the Java SE pieces (a label holds until the
next) and drops Embedded, JRockit and GraalVM. 9+ installs ("Development
Kit 17.0.9") and versions ("11.0.26", "24") now parse, for names that
start with "Java " only, so Temurin, Corretto, Microsoft's OpenJDK and
java-17-openjdk stay out. One finding per CVE (8u441 and 8u441-perf).
NVD: new rows for Java 6-8 (oracle:jdk + jre, and their sun: twins for
the Sun-era records). cvelistV5 has CVE-2013-1493 and CVE-2018-13785 as
"n/a". NVD writes the update in the CPE update field
(oracle:jdk:1.6.0:update13), so the release is the query and
_java_affected decides; "*:update41 up to 1.6.0" is 6u41 and below, an
entry without an update is the GA build. 9+ has no row: NVD names only the
newest affected update, which an exact query for an older one never
returns.
Both paths read Oracle's "newest affected update" as that update and every
earlier one of the same release, the rule the cvelistV5 path already used
(checked against Defender). This is the deliberate exception to "everything
below X is never a match": it stays within one release.
The NVD query now follows startIndex over every page. It read only the
first 200 records for every product; oracle:jdk:1.6.0 has 459.
Field case: Wazuh lists "NanaZip" 6.0.1632.0 and "NanaZip Preview"
6.5.1742.0 (both MSIX, Kenji Mouri) on one host. Neither had a row.
Source: M2Team/NanaZip's own repo advisories (21, all with a CVE) on the
GitHub repo-advisory path. Only that feed states the fix for both lines.
NVD has the stable bound alone and no configuration from CVE-2026-54616
on; cvelistV5 drops the Preview line on 11 records. No NVD or cvelistV5
row: every NanaZip CVE there is also in this feed.
The version decides the line, not the package name: each fix names a build
on a major.minor line, and the build part only grows within it. The
maintainer writes prose ("5.0 Update 1 (5.0.1252.0) or later",
"Stable: >= 6.0.1698.0 / Preview: >= 6.5.1742.0"). _norm_ops now keeps the
bracketed build and reads "or later" as >=, and patched_floor splits on "/"
as well. 6.0.1632 gets 18 CVEs, Preview 6.5.1742 the four fixed in
6.5.1749, and 6.5.1750 and 7.0 get none.
This applies to every repo: the fixed version shown is now patched_floor's
fix for the install's own line, not only a bare build. Wazuh's ">= 4.14.4"
used to leave it empty. With no fix on the install's line, patched_floor
takes the nearest one above it instead of the highest.
Field case: an Intune-only host on "Lenovo System Update" 5.08.03.59
(Lenovo) did not get CVE-2026-6387 (fixed 5.08.04.85). System Update had no
row on any path.
NVD has the record at "Awaiting Analysis" with no configuration, so
cvelistV5 is the only structured source: Lenovo / System Update,
"0" .. lessThan "5.08.04.85". New row on that pair. The zero-padded parts
compare as numbers.
The row matches that structured spelling only. Lenovo's records under
"Lenovo System Update" (CVE-2022-4568, CVE-2023-4632) write prose into the
version field ("Versions prior to 5.08.02.25"). The exact-version fallback
reads that as the fixed build itself, which would flag a patched host. The
name is anchored, so Vantage, System Interface Foundation and Update
Retriever are not matched. Index key bumped to v45.
Since 2026 Adobe files Illustrator and Photoshop CVEs under one product
per yearly line: "Illustrator Desktop 2026" / "Illustrator Desktop 2025",
"Photoshop 2026" / "Photoshop 2025". The cvelistV5 rows only knew
"Illustrator" and "Photoshop Desktop", so those records (23 for
Illustrator, among them CVE-2026-71441) resolved to nothing. A pattern
entry with "20\d\d" now catches them, so next year's line needs no edit.
Each line is stated as "0 .. <= last broken build", the shape Media
Encoder uses. Read literally, the 2026 range covers a patched 2025
install (Illustrator 29.8.10 below 30.6), so both rows set major_floor.
That also floors the older one-line records ("Photoshop Desktop",
0 .. <= 26.1), the way NVD reads them (25.0 .. < 25.12.1, 26.0 .. < 26.2).
Earlier majors are left to NVD.
The Media Encoder test now also covers CVE-2026-34639, which states both
lines inside one affected block. Index key bumped to v44.
Field case: an Intune-only host on "Adobe Media Encoder 2026" 26.2.2
(Adobe Inc.) got none of CVE-2026-48370, -48366, -47979, -47976 and
-47971. Media Encoder had no row on either path.
NVD: new row adobe:media_encoder. NVD has the batch analysed, "<= 25.6.5"
and "26.0 .. <= 26.2.2".
cvelistV5: new row on Adobe's "Adobe Media Encoder". Adobe states each
supported line as "0 .. lessThanOrEqual <last broken build>" and names
the patched build only as "unaffected" (26.3, 25.6.6). Read literally,
the 26 range also covers a 25.6.6 that is already patched. The new
registry flag major_floor gives such a range its bound's major as the
floor, so each range stays on its own line (Media Encoder 2025 = 25.x).
It applies to inclusive bounds only: a "< 26.0" floored at 26 would be
an empty range. Older lines get no finding from cvelistV5; NVD covers
them. Index key bumped to v43.
A Citrix host listed 27 "Microsoft Teams" builds, every one under
WindowsApps\MSTeams_<ver>_x64__8wekyb3d8bbwe, while only the newest folder
existed on disk. Syscollector reads MSIX packages per user hive, and each
profile keeps the registration of the version it last had after Windows
removed the folder. The old builds drew Teams CVEs (CVE-2025-53783 on
25094.310.3616.953) that no update could ever close.
get_packages now keeps only the newest version per package family (name,
architecture, publisher id from the folder name), so the app scan, EOL,
M365 and the software tab all see the same cleaned list. Entries outside
WindowsApps are untouched, so classic Teams 1.x keeps its EOL finding.
0a73eaf pinned the 1.x classic-Teams rule to the exact normalised name
"teams", i.e. what Wazuh syscollector reports. Intune detectedApps lists
the same WindowsApps package by its AppX name "MSTeams", which normalises
to "msteams", so an Intune-only host with MSTeams 1.0.0.0 got no EOL
finding. The Intune path itself was fine: detectedApps feeds the same
run_eol_for_packages -> resolve_ms_lifecycle_eol chain.
Accept both names; the version still decides (1.x classic, 2xxxx new
client), so a current MSTeams stays clean.
The sync client had no row on any path, so CVE-2025-66549 (fixed 3.16.5)
and CVE-2025-47792 (fixed 3.15) never reached a host. Intune and Wazuh
both list it as plain "Nextcloud" (3.15.3.20250107, 3.14.2.20241018).
NVD: new row nextcloud:desktop. NVD has both CVEs analysed, with ranges.
The name is anchored, so Talk, Files and the Office connectors (other
version lines) are not matched. The fourth version part is the MSI build
date; ver_parts=3 drops it, otherwise an exact-version CVE such as
CVE-2023-22472 (3.6.1 only) never equals 3.6.1.20221208.
GHSA: nextcloud/security-advisories joins the repo-advisory path.
Nextcloud publishes there before NVD analyses the record. The repo
covers the server, its apps and the mobile clients too, and Android's
3.x line overlaps the desktop's, so _PACKAGES keeps only the Desktop
package blocks (19 of 278 advisories in the live feed). The range
parser now reads "3.0.3 up to 3.2.4" (CVE-2021-37617) as a range rather
than as "anything below the fix".
No cvelistV5 row: those records name the product "security-advisories"
for every Nextcloud product, with prose bounds ("< 3.16.5"), so the
pair cannot tell the desktop from the server or Android.
Checked against the live feeds: 3.15.3.20250107 gets CVE-2025-66549
only; 3.14.2.20241018 gets that and CVE-2025-47792, and not
CVE-2024-52510, whose fix is 3.14.2. Both paths agree.
Known gap, documented in README.DEV: three old single-OS advisories
(fixed by 3.13.4 at the latest) are not platform-filtered on either
path.
Field case: an Intune host on "LibreOffice 26.2.4.2" (The Document
Foundation) got none of the 26.2.5 batch (CVE-2026-63266..63270, -63277
and siblings, "Addressed in LibreOffice 26.2.5/26.8.0").
Cause: LibreOffice had a row on the NVD-CPE path only, and NVD still has
these records at "Received" with no configuration. The cvelistV5 path had
no LibreOffice row, so resolve() returned None. It now has one, matched
on TDF's own vendor/product pair. Each record bounds one series
("26.2" .. lessThan "< 26.2.5"), so 25.8 and 26.8 stay out.
TDF writes the operator inside the bound. The compare reads only the
digits and survived that, but "< 26.2.5" would have become the finding's
fixed version for good. _clean_bound now strips a "<" inside a lessThan.
"<=" inside a lessThanOrEqual stays as it is: that is HPE's Aruba shape,
which scan_asset_aruba skips while its prose version field is unparsed.
Across the current cvelistV5 ZIP the change touches LibreOffice records
only.
The fourth digit is a build number and needs nothing special: the bounds
are three-part, so 26.2.4.2 sits below 26.2.5 and 26.2.5.1 does not.
Both rows are anchored and skip the Help Pack, Language Pack and SDK.
The Help Pack carries the suite's version and lags behind after an
update, which would keep the finding open. Index key bumped to v42.
Question from the field: when one Intune device answers detectedApps with
500 Internal Server Error, is it fetched again until it succeeds? It was
not, and the gap was silent.
Cause: GraphClient._get retried only timeouts and 429, so a 5xx raised at
once, and get_detected_apps caught that error and returned []. Every
caller (app scan, Intune sync, the asset's software view) read that as a
device with no software: nothing was detected on it that run, and nothing
was recorded. Nothing got closed either, since an empty inventory never
reconciles, but a device that kept failing stayed unscanned for good
without anyone seeing it.
500/502/503/504 are now retried with a short backoff. A failure that
persists raises out of get_detected_apps instead of returning []: the app
scan counts it as inventory_fetch_failed (the nightly log drops `errors`),
the Intune sync records it in its errors instead of a debug line, and the
software view returns a 502 instead of an empty list.
DefenderClient._get gets the same 5xx retry. A single 500 on the
tenant-wide SoftwareInventoryByMachine export emptied it for every machine.
"Microsoft Visio 2016 / 1.0 / Delivered by Citrix" is a launcher for an app
that runs on the Citrix server, not on the host. The CVE scanners drop these
rows (filter_inventory); the EOL sweep kept them on purpose (f14eb1b) and
dated them by name through the MS-lifecycle listing, which put
EOL-MS-LIFECYCLE-MicrosoftVisio2016 on a host where Visio is not installed.
Cause: the stub guard in run_eol_for_packages only blocked the
version-based endoflife.date path; the name-only MS-lifecycle path still ran.
Stubs are now skipped in the loop for every source. They are skipped rather
than filtered up front so an all-stub inventory still counts as an inventory,
and _resolve_stale_ms_lifecycle closes the findings the old behaviour left
open on the next run.
To get a CVE published today, the operator had to rebuild the cvelistV5
index on the host (docker compose exec ... build_product_index(force_fresh=
True)) before pressing the scan button, or wait for the 01:30 refresh. The
"App CVE Scan" button under Data jobs now does that itself: it calls
/app-cve-scan/start?rebuild_index=true, and the job runs the full nightly
refresh (fresh cvelistV5 ZIP + MFSA, GitHub advisories, TeamViewer, Citrix,
IGEL ISNs, MSRC) before it scans. The 01:30 job body moved into
app_cve_scanner_service.refresh_indexes so both run the same code.
Opt-in: the per-asset rescan and API clients keep reading the stored index,
no 557 MB download per click. The button's dialog says what it costs.
A failed rebuild keeps the cached index as before, but the button now says
so ("index rebuilds failed — scanned on the cached index"). That includes a
ZIP download that fell back to the old file on disk; otherwise "completed"
reads as "today's CVEs are in".
The button can now overlap the 01:30 job and the threat-intel cascade, and
all three streamed the ZIP into the same .part file. Each writer now uses
its own temp name; os.replace keeps the swap atomic.
.NET SDK: SDK-only CVEs (CVE-2026-58649, dotnet watch) carry every feature
band's fix in one field, lessThan "10.0.111, 10.0.400". Instead of being
dropped, those bounds now go to new dotnet-sdk-8/9/10 keys, one range per
band: [10.0.0, 10.0.111) and [10.0.200, 10.0.400). A band without its own
fix is out of support and stays vulnerable up to the next fix. Toolset,
Templates and the packs are parts of the SDK and are not matched.
NVD: runtime and SDK share microsoft:.net, so _in_range no longer compares
an SDK bound (patch 100+) with a runtime or the other way round. Without it
the runtime false positive would come back once NVD analyses the record.
.NET Framework (major < 5) is left alone.
ASP.NET Core was not checked at all. CVE-2025-55315 (CVSS 9.9),
CVE-2026-40372 and CVE-2026-26130 name only "ASP.NET Core x.0", never
".NET x.0". New aspnetcore-8/9/10 keys and the microsoft:asp.net_core CPE
match the shared framework and the runtime, not the targeting pack or the
hosting bundle options.
Checked against all 53 .NET CVE records since 06/2025: a fully patched
host with SDK 10.0.401 stays clean, runtime matches are unchanged.
CVE-2026-58649 is in `dotnet watch` (SDK only), but Microsoft files it under
".NET 8.0/9.0/10.0" with the SDK feature-band fixes as bound, two in one
field: lessThan "8.0.130, 8.0.424". The dotnet-* keys compare the runtime
version from the package name, and every runtime (8.0.31) sits below 8.0.130,
so Host, FX Resolver, Runtime and Desktop Runtime were all flagged on fully
patched hosts.
A runtime patch never reaches 100 (one a month); an SDK band always does.
Bounds with patch >= 100 are now dropped for the dotnet-* keys at index time.
Of the 53 .NET CVEs since 06/2025 this is the only one with SDK bounds; real
runtime bounds (CVE-2025-55247, 8.0.21) still match.
CVE-2026-88771 .. 88778 (bulletin CTX697096, cvelistV5 2026-09-27) were
not reported on a 14.1-72.61 appliance, although the records were in
the ZIP before the nightly index build. NVD has them as Received with no
configuration, so cvelistV5 is the only source.
The CNA changed the record shape twice. The vendor is now "Citrix
NetScaler", which the anchored vendor pattern rejected, so the records
never reached the index. And the line moved from `version` into the
bound: version "0", lessThan "14.1-73.37", where scan_asset_netscaler
took only entries whose version equals the line.
The vendor pattern takes the third spelling. An entry with version "0"
applies when its bound is exactly line-dash-build of the appliance's
line; "14.1-73.37 FIPS" and "13.1-37.279 and NDcPP" are not builds and
still never reach a normal appliance.
cvelistV5 now carries CVE-2026-84388 (Fortinet as CNA, 2026-09-22) and
NVD lists it as Awaiting Analysis with no configuration. FG-IR-26-168
and the CNA description both now name "7.4 all versions" as affected
next to "8.0 all versions", so the entry gets a second row, 7.4.0 <= v
< 7.5, whose fix is on the 8.0 line (8.0.1.123). An advisory row takes
an `end` for that case.
The entry also carries the CNA's cpe (fortinet:fortipam_chrome_extension),
so NVD picks up the extension's later CVEs once it analyses them. For a
CVE an advisory row holds, the rows stay the verdict and NVD's result is
dropped: the CNA's structured data says only "8.0.1 affected", which
cannot tell 8.0.1.100 from the fixed 8.0.1.123 and misses 8.0.0 and
7.4. For the same reason cvelistV5 is not read for this extension.
"N CVE(s) with a bound not resolvable to a build — held, not decided"
gave a count only, so there was no way to tell which CVE or which
installed package was held. The line now lists the CVE IDs and the
package name and version.
/auth/users is RequireAdmin, but the Users section was marked
admin: false. Read-only and editor accounts saw an empty table and a
403 in the console. They now see Profile only, and the list is fetched
for admins only.
A tester reported CVE-2026-76183 and CVE-2026-87022 as not detected on
"Apache Tomcat 9.0 Tomcat9 (remove only)" 9.0.107 on Windows. Both were
published on 23.09.2026 and NVD still has them at "Awaiting Analysis"
with no CPE, so cvelistV5 is the only structured source for now.
The real records, run through build_product_index and scan_asset, flag
the host, so the code is not the cause. The likely cause is timing: the
index is rebuilt at 01:30, so a scan on the day of publication could not
see them. The records are fixtures now, together with NVD's CPE ranges
for CVE-2026-73180. The test also covers the false-positive edges: the
fixed builds on each line (9.0.122, 10.1.60, 11.0.26) stay clean, and the
open "0 < 7.0.43" entry never reaches a 9.0 host.
Chrome 154 went stable on 22.09.2026 with 108 security fixes
(CVE-2026-95350 Critical, ...). Two days later none of them had a record
in cvelistV5 or NVD, so no host was flagged. Those were the only two
sources for Chrome. Every earlier release since July was on cvelistV5 by
then, so the gap is always the newest release.
The "Stable Channel Update for Desktop" posts name the fixed build per
platform ("154.0.8037.57 (Linux) 154.0.8037.57/.58 Windows/Mac"). The
lowest one is the lessThan that Google's CNA later writes into the
record (CVE-2026-93374: blog 153.0.8010.52 Linux, record lessThan
153.0.8010.52). _merge_chrome_releases adds these ranges to the chrome
key at the end of the index build, only for CVEs that no record has given
a Chrome range yet. Once the record lands, it decides with the same bound
and the finding continues. ChromeOS, Extended/Early Stable, iOS and posts
naming two majors are never read. When the blog is unreachable, the last
parse is served so findings do not close for a night. Index v41, so the
next App CVE Scan rebuilds it.
Checked against the live feed on 24.09.2026: 1265 CVEs from 17 posts,
all 108 of Chrome 154 with fix 154.0.8037.57.
Netdisco reports a QNAP NAS as vendor "qnap", os "qts", version
"5.2.5.3145", or os "quts hero", version "h5.2.10.3577". It now becomes
asset OS "QNAP QTS" / "QNAP QuTS hero" and is scanned from NVD and
cvelistV5 like the other eight firmware lines. The leading "h" is hero's
spelling and QTS never has one, so a row whose os and "h" disagree gets no
verdict. The two OSes share one numbering and each has its own NVD CPE
(o:qnap:qts, o:qnap:quts_hero) and cvelistV5 key (index v40).
NVD answers a hero virtualMatchString only with the "h", so the NVD pass
keeps it. Older NVD records enumerate every build as an exact CPE with no
fix; the CNA pass backfills the fix.
The CNA states one range per branch, with floors written "5.2.x", "h5.2.0",
"5.x", "4.5.x, 4.4.x", "?" or "unspecified", and bounds like
"QuTS hero h5.2.0.2851 build 20240808". CVE-2025-66273 bounds hero h6.0 as
"?" < h6.0.0.3397 next to its h5.2 and h5.3 ranges; read literally, that
flags every patched h5.x box. An entry without a floor therefore only
applies on the bound's own branch (the Aruba rule).
Checked 23.09.2026 against the full cvelistV5 archive and every NVD
QTS/QuTS hero record: QTS 5.2.5.3145 gets 71 CVEs, 70 from both sources,
CVE-2025-66274 from the CNA only and CVE-2025-48725 from NVD only.
QuTS hero h5.2.10.3577 and QTS 5.2.9.3499 are clean. CVE-2025-59382 names
both OSes unaffected and gives no finding. QNAP's advisory pages attach
the same CNA records as JSON and are not ingested.
MAX_CVES_PER_RUN capped every nightly run at 500 unique CVEs. Nothing was
lost — only answered CVEs get stamped, so the rest came back the next night —
but a large backlog took weeks to drain for no reason: Jev handles the volume.
The run now asks every unanswered CVE in one pass. What kept the run safe is
unchanged: one question per unique CVE, a commit every 50 answers, and a stop
on a systemic failure (auth, quota, network) that leaves the rest unstamped
for the next night.
The nightly audit entry names a token count only when the endpoint sends a
usage block, which neither TypeSafe nor OpenRouter Decisions promises. The
probe asks one question about one public CVE and prints which of the two
wordings the audit entry will carry, instead of waiting for 06:00.
docker compose exec backend python tools/probe_jev_usage.py
A Jev run only left a line in the backend log, so nobody could tell from
the GUI how many API calls a night had cost or how much of the fleet it
covered.
Every run that did something now writes one audit entry (System/Auto,
AI_ANALYSIS_REQUESTED with resource_type "jev_triage"): "Jev triage: 412
CVEs checked on 87 hosts, 13,204 tokens (jev-1.13.0)", new_value with
checked, hosts, tokens, model, the CVEs answered, skipped_cves and error.
Hosts are the ACTIVE assets carrying an open finding for those CVEs.
Tokens come from the endpoint's usage block; a CVE whose answer is
unusable was still billed and counts. When no answer reported usage the
entry says so instead of claiming 0, and a run that answered nothing
claims nothing about the endpoint at all. A run with nothing pending
writes no entry — "0 CVEs" every night would bury the real rows.
The plan endpoint capped at 100 groups, so everything below the top 100
was invisible and column sorting only sorted that cap. It now returns the
whole plan (?limit=N still truncates) and the page sorts and pages it
client-side with the shared Pagination bar, like Assets.
New GET /patch-plan/export streams the same plan as CSV in rank order,
named patch_plan_YYYYMMDD_HHMMSS.csv like the assets export, with the
exploited and check-config CVE lists spelled out in the row.
The Jev hint never read this list: it walks every open finding on ACTIVE
assets, one question per unanswered CVE, capped at MAX_CVES_PER_RUN a run.
A manual create under Assets writes ASSET_CREATED, a sync-created asset
only reached the backend log ("IGEL sync: auto-created asset TX-11777"):
searching the audit log for "created" found users, never assets.
Every sync create site now calls asset_lifecycle.created, the create
counterpart of rename: one ASSET_CREATED entry per new asset, "Asset
created by <source> sync: <hostname>", new_value JSON with hostname and
source. Covered: Wazuh agents and manager nodes, Nessus, vCenter, IGEL,
Intune, Netdisco and the container imports (source = scanner). Defender
never creates. Assets created before this stay without an entry.
Jev could only run over OpenRouter. The AI settings card now also takes a
TypeSafe API key (setting typesafe_api_key, encrypted at rest like the
OpenRouter key, or env TYPESAFE_API_KEY). When it is set, Jev goes straight
to api.typesafe.ai/v1/systemone with the pinned jev-1.13.0. Without it, Jev
uses the OpenRouter key and OpenRouter Decisions with typesafe/jev-1.13.
Both endpoints take the same body and give the same answer. The card
shows "Configured" when either key is set, and the TypeSafe key has its
own Remove button.
JEV_MODEL is gone. It overrode the model on both endpoints, but the two use
different model ids. Adding or removing a TypeSafe key switched the
endpoint and left the other endpoint's model id in place. Every CVE then
failed as a CVE-specific 4xx and was skipped, up to 500 requests with
nothing written and no error reported. The model is now fixed per
endpoint.
For the same failure shape from any other cause, such as a changed schema,
five CVE-specific failures in a row now stop the run with an error. The
run no longer walks through every CVE.
A test checks the actual request: URL, bearer key, model and question.
The Netdisco commit that added a LoadMaster device class changed the
deviceinventory row from os "LoadMaster" to os "LMOS" (LoadMaster OS).
loadmaster_family() compared the slug to "loadmaster" exactly. On the new
rows it returned None, so the asset was written as "kemp LMOS" with the raw
build string, and scan_key() gave no scanner. Neither CVE pass ran on the
box. New LoadMaster CVEs would not have been reported, and open ones would
not have closed.
Both slugs are now accepted. The kemp/progress vendor check still gates the
match. Reproduced by running the report row from the field through the real
client mapping into the scanner: the field build 7.2.63.3 comes out clean,
7.2.48.0 and 7.2.54.17 come out affected, and the device name does not
change any result. A full sync before and after the fix confirms the
missing scan and the restored one.
The Sophos row from the same Netdisco update (vendor "sophos", os "sfos",
model "XGS4300_AM02_SFOS") goes through the same test and was already
correct: the field build 22.0.1 MR-1-Build490 comes out clean, and older
releases come out affected. It is pinned by a test now.
Every column header on the patch plan now sorts, with the same ↕/↑/↓ header
and asc/desc toggle as the Assets table. The whole plan is loaded in one
request, so sorting happens in the browser. Columns that hold lists
(Exploited, Check config) sort by how many CVEs they hold.
The # column keeps the backend's rank: exploited CVEs first, then summed
priority. That rank is the default order, and clicking # restores it after
sorting by another column.
Against a backend without the patch-plan route, /patch-plan falls through
to /{vuln_id}, and the 422 response carries its detail as a list of
objects. The page passed that list straight into React, which cannot
render it, so the page came up blank. It now shows the HTTP status and
asks whether the backend is up to date.
The Jev toggle sat inside the "AI Remediation (OpenRouter)" card, so
nobody looking for "Jev" or "TypeSafe" found it. The card is now titled
"AI Remediation + Jev Triage (OpenRouter)" and says that the same
OpenRouter key serves Jev; no separate TypeSafe key is needed.
The findings list says what is wrong, not what to patch first: one Chrome
update closes a hundred rows, and one KEV on a single host matters more
than all of them. The new /patch-plan page (GET
/api/v1/vulnerabilities/patch-plan) groups open findings (open,
patch_failed) on ACTIVE assets by the product to update, using the
vulnerability_packages rows, else package_name, else the CVE id. Each
group shows hosts, findings, CVEs, the fixed versions as reported (never
compared) and summed/max priority_score. Groups with CVEs exploited in the
wild (KEV / EUVD) rank first, then by summed priority. Every number comes
from data the tool already scores.
TypeSafe Jev (via OpenRouter Decisions, pinned typesafe/jev-1.13) is asked
one question per open CVE, nightly at 06:00 after the last ingest: is it
only exploitable with an optional feature or a non-default configuration?
It is the one triage question no feed answers. KEV, EUVD, EPSS, SSVC and
the CVSS vector already cover exploitation and attack vector, and exposure
is a property of the host, not of the CVE text. The probability is stored
in vulnerabilities.jev_config_dependent (migration 061); CVEs at 0.7 or
above show under "Check config" on the plan. It never closes a finding
(cross-source contract) and never enters priority_score. The job is off by
default (Settings, AI card, or JEV_TRIAGE_ENABLED) and reuses the
OpenRouter key. It sends only public CVE text. An account or network
failure stops the run unstamped; a failure specific to one CVE skips only
that CVE, so the job cannot stall on the same CVE every night.
Dockhand findings sit on the image asset, so the Host column of the
Vulnerabilities list showed "ghcr.io/…/app:latest" and never the machine
to patch. ec34e30 linked each image to its Docker host asset
(container_placements), but only the asset page read that link; the
vulnerability list never did.
The list now attaches runs_on ({id, hostname}) to every item, one query per
page, on every sort path, and the Host cell shows "on docker-01" under the
image name. An image without a link (the Docker host's name matched no
asset, or several) shows nothing extra: the image name stays the only trace,
as before, rather than a guessed host.
The Advisories page mixed two different things in one scroll: the merged
KEV catalogs (CISA, ENISA EUVD, VulnCheck), which are matched against the
inventory and drive the alert mail, and the vendor/CERT RSS feeds, which are
reading material and match nothing. Its header showed "Last fetch" and
"Refresh now" above the KEV list, but both belonged to the RSS feeds alone;
the age of the KEV catalogs was shown nowhere.
The sidebar entry is now "Threat Intel" with two tabs. /advisories/kev puts
"Exploited & open in your environment" (the alert list) first and "Latest
KEV additions" below it. Its header shows each catalog's own last-fetch time,
or "not configured" for VulnCheck without a token. "Refresh catalogs" calls
the new POST /api/v1/advisories/kev/refresh, which forces all three past
their 24h cache and records failures per source. /advisories/feeds holds the
RSS feeds with their own fetch time, refresh and configuration. /advisories
redirects to the KEV tab, so bookmarks and the dashboard link keep working.
The sidebar now also highlights an entry on its sub-pages.
A server running Microsoft Entra Connect Sync 2.6.3.0 showed no CVEs,
because neither registry knew the product. CVE-2026-65673 (SQL injection,
local EoP, CVSS 7.8) affects every build from 1.0.0 up to 2.6.84.0 and is
stated in both sources: NVD as microsoft:entra_connect < 2.6.84.0, and the
cvelistV5 record as Microsoft / "Microsoft Entra Connect", 1.0.0 .. <2.6.84.0.
Both registries now match the product. The NVD path also queries the
pre-rename azure_active_directory_connect CPE (CVE-2021-36949, CVE-2017-8613),
and the cvelistV5 path also reads the "Microsoft Azure Active Directory
Connect 1.X.Y.Z / 2.0.X.Y" products from the same records.
The name is anchored and exact ("Microsoft Entra Connect Sync", "Microsoft
Azure AD Connect", plus the "synchronization services" row, which carries the
same build and dedups with it). The same server carries the Health Agent
(4.5.x) and the pass-through Authentication Agent / Agent Updater (1.5.x),
and 1.5.x sits inside the vulnerable range. The Provisioning Agent (Cloud
Sync) is a separate product and stays out. The cvelistV5 index goes to v39
so the new key is built. The finding closes through the normal app-scan
reconcile once the host reaches 2.6.84.0.
Every image was its own asset in the flat asset list, between the servers,
and the Docker host's own asset (docker-01 from Wazuh/Nessus/vCenter) said
nothing about what ran on it; the host was only text in the image's
description.
The Dockhand sync now matches each environment's Docker hostname (docker info
Name from GET /api/host) against non-container, non-decommissioned assets:
exact name first, then FQDN vs short form. Several candidates and the
environment's public IP / host does not decide, or none: no link, the
description stays the only trace. A wrong host is worse than none. Links go
to the new container_placements table (060), rewritten by every sync, so an
image gone from a host loses its link with the next run. Findings stay on the
image asset; the cross-source retraction is unchanged.
The asset page hides container images by default (include_containers=false,
"Show container images"); a host row shows an "N images" badge that opens
GET /assets/{id}/containers, an image row names its hosts. The list reads
both directions in one query, so its statement count stays flat. The new
route is readonly-visible like /software.
Hawser needs nothing on our side: it is Dockhand's agent for remote hosts,
and every Hawser host is a Dockhand environment like any other.
A script logging in through the API left the same "login OK via local as
readonly" entry as a browser login, with user agent "node" for both: the
Next.js proxy forwarded only Authorization, Cookie and Content-Type. It now
also forwards User-Agent and Sec-Fetch-Mode, and login success and failure
entries end in [Web-UI] (Sec-Fetch-Mode present) or [API]. No schema change.
Fortinet published FG-IR-26-168 on 2026-09-08 (CVSS 9.1, the Privileged
Access Agent extension proxies browser traffic for a malicious site); ten
days later NVD has nothing and cvelistV5 only a RESERVED id, so the
extension registry had no CPE to look up.
An extension entry can now carry `advisories`: rows read off the vendor's
advisory (start inclusive, fix exclusive), checked through the same
bounded _in_range. The FortiPAM entry (gkmiciealelfckimnoopefeplibjciih)
covers 8.0.0 ≤ v < 8.0.1.123: the CVRF lists 8.0.0 and 8.0.1 as affected,
the advisory text names 8.0.1.123, and the extension versions are the
advisory's plus a build number. 7.x is not listed and stays out. The NVD
lookup runs only for entries with a cpe, and the vendor fallback comes
from the entry instead of a hard-coded "Adobe". The finding closes through
the normal app-scan reconcile on update or removal.
One asset per image is deliberate (env A must not retract what env B still
reports), but with the same image on a local and a Hawser-connected host
nothing said where it ran: the asset was named after the image and carried
no host at all.
The sync now writes the asset description per Dockhand environment: the
environment name, the Docker host's own hostname from GET /api/host
(docker info Name) when it differs, connection type, host and public IP
when set, the short image id, the containers (name + 12-char id as docker
ps shows them) and stacks. Different image ids on two hosts show which one
is still on the old build. Container ids change on every recreate and are
shown, never matched on; Dockhand exposes no Docker daemon id and Podman
has none. A failing /api/host only drops the hostname. ingest_image takes
an optional description that replaces the digest line; the push path is
unchanged. No schema change.
A manual rename under Assets is audited, a sync-driven one left no trace:
Netdisco, vCenter, IGEL and Intune adopt the name the source reports once
they matched an asset on a pin, and the old name, still in a ticket or a
mail, then pointed at nothing.
All five rename spots now go through asset_lifecycle.rename, the rename
counterpart of apply_status. It logs "<source> sync renamed asset <id>:
old → new" at INFO and writes an ASSET_UPDATED audit entry "Asset renamed
by <source> sync: old → new", old_value the old name, new_value JSON with
hostname, old_hostname and source so the Asset column shows the new name.
Wazuh, Nessus, Defender and the container imports never rename.
A Kemp LoadMaster 7.2.48.0 in a test showed 15 findings but not CVE-2024-7591
(CVSS 10): both its CNA record (< 7.2.60.1) and NVD (<= 7.2.60.0) name only
the GA fix, and loadmaster_fix deliberately gives a long-term build no verdict
from a GA-only range. The same gap hides CVE-2025-1758 and
CVE-2026-3517/-3518/-3519/-4048 on every 7.2.48 and 7.2.54 box, and
CVE-2024-2448/-2449/-3544 everywhere: their CNA bounds carry labels no reader
parses ("7.2.54.9 ( LoadMaster LTSF)") and NVD files them under
a:progress:loadmaster, which the o: query never returns.
_LM_BULLETIN holds (start, fix) per line for those CVEs, read off the Progress
release notes (Security Updates of 7.2.48.11/.12, 7.2.54.9/.10/.12/.13/.14/.17).
A CVE with a row is decided by the row alone, in the cvelistV5 and the NVD
pass, through the existing per-line rules. Lines the notes do not name stay
out, so a 7.1.35 box gets no verdict where the sources disagree. 7.2.48.0 now
also shows 7591 (fix 7.2.54.12), 1758 (7.2.54.14) and the four 2026 CVEs
(7.2.54.17); 2448/2449/3544 start at 7.2.48.10/.11 and stay off it.
The page was one long wall: empty cards for unconfigured sources and a
Result column dumping every counter as key=value text. Sections are now
collapsible (.tv-section, same caret as the Data jobs drawer), unconfigured
sources share one line, and a sync run shows only the counters that moved;
the full stats grid and error list open on click. Badges and buttons use
tv-tag / tv-btn like the Vulnerabilities page.
A Grype scan of the backend image reported 1576 findings, 16 critical. Most
came from build headers nothing uses: every requirement ships a wheel (xmlsec
and lxml bundle their own libxml2 2.14), so libxml2-dev, libxmlsec1-dev,
libsasl2-dev, libldap2-dev, libssl-dev and pkg-config only pulled in
linux-libc-dev (1161 findings), libunbound8 and Debian's libxml2. The rest
were Debian fixes the python:3.11-slim base had not picked up yet (perl,
libc6, gzip, pcre2, sqlite, libevent) and old pip / setuptools / wheel.
The image now runs apt-get upgrade, upgrades pip, setuptools and wheel, and
installs requirements with --only-binary=:all:, so a requirement without a
wheel fails the build instead of needing a compiler. Rebuilt image: 187
findings, none critical, no High with a fix available; the remaining fixable
ones are Python 3.11 CVEs fixed only in 3.13+. App import, SAML/xmlsec and
the test suite run inside the image.
Dockhand has no scan schedule: it scans on pull, on update and by hand, so an
image that is never pulled keeps its first scan and new CVEs in its packages
never reach the export. With "Rescan nightly" (scan_nightly, default off) the
02:50 UTC job calls POST /api/vulnerabilities/scan-all per environment
(Dockhand >= 1.0.37, synchronous with Accept: application/json, 3 h read
timeout) before reading that environment's export. A failed or partial rescan
lands in the run's errors and does not abort; the export still holds the
previous scan. Sync now never rescans. The token needs images:inspect.
Docs and the settings card name Dockhand's Grype arguments
`-o json --by-cve -v {image}`; the default lacks --by-cve and GHSA ids do not
merge with other sources' CVEs.
Settings → Integrations → Dockhand (URL, dh_ API token, severity floor,
only-fixable). Nightly at 02:50 UTC or Sync now. Reads /api/environments and
each environment's /api/vulnerabilities/export (Dockhand >= 1.0.37) and feeds
every image through the container import, source "dockhand".
The same image on several hosts is one asset and retracts against the union
of all environments; a failing environment aborts before anything is written.
When Dockhand cannot reach a Docker host it exports stale scans of deleted
images while /api/images answers [] — findings without images abort the sync
instead of reviving them. Untagged images and rows without an id are skipped.
Container import: a CVE on a known-exploited catalog (CISA KEV, ENISA EUVD,
VulnCheck KEV) now passes the severity and only-fixed filters (kev_kept), so
enrichment flags it and the KEV alert fires. ingest_report is split into
parsing and ingest_image so both sources share one merge and retraction.
POST /api/v1/vulnerabilities/sync/container takes one Trivy or Grype JSON
image report. One asset per image (normalised repo:tag, source CONTAINER);
findings merge on (cve_id, asset_id) with sources trivy / grype, and GHSA or
distro advisory ids resolve to the CVEs the report itself names, so both
scanners dedupe instead of double-counting.
Ingest filter (min_severity, only_fixed) is server-side; retraction uses the
whole report, so a CVE hidden by the filter never closes as patched, and an
empty report retracts nothing. The first report of an image repository is a
silent baseline (new vulnerabilities.baseline column, Alembic 059): no
new-finding mail, but SLA and KEV alerts still apply. A new tag of a known
repository notifies.
tools/container_scan_push.sh scans every running image on a Docker host and
pushes the reports.
A Sophos Firewall CVE from 2021 on without an _SFOS_ADVISORIES row gets no
verdict, and until now only a log warning said so. The next SFOS advisory would
have been silently skipped. Those CVEs are now collected per run and added as
one line to the errors of the Netdisco sync (sync-run ledger) and of the
app-CVE scan: "sophos: no _SFOS_ADVISORIES row for CVE-… — no verdict until the
advisory's hotfixed releases are added". Pre-2021 CVEs stay debug-only.
NVD is queried under firewall_firmware and a:sophos:firewall only, two calls
per build instead of five. o:sophos:firewall holds only CVE-2022-1807
(cvelistV5 has it); sfos and xg_firewall_firmware only pre-2021 CVEs that no
row covers. a:sophos:firewall stays for CVE-2023-5552, which cvelistV5 lists
as unaffected entries only.
Netdisco reports a Sophos Firewall as vendor "sophos", os "sfos", version
"22.0.1 MR-1-Build490". It now becomes asset OS "Sophos Firewall" with that
string, read as 22.0.1.490, and is scanned from NVD and cvelistV5 like the
other seven firmware lines. EAP builds and GA/MR labels that contradict the
release are not read.
Both sources bound the MR (CVE-2025-6704: NVD < 21.0.2, CNA "21.0 MR2
(21.0.2)"). Sophos hotfixes older releases first (19.0 MR2 .. 21.5 GA for
6704), installs hotfixes automatically by default, and the firmware version
does not change, so the range alone flags patched firewalls. The advisories
are the only source naming hotfixed releases, HTML only (no CSAF, RSS carries
titles). They are not scraped: _SFOS_ADVISORIES holds per CVE the releases
with the fix and the hotfixed releases, from every SFOS advisory 2022-2025.
sophos_fix turns a source match into a finding only if the CVE has a row,
the box's MR got no hotfix and no fix on its own line is at or below it.
That also settles NVD's single "<= 19.0.1" on CVE-2022-3236 against the
18.5 MR5 fix. A CVE without a row gets no verdict and a warning.
NVD: firewall_firmware plus the older names a:firewall, o:firewall, sfos
and xg_firewall_firmware. cvelistV5: Sophos / "Sophos Firewall" (index v38);
SG UTM and the client products are not matched. Checked against the live
sources: 22.0.1.490, 21.5 GA and 21.0 MR1-2 are clean; 20.0 MR1 gets
6704/7382/7624/13973/13974.
Vendors rate by their own scale and can disagree with the CVSS band the
severity badge follows: CVE-2026-85921 is CVSS 8.2 (High) at NVD and
cvelistV5, Critical at MSRC. Chrome and Firefox already showed their
rating next to ours; every other source that states one dropped it.
vulnerabilities.vendor_severity (Alembic 058) holds the ratings per
vendor as JSON, written through Vulnerability.set_vendor_severity:
- Microsoft: the highest CVRF Type-3 Threat ("Max Severity"), or the
Update Guide's severity on the per-CVE path, stamped on every finding
with that CVE id whatever scanner found it
- TeamViewer: the bulletin's Priority (the "(High)" beside the score is
only the CVSS band)
- GitHub repo advisories: the advisory's severity, labelled with the repo
- Android ASB and Samsung SMR: the bulletin's rating
New findings merge their siblings' ratings, so a finding for an older
CVE does not wait for the Sunday 18-month MSRC ingest. IGEL is left out:
its only rating is the CVSS band.
The detail page shows "<vendor>: <rating>" only where it maps to a
different band (Important=high, Moderate=medium). A column rather than a
note in the description, as Mozilla does: M365 and Nessus rewrite the
description nightly.
Netdisco reports a LoadMaster as vendor "Kemp", os "LoadMaster", version
"7.2.63.3.23e1735.RELEASE.20260625-1143". It now becomes asset OS "Kemp
LoadMaster" / "7.2.63.3" and is scanned from NVD and cvelistV5 like the other
six firmware lines. Nothing from the vendor bulletins is ingested.
GA (7.2.55 .. 7.2.63), LTSF (7.2.54.x, EOL 7.2.48.x) and Multi Tenant
(7.1.35.x) share one numbering, and each advisory names one fix per line.
Both sources write those as independent ranges that overlap: the CNA's
"7.0.8 < 7.2.63.3" contains a patched LTSF 7.2.54.19, NVD's "<= 7.2.48.12"
a patched Multi Tenant 7.1.35.13. loadmaster_fix decides per line for both
paths: a range ending on the build's own line decides alone; a 7.1.35 build
gets no other verdict; a GA build takes any containing range; an LTSF build
only a range ending on a later long-term line. A record naming only the GA
fix (CVE-2024-6658's CNA entry) gives an LTSF box no verdict.
NVD files the 7.1.35 fixes under multi-tenant_loadmaster/_hypervisor; those
criteria are read into the decision. cvelistV5 matches "LoadMaster" and
"Multi Tenant (LoadMaster)" under Progress / Progress Software (index v37);
ECS Connection Manager, ObjectScale Connection Manager and MOVEit WAF are not.
CVE-2026-8037: the CNA starts the GA range at 7.2.60.0, NVD at 7.2.55.0, the
bulletin says "GA v7.2.63.1 and all prior". Findings are the union.
CVE-2026-33691 from the same bulletin is an OWASP CRS record without
LoadMaster and is not detected.
Netdisco reports a NetScaler as vendor "citrix", os "netscaler", version
"14.1-72.61". It now becomes asset OS "NetScaler ADC" with that build and is
scanned from NVD and cvelistV5 like the other five firmware lines. Nothing
from the vendor bulletins is ingested.
cvelistV5: the CNA names the release line in `version` and the build within
it in the bound ("14.1" < "43.56"). The generic range reader would read that
as 14.1 <= x < 43.56 and flag every appliance, so the entries are indexed raw
(index v36) and decided by scan_asset_netscaler, which applies only the entry
for the appliance's own line. CVE-2026-19489/-19490 write lessThanOrEqual
"73.32" where bulletin CTX696939 and NVD's analysis say before 73.32. It is
read as lessThan, so a patched appliance is clean and the finding can close.
Both spellings of the CNA (Citrix / "NetScaler ADC", NetScaler / "ADC") are
matched; NetScaler Console, which also files "14.1", is not.
NVD: netscaler_application_delivery_controller and netscaler_gateway. The
fips/ndcpp sw_edition entries carry their own bounds and virtualMatchString
does not filter on them. Verified live, a patched 14.1-73.32 still got
CVE-2026-19490 from the fips entry "through 14.1-73.32". The new
skip_editions registry field drops them.
FIPS and NDcPP builds are separate lines the inventory cannot see. On 13.1
and 12.1 they sit on build 37.x and 55.x, below every normal fix, so those
builds and any model or version naming FIPS/NDcPP get no verdict. Some
records omit the FIPS line the bulletin lists, so the record alone cannot
gate it.
Field check CVE-2026-69414: Defender labels the finding
"microsoft windows_defender 1.1.26070.7", which must stay that exact.
d708a04 skipped the placeholder repair for any export product starting with
"windows", so a "Microsoft Windows" placeholder whose export later named
windows_defender kept the generic OS string for good.
The heuristic is gone. Defender's own label always replaces its OS
placeholder, since it is the more exact name, OS or not. The one exception
is a row MSRC also claims: resolve_stale_os finds a finding by the asset's
OS string, and renaming it would leave MSRC no way to close it.
Rows that already carry Defender's exact label are untouched, as before.
The heal and fill logic only acts on the OS placeholder or an empty column.
d708a04 stops Defender labelling Chrome and Edge CVEs "Microsoft Windows"
and heals rows Defender still reports. Rows it dropped keep the placeholder:
rows MSRC took over, where the list said "Microsoft Windows" while the
detail page showed Edge, and closed rows.
Migration 057 touches only rows Defender created whose package is still the
asset's OS string. If another scanner recorded a package row, the parent
takes the earliest one's name and version. Otherwise, if the fixed version is
dotted and its major differs from the placeholder build (153.x against
10.0.x), name and version are cleared: unknown rather than wrong. Windows
CVEs fixed in 10.0.x and iOS CVEs keep their label. Data only; the downgrade
is a no-op.
Field report CVE-2026-87474: first seen by Defender TVM, the finding showed
package "Microsoft Windows", installed 10.0.26100.9445 (the Windows build)
and fixed in 153.0.8010.36 (Chrome). Rows whose CVE another scanner found
first were correct.
Cause: the tenant-wide SoftwareVulnerabilitiesByMachine export lags the
per-machine /vulnerabilities call, so a CVE published that day reaches the
device before the export names its software. 8ce476c falls back to the
asset's OS and os_version whenever the label is missing, which was meant for
devices the export does not cover (the iPhones of CVE-2026-64726). The
fill-only update then kept the placeholder once the export caught up; when
MSRC later added Edge as a package row, the detail page showed Edge while the
list kept "Microsoft Windows".
The OS fallback now applies only to a machine that has no row in the export
at all, and not when the export is empty. A covered machine with no label
yet gets an empty package, which the next sync fills. On a row Defender
created, its own OS placeholder is replaced once the export names an
application; OS products (windows_*, or the asset's own OS name) keep it.
Rows Defender no longer reports, like the ones MSRC took over, keep the old
label. This change does not rewrite them.
Field report 2026-09-15, four rows in a live estate:
1./2. A UniFi AP carried an igel_unit_id and a netdisco_device_ip. The IGEL
sync (02:30) wrote a thin client's name, IGEL OS and its findings onto the
row, the Netdisco sync (02:40) wrote the AP back over it. The merge came in
over a shared address before c73d062; it could still come in over the
name, because the hostname stage takes any asset without a pin of its own
source. A thin client is never a Netdisco device and vice versa, so the
IGEL and Netdisco chains now pass over assets that carry the other
source's pin, at the name and the address stage
(asset_matching.not_pinned_by). Consolidation with Wazuh, Nessus and
friends is unchanged.
3./4. A Windows server and a pre-053 re-IP fork still held the
netdisco_device_ip of an AP that had long moved. The netdisco-ip stage
took such a row for whatever device got the address next, decommissioned
rows included, so the new device inherited the old one's findings or
vanished behind a retired row. The stage now skips a row when the device
reports a serial the row does not carry AND a different name
(_other_hardware). Stack members share the name, a renamed switch without
a serial reports none; both still match.
Rows that already hold two devices are not split. Delete them and both syncs
recreate their own; README.DEV says so. A serial-less device on a stale pin
still cannot be told apart.
Nach c73d062, 40e9c29, 0936cf4 und a8961ec fuehrte ein Name an vier Stellen
trotzdem auf das Asset eines anderen Geraets — Findings am falschen Host:
1. `hostname.ilike(name)` ohne Escaping. `_` ist in LIKE ein Platzhalter,
"PC_LAB" fand "PC-LAB". Betraf Nessus, Netdisco, vCenter, IGEL, Intune
(match_by_hostname) und Defender.
2. Mehrere gleichnamige Assets ohne Pin der Quelle: die Stufe nahm das mit
der kleinsten id. Zwei "srv01" in zwei Domaenen, Nessus scannt das zweite
und schreibt aufs erste; Netdisco vertauscht beide dauerhaft.
3. Defender dasselbe ganz ohne Schutz. Seit Intune gleichnamige Geraete als
eigene Assets fuehrt, gibt es die Namensvettern dort wirklich; eine
Maschine ohne aadDeviceId landete auf dem erstbesten.
4. Der Wazuh-Cluster-Node (Werksname "node01") uebernahm ueber den Namen das
Asset eines echten Agents und ueberschrieb dessen Agent-ID — Flip-Flop mit
dem Agent-Sync bei jedem Lauf.
Dazu wies das manuelle Anlegen einen vergebenen Hostnamen mit 400 ab, das
zweite Geraet liess sich nicht fuehren — dieselbe Annahme, die c73d062 fuer
die IP-Adresse schon gestrichen hat.
Die Ursache war in allen Faellen dieselbe Annahme: bleibt nach dem Pin-Guard
mehr als ein Kandidat, sei der erste schon richtig. services/asset_matching
.pick_one entscheidet jetzt: ein Kandidat ist der Treffer, bei mehreren die
Adresse, die die Quelle meldet; entscheidet sie nicht, gibt es keinen Treffer
und die Quelle legt ein eigenes Asset an. Eine sichtbare Dublette statt einer
Vermischung. Wazuh-Agent-Stufe und Defender gehen durch dieselbe Regel; der
Re-Image-Fall bei Defender (ein Asset, zwei Maschinen) und die
Neuregistrierung bei Wazuh bleiben unberuehrt, dort gibt es nur einen
Kandidaten. Der Cluster-Node nimmt nur Assets ohne Agent-ID.
Bestehende vermischte Zeilen loest das nicht auf; tools/find_merged_assets.py
bleibt der Weg dorthin.
Der vCenter-Sync fragte aus `hardware.systemInfo` nur uuid, vendor und
model ab. Die Seriennummer steht im selben Objekt und wurde nie gelesen;
am Asset hing die VMware-UUID, aber nichts, was man vom Gehaeuse ablesen
oder dem Hersteller nennen kann.
Gelesen wird `serialNumber`, bei leerem Feld oder Platzhalter die
SMBIOS-Kennungen SerialNumberTag, EnclosureSerialNumberTag und ServiceTag
(Dell meldet die Service-Tag nur dort). AssetTag bleibt aussen vor, das ist
Freitext aus dem BIOS. Platzhalter fallen ueber stable_serial weg. Die Serial
steht in assets.vmware_serial (056), im Edit-Dialog als "ESXi host serial"
und als letzte Spalte im CSV-Export.
Kein Match-Schluessel: der Host wird weiter ueber die UUID gefunden, die
Zuordnung aendert sich nicht. Meldet ein spaeterer Sync keine Serial, bleibt
die bekannte stehen.
Die PropertyCollector-Abfrage holt jetzt `hardware.systemInfo` als Ganzes
statt einzelner Blaetter. `serialNumber` gibt es erst ab API 6.7, und ein
Pfad, den der Server nicht kennt, laesst den kompletten RetrieveContents
scheitern, fuer alle Hosts.
Die VMware-UUID im Edit-Dialog kam bereits mit dd82b85.
Kein Backfill: der naechste vCenter-Sync stempelt die Serial.
Der IGEL-Sync las aus `?facets=details` nur `unitID`. IMI liefert dort
`unitID` und `mac` mit demselben Wert, der MAC. Die Herstellerseriennummer
steht als `deviceSerialNumber` im selben Payload und wurde nie gelesen. `id`
ist die UMS-Datenbankzeile (neu bei jeder Registrierung), `assetID` ein
meist leeres Freitextfeld; beide identifizieren keine Hardware.
Die Seriennummer steht jetzt in assets.igel_serial (055) und matcht nach der
Unit-ID, vor dem Namen. Das faengt den Fall, den die MAC nicht ueberlebt:
Board- oder NIC-Tausch, UD Pocket neu registriert. Hostname und Unit-ID
ziehen dann nach, die Finding-Historie bleibt am Asset.
Die Serial uebernimmt ein Asset nur, wenn dessen Unit-ID in DIESEM Lauf
nicht mehr vorkommt. Zwei UD Pockets auf demselben PC melden dieselbe
Host-Serial und sind trotzdem zwei Geraete; ohne den Guard zoege die zweite
das Asset der ersten an sich (dieselbe Regel wie beim Wazuh-Agent, 0936cf4).
Platzhalter-Serials fallen ueber stable_serial weg.
Unveraendert und jetzt im Test festgenagelt: Umbenennen in UMS behaelt das
Asset (Unit-ID-Match); neue Hardware unter dem Namen eines verschwundenen
Geraets bekommt ein eigenes Asset, das alte wird INACTIVE.
Sichtbar war keiner der Pins. Der Edit-Dialog zeigt jetzt neben Entra,
Intune, Defender und Wazuh auch Intune-Serial, Nessus-UUID, VMware-UUID,
IGEL-Unit-ID und -Serial sowie Netdisco-IP und -Serial; der CSV-Export
haengt die fehlenden Spalten hinten an, damit bestehende Spaltenpositionen
bleiben.
Kein Backfill: der erste Sync nach 055 stempelt die Serial ueber die
Unit-ID. Bereits geforkte Assets werden nicht zusammengefuehrt.
Two faults, either one enough on its own. The inventory filter dropped every
package whose vendor contains "citrix", which was meant for the "Delivered by
Citrix" published-app stubs but also removed every real install ("Citrix
Workspace 2507", vendor "Citrix Systems, Inc."). And behind it, Citrix states
its fixes as release names, not builds: CVE-2026-78546/-78547 give
"2603.11 Current Release (CR)", "2507.1 LTSR CU3" and "LTSR 2607". Read as
digits, "LTSR 2607" is (2607,), so every 25.x/26.x build would have matched,
the patched CU3, CR 2603.11 and LTSR 2607 hosts included.
The inventory states only the build (25.7.1000.1025), with no CR/LTSR and no
CU. Both sides now meet on the build via a catalog read from Citrix's download
pages (title + "Version:"), kept only when the build agrees with its title. Five
CR pages print a sidebar build (22.12.0.48) first, and without that check
they would have entered the catalog as 2302..2307.1. The catalog is seeded with
44 pages and refreshed weekly by the 01:30 index job.
CR and LTSR never share a year.month, so the build's first two fields name the
branch. The decision reuses vmware_release_service.is_affected: CR is one
line, each LTSR is its own line with the CU as update line (CVE-2025-4879 fixes
2402 in CU2 HF1 and CU3 HF1). A bound that resolves to no single catalog build
leaves the CVE undecided and held open, never guessed. Windows hosts only; the
Mac app shares the name and its 25.07.x numbering.
Im Drawer war jedes der acht Kinder mit currentUser?.role !== 'readonly'
gegated, das <details> selbst nicht. Ein Read-Only-User sah damit die Huelle
samt "Data jobs"-Summary und klappte sie auf einen leeren Kasten auf. Nicht
durch 1807ea6 entstanden - der hat nur einen neunten Button hineingeschoben;
die Luecke kam mit e8e4707, als der Drawer entstand.
Jetzt traegt der Container das Gate, die acht identischen Gates darin
entfallen. Das ist das Muster, das Navigation.tsx schon anwendet:
visible.length === 0 -> return null, statt eine leere Huelle zu rendern.
Gleiche Klasse in policies: die Action-Spalte (Edit/Delete, canEdit bzw.
isAdmin) ist ein flex-gap-2-Div ohne eigenes Gate und blieb fuer Read-Only
als leerer Block in der gap-6-Reihe stehen.
Geprueft per AST-Scan ueber app/, components/, hooks/ und lib/, der einen
User je Rolle simuliert und Container meldet, die Chrome ohne Inhalt
rendern: vorher drei Treffer fuer readonly, danach keiner fuer readonly,
editor und admin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_dedupe_reenrolled` fasst alle managedDevices mit demselben deviceName
zusammen, bevor irgendetwas ein Asset anfasst. Fuer den Fall, fuer den die
Funktion geschrieben wurde, ist das richtig: ein gewischtes und neu
angemeldetes Geraet steht zweimal in Intune, beide Eintraege matchten dasselbe
Asset und pinnten abwechselnd ihre eigene device id darauf — der Lauf endete
auf der TOTEN Anmeldung und las deren eingefrorene App-Liste.
Der Schluessel war aber der Name allein, und ein Name ist kein Identifikator.
Ein Windows-Standardname (DESKTOP-XXXXXXX), eine Charge namensgleicher
Android-Geraete: das unterlegene Geraet fiel komplett aus dem Lauf. Kein
Asset, kein Inventar, keine Findings — und nichts in der UI, das sagt, dass
ein System fehlt. Eine Abdeckungsluecke wiegt hier schwerer als eine
sichtbare Dublette: ein Geraet, das gar nicht erst auftaucht, wird auch nicht
vermisst.
Die Hardware-Seriennummer trennt die beiden Faelle und Graph liefert sie im
selben Payload (`serialNumber`, schon im $select). Eine Neuanmeldung behaelt
sie, zwei Geraete teilen sie nicht. Sie steht jetzt in assets.intune_serial
und matcht vor dem Namen — dieselbe Kette wie beim Netdisco-AP (053), aus
demselben Grund: der stabile Identifier der Quelle (hier die device id) ist
eine Anmeldungs-Tatsache und wechselt, die Hardware nicht.
Der Pin fuer die Namensstufe ist deshalb die Serial und NICHT die device id:
auf die device id zu pinnen wuerde jedes neu angemeldete Geraet forken, also
genau den Fall zerstoeren, fuer den die Stufe existiert. Geraete, die keine
Seriennummer melden, verhalten sich unveraendert. Ein Asset ohne Serial bleibt
matchbar — Wazuh legt den Host an, Intune findet ihn ueber den Namen.
`stable_serial` liegt jetzt in services/asset_matching, weil zwei Ketten sie
brauchen; Platzhalter-Serials fliegen dort weiter vor dem Lookup raus, die
Liste ist um die drei DMI-Klassiker ergaenzt ("System Serial Number", "To Be
Filled By O.E.M.", "Default String").
Defender bleibt bewusst ungeschuetzt: ein neu aufgesetztes Geraet behaelt dort
seinen alten Maschineneintrag, mehrere Maschinen-IDs auf ein Asset sind der
Normalfall und der CVE-Abgleich ist darauf gebaut. Die Machines-API liefert
keine Seriennummer — es gibt nichts, was "neu aufgesetzt" von "zwei Hosts, ein
Name" trennt, und ein Guard auf die Maschinen-ID haette das lebende Geraet
nach jedem Reimage aus dem Scan geworfen. In README.DEV steht die Tabelle, wer
womit unterscheidet, und die Bedingung, unter der Defender nachzieht.
Kein Backfill — der erste Sync nach 054 stempelt die Serial ueber die device
id oder den Namen. Bereits zusammengefuehrte Assets werden nicht getrennt:
daran haengen Findings und Audit-Trail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dieselbe Regel wie in 40e9c29, eine Quelle weiter: der Wazuh-Sync fiel nach
der Agent-ID bedingungslos auf `Asset.hostname == name` zurueck. Zwei APC-PDUs
heissen ab Werk beide "RackPDU" — die zweite fand das Asset der ersten, nahm
ihm die Agent-ID weg und schrieb ihre Inventardaten darueber. Ein Geraet im
Inventar statt zwei, und welches es fuehrt, entschied die Reihenfolge des
Laufs.
Die Stufe abzuschalten waere hier der teurere Fehler gewesen: authd ersetzt
seit 4.3 per <force> einen Agent gleichen Namens (Default disconnected_time
1h, after_registration_time 1h), der Host meldet sich danach unter einer NEUEN
ID, und der Hostname ist der einzige Weg zurueck zu seiner Historie. Ein
Pin-Guard wie bei Netdisco/Nessus/vCenter/IGEL haette jede Neuregistrierung
auf eine zweite Zeile geworfen und die Findings an der ersten liegen lassen.
Unterschieden wird an der Frage, die der Sync ohnehin beantworten kann: kommt
die alte Agent-ID in DIESEM Lauf noch vor? Ein ersetzter Agent ist aus der
Liste verschwunden — Asset uebernehmen. Zwei Agents, die beide drinstehen,
sind zwei Hosts — jeder bekommt seine eigene Zeile. Der Konsolidierungsfall
bleibt unberuehrt: ein Asset ohne Wazuh-ID (aus Netdisco, Nessus, manuell)
sammelt der Agent weiter ueber den Namen ein.
Nebenbei die Reihenfolge festgenagelt (order_by): bei zwei gleichnamigen
Assets darf nicht der Query-Planer entscheiden, an welchem ein Agent haengen
bleibt.
Bestehende Zeilen loesen sich wie bei 40e9c29 von selbst auf, sobald beide
Agents in einem Lauf stehen — der zweite faellt durch die Namensstufe und
bekommt seine eigene. Rueckwirkend geteilt wird die Historie nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein UniFi AP hat seine Firmware aktualisiert und dabei einen neuen
DHCP-Lease gezogen. Gleicher Hostname, gleiche Seriennummer, trotzdem zwei
Assets: das neue ACTIVE, das alte INACTIVE, weil kein Geraet mehr darauf
zeigte.
Netdisco war ausschliesslich auf die kanonische Adresse gepinnt — und der
Hostname-Fallback ebenfalls, ueber pin_col=netdisco_device_ip. Das Asset mit
der ALTEN Adresse fiel damit aus genau der Abfrage heraus, die es haette
finden sollen. Alle drei Stufen der Kette haengen an derselben Adresse, also
half keine.
Die Seriennummer ist der einzige Wert, der Rename und Re-Adressierung
ueberlebt, und Netdisco meldet sie pro Geraet — sie wurde nur in den
Beschreibungstext geschrieben. Sie steht jetzt in assets.netdisco_serial und
matcht vor der Adresse.
Der Pin wird einmal gesetzt und nie ueberschrieben: Stack-Mitglieder
(VSF/IRF) melden einen Namen und eine Adresse, aber je eine Seriennummer,
und sie bleiben ein logisches Geraet — das Asset behaelt die erste Serial,
die uebrigen Chassis matchen weiter ueber die Adresse. Namensvettern bleiben
zwei Assets (40e9c29 gilt unveraendert). Platzhalter-Serials ("N/A", Nullen,
alles unter vier Zeichen) identifizieren nichts und fliegen vor dem Lookup
raus: darauf zu matchen waere der Fund am falschen Host, schlimmer als der
Fork, den das hier behebt.
Kein Backfill — der erste Sync nach 053 trifft jedes Geraet ueber seine in
dem Moment unveraenderte Adresse und stempelt die Serial dort. Bereits
geforkte Assets werden nicht zusammengefuehrt: an beiden Zeilen haengen
Findings und Audit-Trail, das bleibt eine manuelle Entscheidung.
test_asset_duplicate_hostname prueft nicht mehr auf den Namen der
Match-Stufe: sie heisst jetzt netdisco-serial statt netdisco-ip, fuehrt aber
auf dasselbe Asset. Die Stufe war nie die Zusage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dieselbe Annahme wie bei der geteilten IP-Adresse (c73d062), eine Stufe
hoeher: jede Sync-Match-Kette faellt nach ihrem stabilen Identifier auf
`Asset.hostname.ilike(name)` zurueck, und diese Stufe war bedingungslos.
Zwei APC-PDUs heissen ab Werk beide "RackPDU" — ein Name, den niemand
aendert, bei manchen Geraeten mangels Zugriff auch gar nicht aendern kann.
Die erste legt das Asset an und pinnt ihre netdisco_device_ip darauf, die
zweite findet es ueber den Namen, ueberschreibt Pin und Adresse. Im Inventar
steht ein Geraet, und welche der beiden Adressen es fuehrt, entscheidet die
Reihenfolge des Laufs.
Ein Name ist kein Identifikator. services/asset_matching.match_by_hostname
laesst ihn nur noch matchen, wenn kein anderes Geraet derselben Quelle den
Namen bereits belegt: traegt ein gleichnamiges Asset schon einen anderen
netdisco_device_ip / nessus_host_uuid / vmware_uuid / igel_unit_id, ist es
ein Namensvetter und kein Treffer. Ein Asset ohne Pin bleibt matchbar —
sonst stirbt der Fall, fuer den die Stufe existiert: Wazuh legt einen Host
an, Netdisco findet dasselbe Geraet ueber den Namen. Alle vier Ketten
gehen jetzt durch dieselbe Regel, inklusive der FQDN-Praefix-Stufe.
Anders als beim IP-Fall loesen sich bestehende Zeilen von selbst auf: das
zweite Geraet faellt beim naechsten Lauf durch die Namensstufe und bekommt
seine eigene. Rueckwirkend geteilt wird die Historie nicht.
Ein Stack ist der Gegenfall und bleibt EIN Asset. VSF-Mitglieder melden
sich je Chassis unter demselben Namen und derselben Adresse — ein logisches
Geraet, eine Firmware, ein CVE-Satz; drei Assets waeren dreifache Findings
und eine falsche Zahl im Dashboard. Was fehlte: der Lauf schrieb nur die
zuletzt gemeldete Seriennummer ans Asset. Der Netdisco-Sync sammelt sie
jetzt je Asset ein ("serials SG01TQ, SG02HY, SG03J6"), damit am Geraet
steht, aus wie vielen Chassis es besteht.
Nebeneffekt, bewusst in Kauf genommen: ein Switch, der seinen Namen behaelt
und eine neue Management-Adresse bekommt, forkt auf eine zweite Zeile, die
alte faellt per Reconcile auf INACTIVE. Eine sichtbare Dublette ist besser
als zwei Geraete auf einer asset_id, die sich Status und Hostnamen
gegenseitig ueberschreiben.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Button sass als einziger seiner Art rechtsbuendig in Zeile 1 der
Filterleiste, waehrend die neun anderen On-Demand-Laeufe im collapsed
"Data jobs"-Drawer lagen. Funktional ist er genau dasselbe: handleEnrichAll
POSTet /vulnerabilities/enrich/bulk, gleiches Muster wie Exploit Intel oder
MSRC Enrich. Unterschieden hat ihn nur die Haeufigkeit, nicht die Art.
Jetzt steht er als erster Eintrag im Drawer und behaelt tv-btn--primary,
bleibt also der auffaellige der Gruppe. Zeile 1 traegt nur noch Clear und
enthaelt damit ausschliesslich, was die Abfrage aendert - die Regel aus
9f57568, die fuer diesen einen Button nicht durchgezogen war.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Vulnerabilities-Leiste war ein einziger flex-wrap-Container: Suche,
Selects, Chips, EPSS-Feld und Aktionen sind je nach Fensterbreite quer
durcheinandergerutscht. Jetzt Zeile 1 = die Abfrage (Suche, Severity,
Status, Host) mit Clear und Refresh Threat Intel rechtsbuendig, Zeile 2 =
Verfeinerung (Source, EPSS, Katalog-Chips) auf einheitlicher Hoehe.
Dazu der Rest der Regel aus 91260b9, die nur halb durchgezogen war:
Emoji und Glyphen raus aus Buttons (Configure, Exposure, Notify All,
recompute), die handgebauten Spinner-Glyphen benutzen den vorhandenen
InlineSpinner, und Heroicons neben einem Textlabel entfallen (Export CSV,
Create Group, Logout, Sync Data ...). Icon-only-Buttons mit sr-only oder
title bleiben, die tragen ihre Bedeutung im Icon.
Exposure und Configure tragen jetzt tv-btn statt eigener Lila- bzw.
Ring-Klassen: eine Farbe bedeutet hier Zustand, nicht Dekoration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alle Felder leeren und speichern loescht nichts. Ein leer eingereichtes
Secret-Feld holt sich im Backend absichtlich den gespeicherten Wert zurueck
(damit Host und IDs editierbar sind, ohne Zugangsdaten neu zu tippen) -- beim
Zuruecksetzen ueberlebt damit das Secret. Und die leere Konfigurationszeile,
die uebrig bleibt, liest die Oberflaeche weiter als "gesetzt": das Badge blieb
gruen.
Reset-Knopf pro Integration, mit Sicherheitsabfrage, fuer alle acht (Wazuh,
Nessus, Intune, vCenter, IGEL, Netdisco, AI, SMTP; OpenRouter und GitHub-PAT
hatten ihren schon). Er schreibt einen leeren Wert -- kein gueltiges JSON, laeuft
also am Secret-Merge vorbei und loescht die Zeile fachlich. Kein eigener
DELETE-Endpoint noetig. Immer anklickbar, nicht nur bei "vollstaendig
konfiguriert": eine halb ausgefuellte Integration ist genau der Zustand, den
man aufraeumen will.
Sync Health kannte NOT CONFIGURED bisher nur fuer Quellen, die noch nie
gelaufen waren -- nach einem Reset lagen die alten sync_runs noch da, und die
Quelle waere auf STALE gefallen und rot geblieben, ohne dass je wieder etwas
laufen kann. Der Zustand folgt jetzt der Konfiguration, nicht der Historie; das
Ledger selbst bleibt unangetastet.
Importierte Assets und Findings bleiben. Eine Quelle nimmt nur ihre eigenen
Findings zurueck, und zwar im Lauf -- ein entfernter Konnektor laeuft nicht mehr
und kann nichts retracten. Alles wegzuraeumen wuerde Findings und Assets
treffen, die andere Quellen mittragen. Ein Purge pro Quelle ist ein eigener
Vorgang, nicht derselbe Knopf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jede Sync-Match-Kette (Netdisco, Nessus, vCenter, IGEL) endete bedingungslos
auf `Asset.ip_address == ip`. Ein abgeschalteter Wazuh-Agent und ein UniFi AP
teilten sich eine Adresse: der Netdisco-Sync fand darueber das Wazuh-Asset,
pinnte seine netdisco_device_ip darauf und beide Geraete hingen an derselben
Zeile. Ab da benannte Netdisco sie um, Wazuh setzte sie per Keepalive-Urteil
auf INACTIVE, Netdisco wieder auf ACTIVE — Deaktivierung und Reaktivierung
derselben asset_id bei jedem Lauf, dauerhaft.
Eine Adresse ist kein Identifikator. Gastnetze, VPN-Pools, VLAN-Segmente und
recycelte DHCP-Leases vergeben dieselbe IP mehrfach; ein Bestand, in dem das
nicht vorkommen darf, ist eine Annahme ueber das Netz des Kunden, keine ueber
unsere Daten. services/asset_matching.match_by_ip laesst die Adresse nur noch
matchen, wenn sie eindeutig ist und kein Name widerspricht. Der Fall, fuer den
die Stufe existiert — ein ESXi, den vCenter nur unter seiner Adresse fuehrt und
den Nessus danach scannt — traegt auf einer Seite gar keinen Namen und
konsolidiert weiter.
Dieselbe Annahme steckte im manuellen Anlegen: create_asset wies eine bereits
vergebene IP mit 400 ab, der Operator konnte die betroffenen Systeme also nicht
einmal von Hand fuehren. Der Check faellt weg.
Der Fix verhindert neue Merges, loest aber keine bestehende Zeile auf — dort
haelt jede Quelle inzwischen ihren eigenen Pin. tools/find_merged_assets.py
findet die Kandidaten am Status-Flip-Flop im Audit-Log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein Dashboard-Aufruf feuert acht Requests. Im Log kamen alle in derselben
Millisekunde zurueck, /auth/me mit 1116ms — ein Request, der eine Zeile liest.
Nichts davon war fuer sich langsam: einzeln gemessen kosten die acht zusammen
355ms, die Seite brauchte trotzdem so lange wie ihre Summe statt so lange wie
ihr teuerster Request.
Die Handler waren `async def`, obwohl jede Zeile darin eine blockierende
SQLAlchemy-Session benutzt. FastAPI fuehrt `async def` auf dem Event-Loop
selbst aus, also hielt jeder Handler den einzigen Loop fuer die Dauer seines
kompletten Query-Batches fest — und `get_current_user` haengt vor jedem
authentifizierten Request, deshalb war auch der Ein-Zeilen-Request betroffen.
111 solcher Handler, keiner davon mit einem einzigen `await`, sind jetzt
plain `def` und laufen im Worker-Threadpool, wo Blockieren vorgesehen ist.
Damit sie dort auch eine Verbindung finden, deckt der Connection-Pool jetzt
die 40 Threads ab, die FastAPI vergibt, statt 30: die 31. gleichzeitige
Anfrage haette nicht auf eine langsame Query gewartet, sondern auf eine
Connection, und das ist ein Timeout, keine Langsamkeit.
Zweiter Posten auf derselben Seite: kev-recent baute den zusammengefuehrten
KEV-Katalog bei jedem Request neu — 2 MB JSON aus `settings` parsen und
mergen, zweimal pro Seitenaufruf, fuer einen Katalog der sich einmal am Tag
aendert. Jetzt memoisiert, und zwar auf den `_updated_at`-Werten der
Quell-Caches statt auf einer Uhr, damit "jetzt aktualisieren, Seite neu laden"
weiter den neuen Katalog zeigt. Das Datumsparsen laeuft ueber fromisoformat
statt strptime; mit 9100 Aufrufen pro Request war es der Hotspot im Merge.
Gemessen mit acht gleichzeitigen Requests gegen 36.000 Findings auf 300
Assets: Seitenaufruf 420ms -> 190ms, Event-Loop blockiert 250ms -> 50ms,
/auth/me unter Last 304ms -> 91ms. Assets-/Scans-Seite 146ms -> 108ms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Seite war zentriert (max-w-5xl mx-auto), Settings ist linksbuendig
mit Seitenleiste — deshalb sah sie weiter fremd aus, obwohl die Karten
darin schon passten. Jetzt exakt dieselbe Schale: max-w-7xl, linke
Leiste mit den Sektionen, Inhalt in max-w-4xl daneben.
Und wie in den Settings ist immer nur eine Sektion sichtbar statt aller
drei Bloecke untereinander: Overview (Global + Hinweis), Providers,
Role mapping. Der Mapping-Editor ist damit ein Klick entfernt statt
zwei Bildschirme Scrollen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
Drei Sachen, die alle dasselbe Problem hatten: jede Seite loeste sie
anders.
Loader: der Ring mit dem Logo aus dem Dashboard ist jetzt <PageLoader>
und ersetzt "Loading Assets...", "Loading Groups...", "Loading
Advisories...", "Loading compliance data…" und die Suspense-Fallbacks.
motion-reduce bleibt drin — ein Spinner ist Dekoration, nicht Information,
das Label traegt die Bedeutung allein.
Dialoge: alert() / confirm() / prompt() des Browsers sind durch einen
eigenen Dialog im App-Design ersetzt, 74 Aufrufe in zehn Dateien.
Bewusst eine imperative Modul-API (dialog.confirm(...)) und kein Hook:
die Aufrufe stecken verstreut in gewoehnlichen Event-Handlern, und einen
Context durch jeden davon zu faedeln waere ein deutlich groesserer
Eingriff als das Problem wert ist. Ohne gemountetes <DialogHost/> faellt
die API auf das native Fenster zurueck — ein confirm, das nie fragt,
wuerde sonst klaglos loeschen.
Destruktive Aktionen bekommen dabei den roten Button und ein Verb statt
"Confirm" ("Delete asset", "Reset MFA"), damit die gefaehrliche Wahl
nicht die ist, die man im Reflex trifft.
Emoji: die sieben Integrationskarten in den Settings und die
Data-Jobs-Buttons unter Vulnerabilities trugen Emoji als Icons. Raus.
Der <PageLoader> uebernimmt den Ring-um-die-Marke, der im Working Tree
schon fertig lag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
Vier verschieden hohe Karten in einem 2x2-Raster, jede mit einem
vollbreiten blauen Button — die Seite sah aus wie ein anderes Produkt als
der Rest der App. Jetzt dieselbe Card + divide-y-Liste wie die
Integrations-Uebersicht in den Settings, ein neutraler Test-Button pro
Zeile, Global als Definitionsliste.
Statt zwei Badges pro Provider nur noch einer. "Disabled + Incomplete" in
Orange las sich wie ein Fehler auf jedem Provider, den nie jemand
eingerichtet hat; die einzige echte Fehlkonfiguration ist ein Provider,
der eingeschaltet aber nicht konfiguriert ist — nur der bekommt jetzt
Warn-Farbe. Ein fehlender Fernet-Key ist danger, weil dann keine Secrets
gespeichert werden koennen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
Die Vulnerabilities-Seite trug auf neun Job-Buttons, sieben Filtern und
einem Dutzend Row-Badges je einen eigenen Farbton (blau/amber/lila/rot/
orange/emerald/sky/cyan/gelb). Wenn alles schreit, hoert man nichts: das
Rot von KEV konkurrierte mit dem Lila von "EOL Check".
Statt Hexcodes zu tauschen erst eine Token-Schicht in OKLCH (Neutral /
Brand / Semantisch, Werte fuer light + mid + dark), dann die Komponenten
darauf umgestellt. Farbwechsel ist ab jetzt eine Token-Aenderung.
Die Regel dahinter, dokumentiert in DESIGN.md: ein Farbton bedeutet
Zustand, sonst nichts.
- Job-Buttons neutral, genau ein Brand-Button (Refresh Threat Intel).
- Filter sind neutrale Pills und faerben sich nur, wenn sie greifen —
ueber :has(input:checked), ohne JS. Informativer als vorher, weil man
jetzt sieht, welche Filter aktiv sind.
- Badges nur noch danger/warn/ok/info. Was bloss eine Identitaet nennt
(Scanner-Name, Paketname, PoC-Zaehler), bleibt neutral. MSF und SSVC
"widespread" behalten die einzige Vollton-Flaeche, sie sind die
hoechste Stufe. Jedes Tag traegt weiterhin seinen Text, damit der
Zustand nicht allein an der Farbe haengt.
- Die acht Data-Jobs sind keine Filter und standen mitten in der
Filterzeile — jetzt in einem zugeklappten <details>.
Die .tv-*-Klassen sind ausschliesslich aus Tokens gebaut und laufen
dadurch in allen drei Themes ohne Eintrag in der Utility-Remap-Schicht.
Das ist der Weg, die Schicht langfristig loszuwerden.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRCKYPm4DQ2vwDkNugGvHK
Der AppShell schiebt den Inhalt mit lg:pl-72 schon an der dunklen
Seitenleiste vorbei. Das max-w-7xl mx-auto der Seite zentrierte ihn
danach ein zweites Mal -- innerhalb dessen, was uebrig war. Auf einem
breiten Monitor steht der Block damit weit rechts, mit einer leeren
Bahn zwischen Menue und Settings. Ohne mx-auto beginnt er dort, wo die
Seitenleiste aufhoert.
Dazu die zugeklappten Karten enger: p-6 und mt-8 waren fuer eine offene
Karte gedacht, zugeklappt steckt da nur eine Titelzeile drin. p-5, mt-3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Logout-Knopf sitzt unten in der dunklen Seitenleiste, per mt-auto ans
Ende geschoben. Nur fehlte dem Desktop-Container das overflow-y-auto, das
die mobile Variante hat: passen Navigation, Theme-Schalter, Benutzerkarte
und Knopf nicht mehr auf die Bildschirmhoehe, laeuft der Knopf unten
hinaus und ist nicht mehr scrollbar. Auf einem 13-Zoll-Display reicht das
Menue dafuer aus.
Dazu ein zweiter Logout dort, wo man ihn zuerst sucht: auf der eigenen
Profilkarte in den Settings, unter E-Mail und Rolle.
Beide rufen jetzt lib/auth.ts:logout() auf, statt die Abfolge "Cookie
widerrufen, dann weiterleiten" zweimal zu fuehren -- ein Logout, der nur
an einer Stelle die Session verwirft, ist genau der Fehler, den man erst
im Log sieht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Dinge, die die Seite unruhig aussehen liessen:
Die Inhaltsspalte lief bis zum Rand von max-w-7xl. Bei einer Zeile aus
Label links und Schalter rechts heisst das: Beschreibung ueber tausend
Pixel, Schalter am anderen Ende des Bildschirms. Jetzt max-w-4xl, damit
die Bedienelemente bei ihrem Label bleiben.
Die 17 Listenzeilen standen auf py-5 und waren damit fast so hoch wie
breit. py-4 reicht.
Und die elf Modals: Tailwind v4 hat bg-opacity-* ersatzlos gestrichen,
also stand hinter jedem Fenster kein durchscheinender Schleier, sondern
eine deckende graue Wand. Jetzt bg-gray-900/60 mit backdrop-blur-sm; die
Panels bekommen die gleichen eckigen Kanten wie die Karten der Seite.
Dieselbe tote bg-opacity-Klasse steckt noch an sechs Stellen in
page.tsx, scans, groups und assets -- nicht Teil dieser Aenderung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auch mit der Sektions-Navigation blieb Integrations die laengste Seite:
sieben Konfigurationskarten mit allen Feldern gleichzeitig offen. Jetzt
sind es native <details>-Karten, zu und mit Titel, Status-Badge und
Chevron -- man sieht auf einen Blick, was konfiguriert ist, und klappt
nur die auf, die man anfassen will.
Kein State, kein Effekt, kein JavaScript: <details>/<summary> bringt
Tastaturbedienung und Screenreader-Semantik mit. Die Trennlinie unter
dem Titel erscheint nur im offenen Zustand, deshalb braucht ihre
Variant-Klasse in globals.css den gleichen Border-Remap wie
border-gray-100, sonst leuchtet sie im Mid-/Dark-Theme hell auf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Settings-Seite stapelte alle Karten in einer Spalte: General, User
Management, Integrations Status, MFA, Notifications, dazu sieben
Integrations-Karten und das Profil. Wer die Syslog-Konfiguration wollte,
scrollte an allem anderen vorbei.
Jetzt links eine klebende Sektions-Navigation, rechts genau eine Sektion:
General, Users, Security, Notifications, Integrations, Profile. Die
Rollenpruefung bleibt unveraendert -- die Nav blendet die Admin-Sektionen
nur zusaetzlich aus und faellt auf die erste sichtbare zurueck, solange
/auth/me noch laeuft. Der aktive Tab haengt im URL-Hash, damit ein Reload
oder ein Lesezeichen dort landet, wo man war.
Auf schmalen Viewports wird die Navigation zu einer horizontal
scrollenden Leiste ueber dem Inhalt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemeldet als "das Dashboard laedt seit den Sync-Status-Sachen langsamer".
Nachmessen ging bisher nicht, und das war der erste Fehler.
Die Antwortzeile im Log lautete "⬅️ Response: 200" — ohne Pfad, ohne Dauer. Das
Dashboard feuert dreizehn Requests gleichzeitig ab, deren Antworten sich also
beliebig verschraenken; welcher davon gebremst hat, war aus dem Log schlicht
nicht ablesbar. Jetzt stehen Methode, Pfad und Dauer in der Zeile, und alles ab
einer Sekunde zusaetzlich als "🐢 SLOW". Das ist das Messinstrument, das der
naechsten Meldung eine Zahl statt einer Vermutung gibt.
Dazu die eine Ursache, die ohne Messung schon feststand: die Seite hing hinter
Promise.all ueber allen dreizehn Requests und zeigte bis zum LANGSAMSTEN nur
"Loading Dashboard...". Die Kennzahlen brauchen davon genau einen. Der hebt
jetzt die Sperre, die Widgets fuellen sich nach.
Wichtig dabei, und der Grund fuer das zusaetzliche widgetsLoading: ein Widget
ohne Daten zeigte bisher "No data.". Wer die Seite frueher sieht, saehe damit
"nichts gefunden", wo "noch nicht da" gemeint ist — die eine Verwechslung, die
dieses Dashboard sich nicht leisten darf. Solange die Charge laeuft, steht dort
ein Spinner. Der Ladebildschirm selbst ist ebenfalls einer, statt einer nackten
Textzeile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Gate-Inventur listet, was ein Readonly-Nutzer erreichen darf, und der neue
Endpoint fehlte darin. Er gehoert hinein: ein Readonly-Nutzer filtert nach Host,
also muss der Picker fuer ihn genauso offen sein wie die Liste, die er ersetzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Befunde aus demselben Screenshot, beide auf GET /api/v1/assets.
Der Host-Filter auf der Vulnerabilities-Seite holte seine Auswahl aus dem
Listen-Endpoint mit limit=1000 — dessen hartem Maximum. Bei 1194 Assets brach
die Liste damit mitten im Alphabet ab und die restlichen 194 Hosts liessen sich
gar nicht auswaehlen. Ein stilles Limit auf einer Auswahlliste ist die
schlechteste Stelle dafuer: es sieht aus wie "gibt es nicht".
Der Picker braucht zwei Spalten, nicht ganze Assets. Neu ist deshalb
GET /api/v1/assets/names — id und hostname, eine Query, kein Cap, sortiert wie
die Tabelle (case-insensitiv, damit "alpine" nicht hinter "Webserver" faellt).
Vor /{asset_id} deklariert, sonst liest FastAPI "names" als Asset-ID.
Dabei aufgefallen, und der eigentliche Grund fuer die Ladezeit: die Liste hat
pro Zeile ein eigenes SELECT count(*) auf vulnerabilities abgesetzt. Bei
limit=1000 sind das tausend Roundtrips in einem Request — im Backend-Log
dominierte genau dieser Aufruf den Seitenaufbau. Jetzt ein gruppiertes COUNT:
drei Statements fuer 100 Assets, dieselbe Zahl wie fuer 10.
Und die Scan-Jobs-Seite holte dieselben 1000 Assets bei jedem Laden, ohne sie
je zu lesen — der State wurde geschrieben und nirgends verwendet. Ersatzlos
raus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemeldet als False Positive auf jedem IOS-XE-Router, und das war es auch.
NVDs komplette Konfiguration fuer CVE-2019-12660 ist EIN Eintrag: cisco:ios_xe
mit versionStartIncluding 16.1.1 und keiner Obergrenze irgendeiner Art.
Woertlich gelesen: jedes IOS-XE-Release ab 16.1.1, fuer immer. Cisco hat den
Fehler 2019 behoben und schreibt im Advisory ausdruecklich "at the time of
publication"; den 17.15-Zweig gab es erst 2024. Am Finding stand entsprechend
"fixed in: n/a" — und das ist der eigentliche Befund: ein Eintrag ohne
Obergrenze nennt keinen Fix, also kann das Finding sich nie schliessen. Ein
Finding ohne Weg heraus ist kein Verdikt.
Gefixt an der Wurzel statt fuer diese eine CVE: _in_range verwirft einen
cpeMatch mit Unter- und ohne Obergrenze. Das ist exakt die Regel, die
_ranges_from_affected ("only ranges with a real upper bound") und
vmware_entries ("affected, fix unknown" ist kein Host-Verdikt) laengst
anwenden — die NVD-Haelfte war der Ausreisser.
Pro Eintrag, nicht pro CVE: ein Record mit zusaetzlicher beidseitig begrenzter
Range meldet weiter ueber die. Blast Radius nachgemessen auf dem gemeldeten
Geraet (C8000V, 17.15.4c): von neun CVEs an cisco:ios_xe haengt genau diese
eine an einem unbegrenzten Eintrag, die anderen acht an exakten Versionen oder
einer echten Range. Offene Findings muessen nicht von Hand als FP markiert
werden — die Cisco-Reconcile schliesst sie beim naechsten Sync selbst.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more firmware lines on the Netdisco path, both matched from the two
authoritative sources alone — no vendor bulletin scraping.
Ubiquiti is the first line where the SNMP os slug decides nothing: the same
fleet answers it as "U6-LR", "U7-Outdoor" and plain "UniFi". The MODEL is what
decides, and it decides twice. Once whether the row is an access point at all —
AP, switch and gateway firmware share one numbering scheme, and CVE-2023-38034
fixes the APs in 6.5.53 and the switches in 6.5.32, so a USW judged against AP
bounds is a finding on the wrong host. And once which records apply, because
half of Ubiquiti's records name a single model: CVE-2024-37380 is the U6+ alone,
fixed in 6.6.74, and every U6-LR on a 6.5.x sits below that bound without ever
having been affected. The model therefore rides in the asset's OS string and
scan_asset_ubiquiti holds a model record to the model it names.
Brocade Fabric OS is covered from NVD alone, and deliberately: Brocade's own
records state the affected versions as an English sentence ("before 9.2.1c3,
and 9.2.2 though 9.2.2b"), which no range collector can read, while NVD's
analysts turn the same prose into proper cpeMatch ranges. Its rebuild letters
are the release and it states real RANGES over them, so the letters are now
ordered rather than dropped — read letter-blind, a switch patched to 9.2.1d sat
below "before 9.2.1c3" and collected the CVE. Brocade's IP switches (ICX,
FastIron) are the same brand with the same version shape and different
firmware; the os slug is what keeps them out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The inventory filter had no NETDISCO option, so neither the list nor the CSV
export (which honours the same filter) could be narrowed to the switch estate.
The filter keys on the netdisco_device_ip pin rather than the creation-time
source enum — same rule as vCenter and IGEL — otherwise every switch Wazuh or
Nessus registered first drops out. The MANUAL branch now excludes them too:
without a scanner linkage of its own, a Netdisco switch was answering as
"genuinely manual".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bJSiCMEPYQYdi6emTDRj2
The IP fallback for a nameless device went through `.split(".")[0]` — the
short form of a hostname, which on an address leaves the first octet. That
name was also a match candidate, so the second nameless device in the same /8
found the first one's asset by hostname and was written onto it: one row with
a LANCOM router's vendor and a netgear switch's firmware.
A short form is now only derived from a real device name; a nameless device is
its full canonical address. Assets an earlier sync named after the first octet
of their own pinned IP are renamed on the next sync, so the existing rows heal
without an operator touching them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bJSiCMEPYQYdi6emTDRj2
Beide Fassungen. Der Punkt, der oben steht und nicht im Nebensatz: ob Netdisco
die EXOS-Zeile so ausliest, wie die Zuordnung es annimmt (vendor "extreme", os
"xos", Version aus dem sysDescr), ist von hier aus nicht pruefbar - das braucht
ein echtes Geraet. Der Testertext sagt das ausdruecklich, nennt die Herkunft
der Annahme (SNMP::Info Layer3::Extreme) und macht daraus einen konkreten
Handgriff: Asset-OS muss "ExtremeXOS" heissen und die Version blank sein, sonst
die deviceinventory-Zeile schicken. Sonst sieht "keine Findings" aus wie
"sauber".
Dazu die zwei Dinge, die beim Testen sonst als Fehler gelesen wuerden: dass
Extreme im Gegensatz zu Cisco eine Fix-Version angibt (verglichen statt exakt
nachgeschlagen), und dass die alten EXOS-CVEs sofort kommen, die 2026er aber
erst nach dem Indexbau - NVD hat fuer die noch keine CPE-Eintraege.
VOSS/ERS steht als eigener Pruefpunkt drin, nicht nur unter "bewusst nicht
drin": dort kann nur etwas FALSCHES auftauchen, und genau das soll jemand mit
echten Geraeten gegenpruefen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third firmware line on the Netdisco path, and the one the connector's
stated scope still listed as "needs its own curated matching". An EXOS switch
(an X440G2 on 31.7.2.4, say) was inventoried as an asset and given no CVE
verdict at all.
Extreme is the plain case of the three, and the pass exists mainly for the
reconcile - a switch carries no software inventory, so the app scan's own
reconcile never reaches it. Versions are dotted numbers and the CNA writes
proper ranges, every one of them FLOORED. CVE-2026-8169 states four on one
record: 0 -> <31.7.4, 32.0.0 -> <32.7.4.15, 33.0.0 -> <33.1.100, 33.2.0 ->
<33.7.1, with defaultStatus "unaffected" - so unlike Cisco's records a release
outside all four is one Extreme judged and cleared. _ranges_from_affected reads
that as it stands, which is why there is no Extreme branch in the index build.
The one deliberate difference from the Aruba pass is that Aruba's branch guard
is NOT applied. HPE leaves the floor implicit and writes one range per branch,
so an unfloored "below 10.17.1021" there is a record about 10.17 and nothing
else. Extreme writes the floor itself, including the literal version "0", which
is the vendor saying every release below 31.7.4 whatever train it is on -
guarding that entry by branch would drop exactly the switches it is about.
Both sources, and here the NVD half is the load-bearing one: Extreme's CNA only
started filing in 2025, so every earlier EXOS CVE is a MITRE record whose
affected[] is {vendor: "n/a", product: "n/a", version: "n/a"} and carries no
structured data at all (verified 08.09.2026 on CVE-2023-43121 and
CVE-2024-27453). NVD also files the same firmware under TWO product names and
splits the CVEs between them, so both are queried - verified live the same day:
extremexos answers CVE-2024-27453, CVE-2020-18305 and the 2017 batch; exos
answers CVE-2023-43118..43121 (the Chalet traversal and privilege escalation)
and none of them appear under the other name. The reverse direction is the 2026
batch: CVE-2026-8169 and -8170 were still "Awaiting Analysis" at NVD with zero
CPE entries while Extreme's own records already stated all four ranges.
The product anchor matters for the same reason it did for Cisco: Extreme files
Fabric Engine (VOSS), ExtremeControl, ExtremeCloud IQ - Site Engine, Extreme
Platform ONE, ExtremeGuest Essentials and ExtremeCloud Universal ZTNA under the
one vendor string, and every one of them numbers releases in a scheme that
looks like a switch's ("25.5.12", "9.2", "26.02.11").
Two normalisations, both at the connector: SNMP::Info reads the version out of
sysDescr, so a row can carry the whole sentence, and a patch build spells
itself "22.7.1.1-patch1-11". The tag is dropped because neither source ever
states one as a bound - and Extreme fixes these by moving to the bound's
release, not by patching below it. The rule is aruba_version's, called rather
than copied, because it is genuinely the same rule.
Fabric Engine (VOSS) is deliberately out, and that is an INVENTORY limit rather
than a data one - both sources carry CVE-2025-11192. SNMP::Info has no VOSS
class, so a VSP is classed as Layer3::Passport and answers os "passport",
vendor "avaya" - precisely what an Avaya/Nortel ERS switch answers, whose 5.9.x
firmware is not Fabric Engine. Nothing else on the row separates them, and
"before 9.3" handed to an ERS numbered 5.x matches every one of them. Likewise
ExtremeWare (slug "extremeware"): a different product line whose NVD bounds
stop at 11.1. Both get the asset and no verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Slack-Datei war eine unpersoenliche Ankuendigung; sie geht aber an den
Kollegen, der die Cisco-Infos geschickt hat. Jetzt als Antwort formuliert:
sein Geraet und seine CVE-Listen namentlich, die Bestaetigung dass seine
Vorgabe (nur NVD und CVEListv5, Cisco-Advisories aussen vor) genau so
umgesetzt ist — samt dem Beleg, dass sie richtig war: seine IOS-XR-Charge
existiert bei NVD noch gar nicht.
Dazu, was ihn beim Testen sonst irritieren wuerde: keine Fix-Version am
Finding und warum, der Indexbau vor dem ersten Sync (XE sofort, XR erst
danach), und der "os x"-Nebenbefund als eigener Pruefpunkt.
Die ausfuehrliche TESTERTEXT.md bleibt unpersoenlich — die ist fuer alle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beide Fassungen auf den Cisco-Stand gezogen: warum bei IOS XE/XR exakt
nachgeschlagen statt verglichen wird (beide Quellen zaehlen die Releases
einzeln auf, 267 bzw. 268 fuer CVE-2026-20267), was daraus folgt (keine
Fix-Version am Finding, ein nicht gelistetes Release wird nicht gemeldet),
warum klassisches IOS draussen bleibt, der "os x"-Nebenbefund, und der
Hinweis auf den Indexbau — v33 heisst, dass der erste Sync nur die
NVD-Haelfte macht, was bei IOS XR sichtbar ist und bei IOS XE nicht.
Testpunkte auf 13 erweitert: der Router aus dem Feldbericht mit seiner
erwarteten CVE-Liste, ein Gegentest auf einem anderen Rebuild derselben
Zeile (die Listen muessen sich unterscheiden), keine macOS-CVEs auf
Netzwerkgeraeten, und die dritte Offline-Suite.
Keine neue Migration — 052 bleibt head; steht jetzt oben, damit niemand
danach sucht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Netdisco connector shipped with "Cisco, Cumulus and the rest need their
own curated matching" as its stated scope. This is Cisco's half: an IOS XE
router (a C1121X on 17.15.4c, say) was inventoried as an asset and given no
CVE verdict at all.
Cisco is the first product here whose match is EXACT rather than a range, and
that is not caution layered on the data, it is the data. Cisco publishes no
ranges: NVD enumerates 267 separate cpeMatch entries for CVE-2026-20267, each
an exact cpe:2.3:o:cisco:ios_xe:<release> with no versionStart/End anywhere,
and Cisco's own CNA record enumerates the same 268 releases as bare `version`
entries. So "affected" is set membership.
Comparing would be strictly worse, because the numbers are not the release.
Cisco ships 17.15.4, .4a, .4b, .4c and .4d as five separate releases and a
record names some and not others, but _vtuple reads all five as (17,15,4).
Two changes follow, and they are deliberately not the same change:
* _in_range's exact-version branch now also requires the release suffix to
be equal. That is a TIGHTENING, applies to every product, and can only
ever reject - numeric equality alone put all three of the 17.15.1w/x/y
CVEs on a router running plain 17.15.1.
* Reading "17.15.4c" as a version at all is NOT shared. _clean_version stays
dotted-numeric-only and the two Cisco registry entries carry
"lettered": True instead. That rule has thirteen call sites across six
services and every one of them compares RANGES, where _vtuple drops the
letters - letting 1.1.1w through there would hand a letter-blind
comparison a version whose letter is the release (OpenSSL: 1.1.1a..1.1.1w
all read as (1,1,1)). Only the exact path can honour a suffix, so only it
gets one.
Both sources, as everywhere here, and neither is redundant - verified
08.09.2026: the IOS XE batch (CVE-2026-20267..20273) is fully enriched at NVD,
while the IOS XR batch a month later (CVE-2026-20274..20280) is still
"Awaiting Analysis" with zero CPE entries and exists structurally only in
Cisco's own records. The reverse also happens: CVE-2019-12660 states its
affected release in the PRODUCT NAME ("Cisco IOS XE Software 3.2.11aSG",
version "unspecified"), so the CNA half cannot read it and only NVD carries it.
The product anchors matter more for Cisco than for anyone else so far: Cisco
files every product it makes under one vendor and states them as separate
affected[] blocks in the SAME record. CVE-2025-20363 carries five - IOS (2005
versions), IOS XR (13), IOS XE (456), ASA (220) and Firepower (94). Matched
loosely, a router collects an ASA's release list.
No fix version is reported, because neither source states one; a Cisco record
lists what is broken and is silent on what is not. The finding still has its
path to close - a device upgraded to a release the record does not enumerate
stops matching and the reconcile closes it. Likewise, defaultStatus is
"unknown", so an unlisted release is one Cisco did not judge and is read as
"no verdict": a brand-new rebuild is under-reported rather than flagged
forever, which is the direction NVD's own CPE data chooses too.
Classic IOS is deliberately out. It numbers releases "15.2(7)E3", which NVD
backslash-escapes into the CPE and no version scheme here reads; those devices
keep the asset and no verdict.
Also fixes a substring trap the same shape as the "ios" one the Aruba commit
fixed, one letter further along: _os_family tested `"os x" in n`, and "os x"
lives inside "cisco iOS XE" / "cisco iOS XR". Every Cisco router would have
been read as a Mac - compared against macOS ranges by scan_asset_os_apple
(17.15.4c sits below every macOS 26 bound) and stripped of its own NVD
findings by _platform_ok for naming the wrong platform.
Deliberately not included: Cisco's own Security Advisories. They carry the
same CVEs the records already state structurally; the one thing they add is
the fixed release, and scraping sec.cloudapps.cisco.com for it is a third
source with its own failure modes - add it when a missing fix version is what
is actually blocking someone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
entrypoint.sh migriert bei jedem Containerstart. Ein manuelles
"alembic upgrade head" unmittelbar danach laeuft gegen denselben Schritt:
beide lesen 050, einer schreibt 051, der andere findet beim UPDATE auf
alembic_version keine Zeile mehr und bricht mit "expected to match one row"
ab — obwohl die Migration angewandt ist. Anleitung prueft jetzt mit
"alembic current" statt nochmal zu migrieren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beide Fassungen auf das aktuelle Update gezogen: was die Karte kann, welche
drei Aruba-Familien erkannt werden und warum sie getrennt bleiben muessen,
was bewusst fehlt (Bulletins, EoM, Nicht-Aruba), der Apple-"ios"-Nebenbefund,
und neun Testpunkte samt der beiden Offline-Suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A switch runs no agent, no MDM enrols it, and a Nessus scan without SNMP
credentials sees an open port and no version — so the devices everything
else is plugged into had no coverage at all. Netdisco already asks them
over SNMP; this reads its deviceinventory report and turns it into assets
and findings.
Aruba detection is family-split on purpose: AOS-CX (10.13.1005), the
ProVision line (16.11.0016) and the Mobility controllers (8.13.1.1) are
three products HPE files under one vendor with overlapping numbers, and a
CX switch matched against a controller's range would be told to install
an image its hardware cannot take. Each family gets its own curated key,
its own anchored product regex and its own NVD CPE, and an index entry
without a floor must additionally share the installed release BRANCH —
HPE writes one range per branch on the same record (CVE-2026-73749
carries 10.18, 10.17, 10.16, 10.13 and 10.10 side by side).
Both CVE sources, as everywhere here: NVD carries usable cpeMatch ranges
for the three 2023 ArubaOS-Switch flaws, while the current AOS-CX batch
(CVE-2026-73749, -44880) sits there unenriched and exists only as HPE's
own CNA records. The switch records state their bounds as prose
("KB/WC/YA/YB/YC.16.11.0015 and below"), which the shared extractor reads
as the number salad (16,11,16,11,12) it looks like — so those are parsed
separately, per branch, and "All versions" is skipped: a finding with no
fix to reach is noise, not a verdict.
The two-letter code line ("WC.", "PL.") names the hardware family, not
the version. It is dropped for comparison and kept in the description,
where it is what an operator matches against HPE's download page.
Also anchors the Apple iOS patterns. They matched "ios" as a substring,
and Netdisco names a Cisco switch's OS exactly that — every Cisco device
in an estate would have been compared against Apple's iPhone ranges,
where IOS 15.2 sits below every bound ever written.
Deliberately not included: HPE's own bulletins (the same data the CNA
records already state structurally) and EOL/EOM (HPE publishes those per
hardware product, not per firmware line, and endoflife.date carries no
ArubaOS at all — an invented date would retire real assets on a guess).
Non-Aruba devices get an asset and no CVE verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M365 Apps fixes are not published to NVD and Wazuh's detector never sees
them: the single public record of "CVE-X is fixed in build Y" is the
Microsoft Learn security-updates page this check parses. The finding cited
NVD, CVE.org, Exploit-DB and the EUVD — catalogues that either know nothing
about the M365 fix or, for a fresh CVE, hold no record at all — but not the
page the detection actually came from. The operator could read the claim and
had no way to check it.
Every M365 finding now carries that page under External References, deep-
linked to the monthly section that lists its CVE.
- The anchor is read off the page, never guessed. _parse_security_page
collects the real h2/h3 ids and keeps the one that matches the date
heading, trying both the padded and the unpadded day (Microsoft writes
"December 09, 2025" and "June 9, 2026" and slugifies each verbatim). 94 of
95 sections currently resolve; a section with no id of its own falls back
to the plain page URL instead of a fragment that lands nowhere.
- detect_missing_cves reports cve_anchors alongside missing_cves, so the CVE
points at the section that first published its fix, not at the newest one
that happens to re-list it.
- Both entry points pass it (nightly run_m365_check and the Intune
detectedApps path), and the upsert re-attaches on every run, so findings
raised before this pick the link up on the next pass.
references is a shared column, so the cross-source contract applies to it as
much as to sources: Vulnerability.add_reference() appends and dedupes, the
M365 check replaces only its own earlier link, and the two writers that used
to fill the column only while it was empty (Nessus see_also, the app-CVE
scanner's vendor bulletin) now merge instead — otherwise an M365 reference
on the same row would have cost the host its vendor advisory.
Migration 051 fills in the rows the check will not re-raise (already closed,
or on a host that no longer reports an M365 install) with the plain page URL,
touching only rows m365_check raised and only where nothing is set.
In the UI the link renders as "MS365 Apps Security Updates" rather than a
bare learn.microsoft.com hostname.
Only the indexer password was changed. The manager API (55000) still
answered, so the agent list came back and no phase raised; the indexer
(9200) refused every query with 401. query_vulnerabilities_from_indexer()
logged that and returned [] — so each of the 31 agents looked like a host
with no CVEs: "deferring backfill", agents_synced += 1, and at the end
"every agent returned 0 CVEs — treating as an outage" as a WARNING in the
container log. Ledger: "sync run #33 wazuh (manual): completed". Green Sync
Health, green "Synchronization Complete", no mail — nothing had been synced.
The earlier fix (51a69a6) only caught the manager-API half: it fails a run
when a phase cannot log in, and it was wired into /scans/autoscan and the
scheduler. run_wazuh_vulnerability_sync — the "Sync Data (Wazuh)" button —
never called it at all, and an indexer error never reached a caller.
Three parts, all needed:
- The client raises. An agent whose CVE list could not be read is not an
agent with no CVEs, so the indexer query raises WazuhAPIError instead of
returning []. Every caller already catches per agent — including
verify_patch_with_rescan, which used to mark a CVE PATCHED because a 401
gave it an empty list.
- A refused agent is neither synced nor empty: it becomes a FAILED per-asset
scan row and an entry in errors, and never reaches empty_agents. Otherwise
one agent answering would let reconcile_empty_agents() close every Wazuh
finding on the refused host — a mass patch on the strength of a 401.
- The manual sync fails the run (fail_if_source_broken) and answers 502, so
the dialog goes red instead of "Synchronization Complete", the ledger row
is failed, Sync Health red, and the alert mail goes out under the usual
24h cooldown.
reconcile_empty_agents() now also writes run_stats["outage"] when nobody
returned anything; button, autoscan and scheduler all turn that into a
failed run. That is the backstop for an indexer that answers 200 with
nothing — wrong index, cluster still coming up — where nothing raises.
Regression: three tests at the real seam — every agent refused (failed,
agents_synced=0, 502, one mail), one agent refused while another answers
(the refused host keeps its findings), and an indexer answering nothing for
everybody (failed via outage).
Field report 06.09.2026: two hosts in Intune AND Defender TVM, no Wazuh agent.
Defender saw the Microsoft Edge update and closed a dozen Edge CVEs at 02:10
("device remediated"). At 03:20 the app-CVE scan pulled those hosts' inventory
— Intune detectedApps, which had not caught up and still listed Edge
132.0.6199.32 — and the MSRC fixed-build pass it drives reopened every one of
them. Edge was really at 132.0.6199.62. Next night the same, until Intune
caught up: a change history describing our polling, not the host.
The brake existed. reopen_if_patched already refuses a reopen whose data is
older than the close — but the grace was keyed on the SCANNER, and 'app_scan'
and 'msrc' sat there as "reads a live inventory". That holds on a Wazuh host.
On an Intune host both read the one list already known to lag: they fetch
nothing themselves, they decide from whatever run_app_cve_scan handed them, and
by the time the decision is made the provenance was gone.
Key the grace on the INVENTORY instead. A scan declares whose list it is
deciding from (audit_events.scanning_inventory / use_inventory), and the two
relaying scanners inherit that lag; every other source keeps its own. The
Wazuh-login-refused fall-through, where Intune answered for a Wazuh host, is
covered by the same declaration — it used to count as live.
The numbers were also backwards: Intune stood at 1 day and Defender at 3.
detectedApps depends on the tenant's refresh policy and is routinely a week
behind, while the Defender software list follows device telemetry (the reason
defender_service feeds it to the app scan in the first place). So Intune goes
to 7 days, m365_check with it — same Graph list — and the Defender software
list declares no lag at all; the 3 days under "defender" describe the TVM CVE
feed and stay there.
Deliberately not done: letting Defender TVM override the app scan outright.
"Defender reports nothing" is not "the host is patched" — for software Defender
does not inventory, or a device that stopped reporting, that would bury a
genuine MSRC finding for good, and for Edge MSRC is the only machine-readable
source there is. Past its lag the reopen goes through, so a host that really
regressed still resurfaces. Nothing about how inventories are fetched changed.
Test: tests/test_reopen_stale_inventory.py — the field case, the reopen after
the lag, and a control that a Wazuh host keeps its immediate say.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Field report 06.09.2026, asset 36: the Wazuh API was dead — the Asset view
could not fetch that host's software inventory at all — and an app-CVE scan
started anyway closed exactly two CVEs on it as "App CVE scan no longer
detects this CVE". Nothing had been patched; the asset was ACTIVE, and the
next scan after Wazuh came back reopened both.
The auto-resolve guard asked only whether `packages` was non-empty, never
where the list came from. The inventory pick is
if asset.wazuh_agent_id and wazuh: ... else: intune detectedApps
and that host is registered in both. With the Wazuh login refused, the scan
fell through to Intune's detectedApps — a thinner list with different product
names that does not carry everything syscollector reports. Non-empty, so the
guard passed, and every product Intune does not list read as "no longer
detected". Only that one host, because every other one is Wazuh-only and got
no list at all.
Same shape for browser extensions: they come from the indexer, and a failed
fetch returned [] — "this host has no extensions" — so the reconcile closed
extension findings against a list that cannot contain them.
Track, per asset, which source OWNS the inventory and which one ANSWERED. A
mismatch (or a failed extension fetch) closes and prunes nothing, for the
app-scan reconcile and the MSRC package reconcile alike; detection still runs,
since a product Intune reports really is installed — only retracting needs the
host's own, whole inventory. The Wazuh client now separates a missing IT
Hygiene index (404 → [], as before, for < 4.14) from an unreachable indexer,
which raises.
Test: tests/test_partial_inventory_no_autoclose.py — the field case, the
extension case, and a control that a healthy inventory still closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to 10c4b86. That fix put the MSRC index build before the scans that
read it (01:30 → 03:20 → 03:50), which was the nightly half of the problem.
The GUI half stayed: the operator ran MSRC enrichment AND an app scan by hand
and still saw only Google Chrome on CVE-2026-84324 — no Edge product, no
fixed build.
Two reasons, both invisible from the outside:
* "MSRC Enrich" (POST /msrc/refresh → msrc_service) pulls per-CVE
remediation TEXT. It does not touch msrc_product_index_v2 — the
product→(CVE, FixedBuild) index the Edge findings are created from.
* The app scan read that index with load_index(), which serves the stored
blob at ANY age, and rebuilt only when it was missing. So the pass
decided from whatever the last nightly build left behind, however old,
and pressing the button again changed nothing.
And there was no button that forced a rebuild: /msrc-scan exists as API only,
with rebuild_index defaulting to False.
Now every scan reads the index through ensure_index(SCAN_INDEX_MAX_AGE = 6h)
instead of load_index — the nightly chain still shares the single 01:30 build,
a run started at 14:00 rebuilds first. A failed rebuild keeps serving the
cache and logs a warning, so an offline MSRC costs no more than before. The
GUI gets "MSRC Scan" next to "MSRC Enrich": rebuild + OS pass as a background
job with status polling, because the CVRF pull outlives the browser's HTTP
patience the same way the app scan did. Readonly may poll the status, not
start it.
Regression: tests/test_msrc_edge_chromium_cve.py section 6 — a 30h-old index
is rebuilt by both run_app_cve_scan and run_msrc_scan, a 1h-old one is not,
and a failed rebuild leaves the run intact with the cached index.
Field report 06.09.: CVE-2026-84324 / CVE-2026-84354 — Chromium CVEs that
MSRC files under "Microsoft Edge (Chromium-based)", fixed build
152.0.4191.62. The app scan had the Chrome half since 02.09.; the host also
ran Edge 152.0.4191.51, and the morning after MSRC published (03.09. ~17:00)
the finding still showed no MSRC source and no Edge product.
Not a parse or compare defect: replayed against the live 2026-Sep CVRF, the
index carries both CVEs under 'edge' and flags .51 against the .62 fix (now
locked down by the new test). The defect is WHEN the index is built:
03:20 app-CVE scan reads msrc_product_index_v2 as stored (allow_stale)
03:50 MSRC nightly rebuilds it
The package pass that creates the Edge findings always decided from the
previous night's index, so a CVE MSRC published on day D reached the host on
D+2 — and a user checking on D+1 saw "not detected". b802f3a moved every
other index that scan decides from into the 01:30 refresh job and left this
one where it was.
Now the 01:30 job rebuilds the MSRC index too (a failed build keeps the cache
and logs a warning), the 03:50 job reuses that build via ensure_index(max_age
6h) instead of pulling the 18 documents a second time, the app scan loads the
index once per run instead of parsing the blob once per asset, and an MSRC
pass that dies on one asset is logged instead of vanishing into a stats field
the nightly never prints.
Regression: tests/test_msrc_edge_chromium_cve.py — fixture shaped like the
live document; index build, scan onto the existing Chrome finding, both
reconciles, retraction after the Edge update, job order, ensure_index.
The Wazuh manager API (55000) refused every login with 500 for a day. The
hourly scheduled scan noticed — the asset sync raised at authentication —
and filed it as "COMPLETED · 1 ERR": the per-agent vulnerability sync reads
the indexer (9200), went through for all 64 agents, and the only failure rule
was "0 agents synced". No red badge, no banner, no mail, until a manual sync
six hours later hit the same 500 and mailed within a second.
Hypothesis that held: two services behind one source. A phase that cannot
log in is a failed run whatever the other phase did.
fail_if_source_broken() replaces the 0-agents rule: a phase failure fails
the run; so does a source-level error among the agents (login refused, 5xx,
connection refused/reset, DNS, SSL) even with 40 agents through. Other
per-agent errors stay completed, but Sync Health now shows them amber as
"N ERR" with the first error, not green OK.
The nightly app CVE scan, SCA refresh and EOL check log in to the same API
per asset and used to warn per asset and finish. Each now logs in once up
front (probe_wazuh_api): refused = a failed Wazuh run in the ledger with the
phase, mailed under the usual 24h cooldown; the API dying mid-run is caught
the same way from the fetch errors. A probe that works writes nothing. The
app scan continues without Wazuh inventory (empty inventory keeps findings,
Intune assets scanned as usual).
The failure mail names phase, counters and the error list, so the reader
sees what still went through. Regression: a scheduler-seam test replays the
incident (asset sync refuses, two agents pass over the indexer, run failed,
agents_synced=2, one mail).
Same gap as Scan Jobs (6a1e6f5), found by walking every GET route as a
readonly user: the sidebar hid the pages, the server did not.
- /groups: GET /api/v1/groups returned every group's member list
(usernames) to any role, while /auth/users is admin-only. The list
stays open — Assets and Vulnerabilities need it for their group
filter — but membership is now only in the admin's response.
GET /groups/default is admin-only; it is the Groups page's own
request, so its 403 is what bounces a non-admin back to the
dashboard.
- /policies: GET /api/v1/policies is editor+ now, matching the sidebar.
The Assets page fetched it for everyone for its editor-only
dropdowns; readonly no longer asks (READONLY_DENIED). The page
bounces on 403.
- /admin/auth: the API was already admin-only, but the page swallowed
the 403 and rendered an empty shell. It bounces now.
- api.ts: the client-side READONLY_DENIED rejection carries
response.status 403, so pages that bounce on 403 do not need to know
the request never left.
Regression test mounts every router as readonly and asserts the exact
allow-list of reachable GET routes, so the next page that is only hidden
in the sidebar fails the suite. README role table updated.
A readonly user saw the dashboard's "coverage at risk" banner and its
"Scan Jobs →" link, and could read the whole page: run ledger, sync
health, error messages, schedules.
Cause: the sidebar commit (e90bf1c) assumed the page "would 403 anyway"
and only hid the menu entry. It never did — every GET under
/api/v1/scans accepted any signed-in role. The banner was added later
on top of that open endpoint and became the visible path in.
Now the server is the gate: list, summary, sync-runs and sync-health
require editor. The banner request is skipped client-side for readonly
(same READONLY_DENIED mechanism as /auth/users), and /scans bounces a
403 back to the dashboard like the audit-log page does. /schedules
stays open because the dashboard shows the next scheduled run to
everyone. README role table updated accordingly.
Regression test mounts the scans router alone with a readonly / editor
user and asserts 403 / not-403 on the four routes.
Three lists paginated three different ways: Assets had « ‹ Page n / N › »,
Vulnerabilities had a row of numbered buttons with an ellipsis, the audit
log a third copy of the Assets bar. None of them let you type a page
number — on 357 pages of assets the only way to page 200 was clicking
Next 181 times or guessing with the per-page selector. The notification
log had no paging at all: the backend already accepted limit/offset, the
GUI fetched the first 50 and stopped.
One shared Pagination component now renders every list bar: range and
total, per-page select, first/prev, a "Page [ ] / N" field that commits
on Enter or blur and clamps to 1..N, next/last. Assets, Vulnerabilities,
Audit Logs and Notification History all use it; the notification-log
endpoint sets X-Total-Count (same pattern as /audit/logs) so that page
shows "x / y" and the header counter shows the real total instead of the
size of the first fetch.
A Wazuh API that refused every login for a day was visible only in the
container log. Every scan-jobs row is per asset, and a sync that fails at
authentication never reaches an asset — so /scans showed a clean history of
COMPLETED runs, the manual button said "Sync Failed — check the logs", and
nobody was told.
Ledger: every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL; button
or scheduler) is one sync_runs row with start, end, status, stats and the
error that stopped it, written from its own session so the record never
depends on the job's transaction. A run where every agent failed the same
way counts as failed, not as "0 triggered". Rows left running by a restart
are closed at startup.
Visible: Scan Jobs gets a Sync Health strip (per source: state, last run,
last success, error) and a Sync Runs table; the dashboard shows a banner
while a source is failed or stale (stale follows the schedule interval, a
weekly Nessus is not stale after six days); the Sync Failed modal shows the
real reason, and the sync endpoints return it instead of "check server
logs".
Mail: a failed run mails the sync-alert recipients (own setting, else the
notification defaults) with the error and the last successful sync, i.e.
how long the coverage gap already is — one mail per source per 24h while
it keeps failing, logged as SYNC_FAILURE in the notification log.
Asked: is every vulnerability-catalogue refresh guaranteed to run BEFORE the
nightly Intune/Defender sync, which on a large estate runs for hours? It was
not. The Intune sync at 02:10 was the first job of the night, and it runs the
app-CVE scan for each of its devices (plus the Defender TVM pull) — reading
the cvelistV5 index and the GitHub-advisory, TeamViewer and IGEL-ISN indexes
as stored. Those were rebuilt inside the app-CVE job at 03:20, i.e. after the
Intune, vCenter and IGEL syncs had already decided from them. So every
Intune-only device was matched against the previous day's catalogue, and only
the 03:20 pass over all assets caught up — hours later on a large estate.
vuln_index_refresh_nightly at 01:30 now rebuilds all four (cvelistV5 with the
MFSA merge, force-fresh ZIP; GitHub advisories; TeamViewer; IGEL ISN) as the
first job of the night, 40 minutes ahead of the Intune sync, and logs its
elapsed time so the gap can be checked. The 03:20 scan reads the same stored
index and no longer walks the 557 MB ZIP a second time. A failed rebuild keeps
the cached index, as before.
tests/test_nightly_index_order.py now asserts the refresh is scheduled before
every sync that reads it, with room for the ZIP walk.
Many IGEL flaws are published only as an ISN — ISN 2026-09 (UMS information
disclosure, 8.6), ISN 2025-31 (stored XSS in UMS, 8.0), ISN 2025-24 (command
execution in IGEL OS, 8.8) carry no CVE id at all, so neither the CPE path nor
the cvelistV5 path could ever see them. 21 of the 149 ISNs with a stated fix
are like that.
igel_isn_service reads kb.igel.com the way teamviewer_bulletin_service reads
the TeamViewer bulletins: the overview links every notice, each page states
the CVSS at the top and the fixed version per release line under "Update
Instructions" ("OS 12: Update to IGEL OS 12.7.1. OS 11: … 11.10.410"). That
per-line fix is the bound; the ISN id stands in for the CVE where there is
none. App versions on an OS line ("update the Chromium app to 151.x") are not
firmware bounds and are dropped; a UMS line told to move to a newer major
affects the whole old line.
Runs for both the endpoints and the UMS server, in the IGEL sync and in the
nightly app scan, between the CPE pass and the cvelistV5 pass so the shared
touched-set keeps every reconcile honest. The UMS server never had a reconcile
at all; it has one now. The index is rebuilt with the other vendor indexes.
Verified against all 198 live notices (parser and prototype agree on every
page) and by tests/test_igel_isn.py.
The probe settled it: `/serverstatus` on this UMS returns HTTP 200 with EVERY
field empty — rmGuiServerVersion, activeMQVersion, derbyVersion, serverUUID
and server all "" — anonymously and logged in, on v1, v2 and v3 alike. Not a
renamed key, not a missing login, and the earlier guess that the component
versions were populated was wrong: the log only ever printed key names.
The endpoint is broken, not forbidden. IMI answers an account without object
permissions with an empty body and HTTP 200 rather than a 401, which looks
the same from outside — so the probe now reads the device and firmware tables
on the same session as a control. Full tables next to a blank serverstatus
mean the account is fine and only that one endpoint is dead, which the IGEL
community has reported and PSIGEL worked around by deleting its status call.
So the operator, who knows which UMS they installed, gets to say. New
optional ums_version / ums_build in the IGEL settings, used ONLY when IMI
reported none — a UMS that answers keeps answering for itself, and the Test
button says which of the two it is showing. Without this the one server that
manages the whole thin-client estate stays the only asset with no version to
compare a CVE range against.
The login retry did not fill rmGuiServerVersion on the UMS in the field, so
the question is no longer "did we authenticate" but "does this UMS publish
its version at all, and where". Guessing another key name into the client
would be the third guess in a row.
Prints the raw serverstatus body before and after the login (so "the login
did not happen" and "the login changed nothing" stop looking alike), then
every neighbouring IMI route and web surface on the same port with any
version-shaped string found in it. Read-only.
/serverstatus is the one IMI endpoint that answers without a login, which is
what makes it a reachability probe that can tell "cannot reach the UMS" from
"reached it, credentials rejected". Asked that way, UMS 12 answers 200 with
the whole envelope and its OWN version blank: activeMQVersion and
derbyVersion populated, rmGuiServerVersion "", no buildNumber at all. The
key was never renamed — the value simply is not handed to an anonymous
caller. So the UMS asset, the one server that manages the whole thin-client
estate, landed with no os_version and a description reading "IGEL UMS", and
nothing ever compared it against a CVE range.
Ask once anonymously, and if the version came back blank, log in and ask the
same question again. A login that fails stays silent here on purpose:
test_connection() logs in itself right afterwards and reports a rejected
password as its own step, so raising here would relabel bad credentials as
an unreachable server.
The warning now also names which known version keys were present but empty,
and whether it fired before or after the login. That is the difference
between "UMS renamed the field" and "something did not authenticate" —
the previous log line listed rmGuiServerVersion among the keys while saying
no version was carried, which cost a round trip to the customer to resolve.
The MFSA fix (978d4f3) named the mechanism but not its other instances. Same
shape, same result, in the sources nobody looked at that day:
night N-1 03:20:00 consumer asks, cache expired -> build
03:20:30 build ends, timestamp stored
night N 03:20:00 consumer asks -> age 23h59m30s -> served from cache
The stamp is written when the build ENDS, so the next night's call at the same
cron minute is always a build-duration short of the 24h TTL. It clears the
night after (~48h), so each of these refreshed every OTHER night at best, and
which night depended on run-to-run jitter.
Affected, all of them sources that decide findings no other path can see:
* GitHub repo advisories — Notepad++ and Wazuh. Neither NVD nor cvelistV5
carries these, so a night stale is a night blind: GHSA-3v57-hgvj-3vj2
(CVE-2026-49441, critical) is fixed in 4.14.6 while NVD's newest bound for
the product is 4.14.5.
* TeamViewer bulletins — the vendor publishes days before NVD has a record,
and 5 of the 30 bulletins carry no CVE id at all.
* The Microsoft 365 Apps security page — this one is the MFSA bug with a
second consumer rather than a later one: the Intune sync (02:10) and the
M365 job (03:10) share the cache, so whichever asked on the far side of the
TTL decided the freshness for both. Patch Tuesday builds are the point of
that job.
The nightly paths now rebuild before they decide; the TTL keeps guarding the
on-demand callers (GUI scan, single asset), which must not pull vendor pages.
A failed rebuild falls back to the cached index — an offline night is no worse
than before. The Intune sync pulls the M365 page itself, because it covers the
Intune-only devices the 03:10 job never sees.
Left alone on purpose: endoflife.date, the MS lifecycle export and the Samsung
SMR page alternate the same way, but carry support-end dates published months
ahead — a day of delay changes no finding.
tests/test_nightly_index_order.py asserts the order and that a vendor site
being down does not take the scan with it.
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>
The nightly Firefox ranges were decided from yesterday's advisories, every
night, because the MFSA index refreshed itself ~10 minutes too late.
_merge_mozilla_mfsa asked get_index(), which honours its own 24h TTL. In the
nightly job the first caller to hit an expired cache is not the merge (03:20)
but the enrichment step that follows it (~03:30) — so the cache timestamp
drifted permanently to just after the build, and the merge read a ~23h50m old
index forever. Self-perpetuating: every night it re-stamped the same offset.
Measured on 02.09.2026, reproduced exactly offline against the advisory repo:
03:21 cvelistv5-scan: MFSA decided 551 Firefox CVEs <- 01.09. cache
03:31 MFSA index built: 644 CVEs <- today's advisories
551 is the index as of mfsa2026-81; the truth that morning was 580 mainline
CVEs. The 29 in mfsa2026-82..85 (CVE-2026-84117..84145, published 01.09.
12:19 UTC) were not scannable for another full day.
force_fresh — which the nightly job already passes to re-download the
cvelistV5 ZIP — now also rebuilds the MFSA index before the merge. A rebuild
that fails still falls back to the cached index, so an offline or rate-limited
night is no worse than before, and the on-demand paths stay cache-only.
While there: a build cut short by the GitHub rate limit produced a PARTIAL
index and stored it, caching "Firefox has fewer CVEs than it does" for 24h.
It is now discarded in favour of the last complete one.
Three iPhones flipped inactive → active every day and the audit log's only
word on it was "(seen again by a source sync)" — the one line that cannot be
told apart from an Intune revive, a Defender TVM revive, or anything else.
Two defects behind the one symptom.
The nameless message came from the nightly TIME-based reconcile, which reacts
to `last_scan`/`last_seen` rather than to a sync run, and those stamps carried
no source, so no message could ever name one. Every sync that stamps them now
records itself in `last_seen_source` (migration 049) and the reactivation entry
quotes it: "seen again by the intune sync — time-based reconcile: last_scan=…,
last_seen=… is inside the 42d window". Rows stamped before the migration say
"unknown source (stamp predates source tracking)" — `assets.source` is the
source that CREATED the asset, not the one that last saw it, and a confident
wrong name in a revisionssicher log is worse than an honest gap. The
event-driven revives (Wazuh, Nessus, Intune, vCenter, IGEL) already named
themselves and are unchanged.
The flap itself: the phones were deleted in Intune, so the Intune reconcile
deactivated them on every sync, and hours later the nightly reconcile revived
them on the stamp the sync BEFORE the deletion had written. That stamp was
already there when the asset was deactivated, so it is not evidence of
anything — the reconcile now requires a stamp newer than the most recent
ASSET_DEACTIVATED entry, the same rule reconcile_missing_from_sync already
follows in the other direction. Fail-open: if that lookup errors the reconcile
behaves as before.
Also surfaced, read-only, in the asset edit dialog next to the VMware build:
Entra device ID, Intune device ID, Defender machine ID, Wazuh agent ID. The
Entra ID is the identifier that survives an Intune deletion while Defender TVM
keeps reporting the same device under another name — it was in the database and
in the CSV export all along, just nowhere on screen, which is exactly where an
operator needs it to exclude the right machine in Defender.
Not done: writing the ID into the Description field. Description is free-form
operator text and a sync overwriting it would clobber what someone typed there.
IGEL UMS as a fifth inventory source, configurable under Settings next to
vCenter. It registers the UMS server and every endpoint device it manages as
assets, then detects IGEL OS CVEs on each.
An IGEL thin client is the one endpoint class nothing here could see. It runs
no Wazuh agent — IGEL OS is a locked-down appliance, you do not install one —
Intune does not enrol it, and a Nessus scan gets an open port and no version.
An estate of several thousand devices showed up as zero assets and zero
findings, which reads as clean and only means unlooked-at.
UMS already holds what is needed, but it needs a join: the device record
carries a firmwareID ("21"), and the version lives in a separate firmware
table. One extra request answers for the whole estate — the alternative, two
calls per device, would be 6000 requests for 3000 thin clients.
CVE detection needs BOTH sources, and neither is redundant:
* cvelistV5 carries IGEL's own CNA records. CVE-2026-82017 and -82018 (the
DEF CON 34 thin-client research, ISN 2026-19 / 2026-20) sit at NVD with no
configuration at all, so this is the only structured source for them.
* NVD's CPE carries CVE-2025-47827, which MITRE filed with vendor "n/a" —
there is no CNA data for cvelistV5 to index. It is also where the UMS
server's own four CVEs live (CVE-2022-25804..25807: hardcoded DES keys,
cleartext LDAP binds).
Two guards decide a finding. OS 11 and OS 12 are separate products with
separate fixes for the same flaw — CVE-2026-82017 is 11.11.150 on one line and
12.7.6 on the other — and taken naively an 11.11.149 device is also "less than
12.7.6". The installed major picks the line, and the product name ("IGEL OS
12") has to agree when the record states one.
The fix version survives an inclusive bound. CVE-2026-82018 says "affected up
to 12.8.2" and names 12.8.3 only in its `unaffected` entries — which is exactly
ISN 2026-20's Update Instructions, and the reason the finding exists. Without
recovering it the finding would call a device vulnerable and stay silent on
what fixes it.
Findings auto-close: an endpoint has no software inventory, so the app scan's
own reconcile never reaches it — the same hole the vSphere path had. The
CPE pass runs first and its hits are folded in, so the one CVE the cvelistV5
index structurally cannot confirm is not closed and reopened every night.
Address matching is deliberately off for devices. vCenter matches on it
because an ESXi host holds a static management address; a thin client takes a
DHCP lease, and a recycled lease would bind one device to another device's
finding history. The MAC-derived unit ID is the pin instead.
No EOL check: endoflife.date carries no IGEL product, and inventing lifecycle
dates for an estate whose real question is "OS 11 is end-of-maintenance" would
be worse than saying nothing.
The vendor security-bulletin page is planned, not built (docs/). The check
found the reason to wait: most ISNs are bundled-component notices — Chromium,
Firefox ESR — whose fix is stated per APP, and IMI v3 exposes no installed-app
resource, so those findings could not be attributed to a device.
Migration 048 (IGEL source + igel_unit_id). The cvelistV5 index key is bumped
to v31 so the cached index rebuilds with the new product.
The dashboard widget explains its lock icon; the Security Advisory
Feeds header listed the same three catalogs but no legend, so 🔒 on
a row had nothing to decode it. Same source line, same suffix.
Also capitalised "Ransomware" in every user-visible string (legend
plus the three tooltips) — it was lowercase everywhere.
VulnCheck KEV shipped as a third exploited-catalog source in f77c741 and the
dashboard reads it, but the immediate-alert mail never did: db_init seeded
kev_alert_sources as "cisa,euvd" on the reasoning that a ~4x larger catalog
should be an opt-in, and there is no UI to opt in with. An exploited CVE that
only VulnCheck listed sat open on an active asset and produced no mail.
Catalog size is not what gates a mail — an OPEN finding on an ACTIVE asset is.
Whichever catalog names it, an exploited CVE on our own machine is worth the
alert, and VulnCheck usually lists days before CISA (lead_days is computed
from exactly that gap), which is the window an immediate alert exists for.
Seed is now all three. Migration 047 rewrites the value on installs still
carrying the untouched old default; anything an operator changed by hand is
left alone — a default correction, not a policy override.
The plumbing was already there: get_kev_catalog / _normalize_sources /
the {{sources}} label line all handle vulncheck. Only the seed was in the way.
Two stale docstrings naming just CISA and ENISA fixed with it.
First run after upgrade sends a backlog, not a storm: MAX_ALERTS_PER_MAIL
caps it at 25 and the job is hourly.
CVE-2026-62911 (auth bypass -> EoP, CVSS 8.0) was reported by Wazuh alone on
a host running "Microsoft Exchange Server 2016 Cumulative Update 23"
15.1.2507.61. Not a false positive: the record bounds 2016 CU23 at
15.01.0.0 .. < 15.01.2507.072, and .61 is below .72.
The app scan stayed quiet because the curated cvelistV5 registry knew exactly
one Exchange product — Subscription Edition. The CU row resolved to nothing,
so it was never compared against anything. The NVD-CPE path cannot cover these
at all: microsoft:exchange_server carries version "2016" and update
"cumulative_update_23", with no build to range-check (SE has its own CPE
product with real builds, which is why that one works there).
One key PER CU, not a shared "exchange" key: the record floors every CU at a
generic 15.0x.0.0, so a 2019 CU14 host (15.2.1544.x) would be compared against
the CU15 bound (1748.049) and reported affected while fully patched. The set is
closed — CU23 was the last 2016 CU, CU15 the last for 2019.
Anchored like the SE row, for the same reason: the same host also lists a plain
"Microsoft Exchange Server" frozen at the CU's RTM build (.6 against the CU
row's .61), plus language packs and KB hotfix rows with stub versions — each
would sit below every fix build forever.
Index setting bumped to v30 so the product index rebuilds.
CVE-2026-16444 (TV-2026-1008, CVSS 7.5) was invisible on every TeamViewer
install. NVD carries no configuration for it — the record sits at "Received"
with zero cpeMatch entries — so the CPE path was blind by construction, and
cvelistV5 was the only structured source left. There the record is filed under
ONE compound product string:
vendor "TeamViewer" product "Full Client, Host, QuickSupport & Portable"
versions 15.0 lessThan 15.81.5
The curated pair list held ("teamviewer", "full client") and four siblings,
none of which equals that string, so the record never entered the index and
15.80.6.0 was never compared against 15.81.5. Across the 54 TeamViewer records
the same client appears under 25 spellings; the compound form is the one the
vendor has used since 2026, which is why coverage looked fine until now.
Replaced by a _PRODUCT_PATTERNS entry, the same mechanism Windows and Apple
releases use. The regex is anchored, enumerates the client component words and
excludes the mobile apps by name: DEX (32 records, versioned 9.x/24.x/25.x),
ONE, Frontline, IoT, Engage, Meeting and Patch & Asset Management all ship
their own version lines and must never be compared against a 15.x client.
CVE-2026-19042 is the control and is deliberately NOT a finding on Windows:
the record and the bulletin both scope it to Linux.
Also here, from the same audit:
- NVD files the client under a SECOND CPE name since the 2023 rename —
28 records say teamviewer:teamviewer, 2 say teamviewer:remote. The registry
queried one, so it lost the other's; added via `also`, the way Adobe's two
Reader names already are.
- New source teamviewer_bulletin_service: the vendor's own security bulletins,
for the same reason Firefox has MFSA and Notepad++ has repo advisories.
5 of the 30 published bulletins carry NO CVE id at all — TV-2026-1006 is a
real Full Client flaw (15.78–15.79.3) that no CVE-keyed source can see. The
bulletin id stands in as the identifier, as GHSA- and NESSUS-PLUGIN- do.
Rows are scoped per platform and per release line; bulletins that state no
version ("All", "Server-side hot fix") are skipped rather than guessed at.
- _upsert learns `refs`, so a finding whose id has no public catalogue page
carries the advisory URL as its only reference.
- An index that parses to empty is never stored: it would be stamped fresh for
24h, leave every TeamViewer finding untouched, and the app-scan reconcile
reads untouched as "no longer detected" → patched.
Verified against the live feeds: 30 bulletins, 17 with client version tables,
and on a Windows host at 15.80.6.0 exactly one finding — CVE-2026-16444,
fix 15.81.5.
The advisories panel learned the third catalog; the dashboard widget did not.
Its badge cell was a two-way ternary — euvd or else CISA — so every row that
only VulnCheck listed got painted with CISA's name and CISA's red, and the
header underneath still read "CISA KEV + ENISA EUVD". Wrong on the facts, and
wrong on the licence: VulnCheck KEV is free to use only where it is named.
Cause was the duplicate definition. The advisories page carried its own badge
map and its own source line; the dashboard carried a copy that predated
VulnCheck. Both now read from frontend/lib/kevBadge.ts, and the widget takes
its attribution from the same response that carries the rows — so a fourth
catalog names itself in both places instead of silently borrowing CISA's.
Unknown sources now fall back to their own id, never to another catalog.
The lead-time badge (−6d) rides along, since that is the reason VulnCheck is
carried at all.
Per-vulnerability data is untouched: kev_listed is still CISA alone, so the
vulnerability tables show no VulnCheck column — there is no such field yet,
and adding one moves every URS score in the fleet.
The KEV panel knew CISA (~1400 CVEs) and ENISA EUVD. VulnCheck KEV carries
~5200 and typically lists them days before CISA does — every record ships
CISA's own date, so the lead time is measurable, not a claim, and each row
now shows it.
Opt-in and silent by default: without VULNCHECK_API_TOKEN the source returns
{} and nothing about the install changes. Alerting is deliberately NOT opted
in either — kev_alert_sources stays "cisa,euvd", because ~4x the CVEs is the
operator's decision, not a side effect of updating.
No SDK: two GETs against a documented JSON API. The backup endpoint hands out
the whole index as one signed ZIP; the paginated index is only the fallback
and is capped at 600 documents on the community tier, which the log says out
loud so a partial catalog is never mistaken for the full one.
Attribution is a licence term, not decoration: "VulnCheck KEV" is named in the
KEV panel header and badges every row the source contributed to. Noted in
LICENSING.md so nobody strips it later.
Per-vulnerability scoring is untouched — kev_listed still comes from CISA
alone. Feeding 5200 CVEs into the URS bonus would move every score in the
fleet and belongs in its own decision.
The dashboard fires fourteen requests and renders nothing until the slowest
returns. Measured against a seeded fleet (300 assets, 80k findings), the burst
took 0.76s — and the server runs them strictly one after another: every
vulnerabilities endpoint is `async def` holding a blocking Session, so six
parallel requests take exactly as long as six sequential ones (measured
speedup 0.90x). Wall clock is therefore the SUM of the endpoints, which makes
every wasted query on the page a direct hit on time-to-render.
Three sources of waste, all removed:
- /reports/dashboard ran eighteen COUNTs over the same scan — four severities,
exploitable, avg, distinct assets, oldest row, seven one-day history counts,
two totals, two asset counts. Now three statements: one aggregate with FILTER
clauses, one grouped history query, one asset count. Response is byte-identical.
- The list endpoint lazily loaded asset, asset.groups and packages per row, so
a twelve-row widget spent 36 round trips after its one real query. Eager-loaded:
39 statements to 5 for that call, and the dashboard fires seven such widgets.
- With distinct_cve the unused `total` re-ran the whole window-function pass
(80ms of a 160ms call) for a number no widget renders. New `with_total=false`,
which the dashboard's seven widget calls now pass; default stays on, so the
Vulnerabilities page is untouched.
Dashboard burst 0.76s → 0.48s locally. The N+1 removal counts for more than
that on a real deployment, where every round trip crosses the network.
Remaining top cost is /compliance/urs (185ms, ~40% of what is left): it pulls
every open finding into Python to compute CPR per row. The materialised
cpr_score column could aggregate that in SQL, but that changes URS numbers
wherever the column is stale, so it stays a separate decision.
The URS widget listed the five highest-risk assets as plain text — the only
list on the dashboard that was not clickable. KEV rows push to the
vulnerability view, compliance "worst offenders" linked out, only URS was a
dead end: the user read a hostname, then searched for it by hand on the
compliance page.
Each entry now links to /compliance?asset=<id>, and the compliance page
reads that parameter once its tables have loaded and opens the asset's
policy modal — the same modal a click on the URS table row opens. That makes
it a direct link to the affected asset, not just to the section.
Same fix for the compliance widget's "worst offenders", which linked to the
bare /compliance page and dropped the asset it named.
useSearchParams forces the page dynamic unless it sits under a Suspense
boundary, so CompliancePage is split into an inner component and a Suspense
wrapper, matching the vulnerabilities page. /compliance stays statically
prerendered.
The "Open >" link to the compliance page reported as missing from the URS
widget is present and unchanged (page.tsx:557).
Feldmeldung: Dashboard und Compliance-Seite brauchen seit dem Einspielen
der Mittwochs-Commits acht Sekunden statt drei. Die Logs zeigen, woran es
liegt, und es sind nicht die Mittwochs-Commits: von allen Calls beider
Seiten ist genau einer langsam, /api/v1/compliance/urs braucht 4,9 bis 6,0
Sekunden, alles andere zusammen unter 300 ms. Beide Seiten haengen ihr
gesamtes Promise.all daran, also wartet die ganze Seite mit.
list_urs hat pro Asset gescort: eine Asset-Abfrage, eine Vulnerability-
Abfrage, eine Result-Abfrage, dann pro Policy ein Lazy-Load der Checks
plus eine Impact-Abfrage. Nachgemessen mit einer 120-Host-Flotte, drei
Policies, 120 Checks: 961 Statements fuer einen Aufruf. Auf SQLite im
selben Prozess sind das 600 ms und faellt niemandem auf; gegen ein
Postgres ueber einen Socket sind es bei 3 ms Round-Trip 5,3 Sekunden —
exakt der gemessene Wert.
Deshalb war es vorher schnell und ist es jetzt nicht: der teure Zweig
zaehlt erst, seit SCA-Daten und Impact-Bewertungen geladen sind. Ohne
gecachte Checks kehrt _weighted_score_for_policy sofort zum gespeicherten
Score zurueck und die zwei Abfragen pro Policy entfallen. Wer URS und
Compliance anbindet, schaltet den Pfad scharf.
compute_urs_bulk() macht denselben Score in fuenf Abfragen, unabhaengig
von der Flottengroesse: Assets, offene Vulnerabilities als Spaltenabfrage,
Results, deren Checks, die Impact-Tabelle. Der Lesepfad schreibt dabei
nicht mehr — bisher hat compute_ass weighted_score auf den geladenen Zeilen
gesetzt, was ein GET per Autoflush zu UPDATEs gebracht hat. Persistiert
wird nur noch, wo es gewollt ist (recompute-all und der naechtliche Job).
compute_avs/compute_ass/compute_urs bleiben als duenne Wrapper ueber
denselben Code, damit Einzel- und Massenpfad nicht auseinanderlaufen
koennen. Die CPR-Formel steht jetzt als cpr_score() auf Modulebene, sodass
der Massenpfad sie ueber eine Spaltenabfrage laufen lassen kann, statt pro
Finding ein ORM-Objekt zu bauen — eine Quelle, keine zweite Kopie.
Dabei aufgefallen: /compliance/assets hat get_compliance_summary()
aufgerufen, das Ergebnis weggeworfen und dieselbe Aggregation nochmal
inline gemacht — beide Kopien mit einer Hostname-Abfrage pro Asset in der
Schleife. Zwei N+1-Schleifen fuer eine Liste. Jetzt eine gemeinsame
_asset_rollups(), zwei Abfragen.
Messung 265 Assets, 3 ms Round-Trip: 2121 Statements / 11,7 s → 6
Statements / 0,18 s, Ergebnis Zeile fuer Zeile identisch.
- tests/test_urs_bulk_scoring.py pinnt Statement-Zahl unabhaengig von der
Flottengroesse, Gleichheit mit dem Einzelpfad in allen drei avs_modes,
und dass ein Lesen nichts schreibt
- tests/test_compliance_rollup_queries.py pinnt dasselbe fuer das Rollup
The "Show decommissioned" checkbox sent include_inactive=true and the backend
filter honoured it correctly, but no code path ever wrote status=DECOMMISSIONED:
the syncs only set INACTIVE/ACTIVE, the edit modal had no status field, and
Delete hard-removes the asset with its findings (CASCADE) instead of retiring
it. The filter had no state to filter for, so the checkbox looked dead.
The PUT endpoint did accept `status`, but wrote it with a bare setattr — the
transition reached the audit log as a generic ASSET_UPDATED instead of the
ASSET_DEACTIVATED / ASSET_REACTIVATED entry every sync-driven transition gets.
- Lifecycle Status select in the asset edit modal (edit only, editor+)
- PUT /assets/{id} routes status through apply_status() so the trail names the
transition, with force=True: the operator-final guard is there to stop a sync
from reviving a retired asset, not the operator who retired it
- tests/test_asset_decommission.py pins audit event, default filter and undo
No UI button for the lifecycle reconcile — it stays the nightly 04:15 job plus
POST /assets/reconcile-lifecycle (editor+). The tester text listing a
"Lifecycle abgleichen" button described a control that never existed.
Same class as 2e1b8e8, different role. The nav lets editors onto
/policies (requires: 'editor'), but POST /api/v1/policies and
DELETE /api/v1/policies/{id} are RequireAdmin — only PUT is
RequireEditor. So an editor saw a "Create Policy" button and a trash
icon that could only ever answer 403.
Fetch the role like the other pages do and gate accordingly: Create and
Delete admin-only, Edit editor-or-above (which also covers a read-only
user reaching the page by direct URL, since the nav item is hidden).
Audited the remaining pages while here — vulnerabilities/ is already
fully gated via currentUser?.role, and scans/, groups/ and admin/auth/
are coherent: every button on them needs exactly the role the nav
already requires to reach the page.
The "⚡ Exposure" toolbar button posts to /assets/refresh-exposure,
which is guarded by RequireEditor. Every other mutating control on the
page (Add Asset, rescan, edit, delete) is wrapped in `canEdit`; this one
was not, so a read-only user saw it, clicked it, and got
"Access denied. Required role: editor" as a failure alert.
Wrap it in the same `canEdit` gate. No backend change — the 403 was
correct, the button should never have been reachable.
Aus dem Security-Review dieser Branch. Vier Findings, alle mit einem
gemeinsamen Muster: eine Kontrolle existiert, greift aber nicht.
MFA war mit dem Passwort allein umgehbar. _issue_mfa_challenge hat das
Challenge-Token mit create_access_token gebaut — ein regulaerer,
signierter Access-JWT. get_current_user prueft Signatur, Ablauf, JTI,
sub und is_active, aber nie den mfa_pending-Claim; den liest nur
/auth/mfa/verify. Wer das Passwort hat, ruft /auth/login, nimmt das
mfa_token aus der Antwort und schickt es als Bearer an jeden geschuetzten
Endpoint — mit der vollen Rolle aus der DB-Zeile, weil PermissionChecker
die Rolle nicht aus dem Token liest. Dasselbe galt fuer das
Forced-Setup-Token, das in den SSO-Pfaden zusaetzlich als URL-Parameter
uebergeben wird, und fuer Refresh-Tokens: /auth/refresh prueft
type == "refresh", die Gegenrichtung war offen, also war ein
Refresh-Token ein 7-Tage-Bearer und die Rotation umgehbar.
Alle Tokens tragen jetzt einen type-Claim, und get_current_user laesst
nur "access" durch — positiv allowlisten, damit ein neuer Token-Typ per
Default abgelehnt statt per Default gueltig ist. Nebenwirkung: bestehende
Access-Tokens ohne type-Claim werden ungueltig. Refresh-Tokens tragen den
Claim schon, Clients holen sich also im Hintergrund ein neues
Access-Token; wer keins hat, loggt sich einmal neu ein.
Wazuh lief durchgaengig mit verify=False. 15 Call-Sites bauten den
Client mit cfg.get("verify_ssl", False), und das war kein Edge Case: der
wazuhConfig-State im Frontend hatte gar kein verify_ssl-Feld, der Save
schrieb den Key also nie, und der Settings-PUT ersetzt den ganzen Wert.
Der Fallback war der einzig erreichbare Zustand. Ueber diese Verbindung
gehen Manager- und Indexer-Credentials als HTTP Basic. Jetzt
bool(cfg.get("verify_ssl", True)) wie bei Nessus/vCenter/Intune, plus
eine Verify-SSL-Checkbox im Wazuh-Formular, damit ein self-signed Zert
bewusst statt stillschweigend akzeptiert wird.
setup.sh hat einen JWT-Key generiert und ihn nirgends hingeschrieben:
das sed suchte einen Platzhalter, den .env.example seit Laengerem nicht
mehr enthaelt, traf nichts, exit 0, und die naechste Zeile meldete
Erfolg. Die Instanz signierte also mit dem oeffentlich bekannten
CHANGE-ME-String — offline faelschbare Admin-Tokens. Das sed ersetzt
jetzt die ganze Zeile und verifiziert danach; wichtiger ist der
App-seitige Guard in jwt_handler, der Platzhalter und Keys unter 32
Zeichen beim Start ablehnt, so wie totp.py es fuer
AUTH_PROVIDER_CRYPTO_KEY schon tut. Damit ist auch der Operator
abgefangen, der .env.example kopiert und Schritt 2 vergisst.
Beide Setup-Skripte legten ausserdem einen Admin mit hardcodiertem
"changeme" an und umgingen damit die Staerke-Pruefung in db_init. Der
Block ist raus, create_initial_data ist der einzige Bootstrap-Pfad.
Dazu RequireEditor auf /assets/reconcile-lifecycle und
/assets/refresh-exposure — beide hatten nur get_current_user, waehrend
jede andere mutierende Route derselben Datei RequireEditor verlangt.
Nicht gefixt, bewusst: JIT-Provisioning verlinkt eine SSO-Identitaet per
unverifizierter E-Mail auf ein bestehendes lokales Konto
(jit_provisioner.py:60). Kein Privilege Gain, weil _refresh_role die
Rolle neu berechnet, aber ein Lockout des legitimen Admins ist moeglich.
Braucht eine Design-Entscheidung, kein Einzeiler.
tests/test_token_purpose.py deckt die Typ-Logik ab. 92 Tests gruen.
The setup steps never mentioned it, so a fresh install keeps the shipped
CHANGE-ME-FERNET-KEY placeholder and the first MFA enrolment fails with a
bare HTTP 500 ("AUTH_PROVIDER_CRYPTO_KEY is not a valid Fernet key").
Nothing else is affected, which is exactly why it stays hidden until
someone turns on MFA.
Also corrects the reverse-proxy section, which still named the compose
fallback ports 3000/8000 instead of the 3003/8022 that .env.example ships.
Docs only — nothing to re-test.
A fresh `docker compose up` never came up. Migration 028 imported the
live ORM (`from app.models.asset import Asset`) and ran `db.query(Asset)`
against a database that is only at revision 028, so the SELECT asked for
`assets.intune_device_id` — a column revision 033 adds. Alembic aborted
with UndefinedColumn, the whole migration chain rolled back (one
transaction for all revisions), and the backend crash-looped against an
empty database.
028 is now raw SQL: one CTE that flips the legacy Nessus rows and writes
the audit entries, comparing `source::text` / `status::text` so it no
longer depends on today's enum labels either.
env.py switches to transaction_per_migration. 027 adds the
ASSET_DEACTIVATED enum label that 028 inserts, and PostgreSQL refuses to
use a new enum value in the transaction that added it ("unsafe use of new
value"). Per-migration transactions also make a failed upgrade resumable
instead of all-or-nothing.
Verified end to end from a clean `git archive` checkout: migrations run
027 -> 046, default admin is created, backend reports healthy, login
through the frontend proxy returns a session cookie.
tests/test_migrations_no_orm_imports.py locks the bug class down: no
migration may import app code, because the ORM mapping is always newer
than the schema of the revision it runs at.
Badges were emitted back-to-back (`</span><span>`) and relied only on
`.src { margin-right: 3px }` for the gap. Mail clients that drop margins on
inline elements rendered them merged: "CISA KEVENISA EUVD",
"CISA KEVENISA EUVDRANSOMWARE".
Join the labels with a literal space and add `white-space: nowrap` so a badge
never wraps mid-label. Regression test covers both.
The provider table named specific models — GPT-4.1 and o3, Claude Opus 4.5
and Sonnet 4.5, Gemini 3 Pro and 2.5 Pro, DeepSeek V3 and R1. Every one of
those is now a generation or more behind, and two are already retired
upstream: OpenAI sunsets o3 today, and Gemini 2.0 shut down on 1 June.
The list was wrong in a way it could not stay right, because the app never
read it. get_ai_models() in app/routers/settings.py queries each provider's
own /models endpoint when the key is entered, so the dropdown already shows
whatever that key can reach — a model released tomorrow appears without a
code change. Documenting a snapshot of that alongside it only created
something to go stale.
Replaced with the provider list, their default endpoints, and the note that
discovery is live. Same six providers as before; nothing in their support
changed.
Documentation only. Nothing to test.
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.
Ahead of the public release, example values now use generic placeholders
instead of values carried over from a live environment: the connection
placeholders in the settings form, the sample data behind the mail-template
preview, the hostnames in the EOL fixtures, and the device identifiers in the
Intune re-enrolment fixtures. The README points at GitHub rather than the
internal Git host.
Comments, docstrings, HTML placeholder attributes and fixture strings only.
No logic changed and the assertions pin the same behaviour as before, so
there is nothing here to re-test.
.gitignore additionally covers every .env variant (only literal .env and
.env.local matched before, so a .env.production would have been picked up by
git add .), the SAML and LDAPS key material .env.example tells operators to
generate in the repo root, AI-tooling state, root-level node artifacts,
generated report exports, and the untracked third-party checkout sitting in
the working tree, which git add . would otherwise commit as a broken gitlink.
Also untracks the local editor-assistant config and an empty root
package-lock.json stub that had no package.json beside it.
Publishing under AGPL without a CLA closes the door on ever offering
commercial terms: every merged PR would be copyright we cannot relicense,
and asking dozens of contributors for permission after the fact does not
work. CLA.md section 2 grants the maintainer a license "under any license
terms, including proprietary" — that clause is the whole point.
LICENSING.md states the open-core boundary up front rather than carving
features out later, which is the move that has burned trust elsewhere. The
entire detection and enrichment engine stays AGPL; Enterprise is limited to
SSO, audit trail, compliance reporting, SIEM forwarding and multi-tenancy,
under a stated two-part rule for what may ever go there.
SECURITY.md gives a private disclosure path — a vulnerability scanner whose
own bugs land as public issues is a poor advertisement.
The CLA bot runs as a GitHub Action writing signatures to a cla-signatures
branch, so there is no third-party service to sign up for.
Placeholders {{LICENSOR}}, {{CONTACT}}, {{GH_OWNER}} and {{GH_REPO}} are
filled in once the legal entity and the GitHub location are settled.
The tree carried our own infrastructure in places a public reader could
reconstruct it from:
- frontend settings placeholders showed the real Wazuh manager and indexer
on 10.10.66.0/24, next to .local placeholders everywhere else in the file
- test fixtures used the live "ise-" hostname convention (dc, mail hybrid,
Exchange, SQL, SAP, Citrix VDI), and one shipped source comment in
sync_agent_vulnerabilities named a domain controller outright
- the Intune re-enrolment fixtures and the _run_intune_sync_locked docstring
carried a real Samsung serial and the two Graph device ids it was captured
from
Replaced with neutral placeholders; the assertions still pin the same
behaviour. README now points at GitHub instead of the internal Gitea host.
.gitignore gained the patterns that only held by luck: every .env variant
(only literal .env and .env.local were covered, so a .env.production would
have sailed into git add .), the SAML/LDAPS key material our own .env.example
tells operators to generate in the repo root, AI-tooling state (.gitnexus was
ignored via .git/info/exclude, which is never cloned), root-level node
artifacts, generated report exports, and the untracked third-party
wazuh-risk-insight checkout that git add . would have committed as a broken
gitlink.
Untracked .serena/ config and the empty root package-lock.json stub.
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).
CVE-2026-62727 landed on the tester's Server 2025 host on 20.08. via the
fixed-build scan and still had no Remediation block afterwards — the same
symptom 0bf3252 was supposed to end.
_list_recent_docs sorted the /updates index by CurrentReleaseDate before
taking the tail. That field is the last REVISION date, not the month the
document is: Microsoft restamps ancient documents in bulk (19.08.2026 put
2020-May, 2021-Nov, 2023-Feb, 2024-Oct on one timestamp; 1999-Sep "Mariner
Release Notes" sits at 2025-10). So the tail was "most recently touched",
not "most recent months". build_product_index takes 18 of that order and
still saw 2026-Aug — which is why the CVE was detected — while the 2-month
catch-up ingest took 2 and could miss it entirely. Detection without
remediation is exactly the shape the tester reported. Sort on the month
parsed from the ID instead; the 18-month backfill gets real coverage too,
which it never had.
Second half, the latency: even correct, the ingest is a whole-month batch
that runs at 03:50. A finding created out of band — the tester's was
detected 16:15 — waited until the next morning. The Update Guide (sug) API
answers per CVE in ~50 KB with the same payload (KB, fixedBuildNumber,
update type, download link), so the detail view now fetches that one CVE
when a Windows finding has no MSRC rows yet, mirroring the Linux/OSV
on-demand path next to it. Rows come out in parse_vulnerability's shape, so
the existing host-branch narrowing applies unchanged: at installed
10.0.26100.33138 it picks KB5120233 (build .33296) and the hotpatch
KB5120228 (.33222).
The old test fixture pretended CurrentReleaseDate tracked the month, which
is what hid this; it now carries restamped entries and fails on the old sort.
Four separate defects made the same product invisible on the one host it
runs on. The tester's two reports are the visible tip of it.
1. GHSA index kept vulnerabilities[0] only. An advisory carries one block
per affected package, each with its own range. GHSA-mvh4-g699-984j
(CVE-2026-54085) lists wazuh-agent ">= 4.2.0" and wazuh-manager
"> 4.2.0" — only the agent was ever indexed, so the manager side was
never seen. 7 of wazuh/wazuh's 74 advisories are shaped this way.
2. The range parser understood only a "< x" upper bound, and read
patched_versions with a bare _vt(). Wazuh writes ">= 4.0.0,
<= 5.0.0-beta2" (CVE-2026-74039), ">= 4.14.4", "≥ 4.8.0",
"4.10.4, 4.14.5" and "from 3.5.0 to 4.3.10." — every one of those
returned False. A ">= x" range has no upper bound of its own and falls
back on the patched value, so an unreadable patched value silenced the
whole advisory: 24 of the 74 were invisible. A 4.14.1 manager goes from
23 findings to 39, with nothing lost.
3. cvelistV5 states the component too — CVE-2026-74039 is filed under
product "wazuh-manager" — but every wazuh-* package resolves to the one
key "wazuh", so the record landed on the agents. The only guard was a
prose heuristic, and that description names neither side. That is the
reported false positive: an agent carrying a manager-only API DoS.
4. All three scan paths dedup on (product key, version). A manager host
runs wazuh-manager AND wazuh-agent at the identical version, so
whichever syscollector listed first took the key and the other was
never scanned. With the agent first, the manager guard in _upsert then
dropped the manager-side CVEs as well.
Hypothesis that held: the component is stated fact in both feeds, and
every path was inferring it from prose instead of reading it.
Verified against the live wazuh/wazuh feed (74 advisories) and the
cvelistV5 record for CVE-2026-74039. GHSA index cache key bumped to v3 to
force a rebuild; the stale agent finding closes itself through the
existing app-scan reconcile on the next scan.
Two bugs, one visible symptom: a hypervisor patched during the day kept every
CVE its old build had, and the manual sync answered "another sync was already
running" forever.
1. The vSphere pass never reconciled. The app scan's auto-resolve sits behind
`if packages:` — a hypervisor runs no agent, has no package inventory, and
so never reached it. Nothing else ever revisited these findings: the new
version and build were written onto the asset (correct in the GUI) while
the findings from the old build stayed open, run after run. The reconcile
now lives in scan_asset_vmware, where the verdict is made, so both callers
(vCenter sync + nightly app scan) get it. Same contract as every other
reconcile: retract our own source, mark patched only once no scanner still
claims it, and close nothing at all when this pass had no verdict — no
build on the asset, no index, or a bound the catalog cannot resolve.
It commits itself: the nightly scan commits an asset only when the scan
FOUND something, and a host whose findings were all just closed found
nothing — its cleanup would have been rolled back and redone every night.
2. The advisory lock leaked across the connection pool. pg_try_advisory_lock
is SESSION scoped — it belongs to the postgres backend, i.e. to the pooled
connection — but both syncs took it on their ORM Session and then commit
once per host, and a committed Session hands its connection back to the
pool. Any concurrent request could take it, after which the sync ran on a
different backend: the closing pg_advisory_unlock found nothing, returned
false, and the lock stayed held for the life of that pooled connection.
Reproduced against postgres: run 1 released it by luck, run 2 unlocked the
wrong backend, run 3 was skipped, one lock held. From then on EVERY vCenter
sync — manual and nightly — was skipped, so nothing rescanned the hosts and
nothing could ever be closed. The lock now gets a connection of its own
(database.advisory_lock), and a failed unlock invalidates that connection
instead of returning it to the pool still locked. Intune/Defender had the
same pattern and the same fix.
Of the 23 CVEs in mfsa2026-74 (announced 18.08.) TrueVuln had found two on
20.08. Not a cache problem — the yml was fetched 19.08. 15:39 — the MFSA index
was only ever used to fill in severity and description, never to find a CVE.
Detection came from cvelistV5, which states Firefox INVERSELY: no affected
range, only one "unaffected" floor per maintained train (115.39 / 140.14 /
153.1 / 154). Nothing in the record marks which floor is the release train and
which are ESR, so _ranges_from_affected only inverts the single-floor shape —
anything else is skipped rather than flag a regular Firefox 121 for an ESR-only
advisory. CVE-2026-74975 and -74989 have one floor; the other 21 have two to
four and produced no range at all. Whether such a CVE was found then came down
to NVD having published a CPE for it, which is why two assets on the same
Firefox build reported different CVE sets.
Mozilla's own advisory says it in one line — fixed_in: [Firefox 154] — so the
MFSA index now decides the Firefox ranges and cvelistV5 keeps everything else:
- mozilla_advisory_service: derive ff_fix (mainline, non-ESR/iOS/Thunderbird)
and esr_fix per CVE; merge fixed_in across advisories instead of letting the
first file listed win, so the regular-vs-ESR verdict no longer depends on
alphabetical order; parse the indented " - Firefox 137.0.2" form (5 of 178
advisories, previously an empty fixed_in); never overwrite a good index with
an empty one and serve it stale when a rebuild fails.
- cvelistv5_scan_service: _merge_mozilla_mfsa replaces the Firefox entries for
every CVE Mozilla has ruled on, drops the ESR/iOS/Thunderbird-only ones, and
spares an install sitting at or above its own ESR train's fix. Index setting
bumped to v28 so the cache rebuilds.
Verified against all 178 announce/2025+2026 advisories and the live cvelistV5
records: all 23 mfsa2026-74 CVEs now resolve to "below 154", identically for
153.0.4 and 147.0.4. Regression test: tests/test_firefox_mfsa_source.py
The 15 Chrome CVEs from stable 151.0.7922.169 were committed to cvelistV5 on
2026-08-18 at 20:43 UTC but only surfaced in TrueVuln two nights later.
Confirmed cause: the nightly index build (03:20 UTC) shares
/tmp/truevuln-cvelistv5-cache.zip with the threat-intel refresh, whose
IntervalTrigger(24h) is anchored to app startup rather than the clock. On a
container started in the late afternoon that job re-downloads the ZIP around
17:00-20:00, so at 03:20 the file is only ~10h old — inside the 12h TTL. The
index build therefore never downloaded anything: it rebuilt, night after
night, from an afternoon snapshot taken before Google published. A simulation
of the real cron times and TTLs puts worst-case detection latency at 34.2h.
The matching logic was never at fault — the real CVE-2026-76044 record flags
150.0.7871.125 and 151.0.7922.139 and clears .169/.170. Only the index was
blind.
Nightly build now passes force_fresh=True, bypassing the TTL. On-demand
callers (manual scan, FP suppression) keep the 12h cache so the GUI never
triggers a 557 MB download. A forced refresh that fails falls back to the
on-disk ZIP instead of skipping the walk.
Worst-case latency drops 34.2h -> 23.8h, at a cost of up to 2 ZIP downloads
per day instead of ~1.4. No job timing changed.
Wazuh syscollector reports the classic client under its unrenamed name
"Microsoft Teams" (WindowsApps MSTeams_1.0.0.0_x64__8wekyb3d8bbwe), which
is exactly the name the current client uses. The name-only guard
`_normalise(pname) == "teams" -> return None` therefore swallowed it and
the host showed no EOL finding.
Version decides: every shipping new-Teams build is 2xxxx.x.x.x, never 1.x
— the same split _teams_classic already uses for the machine-wide
installer. Pinned to the exact "teams" name so "Microsoft Teams Meeting
Add-in for Microsoft Office" (live, at 1.24.x) stays clean.
The feed panel counted f.items.length in its header but rendered
f.items.slice(0, 15). With the "Items per feed" limit at 20 the heise
Security Alerts feed therefore announced "20 items" and listed 15 —
the five newest-but-sixteenth-onward advisories were fetched, cached
and silently dropped at render time.
The slice is older than the configurable limit: it shipped with the
original page (64204f4), back when 15 was the only cap there was. Once
advisory_feeds_max_items became the operator's knob, the hardcoded 15
turned into a second, invisible ceiling that no setting could raise.
Root cause confirmed: one hardcoded display cap contradicting the
server-side limit that the same header reports. Removed it — how many
items a feed keeps is now decided in exactly one place (the setting,
clamped 1-500 in the service), and the header count and the list can
no longer disagree.
An iOS asset carried Android Security Bulletin and Samsung SMR CVEs. They got
there while a matching bug let one asset stand for several physical devices,
but nothing could ever remove them again: the ASB/SMR pass runs for Android
devices only, so on an iOS asset those findings had no scanner left that could
close them. They would have stayed open forever, with the operator's only
option being to delete the asset.
The mobile check now closes them as soon as it sees a device that is not
Android — an ASB CVE on an iPhone is wrong regardless of how it got there.
Same closing contract as everywhere else: drop our own source, close only when
no other scanner still reports the CVE, one audit row per transition. The
patch-level pseudo-finding carries the sync's own source, so it is recognised
by its stable id instead.
Graph reports an all-zero azureADDeviceId for every enrolment that has no
Entra device object of its own — personally-owned and userless mobile devices,
mostly. The sync stored it in aad_device_id and then matched on it, so every
such device found the first asset carrying the placeholder and pinned its own
enrolment id onto it.
One asset therefore stood for a whole class of devices. On every pass it was
re-pinned to whichever device Graph listed next — logged as a re-enrolment,
because the id had genuinely changed — adopted that device's name, and
collected its findings. The devices that lost the race were missing from the
inventory entirely: they never reached the auto-create branch, since the match
had already succeeded.
Two guards, because the constant is not the point: the documented placeholder
is never treated as an id, and neither is any value two devices in the same
sync both report. The second needs no knowledge of which constants Microsoft
considers special, so it also covers the next one.
Defender's machine match reads the same field and gets the same guard.
Migration 046 clears the placeholder out of aad_device_id, so the collision
stops on existing data and the next sync re-creates the assets that were
swallowed. The findings still sitting on the surviving asset are handled
separately.
A wiped-and-redeployed device stays in Intune twice: the dead entry under its
old device id next to the live one, same deviceName. Both matched the same
asset (correct — it is one asset) and both were processed, each pinning its
own device id onto it. The last one in Graph's arbitrary order won:
... re-enrolled — device id b89b… -> a603…
... re-enrolled — device id a603… -> b89b…
One sync, flipping the asset back onto the dead enrolment. Every inventory read
after that went to the old device — this sync's detectedApps, and the app-CVE
scan, which fetches by asset.intune_device_id — so software long gone from the
live device kept being reported as installed and closed findings reopened. The
stale app list never changes again, so the false positives had no way out.
Duplicates are now collapsed before anything touches an asset: newest check-in
wins (lastSyncDateTime, enrolledDateTime breaks ties), the rest are dropped for
the sync and logged. Keyed on the cleaned short hostname the asset match ends
up on anyway, so nothing collapses here that would not have collapsed onto one
asset regardless; nameless records are never merged.
And when Intune is the asset's only inventory source (no Wazuh agent, no
Defender machine), the app list now REPLACES the stored inventory instead of
adding to it: run_eol_for_packages reconciles, so what the live device no
longer has is retracted rather than left open forever. Co-managed hosts keep
the old behaviour — there "not in my list" is not "uninstalled". The OS-EOL
finding is seeded into the seen set so the reconcile cannot close it.
The Wazuh sync judged every agent by its own keepalive against the six-week
grace window and deactivated the ones that had aged out — then
`reconcile_missing_from_sync` ran and revived every INACTIVE asset whose agent
id appeared in `seen_ids`. Wazuh lists disconnected agents too (fetching
active-only was itself a data-loss bug), so the seen-set contained exactly the
hosts the keepalive check had just ruled dead. Deactivate and reactivate for the
same host, in the same sync, in the same second — visible as an
ASSET_DEACTIVATED immediately followed by an ASSET_REACTIVATED "(event-driven
revive)" in the audit log, and as zero INACTIVE Wazuh assets in the inventory
while Wazuh showed 30 disconnected agents.
The revive branch was redundant as well as wrong: every id that reaches
`seen_ids` has already passed through `apply_status` in the agent loop (or the
cluster-node loop), where the keepalive decides. Dropping it leaves the
reconcile with the one job it can actually judge — an agent id that is no longer
in the list at all. A returning host still comes back, through its keepalive.
`reactivated` stays in the returned dict so the sync response keeps its shape.
Sole caller is the Wazuh sync; Nessus, Intune and vCenter use their own
`*_by_seen_ids` reconcile functions and are untouched.
Intune's detectedApps refreshes every 7 days per device, so the app-CVE scan
ran against week-old version strings — a browser patched this morning still
read last week's version, and the finding stayed open until the next
Discovered-apps cycle.
Intune's replacement, Enhanced App Inventory, collects multiple times per day
but has no API: `deviceInventories` is absent from the Graph beta $metadata
(no entity, no navigation property on managedDevice), its doc pages 404, and
the official export-report list has no app-inventory reportName —
AppInv{RawData,Aggregate,ByDevice} are all Discovered apps. Microsoft says
Graph support is planned for later in 2026. Until then the data is
portal-only.
Defender for Endpoint already exposes the same thing app-only and documented:
/machines/SoftwareInventoryByMachine, one tenant-wide paginated call, keyed by
the machine ids the Defender sync already matches to assets. It follows device
telemetry rather than a weekly cycle. Feed it into _run_app_inventory — the
same EOL / M365 / app→CVE pipeline detectedApps feeds — so the fresher list
wins wherever Defender covers the device, and detectedApps stays for the rest.
The only translation is the name: Defender reports CPE-style tokens (vendor
`microsoft`, product `visual_studio_code`) while the product registry matches
installer names. _pkg_name joins and un-escapes them, dropping the vendor when
the product already carries it.
Best-effort throughout: a tenant without Software.Read.All or without Defender
Vulnerability Management gets an empty inventory and the previous behaviour.
Also extends tools/probe_intune_app_inventory.py to walk the whole matrix —
URL shapes, the documented config resource, and every plausible export report
name — so the Intune-side verdict can be re-checked against a live tenant when
Microsoft ships the Graph route.
The CVE sync stamps `last_scan` on every run — it reads the indexer's
stored documents, so a disconnected agent gets a fresh stamp while nothing
was fetched from the host at all. The nightly reconcile took that stamp as
evidence the host still exists, while the Wazuh sync judged the same host
by its own keepalive. Once the six-week window closed, the two disagreed
permanently: sync deactivates on the stale keepalive, reconcile revives on
the fresh scan stamp, twice a day, forever. Yesterday's audit fix would
have written every one of those flips into the log.
`_liveness_stamp()` now decides: a Wazuh asset with an agent id is judged
by its keepalive alone. Everything without a keepalive of its own (Nessus,
vCenter, Intune, manual) keeps both stamps, and so does a legacy Wazuh row
that has no `last_seen` yet — a missing stamp must not deactivate a host.
The assets CSV download ignored the server's Content-Disposition and built
its own filename from `new Date().toISOString()` — always UTC, so an export
started at 09:06:39 CEST landed as `assets_2026-08-18-07-06-39.csv`, two
hours off and in a different format than `audit_log_20260818_090842.csv`.
The audit-log export is a plain `<a href>`, so the browser honours the
server's header and gets server-local time. The assets export is an XHR
(auth headers + filter params), so it now reads the same header instead of
inventing a name: both exports get `<prefix>_YYYYMMDD_HHMMSS.csv` in server
time from one source of truth (the router).
Content-Disposition is added to the CORS expose_headers so the header is
readable when the frontend runs on a different origin than the API.
The audit log's last word on an asset was "active → inactive" (17.8.,
06:44), while the inventory kept showing it ACTIVE. The status was right:
the grace window shipped that same morning (0845fbe) revives a Wazuh agent
that is merely disconnected but whose keepalive is inside the six weeks.
The audit entry was missing, because the agent sync wrote `asset.status`
directly instead of going through the audited path — so a transition the
sync itself caused never reached a trail that has to be revisionssicher.
Every sync status write now goes through `apply_status()`, which logs the
transition and is a no-op when nothing changes (no audit spam per run).
It also refuses to revive a DECOMMISSIONED asset — operator-final, the
rule both reconcile functions already followed and the direct assignment
quietly broke on every sync.
tests/test_asset_status_audit.py drives the real sync against an in-memory
DB with the tester's agent 105.
Adds GET /api/v1/assets/export - a streaming, unpaginated CSV export of
the whole (optionally filtered) asset inventory with every DB column plus
denormalised policy/assignee/groups and open-vulnerability counts.
- Extract the list endpoint's filter logic into _apply_asset_filters so
list + export share one source of truth.
- Use selectinload (yield_per-safe) for policy/assignee/groups to avoid
N+1 lazy loads on large exports.
- Add an "Export CSV" toolbar button that downloads the export, honouring
the current search / source / "show decommissioned" filters.
- Add a regression test for the CSV JSON column formatters.
Also drops the auto-generated AGENTS.md/CLAUDE.md (gitnexus tooling files)
from tracking and gitignores them.
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.
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.
The EUVD link on the detail page only appeared when enrichment had
stored an euvd_id, which only happens for rows the EUVD KEV feed
covers. Every other CVE showed no EU record at all.
The EUVD resolves a CVE id to the same page as its own EUVD-ID
(/vulnerability/CVE-2026-68820 == /vulnerability/EUVD-2026-56468), so
the link needs no id mapping: use the EUVD-ID when we know it, the CVE
id otherwise. Label keeps the EUVD-ID in parentheses when present.
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.
next-auth was never imported — the backend issues the JWTs itself — so a
package carrying a critical advisory sat in the tree doing nothing.
Removing it beats upgrading it.
next 16.1.5 → 16.3.1 also clears postcss, sharp, nanoid, form-data,
follow-redirects and uuid, which came in transitively. eslint-config-next
moves in lockstep with next, otherwise it pins the old toolchain.
npm audit --omit=dev: 9 findings (1 critical) → 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.
The probe reported every failure as "app registration lacks the
permission", but detectedApps and managedDevices succeed on the same
token, so that permission is demonstrably granted. The 403 body is the
Intune service envelope, not an AAD denial — and _get truncates it at
200 chars, cutting off the part that names the cause.
Print the untruncated body, the token's roles claim, and a control
request against a route that certainly does not exist. Intune answers
unknown segments with the same generic 403, so only the contrast
between the three shows whether deviceInventories('ApplicationProperties')
is an ungranted route, a gated feature, or no route at all.
asset.last_scan was stamped with datetime.now() on every sync, so the
dashboard reported fresh data while the underlying detectedApps snapshot
could be seven days old (Discovered apps refreshes weekly per device,
Win32/IME every 24h). An Edge reading 151.0.4129.59 here was already .78 on
the device, and the app-CVE scan ran on the stale version without a warning.
Store lastSyncDateTime — already selected, never used — in last_seen and
count devices whose check-in is >= 7 days old in the sync stats.
Add tools/probe_intune_app_inventory.py: Intune's App inventory replaces
Discovered apps and collects several times a day, but its Graph surface is
not in the beta reference yet, so the payload shape has to come from a real
tenant before any mapping is written.
The hostname was styled as a link (blue, bold) but carried no handler, so
clicking it did nothing — the identical table below already routes through
openAsset.
The column reads new_value.hostname. Writers fill it since c81d030, but
every row written before that stays empty forever — which is exactly
what the tester still sees on the EOL new-finding events — and any
writer that stores a blank hostname produces the same empty cell.
One extra query per page maps vulnerability id → hostname for the rows
that name no host, and merges it into the payload on the way out. No
migration, no stored rows touched, and a future writer that forgets the
field is covered too.
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.
NVD and cvelistV5 have no record for a lot of the ids this scanner
produces, and the GLOBAL GitHub advisory database does not have them
either: github.com/advisories/GHSA-8c6v-7g3w-prrq is a 404 while
wazuh/wazuh's own advisory page serves it. Repo-published advisories
only exist on the repo.
So every CVE now offers a GitHub chip. When the affected product maps to
a repo that publishes its own advisories (the existing _REPO_MAP), the
link points at that repo — the detail endpoint upgrades it to the
advisory itself when the cached repo index knows the GHSA id, and falls
back to the repo's advisory search otherwise. Everything else gets the
global advisory search. All of them are search pages or verified
records, so none can 404.
Two things fixed in passing:
- The GHSA branch of the External References block was dead code: the
block was guarded on /^CVE-/, so a GHSA-only finding (the ones added
in 6304b66) showed no references at all.
- The `references` column has been written by the Nessus sync since it
landed and was never rendered anywhere. Those vendor advisories are
often the only page describing the flaw, and for a NESSUS-PLUGIN- row
they are the only reference it can have — the block now renders for
those rows too, with the catalogue links still restricted to real CVE
ids so no NVD link is built from a plugin id.
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.
Audit logs had its own pagination bar: a "Rows per page:" label, 25/50/100/250
steps and full-word First/Prev/Next/Last buttons. The Vulnerabilities and Assets
tables use "Per page" with 50/100/250/500/1000 and compact «/‹ Prev/Next ›/»
controls. Same bar everywhere now; 1000 is the cap on /audit/logs.
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.
One page of 100 was fetched and the Link header ignored. wazuh/wazuh sits at
60 advisories and notepad-plus-plus at 18, so nothing is missing today — but a
repo crossing 100 would have lost everything past the first page without a
word, which is the failure mode this whole day was made of: a source that
answers 200 and quietly returns part of the answer.
Pages are followed via rel="next", capped at 20 so a malformed header cannot
loop. A multi-page fetch is logged, and hitting the rate limit stops the whole
build rather than storing a partial index as if it were complete.
Checked while answering where these advisories come from: the per-repo
endpoint carries the full history (2023 through 2026, no second page), while
the global advisory database returns exactly one Wazuh entry — it is organised
by package ecosystem and Wazuh is not a package in any of them. The per-repo
endpoint is the right source; the global one would add nothing.
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.
Third and last place this was missing. ASSET_DEACTIVATED and
ASSET_REACTIVATED wrote the bare new status ("inactive") into new_value, which
is not parseable, so the Asset column stayed empty on exactly the rows that
are ABOUT an asset — while the hostname sat in the description right beside
it.
Same JSON shape as the vulnerability events now, with the reason alongside the
host, so the column fills for all three kinds: findings detected, findings
changed, and assets deactivated or revived.
The all-in-one fallback took the host straight out of the configured API URL,
so an installation addressed by name ended up with an FQDN in a column called
IP ADDRESS — which sorts and filters as text and is not what anything reading
that field expects.
It is resolved now, and passed through untouched when the URL already contains
an address. If DNS has no answer the field stays empty: better nothing there
than a value of the wrong kind.
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.
Status changes carry the host in new_value and render correctly; the
VULNERABILITY_DETECTED rows wrote no new_value at all, so the new Asset column
was empty for exactly the events there are most of. The hostname was already
in the description text, which is the part that gets truncated.
Those rows now carry the same JSON as the status changes — host, CVE, source
and severity — so the column fills for both kinds and the data is there for
filtering later.
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.
assets.wazuh_agent_id was String(10), sized for a numeric agent id ("042").
Manager nodes are stored in the same column as "node:<name>", which is 11
characters for "node:master" alone and free-form beyond that, so the insert
was rejected on length and the whole Wazuh sync answered 500. Widened to 64
and the id is truncated to fit.
The failure also showed the block was not as contained as intended: the
guard around the node sync caught nothing, because the offending insert was
only validated at the agent loop's commit further down, outside it. It now
flushes inside the guard and rolls back on failure, so a problem with a node
costs the nodes and not the entire asset sync.
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.
Migration 040 fixed the two constraints pointing at vulnerabilities, which was
only half the chain — I checked what referenced findings and not what
referenced assets. So the delete got one step further and failed on the next
constraint instead:
ForeignKeyViolation: update or delete on table "assets" violates foreign
key constraint "notification_logs_asset_id_fkey"
This time every foreign key into the delete path was enumerated. Three had no
rule, each gets the one that fits: the notification log keeps its row with the
reference cleared (it records that a mail WAS sent, which stays true), while
scans and findings belong to their asset, are NOT NULL, and go with it. The
asset_groups association is included even though SQLAlchemy clears it itself —
the database should not depend on the application getting the order right.
Verified no foreign key into assets, vulnerabilities or scans is left without
a rule.
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.
Naming the host inside the description was not enough: that column truncates
at max-w-xs, and these rows are long — "Vulnerability EOL-CHROME-150 status
changed: open → patched (Superseded — asset moved to a newer chrome release
(EOL-CH…" cuts off before it. So the machine was still only reachable by
exporting the CSV and reading new_value by hand.
It has been in new_value's JSON all along, so the column reads it from there
and no backend change is needed. Rows written before the hostname fix show a
dash rather than a guess.
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.
VENDOR RATING NO LONGER DISAPPEARS
Chrome and Firefox CVEs ship with no CVSS at all, so the vendor's own rating
is the only signal at first: Google states it in the record ("Chromium
security severity: Critical"), Mozilla in its advisories. Once a real score
arrives the severity column is recalculated from it and that rating was gone —
CVE-2026-19137 shows HIGH because CVSS 8.3 is High, while Google calls it
Critical.
Neither is wrong. They answer different questions: Chromium "Critical" means
escape from the sandbox, by Google's own criteria; 8.3 is High on the FIRST
scale. So the vendor's rating now sits next to ours instead of being replaced,
and only when the two differ. Sorting and priority stay on CVSS.
No schema change — Google already writes it into the description, and Mozilla's
is appended there the same way, so the badge reads both from text that is
already stored.
AUDIT ROWS NAME THE MACHINE
log_vulnerability_change has taken a hostname from the start, but the automated
paths (EOL supersede and reconcile, app-scan auto-resolve) never passed one, so
their rows said which CVE changed and not on which system — the tester could
only get at it by exporting the CSV and reading new_value. It is now looked up
when the caller does not supply it, rather than threaded through every call
site: these are single-row writes and the id is indexed.
Two independent failures, both silent, so the second EOL source has been
contributing nothing for a while.
THE WORKBOOK WAS NEVER FOUND
The link pattern required the file to be named eos-product-listing-<month>-
<year>.xlsx. Microsoft renamed it to product-lifecycle-data-new.xlsx, so the
match failed and fetch returned no rows. The export page still answers 200 and
the log shows a successful request, which is why nothing looked wrong. Only
the host, path shape and extension are pinned now — that page carries exactly
one download.microsoft.com xlsx, so the next rename costs nothing.
AND IT WOULD NOT HAVE PARSED ANYWAY
The column lookup asked for "listingname" and "enddate". The workbook calls
them "Product Listing Name" and "Extended End Date", so both fell through to
their positional defaults — and default 3 is "Azure Feature", whose value is
the string "Fixed". Not a date, so every row was dropped as unusable. Columns
are now matched on their real names (punctuation-insensitive), preferring
extended support over retirement, and a workbook with no recognisable date
column says so in the log instead of quietly returning nothing.
Both failures presented identically to a supported product: no lifecycle data
found, move on. Exchange 2016, the Visual C++ redistributables and Lync 2013
are all in this export.
tools/check_ms_lifecycle.py walks the whole chain — link, download, parse,
name matching against real inventory strings — and exits non-zero at whichever
step breaks next.
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.
The page size was hard-coded at 100, so 59k findings could only be walked 591
pages at a time while the assets table has offered 50/100/250/500/1000 all
along. Same control, same choices, same default, so both tables behave alike.
The pagination bar now appears whenever there are results at all. It used to
be gated on totalCount > pageSize, which would have hidden the selector as
soon as the chosen size covered everything — leaving no way back to a smaller
one. Changing the size returns to page 1, otherwise page 5 of 1000-per-page
lands past the end of a 50-per-page list.
The API already accepted up to 1000 (limit le=1000); nothing changed there.
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.
CVE-2026-21710 lists Node 20.20.1, 22.22.1, 24.14.0 and 25.8.1 as concrete
entries and then adds "4.0 lessThan 4.*" and "5.0 lessThan 5.*" for the
ancient lines. The wildcard was carried through as a fix target, so a host on
24.13.1 was advised to upgrade to "4.*" — a release line, not a build anyone
can install.
It sticks, too: a fixed_version is filled in once and never overwritten
(vuln_override_service), so a wrong one outlives the finding. Empty is
recoverable, wrong is not — so wildcards are dropped at the source, and the
ones already written are cleared on the next scan and counted as
fix_versions_cleared.
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.
Android reports the app by its store id, org.mozilla.firefox. The cvelistV5
registry matched "mozilla firefox" with a space, so that path missed Firefox
on every Android device while the CPE path saw it fine — the two sources
disagreed on the same install. Anchored to the start so it stays a package id
match rather than a substring hit; Chrome already had com.android.chrome.
Browser extensions now carry "(User)" the way Windows ARP marks a per-user
install ("Microsoft Visual Studio Code (User)"). An extension lives in the
browser profile, so it is updated or removed per user and will not be found in
Programs and Features — worth saying in the finding itself.
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.
why_open.py explains why a finding stays open. This explains the step before
it: which NVD cpeMatch rule accepted the installed version, printed next to the
parsed version tuples. Every false positive reported so far came down to a
bound written in a different version scheme than the install (Adobe's three
products, Teams' build numbering), and finding that meant reading the NVD page
by hand each time.
Each scan button carried its own list of which counters to display, so a
counter the backend reported but the list did not name was invisible: the log
said "11 FP-suppressed" and the button said "0 findings (0 new)". Adding a
counter to a scan service meant editing several buttons, and it got forgotten
every time. The MSRC button was worse — it sliced the result to the first four
entries and printed the rest as raw backend field names.
lib/scanStats.ts formats whatever the backend actually returned. Known keys
get a readable label, anything else renders from its own name, so a new
counter shows up in the GUI with no frontend change. Zero counters are
dropped, since "0 auto-resolved" is noise.
Applied to the app scan, the per-asset re-scan, the EOL check, the MSRC
enrich and the M365 check.
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 app-scan and EOL pollers had two branches — running, or finished — so
every answer that was not a readable status object fell into "finished". The
empty result then rendered as "0 assets, 0 findings (0 new)" and the button
went idle while the backend scan carried on and logged the real numbers.
The tester hit this after ~15-30 minutes with several tabs open, which is when
the access token expires mid-scan: the poll 401s, the interceptor refreshes and
replays it, and anything that comes back other than a clean status object —
a login-page body from a redirect, a second 401 — landed in the finished
branch. The 401/refresh/200 sequence in the log is the healthy path, not the
bug; the poller's reading of it was.
Now only status "completed" or "failed" stops the poll. Anything else keeps
polling, with a ~2 min counter of unusable answers so a backend restart
mid-scan (the job lives in process memory) ends it with a clear message
instead of spinning forever. Same counter on the catch branch, which could
poll a dead backend indefinitely.
Also surfaces fp_suppressed in the result line, now that suppression runs
inside the scan.
All three ways of starting an app scan — the nightly job, the GUI button, and
a single asset — call run_app_cve_scan and share every check inside it. The
cvelistV5 false-positive suppression was the one step that did not: it hung
off the nightly job, so the same host came out differently depending on how
the scan was started. A manual run left Wazuh's loose-CPE false positives
standing until the next night.
Moved into run_app_cve_scan, scoped to the same asset_id, so a single-asset
run stays a single-asset run. Reported as fp_suppressed in the scan stats,
which the GUI already renders alongside the other counters.
Index freshness stays deliberately different: the nightly job rebuilds the
cvelistV5 index (a ~557 MB ZIP walk), a manual scan reuses the cached one and
only builds when none exists.
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)
This is why false positives survived scan after scan. The reconcile ran, set
the stale findings to patched — and then the transaction was never committed,
because the commit fired only when the scan had FOUND something:
if touched: # touched = a finding was created or re-detected
db.commit()
An asset whose only outcome was closing stale findings rolled its own work
back, every single time. The tester's why_open output made it unmistakable:
two assets with all four preconditions satisfied — ACTIVE, Wazuh inventory,
scanned an hour earlier, package no longer resolving, app-scan the only
source — and eleven findings each still sitting open.
It also explains why the earlier fixes appeared to work selectively: a host
with a fresh finding got its cleanup committed alongside that finding, a
quiet host did not. Exactly backwards, since a host with nothing new to
report is the one whose cleanup matters.
Cleaning up now counts as a change, so it is written like any other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The self-check confirmed the container is current, so the remaining question
is why specific findings survive the reconcile — and that has several
preconditions which fail silently. Nothing in the log says "skipped because
the asset is inactive" or "another scanner still claims it".
docker compose exec backend python tools/why_open.py Dictionaries
Walks the same four conditions the reconcile applies — asset ACTIVE, an
inventory source exists, app-scan is the finding's only source, and the
package no longer resolves under current code — and marks whichever one
fails. The last check is the important one: if the package still resolves,
the CVE is re-detected on every scan and can never close, which points at the
matching rules rather than the reconcile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagnosing "the fix did not take effect" kept failing before it started: the
one-liners I sent over chat picked up a non-breaking space and died with
SyntaxError, three times in a row, so we never learned whether the container
was running the new code at all.
docker compose exec backend python tools/diagnose_scan.py
Seven checks, each printing what it expects: the Adobe add-on exclusion, the
Teams build-shape guard, both halves of the Node inclusive-bound fix, the
target_sw field, the cvelistV5 index key and the NVD cache prefix. A failing
line means the container is behind, and every other symptom is downstream of
that — which is the question worth answering first.
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>
A complete blind spot, and the reason is a units mismatch rather than a
missing entry. Autodesk versions its products by release YEAR and NVD lists
them that way too (autodesk:autocad:2018), but the inventory reports the
internal build — AutoCAD LT 2026 installs as 25.1.60.0. Comparing a build
number against a year matches nothing, so adding the product alone would have
changed nothing.
The year is therefore read from the NAME ("AutoCAD LT 2026 - Deutsch
(German)"), the same idea as name_ver for .NET and Python, with a year pattern
instead of a dotted version. Where the name carries no year — "AutoCAD LT
Private" — nothing is scanned: guessing one is worse than missing the product.
LT and full AutoCAD are separate CPE products and stay apart. The companions
are excluded by anchoring, each having its own unrelated version: Autodesk
Access, CER, Genuine Service, Identity Manager, and "AutoCAD Open in Desktop".
cvelistV5 pairs are deliberately not added yet — NVD's AutoCAD coverage looks
complete on inspection, and an unverified second path is a false-positive risk
rather than a gain. Worth revisiting if a CVE turns up that NVD has not
enriched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Illustrator, Photoshop, InDesign and Bridge were blind spots — nothing looked
at them at all, on software that ships dozens of code-execution CVEs per
patch day (APSB26-42, -51, -40, -32, -39, -89).
Both sources carry them well, so both paths are wired up. cvelistV5 names two
of the four with a "Desktop" suffix ("Photoshop Desktop", "InDesign Desktop")
and two without ("Illustrator", "Bridge"), which is not a pattern — each name
is listed. Verified against CVE-2026-34661, -27289, -27283 and -34630.
The inventory names carry a YEAR the CVE records never mention ("Adobe
Illustrator 2026" at version 30.1), so the year is ignored rather than parsed
— the version field is what gets compared. Two InDesign generations can sit on
one host and both resolve to the same product, each judged on its own version.
Anchored, and the helper components that ship alongside are excluded:
AdobeNotificationClient, Adobe Refresh Manager and AdobeAcrobatDCCoreApp are
not the products and carry unrelated version numbers — the same trap the
dictionary pack sprang last commit.
Index key bumped to v16.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VMware Tools is the clearest case for running both sources. NVD enriched
CVE-2025-41244 with a CPE and marked CVE-2025-41246, -41239, -22247 and
-22230 "NOT SCHEDULED" — the CPE path can never see those four, on software
that sits on every virtualised Windows endpoint. cvelistV5 has all of them,
under three spellings: VMware/Tools, VMware/VMware tools, and n/a/VMware
Tools. All three are mapped; both paths are wired up.
Adding the product alone would not have worked, because VMware writes its
affected versions as placeholders — "13.x.x.x", and "12.x.x, 11.x.x" naming
TWO release lines in a single field. Parsed as digits the second becomes
(12, 11), which is not a version: a 12.4 host compares BELOW that floor and
drops out of its own range, and so does an 11.3 host. Both would have been
reported unaffected while the record says the opposite.
_wildcard_floor now reads the concrete part in front of each `x` and takes the
lowest — the floor of the whole statement. Versions without a wildcard are
untouched, so nothing else changes. This is general, not VMware-specific;
any CNA writing "9.x" gets read correctly now.
Index key bumped to v15.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Adobe entries added in 2dc60be and 3e6c15c matched the product name
anywhere in the string, so they also matched every add-on that mentions it:
"Asian Language And Spelling Dictionaries Support For Adobe Acrobat Reader",
language packs, font packs. Those ship their own version numbers that look
exactly like old Reader builds — the dictionary pack is 23.008.20421 — so each
one collected the full set of Reader CVEs. The tester saw CVE-2026-48373
listed eight times on one host, and not once for the Reader itself.
The name must now START with the product, which drops the "Support For …"
add-ons, plus an explicit exclusion for the packs that do lead with it
(language pack, dictionaries, spelling, font pack). Same pattern as the
Exchange language packs and the Teams add-in — a vendor putting its product
name inside an accessory's name is the recurring shape here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the same report.
Adobe had no cvelistV5 entry at all — only the CPE path, which meant current
versions depended entirely on NVD having published a CPE yet. cvelistV5 names
the product "Adobe" / "Acrobat Reader": no DC suffix, and no Reader-vs-Acrobat
split, because Adobe stopped shipping them apart — APSB26-63 covers both and
links the same release notes for either. NVD meanwhile keeps the older _dc
spellings alive in parallel, which is why 2dc60be made the CPE path query both
names. Verified against CVE-2026-47965 / -47911 / -47961: affected up to and
including 26.001.21651, and the bound resolves correctly against that build.
Wazuh only detects ancient Reader builds (wazuh/wazuh#29960), so these two
paths are the entire coverage for current versions.
Separately, Defender findings flapped open and closed within one sync. The
auto-resolve ran per MACHINE, but several Defender machines can map to one
asset — a re-imaged or dual-registered device keeps its old machine entry. The
machine that no longer lists a CVE closed the finding; the one that still
lists it reopened it a minute later; next sync the same again (tester:
CVE-2026-66313, patched 13:41, open 13:42, patched 15:00). The CVE sets are
now unioned per asset and resolved once, after every machine has been asked.
Index key bumped to v14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NVD describes one Acrobat install under TWO product names — acrobat_reader_dc
and acrobat_reader (the "_dc" suffix is the older spelling, kept in use after
Adobe dropped it) — and each name carries DIFFERENT CVEs. Verified against a
current Reader build: 25 CVEs under one name, 6 entirely different ones under
the other. We only ever asked the first, so those six were invisible. Same
split for Acrobat itself.
A registry entry can now name alternate CPEs; their results are merged, first
entry per CVE kept, so a CVE listed under both names stays one finding.
This path carries the weight for Adobe: Wazuh only detects ancient Reader
versions, which is what made the gap easy to miss — findings existed, just
never the current ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Intune sync button threw away the POST's answer. That answer says either
"started" or "already_running" — and in the second case we are watching
somebody else's run (the nightly job, or a second click on a long sync), whose
stats may well be gone by the time we poll. The status endpoint then returns
no result and no error, and the button fell into a branch that simply said
"Sync completed." — announcing a success it had not witnessed, with none of
the numbers.
Attaching to a running sync is now stated up front, and a missing result is
reported as a warning that points at the log instead of a green success.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three inventories do not see the same moment in time. Wazuh syscollector
is close to live, Intune depends on how aggressively the tenant pushes an
inventory refresh, and Defender TVM's software list trails by days. Any source
could reopen a patched finding immediately, so the normal sequence after
patching a host was: the fast source stops reporting the CVE and the finding
closes — then the slow source runs, still holding its old picture, and reopens
it. Next night the same again. The finding flaps and its change history
describes our polling rather than the host.
A source may now only reopen a finding once its own lag has had time to pass:
three days for Defender, one for the Graph-based ones. Sources that read a
live inventory themselves — wazuh_sync, app_scan, msrc, nessus — keep
reopening immediately, because when they say it is back, it is back.
The grace is a window, not a veto: past its lag the slow source is believed
again, so a host that genuinely regressed still reopens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wipe an Intune device and deploy it again and Intune issues a NEW device id.
The asset kept the dead one, because both ids were written fill-only — set
once, never corrected. Nothing looked broken: the hostname match still found
the asset, the sync reported it as matched. But every inventory fetch went to
an id that no longer exists and came back empty.
That is worse than it sounds, because an asset with no inventory is skipped by
the entire scan chain — the whole package block, the MSRC pass, the reconcile
and the prune all sit behind `if packages:`. Its findings therefore stay open
forever, and no amount of rescanning can clear them. It also explains why the
last two reconcile fixes appeared to do nothing on such a host: the code they
repaired was never reached.
We are holding the id Intune reports for this device right now, so it is by
definition the current one. A change is logged, since a re-enrolment is worth
seeing in the log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e02d303 made the reconcile resolve the parent package_name instead of
matching it literally, which fixed findings the app scan had named "Microsoft
Edge". It did not fix the case one step further out: a CVE that hits Chrome
AND Edge gets its parent name from whichever scanner saw it first, and that is
usually "Google Chrome" — which resolves to no MSRC product at all, since
Chrome is not one. The finding was skipped again, and stayed open with Edge
long past its fix (tester: CVE-2026-16807).
The per-package rows do carry the product this pass knows about, so they are
consulted when the parent name leads nowhere. A finding is still only touched
when something on it belongs to a product this pass evaluated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend log carried OS-EOL and mobile-EOL findings, reactivations, and
Defender's resolved count; the UI showed none of them. A sync that closed 241
Defender CVEs and surfaced 44 mobile EOL issues read as '144 devices, 144
matched, 0 created, 380 app findings' — nothing about the work it had done.
Non-zero counts are appended now, zeros stay hidden so a quiet sync stays
short. Defender gets its own group: new, resolved, unmatched devices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The button said "160 assets, 0 findings (0 new)." for a run that had just
auto-resolved 345 stale findings and pruned hundreds of package rows. That
reads as "nothing happened" — indistinguishable from a scan that genuinely
did nothing, and it is exactly the run an operator wants confirmation of after
patching an estate.
The numbers were in the backend log the whole time; the button simply never
showed them. Now it appends whatever is non-zero: auto-resolved findings,
pruned package rows, MSRC findings. A quiet run stays quiet — nothing is
appended when all three are zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nightly chain was out of order in a way that cost a full day of accuracy.
CISA Vulnrichment — which corrects CVSS and SSVC — ran at 03:00, while the
jobs that CREATE findings ran after it: EOL at 03:15, M365 at 03:20, the app
scan at 03:25 and the MSRC scan at 05:10. Everything those four produced
therefore waited until the NEXT night to be corrected, so a fresh critical
spent 24h at whatever placeholder score it was born with.
URS had the same problem one level up: it recomputed at 04:00 from a picture
that was missing the MSRC findings still an hour away.
Reordered into the dependency chain it always implied — inventory (02:00-02:30),
findings (03:00-03:50), enrichment (04:30-04:50), aggregates (05:10-05:25),
housekeeping (05:40). Same jobs, same spacing, nothing added.
Documented in the README, because the ordering is a constraint rather than a
preference: a new job that produces findings has to sit before 04:30.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A finding is one row per (cve, asset) but lists every affected product, and
two products hit by the same CVE patch on entirely separate schedules.
CVE-2026-17733 hits Chrome and Edge: the tester patched Chrome to
151.0.7922.72 while Edge stayed on 150.0.4078.105, and the finding kept
listing Chrome at its OLD version as still affected. Reading it, you cannot
tell which half is actually outstanding.
The cause is that a package row is only ever written while the product is
detected. Once it is patched, nothing touches the row again, so it keeps
whatever it had — the stale version included. last_seen_at was added for
exactly this and the comment promised a pass that reads it; that pass was
never built. This is it.
Only rows on findings this run re-examined are pruned, and never the last one:
a finding with no packages reads as "we know nothing" rather than "this
product is fixed" — and if it really was the last, the finding itself is stale
and the existing reconcile closes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'Microsoft Exchange Server Subscription Edition' lost its distinguishing half
to an ellipsis — and that half is exactly what separates it from plain
'Microsoft Exchange Server', which sits on the very same host with a
different build. The name now wraps, and carries a title attribute for the
hover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both scores multiply the CVSS in, so a CVE without one came out at zero:
priority 0, CPR "—". A Critical Chrome CVE therefore sorted below a medium
that happened to carry a score, in every priority-ordered list and in the
digest mail. Google states "Chromium security severity: Critical" in prose and
NVD frequently never scores those records, so this is not a rare corner.
The severity band's FLOOR now stands in when no score exists — critical 9.0,
high 7.0, medium 4.0, low 0.1. The floor, not the middle: the estimate can
only ever understate a real score, never inflate one. An unscored critical
lands at CPR 74 against 78.8 for a real 9.8, and above a scored medium, which
is the ordering that was wrong. A real CVSS always wins over the estimate.
Also silences the Settings page for read-only users: every
/api/v1/settings/<key> is admin-only and the page requests a dozen on open,
one 403 each. Same central gate as /auth/users.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A complete blind spot: Wazuh does not detect it (wazuh/wazuh#36200), so an
Exchange SE host reported nothing at all. Both of our sources carry the data —
cvelistV5 states "15.02.0.0 .. lessThan 15.02.2562.043" for CVE-2026-42897, and
NVD has the matching CPE — so this only needed a registry entry on each path.
The leading zeros that make this awkward elsewhere are irrelevant here: Wazuh
reports 15.2.2562.27 and the record says 15.02.2562.043, and both sides are
parsed as numbers rather than compared as strings. That difference is exactly
what the Wazuh issue describes as the cause on their side.
The name regex is ANCHORED and exact, which is the whole risk of this change.
One Exchange host lists five kinds of row — the product itself, "Microsoft
Exchange Server", a dozen language packs (all still on the RTM build), and
"Hotfix Update for Exchange Server Subscription Edition (KB5066373)" whose
version field is the literal "1". Only the product entry carries the real
build; the KB row would compare below every fix ever published and flag the
host permanently.
Index key bumped to v13. tests/test_exchange_se.py pins all five row types
plus the version boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening Assets or Vulnerabilities as a read-only user fired /auth/users on
every page load, which the backend correctly refused — a steady stream of
403s in the log for a call whose result that role can never use. The
assignment dropdowns it feeds are already hidden for readonly.
Gated centrally in the API client rather than by rearranging three pages:
the role is picked up from whichever /auth/me response passes through the
response interceptor — AppShell issues one on startup — and a request
interceptor drops the call before it is sent. No page changes, no extra
round-trip, and until the first /auth/me lands the role is unknown and
everything behaves exactly as before.
The rejection carries no HTTP response, so it cannot trip the 401
refresh-retry path, and all four callers already fall back to an empty list —
which is the correct answer for this role.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A record stating one affected version and no bound at all was dropped
entirely, so the CVE never entered the index and nothing but Defender could
report it. CVE-2026-14266 is exactly that shape: "7-Zip 20.01, status
affected", nothing else.
They were dropped to avoid over-matching, which is a real risk — an unbounded
floor swallows every version above it. A single exact version has no such
problem when it is read as the closed range [v, v]: it matches that one
version and nothing else, which is precisely what the record claims. 20.01
hits, 20.01.0 hits (same version, written differently), 19.00 and 26.01 do
not.
Index key bumped to v12; the cached index has none of these entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading ESTABLISHED sockets as evidence (943569f) exposed a dedup key that
was too narrow: it included the protocol, which was harmless while only
listeners counted, but a busy host has many established sockets on the same
service port, and tcp vs tcp6 already made a single listener look like two
services.
The tester's domain controller listed "RDP :3389" four times and LDAP four
times. Since each additional entry adds 40% of its weight, the exposure score
inflated to 100 for what is one RDP and one LDAP service. Deduplicating by
port alone fixes both: one service, one entry, one weight.
Also feeds Recent Critical from four narrow server-side queries (critical,
high, KEV, EUVD) instead of filtering the 300 newest rows in the browser. A
batch of low-severity CVEs — Chrome publishes dozens at once — fills that
window completely and empties the widget, which no amount of extra depth
fixes; only filtering before the limit does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Edge false positive survived every scan because its own reconcile could
not see it. Whichever scanner creates a finding owns package_name, and the app
scan writes the inventory's wording — "Microsoft Edge" — while the MSRC
reconcile filtered on the exact curated label, "Microsoft Edge
(Chromium-based)". The row matched nothing, so it was never even considered
for closing: the tester's host ran .105, long past the .99 fix, and the
finding stayed open through repeated app scans and MSRC runs.
Findings are now matched to a product the way the SCAN matches them — through
resolve_package / resolve_os — instead of by exact label. The exact-name path
stays for rows this scanner wrote itself, and the OS pass keeps working
because resolve_os is tried too; without that, switching to package matching
alone would have stopped every Windows OS finding from closing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unrelated defects, both found on the same test estate.
MSRC states the Teams fix as 25060212043 — one eleven-digit stamp — while
Teams itself ships 26183.1003.4002.4460. Python compares those tuples element
by element, so 26183 < 25060212043 came out True and a current Teams was
reported vulnerable (CVE-2025-49731). Builds with different segment counts are
not the same numbering scheme and no ordering between them means anything, so
they are no longer compared at all. The product name filters were NOT at
fault here: "Microsoft Teams Meeting Add-in for Microsoft Office" and
"Microsoft Teams VDI Dim-plugin" both resolve to nothing, as intended.
Separately, both port-based scorers required a LISTENING socket, and Wazuh's
syscollector does not always report one. On a Windows Server 2025 domain
controller netstat showed 3389 ESTABLISHED while syscollector returned no
listening entry at all, so RDP scored zero exposure and the DC role went
undetected on a live domain controller. An ESTABLISHED socket whose LOCAL
port is a known service port is an inbound connection, which proves the
service is running just as well. Outbound connections carry an ephemeral
local port, so they cannot be mistaken for one — and only ports that are
actually asked about qualify, never an arbitrary high port.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
max_tokens was 2000 for every call. Ten prioritised findings with a
justification each, plus the strategy paragraph, does not fit — the reply was
cut off mid-JSON, the parser failed, and the audit reported "no
recommendations" with nothing indicating the answer had simply run out of
room. Reasoning models never stood a chance: deepseek-reasoner spends that
same budget thinking before writing a character. Raised to 8000.
Truncation is also no longer silent. finish_reason "length" (stop_reason
"max_tokens" on Anthropic) is logged with the provider, model and limit, so
the next occurrence names itself instead of looking like an empty answer.
And the JSON is now requested through response_format on the providers that
honour it (OpenAI, DeepSeek, OpenRouter, Groq, Mistral) rather than hoping
the prompt talks the model into valid syntax. Providers that would reject the
field are not sent it.
Both call paths also stopped assuming the response shape: an empty choices or
content array raised IndexError inside the client, surfacing as a generic
500 rather than as the empty answer it was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running an AI Audit that produced no recommendations looked exactly like
never having run one — "No AI recommendations available yet. Try running an
AI Audit above." — which is the opposite of what happened and leaves nothing
to act on.
The backend already handles a model that ignores the JSON schema: it puts the
raw reply in global_strategy and returns an empty list. The empty-list branch
ran first and swallowed it. That reply is now shown, labelled as what it is,
with the hint that weaker models fail this way.
The error hint was stale too. It told everyone to check Infomaniak settings
and set the model to 'llama3' — wrong advice on DeepSeek, OpenAI or
Anthropic, pointing at a setting that was already correct. It now points at
the AI Integration screen and at verifying the model still exists.
Also dropped the window.location.reload() that sat on the empty state: it
threw away the result the user had just waited for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
01cf91f fixed the wrong entry point. There are two Wazuh sync
implementations, and the one behind the "Sync Data" button —
run_wazuh_vulnerability_sync — carries its own copy of the same guard, which
I left untouched. So the fix changed nothing for the operator: the tester
stopped the agent, restarted it, ran a sync, and all 348 Windows OS findings
stayed open with wazuh as their only source.
The collective sync now defers empty agents the same way and reconciles them
once the run is over, when the total CVE count proves whether the API was
answering at all. Findings closed this way are counted in vulns_patched, so
the result message reports them instead of silently showing "Patched: 0".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ordering was inverted at the top. `ORDER BY cvss_score DESC` puts NULLs
first in Postgres, so every finding WITHOUT a score led the list — and fresh
Chrome CVEs carry no CVSS at all. The model was handed those as the most
urgent items, ahead of a scored 9.8, which is exactly the "wrong order"
showing up in the audit. It now ranks by the scores the product computes for
this question (priority, then CPR, then CVSS), nulls last.
Scope was wrong too: the query hit the table raw, so it ranked findings on
decommissioned assets and treated EOL- pseudo-CVEs as vulnerabilities. It now
shares the scope rule the reports use.
The model also could not do the job it was asked to do. It received CVE id,
hostname, CVSS and package name — less than the dashboard shows — with no way
to see known exploitation (KEV/EUVD), exploit probability (EPSS), how long a
finding has been open, or whether a fix even exists. Those are what make a 7.5
outrank a 9.8, so they are in the payload now, and the prompt says how to
weigh them instead of leaving it implied.
Two smaller mismatches: the prompt asked for "the top 10" while limit
defaulted to 20 — so the model was told to discard half its input, and at
limit<10 it was asked for more items than it was given. And the same CVE
across 30 hosts filled the list with one problem thirty times; entries are now
deduplicated by CVE, with over-fetch so the dedup does not starve the list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model dropdown was a hand-written list per provider, so it was wrong the
day after every release: DeepSeek showed V3 while V3.2 was current, Anthropic
still seeded claude-3-5-sonnet-20240620, Gemini seeded 1.5-pro. Worse, picking
a model the provider had since retired failed only later, at generation time,
with an error that pointed nowhere near this screen.
A "Load models" button next to the dropdown now asks the provider what it
serves right now — OpenAI, DeepSeek, OpenRouter, Groq, Mistral (all
OpenAI-shaped /models), Anthropic, Gemini, and Ollama's local /api/tags. The
live list replaces the static options once loaded; the static ones remain as
the fallback for providers with no listing endpoint and for before the first
fetch, and the per-provider defaults are now clearly seeds, not choices.
The key is sent in the request body, never a query string — URLs end up in
proxy and access logs. An empty key falls back to the saved one so the list
loads without retyping it, and provider errors are surfaced verbatim (401
reads as "rejected the API key", not a generic failure). Switching providers
clears the list so one provider's models are never offered under another.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every export queried the vulnerabilities table raw, so all four disagreed
with the dashboard they were exported from, in the same three ways:
- Findings on decommissioned and inactive assets were counted. Those hosts
are deliberately hidden on screen, and the gap grows with every machine
retired.
- EOL- and NESSUS-PLUGIN- pseudo-CVEs were counted as CVEs. They are real
work items but they are not vulnerabilities, and they have their own
surface in the UI.
- The executive summary counted severities across ALL statuses, so a
remediated estate still reported hundreds of "Critical Severity" — the
patched ones. A reader takes those as outstanding work. They are now
explicitly labelled and scoped to open findings.
"Top Priority Risks" was the worst of it: a LIMIT 5 with no ORDER BY, so the
database returned any five critical rows it liked under a heading promising
the five that matter most. It now ranks by priority score, then CVSS.
Two exports could also take the server down on a large estate. Patching
Progress put every patched finding of the last 30 days in the table — one
reconcile here closed 14703 at once — and the ISO report loaded every
non-compliant finding into memory just to call len() on it, while printing
20. Both now count in the database and list a bounded, ordered page. The CSV
streams in batches instead of materialising the whole file first.
The scope rule lives in app/services/report_scope.py so the reports cannot
drift apart again, and so it can be tested without the web stack —
tests/test_report_scope.py asserts it against the emitted SQL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The widget judged criticality on the score alone, so a CVE without a CVSS
could never qualify no matter how severe. Chrome CVEs are exactly that case —
Google ships no metrics block and states "Chromium security severity:
Critical" in prose, and NVD frequently never scores them at all. The tester's
dashboard read "No data" under Recent Critical while a batch of fresh Chrome
CVEs sat right next to it in Newly Published.
Where a CVSS exists it still decides, and severity is kept in sync with it.
Only when there is no score does the vendor's own severity get a say. Reading
the severity out of Chrome's prose landed in 6b3744e; this is the half that
makes it visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'Enterprise Security' says nothing; 'Vulnerability Management' says what the
tool is. The full 'Enterprise Vulnerability Management' overflows the 288px
sidebar at this tracking, so the tagline drops the marketing word rather than
wrapping to two lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New pairs change what the index CONTAINS, and a cached index built by the
previous version has no checkmk bucket at all — so the scan would have kept
serving a stale index and found nothing until the key changed anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Checkmk agent was invisible to every path, and the reason was one
character: it numbers patches inside the version string (2.4.0p12), which the
dotted-numeric rule in _clean_version rejected outright, so the version was
discarded before any source was consulted. Wazuh does not detect it either
(wazuh/wazuh#35646), which left it a complete blind spot.
Nothing else had to change — _vtuple already reads 2.4.0p12 as (2,4,0,12), so
the comparisons were correct all along. Both sources state their bounds in the
same notation (lessThan "2.4.0p13"), and both are now wired up: the cvelistV5
pairs for the branch-bounded ranges, the NVD CPE registry entry as the second
route.
The exclusion that made the rule strict stays intact: Linux distro versions
(1:3.2, 4.6.5-3.el8, 2.43.0.windows.1) are still rejected — they belong to
Wazuh and pushing them at NVD produces noise. tests/test_checkmk_version.py
covers both halves, including that an older release branch answers only for
itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A host that Wazuh no longer reports any CVE for kept every finding it ever
had. The tester's ise-dc01 showed 0 Critical / 0 High / 0 Medium in Wazuh
while 348 findings sat open here, all "first detected by WAZUH" — the app
scan could not help, since those CVEs are Wazuh's alone.
The cause was a guard doing its job too bluntly. An empty answer means one of
two opposite things — the host really is clean, or Wazuh could not answer —
and the per-agent sync cannot tell them apart, so it refused to close
anything rather than risk mass-patching an estate from a transient blank.
The distinction exists one level up: a run in which OTHER agents returned
CVEs proves the API is answering, which makes this agent's emptiness real.
So the empty agents are now collected during the run and reconciled at the
end, once that is known. If EVERY agent comes back empty, that is an outage
pattern, not an estate that patched itself overnight — nothing is closed, and
it is logged as such.
Findings another scanner still reports keep their other sources and stay
open; only Wazuh's own claim is withdrawn. The single-asset sync keeps the
old careful behaviour: one agent alone says nothing about the API's health.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome CVE records carry no metrics block whatsoever — Google puts the
severity in the description instead: "… (Chromium security severity:
Critical)". Every fresh Chrome finding therefore landed on the neutral
'medium' placeholder, so a Critical sandbox escape sorted level with a Low UI
glitch, both in the queue and in the daily digest mail. For a team that
triages by severity that is worse than no data.
The severity word is now read from the description when no metrics block
exists. No score is invented — only what the vendor stated, and a real CVSS
always wins over the prose. Once NVD publishes a score, the existing
CVSS-sync takes over as before.
Existing rows heal on the next scan: the upsert already lifts a finding off
the medium placeholder when a source reports a better severity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Citrix published-app delivery leaves a registry stub for software that is NOT
installed on the box: a self-chosen product name and a placeholder version
("SAP Business Client 1.0", vendor "Delivered by Citrix"). A 1.0 reads as
ancient against any CVE data, so whatever matches it reports decade-old CVEs.
The filter existed, but each scan path applied it for itself — so every path
added later started out unfiltered. Three had it, four did not, including the
SAP path added yesterday, which is how a published-app stub turned into a
finding again.
Filtering the fetched inventory once, before any path sees it, is the only
version of this that stays true as paths are added. The EOL check fetches its
own inventory and now filters it the same way; a placeholder 1.0 reads as
end-of-life there too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MDE allows roughly 100 calls a minute and the vulnerability walk makes one
call per machine, so any sync of real size runs into 429 — the more so when
an app scan is running alongside it, which is exactly what the tester did
before seeing a wall of "429 Too Many Requests".
The client treated 429 as a hard error, so one throttled call threw away
everything the sync had left to do. A 429 is "come back shortly", not a
failure: it now retries, waiting as long as Microsoft's Retry-After header
asks and backing off geometrically (capped at 60s) when the header is missing
or unparseable.
The Graph client already did this for Intune; only Defender was missing it,
which matches the log — every throttled call came from
api.securitycenter.microsoft.com.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NVD enumerates one CPE per SAP patch level, which can only ever describe the
levels that existed when the record was written — a host on a newer level
falls through. cvelistV5 states the bound instead ("7.70 PL0" ..
"7.70 PL11"), so it keeps answering as SAP ships more levels.
The records contradict themselves, and the resolution is the whole point of
this change: CVE-2023-32113 carries BOTH "<= 7.70" (the entire release) and
"7.70 PL0".."7.70 PL11". A host on 7.70 PL26 is affected by the first
statement and patched by the second. The patch level is the precise one, so
it decides for its own release, and the release-wide bound only answers for
releases that have no patch-level entry at all. Entries are therefore weighed
per CVE, not row by row.
Both vendor spellings are matched — records days apart say "SAP SE" and
"SAP_SE".
Index key bumped to v10 (new sap bucket). tests/test_sap_cvelistv5.py pins
the contradiction, the fallback, and the unreadable-level case against the
verbatim version objects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the real syscollector rows in hand the level-only match turns out to be
a false-positive generator. Every SAP release carries the same patch level
NUMBERS — NVD lists gui_for_windows 7.70:patch_level17 right next to
8.0:patch_level1 — so the tester's host (SAP GUI for Windows 8.00 64bit,
Patch 17) matched every 7.70 CVE that happens to have a patch_level17 entry.
The release is now compared as well, and numerically: Wazuh reports "8.00"
where NVD writes "8.0", so a string compare would have rejected the host's
own release.
The parser is confirmed against the verbatim inventory strings, which differ
per product — Business Client states the level in the version field
("8.00 PL26") while SAP GUI states a compilation there and puts the level in
the NAME ("SAP GUI for Windows 8.00 64bit (Patch 17)"). Both are pinned in
the test, along with "Compilation" not reading as a level despite containing
the letters p-l.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was never in scope — it came in from the Wazuh issue's product list — and
it does not fit the patch-level model the other two SAP products use: AfO
ships 2.8.x builds and states no patch level anywhere, so the match could
never fire for it.
Worse is what NVD says about it. CVE-2021-38175 lists
`cpe:2.3:a:sap:analysis_for_microsoft_office:2.8:*` with a wildcard update
field and no range whatsoever, so any 2.8 build would count as affected
forever, no matter how many patches it has. cvelistV5 carries the honest
bound ("< 2.8") but the cvelistV5 path has no SAP pairs yet, so there is no
correct source to scan this product with today.
A test now pins that a wildcard update field never matches, for any patch
level — the guard that keeps this class of CPE from becoming a false positive
if another SAP product is added later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SAP GUI, Business Client and Analysis for Office were a complete blind spot:
cvelistV5 carries no CPE for them, and Wazuh misses them too
(wazuh/wazuh#30334), so nothing in the pipeline ever looked at them.
NVD has the data, but not as a range — SAP enumerates ONE CPE per patch
level in the update field (cpe:2.3:a:sap:gui_for_windows:7.70:patch_level4),
verified against CVE-2023-32113 with 15 entries and CVE-2021-38150 with 68.
So "affected" is set membership, not a comparison, and the existing range
check cannot express it: it compares the base release, which is identical for
a fully patched host and an unpatched one.
The level is read from either inventory string ("7.70 PL 12" in the version
field, "Patch 4" in the product name — both spellings appear in the Wazuh
issue). When no level can be read the product is SKIPPED, not scanned: the
base release alone matches every CVE ever filed against it, so one unreadable
string would otherwise produce a page of false positives. The patch level is
part of the cache key, so PL 12 can't be served PL 3's answer.
tests/test_sap_patch_level.py pins both halves against the real criteria
strings, the unknown-level case included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Defender TVM reports the CVE, never the build it sits on, and for an
OS-level CVE it omits the software label entirely. Those findings rendered
with an empty package and an empty "Installed" — reading as "we know
nothing" while the asset record had carried the OS and its version all
along (tester: CVE-2026-64726 across the iPhones).
Fall back to the asset's own OS and os_version when Defender names no
software. Existing rows are filled only where the column is still empty, so
a scanner that actually inspected the software keeps the last word.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same failure the EOL check had before dabec89: the scan ran inside the
request, so a full estate — plus the cvelistV5 and MSRC index builds on first
use — outlived the browser's HTTP patience. The GUI showed "Backend
connection failed" while the backend ran happily to completion, with no way
to tell whether anything had happened.
POST /app-cve-scan/start spawns the run on its own DB session and returns 202;
GET /app-cve-scan/status reports running/result/error, and the button polls it.
Starting a second run while one is active attaches to the running job instead
of queueing a duplicate. The synchronous POST /app-cve-scan stays for API
clients.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple OS findings could only ever come from NVD, and NVD's Apple enrichment
lags: CVE-2026-28911 sat at "UNDERGOING ENRICHMENT" with no configuration at
all, so the CPE path had nothing to match and the cvelistV5 path refused
every non-Windows asset outright. Both iPhones and Macs therefore showed
these CVEs only where Defender happened to report them — with no package or
installed version, since Defender carries neither.
cvelistV5 has the data on day one (Apple / macOS / lessThan 14.8.8), so the
OS scan now reads it there too.
Apple states each parallel release train as its own ZERO-floor range on the
same CVE — CVE-2026-64721 carries lessThan 14.8.8, 15.7.8 AND 26.6 — so a
Sonoma 14.7 host matches all three at face value and the finding would claim
"fixed in 26.6", an upgrade that host will never receive. The major version
picks the train, the same role _win_family plays for Windows builds.
tests/test_apple_os_train.py pins that behaviour.
Index key bumped to v9; the cached index has no apple-os bucket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CVE-2026-15120 is a Chromium CVE that Edge ingests, so the CPE path wrote
Chromium's fixed build (150.0.7871.114) onto the finding first. MSRC — the
only source that knows the Edge build that actually ships the fix
(150.0.4078.65) — could never correct it: fixed_version was written
fill-only.
A host on Edge 150.0.4078.83 was therefore patched weeks ago and still showed
OPEN, because .83 compares below .7871.114. Verified against the CVRF: no
MSRC document carries a 150.0.7871.* FixedBuild for any Edge product, so the
value could only have come from the Chromium side.
MSRC is authoritative for its own products' fixed build, so it now corrects
the row — the same reasoning that already made the installed version a
refresh instead of a fill-only write, one field over. The reconcile closes
these findings on the next scan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "via …" line under an affected package was missing or wrong in three
constellations, so a finding's provenance could not be read off the detail
page:
- A package confirmed by two scanners kept only the one that wrote first —
record_affected_package never merged `source` on an existing row.
- Findings that only carry the parent summary (OS-level rows, pseudo-CVEs,
data written before per-package tracking) fell into a fallback block that
renders no source at all.
- Rows with a NULL source rendered nothing instead of falling back to the
finding's own sources.
Package sources are now cumulative ("app-scan,msrc", widened to VARCHAR(60)
in migration 039), the API synthesises a package entry from the parent
summary when no child rows exist, and the UI renders every source as a chip
— never blank.
Also makes HOST / PACKAGE / ASSIGNED TO sortable; the assignee sorts by
username, not by id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same host, two findings, opposite outcomes — the tester's screenshot:
CVE-2026-50518 first seen by APP-SCAN package "…Server 2016 Datacenter" -> patched
CVE-2026-49798 first seen by WAZUH package "…Server 2016 Datacenter 10.0.14393.9234" -> still open
Three writers produce three spellings of package_name for the SAME OS
finding: the MSRC catalogue name, the asset's OS string (app scan), and the
asset's OS string with the build appended (Wazuh). The previous fix covered
the first two by exact match, so Wazuh-created rows still never matched and
kept msrc as an unreachable last claimant.
resolve_stale_os now walks the asset's open msrc rows itself: exact match on
the labels PLUS a prefix match on the asset's own OS string, which catches
anything appended to it. The prefix is safe because it uses the FULL OS
string — "…Server 2012 Standard" cannot prefix "…Server 2012 R2 Standard" —
while the MSRC catalogue label is deliberately not used as a prefix, since
"Microsoft Windows Server 2012" would. Non-OS products on the same host
(SharePoint, Chrome) are unaffected. Closing is audited with host and build.
Root cause, visible in the tester's screenshots: the asset inventory shows
Server 2025 on 10.0.26100.33158 and Server 2016 on 10.0.14393.9339 — both
exactly the fixed build — while the findings still read installed .32995 /
.9234 and stayed OPEN with msrc as the only remaining source.
_resolve_stale matches package_name EXACTLY against the MSRC catalogue
label (Microsoft Windows Server 2025). But an OS finding first seen by the
app scan or Wazuh carries the ASSET's OS string instead (Microsoft Windows
Server 2025 Standard, ... 2016 Datacenter), because cvelistv5.scan_asset_os
labels rows with asset.operating_system. So the query never returned those
rows: the other scanner retracted its source once the host was patched,
msrc was left holding a claim it could not reach, and the finding stayed
open forever.
_os_labels() now considers both spellings — the MSRC label AND the asset's
own OS string. Deliberately exact strings, not a prefix match, since
Microsoft Windows Server 2012 prefixes ... 2012 R2 and a prefix rule would
close a different release's findings.
1) MSRC OS findings never closed after a host was patched. The OS scan AND
its reconcile both live in run_msrc_scan (its own nightly job / button),
while the app scan only ever called the PACKAGE pair — so pressing
'App CVE Scan' after patching left every MSRC OS finding open and looked
like nothing had happened. The app scan now runs the OS scan plus the new
resolve_stale_os(), scoped to the asset's own OS product label so it
cannot touch SharePoint/Edge findings it never evaluated.
2) MSRC wrote package_version fill-only, so a finding kept the build seen at
first detection and never showed the host's current one — the same defect
the app scan had with Firefox ('Installed 150.0.3'). Now refreshed on
every re-detection, like every other scanner.
Java was in no scanner registry at all — a complete blind spot. NVD leaves
these CVEs 'Awaiting Enrichment' (no CPEs, no affected block), so cvelistV5
is the only structured source, confirmed across CVE-2026-60526 / -21925 /
-47057 / -62574.
Three things Java needs that no other product did:
- Identity via BOTH routes the records use: the vendor/product block
('Oracle Corporation' / 'Oracle Java SE') and the CPE product
(oracle:java_se, plus the older oracle:jre / oracle:jdk — Java SE IS the
JRE, and the same CVE can be filed either way).
- Version from the NAME, not the field: Wazuh/Intune report 'Java 8 Update
441' with an ARP build of 8.0.4410.7, which maps to nothing. Parsed to
(feature, update); '1.8.0_471' and '8u491' spellings both handled.
- Bare affected versions get indexed. Oracle states 'version: 8u491,
status: affected' with no range, so the normal range extractor yielded
nothing for these records.
Matching rule is CUMULATIVE — installed <= affected, within the same
feature release. Oracle only names the current supported update, but older
ones carry the same flaw; validated against Defender TVM, which reports
CVE-2026-62574 (affected 8u491) on hosts running 8u102 and 8u191. Java 11
is never matched by an 8u CVE.
Verified against the live CVE-2026-62574 record: 8u102/8u191/8u441/8u491
all flagged, newer safe. Index bumped to v8 so the new key is picked up.
Tester: MSRC-only Edge findings sat at MEDIUM while showing CVSS 9.6 /
8.8 / 8.3. Sources without a score seed a neutral placeholder (MSRC
publishes a CVSS for only a minority of its CVEs), and whatever filled
the score afterwards — the correction cascade, sibling inheritance —
never revisited the severity. App-scan-detected rows looked right because
they arrive with a score in the first place.
Vulnerability.sync_severity_with_cvss() derives severity from the score
and is called by refresh_scores(), which every sync/enrich/override path
already invokes, so the fix applies everywhere rather than in one
scanner. Rows whose severity an operator pinned manually are left alone.
Neither README mentioned anything added in this round. README.md now
covers the CVSS-correction cascade (Vulnrichment → NVD → cvelistV5 →
GHSA) and NVD_API_KEY, Mozilla MFSA severity, sibling metric inheritance,
EOL severity scaling by days-past-EOL plus the compliance mapping and the
background job, Microsoft Edge via MSRC (and why Chrome data must never
score it), GitHub repository advisories, multi-product findings, the
vendor column, silent token refresh and the two-way audit trail.
README.DEV.md gains a developer section on the three non-NVD sources, the
one-finding-many-products model and why it is not two rows, the
cross-source contract with the four false-negative classes that got the
inventory override removed, the ACTIVE-asset guard, the background-job
endpoints, the github_pat setting, and the theme layer's un-layered CSS
trick including the opacity/arbitrary-value gotcha.
The EOL check ran synchronously, so on a full estate it outlived the
browser's HTTP patience — the tester saw 'Backend connection failed'
while the backend was still happily working, with no way to tell whether
anything had happened.
Now POST /eol-check/start spawns the run on its own DB session and returns
202 immediately; GET /eol-check/status reports running/stage/done/total
plus the final stats or the error. The per-asset loop updates the progress
counters, so the button shows 'N/M assets — hostname' while it works and
the final summary (including how many stale MS-lifecycle findings were
closed) when it finishes. Starting a second run while one is active
attaches to the running job instead of queueing a duplicate.
The synchronous POST /eol-check stays for API clients and small installs.
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.
Tester: CVE-2026-16423 showed CVSS 8.8 on the Google Chrome row but '-'
and priority 0 on the Microsoft Edge row of the same CVE. CVSS/EPSS/KEV
are properties of the CVE, not of one host, so the two must agree.
Cause: only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, and
Edge CVEs are absent from NVD/cvelistV5, so an MSRC-only finding often
has no score to enrich from — while the same CVE on another asset already
had one. apply_canonical_from_siblings() exists for exactly this and was
wired into the Wazuh and Nessus syncs only.
Call it on newly created findings that have no score, in both the MSRC
and app-scan create paths (which also covers the GitHub repo-advisory
findings, since those upsert through app-scan).
The index rebuild worked (log: 18 docs, 10 products, 11154 fix entries)
but the very first Edge INSERT died on NotNullViolation — severity is NOT
NULL and the MSRC create path never set it. Until Edge joined, MSRC
practically always hit the existing-row branch (Windows/SharePoint
findings were already there from other scanners), so the create path
rarely ran and the missing column went unnoticed. One failed flush poisons
the session, so the whole scan 502'd.
Fixing it with a neutral placeholder alone would have been wrong here:
Edge CVEs are in NEITHER NVD NOR cvelistV5, so the enrichment cascade can
never fill the score in later — every Edge finding would sit at
'medium / no score' forever. MSRC does publish both, so the index now
carries CVSS base score, vector and Microsoft's own severity word
(Critical/Important/Moderate/Low → critical/high/medium/low, falling back
to the score, then to medium). Applied on create and backfilled onto
existing rows that still lack a score.
Also imports VulnerabilitySeverity in _upsert, which was not in scope.
Tester added Edge support, ran App-CVE-scan + MSRC refresh, and still saw
CVE-2026-16417 attributed to Google Chrome only — with the MSRC Edge fix
build (150.0.4078.96) already showing in the Remediation panel, so the
data was there but no Edge FINDING existed.
Cause: the product index is cached under a fixed setting key and served
with allow_stale=True. Adding Edge to _PRODUCTS changes what the index
CONTAINS, but the cache built by the previous version has no edge key at
all — and load_index happily returned it. Worse, when no index exists the
app scan just skipped every MSRC product instead of building one.
Bump the key to msrc_product_index_v2 (retires the stale cache on deploy)
and build the index on demand in the app scan when it is missing, matching
what the cvelistV5 path already does.
1) The per-package helper added in 24eebd3 crashed the whole App CVE scan
with a 502: a plain query does not see rows added earlier in the SAME
unflushed transaction, and one run legitimately records the same
(finding, product) twice — the tester's log shows vulnerability 68148
getting 'Microsoft Edge' twice in one batch — so the second INSERT hit
uq_vulnpkg_vuln_package and the IntegrityError took the request down.
Now: also scan db.new for a pending row (update it instead of adding a
second), wrap the insert in a savepoint so a lost race rolls back only
that statement, and swallow any remaining error — this is display data
and must never break a scan.
2) The App-CVE-scan confirm dialog still described only OSV / NVD-CPE.
It now names what actually runs (OSV/NVD, cvelistV5 incl. Windows OS
builds, MSRC fixed-builds incl. Edge, GitHub repo advisories, M365,
endoflife.date) and answers the tester's question explicitly: every
asset with Wazuh OR Intune inventory is scanned, not just Wazuh ones.
Tester: CVE-2026-16417 lists only google:chrome CPEs at NVD/cvelistV5, but
MSRC documents Microsoft Edge as affected too (fixed build 150.0.4078.96).
On a host running BOTH browsers unpatched, two different products are
genuinely affected by the same CVE — and their build schemes are unrelated,
so neither version can stand in for the other.
A finding is unique per (cve_id, asset_id) — index uq_vuln_cve_asset — so
the answer is one finding carrying BOTH products, not two rows: splitting
would break dedup, source reconciliation and every count. The per-package
table (vulnerability_packages) exists for exactly this and was only ever
written by the Wazuh sync, so app-scan and MSRC findings showed whichever
product happened to be written first.
New audit_events.record_affected_package(); wired into both upsert paths
of app_cve_scanner_service and msrc_scan_service (create + existing).
Idempotent per (finding, package name) — re-detection refreshes version,
fix and last_seen instead of appending duplicates. The API already returns
these as 'packages' and the CVE detail page already renders one card per
entry, so Chrome and Edge now appear side by side with their own installed
and fixed versions.
Tester verified that Edge CVEs (CVE-2026-57989/-57990/-57978, published
2026-07-24) exist in NEITHER NVD ('CVE ID Not Found') NOR cvelistV5, while
MSRC carries CVSS, severity and the fixed build. The July CVRF alone lists
436 Edge CVEs with a FixedBuild, so no other scanner path could ever see
them.
Add Edge to the curated MSRC product registry; the existing package scan
and reconcile pick it up unchanged. branch=False because Edge servicing is
cumulative (plain installed < fixed); a newer major compares greater and
drops out on its own.
Two guards the tester's screenshots motivated:
- Edge is never scored off Chromium/Chrome data — the builds diverge
entirely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187), so a
google:chrome range says nothing about an Edge build.
- WebView2 excluded from the match: it ships as its own package with its
own version (that host had Edge .83 beside WebView2 .99).
Verified against the live CVRF: Edge 150.0.4078.83 → 18 findings (the ones
genuinely still open), current .99 → 0, a future 151.x → 0.
A disconnected Wazuh agent still serves its LAST STORED syscollector data,
so the app scan 'succeeds' on data that can be months old and then closes
findings from it. Wazuh's own vuln sync only walks active agents, so
nothing ever reopens them — the tester's disconnected host ended up with
zero CVEs in TrueVuln while Wazuh still listed 31 for it. The app-scan
reconcile now runs only when the asset is ACTIVE; skipped ones are counted.
Sync result message now reports what actually happened (CVE reports seen,
created / updated / reopened / confirmed / patched) instead of three
counters that were mostly structurally zero.
1) REMOVED the inventory-based override that closed findings over a lone
Wazuh claim. It produced false negatives in four distinct ways, one
surfacing per test round: multi-stream fixes (MySQL 8.0.23 vs stored
7.6.34), 2.x minor servicing lines (Git 2.49.0 vs 2.41.7), MSI build
numbers in the version field (Python 3.13.7150.0 vs fix 3.13.10), and
stale inventory from disconnected agents. Each guard fixed one class
and the next round found another — the premise is wrong: a single
stored fixed_version cannot validate an arbitrary inventory version
string. Back to the strict contract: every scanner retracts only its
OWN source, and a finding closes when no source is left. False
negatives are worse than a finding that lingers.
2) Python now uses name_ver: Windows reports the MSI build (3.13.7150.0)
in the version field while the semantic version (3.13.7) is in the
display name, so real Python CVEs never matched NVD's ranges.
3) Wazuh sync counters were misleading — 'Updated' counted ONLY a CVSS
score change, so a run that reopened findings and refreshed versions
still said 'Created: 0, Updated: 0, Patched: 0'. Now any real field
change counts as updated, reopens get their own counter, and
unchanged-but-confirmed rows are counted too.
1) The major-only branch guard shipped in a74c229 was too weak for 2.x
projects, where the servicing line lives in the MINOR: Git 2.49.0 vs
the stored fix 2.41.7 share major 2, so the finding was still closed
wrongly (2.49.0 is inside '>= 2.49.0-rc0, < 2.49.1' — the real fix for
that branch is 2.49.1). Guard now requires the same major.minor.
Re-checked against every case seen so far: Git/MySQL/Suricata stay
open, Edge/Notepad++/Chrome single-stream cases still close.
2) Status-change audit rows named the CVE but not the system, so a reopen
couldn't be attributed or found by host. log_vulnerability_change takes
an optional hostname, puts it in the description ('… → open on HOST')
and in the JSON payload; reopen_if_patched resolves it from the
finding's asset. The description is what the audit-log search matches,
so host search now works for these rows.
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).
Closes a real gap the tester chased down: Notepad++ CVEs (CVE-2026-57233,
-54758, -52886, …) are in neither NVD nor cvelistV5, so no scanner path
could see them. They exist only on the project's own GitHub advisories.
My earlier assessment ('GHSA carries no version data') was based on the
GLOBAL advisory API, whose unreviewed entries are bare NVD mirrors. The
PER-REPO endpoint is a different source: the maintainer publishes
vulnerable_version_range + patched_versions there. Verified live against
notepad-plus-plus: 18 advisories, all 18 with a range, 12 with a CVE id,
9 with CVSS.
New github_repo_advisory_service: product→repo registry (one line per
product), 24h cached index, optional github_pat. Range parser handles the
maintainer free-text seen in the live feed ('<= v8.9.6.4', '< v8.9.6.4',
'<=8.9.1', 'old versions - 8.8.1', 'v8.9.4 & v8.9.5', bare versions) with
patched_versions as a hard safety net — at/past the patched release is
never flagged. Explicit version lists match exactly, never 'everything
below'. Findings are written through the app-scan upsert, so the existing
reconcile auto-resolves them on update; no new source to maintain.
End-to-end against the live feed: 8.9.6.4 → 2 findings, 8.9.6 → 6,
8.5.0 → 9, patched 8.9.7 → 0.
Published-date-desc feeder returned the newest 300 per-asset rows, mostly
low/medium; after the CVSS≥8/KEV/EUVD filter only 2 criticals survived.
Same starving Newly Published had — same cure: distinct_cve=true collapses
per-asset duplicates server-side and the deeper window (300) reliably
fills the 10 slots.
1) Windows-OS CVEs stuck open: only the separate ASSET sync refreshed
asset.operating_system/os_version; the Wazuh VULN sync (the flow that
actually runs regularly) never did. So scan_asset_os kept judging OS
CVEs against a stale build — tester: Server 2016 host already on
.9339 (the fix build per Wazuh dashboard) while findings showed
installed .9140 and stayed open. The vuln sync now refreshes OS
name/build from the agent record; the existing stale-reconcile then
closes the findings on the next app scan.
2) Recent Critical CVEs widget pinned the same old 2021 KEV heavyweights
(priority-sorted, recency ignored). Feeder now sorts by CVE published
date desc (filter unchanged: CVSS ≥ 8 or KEV or EUVD), same-day ties
broken by CPR desc. Subtitle + View-All link match.
Tester (dark theme): dashboard widget headers ('Recent Critical CVEs' …)
and the sticky vulnerabilities list header stayed light-gray under light
text. Cause: opacity and arbitrary-value utilities are their OWN class
names — bg-gray-50/50, bg-[#F3F4F6]/95, bg-white/50 — which the plain
.bg-gray-50/.bg-white remaps never touch.
Add escaped-selector remaps for those, kill the pastel indigo gradient on
the AI widgets (background-image none + dark surface) with matching
indigo text/border/badge tints, bg-base-200, and the group-hover blue
tint on the sticky actions column. Dark-theme autofill now carries
!important + caret-color so it actually beats the light-scheme autofill
rule (which is !important). Verified via computed styles in the browser:
all previously-light classes resolve to dark surfaces in mid/dark.
Tester: CVE-2026-16395 flagged on 'Mozilla Firefox 52.2.1 ESR' — the
cvelistV5 record carries only 'unaffected 153, lte *' (release train),
and per mfsa2026-69/-70 no ESR branch is affected at all. Both matchers
(cvelistV5 registry + curated CPE registry) resolved the ESR package to
the plain firefox key, so ESR builds were judged against release-train
ranges — structurally wrong (NVD tracks ESR as its own firefox_esr CPE;
Mozilla ships separate ESR advisories).
Guard both resolve() paths: a package whose name carries the ESR word
never resolves to the firefox key. ESR patch state would need the MFSA
fixed_in data (already stored in the mozilla_advisory index) — until
that's wired, no match beats a false positive. Existing FP rows
auto-close on the next app-scan via the stale reconcile.
Tester: Edge CVE-2026-58525 stuck open showing installed 150.0.4078.48 /
fix .50 while the live inventory already had .83. Chain: app-scan
correctly dropped its source on re-scan, but Wazuh's VD states index
re-evaluates lazily and kept claiming the CVE (with the OLD version), so
the wazuh-only row stayed open indefinitely.
Inventory-verified override in the app-scan reconcile: when a stale row's
only remaining source is wazuh AND the CURRENT inventory shows the
finding's package at/past its fixed_version, close it over Wazuh's stale
claim (audit reason says so). Conservative: needs a parseable fix AND a
name-matching package with a parseable version; Linux rpm/deb versions
never parse → those stay Wazuh's call. Reconcile query now also selects
wazuh-only rows (previously filtered on app-scan source, so a row that
lost app-scan in an earlier run was never revisited). Name→version map
built once per asset.
The previous commit added the theme button to shell/Header.tsx, which is
imported NOWHERE (dead component — the app shell renders its own top bar,
desktop has none at all), so the tester couldn't find any button.
New ThemeSwitcher: segmented Light / Mid / Dark control at the bottom of
the sidebar (desktop sidebar + mobile drawer, above the user box) —
always visible on every page. Dead Header.tsx deleted so the next change
doesn't land there again.
The old theme toggle only flipped daisyui variables, which the hardcoded
light utility classes (bg-white, text-gray-900, …) never react to — so
it visibly did nothing. Instead of rewriting every page with dark:
variants, add an UN-layered override block in globals.css that remaps
the light-surface utilities (plus tinted badges, borders, inputs,
scrollbars) under html[data-theme='mid'|'dark'] via CSS variables —
un-layered author CSS beats Tailwind's @layer utilities without
!important. Sidebar (bg-gray-800/900) and brand colors intentionally
untouched.
useTheme now cycles light → mid → dark (legacy 'corporate'/'dark'
values migrate), the Header button shows the active theme (sun / moon /
solid moon + label), and an inline pre-paint script in layout.tsx
applies the stored theme before hydration so dark users get no white
flash. Verified all three themes render correctly in the browser.
The Vendor column showed for app-scan/Intune/Defender findings but not
for Wazuh's own vulnerability-detector rows (the main Wazuh source),
which set package_name/version but never package_vendor — inhomogeneous.
Thread package.vendor from the Wazuh states-vulnerabilities index
through get_vulnerabilities into the sync: take the first non-empty
vendor per CVE, set it on create and backfill existing rows. Wazuh
populates vendor for most Windows/RPM packages, so those now match the
other sources; where the source has no vendor it stays blank (same limit
as syscollector).
The audit log only did fixed 100-row 'load more' with no total, search or
sort. Backend GET /audit/logs now accepts search (free-text over
description, event type, resource, IP, and username via outerjoin), sort
+ order over a whitelisted column set, and sets X-Total-Count (already
CORS-exposed) so the UI can page. Frontend rewritten to match the CVE
view: debounced search box, clickable sort headers, rows-per-page
selector (25/50/100/250), and First/Prev/Next/Last with 'X-Y of N'.
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.
- MFA verify: decode_token returns None on an expired 5-min challenge
(it doesn't raise), so the exception-only guard let None.get() throw →
500 'Internal Server Error'. Guard None → clean 401 'MFA session
expired'. Login page routes that message back to the credentials step
instead of stranding the user on a dead MFA form.
- MS lifecycle: the 24h DB cache still let every synced device re-download
the export before the first commit landed (tester saw dozens of
identical GET .../lifecycle/products/export/). Add an in-process memo so
one sync fetches at most once.
- Intune live software inventory (/assets/{id}/software) hardcoded
vendor='' — now uses detectedApps.publisher, so the Vendor column fills
like Wazuh's.
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.
The 30-min access token had no client-side renewal: on any 401 the SPA
redirected straight to /login, so an actively-used session was bounced
every 30 minutes even though a valid 7-day refresh cookie existed.
- /auth/refresh now falls back to the HttpOnly refresh_token COOKIE when
no body is sent — the browser can't read that cookie to put it in the
body, so the cookie path is what makes SPA silent refresh possible.
- axios interceptor: on 401 (non-auth endpoints) try one /auth/refresh
then replay the original request; redirect only when refresh itself
fails (refresh expired/revoked = genuinely inactive). Single-flight
guard shares one refresh across concurrent 401s, since the backend
rotates (revokes) the refresh token on use.
Net: active users stay logged in up to the 7-day refresh window (all
roles); only real inactivity logs out.
- Only real CVE-IDs enter the correction cascade now (was: everything
except NESSUS-). Drops GHSA-only/pseudo ids that can never resolve —
spares wasted lookups and shrinks the missing set that gates NVD.
- _load_via_nvd now uses the NVD_API_KEY env (apiKey header, same as the
enrichment service) and throttles ~45/1s instead of 5/1s with a key.
- Stage-2 NVD cap scales with the key: 1500 (key) vs 100 (unauth), so a
key actually gets used instead of always falling through to cvelistV5.
- Correct-CVSS button label/confirm/tooltip now describe the real
cascade (Vulnrichment → NVD → cvelistV5 → GHSA, each filling only what
the previous left empty, CVE-IDs only) instead of 'CISA Vulnrichment'.
Answers 'is vendor readable via the Intune/Defender API?' — yes:
- Intune detectedApps.publisher was being dropped in _map_apps; now kept
as the package vendor.
- Defender softwareVendor was folded into the package label; now stored
separately.
- Wazuh syscollector already carries vendor; now threaded through.
New vulnerabilities.package_vendor column (migration 038, idempotent),
populated at the package-finding chokepoints (app_cve_scanner._upsert +
cvelistv5 scan_asset via pkg vendor, defender _upsert_cve) and shown in
the Affected Package card on the CVE detail page.
Scope: single-package findings. Per-package (vulnerability_packages) and
Nessus/m365 vendor left as follow-up — Nessus rolls vendor into the
plugin name and m365/OS vendor is implicit (Microsoft).
Drop the bare-"firefox" alternative from the product key regex so a
stray "…Firefox…" inside another product's name can't resolve to the
Firefox CVE key. Windows ARP / Wazuh inventory always carry the "Mozilla"
prefix (incl. "Mozilla Firefox ESR"), so no real install is lost.
Mozilla publishes a per-CVE impact rating (critical/high/moderate/low)
in foundation-security-advisories before NVD/cvelistV5 have a score — the
gap on brand-new Firefox CVEs. New mozilla_advisory_service builds a
{cve: severity/title/fixed_in/esr_only} index from the MFSA repo (line
parser, no PyYAML dep; 24h cache; github_pat lifts the rate limit) and
apply_mozilla_severity fills the placeholder severity in enrich_vulnerabilities
when the vuln has no CVSS-derived value. Mozilla carries no numeric CVSS,
so it only sets severity/description, never clobbers a real score.
fixed_in / esr_only (e.g. only 'Firefox ESR 115.13' → regular Firefox
unaffected) is the authoritative ESR discriminator, stored for the
upcoming Firefox-scan ESR exclusion.
Stage 4 of the score cascade (vulnrichment → NVD → cvelistV5 → GHSA).
GHSA carries advisories for CVEs still missing from NVD and cvelistV5
when very fresh — the gap the tester hit on new Firefox/Notepad++ CVEs.
Fills CVSS/severity/description/references only; GHSA 'unreviewed'
advisories carry no affected-version range (verified against the live
API), so no fix/detection data is derived — this is enrichment, not
new-CVE detection.
Optional github_pat setting (encrypted at rest, admin-only Settings
card) lifts the GitHub rate limit 60 → 5000 req/h; the loop stops
cleanly when the limit is hit.
The M365 check only ever ADDED rows: upsert refreshes package_version
while a CVE is still missing, but when a host updated Office and the CVE
left the missing set, detect_missing_cves returned affected=False, the
loop skipped it, and the finding stayed OPEN forever with a stale
installed build (e.g. CVE-2026-45460 shown as installed 16.0.19929.20172
on a host already on 20131.20154, past the 20026.20166 fix).
Add a reconcile mirroring Defender/app-scan: after scanning an asset
where an M365 install was actually seen, drop the microsoft365-apps
source from open findings whose CVE is no longer missing, and mark
patched when no other source still reports them. Wired into both
run_m365_check and run_m365_for_packages.
The MS 365 Apps security-updates page lists OS-level components (GDI,
MSXML, …) under its 'Office suite' heading — CVEs that are really
Windows-OS bugs Office bundles (e.g. CVE-2026-50387, a Windows GDI vuln
whose real fix is a Win10/Server2016/2025 OS build). The M365 check swept
those in and, worse, upsert overwrote the correct scan_asset_os finding
on the same (cve, asset) row with an M365 product + Office build.
Skip any CVE the cvelistV5 Windows-OS registry already owns, in both
entry points (run_m365_check + run_m365_for_packages). scan_asset_os
keeps ownership with the correct build + MSRC KB.
Fill-only backfill never corrected fixed_version values stored by
pre-sanitize syncs, so an old cross-line build (e.g. 6.2.9200.x
Server-2012) stayed on a 10.0.26100 Server-2025 host even after the
sanitize guard shipped. Re-sanitize the stored value each sync and clear
it when it belongs to a different build line; MSRC panel supplies the
correct per-branch KB.
- cvelistv5: inverse-unaffected fix bound now only for records with
exactly ONE unaffected entry. Firefox ESR CVEs carry two (e.g.
CVE-2026-16361: 115.38 lte 115.* + 140.13 lte *), and Mozilla writes
the ESR floor as an unbounded 'lte *', so a 'below X' rule wrongly
flagged regular Firefox 121/152. Multi-train records now skipped.
- advisory feeds: DEFAULT cisco-psirt URL corrected to
psirtrss20/CiscoSecurityAdvisory.xml (old rss.x?i=44 is DTD-refused);
saved configs auto-migrate the stale URL on load.
Deleting a user cleared only Asset/Vulnerability assignments, but users.id is
referenced by four more tables — notification_logs, ai_reports, user_groups,
audit_logs — so Postgres RESTRICT blocked the delete with a 500.
Clear every FK first. Historical rows are NULLed, not deleted, so a removed
user's notifications, AI reports and — critically — AUDIT LOG entries survive
(a user deletion must not erase who-did-what). Group memberships are removed
outright. Then expire the cached relationship state so the User.audit_logs
delete-orphan cascade doesn't re-delete the rows we just detached.
The 'no fixed build for Microsoft products' claim was disproved in this branch
(modern records carry numeric ranges; the old claim came from sampling ancient
CVEs). Describe what actually shipped: family-matched Windows OS detection via
cvelistV5 bounded ranges (Server and Client), per-release SharePoint/.NET keys,
and MSRC as the second authoritative source.
New early-warning surface the tester asked for: the newest 7-Zip advisory was
on ZDI before NVD or cvelistV5 had it. A dedicated Advisories page now shows
the CISA-KEV latest additions plus configurable RSS/Atom sources.
- advisory_feed_service: fetch + parse (RSS and Atom), cached in a setting so
the page serves instantly; per-feed errors recorded, never fail the run.
Default feeds verified LIVE before shipping (ZDI published/upcoming, CERT-EU,
BSI/CERT-Bund WID, Cisco PSIRT); Cisco emits junk after the XML root, so a
lenient per-item fallback parses it anyway. Security: any DOCTYPE/ENTITY
declaration is refused outright (XXE / billion-laughs) — feeds never need
DTDs, no defusedxml dependency required.
- GET /api/v1/advisories/feeds (cache; first call fetches),
POST /feeds/refresh (editor). Config = advisory_feeds_config setting,
editable from the page (admin): enable/disable, rename, custom URLs.
- Scheduler refresh every 6h; 'Advisories' nav item for all roles (read-only
surface; Refresh for editor+, Configure admin-only).
Parser self-checked against rss/atom/cisco-junk/DTD-refusal fixtures.
Wazuh sometimes attaches a fix build from a different Windows release to an OS
finding (tester: 'Fixed in 6.2.9200.26079' — a Server 2012 build — on a Server
2025 host at 10.0.26100.x). A fix on another build line says nothing about the
host; showing it is misinformation. Sanitize at both sync ingest points: for
'Microsoft Windows*' packages, when installed and fix parse as 4-part builds on
different build lines, the fix is dropped (the MSRC remediation panel supplies
the correct per-branch KB anyway). Non-OS packages and unparseable versions are
untouched.
The July .NET CVEs (CVE-2026-47304/50649/50525/50659) were invisible to the
app scan because cvelistV5's registry had no .NET entry. Added per-release keys
(dotnet-8/9/10 -> '.NET 8.0'/'9.0'/'10.0'), same reasoning as SharePoint: the
record floors are generic release floors (8.0.0 .. 8.0.29), so a shared key
would cross-match releases. The real semantic version lives in the ARP NAME
('Microsoft .NET Runtime - 8.0.16 (x64)') and bumps with every monthly patch;
the version field is an MSI build -> the scan now supports name_ver entries via
the existing _effective_version mechanic. Matches Runtime/Host/Desktop-Runtime;
SDKs excluded (8.0.1xx numbering never falls in the runtime range, runtime is
installed alongside anyway).
.NET FRAMEWORK stays out deliberately, with data this time: its ARP version
(4.8.04084) is STATIC across monthly patches — only file versions change — so
comparing it to fix builds like 4.8.4803.0 would flag every install forever.
Framework patch state is file/KB-based; Defender TVM covers it and those
findings already flow in via the Defender sync. The record's ampersand ranges
('2.0.50727.9069 & 3.0...') parse to no-match, verified harmless.
Index bumped to v7. Verified against the live records: 8.0.16 -> affected by
8.0.0..8.0.29; 8.0.29 -> clean; SDK/Framework/Targeting-Pack names don't
resolve.
Three tester-reported issues:
1. Cross-confirmed findings never auto-resolved. Each reconcile (app-scan /
defender / msrc) skipped findings any OTHER scanner also reported, so a
Chrome CVE seen by app-scan AND Defender was closed by neither — a patched
host (150.0.7871.125 installed, fix .115) kept an open finding forever.
All three reconciles now use the Nessus-backfill contract instead: drop
YOUR OWN source when no longer detected, mark patched (audit-logged) only
once no source is left. A finding another scanner still reports stays open
under that scanner. Verified both directions in a self-check.
2. Installed version went stale. The app-scan upsert only filled
package_version when empty, so the row kept its first-ever version
(tester: Firefox showed 'Installed: 150.0.3' while 152.0.5 was on the
box). Re-detection now refreshes the version.
3. Citrix published-app registry stubs ('Firefox 1.0', vendor 'Delivered by
Citrix') describe software NOT installed on the box and produced
ancient-CVE false positives (CVE-2008-2798 against Firefox 1.0). Packages
whose vendor names Citrix are skipped in both scanners; the stub vendor
field is the discriminator, real installs keep their real vendor.
1. Wrong KB suggested: a Win11 24H2 host at 10.0.26100.8655 was shown KB5099536
build 10.0.26100.33158 — the Windows Server 2025 update. The remediation
display collapsed each build branch to its NEWEST revision before knowing the
host, but one branch can carry several revision sequences (26100 = 24H2 at
~8xxx AND Server 2025 at ~33xxx), so the Server KB always won. Now all
candidates per branch are kept and, once the host is known, the pick per
update-type is the SMALLEST fix revision still above the installed one —
the host's own servicing sequence (cumulative updates make smallest-above
the fixing one). Host past everything -> newest as reference.
Verified: 26100.8655 -> KB5087539 (.8875); 26100.32690 -> KB5099536
(.33158, .8875 correctly skipped as below installed); 26200 line untouched.
2. MSRC Enrich button was stuck on 'Started in background' forever. Track the
refresh state module-side, expose GET /msrc/refresh/status, and let the
button poll every 4s until it can show the real outcome (stats or error) —
same pattern as the Intune sync status.
A CVE on 200 hosts was 200 rows and the counters counted each occurrence
(tester saw 'Neue Schwachstellen 11848' with 'Kritisch 467, Hoch 11381' —
per-(CVE,asset), not distinct). Collapse the digest to one row per CVE:
- {{rows}} is now one line per CVE, sorted by CPR desc, columns CVE | Severity |
CVSS | CPR | #Systems | Package. The CVE links to ?cve_id= (no asset_id), i.e.
the vulnerability view listing every affected asset; #Systems says how many.
- Severity counts and {{total}} are per distinct CVE, so 'Neue Schwachstellen'
and the tiles no longer multiply by host count. {{affected_assets_count}}
stays distinct assets across the digest.
- default digest template + preview sample + settings hint updated (Host column
dropped — meaningless once aggregated). Notification-history subject/body show
distinct-CVE count.
Verified on the tester's shape: 47× CVE-2026-50387 + 2× another -> 2 rows,
counters {critical:1, high:1}, #Systems 47.
The tester proved two of my 'structurally impossible' claims wrong by patching
around them locally. Both were wrong, and for the same reason as before —
checking too narrow a slice of the data.
1. Newest Firefox CVEs were never indexed. Mozilla states the INVERSE: no
affected range at all, only 'version 152.0.6, lessThanOrEqual *,
status: unaffected'. _ranges_from_affected skipped every non-affected entry,
so the CVE vanished. 'X and up are fixed' == 'below X is affected', so a
lone unaffected floor now yields a fix bound. Guarded: only when the entry
declared no affected range of its own AND there is exactly one floor —
several floors mean several branches (release vs ESR) and picking one would
over- or under-report.
2. SharePoint 2013 does have real fix ranges. I concluded otherwise from six
RECENT MSRC docs — of course a product EOL since 2023-04-11 is absent there.
The CVE records from its supported years carry real builds
(CVE-2023-23395: 15.0.0 .. 15.0.5537.1000) and an unpatched farm is behind
all of them. That is exactly what the tester's Nessus reports.
Verified against the live records, both directions:
Firefox 147.0.4.0 / 152.0.5 -> affected; 152.0.6 / 153.0 -> clean
SharePoint Foundation 2013 @ 15.0.4569.1506 -> flagged by CVE-2023-23395
and CVE-2022-35823
regression: WS2016 @ 14393.5000 -> flagged, @ 14393.9234 -> clean
Index bumped to v6 so it rebuilds once.
Re-enables the OS scan that c4abbaa turned off, with the guard that was missing.
Two independent checks, each one learned from a real false positive:
1. FAMILY — the entry's product name must belong to the asset's Windows family.
Windows 11 24H2 and Windows Server 2025 share build line 10.0.26100 but keep
separate revision sequences, so CVE-2026-41089 (Server 2025 only, fix .32860)
matched a fully-patched 24H2 client at .8655. The index now keeps the product
name to make this possible.
2. RELEASE-BOUNDED — the range's floor and fix must sit on one build line. Older
records use a generic 10.0.0 floor that swallows every lower build
(CVE-2021-26432 put a 17763 fix on a 14393 host).
Together the range only answers 'patched or not' within the host's own release.
Verified end-to-end against the real records, both directions:
Win11 24H2 @ 26100.8655 -> EMPTY (the reported FP)
WS2016 @ 14393.9234 -> EMPTY (patched; also rejects the 2021 record)
WS2025 @ 26100.32690 -> CVE-2026-41089 (.32860), CVE-2026-47291 (.32995)
Win11 23H2@ 22631.5000 -> CVE-2026-47291 (.22631.7219) only
WS2016 @ 14393.5000 -> CVE-2026-47291 (.14393.9234) only
Index bumped to v5 (entries carry the product name) so it rebuilds once.
SharePoint was simply absent from the curated registry — the app scan never
looked for it, which is why Nessus found those CVEs and we did not. cvelistV5
carries usable numeric ranges for it, verified across 82 recent CVEs rather
than one sample:
Microsoft SharePoint Enterprise Server 2016 16.0.0 .. 16.0.5556.1005
Microsoft SharePoint Server 2019 16.0.0 .. 16.0.10417.20153
Microsoft SharePoint Server Subscription Edition 16.0.0 .. 16.0.19725.20384
One key PER RELEASE, not a shared 'sharepoint' key: every range uses a generic
16.0.0 floor and all three releases report 16.0.x, so a shared key would let a
2016 install (16.0.5456) fall inside the 2019 range — the same cross-release
false positive the Windows OS scan just produced. The release is decided by the
product NAME; the range then only answers 'patched or not' within it.
2013 stays out: Microsoft publishes no fixes for it, so no range exists. Its
EOL finding is the signal there — this changes nothing for a 2013-only host.
Identifying a Windows release by its build LINE is not sound, so the whole
premise of 5703dfa fails. Windows 11 24H2 and Windows Server 2025 both sit on
10.0.26100 but keep separate revision sequences:
CVE-2026-41089 affects Windows Server 2025 ONLY, fix 10.0.26100.32860
a fully-patched 24H2 client reports 10.0.26100.8655
-> 8655 < 32860, so the client matched a Server-only CVE
The same-line floor/fix guard from e19c8d9 was necessary but not sufficient: it
only rejects the old generic-floor records, not two releases sharing a line.
Disabling rather than attempting a third in-place fix: the FPs are in the
tester's data now, and I have already been wrong three times here by
generalising from too few records. Windows products are no longer indexed at
all, so the findings self-heal via the app-scan auto-resolve on the next run.
Package scanning is untouched.
Re-enabling needs the entry's product NAME in the index, matched against the
asset's OS family, verified against CVE-2026-41089 (client must not match) and
CVE-2026-47291 (each must match its own line). Written up in scan_asset_os.
Regression from 5703dfa. A Server 2016 host (10.0.14393.9234) was flagged with
CVE-2021-26432 'fixed in 10.0.17763.2114' — a Server 2019 build. My claim that
cvelistV5 ranges are release-bounded came from checking ONE modern record, and
older ones are not:
CVE-2021-26432 version=10.0.0 lessThan=10.0.17763.2114 (generic floor)
CVE-2026-47291 version=10.0.14393.0 lessThan=10.0.14393.9234 (release-bounded)
A '10.0.0' floor makes the range swallow every lower build, so a 14393 host fell
inside a 17763 fix. The OS scan now requires the floor and the fix to sit on the
same build line and skips the rest — such an entry carries no release info at
all, and the OS string alone can't supply it. Costs nothing in practice: Windows
servicing is cumulative, so a host behind on an old CVE is already flagged by
that line's newer ones. Existing false positives self-heal on the next scan via
the app-scan auto-resolve.
Also:
- scheduler job renamed to '(Windows OS)' — it covers client too, not just Server
- dashboard: AI Audit History hidden from read-only (AI endpoints are editor+)
- dashboard: 'Export Report' had no onClick at all (dead for every role) — now
links to the reports page
- compliance: the Impact-CSV upload card is admin-only (the import endpoint is
RequireAdmin), so it no longer 403s silently for other roles
Corrects bf529ed, which claimed Microsoft products cannot use the CPE/cvelistV5
path. That was wrong, and the reasoning behind it was a sampling error: the
'no ranges' check looked at CVE-2013-3900 and the 'lessThan: publication' check
at CVE-2021-24072 — both ancient. Modern MS records carry real numeric ranges.
Re-verified on the CVE the tester cited:
CVE-2026-47291 cvelistV5: 20/20 affected entries have a numeric lessThan,
0 'publication'
NVD: 22/24 microsoft cpeMatch have versionEndExcluding
CVE-2026-45467 (SharePoint, current): lessThan 16.0.5556.1005 etc.
So there is no Server/Client split and no reason to special-case Microsoft.
Implementation reads cvelistV5, not NVD, for one concrete reason: NVD flattens
these to an END bound only, so a 1607 host (14393.x) sits inside the 22H2 range
(endExcluding 19045.7417) and false-positives. cvelistV5 keeps the range bounded
(10.0.22631.0 .. 10.0.22631.7219), and those bounds select the host's release by
themselves — no build->release table, Server and Client fall out for free.
- pattern products in the index (one key for the whole 'Windows 10|11|Server'
family) so a new release doesn't need a registry row; anchored so SharePoint,
Teams and '.NET Framework on Windows Server 2016' can't be swallowed
- scan_asset_os() flags straight from asset.os_version; runs for every Windows
asset, no software inventory required
- index bumped to v4 (entries changed) so it rebuilds once
Extends the MSRC scan from OS to installed MS software, hooked into the app-CVE
scan which already holds the inventory. Verified against live data first:
- Syscollector DOES report the serviced build, not RTM ('Microsoft SharePoint
Foundation 2013' -> 15.0.4569.1506), so the FixedBuild compare is sound — the
false-positive worry that blocked this doesn't apply.
- Branch semantics are per-product, not universal: Windows Server 2016 (14393)
and SharePoint 2019 (10417) keep a stable 3rd segment, but SharePoint 2016
moved 5535->5539->5543->5552->5556 in six months and SubEd 19127->19725.
Prefix-matching those would silently match NOTHING, so products now carry a
flag; SharePoint identifies by name (2016/2019/SubEd all report
16.0.x) and compares plain installed < fixed, which is right because MS
servicing is cumulative.
- SharePoint 2013 deliberately unsupported here: 0 products across six MSRC
docs — Microsoft ships no fixes for it, so no FixedBuild can exist. Its EOL
finding is the signal instead.
Reconcile is scoped to the product labels each pass actually evaluated —
otherwise the OS pass would auto-resolve every SharePoint finding it never
looked at, and vice versa.
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.
Closes the Wazuh-CTI delay for Microsoft OS CVEs, which NVD/cvelistV5 provably
cannot: they carry no fixed build for MS products (rangeless CPEs /
lessThan:'publication'), so they can't distinguish a patched host from an
unpatched one. MSRC's CVRF does — each Type-2 remediation pairs a FixedBuild
with the ProductIDs it covers, e.g. CVE-2026-33834 -> 10.0.14393.9140 for
'Windows Server 2016' (+ Server Core), KB5087537.
- msrc_scan_service: builds a curated product->(cve, fixed_build, kb) index from
the monthly CVRF docs (cached in a Setting, nightly rebuild), then flags a host
when its build is behind the newest fix ON ITS OWN servicing branch (a 2016
fix says nothing about a 2019 host). Auto-resolves + audit-logs once the host
catches up; never touches findings another scanner reports.
- Product mapping is anchored so 'Microsoft .NET Framework 4.8 on Windows Server
2016' can't be mistaken for the OS family. Windows client (10/11) is out of
scope until there's a build->release table.
- Nightly job at 05:10 + POST /api/v1/vulnerabilities/msrc-scan (editor).
Verified against live MSRC data: a host at exactly 10.0.14393.9140 is correctly
NOT flagged; an older build is; a different branch is ignored.
Investigated adding Windows Server (and SharePoint) to the app-CVE scanner to
beat the Wazuh CTI delay. Verified against live data that the premise doesn't
hold: NVD gives MS OS entries as rangeless CPEs
(cpe:2.3:o:microsoft:windows_server_2016:-:* with versionEndExcluding=null) and
cvelistV5 MS records use lessThan:'publication' — neither carries the fixed
build, so neither distinguishes a patched host from an unpatched one. _in_range
already rejects those wildcard/'-' versions, so registry entries would match
nothing while spending NVD quota. Leave a note where the next person will look
instead of shipping dead entries; MSRC (KB + FixedBuild) is the viable source.
New notification_lifecycle_mode setting (default 'exclude'): pseudo-findings
that aren't real CVEs — EOL-* (endoflife.date), ANDROID-PATCH-* (patch-level
staleness), NESSUS-PLUGIN-* — no longer mix into the vulnerability mails. The
rule is 'anything not CVE-*' so future pseudo prefixes are covered without a
code change. Applies to both delivery modes (single/digest) and both schedules
(per-sync/nightly); 'include' restores the legacy mixed behaviour. Excluded
findings stay fully visible in the dashboard — only the mail routing changes.
Paging used from/size, which OpenSearch hard-caps at index.max_result_window
(default 10000). Agents with more findings 400'd at from=10000, the whole agent
then read as '0 vulns' and its backfill was skipped — so hosts with 24k/27k
findings silently contributed nothing. Switch full syncs to the scroll API (no
window ceiling, no unique-sort requirement), with a fallback to windowed paging
if the indexer refuses scroll; explicit-offset callers keep from/size but stop
at the window instead of erroring.
Also hide the dashboard AI Audit controls + recommendations panel from
read-only users (the audit endpoint is editor-gated and threw 'Access denied').
The digest only exposed the per-CVE count (inside {{rows}}), so a top-level
{{affected_assets_count}} in the digest body stayed literal in sent mail (the
preview substituted it from sample data, masking the gap). Add it to the digest
variables = distinct assets across the whole digest ('affected systems' tile).
Docs + template hint updated.
Email links (dashboard button, cve/asset deep links, the CVE links in the
digest table) are built from DASHBOARD_URL; unset → they fall back to
http://localhost:3000 and break. Uncomment it with the right default port and a
clear 'set this to how users reach the frontend' note.
- Hide the AI Remediation section for read-only users (Generate hits an
editor-only endpoint; output isn't persisted, nothing to show them).
- Copy buttons: navigator.clipboard is undefined over plain http://<ip>
(non-secure context) so copies silently failed — add a textarea+execCommand
fallback.
- Email template Preview: add the new sample variables (cpr_score,
affected_assets_count, cve/asset deep links) and a sample {{rows}} HTML table
so previews render real values instead of literal {{...}} placeholders.
Read-only users can't fetch /auth/users (admin-only), so the disabled assign
<select> resolved to 'Unassigned' even when the finding/asset WAS assigned — the
head icon showed assigned but the name was hidden. Render the assignee name
straight from the payload (assigned_user_name / group name) as read-only text
instead. Applied on both the vulnerabilities and assets lists. Also fix a
pre-existing invalid title= prop on the Nessus <svg> (use a <title> child).
Read-only saw an empty Actions column with just a '—' placeholder. Gate the
header, the per-row cell, and the empty-state colSpan on role so the column
disappears for read-only instead of hanging around blank.
Every audit_logs insert is mirrored as an RFC-5424 syslog message with a
per-event severity (login failures/lockouts/access-denied → warning, security
alerts → alert, config/deletes → notice, else info). A SQLAlchemy after_insert
hook enqueues onto a bounded queue drained by one daemon worker (never blocks
the request/flush; bursty syncs drain sequentially). Config cached 30s; TCP
keeps a persistent socket with reconnect. Disabled by default → no-op until
enabled. Admin-only config card (host/port/UDP-TCP/facility) with a Test button;
POST /api/v1/settings/syslog/test sends a probe. No TLS yet (UDP+TCP).
Opt-in via the auth_lockout_permanent setting: after 5 failed logins an account
is locked until an admin clears it (post-incident review), instead of the
temporary 15-min auto-unlock. Safeguard: the last active admin is NEVER
permanently locked (falls back to the temporary window) so a brute-force on the
admin login can't lock everyone out for good.
- migration 037: users.locked + locked_at
- local.py: permanent-lock check + threshold escalation with last-admin guard
- POST /auth/users/{id}/unlock (admin, audit-logged); list_users exposes locked
- Settings UI: 'Permanent account lockout' toggle + per-user Unlock button/badge
Bring the READMEs in line with this session's changes:
- New-CVE mails now fire from all four scanner sources (was Wazuh/Nessus only)
- Document notification_schedule (per_sync/nightly roundup), nightly hour, the
rate-limit settings, and the windowing setting
- Digest table: CPR-desc sort, #Systems count, deep links; document the single-
and digest-mode template variable sets and the editable digest template
- RBAC table: notification history is own-only for non-admins (privacy),
reports viewable by all; broaden the 'no mail' troubleshooting row
The aggregated digest rows now render highest-CPR (most urgent) first, rows
without a CPR last — risk-led ordering the tester asked for. Per-CVE
affected-systems count was already surfaced (the #Systems column).
- Vulnerabilities list: the per-row Change-Status / Suppress / Mark-False-
Positive buttons and the assign dropdown were shown to read-only users and
threw 'Access denied. Required role: editor'. Gate them (editor+); assign
select disabled for read-only.
- CVE detail page: Dismiss/Reactivate now shown only to editor+ (fetches
/auth/me for the role).
- Notification log (/notifications): GET /log returned EVERY sent mail —
recipient emails + which CVE went to whom — to any authenticated user (privacy
leak). Now admins see all; everyone else sees only notifications addressed to
them (user_id == own).
Both single and digest new-CVE emails now expose risk/priority context and
ready-made links so operators don't hand-build URLs:
- cpr_score: the contextual priority rating (risk-led triage)
- affected_assets_count: distinct assets carrying this CVE (blast radius),
computed once per batch via a grouped query
- cve_link / asset_link / cve_on_asset_link: prebuilt deep links into the
vulnerabilities view (?cve_id=, ?asset_id=, both)
Digest rows gain CPR + #Systems columns and a clickable CVE; the single-mode
default template shows CPR, affected count and two action buttons. Settings
variable hints updated.
#2 The digest template (email_template_new_vuln_digest, actually sent in the
default digest mode) was not editable in the UI — only the single-mode template
was — so a customized template appeared unused. Add a digest-template editor in
Settings (mode-aware Active/Inactive badges clarify which applies).
#3 Add a nightly aggregated roundup: notification_schedule='nightly' suppresses
per-sync mails and a self-gated hourly scheduler job sends ONE digest per
recipient of all CVEs since the last run (windowed via
notification_nightly_last_run), at notification_nightly_hour. Plus GUI-config
send rate limiting (email_rate_delay_seconds, email_max_per_run) applied in the
dispatch loop against provider anti-spam.
dispatch_new_vuln_notifications was only called by the Wazuh and Nessus syncs,
so brand-new findings from the app-CVE scanner and Defender TVM never triggered
a notification (tester: 'only Wazuh mails arrive'). Call the same digest
dispatch after each of those syncs enriches its new findings.
Every other admin toolbar button on the vulnerabilities page is hidden for
read-only (role !== 'readonly'), but Refresh Threat Intel was only disabled —
so it showed greyed-out and dead. Hide it like the rest for consistency.
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.
AssetResponse omitted intune_device_id, so the frontend's software-inventory
and app-rescan buttons (gated on wazuh_agent_id || intune_device_id) never
appeared on Intune-managed iOS/macOS/Android devices — only Wazuh hosts. Add
intune_device_id (+ defender_machine_id) to the response; the software endpoint
already handles the Intune detectedApps path.
A read-only user saw every row action (Wazuh/Nessus/app rescan, edit, delete,
assign) and Add Asset, but the backend gates those (RequireEditor/RequireAdmin)
so clicking threw 'Access denied. Required role: editor'. Fetch the current
role and show mutating actions only to editor+ (delete to admin); read actions
(installed software, coverage gap, exposure) stay for everyone.
Every other card in the System-config column is already gated behind
userRole==='admin', but the OpenRouter and Intune cards rendered
unconditionally, so a read-only user saw empty admin config with dead
Save/Test buttons (backend already 403s them via RequireAdmin, so no data
leak — but confusing). Gate both like the rest.
New GET /assets/{id}/software pulls the live software inventory (Wazuh
syscollector packages, else Intune detectedApps) — the same inventory the
app-CVE scanner consumes — without persisting it. A list-icon button on each
Wazuh/Intune-backed asset row opens a modal with name/version/vendor.
Read-only, deduped, sorted. No DB schema (ponytail: on-demand until a
searchable/reporting inventory is actually needed).
- OpenSSL FIPS provider builds (e.g. Veeam's 'OpenSSL v3.0.0 FIPS') were
matched against OpenSSL CVEs, but the advisories explicitly carve the FIPS
modules OUT (vulnerable code is outside the FIPS boundary) and they carry a
separate 4-part build version — pure false positive (CVE-2025-15467). Exclude
via negative lookahead 'openssl(?!.*fips)'. Existing FP self-heals: next scan
no longer detects it -> auto-resolve marks it patched (now audit-logged).
- Add an 'App CVE re-scan' action button on asset rows (Wazuh- or Intune-backed
assets) that hits the existing POST /vulnerabilities/app-cve-scan?asset_id=,
analogous to the Wazuh rescan button.
Automated patch transitions were silently flipping status to 'patched' with no
audit trail — no global audit-log row and no per-CVE Change History entry, so
the remediation was invisible (Revisionssicherheit gap the tester flagged on
CVE-2026-15131). log_vulnerability_change already writes one AuditLog row that
feeds BOTH surfaces; Wazuh/Nessus sync already called it, three paths did not:
- app-scan auto-resolve (_resolve_stale_app_findings): now logs source=app_scan
- Defender TVM had NO auto-resolve at all — add one (defender-only, guarded to
non-empty machine responses) that logs source=defender_sync
- verify_patch_with_rescan flipped patched/patch_failed without logging — now
records the outcome (source=verify_patch_rescan)
All automated (user_id=None) with a WHO/WHEN/WHY reason string.
The sync-status poll showed 'Sync done — undefined devices…' in green when a
run was skipped by the advisory lock (result = {skipped}). Branch on skipped
(amber 'already running'), guard the stat fields with ?? 0, and give states
distinct colors: loading=blue pulse, warning=amber, success=green, error=red.
Two gaps surfaced by a Chrome finding (CVE-2026-15131):
1. App-scan was additive-only: after Chrome was updated past the vulnerable
version, the CVE simply stopped being re-detected but the open finding
lingered forever. Add a per-asset reconcile — an app-scan-ONLY open finding
not re-detected this run (and not claimed by Wazuh/Nessus/Defender) is marked
patched. Guarded to runs that actually had a package inventory so a transient
fetch failure can't false-close everything.
2. The cvelistV5 scanner hardcoded cvss=None, so findings it created showed CVSS
N/A even though the record carries a 3.1 vector. Extract baseScore/severity
(_cvss_from_record, CNA>ADP, 3.1>3.0>4.0), store per index entry (index bumped
to v3), and backfill cvss_score/fixed_version/severity onto existing findings
when better data arrives. Self-check for the parser under __main__.
The widgets fetched a per-asset-duplicated list and deduped by cve_id
client-side. When a CVE sits on many assets, a handful of CVEs x N assets fill
the whole fetch window, so dedup starved and the widget showed ~4 of 10. With
131 assets no limit can guarantee 10 distinct (10x131 > 1000 cap).
Add a distinct_cve query param to the vulnerability list: a row_number() window
partitioned by cve_id keeps one representative row per CVE (newest published,
then most recent) server-side, composing with every sort_by. Newly-Published
and EOL widgets pass distinct_cve=true (Mobile stays per-asset by design).
The manual sync is fire-and-forget (202), so the GUI got stuck on 'Sync started
in background' forever. Track last/current sync state in the router and expose
GET /sync/status; the settings page now polls every 3s and shows the real
result (devices/matched/created/app-findings + Defender new CVEs) or the error.
Two overlapping Intune/Defender syncs updated the same asset rows in different
orders -> 'deadlock detected' during flush. The router's _INTUNE_SYNC_RUNNING
flag is per-worker (Gunicorn has several) and the nightly scheduler runs in its
own context, so neither guards cross-process. Wrap the whole sync in
pg_try_advisory_lock: a second concurrent caller is now cleanly skipped instead
of deadlocking. Lock is released (rollback-safe) in finally; the nested Defender
sync shares the same locked session.
Expose Swagger /docs + ReDoc /redoc on a production instance without full
development mode (ENABLE_API_DOCS=true) for ITSM/CMDB integrators. Raw
/openapi.json spec is served regardless. OpenAPI title/description rebranded
VulnManager -> TrueVuln.
Tester hit an asset named "<enrollment-GUID>_IPad_6/16/2026_9:58 AM" — Intune's
management-name blob that Graph puts in `deviceName` for supervised/userless/
ABM iOS devices. Two improvements:
1. Readable, stable hostname (_clean_device_name in intune_service): when
deviceName is a management-name GUID blob, compose "<model>-<serial>"
(e.g. iPad13,4-DMPXK7MP9Y) from fields we already fetch; real names pass
through unchanged. Existing ugly assets self-heal on the next sync — the
asset is matched by its stable id and its hostname refreshed (rename
tracking, which also covers the autodeploy renames the tester asked about).
2. Cross-source merge on the Entra/AAD device id. The same physical device has
different per-service ids (intune_device_id ≠ defender_machine_id) and only
merged by hostname before — which fails when Intune's deviceName and
Defender's computerDnsName differ (both often the management name). Now both
syncs store + match on the AAD device id (Intune `azureADDeviceId` ==
Defender `aadDeviceId`): match cascade id → aad_device_id → hostname, and
each sync backfills both ids so one physical device becomes one asset.
Migration 036 adds assets.aad_device_id (indexed, nullable, idempotent).
Verified the name-cleaning against the exact blob + real-name + Windows cases.
A CVE that affects .NET AND Visual Studio (and SQL, etc.) carries one MSRC
fix build per product. The remediation view showed the wrong one — a .NET
Host 8.0.12 finding displayed "build 18.6.3 → Visual Studio 2026 18.6"
instead of the .NET update.
Two causes in the MSRC display logic (get_vuln_remediations):
1. The dedup keyed on (Windows-build-branch, update-type). Non-Windows
product builds (.NET/VS/SQL) have no 4-part Windows build → branch None,
so DIFFERENT products collapsed into one entry (last-wins = VS). Now
branch-None fixes are keyed on the build itself, so each product's fix
survives.
2. No product scoping. Added a filter: for a non-Windows, non-M365 finding,
narrow the shown fixes to the build line whose major matches the installed
package version (8.0.12 → major 8 → keep .NET 8.0.28, drop VS 18.6.3).
Falls back to the full set when nothing matches, so a fix is never hidden.
Migration-free (display-only; the DB already stores every product's fix as a
separate cve_remediations row). Verified the dedup keeps both builds and the
filter picks 8.0.28 for a .NET 8.0.12 finding.
Blocking operations (SMTP send loops, Wazuh/Nessus/Graph HTTP, bulk
re-scoring) were declared `async def` but do synchronous work inline, so they
ran ON the event loop and froze the entire web GUI until they finished
(tester: "Notify All Critical/High" locks TrueVuln the whole time, and the
same at many other places).
Converted the offending endpoints to plain `def` so Starlette runs them in a
worker threadpool off the event loop — the GUI stays responsive while they
run. Verified each has no `await` in its body before converting (mechanical,
behaviour-preserving):
- notifications: notify-critical (the reported one), test email
- assets: coverage-gap, refresh-exposure, sync-wazuh-assets, rescan-asset
- scans: trigger-autoscan
- vulnerabilities: enrich-single, refresh-kev-catalog, wazuh sync,
override-from-nessus, recompute-priority-scores
- nessus: test-connection, list-scans, scan-host
(Several other heavy endpoints — m365-check, eol-check, exploit-intel,
bulk-enrich, nessus /sync, app-cve-scan, suppress-fp — were already sync def
from earlier in this work.)
Tester asked what assignment actually does and whether owner-assignment must
be mandatory in the runbook. Documented the real behaviour rather than
changing code (per decision, SLA stays assignment-only):
- New README.DEV.md subsection "Asset / Finding assignment — what it actually
does": the recipient cascade, what assignment controls (new-CVE digest with
admin/default fallback vs. SLA-breach digest with NO fallback, the
Assigned-To column, auto-cleanup) and what it explicitly does NOT do
(scanning/scoring/SLA-calc/reports/visibility — all role-based). Runbook
recommendation: assign each asset to a system owner, since that's the only
way SLA-breach mails reach anyone and to route new-CVE mails to the real
owner instead of blanket-to-admins.
- Documented the new notification_default_recipients setting in the settings
table; refreshed the "no mail after sync" troubleshooting row (the fallback
changed when it's truly empty) and the fresh-install step.
- One-line notes in README.md Notifications + Asset Management sections.
The rebrand renamed many Tailwind color classes vulncheck-blue → truevuln-blue
but left the theme token as --color-vulncheck-blue, so those 115 classes
referenced an undefined color → transparent backgrounds / unstyled text
(tester: the "Save Template" button was invisible until hover; also affected
buttons, links, focus rings, sort arrows app-wide).
Unified everything on truevuln-blue: renamed the @theme token to
--color-truevuln-blue and the remaining 156 vulncheck-blue class usages to
truevuln-blue. Now all 271 usages resolve to one defined token; 0 vulncheck-blue
left.
New-vulnerability emails only went to a finding's assigned user/group (or its
asset's). The thousands of scanner findings are assigned to nobody, so the
nightly digest and the bulk "Notify All Critical/High" resolved zero
recipients — "Sent: 0, Failed: 0" — even with SMTP configured and an admin
email set.
_resolve_recipients_for_vuln now falls back, when the assignment cascade is
empty, to get_default_recipients(): the configured
`notification_default_recipients` setting (comma/semicolon/space-separated
emails), or — if unset — every active admin user, so "the admin gets
everything" works out of the box. The bulk-notify endpoint was rewritten to
use the same cascade (it previously only checked asset assignment, with no
fallback). Added a "Default Recipient(s)" field to the notification settings UI.
Three tester-reported issues:
1. .NET Runtime/Desktop Runtime/SDK/Host not detected. Wazuh syscollector
reports the MSI build number (48.x/94.x) in the version FIELD while the
real semantic version (6.0.16) is only in the display NAME — so the scan
queried a bogus version that never matched NVD's 6.0.x/8.0.x ranges.
Registry entries can now set name_ver=True; _effective_version pulls the
version from the name for those. The finding's package_version now shows
the semantic version too. Developer reference packs (Framework X.Y
Targeting Pack / Multi-Targeting Pack / SDK / Client Profile) are excluded
— they're build-time assemblies, not the installed runtime, and would
false-positive against runtime CVE ranges. The standalone .NET SDK still
matches (it's a real product with its own CVEs).
2. Teams false positives: "Microsoft Teams Meeting Add-in for Microsoft
Office" and "Microsoft Teams VDI Citrix plugin" were matched as Teams and
picked up Teams-app CVEs (CVE-2023-29330, CVE-2025-49737). They're
separate products with their own versioning. The Teams name-regex (both
the NVD-CPE and cvelistV5 registries) now excludes add-in/plugin/vdi/
citrix/machine-wide.
3. cvelistV5 scanner had no OS-platform check, so a macOS-only Teams CVE
(CVE-2025-49737) could land on a Windows host. The index now stores each
affected[] entry's platforms; scan filters by the asset's OS family — but
only when the record lists a real OS (Windows/macOS/Linux/Android/iOS).
Microsoft also uses platforms for CPU arch ("x64-based Systems"), so
arch-only records are kept (can't judge the OS → never drop on arch).
Also dropped the "Teams for Mac" product pair (belt-and-suspenders, since
not every CNA fills platforms). Index cache bumped to v2 to force a
rebuild with the new platforms field.
All resolution/version/platform logic verified against the real installed-
name strings and the actual CVE CPE data.
The image was hardcoded to /raw/branch/main/..., so viewing README.md on the
dev branch still rendered main's (stale) screenshot regardless of what dev
actually had. A relative path resolves against whatever ref/branch is being
viewed (Gitea/GitHub both do this), so dev shows dev's image and main shows
main's once merged.
Accidental duplicate from the Gitea web-upload UI (a folder named
dashboard.png containing a file of the same name); the correct,
referenced screenshot lives at docs/screenshots/dashboard.png.
README.md: the "Features" section was silent on everything built this
session — added a subsection for the built-in App→CVE scanner (OSV/NVD-CPE/
cvelistV5), Wazuh false-positive suppression, and mobile device security
(EOL/EOS, Android patch-level, per-CVE Android via Samsung SMR/Google ASB);
extended the KEV bullet to mention the new advisory dashboard widget; added
the new /api/v1/advisories endpoint group to the API table.
README.DEV.md: added a 6th feature-group entry to "What this branch adds"
and a full new section (matching the doc's existing per-feature style —
what/why, module map, design decisions, out-of-scope) covering the
App→CVE engine, FP suppression, mobile device security, and the KEV
advisory feed, so this session's work has the same documentation trail as
every earlier feature drop in this doc.
frontend/README.md: was still the untouched create-next-app boilerplate,
including "Deploy on Vercel" instructions that don't apply (this app ships
via Docker Compose). Replaced with a short pointer to the root README/
README.DEV for setup and deployment.
Not touched: .env.example DEFAULT_ADMIN_EMAIL (admin@vulnmanager.local) —
verified consistent with README's own reference to it, no drift.
Nav/Drawer render the name as two separate JSX text nodes ("Vuln" + a
<span> for the color-accented "Check") for the two-tone styling — the
literal string "VulnCheck" never appears in the source, so the earlier
exact-string rename pass didn't touch it. Logo/Nav still showed "VulnCheck"
after that commit (tester screenshot). Now "True" + accented "Vuln",
matching the rest of the rebrand. logo.svg itself is just the shield+check
glyph with no embedded text — nothing to change there.
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).
Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).
Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
README lines derived from it (git clone target dir, tree listing) — a real
repo rename is a manual Gitea-side step (Settings → repository name) the
user would need to do themselves, and existing clones would need
`git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
accounts that still exist under the old name; renaming the text alone
wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
vulncheck/...) — tied to GitNexus's own index name for this repo, not our
branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
(vulnmanager-*) — explicit user decision: infra naming carries real
deploy/data risk on an already-running instance and isn't part of the
product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
— invisible internal CSS variable name, renaming it would touch ~270
className occurrences for zero user-visible benefit.
Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
Tester-reported false negatives (Wazuh's own detector misses these; our
app-scan is independent — it works off Wazuh's raw package inventory, not
Wazuh's CVE engine, so it can catch what Wazuh's detector can't).
- Microsoft .NET Runtime/Desktop Runtime/SDK/Host, and .NET Framework: NVD
files ALL of them (6/7/8/9 and Framework 2.0–4.8.1) under the SAME CPE
(cpe:2.3:a:microsoft:.net), each major version scoped by its own cpeMatch
range — one registry row covers both families via the existing NVD-CPE
scanner. .NET Framework itself typically isn't a Windows "installed
program" (it's a Windows feature, not an MSI package), so it's usually
invisible to Wazuh syscollector's package list regardless of this
registry row — documented as a Wazuh-agent data-source limit, not
something curation can fix. "Teams Machine-Wide Installer" excluded
(placeholder entry, not a real Teams binary).
- Microsoft Teams (new + classic, both use cpe:2.3:a:microsoft:teams):
added to both the NVD-CPE registry and the cvelistV5 registry (defense in
depth — a fresh Teams CVE from Microsoft's own CNA data can predate NVD's
CPE processing, as seen with Edge CVE-2026-58523 today).
Verified resolution against realistic installed-program name strings
(Desktop Runtime/SDK/Framework variants, Teams, Teams Machine-Wide).
Deferred (separate, larger pieces — not in this change):
- SAP Business Client/GUI/Analysis for MS Office: NVD's CPE for these covers
a whole minor version (e.g. all of 2.8) with no patch-level (PL/SP)
granularity, so a naive match would false-positive on already-patched
installs (tester's own example: 2.8 SP29 flagged when only earlier SPs are
vulnerable). Needs a dedicated PL/SP-aware matcher — parse the SP/PL
number out of the installed version/display string and compare against a
curated per-CVE fixed-SP table sourced from SAP's own advisories, similar
in shape to the Samsung SMR-vs-ASB precision work. Real scope, own turn.
- Microsoft Teams classic EOL flagging: no endoflife.date product exists for
Teams; would need a hardcoded EOL/retirement-date entry (same shape as the
existing ms_lifecycle_service exotics for Silverlight etc). Small add-on,
can do on request.
1. NVD/OSV per-(product,version) cache TTL 7d → 24h. A version queried
BEFORE a new CVE for that exact version is published stays cached empty
for the whole window, hiding the CVE from every host on that version
until it expires — exactly the tester's CVE-2026-14152 (undetected) vs
CVE-2026-13778 (detected, same day) discrepancy. 24h still collapses
most redundant traffic (many hosts share a version).
2. cvelistV5 range parser: some CNA records emit "version" == "lessThan"
(e.g. a few Chrome entries) — read literally that's a zero-width,
impossible range, so the CVE was silently unmatchable. NVD treats these
as an open floor (no lower bound); we now do the same instead of
dropping the CVE. Verified against the exact malformed record.
3. Added Microsoft Edge (Chromium-based) to the cvelistV5 registry — it was
only in the NVD-CPE registry, so a fresh Edge CVE with no NVD-CPE data
yet (verified: CVE-2026-58523 has none) was invisible to cvelistV5
detection too, despite the CNA (Microsoft) publishing its own
affected[]/cpeApplicability directly in the record.
4. Graph get_detected_apps: paced with a 300ms minimum gap between calls on
one client. A device-by-device sync loop was sustaining 429s from Graph
(single-call retry wasn't enough against back-to-back device calls).
(No change: the "400 then 200" on the first device of the beta fallback is
expected — _v1_detected_apps_ok is per GraphClient instance, and a fresh
client is built each sync run, so the first device in a run always probes
v1.0 once before flipping to beta for the rest of that run.)
1. Samsung SMR precision (tester's point 1): security.samsungmobile.com's
yearly page (?year=YYYY) serves ALL ~12 months' content server-side in the
raw HTML — the accordion is pure CSS/JS, it doesn't gate what's delivered
— so it IS reliably parseable per month, contrary to what I assumed
earlier. New samsung_smr_service parses each SMR-MMM-YYYY block: Google
Critical/High CVEs MINUS the "Not applicable to Samsung devices" list,
plus Samsung Semiconductor Critical/High fixes. Cached per year (24h).
android_cve_service.check_android_cves now takes `manufacturer`: Samsung
devices try the SMR month first (source 'samsung-smr'), falling back to
raw ASB (source 'android-asb') only when SMR doesn't cover that month or
the fetch fails. Non-Samsung Android stays on ASB. This directly fixes
the false positive the tester hit: CVE-2025-59604 (Qualcomm-only) is
explicitly "Not applicable to Samsung devices" on Samsung's own page and
is now excluded — verified against the live page (absent from the parsed
June-2026 set).
2. Cache bug (tester's point 2): a device stuck showing only the newest
month's CVEs despite older months (verified live: 2025-09 had 59, 2025-12
had 57 AOSP critical/high CVEs) turned out to be stale cache entries from
before today's URL-format and SoC-section-filter fixes — the "non-empty is
immutable" cache rule kept serving pre-fix (empty or wrong) data forever.
Bumped _CACHE_PREFIX (asb_month_v2_) so every month is refetched under the
current, correct logic.
Both parsers verified against live data (JS + Python) before shipping:
19 months of Samsung SMR (2025-01–2026-07) parsed without error with
plausible per-month counts; ASB severity counts confirmed non-zero and
variable across all 12 lookback months.
New security-advisory section, independent of asset findings: a rolling view
of what's being actively exploited in the wild so operators see 0-days/
exploited CVEs even before a scanner flags an affected asset.
- Reuses the KEV catalog enrichment already fetches + caches (24h); added
vendor/product/name to the cached entry (enrichment ignores the extras).
- advisory_service.get_recent_kev: newest KEV entries, annotated with whether
the CVE is already in inventory (+ asset count) via one grouped query.
- GET /api/v1/advisories/kev-recent (new advisories router).
- Dashboard widget "Actively Exploited · CISA KEV": CVE, vendor/product,
date added, 🔒 ransomware flag, and an "In inventory · N" badge (vs
"not seen"). Rows link to our CVE detail; View All → the CISA catalog.
Backend py_compile + frontend tsc clean.
Raw ASB import created false positives on Samsung (and any) devices: ASB
lists chipset-vendor CVEs (Qualcomm/MediaTek/Unisoc/Imagination/Arm) that
only affect devices with that SoC — Samsung's own SMR marks many "Not
applicable to Samsung devices". The parser now walks section headers + rows
in order and keeps only the AOSP sections (Framework/System/Kernel/Runtime/
Media/Google Play/Widevine) that apply to any Android device at that patch
level; SoC/vendor sections are dropped.
Verified on 2025-09: 115 total → 59 AOSP kept, ~49 SoC dropped; the Framework
KEV CVE-2025-48543 is retained.
(Samsung's own page can't scope per-device either without JS — it loads
month bodies via AJAX — so AOSP-section filtering is the robust fix. Samsung-
proprietary SVE CVEs remain out of scope.)
VulnOverrideService.__init__ requires db, but the bulk cvelistV5 ZIP date
pass called it with no args → "missing 1 required positional argument: 'db'"
and fell back to slow per-CVE HTTP lookups. db is in scope; pass it.
- android-asb: from 2026 Google nests the bulletin under a year segment
(/bulletin/2026/2026-01-01); older months stay flat (/bulletin/2025-10-01).
fetch_asb_month now tries the year-nested URL first, then flat. Verified:
2026-01 → nested 200/flat 404; 2025-xx → nested 404/flat 200.
- cvelistV5 scan: stop using lessThanOrEqual as fixed_version — that bound
means the version is still affected (no published fix), so leave
fixed_version empty (correct "no patch available", e.g. 7-Zip CVE-2026-58052
≤26.02). Only lessThan is a real fix target.
- app-cve OS scan: add macOS (cpe:2.3:o:apple:macos) alongside iOS/iPadOS so
Mac assets get OS CVEs (e.g. Apple CVE-2026-43700, previously only Defender
saw it).
Future ASB months Google hasn't published yet return 404. fetch_asb_month
only cached successful months, so every Android device re-fetched the same
404 months on every sync (the tester saw 2026-01…06 hammered dozens of times).
Now empty/404 results are cached too, with a 3-day TTL: months with CVEs stay
immutable/cached forever; empty months are skipped for 3 days, then retried
so a newly-published month is picked up. Legacy list-format cache entries
still read correctly.
(The 404s are expected — those months aren't published on source.android.com
yet; the 2025 months that exist already produced android-asb findings.)
For Intune-managed Android devices, raise real-CVE findings for the months
the device is behind on patches. Device patch level (androidSecurityPatchLevel,
e.g. 2025-03-01) → every monthly Android Security Bulletin published after it
lists CVEs the device hasn't received. Fetched from source.android.com
(stable, static, per-month) and parsed (CVE + severity, severity carried
across rowspan rows); cached per month in a Setting.
Source 'android-asb' (real CVE ids → EPSS/KEV/CVSS enrichment applies). Wired
into the Intune sync alongside the patch-level-staleness headline finding.
Why not Samsung's SMR page: securityUpdate.smsb ignores the year/month query
param and loads the month via JS — a plain fetch returns the same latest month
regardless (verified: 2025-03/-04/-09 all identical). ASB is the upstream
source for the Google CVEs Samsung ships (the security-critical bulk).
Samsung-proprietary SVE CVEs aren't covered (their page is unscrapeable).
Volume guards: Critical + High only, last 12 months (tunable via _MAX_MONTHS
/ _WANT_SEV). Verified month math + the ASB parser against the live bulletins
(2025-09 → 109 CVEs, per-month distinct).
Why the tester saw nothing: the manual "App CVE Scan" only LOADED the cached
index, it never built it — only the nightly did, and that build likely timed
out on the 557 MB download (120 s). So 7-Zip CVE-2026-58052 / Notepad++
CVE-2026-52885 (both in-registry, in-range) were never matched.
- Manual scan now builds the index when it's missing (same result as the
nightly), then caches it.
- ZIP download timeout 120 s → 600 s.
- Index build pre-filters on raw bytes (only JSON-parse files mentioning a
curated vendor) → ~99% fewer json.loads, build drops from minutes to ~a
minute.
Verified the two CVEs' vendor/product (7-Zip/7-Zip, notepad-plus-plus) match
the registry and the installed versions (26.01 ≤ 26.02, 8.9.5 < 8.9.6.4) fall
in range — so they will now be detected once the index exists.
Item 3 — the inverse of detection. Wazuh's CPE match over-reports CVEs across
product editions (a SQL Server 2019 / 15.x host carrying a CVE that only
affects 16.x/17.x). Now: for each open Wazuh finding, read the CVE's
cvelistV5 affected[] ranges scoped to the matched product, and if the
installed version is provably OUTSIDE every clean range → mark
status=false_positive (reversible via unmark, defer_reason explains why).
Conservative guards (never hide a real finding):
- Wazuh source only (the over-matcher); Nessus/app-scan/EOL untouched.
- Product match needs ≥2 shared significant tokens → only multi-word
products (SQL Server, Visual Studio…) are ever scoped; single-token apps
are left alone.
- If any relevant affected entry has no clean numeric range → abort (keep).
- If installed is inside ANY affected range → keep (it's real).
Nightly job runs it after the scan; manual POST /suppress-false-positives.
Verified on the tester's exact CVEs (installed 15.0.4013.40): CVE-2026-26116
(17.x-only) + CVE-2026-33120 (16.x-only) suppress; CVE-2024-29047 (covers
15.x) correctly kept.
The source-filter branch did `from app.models.vulnerability import
Vulnerability` locally, which made Vulnerability a function-local everywhere —
so the later vuln_count query (line 465) raised UnboundLocalError whenever no
source filter was passed. Vulnerability is already imported at module level;
dropped the redundant local import.
Catches CVEs the NVD-CPE scanner misses: fresh CVEs NVD hasn't CPE'd yet, or
ones filed under a different CPE product string than we curated (TeamViewer
lives under teamviewer:remote, not teamviewer:teamviewer). Matches directly
against cvelistV5 affected[].vendor/product + version ranges — the
authoritative MITRE feed we already cache as a ZIP.
- Curated product registry (name-regex → cvelistV5 vendor/product pairs):
TeamViewer, Notepad++, Devolutions RDM, 7-Zip, Firefox, Chrome, VLC,
PuTTY, WinSCP, Wireshark, FileZilla, Zoom. Unknown software ignored.
- build_product_index: one walk over the cached ZIP → {product_key:
[{cve,start,lt,lte}]} for curated products only; cached in a Setting,
rebuilt by the nightly job (the 557 MB walk happens once, not per scan).
- scan_asset: resolve installed software → indexed CVEs → version-range
check → upsert (source 'app-scan', shared badge/cross-confirm/enrichment).
- Wired into run_app_cve_scan (loads cached index; skipped+logged if not
built yet) and the nightly job (builds index first).
Verified against the real CVE JSON: TeamViewer CVE-2026-23572 (<15.74.5),
Notepad++ CVE-2026-52885 (<8.9.6.4), Devolutions CVE-2026-13372
(2026.2.5–2026.2.11) all detect at affected versions and correctly do NOT
match patched versions.
assets: the previous filter keyed Nessus on nessus_host_uuid, but imports
whose host had no uuid (matched by hostname) carry it nowhere — they fell
into MANUAL. Now each scanner filter also matches assets with ≥1 finding
whose `sources` list contains that scanner ("nessus"/"wazuh"/...), which is
the maintained multi-source truth. MANUAL = no linkage AND no scanner vuln.
ui: the no-dependency AI-remediation renderer showed GFM tables as raw pipes,
literal "\n" inside code blocks, and "<br>" as text. Now: unescape literal
\n/\t, render <br> as a line break inline (without splitting the line, which
would corrupt table rows), and render GFM tables (header/sep/body).
Description bugs (tester screenshot):
1. lowercase vendor ("samsung Galaxy Tab A8") — Intune reports manufacturer
lowercase. Use a fixed vendor label (Apple iPhone / Apple / Samsung).
2. doubled model ("Samsung Galaxy Tab A8 Galaxy Tab A8 — EOL") —
upsert_eol_vulnerability appends the release label itself, so product_name
must be the VENDOR only. Now title reads "Samsung Galaxy Tab A8 — EOL" and
the full device name is written back into the package column.
Mapping fixes from the tester (he resolved the two I left open, and caught a
wrong one):
- SM-X236 was mapped to Tab A9+ → it's Tab A11+ (5G). Corrected.
- SM-X230 → Tab A11+ (Wi-Fi), SM-X130/X135 → Tab A11, SM-X356 → Tab
Active5 Pro. Added. All names verified against the live API.
From the tester's missing-model list: Tab A 7.0/8.0 (2015/16), Tab A9+ 5G
(SM-X236), Tab Active / Active2 / Active3 / Active5, XCover3/4/4s. All 13
new release names verified against the live endoflife.date API; SM-G556 /
G525 / G736 were already mapped.
Two codes left out pending the marketing name (avoid a wrong-EOL mapping):
SM-X356 and SM-X230 — not confidently identifiable.
Separate dashboard widget for phones & tablets, kept apart from the
desktop-software EOL widget (which now excludes mobile findings).
Reuses existing data — the findings are already vuln rows. New
finding_type=mobile filter on the vulns endpoint ORs the mobile cve_ids
(ANDROID-PATCH-%, EOL-IPHONE/IPAD/SAMSUNG-MOBILE/SAMSUNG-GALAXY-TAB-%) and
bypasses the pseudo-CVE exclusion. The widget feeds from it (top 10, cvss
desc → vendor-EOL 9 > patch ≥1y 8 > EOL-soon 5.5 > end-of-active-support 3),
deduped by cve_id+asset so the same EOL stream on N devices stays N rows.
"View All" deep-links to /vulnerabilities?finding_type=mobile, which the
list page now reads from the URL.
ponytail: one combined widget, not two — severity sort already surfaces the
worst (vendor-EOL reached) above the milder patch-staleness rows, and each
row's title says which it is. Split into two later if the volume warrants.
assets: the source filter used the creation-time `source` enum, so an asset
first created by Wazuh/Manual and later matched by Nessus stayed off the
"Nessus" filter. Now filter by the actual per-scanner id columns
(wazuh_agent_id / nessus_host_uuid / intune_device_id|defender_machine_id),
so a merged asset shows under every scanner that sees it. MANUAL = no linkage.
mobile-eol: expand the Samsung SM-code table from ~30 to ~100 models —
S20→S25, Note10/20, Z Fold/Flip 2→7, full A-series, XCover, Tab S6→S10 +
Tab A. All 100 release names verified against the live endoflife.date API.
Apple was never table-bound (it fuzzy-matches the full live list). Unmapped
Samsung models still skip (no false-positive). Graduated severity (eoas→low,
eol-soon→medium, eol→high) already comes from eol_service.
For Intune-managed phones/tablets, runs during the Intune sync (device dict
already in hand — no extra fetch):
1. Device-model EOL/EOS via endoflife.date, reusing eol_service
(fetch/cache/EOLStatus/upsert → EOL- pseudo-CVE). Model→release mapping:
- Apple: Intune reports the marketing name → fuzzy-match the endoflife
release label/name (iPhone 15 Pro Max → 15-pro-max, iPad Air (5th
generation) → air-5). Bare identifiers (iPhone15,3) are skipped.
- Samsung: SM-code has no textual overlap with endoflife → curated
SM-prefix table (S21–S25, A-series 5G, XCover, Tab S9/S10), matched by
prefix so region suffixes are ignored. Unmapped models are skipped (no
false-positive). All release names verified against the live API.
2. Android security-patch-level staleness from Intune's
androidSecurityPatchLevel: age >=90/180/365d → low/medium/high pseudo-
finding (ANDROID-PATCH-LEVEL-STALE, one per asset). The control instance
that flags "patches not actually applied" without scraping any vendor
bulletin. is_pseudo_cve recognises the new prefix.
Added androidSecurityPatchLevel to the managedDevices $select.
Deferred (not built): per-CVE Android bulletin scraping (Google ASB /
Samsung SMR) — brittle, no API.
Desktop Firefox on a Windows host was matching the Firefox-for-iOS CVE
(cpe:2.3:a:mozilla:firefox:*:...:iphone_os:*, target_sw=iphone_os). We
matched on vendor:product only and ignored the CPE platform field.
Now record each matching cpeMatch's target_sw (CPE index 9) and keep a CVE
for an asset only when target_sw is platform-neutral (*) or names the asset's
OS family (_os_family). Applied to both package and OS scans.
Cache key bumped to v2: → pre-fix rows (without target_sw) are ignored so the
stale FPs aren't served from cache; they re-fetch with the platform data.
Also: 503 backoff raised to >=3s × attempt over 4 tries (NVD 2.0 503s under
load even WITH a key — it's server-side, not auth), and the scan logs whether
NVD_API_KEY is present so a missing key is obvious in the logs.
Apple ships the precise OS version (e.g. 18.1.2) and NVD carries proper
version ranges for cpe:2.3:o:apple:iphone_os / :ipados, so it's the same
clean CPE-range check the desktop apps already use — no new machinery.
Reads asset.operating_system + asset.os_version (already synced from Intune),
so it covers Intune-only iPhones/iPads that have no syscollector packages.
Runs per asset regardless of package inventory; cached per (cpe, version) so
N devices on the same iOS build = one NVD query.
Android intentionally omitted: NVD only lists the base version (13/14/15)
without ranges → needs the Intune security-patch level + Android bulletin
parsing, a separate feature.
Three bugs from the first app-scan run:
1. Linux rpm/deb packages flooded NVD with junk queries
(python:python:4.6.5-3.el8, epoch 1:3.2, 2.43.0.windows.1, ...) →
503 storm + false-positives. Those packages are Wazuh's domain. Add
_clean_version: only clean dotted-numeric versions reach the scanner
("7.0.2 (34567)" → "7.0.2"); epoch/release-tag versions are skipped.
2. A transient NVD 503/429 cached an EMPTY result for 7 days → real CVEs
missed until TTL. _query_nvd_cpe now retries (3x backoff) and raises
_TransientNVD on persistent 429/502/503/504; lookup_cves returns []
WITHOUT caching so the next run retries. Permanent 4xx still cache empty.
3. v1.0 $expand=detectedApps 400s on this tenant → it 400'd once per
device, every run. Flip _v1_detected_apps_ok off after the first 400
and go straight to beta. _get now retries 429 honouring Retry-After.
NVD_API_KEY strongly recommended — keyless NVD is the main 503 source.
Maps installed software (Wazuh syscollector packages + Intune detectedApps)
to real CVEs via OSV and NVD-CPE with an own version-range check. Closes the
coverage gap for Intune-only / mobile devices that have no real scanner
(Intune managedDevices add every device but carry no CVE data; Defender TVM
only covers MDE-onboarded hosts with findings).
Design: curated + precise (low false-positives).
- Curated product registry (~24 common apps) name-regex → CPE/OSV; unknown
app names are ignored (no CPE auto-guessing → no FP storm).
- NVD-CPE: query per product CPE, then verify the installed version actually
falls inside each CVE's cpeMatch range ourselves (start/end incl/excl,
exact-version equal, wildcard skipped); versionEndExcluding → fixed_version.
- OSV: precise server-side version match for language ecosystems.
- Results cached per (product_key, version) in app_cve_cache (TTL 7d) so the
same version across N hosts = one query (and stays under NVD's rate limit).
Findings upsert as source 'app-scan' with real CVE ids → the normal
EPSS/KEV/CVSS enrichment + multi-source remediation apply, and they
cross-confirm with Wazuh/Nessus/Defender on the same (cve, asset).
Wiring:
- Migration 035 + AppCveCache model.
- Piggyback in intune_service._run_app_inventory (detectedApps already fetched).
- POST /api/v1/vulnerabilities/app-cve-scan (sync def → threadpool).
- Nightly scheduler job app_cve_scan_nightly (03:25 UTC).
- Frontend: app-scan source badge + filter + "App CVE Scan" button.
NVD_API_KEY recommended for scale (rate limit). Run alembic upgrade head.
Two tester bugs:
- Assets list: sorting Exposure or Risk descending surfaced the empty
("—") rows first instead of the real high scores. The generic numeric
sort branch didn't use nulls_last; now NULLs always sort last in both
directions.
- CVE detail "Affected Asset" linked to /assets?id= (just the asset list).
Now links to /vulnerabilities?asset_id=<id> → all findings on that asset.
Tester: Defender-sourced CVEs showed an empty Package/affected-software
column, although Defender's own UI lists the affected software (e.g.
"OpenSSL 1.1.1.0").
Cause: /api/machines/{id}/vulnerabilities returns CVE definitions only —
no per-device software. Now run_defender_sync additionally pulls the
tenant-wide SoftwareVulnerabilitiesByMachine assessment (one paginated
export), builds a (machineId, CVE) → "vendor name version" map, and sets
package_name on each Defender finding (and backfills it on an existing row
whose package was empty). Best-effort: if the tenant/plan doesn't expose
the export, behaviour is unchanged.
Tester: the port-based exposure score put nearly every Windows host at 100
(no separation), and the thing that actually matters — whether a host runs
a crown-jewel role enabling lateral movement / domain takeover — wasn't
captured.
- Migration 034 + model: assets.high_value_score (0-100) + risk_dimensions
(JSON roles) + _updated_at.
- app/services/risk_dimensions_service.py: detect_risk_dimensions(ports,
packages) → roles from syscollector ports (port + process) and installed
packages: Domain Controller, ADCS/CA, backup servers, SW-distribution,
Exchange, WSUS, MSSQL, DNS, DHCP, WinRM. Score = max(weight) + 0.3·rest
(cap 100). risk_factor() maps it to a URS band (>=90→1.5 … else 1.0).
- exposure_service: rebalanced port weights — baseline Windows
(SMB/MSRPC/NetBIOS/WinRM) now LOW; real remote-control/cleartext
exposures (Telnet/VNC/RDP/FTP) stay HIGH. Risk detection runs in the same
pass (reuses fetched ports + one get_packages call).
- urs_service: URS uses max(operator criticality factor, role factor) — a
DC/ADCS host rises to critical weighting even at criticality=normal;
operator can still set higher. criticality field untouched.
- assets API: high_value_score + risk_dimensions in the response + sortable;
Assets page gets a "Risk" column with score + role badges.
Verified detection: DC(88+389)→100, SQL pkg+WinRM→79, plain Win→0,
Exchange+Veeam→100. Migration 034 required: alembic upgrade head.
Roles need Wazuh syscollector (ports+packages); Nessus/Intune-only → v2.
Tester: the Assets page showed at most 100 entries with no paging and no
page-size control.
- Backend list_assets now returns the pre-pagination total in an
X-Total-Count header (body stays a plain array — the asset dropdowns on
the scans/vulnerabilities pages still consume a list). limit/offset were
already supported.
- Assets page: page + page-size (50/100/250/500/1000) controls, prev/next/
first/last, "X–Y of N", debounced search, page resets to 1 on any
filter/search/page-size change.
- The asset dropdowns on the scans + vulnerabilities pages now request
limit=1000 so large estates aren't silently truncated there either.
Tester-approved: show the newest MSRC KB per (build-branch + update-type)
— normally one entry, two on hotpatch hosts (Security Update + Security
Hotpatch Update); drop the superseded rest.
- msrc_service now stores the MSRC update type (SubType) in the fix row's
`detail`, so the detail view can keep Security-Update vs Security-Hotpatch
separately. (Re-run MSRC Enrich to populate it on existing rows.)
- get_vuln_remediations: dedup fixes to newest per (branch, type); for a
Windows-OS finding narrow to the host's own build branch (1–2 entries).
Special case — Microsoft 365 Apps findings: the MSRC KBs are perpetual/MSI
Office builds that never match the installed Click-to-Run channel build, so
branch-filtering is skipped (it would hide everything). Instead the deduped
per-Office-version set + the aka.ms pointer are shown, and a clear
"Update via Office channel to build <channel build>" hint is prepended —
the actually-actionable fix for C2R installs.
Verified: Windows 10.0.26100.32690 → only the 26100 SU + 26100 Hotpatch;
M365 example 7 KBs → 4 (newest per Office line + aka.ms).
Tester: clicking "M365 CVEs" froze the whole web GUI until the cvelistV5
comparison finished — same class of bug as the earlier date-backfill case.
Cause: these endpoints were `async def` but call fully-synchronous,
blocking services inline (httpx + DB), starving the single asyncio event
loop. Fix: declare them as plain `def` — FastAPI/Starlette then runs them
in a worker threadpool, off the event loop, so the GUI stays responsive
(responses/counts unchanged). Converted: m365-check, eol-check,
exploit-intel/refresh, bulk enrich (Refresh Threat Intel),
override/vulnrichment (Correct CVSS), and the Nessus /sync trigger.
Also:
- Intune detectedApps: v1.0 `$expand=detectedApps` returns HTTP 400 on the
managedDevice; now falls back to the beta /detectedApps navigation
collection (paginated). Still best-effort.
- Defender 403 ("Missing application roles"): /api/machines needs
Machine.Read.All in addition to Vulnerability.Read.All — documented in
the Intune settings tooltip + .env.example. (Sync stays non-fatal.)
The README still described VulnCheck as Wazuh-only with AI — out of date.
Updated to reflect what's actually shipped:
- Tagline/overview: multi-source (Wazuh + Nessus + Microsoft Intune /
Defender), enrichment, EOL/M365, multi-provider auth.
- Features: new "Scanner & Inventory Integrations" (Wazuh/Nessus/Intune/
Defender + cross-confirm + soft-inactive lifecycle), Threat-Intel
Enrichment (EPSS/KEV/EUVD/exploit catalogs/cvelistV5/Vulnrichment/NVD),
EOL & Microsoft 365 detection, Multi-Source Remediation (scanner/MSRC/
Ubuntu/RHEL/OSV), AI on-demand remediation.
- Configure-integrations + Integrations note + RBAC settings row + the
architecture diagram updated for the new sources.
- Removed the Roadmap section (its items — LDAP/AD auth, more SIEM
integrations, scanning without Wazuh — are now shipped).
Tester, two MSRC-detail changes:
1) The MSRC remediation listed a KB for every Windows version (noise).
Now the block shows only the KB(s) matching the affected host's Windows
build branch (e.g. installed 10.0.26100.32690 → only the 26100-line
KBs, newest first); falls back to the full list when nothing matches or
the host build is unknown. Filtering is server-side in
GET /vulnerabilities/{id}/remediations using the asset os_version.
2) Prepend the deterministic MSRC update-guide link for the CVE
(https://msrc.microsoft.com/update-guide/vulnerability/<CVE>) to the
MSRC block. Advisory items now render as clickable links in the UI
(also surfaces OSV/Ubuntu advisory URLs that were previously hidden).
No migration / no new fetch — pure response shaping over cached MSRC rows.
Tester: M365 findings showed the synthetic placeholder description; the
CVSS-correction cascade (by design, for efficiency) never touches
title/description. Fill them separately from the authoritative source.
apply_real_cve_metadata(db, cve_ids): fetches the real CVE title + English
description from CVE.org cvelistV5 raw and writes them onto the M365
findings (first_detected_by='m365_check'), keeping a trailing note that
the finding originated from the Microsoft 365 Apps source (so the
provenance stays clear). Runs after CVSS-correction + enrichment in both
run_m365_check and run_m365_for_packages.
Verified live: CVE-2026-45456 → title "Microsoft Outlook and Word Remote
Code Execution Vulnerability" + the real type-confusion description.
Optional per-device CVE feed from Microsoft Defender for Endpoint (TVM),
toggled by `defender_tvm` in the Intune settings card. Reuses the same
Entra app (tenant/client/secret) but a separate API + scope.
- app/integrations/defender_client.py: client-credentials token for scope
https://api.securitycenter.microsoft.com/.default; get_machines() +
get_machine_vulnerabilities() (paginated); test_connection().
- app/services/defender_service.py: run_defender_sync — match Defender
machine → asset by computerDnsName (pin defender_machine_id), upsert
REAL Vulnerability rows (source='defender', cross-confirm via add_source
with wazuh/nessus/intune), then VULNERABILITY_DETECTED audit + EPSS/KEV/
date enrichment. Real CVE ids → cvelistV5/MSRC/OSV remediation applies.
- intune_service.run_intune_sync runs the Defender pass when enabled.
- Settings card: "Defender TVM CVEs" toggle (needs Vulnerability.Read.All
on WindowsDefenderATP + Defender licensing).
Needs Application permission Vulnerability.Read.All (WindowsDefenderATP) +
admin consent. No migration (defender_machine_id added in 033).
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 (follow-up to Plan P 45524f7): M365 Apps CVEs were created with a
placeholder severity=medium / cvss=None and a thin description. Pull the
real metrics straight away at check-in and clarify the description.
- After an M365 check creates/updates findings, run the CVSS-correction
cascade (vulnrichment → cvelistV5 → NVD) for the touched CVE ids, then
enrich_vulnerabilities (EPSS/KEV/EUVD + NVD dates). These are real CVE
ids, so they resolve like any other. The override path never touches
`description`, so the M365 source note is preserved.
- Description rewritten: states the affected product, installed vs fixed
build + "update via the Office channel", explains the detection source
(Wazuh syscollector build vs MS365 release notes — not in NVD/Wazuh),
and notes metrics come from MSRC + Vulnrichment/cvelistV5 with a pointer
to the MSRC remediation section.
No migration. CVSS/severity now populated on the next M365 Enrich run.
Tester idea: use the OSV.dev aggregator to broaden Linux/cross-ecosystem
coverage. Design question (fallback vs overwrite) → neither clobbers:
OSV is added as its OWN source (own UI block, source='osv'), never
overwriting vendor rows, and it also fills gaps for distros/ecosystems the
vendor providers don't cover (Debian, SUSE, Alpine, Rocky, AlmaLinux, npm,
PyPI, Go, ...).
- linux_remediation_service.fetch_osv(): GET api.osv.dev/v1/vulns/<CVE> →
per-ecosystem fixed versions (skips commit-hash "fixes") + filtered
advisory references (USN/RHSA/DSA/GHSA/errata/SUSE/Alma...).
- enrich_cve_linux() now stores the matching vendor provider AND OSV as
separate sources per CVE.
- GET /vulnerabilities/{id}/remediations triggers on-demand enrichment for
ANY non-Windows host (was Ubuntu/RHEL only), so OSV covers Debian/SUSE/
etc.; cached in cve_remediations, off the event loop.
- UI labels the block "via OSV.dev (aggregator)".
Verified live: CVE-2023-48795 → OSV yields Debian DSA / GHSA / FreeBSD
advisory links the vendor providers don't. No migration (reuses 032).
Tester feature (step 2 of multi-source enrichment): add Linux distro fixes
alongside the Nessus scanner solution and MSRC, reusing cve_remediations.
Unlike MSRC (monthly bulk doc), the Linux trackers are queryable per-CVE,
free and unauthenticated — so we fetch on demand when the CVE detail opens
for a Linux host, then cache into cve_remediations (repeat views instant,
re-fetch refreshes).
app/services/linux_remediation_service.py:
- provider_for_os(): Ubuntu -> ubuntu; CentOS/RHEL/Rocky/Alma/Oracle/
Fedora -> redhat (CentOS/Alma rebuild RHEL, so the RHSA + fixed NVR is
the actionable fix).
- fetch_ubuntu(): ubuntu.com/security/cves/<CVE>.json → per-release fixed
package versions (kind=fix), USN advisories, mitigation.
- fetch_redhat(): access.redhat.com securitydata → RHSA advisory + fixed
package NVR + errata link, mitigation/statement.
- enrich_cve_linux(): pick provider by host OS, replace cached rows.
GET /vulnerabilities/{id}/remediations now triggers this enrichment
(off the event loop via asyncio.to_thread) on first view for a Linux host.
UI groups it under "via Ubuntu USN" / "via Red Hat / CentOS / Alma".
Verified live: CVE-2024-6387 (regreSSHion) returns per-release openssh
fixed versions + USN-6859-1 + the LoginGraceTime mitigation.
No migration (reuses cve_remediations from 032).
Tester feature (step 1 of multi-source enrichment): augment the Nessus-
only scanner remediation with authoritative Microsoft data, for Windows
OS *and* MS products (Office/365, .NET, SQL, Exchange, ...).
MSRC's per-CVE endpoint 404s, so we ingest the monthly CVRF documents
(api.msrc.microsoft.com/cvrf/v3.0/cvrf/{YYYY-Mon}, ~4 MB each) and extract
per-CVE remediations:
- fixes: KB number + FixedBuild + download URL (Remediations Type 2/3)
- workarounds / mitigations (containment): Notes "Workarounds" /
"Mitigations", HTML stripped to text — covers the "no KB yet, only
containment" case the tester called out.
- Migration 032 + model: cve_remediations (CVE-level, source-tagged).
- app/services/msrc_service.py: refresh_msrc() pulls the last N monthly
docs (default 18, setting msrc_months_back), stores rows only for CVE
ids already in the DB (keeps it relevant). Re-parse replaces a CVE's
rows so MS revisions (containment-only -> KB later) self-update.
- Endpoints: GET /vulnerabilities/{id}/remediations (scanner + external,
grouped by source) and POST /vulnerabilities/msrc/refresh (fire-and-
forget background thread). Weekly scheduler job (Sun 04:40).
- UI: CVE detail now renders a Remediation block per source ("via scanner"
/ "via Microsoft (MSRC)") with KB + download links, workarounds, and
mitigation/containment. "🛡️ MSRC Enrich" button on the vuln list.
Verified parse against the live 2026-May CVRF doc (KB+build+catalog link
per Windows build). Migration 032 required: alembic upgrade head.
Step 2 (Linux: Ubuntu USN / CentOS errata) reuses cve_remediations next.
Tester: AI remediation for EOL/EOS findings was generic and sometimes
hallucinated a patch that doesn't exist (the product is out of support).
For pseudo-CVE findings (cve_id starts with EOL- / NESSUS-PLUGIN-), the
prompt now switches to an end-of-life system prompt: state the EOL/EOS
risk, name the supported target release + timeline, give OS-specific
upgrade/replace commands and download location, list interim compensating
controls / containment while the migration is pending, and a verification
step — explicitly told NOT to suggest applying a non-existent patch.
Real-CVE findings keep the existing patch-focused prompt.
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 false-positive: the evergreen Microsoft Edge browser (148.x/149.x)
was flagged EOL as "Azure Stack Edge" (end 2024-03-31).
Cause: the lifecycle-export matcher was bidirectional (rn in target OR
target in rn). "Microsoft Edge" normalises to just "edge" ("microsoft" is
stripped), and "edge" is a substring of the unrelated listing
"azure stack edge" — so the reverse direction matched.
Fix: one direction only — the LISTING name must be contained in the
product name. Verified against the live April-2026 export:
Microsoft Edge / Edge WebView2 -> no match (was Azure Stack Edge)
SQL Server 2014 Management Objects -> SQL Server 2014 (kept)
Exchange Server 2016 -> kept
a real "Azure Stack Edge" device -> still matches
Existing wrong EOL-MS-LIFECYCLE-Azure_Stack_Edge rows on Edge hosts won't
be recreated; dismiss them once via the detail-page Dismiss button.
Tester: the AI remediation output showed raw markdown (**bold**, ``` fences)
verbatim.
Added a dependency-free mini-markdown renderer for the AI Remediation
section: fenced code blocks become dark, horizontally-scrolling <pre>
blocks with a language label and a Copy button; **bold**, `inline code`,
headings, and bullet/numbered lists are formatted. Other text passes
through.
Also added an "EOL check done: …" summary log line (incl. the
ms_lifecycle/exotics finding count) so it's verifiable whether the
Visual C++ / Silverlight MS-lifecycle fallback fired.
Tester: a cross-confirmed (Wazuh + Nessus) CVE showed the generic
remediation "Install the patches listed below." instead of the specific
Nessus plugin solution ("Upgrade to Paessler PRTG ... 18.2.40.1683 or
later", plugin 277614).
A CVE can match several Nessus plugins on one host; a catch-all plugin's
generic solution was overwriting / blocking the real plugin's fix because
the precedence relied on a title-based specificity heuristic.
Now remediation precedence is text-based: a non-generic solution always
replaces an empty/generic one, and a generic solution never overwrites a
specific one. _is_generic_remediation() flags catch-all phrases
("install the patches listed below", "apply the appropriate patch",
"n/a", "no known fix", ...). Existing wrong rows self-heal on the next
Nessus sync.
Tester: let the OpenRouter API key be set in the UI like the other
integrations, not only via env.
- openrouter_api_key added to PROTECTED_SETTING_KEYS → encrypted at rest
(same Fernet key as Wazuh/SMTP/Nessus configs). ai_service already reads
env first, then this setting, so no service change needed.
- Settings page: new "AI Remediation (OpenRouter)" card — API key
(password, shows configured/not-set), model (default openrouter/free),
optional comma-separated fallbacks, Save + Remove Key. Admin-only (PUT
/settings is RequireAdmin).
- settings GET redaction now treats an empty decrypted protected value as
unset, so "Remove Key" reflects correctly after reload.
Env OPENROUTER_API_KEY still overrides the stored value when present.
Tester, two EOL-detail/widget polish items:
1) CVE detail: the External References block (NVD / CVE.org / Exploit-DB
links) is now hidden for EOL- / NESSUS-PLUGIN- pseudo-CVEs — those have
no real CVE record, so the links led nowhere. Shown only when cve_id
matches /^CVE-/.
2) Dashboard "Newly EOL / EOS" widget: replaced the (usually empty) CPR
column with the affected asset's hostname, linked to
/vulnerabilities?asset_id=<id>&eol=1 so a click shows ALL of that host's
EOL findings. renderVulnWidget gains an assetColumn option; the two CVE
widgets keep CPR.
Tester: EOL findings (and any vuln) could only be dismissed from the list,
not the detail page — which only showed a status badge.
Added a Dismiss button next to the status badge: marks the finding
false-positive with an audit-logged reason (hides it from the default
active list). When already false-positive it flips to Reactivate. Reuses
the existing /false-positive and /unmark-false-positive endpoints. Works
for EOL pseudo-CVE rows too — they're ordinary vulnerability records.
Tester: the "Newly EOL / EOS" widget's first column showed the synthetic
EOL pseudo-CVE id (EOL-CHROME-148) which is noise. renderVulnWidget now
takes optional labelField/firstColHeader; the EOL widget uses
package_name with a "Product" header (tooltip still shows the id). The two
CVE widgets are unchanged.
Tester feature request: generate OS-aware fix guidance per CVE via
OpenRouter (OpenAI-compatible).
- app/services/ai_service.py: calls OpenRouter /chat/completions via httpx
(no new SDK dep). Config from env first, then settings table:
OPENROUTER_API_KEY, OPENROUTER_MODEL (default openrouter/free),
OPENROUTER_FALLBACKS (route=fallback). Builds an OS-aware prompt from the
CVE + host (package, installed/fixed version, OS, scanner remediation)
and asks for concrete commands + verification + mitigation. Maps 401/402
to clear errors.
- POST /vulnerabilities/{id}/ai-remediation runs it via asyncio.to_thread
(off the event loop). GET /ai-remediation/status reports whether a key
is set so the UI hides the button when unconfigured.
- CVE detail: "🤖 AI Remediation" section with Generate/Regenerate button,
shown only when configured.
- .env.example documents the OpenRouter keys.
Keyless by default = feature hidden; no behaviour change unless a key is set.
Tester: Nessus already provides a per-finding remediation ("solution");
show it instead of burying it in the description blob.
- Migration 030: add vulnerabilities.remediation (TEXT).
- Nessus sync stores the plugin solution in the new remediation column
(was appended to description); description now holds the synopsis only.
- API exposes remediation; CVE detail renders a "🛠️ Remediation (via
scanner)" section below Affected Package when present.
Migration 030 required: alembic upgrade head.
Tester: Visual C++ Redistributables (and other MS products) stayed in the
coverage gap, never flagged EOL.
Root cause: the MS-lifecycle fallback only ran when resolve_product_slug
returned None. But VC++ redistributables map to the 'visual-cpp' slug, so
they took the endoflife.date path — which has no data matching
redistributable build numbers → no finding — and the fallback (which has
the hardcoded VC++ 2008-2013 EOL dates) never ran.
Restructured both EOL loops (manual endpoint + nightly job): try
endoflife.date first, and run the MS-lifecycle export / hardcoded-exotics
fallback whenever endoflife.date yields nothing actionable — not only when
the name is unmapped. Now VC++ 2008-2013 → EOL, 2015-2022 → supported
(not flagged), Silverlight → EOL, and any MS product endoflife.date can't
resolve a release for gets a second chance against the lifecycle export.
Tester: CPR is the best single risk-based metric, so the main list should
lead with it. Changed the default sort from priority to cpr (descending).
Users can still click any column header to re-sort.
The /dates/backfill endpoint awaited the whole job before responding, so a
ZIP walk over thousands of CVEs outran the reverse-proxy request timeout →
503.
Now it launches the date fill in a detached daemon thread (own DB session)
and returns 202 immediately. A module-level guard prevents a double-click
from stacking concurrent 600 MB ZIP walks. Progress is in the backend logs
("CVE dates:" / "Date backfill done"). Button shows "started" and refreshes
the list a few seconds later.
Tester confusion: "Refresh Threat Intel" never set published dates
(nvd_dates_set=0) — by design it skips the date pass to stay fast/non-
blocking — so dates only filled on the nightly job, leaving the operator
waiting and unsure.
New POST /vulnerabilities/dates/backfill: date-only enrichment (no
EPSS/KEV) over every CVE row missing a published_date, via the cvelistV5
ZIP/raw cascade with NVD fallback. Runs in a worker thread
(asyncio.to_thread) with its own DB session, so the GUI stays responsive.
No NVD API key needed — cvelistV5 is the primary, keyless source.
Adds a "Backfill Dates" button on /vulnerabilities so the operator can
trigger it explicitly and see the result, instead of waiting for the
nightly scheduler.
The per-run cap (4000) was applied before the cvelistV5 ZIP pass, so a DB
with more undated CVEs than the cap stayed partially dated for several
nightly runs. During that partial state the "Newly Published" widget
showed a misleading subset — newest-published rows that had been dated
floated above genuinely-recent CVEs still sitting at NULL.
A local ZIP walk is cheap, so it should not be capped. Now the ZIP pass
runs over the entire missing set (dates a fresh DB completely in one run);
the per-run cap applies only to the slow per-CVE raw/NVD fallback for
whatever the ZIP didn't contain.
Tester: the "Fixed in" field showed a raw 40-char git commit hash
(7713bd320ed4fc3d08a22...) that overflowed into the FIX badge and looked
broken.
Linux-kernel CVEs report their fix as an upstream commit hash, not a
Debian package version. Added formatFixedVersion(): a hex string (12-64
chars, no version separators) is shown as "upstream commit <short>" with
the full hash on hover; real versions render unchanged. Added break-words
so nothing overflows the cell.
Tester suggestion: do the date backfill like the CVSS-correction cascade —
download/parse the cvelistV5 ZIP snapshot instead of thousands of per-CVE
fetches when many CVEs need dating at once.
Hybrid with a threshold, reusing the CVSS cascade's SHARED disk cache
(/tmp/vulncheck-cvelistv5-cache.zip, 12h TTL):
- missing > 200 -> one ZIP snapshot + local walk (cveMetadata
datePublished/dateUpdated). Download-free when CVSS-correction already
pulled the ZIP. Fresh-DB bulk fill drops from ~33 min to seconds.
- missing <= 200 -> per-CVE raw GitHub (no 557 MB download for a handful
of new CVEs on nightly runs).
Anything the ZIP didn't contain falls through to the per-CVE path, then
NVD as last resort.
Adds VulnOverrideService.load_cve_dates_via_zip() + a shared
_download_cvelistv5_zip() helper. Persistent date cache unchanged.
Tester: the result toast under the EOL Check / Exploit Intel / M365 CVEs
buttons ("30 assets, 4203 packages checked …") was cut off the left edge
at 100% browser zoom (only visible at 75%).
It was absolute right-0 + whitespace-nowrap, so the long single line grew
leftward past the viewport edge when the button sits on the left of the
toolbar. Switched to whitespace-normal + fixed w-60 so it wraps to a few
lines and stays on-screen.
Tester: a Nessus sync correctly soft-inactivated asset #36 (verified in
DB + audit ASSET_DEACTIVATED), but it then vanished from the Assets view
entirely — not even visible with an INACTIVE badge.
Cause: the asset list defaulted to ACTIVE-only and hid INACTIVE behind the
"Show inactive" toggle. But the whole purpose of the Status column is to
surface soft-inactive hosts inline (amber INACTIVE badge), not bury them.
Fix: default list now shows ACTIVE + INACTIVE (hiding only
DECOMMISSIONED, which is operator-final). The toggle is repurposed to
"Show decommissioned" (include_inactive=true reveals those). Status-filter
and audit/history behaviour unchanged.
Tester suggestion: reuse the CVSS-correction cascade's authoritative
source (CVE.org cvelistV5) for the published/updated dates instead of
crawling the rate-limited NVD API one CVE at a time.
The NVD-only backfill needed a 6.5s sleep between requests without an API
key, so dating thousands of CVEs took hours (and, before the scheduler
thread-pool fix, froze the GUI the whole time).
Now the date backfill hits the official cvelistV5 raw JSON on GitHub first
(cveMetadata.datePublished / .dateUpdated — present for every published
CVE, GitHub raw has no aggressive rate limit, no sleep needed), and only
falls back to the NVD API per-CVE when cvelistV5 has no record. Per-run
cap raised to 4000 since the fast path no longer sleeps. Persistent cache
unchanged — each CVE still fetched once ever.
This gives both published_date and last_modified_date (the tester
specifically wanted dateUpdated) and drains a fresh DB in ~1-2 nightly
runs instead of weeks.
Verified live: cvelistV5 returns datePublished + dateUpdated for
CVE-2024-3094, CVE-2021-44228, CVE-2014-0160.
Tester: while the post-startup enrichment (EPSS + NVD date backfill) ran,
VulnCheck was completely unusable — only the logo, no login — for the
whole duration.
Root cause: the scheduler is an AsyncIOScheduler and every job was
declared `async def`, so APScheduler ran them as coroutines ON the single
asyncio event loop. But the job bodies are 100% synchronous blocking I/O
(sync httpx, time.sleep for NVD rate-limiting, heavy DB work) — there is
not one `await` in the whole file. A blocking coroutine starves the event
loop, so the ASGI server can't serve any request -> frozen UI.
Fix: declare all 15 jobs as plain `def`. APScheduler's AsyncIOExecutor
runs non-coroutine jobs via loop.run_in_executor(), i.e. in a worker
thread, off the event loop. The enrichment/sync jobs can now take as long
as they need without blocking logins or page loads.
No behavioural change to the jobs themselves — they were already sync.
Tester screenshots — four issues:
1) Dashboard/assets wasted huge left/right gutters. Dropped the
max-w-[1800px] cap on both pages -> content uses full available width
(minus AppShell padding).
2) After a Nessus re-sync, a finding still showed BOTH the legacy
EOL-NESSUS-{plugin_id} row AND the new slug-named row (e.g. Adobe
Reader -> EOL-ADOBE-ACROBAT-...). The slug alias already resolves, but
the old plugin-id row was never removed. _upsert_nessus_eol now deletes
the legacy EOL-NESSUS-{plugin_id} row for the asset whenever the plugin
resolves to a real product slug. Re-sync homogenises existing data.
3) "Newly Published" still looked unsorted: until the NVD published_date
backfill drains, most rows have published_date NULL and were ordered by
id (meaningless). Added a secondary sort on the CVE's own year+sequence
so "newest CVE number first" holds even before backfill. Bumped the
no-key NVD backfill cap 60 -> 150/run so dates fill faster.
4) Assets "Assigned To" select truncated to "Unas..." — widened
maxWidth 140 -> 200px (minWidth 120).
Tester: "Newly Published CVEs" card has too much empty space.
Cause: the 3-widget grid used items-stretch + h-full on each card, so all
cards were forced to the height of the tallest (Recent Critical, 10 rows).
A shorter list (Newly Published / Newly EOL) left a big blank gap below its
last row.
Fix: items-start on the grid + drop h-full on the card, so each widget is
exactly as tall as its own content.
Tester: endoflife.date misses Microsoft "exotics" (and many server SKUs),
and Microsoft has no lifecycle API. The only machine-readable primary
source is the monthly Excel export linked from
learn.microsoft.com/lifecycle/products/export — whose download URL (GUID
+ month) changes every month.
New app/services/ms_lifecycle_service.py:
- fetch_lifecycle_data(): scrape the export page for the current
eos-product-listing .xlsx link, download it, parse
ListingName/Release/EndDate via openpyxl, cache 24h in settings.
- resolve_ms_lifecycle_eol(): match a syscollector product name to a
listing; pick the latest NON-ESU end date (paid Extended Security
Updates are an add-on most hosts lack, so a product is treated EOL
when standard extended support ends). Returns a reusable EOLStatus.
- Hardcoded exotics NOT in the export (tester-named): Silverlight and
the old Visual C++ 2008-2013 redistributables, with fixed EOL dates.
Wired as a *fallback*: the EOL check (manual button + nightly job)
consults endoflife.date first and only falls back to MS-lifecycle for
names endoflife.date can't map. Findings upsert through the existing
EOL pseudo-CVE path (cve_id "EOL-MS-LIFECYCLE-...").
Verified against the live April-2026 export: SQL Server 2014 -> EOL
(2024-07-09), SQL Server 2019 -> not EOL (2030), Exchange 2016 -> EOL
(2025-10-14). Adds openpyxl==3.1.5.
No migration — uses existing vulnerabilities + settings tables.
Tester: M365 Apps security fixes never reach NVD and are invisible to
Wazuh's vulnerability detector — they only live on the Microsoft Learn
"Microsoft 365 Apps security updates" page. No Microsoft API exists.
New app/services/m365_service.py:
- fetch_security_data(): parse that page into monthly releases
(channel->build map + CVE list), cached 24h in settings.
- parse_build("16.0.19929.20172") -> (19929, 20172); compares the last
two dotted build segments numerically.
- channel_for_product(): tester's rule — name contains "enterprise" ->
Monthly Enterprise Channel, else Current Channel.
- detect_missing_cves(): installed >= newest channel build -> UNAFFECTED;
otherwise union the CVEs of every monthly section the host is behind.
- upsert_m365_vulnerability(): real-CVE rows (enrichable like any CVE),
placeholder severity refined by nightly enrichment / Correct-CVSS.
- run_m365_check(): walk Wazuh-linked assets, collapse per-language
duplicates, upsert.
Verified against the live page + tester's example: installed
16.0.19929.20172 vs MEC 19929.20162 -> UNAFFECTED (0 CVEs); an older
build -> the month's 15 CVEs. 92 releases parsed cleanly.
Wired up:
- POST /api/v1/vulnerabilities/m365-check (synchronous, RequireEditor).
- Nightly job m365_check_nightly at 03:20 UTC.
- "M365 CVEs" button on the vulnerabilities page.
No migration — uses existing vulnerabilities + settings tables.
Follow-up to the published_date backfill. The NVD CVE API sleeps up to
6.5s per request without an API key, so running the backfill inline on
interactive / sync paths could hang them for minutes.
- NVD backfill now only runs in the nightly enrich_all_open_vulnerabilities
job (and single-vuln enrich, <=1 lookup). Disabled on the "Refresh
Threat Intel" button, the Wazuh full + per-agent syncs, and the Nessus
sync — all pass use_nvd_dates=False to stay fast.
- Per-run lookup cap is now key-aware: 60/run without a key (~6.5 min),
1500/run with a key (~18 min) — avoids a no-key install stalling the
nightly job for the better part of an hour.
- Document NVD_API_KEY in .env.example (free key → ~10x faster fill,
picked up automatically via env_file).
Tester: action buttons cut off / out of viewport at 100% browser zoom,
only visible at 75%.
The dashboard header right-group (audit filter + AI Audit + history +
Export Report) was a non-wrapping flex row. On any sub-1800px viewport
at 100% zoom it overflowed past max-w-[1800px] and got clipped. Add
flex-wrap + justify-end so it drops to a second line instead of running
off-screen. (The /vulnerabilities action bar already wraps.)
Tester: "Newly Published" still wrong even in VIEW ALL.
Real root cause (not the sort SQL — that was already nulls-last desc):
published_date was NEVER populated. No ingest path wrote it — Nessus and
Wazuh imports only set detected_at, and enrichment only touched
EPSS/KEV/EUVD. So the column was 100% NULL and the sort had nothing to
order by → arbitrary order.
Fix: backfill published_date (and lastModified) from the NVD CVE API
inside the enrichment job.
- fetch_nvd_cve_dates(): single-CVE GETs against services.nvd.nist.gov,
persistent settings cache (dates are immutable → fetch each CVE once
ever), capped NVD_MAX_LOOKUPS_PER_RUN=400 so a fresh DB drains the
backlog over nightly runs instead of one rate-limit storm.
- Honors NVD_API_KEY env for the 50/30s limit (else 5/30s, 6.5s sleep).
- Only looks up vulns where published_date IS NULL — already-dated rows
cost zero calls.
- Toggle enrichment_nvd_dates_enabled (default on).
Also adds last_modified_date column (migration 029) + exposes both in the
vulnerabilities API response, per tester ("published date und updated
date"). Index on published_date for the sort.
Migration 029 required: alembic upgrade head.
Tester on alembic 028 + rebuilt, asset INACTIVE still never set after
a reduced-scope Nessus sync.
Root cause: reconcile keyed on nessus_host_uuid. The single host in the
reduced scan had no host_uuid in its Nessus host_info, so the seen-uuid
set came back EMPTY → the fail-open guard ("empty set = upstream maybe
failed, don't deactivate anything") skipped the whole reconcile → the
dropped hosts from the previous scan stayed ACTIVE.
Fix: track the matched asset.id of every host touched this sync
(seen_asset_ids) and reconcile on that instead of the uuid set. A
uuid-less host still contributes its id, so the seen-set is non-empty
and the dropped assets get inactivated.
New reconcile_nessus_by_seen_ids():
candidate = ACTIVE AND (source==NESSUS OR nessus_host_uuid NOT NULL)
AND id NOT IN seen_asset_ids → INACTIVE
revive = same set, INACTIVE, id IN seen_asset_ids → ACTIVE
fail-open only when seen_asset_ids is truly empty (no host matched).
Logs "Nessus sync reconcile: N seen, M inactivated, K candidates" so
the next report is self-diagnosing.
Old uuid-keyed reconcile_missing_from_sync kept for the Wazuh path /
back-compat.
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.
From tester screenshot feedback (batch 2026-06-03):
#3 Asset deletion audit was thin ("Asset deleted: <truncated>") with
no clear WHO/impact. Now logs analog to the CVE status-change
entries: event_description names the host + IP + deleting user +
cascade-removed vuln count, and old_value carries a JSON snapshot
(hostname, ip, os, source, status, wazuh_agent_id, vuln count,
deleted_by). Audit committed BEFORE the delete so it survives even
if the cascade fails.
#2 + #4 Dashboard + Assets wasted large left/right gutters on wide
monitors and the assets table got squished/clipped (max-w-7xl =
1280px). Bumped dashboard, assets and scans pages to max-w-[1800px]
so wide screens use the space and the assets columns (incl. the new
Exposure col) stop clipping.
Still open (needs the tester's TXT, unreadable from Downloads
sandbox): EOL/EOS MS-product handling + remaining widget-fill polish.
Tested on the staging instance: the bare `alembic upgrade head` flow
errors out with "0 found when updating 027 to 028" when the
`alembic_version` table is empty (a state the pre-feedback DB on
docker-01 was in). Replace the single command with stamp-then-upgrade
so the same runbook works whether the version row is present or not.
Also add a tip on the right way to inspect alembic_version: Postgres
runs in its own container, `docker compose exec backend psql` hits
the wrong socket.
One-shot data migration that flips ACTIVE NESSUS-sourced assets with
nessus_host_uuid IS NULL to INACTIVE, with audit log entries. Closes
the backlog that the runtime path cannot reach (it only matches by
pinned UUID). Idempotent — clean DBs are a no-op. Downgrade is a
no-op (operator decision to revive).
Apply with:
docker compose exec backend alembic upgrade head
Asset rows created before nessus_host_uuid was pinned (matched by IP
or hostname in older syncs) are silently skipped by
reconcile_missing_from_sync because id_field.isnot(None) excludes
them. After a reduced scan they stay ACTIVE forever, contradicting
the "sync-driven INACTIVE" promise in feedback 808246f.
- asset_lifecycle.reconcile_missing_from_sync: log a count of legacy
unpinned ACTIVE assets so the next tester reproduction surfaces
the root cause immediately.
- nessus_sync.reconcile_legacy_nessus_assets: one-shot helper that
flips these rows to INACTIVE with an audit-log entry, safe to run
multiple times.
Fixes feedback 2026-06-02 #3 (INACTIVE not flipping on reduced scan).
- _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).
COALESCE(published_date, detected_at) made every EOL row ride the
detected_at fallback to the top of the "Newly Published" list — a
tester-reported false chronological order. Skip rows whose cve_id
starts with EOL- or NESSUS-PLUGIN- when sort_by=published_date and the
caller did not explicitly search=EOL-.
Fixes feedback 2026-06-02 #5a.
New helper _slug_pseudo_cve resolves the plugin_name to an
endoflife.date slug via eol_service.resolve_product_slug, then emits
EOL-{SLUG}-{VERSION|P####} instead of EOL-NESSUS-{plugin_id}. Office
variants keep the year-based EOL-MS-OFFICE-YYYY id for the language-
pack dedup. Legacy _office_pseudo_cve retained as a fallback when slug
resolution fails.
Detection Sources panel (nessus + plugin_id) is untouched — the
plugin id remains queryable from the vuln.nessus_plugin_id column.
Fixes feedback 2026-06-02 #2b, #4. Existing rows still need a one-off
backfill (out of scope here).
- card root gets flex flex-col h-full so cards in the 3-col grid are
equal height (no whitespace below short lists)
- table gains explicit colgroup with table-fixed so the Flags column
cannot be clipped by overflow-x-auto at lg:1/3 width
- CVE cell now truncates with a tooltip instead of overflowing
- grid wrapper uses items-stretch for symmetric row heights
Fixes feedback 2026-06-02 #2a, #2c, #5b.
Hostnames now sort by LOWER(hostname) so 'alpine' < 'Webserver'.
last_scan ORDER BY applies nulls_last on both asc and desc (was
implicit on desc only). Fixes feedback 2026-06-02 #1.
2026-06-02 14:34:52 +02:00
446 changed files with 84289 additions and 3754 deletions
@@ -118,6 +122,8 @@ Pure business logic, no FastAPI imports. Reusable from scheduler + routers.
| Service | Role |
| Service | Role |
|---|---|
|---|---|
| `enrichment_service.py` | EPSS / KEV / EUVD enrichment per CVE, with 24h cache in `settings` |
| `enrichment_service.py` | EPSS / KEV / EUVD enrichment per CVE, with 24h cache in `settings` |
| `kev_service.py` | Merges the KEV sources (CISA KEV + ENISA EUVD exploited + VulnCheck KEV) into one catalog and annotates it with inventory impact (open vs remediated, active assets only) |
| `kev_alert_service.py` | Immediate mail when an actively-exploited CVE has open findings here; idempotent per CVE |
| `vcenter_client.py` | vSphere SOAP (pyVmomi) — `about` + one PropertyCollector pass over every `HostSystem` |
| `igel_client.py` | IGEL IMI REST v3 — `serverstatus`, the firmware table, and every device in one `?facets=details` call |
| `xenserver_client.py` | XAPI on the pool coordinator — XML-RPC pool/host records + `GET /updates` |
### 3.4 Auth Stack — Strategy Pattern
### 3.4 Auth Stack — Strategy Pattern
@@ -173,10 +187,28 @@ SSO/LDAP login. `RoleMapper` re-evaluates role on every login from
|---|---|---|
|---|---|---|
| `scheduler_sync` | every 60 s | Picks up DB changes to `scan_schedules` and re-registers jobs |
| `scheduler_sync` | every 60 s | Picks up DB changes to `scan_schedules` and re-registers jobs |
| `sla_breach_check` | every 1 h | SLA-overdue scan; honors `sla_breach_enabled` toggle + `PolicyStatus.DISABLED` skip; digest or single mode |
| `sla_breach_check` | every 1 h | SLA-overdue scan; honors `sla_breach_enabled` toggle + `PolicyStatus.DISABLED` skip; digest or single mode |
| `threat_intel_refresh` | every 24 h | Refreshes EPSS, KEV, EUVD across all open vulns |
| `threat_intel_refresh` | every 24 h | Refreshes EPSS, KEV, EUVD across all open vulns; runs `kev_alert_check` straight after, since the catalogs just moved |
| `compliance_sca_nightly` | 02:00 UTC | Wazuh SCA pull for every linked asset |
| `kev_alert_check` | hourly, :25 | Mails actively-exploited CVEs (sources per `kev_alert_sources`, default CISA KEV + ENISA EUVD) that have OPEN findings on active assets; honors `kev_alert_enabled`, idempotent via `kev_alert_state` |
| `vulnrichment_nightly` | 03:00 UTC | 3-stage CVSS/SSVC/fixed_version cascade |
| `advisory_feeds_refresh` | every 6 h, :20 | ZDI/CERT-EU/BSI/... RSS feeds into the advisories cache |
| `https_certificate_check` | 03:45 | Renews the auto-generated HTTPS certificate (< 30 days left, names changed); logs a warning for an uploaded one about to expire. Also runs in `lifespan` |
| `new_vuln_digest_nightly` | hourly, :05 | Sends the new-CVE roundup at `notification_nightly_hour` (default 06) when `notification_schedule` is nightly |
| `vuln_index_refresh_nightly` | 01:30 | Rebuilds the cvelistV5 (+MFSA), GitHub-advisory, TeamViewer, IGEL-ISN and MSRC fixed-build indexes before any sync or scan reads them |
| `compliance_sca_nightly` | 02:00 | Wazuh SCA pull for every linked asset |
| `intune_sync_nightly` | 02:10 | Intune managed devices → assets + OS-EOL + per-device app-CVE scan, Defender TVM CVEs when enabled; holds an advisory lock, can run for hours |
Alle Farben liegen als CSS-Variablen in `frontend/app/globals.css`.
Farbwechsel = Token-Werte ändern, nichts anderes. Keine neuen Hex-Werte
in Komponenten.
Format ist **OKLCH** (`L C H`): wahrnehmungsgleichmäßige Helligkeit, dunkle
und helle Stufen verlieren die Sättigung nicht. Chroma bleibt für UI unter
~0.22, Neutrale unter 0.02.
## Die Regel
Drei Gruppen, und nur die Zuordnung entscheidet — nicht der Geschmack:
| Gruppe | Wofür | Tokens |
|---|---|---|
| **Neutral** | Der Großteil der UI: Flächen, Text, Border, alles was nur *benennt* | `--tv-fg`, `--tv-fg-soft`, `--tv-fg-muted`, `--tv-line`, `--tv-line-strong`, `--tv-btn-bg`, `--tv-mute-*` |
| **Brand** | Genau eine Sache: die primäre Aktion und der aktive Filter | `--tv-brand`, `--tv-brand-solid`, `--tv-brand-hover`, `--tv-brand-soft`, `--tv-brand-ring` |
TrueVuln is **open core**. This document is the authoritative statement of what is
free and open source software and what is not. It is published alongside the first
public release so the boundary is known up front and does not move under you.
Copyright (C) 2026 {{LICENSOR}}
## The short version
| | |
|---|---|
| **Everything in this repository** | Free and open source under **AGPL-3.0-or-later**. Self-host it, modify it, run it in production, commercially, at no cost. |
| **TrueVuln Enterprise** | A separate, commercially licensed add-on. Not in this repository. Not required to run TrueVuln. |
There is no "crippled community edition". The entire detection and enrichment
engine -- the part that actually does the work -- is AGPL and stays AGPL.
## What is AGPL-3.0 (this repository)
The complete, production-usable vulnerability management platform:
Prioritize, verify, and resolve vulnerabilities with automated workflows and AI-powered analysis.
Aggregate findings from multiple scanners, enrich them from authoritative sources, and prioritize, verify, and resolve them with automated workflows and AI-powered analysis.
VulnCheck is a self-hosted vulnerability management dashboard that integrates with [Wazuh](https://wazuh.com/) to provide a centralized view of your infrastructure's security posture. It automates vulnerability discovery, SLA tracking, patch verification, and reporting -- backed by AI analysis from multiple providers.
TrueVuln is a self-hosted vulnerability management dashboard that aggregates findings from [Wazuh](https://wazuh.com/), Tenable Nessus, and Microsoft Intune/Defender into a single, prioritized view of your infrastructure's security posture. It enriches every CVE from authoritative open sources, detects end-of-life software and Microsoft 365 Apps gaps that scanners miss, and automates SLA tracking, patch verification, and reporting -- backed by AI analysis from multiple providers.
**Key capabilities:**
**Key capabilities:**
- Sync vulnerabilities and assets from Wazuh automatically
- Multi-source inventory & findings: Wazuh agents, Nessus scans, Microsoft Intune (MDM/UEM) + Defender for Endpoint TVM
- AI-powered CVE analysis with remediation recommendations
- Cross-confirmation when several scanners report the same CVE on the same host
- SLA policy enforcement with automated email alerts
- **Tenable Nessus** -- import findings from configured scans (X-ApiKeys), launch/poll/import scans, VPR score, exploit availability/maturity, scanner remediation text
- Automated patch verification: mark as patched, VulnCheck rescans and confirms
- **Microsoft Intune (Graph API)** -- app-only (client-credentials) sync of managed devices → assets + OS-EOL, and `detectedApps` → EOL/M365 detection (no Wazuh agent required)
- Deduplication of CVEs per asset
- **Microsoft Defender for Endpoint (TVM)** -- optional real per-device CVEs via the Defender API, plus the tenant's **installed-software inventory** (`Software.Read.All`), which follows device telemetry instead of Intune's 7-day Discovered-apps cycle and feeds the same EOL / M365 / app→CVE detection
- One CVE per asset (deduplicated); multiple scanners on the same finding are merged and flagged **cross-confirmed**; assets are soft-inactivated (not deleted) when a source stops reporting them, with audit trail
### Threat-Intelligence Enrichment
- **EPSS** (FIRST.org) exploitation probability, **CISA KEV** (known-exploited, plus a dedicated "actively exploited" advisory dashboard widget independent of asset findings), **ENISA EUVD** (EU exploited/critical), **VulnCheck KEV** (optional, free community token — ~5200 exploited CVEs vs CISA's ~1400, usually listed days earlier; each row shows the lead time over CISA. Requires prominent "VulnCheck KEV" attribution, which the Advisories page renders)
- **Public-exploit catalogs** -- Exploit-DB, PoC-in-GitHub, Metasploit module index
- **Authoritative CVE metadata** -- published/last-modified dates, CVSS, and descriptions from CVE.org **cvelistV5**, CISA **Vulnrichment**, and the **NVD** API
- **CVSS-correction cascade** (for placeholder/missing scores) -- CISA Vulnrichment → NVD → cvelistV5 → **GitHub Advisories (GHSA)**, each source only for the CVEs the previous one left empty; real CVE-IDs only. An `NVD_API_KEY` raises the NVD stage from 5 to 50 req/30s, an optional GitHub PAT lifts GHSA from 60 to 5000 req/h
- **Mozilla MFSA** -- per-CVE impact rating (critical/high/moderate/low) straight from Mozilla's advisory repo, for fresh Firefox CVEs that have no score in NVD/cvelistV5 yet
- **Metrics are CVE-global** -- a finding created without a score inherits CVSS/EPSS/KEV from the same CVE on another asset, so the same CVE never shows two different scores
### End-of-Life & Microsoft 365 Detection
- **EOL/EOS detection** for installed software and OS via [endoflife.date](https://endoflife.date) plus the Microsoft product-lifecycle export (covers exotics like SQL Server, Visual C++ Redistributables, Silverlight)
- **Severity scales with age** -- a product 1000+ days past end-of-life is rated CRITICAL rather than a flat HIGH, and EOL findings cite the control failure (PCI-DSS 6.3.3, NIST 800-53 CM-8, HIPAA 164.312(a)(1)) for audit reports
- **Self-correcting** -- findings whose product match no longer holds are re-evaluated and closed automatically; the check runs as a background job with live progress instead of blocking the browser
- **Microsoft 365 Apps CVE detection** -- compares the installed Office build against the Microsoft 365 Apps security-update channels for CVEs that never reach NVD or Wazuh
### Windows OS & Microsoft Product CVE Detection
- **Windows OS CVEs (Server AND Client)** via cvelistV5's bounded build ranges, family-matched (a Win11 24H2 host never matches a Server-2025-only fix even though both live on build line 26100) — patch-level accurate, available on Patch Tuesday, well ahead of the Wazuh CTI feed
- **SharePoint 2013/2016/2019/Subscription** and **modern .NET (8/9/10)** detection with one registry key per release, so generic version floors can't cross-match releases (.NET Framework is excluded by design: its ARP version is static across monthly patches — Defender TVM covers it file/KB-based)
- **MSRC fixed-build scan** as a second, authoritative source (KB numbers per servicing branch); cross-confirms, auto-resolves once a host catches up, nightly job + manual trigger (`POST /api/v1/vulnerabilities/msrc-scan`)
- **Microsoft Edge CVEs** -- MSRC is the *only* machine-readable source for these (they appear in neither NVD nor cvelistV5); matched against Edge's own fixed build, never against Chromium/Chrome ranges, since the two build schemes diverge entirely (Edge 150.0.4078.99 rides on Chromium 150.0.7871.187)
### Built-in App→CVE Scanner & Mobile Device Security
- **App→CVE scanner** -- maps installed software (Wazuh syscollector packages, Intune `detectedApps`) to real CVEs via a curated OSV / NVD-CPE registry plus a direct cvelistV5 range match (catches fresh CVEs NVD hasn't CPE'd yet, or ones filed under a CPE product string that wasn't curated); results cross-confirm and enrich like any other source
- **False-positive suppression** -- flags a Wazuh finding as false-positive when the installed version is provably outside every clean cvelistV5 version range for the matched product (e.g. a SQL Server 2019 host wrongly flagged with a 2022/2025-only CVE)
- **GitHub repository advisories** -- CVEs a project publishes only on its own GitHub advisory page, reaching neither NVD nor cvelistV5 (e.g. Notepad++); version ranges come straight from the maintainer, with the published patched version as a safety net
- **One finding, every affected product** -- when a single CVE hits two products on the same host (e.g. Chrome *and* Edge), the finding lists both with their own installed and fixed versions instead of naming only whichever scanner ran first
- **Vendor / publisher** captured from every inventory source (Wazuh `vendor`, Intune `publisher`, Defender `softwareVendor`) and shown on the finding
- **Mobile device EOL/EOS** (Samsung, Apple) and **Android security-patch-level staleness** for Intune-managed phones/tablets, with a dedicated dashboard widget
- **Per-CVE Android detection** -- Samsung's own SMR bulletin (precise, excludes chipset CVEs that don't apply to the device) with Google's ASB as a fallback
### Multi-Source Remediation
The CVE detail page shows remediation from every available source side by side:
- Scanner solution (Nessus), **Microsoft (MSRC)** KB + fixed build + download link (filtered to the host's build), **Ubuntu USN**, **Red Hat / CentOS / Alma** errata, and the **OSV.dev** aggregator (Debian, SUSE, Alpine, Rocky, language ecosystems)
- Workarounds / mitigations / containment when no patch exists yet; the MSRC update-guide link per CVE
### AI-Powered Analysis
### AI-Powered Analysis
Supports multiple AI providers for CVE analysis, threat assessment, and remediation guidance:
Supports multiple AI providers for CVE analysis, threat assessment, and on-demand OS-aware remediation generation (including an OpenRouter integration for the per-CVE "Generate fix steps" button, EOL-aware for end-of-life findings):
| Provider | Models |
**Model lists are fetched live** from each provider's own `/models` endpoint the moment you enter an API key -- the dropdown shows exactly what your key can reach, including models released after this README was written. There is no hardcoded model list to keep in sync.
|---|---|
| OpenAI | GPT-5.2, GPT-4.1, o3, o3-mini |
| Anthropic | Claude Opus 4.5, Sonnet 4.5, Haiku 4.5 |
| Google Gemini | Gemini 3 Pro, 3 Flash, 2.5 Pro |
- **Patch verification** triggers a Wazuh rescan and updates status automatically
- **Patch verification** triggers a Wazuh rescan and updates status automatically
### Notifications
#### Nightly job order (UTC)
- Customizable HTML email templates for SLA breaches and new vulnerability alerts
- Live preview of email templates with sample data
- SMTP configuration with test email functionality
- Full notification history with status tracking (Sent, Failed, Suppressed)
> **Performance Note:** Avoid sending large batches of emails simultaneously. Most SMTP providers (especially Proton, Gmail, Outlook) enforce strict rate limits (~10-20 emails per minute). Exceeding these limits may result in "too many connections" errors or temporary blocks. SLA breach notifications are throttled to 1 email per vulnerability per 24 hours by default.
The order is not arbitrary — each stage depends on the one above it. Inventory
is collected first, findings are produced from it, enrichment corrects those
findings, and only then are the aggregates computed. Anything that creates
findings therefore has to run **before** the enrichment, or its scores would
stay uncorrected until the following night.
| Time | Job | Stage |
|------|-----|-------|
| 01:30 | Vulnerability index refresh (cvelistV5 + MFSA, GitHub advisories, TeamViewer, IGEL ISN) — before every sync that decides from them | catalog |
Independent of the chain: security advisory feeds every 6h at :20, the
new-vulnerability digest mail at the configured hour, and the hourly SLA check.
Wazuh's own vulnerability sync is a user-defined schedule (Scans → Schedules),
not part of this chain.
### Notifications
- New-CVE email alerts from **every** scanner source (Wazuh, Nessus, App→CVE scanner, Defender TVM) — not just Wazuh
- Customizable HTML email templates for SLA breaches, single new-CVE alerts, **and** the aggregated digest — all editable in the UI with live preview
- Digest table sorted by **CPR** (priority) descending, with per-CVE affected-systems count and ready-made deep links; template variables include `{{cpr_score}}`, `{{affected_assets_count}}`, `{{cve_link}}`, `{{asset_link}}`
- **Delivery schedule:** per-sync (immediate) or a **nightly roundup** — one aggregated mail per recipient of the day's new CVEs
- **Send rate limiting** (delay between mails + max per run) to respect SMTP-provider anti-spam limits
- Recipient routing by asset/finding assignment (user or group), with a configurable **Default Recipient(s)** fallback — or all admins — for unassigned new-CVE mails
- Notification history with status tracking (Sent, Failed, Suppressed) — admins see all; other roles see only notifications addressed to them
> **Performance Note:** Most SMTP providers (Proton, Gmail, Outlook) enforce strict rate limits (~10-20 emails/minute). Use **nightly roundup** mode and/or the send rate limit to stay under them. SLA-breach notifications are throttled to 1 email per vulnerability per 24 hours by default.
### Asset Management
### Asset Management
- Auto-discovery from Wazuh agents or manual creation
- Auto-discovery from Wazuh agents or manual creation
- Bulk assignment to users, groups, and SLA policies
- Bulk assignment to users, groups, and SLA policies
- Track OS, location, owner, and scan history per asset
- Track OS, location, owner, and scan history per asset
> **Asset ownership:** Assigning an asset (or an individual finding) to a user/group is a **notification/ownership tag** — it routes new-CVE and SLA-breach emails to that owner. It does **not** affect scanning, scoring, or visibility (all view-permitted users see every CVE). New-CVE mails fall back to the default recipients / admins when unassigned; **SLA-breach mails have no fallback and require an assignee**, so assign each asset to a system owner. See README.DEV for details.
@@ -97,8 +176,11 @@ Supports multiple AI providers for CVE analysis, threat assessment, and remediat
### Security
### Security
- JWT authentication with role-based access control (Admin, Editor, Readonly)
- JWT authentication with role-based access control (Admin, Editor, Readonly)
- Bcrypt password hashing with account lockout
- 30-minute access token with **silent refresh** against a 7-day refresh cookie -- an actively used session stays signed in; only real inactivity logs you out
- **Revisionssicher audit trail** in both directions: a finding going open → patched *and* patched → open is recorded, naming the CVE, the host and the scanner that caused it
- Audit log with full-text search (description, event, resource, IP, user), sortable columns and real pagination
- Bcrypt password hashing; login rate-limit + **account lockout** after 5 failed attempts — temporary 15-min auto-unlock, or opt-in **permanent lockout** cleared only by an admin (the last active admin is never permanently locked)
- Comprehensive audit logging (who changed what, when), optionally **forwarded to a syslog/SIEM** server (UDP/TCP, RFC-5424, per-event severity) for central alerting
- Security headers (CSP, HSTS, X-Frame-Options)
- Security headers (CSP, HSTS, X-Frame-Options)
- Rate limiting on API endpoints
- Rate limiting on API endpoints
@@ -109,7 +191,11 @@ Three roles with hierarchical permissions: **Admin > Editor > Readonly**
@@ -179,14 +265,19 @@ Then edit `.env` and set at minimum:
Paste the output as `JWT_SECRET_KEY` in `.env`.
Paste the output as `JWT_SECRET_KEY` in `.env`.
3. **`DEFAULT_ADMIN_PASSWORD`** — must satisfy strength rules: ≥8 chars with uppercase, lowercase, digit, and special character. The default `ChangeMe123!` passes; weaker values (like `changeme`) cause the admin **not** to be created and login will fail.
3. **`DEFAULT_ADMIN_PASSWORD`** — must satisfy strength rules: ≥8 chars with uppercase, lowercase, digit, and special character. The default `ChangeMe123!` passes; weaker values (like `changeme`) cause the admin **not** to be created and login will fail.
4. **`AUTH_COOKIE_SECURE`** — `true` only when serving the UI over **HTTPS** (e.g. behind a reverse proxy with TLS). If you are accessing the app over plain **HTTP** (e.g. `http://server:3003` during testing), set this to `false`, otherwise the browser silently drops the session cookie and login appears to do nothing — the page refreshes but the user stays logged out.
4. **`AUTH_PROVIDER_CRYPTO_KEY`** — Fernet key used to encrypt TOTP secrets and the LDAP bind password at rest. Generate with:
sed -i 's/^AUTH_COOKIE_SECURE=.*/AUTH_COOKIE_SECURE=false/' .env
```
```
The `.env.example` ships with production-ready defaults for running behind a reverse proxy with HTTPS. Also review `TIMEZONE` and `DASHBOARD_URL`.
Leaving the shipped `CHANGE-ME-FERNET-KEY` placeholder makes **MFA enrolment fail with HTTP 500** (`AUTH_PROVIDER_CRYPTO_KEY is not a valid Fernet key`) and blocks LDAP bind-password storage. Everything else still works, so the failure only shows up the first time someone turns on MFA.
5. **`DASHBOARD_URL`** and **`TLS_SERVER_NAMES`** — the address users open TrueVuln at (`https://truevuln.corp.example`) and any further names or IPs they type. Both go into the HTTPS certificate the installation's own CA issues on first start. `setup.sh` fills them with this host's name and IP.
Keep **`AUTH_COOKIE_SECURE=true`**: TrueVuln is served over HTTPS, so the session cookie never needs plain HTTP.
The `.env.example` ships with production-ready defaults (HTTPS via the bundled nginx). Also review `TIMEZONE`.
Verify the file exists and `POSTGRES_PASSWORD` is set before continuing:
Verify the file exists and `POSTGRES_PASSWORD` is set before continuing:
@@ -206,29 +297,78 @@ docker compose up -d --build
> **Wait 2-3 minutes after deploy** before opening the UI. The first start has to pull images, run database migrations, build the Next.js frontend, and warm up the backend. Hitting the login page too early can show a blank screen, a 502, or login that "looks" broken because the frontend bundle is still compiling. Watch `docker compose ps` until all three services report `Up (healthy)` / `Up`, and `docker compose logs frontend` shows `✓ Ready in …`.
> **Wait 2-3 minutes after deploy** before opening the UI. The first start has to pull images, run database migrations, build the Next.js frontend, and warm up the backend. Hitting the login page too early can show a blank screen, a 502, or login that "looks" broken because the frontend bundle is still compiling. Watch `docker compose ps` until all three services report `Up (healthy)` / `Up`, and `docker compose logs frontend` shows `✓ Ready in …`.
This starts three containers:
This starts four containers:
| Service | URL | Description |
| Service | URL | Description |
|---|---|---|
|---|---|---|
| Frontend | `http://localhost:3003` | Web UI |
| nginx | `https://<host>` (`HTTPS_PORT`, 443); port 80 redirects | Web UI over TLS 1.2/1.3 |
| Backend | `http://localhost:8022` | API (bound to localhost only) |
| Frontend | `http://127.0.0.1:3003` | Web UI, plain HTTP, this host only |
| PostgreSQL | Port 5432 | Database |
| Backend | `http://127.0.0.1:8022` | API, this host only |
| PostgreSQL | `127.0.0.1:5432` | Database |
### Reverse Proxy (recommended)
### HTTPS
VulnCheck is designed to run behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy, etc.):
The bundled nginx (Compose profile `tls`, on via `COMPOSE_PROFILES=tls` in `.env`) is the only service reachable from the network. On first start the backend creates a **CA for this installation** and issues the server certificate from it for `TLS_SERVER_NAMES`, the `DASHBOARD_URL` host and localhost; it renews itself nightly.
1. Point your reverse proxy to the **frontend** port (default `3000`)
- **Make browsers trust it:***Settings → Security → HTTPS Certificate → Download TrueVuln CA*, then distribute `truevuln-ca.pem` as a trusted root (GPO/MDM). The CA carries name constraints and can issue for those names only, so a stolen CA key is no good for any other site. Adding a name later creates a new CA, which has to be distributed again.
2. The frontend proxies all API calls to the backend internally via Docker networking
- **Certificate from your internal PKI:** upload it on the same card (PEM with chain, DER or PFX/P12, key separately or inside). It is checked (key matches, chain complete, covers the `DASHBOARD_URL` host, not expired, not self-signed, RSA ≥ 2048 / ECDSA P-256 or P-384, SHA-256+) and served within 30 seconds, without a restart. The key stays on the `tls_data` volume, never in the database. The CA key lives in a separate volume (`tls_ca`) that only the backend mounts.
3. You do **not** need to expose the backend port (8000) externally
- **TLS:** TLS 1.3 only, HSTS (two years). `nginx:stable-alpine` currently ships OpenSSL 3.5, which also offers the hybrid post-quantum key exchange X25519MLKEM768 to browsers that support it. Clients without TLS 1.3 (Windows Server 2019 and older via SChannel, Java 8 before u261) cannot connect.
- **Hardening:** nginx follows the CIS NGINX Benchmark v3.1.0 (mapping in `README.DEV.md`). It answers only for the names on the certificate (`TLS_SERVER_NAMES`, the `DASHBOARD_URL` host, localhost, 127.0.0.1) and closes the connection for any other `Host`; a name users type must be in that list.
**Required proxy settings:**
### With your own reverse proxy
- Forward `X-Forwarded-For` and `X-Real-IP` headers (for rate limiting and audit logs)
- Enable WebSocket passthrough if needed
**Required `.env` settings** (already set in `.env.example`):
- **Proxy on another host:** keep the bundled nginx and point the proxy at `https://<truevuln-host>:443`, trusting the TrueVuln CA (nginx: `proxy_ssl_trusted_certificate truevuln-ca.pem; proxy_ssl_verify on;`). The whole path stays encrypted.
- `TRUST_PROXY_HEADERS=true`
- **Proxy on this host, installed directly (systemd nginx/Apache/Caddy):** remove `tls` from `COMPOSE_PROFILES` and point the proxy at `http://127.0.0.1:3003`. Traffic never leaves the host.
- `AUTH_COOKIE_SECURE=true` (when using HTTPS)
- **Proxy on this host as a Docker container (Nginx Proxy Manager, OpenResty, Traefik, …):** remove `tls` from `COMPOSE_PROFILES`. `127.0.0.1` inside that container is the container itself, and the host's LAN IP no longer answers on 3003, so neither works as a target. Put the proxy container into TrueVuln's network and address the frontend by name:
```bash
docker network ls | grep vulnmanager-net # e.g. vulncheck_vulnmanager-net (prefix = Compose project)
Target in the proxy: `http://vulnmanager-frontend:3000` (Nginx Proxy Manager: Scheme `http`, Forward Hostname `vulnmanager-frontend`, Forward Port `3000`). Port 3000 is the port *inside* the frontend container; it does not clash with anything on the host using 3000, and 3003 is only the host-side mapping. `docker network connect` is lost when the proxy container is recreated (e.g. on an update), so make it permanent in the proxy's own Compose file:
```yaml
services:
app: # the proxy service
networks: [default, truevuln]
networks:
truevuln:
external: true
name: vulncheck_vulnmanager-net
```
The proxy is then in the backend's Docker subnet and trusted for `X-Forwarded-For` without configuration.
Either way the proxy must set `X-Forwarded-For` (nginx/OpenResty: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`), and `TRUST_PROXY_HEADERS=true` stays on. The backend trusts the header from its own Docker subnet without configuration. A proxy on **another host** goes into `FORWARDED_ALLOW_IPS` (its IP), otherwise audit log and rate limit see the proxy instead of the client. Older guides told you to put Docker gateway IPs (`172.18.0.1`, …) there; that is not needed any more and can go.
### Upgrading from a version without the bundled nginx
The frontend port is now bound to `127.0.0.1`, so a proxy that reached `http://<host>:3003` from another machine stops working. Add to `.env`:
```bash
COMPOSE_PROFILES=tls
HTTPS_PORT=443
HTTP_PORT=80
TLS_SERVER_NAMES=truevuln.corp.example,10.0.0.5
```
set `DASHBOARD_URL` to the `https://` address and `AUTH_COOKIE_SECURE=true` (older instructions said `false` for plain HTTP), run `docker compose up -d --build`, and either switch the proxy to HTTPS (above) or let users open TrueVuln directly.
Already running your own proxy on this host (Nginx Proxy Manager and the like)? Then leave `COMPOSE_PROFILES` without `tls` and re-point the proxy as described in [With your own reverse proxy](#with-your-own-reverse-proxy). Symptom of a proxy still pointing at the old target: **502 Bad Gateway**, and in the proxy's error log `connect() failed (111: Connection refused) … upstream: "http://<lan-ip>:3003/"`.
### Upgrading from PostgreSQL 15
The database runs on PostgreSQL 18 (`postgres:18-alpine`), the backend on Python 3.14. The 18 image keeps its data in a new volume `postgres18_data` mounted at `/var/lib/postgresql`; started against the old `postgres_data` volume it refuses to start. Migrate **before** the first `up` of the new version:
1. Back up as usual (see [Database Backup & Restore](#database-backup--restore)); the script also keeps its own dumps under `backups/`.
2. `git pull`. Already ran `docker compose up -d` and saw a crash-looping or empty database? No data is lost; carry on.
3. `./tools/migrate_postgres_15_to_18.sh` — stops the stack, dumps the 15 cluster with `pg_dump` 18 into the new volume, compares every table's row count and the login with `POSTGRES_PASSWORD` from `.env`, and leaves `postgres_data` untouched. An empty database the 18 image created on a premature start is moved inside the new volume to `pre-migration-<time>/`, not deleted.
4. `docker compose up -d --build`, log in, check assets and findings.
5. The script warns about other roles with md5 passwords (deprecated in 18): set them again with `ALTER ROLE <name> PASSWORD '…'`. Own changes to `postgresql.conf`, `pg_hba.conf` or `ALTER SYSTEM` are not carried over.
6. Rollback: `docker compose down`, put `image: postgres:15-alpine` and `postgres_data:/var/lib/postgresql/data` back into `docker-compose.yml` (and declare `postgres_data` under `volumes:`), `docker compose up -d`. Anything written since the migration is lost. Migrating again later: `docker volume rm <project>_postgres18_data` first; the script refuses to reuse a copy whose source has been used since.
7. Once it has run cleanly for a few days: `docker volume rm <project>_postgres_data` and `rm -rf backups/pg15-to-18-*`. Until then avoid `docker volume prune -a`: the old volume is no longer declared in compose and counts as unused.
### 4. First login
### 4. First login
@@ -295,11 +435,13 @@ curl -X POST https://your-domain.tld/auth/setup-admin \
### 5. Configure integrations
### 5. Configure integrations
In **Settings**, configure:
In **Settings**, configure the integrations you need (all optional, all stored encrypted at rest):
1. **Wazuh SIEM** -- API URL, credentials, and Indexer connection
1. **Wazuh SIEM** -- API URL, credentials, and Indexer connection
2. **AI Provider** -- Choose your provider and enter API credentials
2. **Tenable Nessus** -- base URL + API keys, default scan IDs
3. **SMTP Email** -- For SLA breach and vulnerability notifications
| `TRUST_PROXY_HEADERS` | `false` | Trust `X-Forwarded-For` for client IP (enable behind reverse proxy) |
| `TRUST_PROXY_HEADERS` | `true` (Compose) | Trust `X-Forwarded-For` for client IP in audit log and rate limit |
| `FORWARDED_ALLOW_IPS` | -- | Extra trusted proxies (IPs/CIDRs), e.g. a reverse proxy on another host. Loopback and the backend's Docker subnet are always trusted |
| `DASHBOARD_URL` | -- | External URL for email notification links; its host goes into the HTTPS certificate |
### Integrations
### Integrations
All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page in the UI. No additional environment variables are needed for these.
All integrations (Wazuh, Nessus, Intune/Defender, AI/OpenRouter, SMTP) are configured through the **Settings** page in the UI and stored encrypted at rest. No additional environment variables are required. Optional env keys exist for headless/CI use (e.g. `NVD_API_KEY`, `OPENROUTER_API_KEY`) — see `.env.example`. Multi-provider auth (LDAP/OIDC/SAML/MFA) is configured via env + the auth admin UI; see `.env.example`.
---
---
@@ -366,25 +515,24 @@ All integrations (Wazuh, AI, SMTP) are configured through the **Settings** page
### Reverse Proxy Setup
### Reverse Proxy Setup
VulnCheck is designed to run behind a reverse proxy. Only the **frontend port** needs to be reachable by the proxy -- the frontend handles all API routing internally.
TrueVuln serves HTTPS itself (see [HTTPS](#https)). A reverse proxy in front is optional:
**Proxy on another host, re-encrypting to TrueVuln:** the `Host` it forwards (here `vuln.example.com`) must be the `DASHBOARD_URL` host or in `TLS_SERVER_NAMES`; TrueVuln's nginx closes the connection for any other name.
1. Add a new proxy host pointing to `http://<docker-host>:3003`
2. Enable SSL with Let's Encrypt
3. No custom location blocks needed -- the frontend proxies everything
The backend port (8022) is bound to `127.0.0.1` by default in `docker-compose.yml` and does not need to be exposed.
**Proxy on the same host:** remove `tls` from `COMPOSE_PROFILES`. Installed directly on the host: `proxy_pass http://127.0.0.1:3003;`. Running as a Docker container: join it to `<project>_vulnmanager-net` and use `proxy_pass http://vulnmanager-frontend:3000;` (see [With your own reverse proxy](#with-your-own-reverse-proxy)).
Frontend (3003) and backend (8022) are bound to `127.0.0.1` in `docker-compose.yml`.
### Security Checklist
### Security Checklist
@@ -401,11 +551,11 @@ The backend port (8022) is bound to `127.0.0.1` by default in `docker-compose.ym
- [ ] Set a strong `POSTGRES_PASSWORD`
- [ ] Set a strong `POSTGRES_PASSWORD`
- [ ] Change the default admin password after first login
- [ ] Change the default admin password after first login
@@ -650,7 +900,7 @@ If you encounter issues or have questions, please [open an issue](https://gitea.
## Support the Project
## Support the Project
If VulnCheck is useful to you, consider buying me a coffee! ☕
If TrueVuln is useful to you, consider buying me a coffee! ☕
<a href="https://buymeacoffee.com/vulncheck" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
<a href="https://buymeacoffee.com/vulncheck" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
danke fuer den Screenshot — drei Punkte, davon zwei derselbe Fehler. Alles gefixt, liegt auf dev. Keine neue Migration, 052 bleibt head, also nur bauen und neu starten (Backend + Frontend).
*1+2: die Assets, die "10" und "192" heissen — und die vermischte Zeile*
Nicht jedes Geraet antwortet Netdisco einen Namen (bei euch die AudioCodes-Gateway und der LANCOM). Der Sync faellt dann auf die kanonische IP zurueck, hat davon aber die Kurzform genommen — so wie er es bei einem FQDN macht (sw-12.example.local → sw-12). Auf einer Adresse angewandt bleibt das erste Oktett uebrig. Ab sofort ist ein namenloses Geraet die *volle IP*, eine Kurzform wird nur noch aus einem echten Namen gebildet.
Und das ist auch die Erklaerung fuer die Zeile mit LANCOM als Hersteller und der netgear-Firmware 5.4.2.30: der abgeschnittene Name war nicht nur haesslich, er war ein *Suchschluessel*. Das zweite namenlose Geraet im selben /8 hat ueber hostname="192" das Asset des ersten gefunden und wurde daraufgeschrieben. Zwei Geraete, eine Zeile. Mit der vollen Adresse kann das nicht mehr passieren.
Aufraeumen musst du nichts: heisst ein Asset genau wie das erste Oktett der IP, an die es gepinnt ist, benennt der naechste Sync es um, und das Geraet, das den Pin nicht haelt, bekommt sein eigenes Asset. Einzige Einschraenkung: die falsche os_version bleibt auf dem geheilten Asset stehen, bis dessen eigenes Geraet wieder eine Version meldet — ein leerer Wert ueberschreibt nie einen vorhandenen. Wenn dir das zu langsam ist, die zwei Zeilen einmal loeschen, der naechste Sync legt sie sauber neu an.
*3: Netdisco im Quellfilter*
War schlicht nicht in der Liste, und damit fehlte er auch im CSV-Export, der genau diesen Filter mitnimmt. Ist drin. Der Filter geht ueber die Netdisco-Verknuepfung und nicht ueber die Quelle, unter der ein Asset mal angelegt wurde — sonst faellt jeder Switch raus, den Wazuh oder Nessus zuerst gesehen hat. Nebenbei aufgefallen: dieselben Switche standen vorher unter "Manual", weil sie keine der abgefragten Scanner-Verknuepfungen hatten. Auch das stimmt jetzt.
*Was ich dich zu pruefen bitte*
1. Einmal syncen, dann die beiden Zeilen anschauen: voller Name statt "10"/"192", und jedes Geraet mit seinem eigenen Hersteller und seiner eigenen Firmware.
2. Inventar → Filter auf Netdisco: kommen alle Netzwerkgeraete, auch die, die ein anderer Scanner zuerst kannte? "Export CSV" bei gesetztem Filter → dieselbe Menge.
3. Unter "Manual" darf jetzt kein Switch mehr stehen.
4. Aruba/Cisco/Extreme unveraendert gegentesten, dass nichts kaputtgegangen ist.
Die OS-Spalte zeigt bei den beiden weiterhin "AudioCodes LTD" bzw. "LANCOM Systems" — das ist der Hersteller, weil Netdisco fuer die Geraete gar keinen OS-Slug meldet. Kosmetisch, auf Findings hat es keinen Einfluss; sag Bescheid, wenn es stoeren soll.
prompt=f"""You are a Vulnerability Management Expert. Analyze the following list of vulnerabilites and prioritize the top 10 that need immediate attention.
# "top 10" was hardcoded while `limit` defaulted to 20 — the model was
# told to discard half its input for no stated reason, and at limit<10
# it was asked for more items than it had been given.
want=min(10,len(vuln_data))
prompt=f"""You are a vulnerability management expert. Rank the {want} findings below that need attention first.
# All hops were trusted proxies — fall back to leftmost.
returnxff.split(",")[0].strip()
xri=request.headers.get("x-real-ip")
ifxri:
returnxri.strip()
returnpeeror"unknown"
limiter=Limiter(key_func=get_client_ip)
limiter=Limiter(key_func=get_client_ip)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.