- 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.
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.
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.
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.
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-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: 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: 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 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.
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 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 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 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: 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: "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.
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).
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).
Security review follow-up (post-commit hook):
- Empty seen_ids guard: when the upstream Nessus/Wazuh sync returns
zero host ids, skip the reconcile and log a WARNING. The previous
behaviour would have deactivated every active asset of that source
on an upstream API failure.
- Backfill now Python-side: replaced the bulk .update() with
synchronize_session=False (which desyncs pre-loaded Asset instances)
with a fetch + ORM mutate. Also tightened the predicate to
length(nessus_host_uuid) > 0 so empty strings are skipped.
- _audit_asset_status: explicit dict-based transition -> event-type
mapping. Reason text is preserved in event_description so the
audit UI can distinguish 'sync missed it' from 'operator
decommissioned'.
Tester feedback round 2026-06-01 — 8 issues grouped into 4 fixes.
**A. Sync-driven INACTIVE reconciliation** (highest priority)
- AssetSource.NESSUS enum value added; pre-existing MANUAL assets with
a nessus_host_uuid re-tagged in 027 migration.
- New `reconcile_missing_from_sync()` helper in asset_lifecycle flips
ACTIVE assets of a given source (WAZUH/NESSUS) to INACTIVE when their
id (wazuh_agent_id or nessus_host_uuid) is missing from the latest
sync. Vice-versa reactivates INACTIVE assets that re-appear.
- Hooked from nessus_sync.run_nessus_sync (after per-host loop, before
commit) and from POST /api/v1/assets/sync_wazuh (after the agents
loop). Both return assets_inactivated/assets_reactivated counts in
their response.
- INACTIVE-asset CVEs now hidden from list_vulnerabilities and
get_dashboard_statistics by default (new
`include_inactive_assets` query param opts back in).
- AuditEventType gains ASSET_DEACTIVATED + ASSET_REACTIVATED so the
transitions are filterable in the audit UI.
**B. EOL pseudo-CVE naming for Nessus + endoflife.date**
- nessus_sync: new `_office_pseudo_cve()` returns
EOL-MS-OFFICE-{YEAR} for Office variants, else EOL-NESSUS-{pid}.
`_normalise_office_pkg()` collapses MUI/Proofing strings to "MS
Office". Title suffix "OSX MUI..." trimmed for Office.
- nessus_sync: end-of-pass sweep collapses pre-existing
EOL-NESSUS-{pid} Office rows into the unified EOL-MS-OFFICE-{year}
anchor (status=patched + audit).
- eol_service: _PRODUCT_SLUGS now maps microsoftoffice[proofing/osxmui
/osxmuigerman/formac] -> ms-office. _YEAR_KEYED_SLUGS includes
ms-office. _pseudo_cve_id produces EOL-MS-OFFICE-{year} for free.
- eol_service: existing-row branch bumps detected_at so the
sort_by=detected_at widget ranks freshest finding first.
**C. Asset view icon asymmetry + dashboard panel fix**
- assets/page.tsx: coverage-gap icon now shows for any asset with
wazuh_agent_id OR nessus_host_uuid.
- nessus_sync._find_or_create_asset: backfills ip_address on existing
assets (UUID / hostname match) so the "Launch targeted Nessus scan"
button (already gated only on asset.ip_address) lights up.
- frontend types + backend AssetResponse expose nessus_host_uuid +
source.
- page.tsx CVE-id cell: `break-all` -> `whitespace-nowrap` so the
free L/R dashboard space isn't wasted on character-wrapped ids.
**D. Assets table sortable columns**
- list_assets: new sort_by / sort_order with whitelist-driven ORDER BY.
Last-scan sort uses nulls_last(). Joins Policy/User only for those
columns. Unknown sort_by falls back to hostname asc.
- assets/page.tsx: sortBy/sortOrder state, handleSort toggler, SortArrow
indicator, clickable headers with cursor-pointer + hover style, and
a useEffect re-fetch on change. Mirrors the pattern in
vulnerabilities/page.tsx.
**Out of scope (flagged)**
- `delete_asset` remains a hard delete with cascade. The user's
"previously-deleted asset not re-created by Scan+Sync" expectation is
intentional design — the auto-create gate (auto_create_assets) and
the new INACTIVE flip cover the rest of the use cases.
- DECOMMISSIONED AssetStatus is still unreachable through automation.
- Vulnerability-count column in assets table stays non-sortable (it
is a Python-side count, not a SQL column).
**Migration**
- 027_add_nessus_source_and_audit_events.py: ALTER TYPE for both
enums + UPDATE backfill. Run `alembic upgrade head` once on prod.
The endoflife.date EOL sweep only covers Wazuh agents
(scheduler filters wazuh_agent_id IS NOT NULL) and Nessus's own
'Unsupported Version Detection' plugins (64784 SQL Server, 56998/97085
Office, ...) carry no CVE, so they were dropped by the non-CVE skip in
nessus_sync. Result: Nessus-only / synced hosts had zero EOL coverage.
Re-admit the Unsupported-Version plugin family (detected via
unsupported_by_vendor=true or the plugin name) as EOL pseudo-vulns with
cve_id EOL-NESSUS-{plugin_id}. The EOL- prefix makes is_eol_finding true
so they appear in the dashboard Newly EOL/EOS widget next to
endoflife.date findings. Severity/CVSS/installed+fixed version come from
the plugin itself. Dedup key (cve_id, asset_id); the EOL id is added to
the per-run seen set so the source-backfill doesn't immediately patch
the row it just created. New 'eol_created' stat in the sync summary.
Other non-CVE plugins (compliance/cipher/info) stay skipped.
'Veeam Explorer for PostgreSQL 13' (v13.1.5.2) was matched to the
postgresql endoflife slug because resolve_product_slug did an
unanchored substring scan ('postgresql' appears in the name) and the
tool's own version 13.x prefix-matched PostgreSQL release 13 -> bogus
HIGH 'EOL-POSTGRESQL-13' finding. The version is Veeam's, not Postgres'.
Two guards: a _WRAPPER_TOKENS denylist (veeam, explorer-for, connector,
odbc, jdbc, driver, ...) that skips name-dropping tools outright, and
the substring scan is now prefix-anchored so a product name must START
with a known key. 'Microsoft SQL Server 2016 Express' and the Office
year match still resolve; '... for PostgreSQL' no longer does.
eol_service: Office (and other year-keyed products) report a build
number from Wazuh syscollector (16.0.4266.1001) while endoflife.date
keys releases by year (name/label '2016'). The numeric prefix match in
_pick_release never matched, so Office 2016 EOL went undetected. Add a
year-from-product-name resolver (_YEAR_KEYED_SLUGS, _extract_year,
_pick_release_by_year) used before the numeric fallback. SQL Server and
other numeric-versioned products are unaffected.
dashboard: long EOL pseudo-CVE ids (EOL-WINDOWS-SERVER-2016) plus
whitespace-nowrap forced the 'Newly EOL/EOS' widget wider than its grid
column, scrolling CPR/Flags out of view. CVE cell now break-all.
assets: 9 columns at px-6 padding (~432px) overflowed max-w-7xl and
clipped the Actions column. Reduced table padding to px-3.
Tester: use Wazuh IT-hygiene (open listeners) as an extra risk
indicator — a host exposing VNC/RDP/Telnet is network-vulnerabler
regardless of CVE count.
Schema (migration 026)
- assets.network_exposure_score (FLOAT 0-100, partial-indexed >0)
- assets.exposed_services (JSON [{port, proto, service, risk, ip}])
- assets.exposure_updated_at
Service (exposure_service.py)
- get_ports() added to WazuhClient (/syscollector/{agent}/ports).
- analyze_ports(): classifies LISTENING sockets against a risky-port
table (Telnet 40, RDP/VNC/SMB 30-35, FTP/rsh 28-30, DB ports 22-26,
SSH 8, …). Loopback-bound listeners excluded. Score = strongest
listener at full weight + 40% of each additional, capped 100.
- refresh_all_exposure() walks Wazuh-linked assets, persists.
API + scheduler
- POST /api/v1/assets/refresh-exposure (on-demand).
- Nightly job 02:30 UTC.
- AssetResponse exposes network_exposure_score + exposed_services
(JSON parsed to list) + exposure_updated_at.
Frontend
- Assets list: new Exposure column — coloured score badge (red ≥60,
orange ≥30, yellow else) with the top services inline + full list
in the tooltip.
- "⚡ Exposure" toolbar button triggers a refresh.
Informational only — does NOT auto-change asset criticality (operator
owns that). Surfaces the data so the operator can raise criticality
on heavily-exposed hosts.