"Sign in with a passkey" on the login page replaces password and TOTP. User verification is required, so the passkey is two factors on its own; password login stays alongside. Users add, list and remove passkeys under Settings -> Profile after confirming their password. WebAuthn checks (challenge, origin, RP ID, signature, sign count) come from py_webauthn; the browser side uses the native PublicKeyCredential JSON helpers. Off unless WEBAUTHN_ORIGIN is set; its hostname is the RP ID. Local accounts only: a passkey sign-in never asks the directory, so an LDAP user disabled or demoted in AD would keep access and role. Deactivated and permanently locked users are refused. Challenges are 5-minute signed tokens burnt on first use (token_revocation.claim, check and revoke under one lock). The admin "Reset MFA" also removes the user's passkeys. New table passkeys (migration 067). Rules live in app/auth/passkey.py, endpoints under /auth/passkeys in app/routers/auth_passkey.py.
237 KiB
TrueVuln — Dev Branch Features
Threat-Intel-Enrichment, Risk-Based Prioritization & Multi-Provider Auth
Branch-only changes that are not yet on main. See README.md for the stable feature set.
What this branch adds
Six feature groups on top of stable main:
1. Threat-intel enrichment — extends the priority score beyond CVSS + Wazuh exploit flags with multiple independent threat-intelligence sources, plus a separate CPR (Cybersecurity Priority Risk) score.
| Feature | Source | Notes |
|---|---|---|
| EPSS | FIRST.org | Probability of exploitation in next 30 days |
| CISA KEV | CISA.gov | US-side known-exploited catalog (~1500 CVEs) |
| ENISA EUVD | euvd.enisa.europa.eu | EU exploited + ENISA-flagged critical (~1600 CVEs) |
| CISA Vulnrichment | cisagov/vulnrichment | Authoritative per-CVE CVSSv3.1 corrections + SSVC decision points (Exploitation / Technical Impact / Automatable). Parses CNA + ADP containers. |
| NVD REST API | services.nvd.nist.gov | 2nd-stage fallback for CVEs Vulnrichment hasn't analysed yet (rate-limit-bounded, ≤ 100 per run) |
| cvelistV5 | CVEProject/cvelistV5 | 3rd-stage backstop — official MITRE CVE.org cache, exhaustive (~250k+ CVEs). 12h on-disk cache. |
| Tenable VPR | Nessus plugins | Tenable's proprietary 0–10 priority rating; surfaced as a parallel reference, not folded into CPR |
| CPR Score | computed | percentile-weighted (CVSS×10 × 0.6) + (EPSS_Percentile × 0.4), range 0-100, JacquesKruger/EPSS-Server algorithm |
All sources are free, no API key required.
2. Multi-provider authentication — Strategy-Pattern auth stack with local (TOTP-MFA), LDAPS, OIDC (Entra ID / Okta / Keycloak / Google) and SAML 2.0 running in parallel, plus an admin UI for group→role mapping.
3. Nessus integration — Tenable Nessus scan import alongside Wazuh on the same (cve_id, asset_id) row. Source badges, cross-confirmation, per-host scan rows in the scan history, OS auto-fill from plugin 11936.
4. Compliance via Wazuh SCA — per-policy pass/fail/N/A summary stored in compliance_results. Dashboard widget + dedicated /compliance page with per-asset modal, nightly auto-refresh.
5. Unified Risk Score (URS) — single 0-100 figure per asset combining Asset Vulnerability Score (AVS, from CPR) and Asset Security Score (ASS, from impact-weighted SCA), multiplied by per-asset criticality. Daily snapshots feed a trend arrow. CSV-importable CIS-Benchmark impact weights drive the weighting.
6. Built-in App→CVE detection + Mobile Device Security — a curated OSV/NVD-CPE/cvelistV5 scanner maps installed software to real CVEs (and suppresses Wazuh's loose-CPE false positives) for hosts with no real vulnerability scanner; a parallel track handles mobile devices — EOL/EOS for Samsung/Apple models, Android patch-level staleness, and per-CVE Android detection via Samsung's own SMR page (preferred, precise) or Google's ASB (fallback). Plus a CISA-KEV "actively exploited" advisory feed, independent of asset findings.
Priority Score (current formula)
exploit_bonus = max(
4.0 if (CISA KEV listed) OR (ENISA EUVD listed),
3.0 × EPSS_probability,
4.0 if Wazuh exploit_available else 2.0 if Wazuh exploitable,
SSVC exploitation_status:
widespread 5.0 | active 4.0 | poc 2.0,
Nessus exploit_code_maturity:
High 4.0 | Functional 3.0 | PoC 1.5,
)
# — five sources, max() to prevent double-count
ssvc_addon = (
+2.0 if SSVC technical_impact == 'total'
+1.0 if SSVC automatable == 'yes'
) # capped at +3; adds ON TOP of exploit_bonus
# because technical_impact + automatable describe
# DIFFERENT dimensions than exploitation signals
base = (CVSS + exploit_bonus + ssvc_addon)
× asset_criticality_factor + age_bonus
catalog_bonus =
+10 if CISA KEV listed
+5 if KEV ransomware-use known
+10 if ENISA EUVD listed
+3 if ENISA-flagged critical
priority = min(base + catalog_bonus, 100)
# CPR — JacquesKruger algorithm (percentile-weighted, capped 100)
cpr_score = (CVSS × 10 × 0.6) + (EPSS_Percentile × 100 × 0.4)
Ranking rule: priority first, CPR breaks ties, unscored last. Followed by
the vulnerability list default sort, the Recent Critical widget (selection =
newest, order = priority), the new-vuln digest ({{rows}}: Prio + CPR
columns), the SLA digest (Prio column), the single new-vuln and SLA mails
({{priority_score}}, {{cpr_score}}), the PDF reports, JEV triage and the AI
ranking. The patch plan ranks updates, not findings: exploited first, then
the summed priority the update removes. Deliberately not priority: the KEV
alert (a feed of new catalogue listings, newest first — every row is exploited
and present) and the Mobile widget (lifecycle pseudo-findings, ordered by their
fixed pseudo-CVSS). CPR is never the primary key:
it needs EPSS, which a freshly published critical CVE does not have yet, so a
CPR order put it below every scored medium. Priority exists without EPSS and
without CVSS (severity floor). URS/AVS still aggregates CPR per asset — it is
an asset score, and priority already carries the asset factor.
CPR was previously a simple CVSS × EPSS × 10 which collapsed to
near-zero for the 99% of CVEs with EPSS < 1% — useless for triage.
Switch to JacquesKruger's percentile blend lands in 6dc182f.
What does this mean in practice?
- CVE in both CISA KEV and ENISA EUVD ⇒ +20 stacking bonus = highest confidence, shoots to the top
- High EPSS but no catalog listing ⇒ still scored aggressively (continuous 0–3 signal)
- SSVC
total + automatable + active⇒ adds 3 + 4 = 7 base bonus, marked "patch right now" - No external data ⇒ falls back to old behaviour (CVSS + Wazuh flags + asset/age)
- CVE-2026-8390 (CVSS 9.8, EPSS percentile 11.9) ⇒ CPR 63.56 (Medium) — old formula was 0.04
UI changes
Vulnerabilities list (/vulnerabilities) gains:
| Column / Filter | What it shows |
|---|---|
| EPSS column | percentile-coloured probability % |
| KEV column | red badge + RANSOM sub-badge for ransomware-use CVEs |
| EUVD column | blue badge + EU-CRIT sub-badge for ENISA-flagged critical |
| CPR column | combined CVSS × EPSS × 10 score with mini-bar |
| Filter KEV | only CVEs in CISA KEV catalog |
| Filter EUVD | only CVEs in ENISA EUVD catalog |
| Filter EU Critical | only ENISA-flagged critical CVEs |
| Filter KEV + EUVD | intersection — confirmed by both US and EU sources |
| Filter EPSS ≥ | minimum EPSS probability in percent |
| Button Refresh Threat Intel | manual bulk-enrichment trigger |
The vuln detail page shows three threat-intel sections (CISA KEV, ENISA EUVD, EPSS) plus a Priority Score breakdown that itemises every component, plus the CPR score on a separate line.
When is enrichment triggered?
- Daily scheduler job — runs every 24 h, refreshes EPSS + KEV + EUVD for all open vulnerabilities. First run 5 minutes after backend startup.
- After Wazuh sync — newly discovered CVEs are auto-enriched before they reach the UI.
- Manual "Refresh Threat Intel" button — bulk-enriches every open vuln on demand.
- Per-vuln refresh — link on the detail page enriches one CVE.
CISA KEV and ENISA EUVD catalogs are cached for 24 h in the settings table to avoid hammering the upstream feeds.
New API endpoints
All require editor role (admin too) and JWT auth.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/vulnerabilities/enrich/bulk |
Body {"only_open": true} or {"vulnerability_ids": [...]} — bulk enrich |
POST |
/api/v1/vulnerabilities/{id}/enrich |
Enrich one vulnerability |
POST |
/api/v1/vulnerabilities/enrich/kev/refresh |
Force-refresh CISA KEV cache |
Response stats schema (bulk):
{
"stats": {
"total": 1540,
"epss_updated": 1532,
"kev_marked": 9, "kev_cleared": 0,
"euvd_marked": 9, "euvd_cleared": 0
}
}
New filter query parameters on GET /api/v1/vulnerabilities
kev_only=true— only CISA KEVeuvd_only=true— only ENISA EUVDeu_critical=true— only ENISA-flagged criticalin_any_catalog=true— KEV ∪ EUVDin_both_catalogs=true— KEV ∩ EUVDepss_min=0.5— minimum EPSS score (raw 0.0-1.0, UI sendsvalue / 100)
New database fields (Alembic 007 + 008)
Added to vulnerabilities:
| Column | Type | Purpose |
|---|---|---|
epss_score |
float | EPSS probability 0.0-1.0 |
epss_percentile |
float | EPSS percentile rank |
epss_updated_at |
timestamp | last EPSS refresh |
kev_listed |
bool, indexed | in CISA KEV catalog |
kev_date_added |
timestamp | KEV add date |
kev_ransomware_use |
bool | ransomware campaign use |
kev_short_description |
text | from KEV JSON |
euvd_listed |
bool, indexed | in ENISA EUVD (exploited) |
euvd_critical |
bool | ENISA-flagged critical |
euvd_date_added |
timestamp | EUVD add / exploited-since date |
euvd_id |
string | ENISA EUVD-ID |
enrichment_sources |
text (JSON) | sources used, e.g. ["epss","kev","euvd"] |
enrichment_updated_at |
timestamp | last enrichment run |
CPR score is not persisted — computed on the fly from cvss_score × epss_score × 10.
Settings toggles
Three new entries in the settings table (defaults seeded at first start):
| Key | Default | Purpose |
|---|---|---|
enrichment_epss_enabled |
true |
Toggle EPSS enrichment |
enrichment_kev_enabled |
true |
Toggle CISA KEV enrichment |
enrichment_euvd_enabled |
true |
Toggle ENISA EUVD enrichment |
Change via admin API:
curl -X PUT http://<host>/api/v1/settings/enrichment_euvd_enabled \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"value":"false"}'
Deployment / upgrade path
# 1. Pull dev branch
git pull origin dev
# 2. Rebuild containers
docker compose build backend frontend && docker compose up -d backend frontend
# 3. Run migrations (007 + 008)
docker compose exec backend alembic upgrade head
docker compose exec backend alembic current # expect: 008 (head)
# 4. Wait 5 min for the first scheduler-triggered enrichment, OR press
# "Refresh Threat Intel" in the UI for an immediate run.
Outbound HTTPS required
The backend container must be able to reach:
https://api.first.org(EPSS)https://www.cisa.gov(KEV feed)https://euvdservices.enisa.europa.eu(EUVD search + critical)
If you operate behind a proxy, set HTTPS_PROXY on the backend service.
Out of scope (future ideas)
- CIRCL aggregator KEV (CISA + CIRCL + EUVD in one endpoint via
cvepremium.circl.lu) - EPSS bulk CSV download (
epss_scores-current.csv.gz) for very large deployments - ENISA EUVD descriptions / references backfill into our
description/referencesfields - NVD enrichment for missing CVSS vectors / CWE-IDs
- Custom catalog ingestion (user-uploaded CSV of "internally exploited" CVEs)
Verification snippets
TOKEN=$(curl -sk -X POST http://<host>/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"PASS"}' | jq -r .access_token)
# Trigger bulk enrichment
curl -X POST http://<host>/api/v1/vulnerabilities/enrich/bulk \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"only_open": true}' | jq
# Show fields on one CVE
curl -s "http://<host>/api/v1/vulnerabilities?limit=1" \
-H "Authorization: Bearer $TOKEN" \
| jq '.items[0] | {cve_id, cvss_score, epss_score, kev_listed, euvd_listed, cpr_score, priority_score}'
# List only CVEs confirmed by both catalogs
curl -s "http://<host>/api/v1/vulnerabilities?in_both_catalogs=true&limit=5" \
-H "Authorization: Bearer $TOKEN" \
| jq '.items[] | {cve_id, kev_listed, euvd_listed, priority_score, cpr_score}'
# Watch enrichment in the logs
docker compose logs -f backend | grep -iE "epss|kev|euvd|enrich"
Expected log lines on a fresh refresh:
EUVD: fetching fresh catalogs from ENISA
EUVD: cached ~1700 unique CVEs from ENISA (exploited pages processed=N, critical entries=4)
KEV: fetching fresh catalog from CISA
KEV: cached ~1500 entries from CISA
EPSS: fetched N scores for N CVEs
Enrichment done: ... epss_updated=N, kev_marked=M, euvd_marked=K
Multi-Provider Authentication
LDAPS + OIDC + SAML 2.0 running in parallel alongside local username/password, with optional TOTP MFA, Just-In-Time provisioning, and runtime-editable group→role mappings.
Architecture (Strategy Pattern)
┌──────────────────┐
│ /auth/login │ username + password
└────────┬─────────┘
│
▼
┌────────────────────┐ ┌──────────────────────┐
│ AuthOrchestrator │ ───► │ LocalAuthStrategy │ bcrypt + TOTP gate
│ (AUTH_LOOKUP_ORDER│ └──────────────────────┘
│ chain, e.g. │ ┌──────────────────────┐
│ local → ldap) │ ───► │ LdapStrategy │ search-then-bind, LDAPS
└────────┬───────────┘ └──────────────────────┘
│
▼
┌────────────────────┐
│ JITProvisioner │ ─► auto-create User on first SSO/LDAP login,
│ + RoleMapper │ auto-link existing email, re-eval role each login
└────────┬───────────┘
▼
JWT issued (httpOnly cookie + bearer)
┌──────────────────────────┐
│ /auth/oidc/login │ ──► PKCE redirect to IdP
│ /auth/oidc/callback │ ──► JWKS-validated id_token → orchestrator.complete_sso_login()
└──────────────────────────┘
┌──────────────────────────┐
│ /auth/saml/login │ ──► python3-saml AuthnRequest
│ /auth/saml/acs │ ──► strict signature + XSW check → orchestrator.complete_sso_login()
│ /auth/saml/metadata │ ──► auto-generated SP descriptor
└──────────────────────────┘
Each strategy lives in app/auth/strategies/. Adding a new IdP type is a single new file
that implements AuthStrategy — no changes to /auth/login needed.
Providers
| Provider | Endpoint(s) | Notes |
|---|---|---|
| Local | POST /auth/login |
bcrypt; optional TOTP via /auth/mfa/* |
| LDAPS | POST /auth/login (chain) |
ldap3, strict TLS, AD-flavoured defaults (sAMAccountName/memberOf/objectGUID) |
| OIDC | GET /auth/oidc/login + /callback |
Authlib, PKCE S256, strict id_token validation (iss/aud/exp/nonce/sig via JWKS) |
| SAML 2.0 | GET /auth/saml/login + POST /auth/saml/acs + /metadata |
python3-saml, strict=true (XSW + AudienceRestriction + Recipient + Replay) |
GET /auth/providers is the public discovery endpoint the login UI reads to render
the right mix of buttons.
TOTP (RFC 6238) MFA
Optional second factor for local accounts only — SSO users get MFA from their IdP.
Self-service flow (UI)
Visit /settings → Two-factor authentication card (top of the right column):
- Click Enable MFA → confirm current password
- Scan the rendered QR (SVG, generated client-side via
qrcode.react— secret never leaves the browser session) or paste the base32 secret manually into your authenticator app - Enter the 6-digit code your app shows → Activate MFA
- Badge flips to
ENABLED. Next login asks for the code as a second step.
To turn it off: same card, Disable MFA → password.
For non-local users (LDAP/OIDC/SAML) the card shows a hint that MFA is managed by the upstream IdP and offers no buttons.
Admin reset (lost device)
Settings → User Management → orange Reset MFA button next to the user clears
totp_enabled + totp_secret. The user can then log in with password alone and
re-enrol via /settings. Logged as MFA_DISABLED audit event with the admin
as actor.
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /auth/mfa/setup |
self | Verify current password → return secret + provisioning_uri (otpauth://) |
POST /auth/mfa/activate |
self | Confirm enrolment with a fresh code, flip totp_enabled |
POST /auth/mfa/disable |
self | Disable, requires password |
POST /auth/mfa/verify |
login flow | Second-factor step after /auth/login returns mfa_required: true |
POST /auth/users/{user_id}/reset-mfa |
admin | Force-reset MFA for any user (no password required, audit-logged) |
Secrets are stored Fernet-encrypted in users.totp_secret. Key in env:
AUTH_PROVIDER_CRYPTO_KEY — losing it invalidates every stored TOTP enrolment.
Frontend proxy note
Next.js needs an explicit route.ts for every backend path it forwards.
A catch-all at frontend/app/auth/[...path]/route.ts covers the new
/auth/mfa/*, /auth/providers, /auth/oidc/*, /auth/saml/* endpoints
and /auth/passkeys/* in one file. Specific routes (e.g. /auth/login, /auth/me,
/auth/users/{id}/reset-password, /auth/users/{id}/reset-mfa) keep
priority — Next.js prefers more specific segments over catch-alls.
Passkeys (passwordless sign-in)
Sign in with a passkey on the login page replaces password and TOTP:
user verification (Touch ID / Windows Hello / PIN) is required, so the passkey
is two factors on its own. Password login stays alongside. Code:
app/auth/passkey.py (rules, WebAuthn via py_webauthn),
app/routers/auth_passkey.py, table passkeys (migration 067).
- Off unless
WEBAUTHN_ORIGINis set — exactly the URL users open (https://truevuln.company.com). Its hostname is the WebAuthn RP ID; changing the hostname later invalidates every registered passkey. - Local accounts only. A passkey sign-in never asks the directory, so an
LDAP user disabled or demoted in AD would keep access and role. SSO users
get their factors from the IdP. A user later moved off
localcan no longer sign in with an old passkey. - Discoverable credentials: the login needs no username; the browser offers the passkeys it has for this site.
- MFA policy: a passkey sign-in skips the TOTP step and
mfa_enforcedforced setup (it already is two factors). It refuses deactivated and permanentlylockedusers, but not the temporary lockout — that window counts password guesses, which a passkey does not make. - Challenges are 5-minute signed tokens burnt on first use
(
token_revocation.claim), so a captured response cannot be replayed. The burn list is in memory: one worker, as for refresh-token revocation.
Self-service flow (UI)
/settings → Profile → Passkeys card: Add passkey → name + current
password → browser prompt. Each passkey shows created / last used and can be
removed. Uses the browser's native PublicKeyCredential.parse…FromJSON /
toJSON(); browsers without them don't see the button.
Admin reset (lost or stolen device)
The existing Reset MFA button also removes all of the user's passkeys. Sessions already open stay valid until their tokens expire.
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /auth/passkeys/register/options |
self | Verify current password → creation options + challenge token |
POST /auth/passkeys/register |
self | Verify attestation, store passkey |
GET /auth/passkeys |
self | List own passkeys |
DELETE /auth/passkeys/{id} |
self | Remove own passkey |
POST /auth/passkeys/login/options |
public | Request options + challenge token |
POST /auth/passkeys/login |
public | Verify assertion → full session (cookies + tokens) |
Audit: sign-in as LOGIN_SUCCESS / LOGIN_FAILED ("via passkey"), add /
remove as MFA_ENABLED / MFA_DISABLED with "Passkey …" in the text.
Just-In-Time provisioning + auto-link
On first SSO/LDAP login, the orchestrator:
- Looks up
(auth_provider, external_id)— exact subject match - Falls back to lookup by email → if a local user matches, auto-link
(per requirements). Logged as
USER_AUTO_LINKED. - Otherwise JIT-creates a new user stub with
password_hash = NULL, role from group mapping,is_active=true. Logged asJIT_USER_CREATED.
On every subsequent login, the role is re-evaluated from current external
groups — promotions/demotions in the IdP propagate immediately. Logged as
EXTERNAL_ROLE_MAPPED.
Group → Role mapping
Patterns are fnmatch style (*, ?), case-insensitive, first match wins.
Rules are admin-editable via the UI at /admin/auth or via the API:
curl -X PUT https://<host>/api/v1/auth-config/role-mappings \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"mappings": {
"ldap": [
{"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
{"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
],
"oidc": [
{"pattern": "<azure-group-uuid-admin>", "role": "admin"}
],
"saml": [
{"pattern": "TrueVuln-Admins", "role": "admin"}
]
}
}'
Rules with no match fall through to AUTH_JIT_DEFAULT_ROLE (default: readonly).
Admin UI
/admin/auth (RequireAdmin) — runtime control of the runtime-controllable parts.
Endpoint config still lives in env (restart required for those changes).
- Global panel — lookup order, JIT on/off, default role, crypto-key health check
- Provider status cards — Enabled / Configured badges + user-count per provider
- Test buttons:
- LDAP: opens TLS connection, binds with service account, optionally searches a sample username to verify filter + attribute mapping
- OIDC: fetches discovery doc + JWKS, reports issuer / endpoints / key count
- SAML: parses IdP metadata, returns SSO URL + entity ID + cert presence
- Mapping editor — per-provider rule list, add / edit / reorder / remove, save via PUT
New API endpoints
| Method | Path | Role | Purpose |
|---|---|---|---|
GET |
/auth/providers |
public | Provider discovery for the login UI |
POST |
/auth/mfa/setup |
self | Begin TOTP enrolment |
POST |
/auth/mfa/activate |
self | Confirm enrolment |
POST |
/auth/mfa/disable |
self | Disable enrolment |
POST |
/auth/mfa/verify |
login flow | Second factor after /auth/login |
POST |
/auth/users/{user_id}/reset-mfa |
admin | Force-reset MFA and passkeys for any user (lost-device escape hatch) |
* |
/auth/passkeys/* |
self / public | Passkey enrolment and passwordless sign-in (see Passkeys) |
GET |
/auth/oidc/login |
public | Start OIDC SP-initiated flow |
GET |
/auth/oidc/callback |
IdP-redirect | Complete OIDC flow |
GET |
/auth/saml/login |
public | Start SAML SP-initiated flow |
POST |
/auth/saml/acs |
IdP-POST | SAML Assertion Consumer Service |
GET |
/auth/saml/metadata |
public | Auto-generated SP descriptor |
GET |
/api/v1/auth-config/status |
admin | Provider readiness + user counts |
GET |
/api/v1/auth-config/role-mappings |
admin | Current rules |
PUT |
/api/v1/auth-config/role-mappings |
admin | Replace rules (validated) |
POST |
/api/v1/auth-config/ldap/test |
admin | Bind + optional sample search |
POST |
/api/v1/auth-config/oidc/test |
admin | Probe discovery + JWKS |
POST |
/api/v1/auth-config/saml/test |
admin | Probe IdP metadata |
Database fields (Alembic 009)
users table gains:
| Column | Type | Purpose |
|---|---|---|
auth_provider |
enum (local/ldap/saml/oidc), indexed | Strategy that owns this identity |
external_id |
string, indexed | IdP subject / objectGUID / nameID |
external_groups |
text JSON | Raw groups snapshot from last login |
last_provider_sync |
timestamp | Last refresh from IdP |
totp_secret |
string (encrypted) | Fernet-encrypted base32, NULL for SSO users |
totp_enabled |
bool | Activated after /auth/mfa/activate |
password_hash |
string, now nullable | NULL for non-local users |
New audit-event values: LOGIN_LDAP_SUCCESS, LOGIN_SSO_SUCCESS,
AUTH_PROVIDER_FAILED, JIT_USER_CREATED, EXTERNAL_ROLE_MAPPED,
USER_AUTO_LINKED, MFA_ENABLED, MFA_DISABLED, MFA_VERIFIED, MFA_FAILED.
POST /auth/login success and failure entries end in [Web-UI] or [API].
Browsers send Sec-Fetch-Mode on every fetch and scripts don't, so this is
the client's claim, not proof. The Next.js proxy (frontend/lib/proxy.ts)
forwards Sec-Fetch-Mode and User-Agent for this; before it did, every
entry carried node as user agent.
The client IP: the proxy forwards X-Forwarded-For too. Next.js keeps a
client-supplied one verbatim, which made this unsafe while port 3003 was open
to the network. Since the frontend is bound to 127.0.0.1 and only nginx (or
the operator's proxy) reaches it, the edge proxy appends the real peer
($proxy_add_x_forwarded_for), and uvicorn's ProxyHeadersMiddleware takes
the rightmost entry that is not a trusted proxy — a forged entry in front
never counts. Trusted are loopback + the backend's own Docker subnet, read
from /proc/net/route (rate_limiter.forwarded_allow_ips), plus
FORWARDED_ALLOW_IPS. Not RFC1918 as a whole: internal clients live there,
and a trusted client could name any address. The rate limiter keys on the
resolved request.client.host; it used to re-parse the header trusting all
private networks, so an internal user could pick a fresh key per request.
Configuration
Provider enablement is comma-separated:
AUTH_PROVIDERS=local,ldap,oidc,saml # subset = disabled
AUTH_LOOKUP_ORDER=local,ldap # credential-strategy chain
AUTH_JIT_PROVISIONING=true
AUTH_JIT_DEFAULT_ROLE=readonly
AUTH_PROVIDER_CRYPTO_KEY=<fernet-key> # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
Account lockout
Login is rate-limited (5/min/IP) and locks a local account after
ACCOUNT_LOCKOUT_THRESHOLD (5) failed attempts. Two modes:
- Temporary (default) — auto-unlocks after
ACCOUNT_LOCKOUT_DURATION_MIN(15 min). No admin action, no self-lockout risk. - Permanent — set the
auth_lockout_permanentsetting (Settings → General) totrue. The account stays locked until an admin clears it viaPOST /auth/users/{id}/unlock(Unlock button in User Management). Safeguard:_is_last_active_admin()prevents permanently locking the last recoverable admin (it falls back to the temporary window) so a brute-force on the admin login can't lock the whole system out. Columns:users.locked,locked_at(migration 037).
Audit-log → syslog / SIEM forwarding
syslog_service.py mirrors every audit_logs insert to an external syslog
server (RFC-5424, UDP or TCP) when the syslog_config setting is enabled
({enabled, host, port, protocol, facility}; admin card under Settings). A
SQLAlchemy after_insert hook enqueues onto a bounded queue drained by one
daemon worker — never blocks the request/flush, bursts drain sequentially.
Per-event severity: FAILED/DENIED/LOCK → warning, SECURITY_ALERT/escalation →
alert, deletes/config-change → notice, else info. Disabled = cheap no-op.
No TLS yet (UDP/TCP). Test probe: POST /api/v1/settings/syslog/test.
The message carries user_id=, user= and src_ip= when the row has them.
Audit log: who, what, from where
Every row with a user_id gets the actor's username, client IP and user
agent stamped on insert (audit_log._stamp_actor, fed by the app-wide
capture_request_origin dependency), so a writer cannot forget them. The
username is a snapshot: delete_user nulls user_id, the name stays
(migration 065 backfilled it for users that still existed). Rows without a
user (sync, scheduler) stay as they are: no name, no IP — they read
"System/Auto". Asset edits (single and bulk) record each changed field
old → new; a settings PUT writes CONFIG_CHANGE with the changed field
names and redacted old/new values, and nothing when the value is unchanged.
Every POST/PUT/PATCH/DELETE route audits itself, and
tests/test_audit_user_actions.py fails for a new one that does not. Two
helpers in app/services/audit_trail.py: record_change (one row naming each
field old → new; create passes before={}, delete after={}; nothing changed,
no row) for groups, policies, scan schedules, finding/asset assignment and
suppression; record_action for actions that change no single record. Buttons
(sync, scan, refresh, connection test) take the route dependency
audited("What") from app/auth/dependencies.py: one row once the endpoint
returned, before the response goes out (scope="function") — a refused call
or one that raises writes none, a connection test answering {"ok": false}
is recorded as attempted — with the query parameters and only the named
body_fields (bodies carry passwords). Groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, both told apart
by resource_type and the description; no new enum values, so no migration.
Custom CA certificates (internal PKI)
Integrations behind an internal-PKI certificate used to work only with
"Verify SSL" off. httpx verifies against certifi, not the OS store, so
update-ca-certificates in the container changes nothing; what httpx, pyVmomi
(vCenter) and ldap3 (LDAPS without LDAP_CA_CERT_PATH) all honour is
SSL_CERT_FILE. ca_trust_service.py therefore writes one bundle — certifi
(or the SSL_CERT_FILE the process started with) plus the uploaded CAs — to
$TMPDIR/truevuln-ca-bundle.pem and points SSL_CERT_FILE at it. Each new
client reads the variable, so an upload applies to the next connection, no
restart. The bundle keeps the public roots: SSL_CERT_FILE replaces the
store, and a bundle of only the internal CA breaks every NVD/MSRC/GitHub feed.
- Admin card Settings → Security → Trusted CA Certificates; API
GET/POST /api/v1/trust-store/ca-certificates,DELETE …/ca-certificates/{sha256}— admin only, reads included (what the server trusts decides whose certificate it accepts on connections that carry integration credentials). - Accepts PEM (one cert or a chain) or DER. Rejected: non-CA certificates
(no
basicConstraints CA:TRUE), expired CAs, any file containing a private key. - Stored as JSON under the
custom_ca_certificatessetting; the genericPUT /api/v1/settings/{key}is denied for it so every change is validated and audited (CONFIG_CHANGE,resource_type=ca_certificate). - Rebuilt from the DB in
lifespanbefore the scheduler starts, so the first sync after a restart already trusts it. One process (uvicorn without--workers); with several workers each would need to rebuild on change. - Python ≥ 3.13 verifies with
VERIFY_X509_STRICT: a CA withoutkeyUsageor a server certificate without Authority Key Identifier fails there. 3.11 did not set the flag, and older internal PKIs look exactly like that, soca_trust_serviceclears it inssl.create_default_contextandssl._create_default_https_contextfor the whole process (httpx, pyVmomi, urllib). Test:test_alte_interne_pki_ohne_keyusage_und_aki_bleibt_vertrauenswuerdig.
HTTPS certificate of the web UI (bundled nginx)
TrueVuln used to speak plain HTTP only; HTTPS needed a foreign reverse proxy
and port 3003 was open on every interface. Now nginx/ (Compose profile
tls) terminates TLS and proxies to the frontend; frontend and backend are
published on 127.0.0.1 only.
- The backend owns the files, nginx only reads them:
https_certificate_service.pywritesserver.pem/server.keytoTLS_DIR(volumetls_data, mounted read-only by nginx) andca.pem/ca.keytoTLS_CA_DIR(volumetls_ca, backend only — nginx faces the network). Keys 0600; all files of a change are written to.tmpfirst, then renamed.nginx/40-truevuln-tls.shwaits for the pair at start and polls inode/mtime/size every 30 s; on change it runsnginx -t && nginx -s reload, and logs once if nginx rejects the new pair (caught between the two renames it retries). No Docker socket. - Auto mode: one CA per installation (ECDSA P-256, 10 years,
pathlen 0, name constraints on exactlyserver_names()=TLS_SERVER_NAMES+DASHBOARD_URLhost + localhost/127.0.0.1). ca.key sits on the vulnerability server; an unconstrained root in every browser would let whoever steals it impersonate any site. A name the CA does not cover → new CA (warning in the log, must be redistributed). Server certificate: 397 days (Apple caps at 825 even for private CAs), reissued when < 30 days left, names changed, or key/CA no longer match. Runs inlifespanand nightly at 03:45 (https_certificate_check). A CA created or replaced writes a systemCONFIG_CHANGEaudit row. An uploaded certificate whose key no longer matches falls back to auto (nginx would not start with it). - Custom mode:
install_custom_certificatetakes PEM (chain, optionally with the key), DER or PKCS#12 and a separate key file. Checks: key matches a cert in the upload, RSA ≥ 2048 / EC P-256/384 (Chrome has no P-521 signature), leaf is neither a CA nor self-signed, valid now, EKU allows serverAuth, has a SAN that covers theDASHBOARD_URLhost, SHA-256+ signatures. The chain is built by signature, in any order; it must end in a self-signed root in the upload or at a root in certifi / the uploaded Trusted CA Certificates. Served: leaf + intermediates, no root. - Auto vs. custom is read off the served certificate (issuer
O=TrueVuln (auto-generated)), not stored anywhere — renewal never overwrites an uploaded certificate.DELETEreissues from the CA (force_auto). - API
GET/POST/DELETE /api/v1/https-certificate,GET …/ca.pem— admin only, reads included; upload/reset writeCONFIG_CHANGE(resource_type=https_certificate) with certificate facts only, never the key. - The Next.js proxy (
frontend/lib/proxy.ts) forwards request bodies as bytes;request.text()mangled DER/PFX uploads.
CIS NGINX Benchmark v3.1.0 (bundled nginx)
All Level 1 recommendations are met or do not apply; Level 2 is taken where it
costs nothing. Checked against the built image with nginx -T and the
benchmark's curl tests (2026-10-31). Where it lives: nginx/default.conf.template,
nginx/15-truevuln-server-names.envsh, nginx/40-truevuln-tls.sh, nginx/Dockerfile.
| CIS | Status | How / why not |
|---|---|---|
| 1.1.1–1.2.2 install, repo, updates | met | nginx:stable-alpine (nginx.org packages, 1.30.x) + apk upgrade at build; current only as long as the image is rebuilt |
| 2.1.1 dynamic modules | met | stock nginx.conf loads none |
| 2.2.1–2.2.3 account | met | workers run as nginx, password locked (!), shell /sbin/nologin (image default) |
| 2.3.1, 2.3.2 owner, permissions | met | root:root; 40-truevuln-tls.sh sets 750/640 after the template is rendered. tls/ is the backend's read-only volume (key 0600) |
| 2.3.3 PID file | met | /run/nginx.pid root 644 |
| 2.4.1 ports | met | 80 (redirect) and 443 only |
| 2.4.2 unknown hosts | met, one deviation | default_server returns 444 on 80 and 443; the app answers only for server_names(), passed in by the .envsh. No ssl_reject_handshake: browsers send no SNI for an IP, so https://10.0.0.5 must complete the handshake in the default server and is matched by Host afterwards |
| 2.4.3, 2.4.4, 5.2.1 timeouts | met | keepalive, send, client header/body 10 s. They count between two reads/writes, not per request; proxy_read_timeout 600 s for AI analyses is the upstream side |
| 2.5.1 server_tokens | met | Server: nginx, no version |
| 2.5.2 error pages | met, one gap | stock index.html/50x.html removed, error.html for nginx's own errors. A 413 over HTTP/2 still gets nginx's built-in page (nginx ignores error_page there; HTTP/1.1 is fine) |
| 2.5.3 hidden files | met | location ~ /\. → 404. No route has a path segment starting with a dot; names like ".NET" travel in query strings |
| 2.5.4 proxy disclosure | met | proxy_hide_header X-Powered-By (Next.js), Server |
| 3.1–3.3 logging | met | stock main format (all required fields incl. X-Forwarded-For) to stdout, error_log notice to stderr → docker logs |
| 3.4 source IP | met | X-Forwarded-For, X-Real-IP, X-Forwarded-Proto |
| 4.1.1 HTTP → HTTPS | met | 301, known names only |
| 4.1.2 trusted certificate | operator | own CA (distribute it) or upload from the internal PKI, see above |
| 4.1.3 key permissions | met | backend writes 0600, nginx mounts read-only |
| 4.1.4–4.1.6 protocols, ciphers, DH | met | TLSv1.3 only; OpenSSL's 1.3 suites; no ssl_dhparam |
| 4.1.7 OCSP stapling | n/a | the own CA runs no OCSP responder; Let's Encrypt has dropped OCSP too |
| 4.1.8 HSTS | met | max-age=63072000; includeSubDomains, no preload (internal names) |
| 4.1.9, 4.1.10 upstream mTLS / verify (L1/L2 proxy) | n/a | upstream is plain HTTP to frontend:3000 on the internal Docker bridge, never leaves the host |
| 4.1.11 session resumption | met | session tickets on (default), shared cache; resumption tested |
| 4.1.12 HTTP/3 (L2) | not taken | needs UDP 443 published and opened; no hardening gain |
| 5.1.1 allow/deny (L2) | not taken | site-specific; put it in front with the firewall or an own proxy |
| 5.1.2 methods | met | GET HEAD POST PUT PATCH DELETE; anything else 405. Same origin, so no CORS preflight |
| 5.2.2 body size | met | 1 MB, 100 MB under /api/ (all uploads; CIS benchmark imports) |
| 5.2.3 URI buffers | met | default large_client_header_buffers |
| 5.2.4, 5.2.5 conn/rate limit per IP (L2) | not taken | behind an operator's proxy every user shares one IP; HTTP/2 counts each stream as a connection. Login is rate-limited and locked out in the backend (slowapi, per real client IP) |
| 5.3.1 X-Content-Type-Options | met | nosniff on every response |
| 5.3.2 CSP (L2) | met | Next.js pages had none; nginx adds one only where the upstream sent none (backend responses keep theirs). 'unsafe-inline' for scripts: Next.js inlines its bootstrap. img-src https: because the e-mail previews are srcdoc iframes, which inherit the page's policy. frame-ancestors 'none' |
| 5.3.3 Referrer-Policy (L2) | met | strict-origin-when-cross-origin |
| 6 MAC | host | AppArmor/SELinux is the Docker host's (docker-default profile) |
See .env.example for full LDAP / OIDC / SAML blocks with worked
examples for Entra ID, Okta, Keycloak, Google, and Active Directory.
Security defaults baked in
- LDAPS:
CERT_REQUIRED+ custom CA path; refuses if filter returns >1 entry (anti-impersonation); filter args escaped vialdap3.utils.conv.escape_filter_chars; bind password Fernet-encrypted in DB after first bootstrap - OIDC: PKCE S256 mandatory; nonce check (replay defense); essential claims
validation; JWKS auto-rotation on signature failure;
prompt=select_accountto defeat silent IdP-session reuse; state in signed cookie (no server-side session) - SAML:
strict=true(XSW + AudienceRestriction + Recipient + Replay enforcement);wantAssertionsSigned=true; same-origin check onRelayStateto prevent open redirect; signed AuthnRequests - Logs: never the password or raw token; truncated identifiers; generic "Invalid credentials" to every credential-based failure mode (anti-enumeration)
Deployment
git pull origin dev
docker compose build backend frontend && docker compose up -d backend frontend
docker compose exec backend alembic upgrade head # 008 → 009
# 1) Generate Fernet key (do this exactly once and back it up):
docker compose exec backend python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Add the output as AUTH_PROVIDER_CRYPTO_KEY=... in .env
# 2) Enable the providers you want
# .env: AUTH_PROVIDERS=local,ldap,oidc
docker compose restart backend
Backwards compatible: defaults are AUTH_PROVIDERS=local, so installations that
don't touch .env continue working unchanged.
Outbound network required per provider
| Provider | Hosts |
|---|---|
| LDAPS | your DC on port 636 (or whatever LDAP_PORT) |
| OIDC | IdP discovery URL, token endpoint, JWKS URI, userinfo |
| SAML | IdP metadata URL (and IdP SSO URL from user browser, not backend) |
Container deps
No system libraries for python3-saml: the xmlsec and lxml wheels bundle
libxml2 and xmlsec1, and the Dockerfile installs requirements with
--only-binary=:all:, so a requirement without a wheel fails the build. The
-dev packages it used to install only added CVE findings to the image
(linux-libc-dev alone was three quarters of a Grype scan). The image runs
apt-get upgrade, because python:*-slim lags behind Debian security updates.
Verification
After deploy, walk through these in order:
# Discovery endpoint should now list enabled providers
curl -s http://<host>/auth/providers | jq
# Local login still works
curl -s -X POST http://<host>/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}'
# Admin: provider status snapshot
TOKEN=$(curl -s -X POST http://<host>/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -s http://<host>/api/v1/auth-config/status -H "Authorization: Bearer $TOKEN" | jq
# Test LDAP bind (after configuring LDAP_* env vars)
curl -s -X POST http://<host>/api/v1/auth-config/ldap/test \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"sample_username":"jdoe"}'
# Test OIDC discovery
curl -s -X POST http://<host>/api/v1/auth-config/oidc/test -H "Authorization: Bearer $TOKEN" | jq
Audit log (/admin/audit-logs) shows LOGIN_LDAP_SUCCESS, LOGIN_SSO_SUCCESS,
JIT_USER_CREATED, EXTERNAL_ROLE_MAPPED, and MFA_* events as users start
logging in via the new paths.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Multiple head revisions are present on alembic upgrade |
Two migrations with the same revision number landed on dev (we hit this when 008-EUVD and 008-auth collided) |
Renumber yours linearly. See git log for the conflict-resolution commit (fix(alembic): rename ...). |
LookupError: '<x>' is not among the defined enum values for authprovider |
SQLAlchemy default maps enums by NAME but the postgres authprovider type was created with lowercase VALUES |
Use SQLEnum(AuthProvider, values_callable=lambda x: [e.value for e in x]) — already applied to users.auth_provider. Same pattern for any future enum where name ≠ value. |
ValueError: malformed bcrypt hash (checksum must be …) on login when username is wrong |
The constant-time dummy verify in LocalAuthStrategy had a fake/placeholder hash |
Already fixed — we generate a real hash once at module import. |
MFA setup returns 500 AUTH_PROVIDER_CRYPTO_KEY env var is not set |
Var added to .env but docker compose restart backend doesn't reload env into the existing container |
Use docker compose up -d --force-recreate backend. Verify with docker compose exec backend printenv AUTH_PROVIDER_CRYPTO_KEY. |
MFA setup returns 404 HTML with x-nextjs-cache: HIT |
Next.js had no proxy route for /auth/mfa/* so it served them as page routes |
Catch-all frontend/app/auth/[...path]/route.ts is now in place. Frontend rebuild required. |
| MFA card not visible in Settings | Frontend container has the pre-MFA build | docker compose build frontend && docker compose up -d frontend. Hard reload (Ctrl+Shift+R). |
| LDAP test bind fails immediately | Bootstrap password loaded once and encrypted in DB; subsequent decrypts fail if you rotated AUTH_PROVIDER_CRYPTO_KEY |
Clear the cached bind: DELETE FROM settings WHERE key='ldap_bind_password_encrypted'; then restart. The next start re-bootstraps from LDAP_BIND_PASSWORD_BOOTSTRAP. |
| No mail after a sync (Wazuh/Nessus/app-scan/Defender) that found new CVEs | Recipient cascade empty and the fallback also empty (finding/asset unassigned, notification_default_recipients unset, and no active admin has an email) OR severity below notification_min_severity OR notifications suppressed on the vuln OR notification_schedule=nightly (per-sync sends are deferred to the nightly roundup) |
Give an admin an email, set notification_default_recipients, assign the asset, lower the threshold, un-suppress via the bell icon, or check the delivery schedule. Verify with docker compose logs backend | grep -iE 'digest|notifications'. (SLA-breach mails have no fallback — they still require an assignee; see Asset / Finding assignment.) |
New-Vulnerability Digest Notifications
Replaces the old per-CVE inline email send during Wazuh sync. Default mode for new installs is digest: one summary mail per recipient at the end of each sync run, with a styled HTML table of every CVE above the severity threshold relevant to them. No SMTP rate-limit problems with large syncs.
How it triggers
Every sync source routes new findings through the same dispatcher
(dispatch_new_vuln_notifications()): Wazuh, Nessus, the App→CVE scanner,
and Defender TVM. (Earlier only Wazuh/Nessus did — app-scan and Defender
findings were silently skipped; fixed.) Each source calls the dispatcher after
it enriches its freshly-created findings.
After a sync completes:
- Backend collects the sync's newly-created vuln ids
- Loads them, applies
notification_min_severityfilter - Resolves recipient per CVE via the cascade
(
vuln.assigned_user→vuln.assigned_group→asset.assigned_user→asset.assigned_groups), else the default-recipients/admins fallback - Groups CVEs by recipient email
- Sends one digest mail per recipient (rows sorted by CPR desc)
- Writes one
NotificationLogper recipient (anchor vuln + total count)
If a sync created 0 new findings, no mails fire (no noise on routine syncs).
Delivery schedule (per-sync vs nightly roundup)
notification_schedule controls when mail goes out:
per_sync(default) — dispatch sends at the end of each sync run.nightly— per-sync sends are suppressed (dispatch_new_vuln_notificationsreturns early whenrespect_schedule=True). A scheduler job (new_vuln_digest_nightly, registered hourly, self-gates onnotification_nightly_hour+ the schedule flag) then callssend_nightly_new_vuln_digest()which aggregates all active findings detected since the last run — windowed via thenotification_nightly_last_runsetting so nothing double-sends or is missed — into ONE mail per recipient. Fewer mails → friendlier to provider anti-spam.
Send rate limiting
The dispatch send-loop paces mail per get_email_rate_limit():
email_rate_delay_seconds (sleep between mails) and email_max_per_run
(hard cap per dispatch; 0 = unlimited). Guards against provider bursting.
Settings
| Setting key | Default | Purpose |
|---|---|---|
notification_mode |
digest |
digest (one mail per recipient) or single (legacy, one mail per CVE) |
notification_schedule |
per_sync |
per_sync (send after each sync) or nightly (suppress per-sync, one roundup) |
notification_nightly_hour |
6 |
Hour (0–23, server time) the nightly roundup fires |
notification_nightly_last_run |
— | Internal — ISO timestamp of the last nightly roundup (window anchor) |
notification_min_severity |
critical |
Skip CVEs below this severity (critical / high / medium / low) |
notification_lifecycle_mode |
exclude |
exclude = pseudo-findings that aren't real CVEs (EOL-*, ANDROID-PATCH-*, NESSUS-PLUGIN-*) never trigger the CVE mails; include = legacy mixed behaviour. Rule is "anything not CVE-*" (is_lifecycle_finding()), so new pseudo prefixes are covered automatically |
notification_default_recipients |
— | Fallback recipients (comma-separated) for new-CVE mails when a finding has no assignee. Empty → all active admins. |
email_rate_delay_seconds |
0 |
Seconds to sleep between mails in a dispatch (anti-spam pacing) |
email_max_per_run |
0 |
Max mails per dispatch run (0 = unlimited) |
smtp_config |
— | Required. Without SMTP no mails go out. |
All editable via Settings → Notifications (no env-var needed).
Mail content
Subject: [TRUEVULN] N new vulnerabilities detected
Body: severity-count badges, then a table sorted by CPR descending with
columns CVE (deep link) · Severity · CVSS · CPR · Host · #Systems · Package,
and an "Open in dashboard" button. #Systems = distinct assets affected by that
CVE across the whole inventory (_affected_counts()). Default template lives in
email_service.py:DEFAULT_DIGEST_TEMPLATE, editable in Settings →
Notifications → New Vulnerability Digest Template (the email_template_new_vuln_digest
setting). The email_template_new_vuln (single-mode) template is separate and
only used when notification_mode='single' — the UI badges show which is active.
Digest top-level variables: {{total}}, {{affected_assets_count}}
(distinct systems across the digest), {{rows}} (the pre-rendered table,
CPR/#Systems/links baked in), {{count_critical|high|medium|low}},
{{recipient_name}}, {{detected_at}}, {{dashboard_url}}.
Single-mode variables (per CVE): {{cve_id}}, {{severity_upper}},
{{cvss_score}}, {{cpr_score}}, {{affected_assets_count}},
{{asset_hostname}}, {{package_name}}, {{description}}, {{detected_at}},
{{dashboard_url}}, and ready-made deep links {{cve_link}}, {{asset_link}},
{{cve_on_asset_link}}.
Fresh-install behaviour
Out-of-box flow for a clean deployment:
- SMTP configured in Settings → Email
- A recipient exists: either the asset/finding is assigned to a user/group
with a populated
users.email, or a fallback applies (see Asset / Finding assignment below) — an active admin email, or the configurednotification_default_recipients notification_min_severityset (defaultcriticalworks for most)- First Wazuh sync runs (manual or scheduled)
- New CVEs found → digest mail goes out automatically, no extra config
Zero env-var setup needed for the notification path. notification_mode row
is created on first save (or stays absent and defaults to digest).
Verification
docker compose logs --tail=200 backend | grep -iE "Created:|new vuln|notifications:|digest"
Expected on a sync that found new CVEs assigned to people:
Sync: New vulnerability CVE-2026-XXXX on host ... severity=critical
Digest email sent to alice@example.com: 12 CVEs
Wazuh sync notifications: 3 sent, 0 failed, 3 recipients
/notifications UI shows the resulting log row(s) per recipient.
Asset / Finding assignment — what it actually does
Assignment (a user or group, set on an asset or on an individual finding) is purely a responsibility / notification tag. It does not gate scanning, scoring, the SLA calculation itself, report contents, or visibility — RBAC is role-based, so anyone with view rights sees every CVE regardless of who it's assigned to.
Recipient cascade (both mail paths, first match wins):
finding.assigned_user → finding.assigned_group → asset.assigned_user
→ asset.assigned_groups.
What assignment controls:
| Effect | Behaviour |
|---|---|
New-CVE digest (email_service._resolve_recipients_for_vuln) |
Goes to the assignee via the cascade. Fallback when nothing is assigned: notification_default_recipients, or — if that's empty — every active admin. So "the admin gets everything" works without any assignment. |
SLA-breach digest (scheduler.check_sla_breaches) |
Goes to the assignee via the cascade only — no fallback. An unassigned SLA breach sends no mail to anyone. |
| "Assigned To" column (Assets & Vulnerabilities pages) | Display + sort only. |
| Cleanup | Deleting a user/group nulls their assignments automatically. |
Runbook recommendation: assign each asset to a system owner (a user or group with a populated email). It's the only way to (a) route new-CVE mails to the real owner instead of blanket-to-admins, and (b) make SLA-breach mails reach anyone at all — the admin fallback does not apply to SLA breaches. For a catch-all on new-CVE mails without per-asset ownership, set Settings → Notifications → Default Recipient(s) instead.
Nessus Integration (multi-scanner unified view)
Wazuh has structural detection gaps (Adobe Acrobat/Reader, MS Office /
M365, .NET 5–10, EOL/EOS software, network appliances). Nessus closes
those gaps. This branch imports Tenable Nessus scan findings via the
REST API and merges them onto the existing (cve_id, asset_id) rows so
the same UI shows the union, deduplicated. Cross-scanner confirmation
becomes a confidence signal.
Architecture
┌──────────────────────────┐
│ POST /sync/wazuh │ Wazuh agent loop
└──────────┬───────────────┘
│ ┌─────────────────────────────┐
├──►│ (cve_id, asset_id) merge │ ◄── one row, sources=[…]
│ │ - add_source('wazuh') │
│ │ - severity = max(existing, │
│ │ nessus, wazuh) │
│ └─────────────────────────────┘
┌──────────┴───────────────┐
│ POST /sync/nessus │ Nessus scan iteration
└──────────────────────────┘
A single Vulnerability row carries sources = ["wazuh","nessus"] when
both scanners reported the same CVE on the same asset.
cross_confirmed becomes True. Non-CVE Nessus findings (EOL,
Compliance, Cipher) are stored under a pseudo-CVE id
NESSUS-PLUGIN-{plugin_id} and rendered with a NON-CVE badge so they
remain visible in the existing Vulns list without polluting CVE-based
reports.
Schema delta (Alembic 010)
| Column | Where | Purpose |
|---|---|---|
vulnerabilities.sources |
TEXT (JSON list) | scanners that detected this finding |
vulnerabilities.nessus_plugin_id |
string indexed | Nessus plugin reference |
vulnerabilities.nessus_finding_uuid |
string | per-host-per-plugin stable id |
vulnerabilities.first_detected_by |
string | which scanner first reported (wazuh/nessus/manual) |
vulnerabilities.cve_id |
widened 20 → 50 | room for NESSUS-PLUGIN-* pseudo-IDs |
vulnerabilities.nessus_vpr_score |
float indexed (mig. 011) | Tenable VPR 0–10 alongside our own priority |
vulnerabilities.exploitation_status |
string indexed (mig. 012) | SSVC: `none |
assets.nessus_host_uuid |
string indexed | pin Nessus host after first match |
scan_schedules.scanner_type |
string default 'wazuh' | route scheduled syncs to wazuh or nessus |
Migration 010 backfills sources='["wazuh"]' and
first_detected_by='wazuh' on every existing vulnerability — no data
loss, zero behaviour change until Nessus is configured. Migrations 011
- 012 are idempotent (
ADD COLUMN IF NOT EXISTS).
Asset matching
Per Nessus host, the sync tries in order:
assets.nessus_host_uuid(pinned after first successful match)assets.hostname(case-insensitive)assets.ip_address(exact)- If still no match AND
nessus_config.auto_create_assetsis true → create a new asset withsource=manual. Default is off (safer).
Unmatched hosts are returned in the sync response under
unmatched_hosts so admins can spot drift between Nessus targets and
the TrueVuln inventory.
Merge logic per (cve_id, asset_id)
existing = find(cve, asset)
if existing:
existing.add_source("nessus")
# Nessus wins when it provides a value (typical case: Wazuh dropped
# a placeholder 10.0 on the row, Nessus has the real per-plugin score).
if nessus_cvss is not None:
existing.cvss_score = nessus_cvss
if nessus_severity and nessus_severity != "none":
existing.severity = nessus_severity
else:
existing.severity = max(existing.severity, nessus_severity)
existing.nessus_plugin_id = plugin_id
existing.nessus_vpr_score = vpr_score # always latest
if existing.status == patched:
existing.status = open # Nessus sees it again → reopen
else:
create new with sources=["nessus"], first_detected_by="nessus"
Override safety: if Nessus has no CVSS for a CVE (None), the existing
Wazuh value is preserved — no data wipe. Same for severity = none.
When a previously-Nessus-flagged CVE is missing from this scan run, we
only drop "nessus" from the sources list. If the list empties
(no scanner sees it anymore), status flips to patched. Wazuh findings
are never removed by a Nessus sync.
Manual overrides — 3-stage CVSS correction cascade
app/services/vuln_override_service.py corrects CVSS/severity/SSVC
data from authoritative feeds in a three-stage cascade. Each stage
only fetches CVEs the previous stage missed, so the cost stays
bounded even for full-DB corrections.
┌─ Stage 1 — CISA Vulnrichment ──────────────────────────────────┐
│ github.com/cisagov/vulnrichment │
│ • ZIP snapshot when > 25 CVEs (~250 MB, no cache, ~2 min) │
│ • per-CVE raw fetch otherwise │
│ • parses BOTH containers.cna AND containers.adp metrics │
│ (Microsoft, Mozilla etc. supply CVSS in cna; CISA adds │
│ SSVC decision points in adp — both contribute) │
└─────────────────────────────────────────────────────────────────┘
│ CVEs Vulnrichment doesn't have
▼
┌─ Stage 2 — NVD REST API ───────────────────────────────────────┐
│ services.nvd.nist.gov/rest/json/cves/2.0?cveId=... │
│ • bounded to ≤ 100 missing CVEs per run (rate-limit safe) │
│ • throttled 1 req/sec │
│ • picks cvssMetricV31 → V30 first hit │
└─────────────────────────────────────────────────────────────────┘
│ still missing after NVD
▼
┌─ Stage 3 — cvelistV5 (MITRE/CVE.org) ──────────────────────────┐
│ github.com/CVEProject/cvelistV5 │
│ • 557 MB ZIP, disk-cached 12h at │
│ /tmp/truevuln-cvelistv5-cache.zip — one downloader for the │
│ whole app (cvelistv5_scan_service._ensure_zip: lock, │
│ ZIP check, 15 min backoff after a failed download) │
│ • exhaustive — every published CVE (~250k+) │
│ • same CVE-5 JSON shape as Vulnrichment, parser reused │
└─────────────────────────────────────────────────────────────────┘
NVD and GHSA (stage 4) remember for 6h a CVE they answered without a score (404 / empty / no CVSS), so per-device checks don't re-ask them for fresh CVEs nobody has scored yet; rate limits and errors are retried.
Verified records carry source ∈ {vulnrichment, nvd, cvelistv5} so
the operator can tell where each correction came from. All three are
treated as authoritative — Nessus sync respects an
exploitation_source of any of the three and won't clobber the
corrected score with its plugin-bundle CVSS on the next sync.
Endpoints
POST /api/v1/vulnerabilities/override/check— dry-run, lists rows that would changePOST /api/v1/vulnerabilities/override/nessus/{asset_id:int}— pull from Nessus plugin output for one asset (per-CVE re-parse)POST /api/v1/vulnerabilities/override/vulnrichment?dry_run=…— trigger the cascade above; used by the "Correct CVSS" buttonPOST /api/v1/vulnerabilities/override/vulnrichment/start— async variant (background job + progress card in the UI)
Detection rules (per row):
- Wazuh placeholder score (
cvss_score == 10.0), OR - discrepancy of ≥ 1.0 between current value and verified value
When triggered, the cascade also writes
vulnerabilities.exploitation_status (SSVC none|poc|active|widespread),
ssvc_technical_impact (partial|total) and ssvc_automatable
(yes|no) when Vulnrichment supplied them.
Endpoints
| Method | Path | Role | Purpose |
|---|---|---|---|
GET |
/api/v1/settings/nessus_config |
admin | Current Nessus configuration |
PUT |
/api/v1/settings/nessus_config |
admin | Save Nessus configuration |
POST |
/api/v1/vulnerabilities/nessus/test |
admin | Probe API keys, return server version + scan count |
GET |
/api/v1/vulnerabilities/nessus/scans |
editor | List scans visible to the configured keys |
POST |
/api/v1/vulnerabilities/nessus/sync |
editor | Trigger sync. Body {"scan_ids":[12,17]} or {} for defaults |
PATCH |
/api/v1/vulnerabilities/{id}/false-positive |
editor | Mark FP with optional reason; sets notification_suppressed=true |
PATCH |
/api/v1/vulnerabilities/{id}/unmark-false-positive |
editor | Revert to OPEN |
New query parameters on GET /api/v1/vulnerabilities:
?source=wazuh|nessus|manual and ?cross_confirmed=true.
Configuration
Stored at settings.nessus_config as JSON. UI editor in
Settings → Integrations → Tenable Nessus.
{
"base_url": "https://nessus.local:8834",
"access_key": "<64-char>",
"secret_key": "<64-char>",
"verify_ssl": true,
"default_scan_ids": [12, 17],
"auto_create_assets": false
}
Generate keys in the Nessus UI → My Account → API Keys → Generate.
Auth header is X-ApiKeys: accessKey=…; secretKey=… — no
login/logout round-trip.
Scheduled syncs
ScanSchedule.scanner_type decides the route:
wazuh(default for legacy schedules) → existing Wazuh-agent loopnessus→run_nessus_sync()usingnessus_config.default_scan_ids
The Settings → Scan Schedules UI should expose this when you create a
schedule (UI hook is on the backlog — for now, schedules created via
API can set scanner_type='nessus' directly).
UI
Vulns list (/vulnerabilities):
- Source column with per-scanner badges (Wazuh green, Nessus
purple, manual gray).
✓×2emerald badge when both scanners reported. AmberNON-CVEbadge for pseudo-CVEs. - Source filter (all / wazuh / nessus / manual).
- Cross-confirmed checkbox: only multi-scanner findings.
- New NoSymbolIcon in Actions: mark / unmark false positive.
Marking sets
status=false_positiveand suppresses notifications.
Settings → Integrations gains a Tenable Nessus row with:
- Active / Config-Required badge
- Sync now button (when configured) running an immediate import
- Configure opens the modal with Base URL, Access Key, Secret Key, Default Scan IDs (comma-separated), Verify-SSL toggle, Auto-create Assets toggle, and a Test connection button.
Verification
# 1. Migrate
git pull origin dev
docker compose build backend frontend && docker compose up -d backend frontend
docker compose exec backend alembic upgrade head # 009 → 012
docker compose exec backend alembic current # 012 (head)
# 2. Backfill check
docker compose exec postgres psql -U vulnmanager -d vulnmanager -c "
SELECT first_detected_by, COUNT(*) FROM vulnerabilities GROUP BY first_detected_by;"
# wazuh = <existing>, NULL = 0, nessus = 0 (until first sync)
# 3. Set nessus_config via the Settings UI, then Test connection.
# 4. First sync
TOKEN=$(curl -sk -X POST http://<host>/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -X POST http://<host>/api/v1/vulnerabilities/nessus/sync \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
# returns {scans_processed, hosts_synced, vulns_created, vulns_merged,
# scores_overridden, vulns_marked_patched, unmatched_hosts[],
# notifications:{…}}
# 5. Cross-confirmation listing (CVEs in both scanners)
curl -s "http://<host>/api/v1/vulnerabilities?cross_confirmed=true&limit=5" \
-H "Authorization: Bearer $TOKEN" \
| jq '.items[] | {cve_id, sources, first_detected_by, priority_score}'
# 6. False-positive flow
curl -X PATCH http://<host>/api/v1/vulnerabilities/123/false-positive \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason":"Vendor confirmed N/A in our config"}'
UI smoke test:
- Source column shows correct badges per row
- Filter "Nessus only" shows only
sources=["nessus"]entries - Filter "Cross-confirmed" shows only multi-scanner entries
- "Sync now" button surfaces stats inline on the Settings card
- Click NoSymbolIcon → prompt → vuln moves to FALSE_POSITIVE status
Nessus — out of scope for v1
- Triggering Nessus scans from TrueVuln (we only import existing scans)
- Two-way sync of false-positive status back to Nessus
- Network discovery / SNMP-based asset auto-creation beyond the hostname/IP-match step
- Scheduled-sync configuration UI for
scanner_type='nessus'(backend is ready; UI hook still pending)
Compliance + Unified Risk Score (URS)
End-to-end compliance scoring built on Wazuh's Security Configuration Assessment (SCA) and the colleague's impact-weighted approach. Two layers:
- SCA refresh pulls
/sca/{agent_id}from Wazuh per asset and stores per-policy pass / fail / N/A counts. - URS scoring combines those counts (Asset Security Score, ASS) with the asset's CPR scores (Asset Vulnerability Score, AVS) and the asset's criticality multiplier into one operator-facing 0-100 figure with severity bands.
Architecture
Wazuh /sca/{agent_id} CIS-Benchmark CSV upload
│ │
▼ ▼
┌──────────────────────┐ ┌───────────────────────────┐
│ compliance_results │ │ compliance_impacts │
│ + compliance_checks │ ◄────── │ (cis_id, benchmark, 0-100)│
└──────────┬───────────┘ └───────────────────────────┘
│ weighted_score = Σ(impact × passed) / Σ(impact)
▼
ASS (avg of weighted_score across policies, 0-100)
│
│ ┌─────────────────────────────┐
│ │ Vulnerability.cpr_score │
│ │ (CVSS%×0.6 + EPSS%×0.4) │
│ └─────────┬───────────────────┘
│ ▼
│ AVS (hybrid: 0.7×avg + 0.3×max CPR)
▼ ▼
└───── URS = ((AVS+ASS)/2) × criticality_factor ──► snapshot
(daily)
Schema delta (Alembic 013–017)
| Migration | Adds |
|---|---|
| 013 | vulnerabilities.ssvc_technical_impact + ssvc_automatable |
| 014 | scans.scan_type enum value nessus (with 015 fix for uppercase) |
| 015 | scantype enum uppercase NESSUS (SQLAlchemy convention) |
| 016 | compliance_results + compliance_checks tables |
| 017 | compliance_impacts + asset_risk_snapshots + assets.criticality + assets.compliance_frameworks + compliance_results.weighted_score |
compliance_impacts is the per-(cis_id, benchmark) weight loaded
via CSV upload. Unique constraint on (cis_id, benchmark) so repeat
uploads upsert cleanly.
asset_risk_snapshots stores one (AVS, ASS, URS, severity) row per
asset per day. Drives the 7-day trend arrow on the dashboard +
asset detail. Pruned > 90 days by the nightly job.
URS formula
AVS_hybrid = 0.7 × avg(CPR open vulns) + 0.3 × max(CPR open vulns)
— alternative modes: 'avg' (mean only), 'max' (highest only)
weighted_score(policy) = 100 × Σ(impact × passed) / Σ(impact)
— falls back to plain pass% when no impacts loaded
ASS = mean(weighted_score for each policy of the asset)
criticality_factor:
low 0.7
normal 1.0 (default)
high 1.3
critical 1.5
URS = min(100, round((AVS + ASS) / 2 × criticality_factor, 1))
Severity bands (spec):
| URS | Severity | Color |
|---|---|---|
| 90–100 | CRITICAL | red filled |
| 70–89 | HIGH | red-100 |
| 40–69 | MEDIUM | orange-100 |
| 1–39 | LOW | emerald-100 |
| 0 | NONE | grey |
When only one of AVS/ASS is available (e.g. no SCA loaded yet) the URS uses the single value directly without averaging the missing half. The criticality multiplier still applies.
Endpoints
| Method | Path | Role | Purpose |
|---|---|---|---|
GET |
/api/v1/compliance/summary |
any | global widget feed (avg score, pass/fail totals, worst 5) |
GET |
/api/v1/compliance/assets |
any | per-asset roll-up list |
GET |
/api/v1/compliance/{asset_id:int} |
any | policies for one asset |
GET |
/api/v1/compliance/{asset_id:int}/{policy_id}/checks |
any | deep-dive per-check rows (cached + ?refresh=true re-pull) |
POST |
/api/v1/compliance/{asset_id:int}/refresh |
editor | SCA refresh one asset |
POST |
/api/v1/compliance/refresh |
editor | SCA refresh every Wazuh-linked asset |
POST |
/api/v1/compliance/impacts/import |
admin | multipart CSV upload — CIS impact ratings |
GET |
/api/v1/compliance/impacts/stats |
any | per-benchmark count + avg impact |
GET |
/api/v1/compliance/urs/{asset_id:int}?avs_mode=hybrid |
any | compute + snapshot URS for one asset |
GET |
/api/v1/compliance/urs?avs_mode=hybrid&limit=200 |
any | URS for every asset, sorted highest-risk first |
POST |
/api/v1/compliance/urs/recompute-all?avs_mode=hybrid |
editor | force full recompute + snapshot |
Path-parameter routes use the Starlette :int converter so URLs
like /compliance/urs and /compliance/impacts/stats do not
collide with /compliance/{asset_id} — a real bug that produced
422 unable to parse 'urs' as integer until 336e817 fixed it.
avs_mode parameter accepted on all URS endpoints (hybrid | avg
| max). Pydantic-v2 pattern= constraint, not legacy regex=.
Impact CSV format
The importer is forgiving: any header that case-insensitively matches
one of the candidates below works. Filename without extension becomes
the benchmark label unless the CSV carries an explicit benchmark
column.
| Field | Header candidates |
|---|---|
| cis_id | cis_id, recommendation #, recommendation, section, subsection, id, control, ref, # |
| title | title, description, name, recommendation_title |
| impact | impact, score, weight, risk_score, risk, severity |
| level | level, profile, tier |
| benchmark | benchmark, policy, os, policy_name (else filename) |
Impact values are auto-rescaled:
- 0–100 raw int → kept
- 0–10 (CIS Workbench risk score) → ×10
- percent strings ('45%') → stripped
- garbage / missing → 50 (neutral)
If the importer reports imported: 0, the UI alert now shows a
diagnose block with detected headers + sample skipped rows so the
operator can see which column is missing.
Asset criticality + frameworks
Asset edit modal gains a Criticality dropdown (low / normal /
high / critical) — feeds the URS multiplier. Stored in
assets.criticality VARCHAR(16) NOT NULL DEFAULT 'normal'.
assets.compliance_frameworks is a JSON array (text column) of
mandatory framework slugs like ["iso_27001","nis2","pci_dss"]. Not
surfaced in the UI yet — earmarked for per-framework URS breakdown
in a follow-up.
Scheduled jobs
| Cron | Job id | What |
|---|---|---|
| 02:00 | compliance_sca_nightly |
Wazuh SCA refresh for every linked asset |
| 04:30 | vulnrichment_nightly |
CISA Vulnrichment ZIP snapshot CVSS/SSVC correction |
| 05:10 | urs_nightly |
URS recompute + 90-day snapshot prune |
| every 24 h | threat_intel_refresh |
EPSS + KEV refresh (existing) |
Ordering matters: SCA + Vulnrichment must land before URS so AVS/ASS inputs reflect today's data.
UI
- Sidebar new entry
Compliancebetween Assets and Scan Jobs /compliancepage:- 4 header cards (avg score, assets covered, pass total, fail total)
- Worst offenders list (top 5 lowest avg score, clickable)
- URS table with AVS / ASS / URS / severity per asset, sortable,
AVS mode picker (hybrid / avg / max),
↻ recomputebutton - Impact CSV upload card with per-benchmark stats after import
- All Assets table with per-asset modal (per-policy score bars,
↻per-asset refresh)
- Dashboard gains a
Unified Risk Score (URS)widget above the existing Compliance widget — avg URS, severity-spread badges, top-5 highest-risk assets. Hidden when no URS data yet. - Asset edit modal gains the
Asset Criticalitydropdown with multiplier labels (×0.7 .. ×1.5) for transparency.
Verification
# 1. Migrate
git pull origin dev
docker compose build backend frontend && docker compose up -d
docker compose exec backend alembic upgrade head # 015 → 017
docker compose exec backend alembic current # 017 (head)
# 2. SCA refresh — pull what Wazuh already evaluated
TOKEN=$(curl -sk -X POST http://localhost:8000/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -X POST "http://localhost:8000/api/v1/compliance/refresh" \
-H "Authorization: Bearer $TOKEN"
# expected: {"assets_synced":N,"policies_synced":M,"errors":[]}
# 3. Upload CIS impact CSVs (multi-file ok)
curl -X POST "http://localhost:8000/api/v1/compliance/impacts/import" \
-H "Authorization: Bearer $TOKEN" \
-F "files=@windows11_level1_cis.csv" \
-F "files=@windows_server_2025_level1_member_server.csv"
# expected: {"imported":N,"files":2,"skipped_no_id":0,"skipped_not_cis":0,...}
# 4. Compute URS for all assets
curl -X POST "http://localhost:8000/api/v1/compliance/urs/recompute-all" \
-H "Authorization: Bearer $TOKEN"
# 5. Read URS distribution
curl -s "http://localhost:8000/api/v1/compliance/urs?limit=10" \
-H "Authorization: Bearer $TOKEN" \
| jq '.[] | {hostname, urs, severity, criticality}'
# 6. Quick DB sanity
docker compose exec postgres psql -U vulnmanager -d vulnmanager -c "
SELECT severity, COUNT(*) FROM asset_risk_snapshots
WHERE snapshot_date >= now() - interval '1 day'
GROUP BY severity ORDER BY severity;"
Compliance / URS — out of scope for now
- Per-framework URS (URS_CIS, URS_ISO27001, URS_NIS2 ...) — schema
ready (
assets.compliance_frameworks), UI breakdown still pending - Concentration penalty (≥3 high-impact failures = +10 URS)
- Risk Acceptance per individual SCA check (parallel to vuln FP flow)
- Wazuh check
complianceblock parsing for cross-framework mapping (cis_csc_v8, iso_27001, pci_dss, nist_800_53 etc.) — Wazuh emits it; we don't persist the mapping yet - Time-decay weight when
last_synced > 7 days(data confidence)
App→CVE Detection Engine & Mobile Device Security
Closes the coverage gap where a device has no real vulnerability scanner (Intune-only endpoints, mobile devices) or where the scanner's own CPE matching is too loose (false positives) or too narrow (false negatives).
Built-in App CVE Scanner
Maps installed software (Wazuh syscollector packages, Intune detectedApps)
to real CVEs via two independent, complementary sources:
| Source | Module | Strength |
|---|---|---|
| OSV.dev | app_cve_scanner_service.py |
Precise server-side version matching for language ecosystems (npm, PyPI, ...) |
| NVD-CPE | app_cve_scanner_service.py |
Broad desktop-app coverage via curated CPE registry + own version-range check (cpeMatch start/end incl/excl) |
| cvelistV5 range match | cvelistv5_scan_service.py |
Catches CVEs NVD hasn't CPE'd yet, or filed under a CPE product string we didn't curate (e.g. a TeamViewer CVE under teamviewer:remote, not teamviewer:teamviewer) — matches directly against the CNA's own affected[].vendor/product + version ranges |
Both registries are curated (name-regex → vendor/product), not fuzzy —
unknown software is skipped rather than guessed, to keep false-positives
near zero. Findings upsert as source='app-scan' with real CVE ids, so the
normal EPSS/KEV/CVSS enrichment and multi-source cross-confirm apply.
The cvelistV5 path needs a reverse index ({vendor,product} → CVE ranges) built by walking the ~557 MB cvelistV5 ZIP once; cached in a
Setting, rebuilt at 01:30 by vuln_index_refresh_nightly
(app_cve_scanner_service.refresh_indexes). The "App CVE Scan" button under
Data jobs runs that same refresh first (/app-cve-scan/start?rebuild_index=true:
fresh ZIP, vendor indexes, MSRC), so today's CVEs are found without waiting
for the night or a shell on the host. Every other scan (single asset, API)
reads the stored index and builds it only if missing.
Intune inventory opt-out (app_scan_intune)
Intune devices reach the app-CVE scan three ways: the Intune sync's own pass
over detectedApps (gated by detected_apps), the Defender sync feeding its
software inventory into the same pipeline, and this scan fetching
detectedApps for every asset whose inventory is Intune's. They upsert the
same (asset, CVE) row, so there is no duplicate finding, only repeated work.
The Intune setting "App-CVE scan on Intune/Defender inventory"
(app_scan_intune, default on) turns all three off at once, for an operator who
wants only Defender TVM CVEs on those devices. EOL and M365 detection keep
running (Defender reports neither), and so do the OS passes (iOS). A host with
a live Wazuh agent is still scanned from syscollector. Every detection needs a
way to close, so each run retracts the app-scan source from the package
findings on an ACTIVE Intune-inventory asset. A finding closes only when no
other source (e.g. defender) is left, and the audit reason names the setting.
A single-asset scan (asset_id, the "App scan" action in the Assets view)
also runs the M365 Apps detection (m365_service.run_m365_for_packages) on the
inventory it just read, so a host that updated Office has its
microsoft365-apps findings closed on the spot. M365 findings carry their own
source, and only the M365 detection retracts it. The full scan skips that
pass, because the nightly chain runs the M365 step right before it. The pass
also skips a partial inventory (Intune fallback for a Wazuh host). Other places
the M365 detection runs: the 03:00 chain (m365_check_nightly, Wazuh-linked
assets), the "M365 CVE" Data job (/m365-check, Wazuh-linked assets), and
every device the Intune sync or the Defender software inventory reads
(_run_app_inventory).
Visio and Project in the M365 detection. The 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 subscription apps (ODT product ids
VisioProRetail, ProjectProRetail) are separate Click-to-Run products. They
are on a host only when selected, and then appear as their own inventory
entries ("Microsoft Visio - de-de"). Microsoft requires them on the suite's
version and update channel ("Supported scenarios for installing different
versions of Office, Project, and Visio"). So _parse_security_page records
which CVEs are listed only under Visio or only under Project (app_cves), and
missing_for_packages gives such a CVE only to a host with that app's entry,
measured against that entry's build and the suite's channel. A CVE also listed
under Word or "Office suite" is shared code and stays with the suite. A host
with only Visio/Project gets only their CVEs. MSI Visio 2016 and Visio/Project
LTSC (other channels) are not matched.
Office 2021 / 2024 in the M365 detection. The same page has a build line
per perpetual edition: "Office 2024 Retail", "Office LTSC 2024 Volume
Licensed", and the same for 2021. office_channel maps a suite entry
("Microsoft Office LTSC Professional Plus 2024", "Microsoft Office Home &
Business 2021") to its line: "LTSC" in the name means Volume Licensed, else
Retail. The two lines never share a major build (LTSC stays on 14334 / 17932,
Retail moves monthly), so a build whose major is not on its line is skipped
(_on_line) — an LTSC build read against Retail would be behind on every
section. Office 2019 is not checked: out of support since 2025-10-14 (its
lines on the page are history), and Retail and Volume share one name
("Microsoft Office Professional Plus 2019"), so the line cannot be chosen.
Visio / Project subscription entries keep following a Microsoft 365 Apps
suite's channel only. EOL for Office 2019 / 2021 / 2024 comes
from endoflife.date office (year from the name; LTSC 2024 → 2024-ltsc).
False-positive suppression (same cvelistV5 data, inverse direction):
Wazuh's own CPE matching sometimes over-reports across product editions
(e.g. flags a SQL Server 2019 host with a CVE that only affects 2022/2025).
cvelistv5_scan_service.suppress_false_positives marks a Wazuh finding
false_positive only when the installed version is provably outside
every clean cvelistV5 range for the matched product — conservative by
design (Wazuh-sourced only, ≥2 shared significant tokens required to scope
a product, any unbounded/ambiguous range aborts the check).
Endpoints: POST /api/v1/vulnerabilities/app-cve-scan,
POST /api/v1/vulnerabilities/suppress-false-positives (both scoped to
asset_id optionally). Nightly job runs the scan, rebuilds the cvelistV5
index, then runs suppression.
Browser extensions (Wazuh IT Hygiene, Wazuh 4.14+): _EXTENSION_REGISTRY,
keyed by store id, one key per browser. An entry with a cpe is looked up in
NVD, target_sw read as the browser. An entry may also carry advisories: rows
read off the vendor's advisory (start inclusive, fix exclusive; end
instead of fix for a line whose fix is on a later line). A CVE a row holds is
decided by the row alone; NVD adds only the others. Current rows:
CVE-2026-84388, Fortinet Privileged Access Agent for Chrome, 8.0.0 ≤ v <
8.0.1.123 and 7.4.0 ≤ v < 7.5, fix 8.0.1.123 (FG-IR-26-168: "8.0 all versions",
"7.4 all versions"). The entry also carries the CNA's cpe
(fortinet:fortipam_chrome_extension); NVD had no configuration for it as of
2026-09-28. cvelistV5 is not read: its structured data says only "8.0.1
affected", which cannot separate 8.0.1.100 from the fixed 8.0.1.123 and
misses 8.0.0 and 7.4. Keep the rows even after NVD analyses the CVE, unless
NVD's range is build-precise. Findings close through the normal app-scan
reconcile when the extension is updated or removed.
LibreOffice (libreoffice:libreoffice on the NVD path, key libreoffice
on cvelistV5). The Document Foundation states one range per series and puts
the operator inside the bound: version "26.2", lessThan "< 26.2.5". The
compare reads only the digits, but the string became the finding's fixed
version, so _clean_bound strips a < inside a lessThan. It leaves <=
alone: that is HPE's Aruba shape, which scan_asset_aruba skips while its
prose version field is unparsed. The installed four-part build (26.2.4.2)
compares against the three-part bound as it is. NVD had no configuration for
the 26.2.5 batch (CVE-2026-63266 ff.) as of 2026-10-06, so cvelistV5 carries
it until NVD catches up. The records bound the 26.2 series only; 25.8 and
older get no finding from them. The Help Pack, Language Pack and SDK are not
the suite and are not matched.
Adobe Media Encoder (adobe:media_encoder on the NVD path, key
adobe-media-encoder on cvelistV5). Adobe states each supported line as
version "0", lessThanOrEqual "26.2.2" (and "25.6.5" for the 2025 line),
with the patched build only as an unaffected entry (26.3, 25.6.6). Read
literally, the 26 range covers a 25.6.6 that is already patched. The row sets
major_floor, which gives an open-floored inclusive range its bound's major as the floor,
so each range stays on its own line. Older lines get no finding from
cvelistV5; NVD (<= 25.6.5, 26.0 .. <= 26.2.2 for CVE-2026-48370) still
covers them. The finding's fix hint is >26.2.2, see _fix_target.
Adobe Illustrator / Photoshop yearly lines. Since 2026 Adobe files these
under one product per yearly line ("Illustrator Desktop 2026", "Illustrator
Desktop 2025", "Photoshop 2026", "Photoshop 2025"), each stated as
0 .. <= last broken build like Media Encoder. The names match through
_PRODUCT_PATTERNS (20\d\d, so next year's line needs no edit), and both
rows set major_floor. Without it a patched Illustrator 29.8.10 sat below the
2026 bound 30.6. Older one-line records ("Photoshop Desktop", 0 .. <= 26.1)
are floored the same way, which is how NVD reads them too (25.0 .. < 25.12.1,
26.0 .. < 26.2): the earlier major is NVD's to judge. InDesign and Bridge write
the same two-line shape and do not set the flag yet.
Nextcloud Desktop (nextcloud:desktop on the NVD path, repo
nextcloud/security-advisories on the GitHub repo-advisory path). Windows and
macOS list the sync client as plain "Nextcloud"; both rows are anchored, so
Talk, Files and the Office connectors (own version lines) are not matched. The
MSI version's fourth part is the build date (3.15.3.20250107); ver_parts=3
drops it, otherwise an exact-version CVE (CVE-2023-22472, 3.6.1 only) never
matched. The GitHub repo carries advisories for the server, its apps and the
mobile clients as well, so _PACKAGES keeps only the Desktop package blocks:
the Android client's 3.x line overlaps the desktop's. No cvelistV5 row: those
records name the product "security-advisories" for every Nextcloud product, so
the pair cannot tell the desktop from the server or Android. Distro packages
(nextcloud-desktop 3.7.3-1) stay Wazuh's, as everywhere. Known gap: a few
old advisories are single-OS (CVE-2024-37885 macOS, CVE-2023-22472 Windows,
CVE-2024-46958 Linux) and neither path filters on that — the GHSA feed has no
platform field, and the NVD path does not read running-on (AND) nodes for any
product. Only clients below 3.13.4 are affected.
NanaZip / NanaZip Preview (repo M2Team/NanaZip on the GitHub
repo-advisory path only). Both are MSIX; Wazuh lists "NanaZip" and "NanaZip
Preview", the identity names 40174MouriNaruto.NanaZip[Preview] match too.
The version alone says the line: every fix names a build on a major.minor
line (6.0 stable, 6.5 preview until 6.5.1749 made it stable, 7.0 after it),
and within a line the build part only grows, preview or not. The name adds
nothing. The maintainer states the fix per
line in prose ("Stable: >= 6.0.1698.0 / Preview: >= 6.5.1742.0", "6.0 Update 1
(6.0.1638.0) or later / 6.5 Preview (6.5.1638.0) or later"); _norm_ops keeps
the bracketed build, turns "or later" into >=, and patched_floor takes the
fix on the install's own line, which is also the fixed version shown. No NVD or
cvelistV5 row: every NanaZip CVE there is also in this feed, NVD has the stable
bound only (no configuration at all from CVE-2026-54616 on), and cvelistV5
drops the Preview line on 11 records. The four "< 6.5.1749.0" records
(CVE-2026-55780..55783) land on 6.0 hosts too: the 6.0 line ended at 6.0.1742
without a fix.
Microsoft Teams, classic 1.x (key teams on cvelistV5, microsoft:teams
on NVD). Names: "Microsoft Teams" (and "... classic"), the AppX name
"MSTeams" Intune reports (new Teams, and classic at 1.x) and a bare "Teams". The Machine-Wide Installer stays out: it stages
the client into each profile and keeps its own version while the client
updates itself (its EOL flag is separate, _teams_classic). Classic, new
(25122.1415.3698.6812), Android (1.0.0.2025102802) and iOS (7.10.1) Teams share
one product, so only bounds in the install's own build shape are compared, on
both paths. Upper bounds only: Microsoft writes the desktop floor as "1.0.0",
and checking it refused every classic range (CVE-2023-29328/-29330, fixed
1.6.00.18681). On NVD the check drops CVE-2023-24881 ("< 2.10.1", Microsoft's
record says 2.0.0 .. < 2.10.1) from classic 1.x. The NVD fix is kept per
target_sw and picked per host: the host's own platform, else a platform-neutral
entry, else the first listed. Windows hosts were told the macOS build
(1.6.00.17554, listed first) for CVE-2023-29328. On a Mac the cvelistV5 key
is skipped: "Teams for Mac" is not paired, so it held only the desktop's
ranges; NVD's macOS entries cover the Mac.
Version 1.0.0.0 (also 1.0, 1, 0.0.0.0) is a placeholder, not a build:
Intune's "MSTeams 1.0.0.0" and Wazuh's WindowsApps\MSTeams_1.0.0.0_… are the
new client's MSIX package with no build read. No desktop Teams shipped as
1.0.0.0, and classic was never MSIX. Read as a build it sat below every
classic and Android bound (CVE-2023-29328/-29330/-4863, CVE-2022-21965) and
drew the classic EOL. Both CVE paths and _teams_classic skip it
(teams_placeholder_version); "MSTeams" otherwise counts as the new client
for EOL, like "Microsoft Teams". On NVD the Teams lookup takes
microsoft:teams exactly: teams_panels and teams_phones (1.0.9x builds)
gave Teams for Android their CVE-2025-53783 fix. Prefix matching stays for
everything else, Adobe relies on it (acrobat_dc).
The same placeholder on ANY MSIX package is skipped by every scanner and EOL
(_is_stub, beside the Citrix delivery stubs). Win32
apps register a package for shell integration and leave it at 1.0.0.0:
Intune lists "TeamViewer.Client 1.0.0.0" (publisher "CN=TeamViewer Germany
GmbH, …") beside the real "TeamViewer 15.83.3.0", and it drew CVE-2020-13699.
A package is told by its publisher (the signer's DN, "CN=…", in Intune) or
its dotted identity name (Wazuh). A 1.0.0.0 on a plain ARP row stays: the
number alone does not prove the row is not a real install.
The TeamViewer desktop client is the exception where the product proves it:
it has been on 15.x since 2019 and no client since 2005 carried 1.0. Intune
lists a MacBook's TeamViewer as "TeamViewer 1.0"; read as a build it drew
CVE-2010-3128 (≤ 5.0.8703, DLL hijack), CVE-2018-14333 (≤ 13.1.1548,
TeamViewer.exe memory), CVE-2019-18988 (≤ 14.7.1965, registry key),
CVE-2021-34858 (< 15.21.2), CVE-2021-35005 (< 15.18.5), CVE-2024-0819
(< 15.51.5) and CVE-2024-6053 (< 15.57.3). The first three are Windows-only
by their own text, but NVD names no platform for them, so no platform filter
can drop them; any build a current macOS runs is above all seven bounds.
_is_stub drops a client row
(teamviewer_bulletin_service.is_client_install) at a placeholder version,
so the host's TeamViewer is unscanned until an inventory reports its build.
Oracle Java SE, JRE and JDK (key oracle-java on cvelistV5; NVD rows
oracle:jdk and oracle:jre, each with its sun: twin for the Sun-era
records, 6-8 only; a JDK also asks for the JRE's CVEs). Version from the
inventory NAME ("Java(TM) SE Development Kit 6 Update 13" → 6u13; 9+ from
"... Development Kit 17.0.9", Oracle names starting "Java " only, so Temurin,
Corretto, Microsoft's OpenJDK and the distros' java-17-openjdk stay out). Oracle names the newest affected
update per release and means every older one too. cvelistV5 records come in
three shapes: bare ("8u441", "17.0.14"), labelled per entry ("Oracle Java
SE:17.0.9", next to "Oracle GraalVM for JDK:17.0.9"), and a labelled list split
across entries ("Java SE: 6u181", "7u171", "10; Java SE Embedded: 8u161;
JRockit: R28.3.17"). _java_se_versions keeps the Java SE pieces; a label
holds until the next one. The products "Java" (2018-2020) and "Java SE JDK and
JRE" (2021-2024) were not paired before, so those years reached no Java
install. Older records are "n/a" in cvelistV5 (CVE-2013-1493, CVE-2018-13785):
NVD has them as oracle:jdk:1.6.0:update13, so the release is the query and
the CPE update field decides (_java_affected). "*:update41 up to and
including 1.6.0" is 6u41 and below; an entry without an update is the GA
build. 9+ has no NVD row: NVD names only the newest affected update (17.0.14),
which an exact query for 17.0.9 never returns. The NVD query now follows
startIndex over every page; it read only the first 200, which cut
oracle:jdk:1.6.0's 459 records off silently (and any other product past 200).
Lenovo System Update (key lenovo-system-update on cvelistV5, no NVD row).
NVD had CVE-2026-6387 at "Awaiting Analysis" with no configuration as of
2026-10-07. Lenovo's record states 0 .. lessThan "5.08.04.85", and the
zero-padded parts compare as numbers (5.08.03.59 is affected). The row matches
Lenovo's structured spelling Lenovo / System Update only. The records under
Lenovo / Lenovo System Update (CVE-2022-4568, CVE-2023-4632) put prose in the
version field ("Versions prior to 5.08.02.25"). The exact-version fallback would
read that as the fixed build itself. The 2019/2020 records floor at
unspecified, and CVE-2022-0354 states only various. None of them compares,
so they never fire. That is a miss, not a wrong finding, and every version they
bound is pre-5.08. The name is anchored, so Vantage, System Interface
Foundation and Update Retriever stay out.
Zoom Workplace and Zoom VDI (keys zoom / zoom-vdi on cvelistV5; NVD
rows zoom:workplace_desktop and zoom:workplace_virtual_desktop_infrastructure).
NVD renamed both products and kept every old name in use, so each row asks all
of them: the desktop client also as zoom:zoom (to 2023, CVE-2024-24691) and
zoom:meetings, the VDI client also as virtual_desktop_infrastructure and
vdi_windows_meeting_client(s). CVE-2026-53412 names only the new ones and the
old single zoom:zoom row missed it. zoom:workplace without a suffix is the
phone app. Names are anchored: "Zoom", "Zoom (64-bit)", "Zoom Workplace
(64-bit)", "zoom.us" for the desktop; "Zoom [Workplace] VDI…" for the VDI
client and its plugins (Universal, Citrix, VMware, AVD), never the macOS plugin
installer. Zoom Rooms, the Outlook plugin and the SDKs used to match a bare
"zoom" and drew the client's CVEs.
The MSI writes major.minor.BUILD ("7.1.43453"; the VDI Universal Plugin's
6.4.26620 is release 6.4.12). The EXE writes "6.0.11 (39959)", which reads as
the patch. A build cannot be placed against a bound inside its own minor, so
zoom_undecided gives such a bound no verdict on both paths: 6.6.26830 against
"6.6.0 .. < 6.6.15" opens no finding, and the CVE goes into touched, so an
existing finding stays open instead of closing on no evidence (logged at info).
A bound in another minor, or x.y.0 as an inclusive floor / exclusive ceiling,
still decides (6.6.22255 is below 7.0.0). NVD is asked for x.y.0, not the
build: its server-side match reads the build as a patch too, and returned
nothing for 6.6.26830. Across CPE names a real match beats
an undecided one.
That limit now applies only to builds nobody has mapped. The release is in
the ARP "Comments" ("7.1.5 (43453)"), which neither Wazuh nor Intune reports,
and in Zoom's own download URLs: 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, and
zoom_bulletin_service.build_builds reads them on the nightly cvelistV5 build
into {client|vdi: {"7.1.43453": "7.1.5"}} (setting zoom_build_index, about
150 client and 40 VDI builds on 2026-10-16). Only manifests of unknown builds
are fetched, and only a URL of the manifest's own build counts: 2020's
cdn.zoom.us/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 both scan paths compare, so the finding shows the
release like the ARP comment. A build not in the table stays undecided as
above. Field report 2026-10-16: Zoom Workplace (64-bit) 7.1.43453 was
7.1.5 and missed ZSB-26021/22.
NVD files the VDI client and the VDI plugins under one CPE and one version
line. zoom_cve_applies reads the CVE text: a plugin takes only what names a
VDI plugin (CVE-2026-30905, CVE-2026-53416 "VDI Client and Plugins"; the
Outlook plugin does not count), the client everything except a plugin-only
flaw ("a malicious client" in passing does not name the client). A Citrix- or
VMware-plugin-only flaw would still reach the Universal plugin; none is filed
under these CPEs today (the 2021 ones are vdi_citrix / vdi_vmware). cvelistV5 is the smaller half: most Zoom
records say "see references", the 2021-2022 ones floor at unspecified, and the
2023 ones write prose ("before 5.14.0") that the exact-version fallback would
read as the fixed build. Only the spellings with a real bound are paired, and
only Windows hosts are scanned there; NVD's target_sw places macOS and Linux.
It still matters: CVE-2026-53416 was at "Awaiting Analysis" at NVD on 2026-10-13.
The "see references" records point to Zoom's own bulletin, and that is read
instead (zoom_bulletin_service, merged by _merge_zoom_bulletins on the
nightly cvelistV5 build). CVE-2026-108184/-108185 (ZSB-26021/22) had neither a
bound on cvelistV5 nor a configuration at NVD on 2026-10-14, and no host was
flagged. The overview lists the newest 20 bulletins; each line of "Affected
Products" for Zoom Workplace and its VDI Client / VDI Plugins becomes ranges:
"starting on version 7.0.0 and before version 7.2.0 and 7.1.9 in their
respective branches" is [7.0.0, 7.1.9), each lower fix closing its own x.y
branch. Several branches without "starting on" floor at the lowest one's
x.y.0 ("before 6.5.17 and 6.6.14" does not reach 6.4; NVD floored
CVE-2026-30902 and -53412 the same way, not -53410); a single "before 6.6.0"
is everything below. Rooms, the SDKs, Contact Center and the mobile apps are
skipped. A line without "before"/"prior to" ("VDI Plugin version 6.6.10")
leaves its whole key to the record and NVD. The bulletin decides each CVE it
bounds per key, over a record's range (CVE-2026-53416's record says
"< 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 text heuristic. The MSI-build limit above applies to a
build the table does not know. 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 has no bound.
XenCenter (key xencenter, no pairs, no NVD row). Its CVEs exist only in
Citrix's bulletins: CVE-2026-42491 was unknown to NVD and cvelistV5 on
2026-10-23, and CTX696811 says "XenCenter versions before 2026.4.0". The
bulletin list on support.citrix.com is a JavaScript app, so the list is
XenCenter's static "What's new" page on docs.xenserver.com, which links each
security release to its bulletin (xencenter_bulletin_service, merged by
_merge_xencenter_bulletins on the nightly build). The bulletin is read from
/external/article/<CTX>; support.citrix.com answers 403 to a User-Agent
without "Mozilla". A bulletin counts only if its bound is the release the
notes link it from and it names exactly one CVE, since XenServer bulletins
also bundle host CVEs. The install reads "XenCenter 2026.4.1" / 26.4.1. The
MSI version drops the century. A name and version that name different
releases, or the Citrix Hypervisor console (8.2.x, not bounded by these
bulletins), give no verdict: open findings are held, none is opened. Windows
hosts only. The SDK bound in the same bulletins is not read, since the SDK is
not an installed program. A bulletin that is not freshly parsed keeps its last
parse.
XenServer VM Tools for Windows (key xenserver-vm-tools, no pairs, no NVD
row). The record for CVE-2025-27462/-27463/-27464 (XSA-468) names "Xen /
Windows PV drivers / all", and NVD left them Deferred with no configuration.
That is the Xen Project's driver source, not the tools XenServer ships, and
"all" bounds nothing. Citrix's CTX692748 does: "XenServer VM Tools for Windows
… All versions BEFORE 9.4.1". The tools' "What's new" on docs.xenserver.com
(/xenserver/8/vms/windows/vm-tools.html) links that release to the bulletin,
so it is the list (vm_tools_bulletin_service, merged by
_merge_vm_tools_bulletins, fetched with XenCenter's fetch). 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 (XenServer, Citrix Hypervisor,
XenCenter, SDK). Every CVE it lists is then the tools'. The install reads
"XenServer VM Tools" / XenServer / 9.6.0, or "Citrix VM Tools" before the
9.3.2 rebrand. The version is the installer's (9.6.1 installs as 9.6.1.12).
The Management Agent entry is not read: 9.4.2 ships agent 9.3.2.95. Windows
hosts only; Linux guests are not affected. The Management Agent's own
auto-update (the recommended default) installs the tools MSI, so the entry
follows it. Known limit: the I/O drivers can also be updated through Windows
Update (not recommended by XenServer), which leaves the tools' entry at its old
version. Such a VM is reported until the tools themselves are updated. The
names "Citrix Hypervisor PV Tools" and "Citrix XenServer Windows Management
Agent" are not read. The release notes say they can linger beside a newer
install.
Blender (key blender on cvelistV5, no NVD row). NVD had CVE-2026-60103
"Deferred" with no configuration as of 2026-10-13, and its blender:blender
rows stop at 2022. The record states 3.0.0 .. lessThanOrEqual "5.1.2" (fix
shown as >5.1.2) plus an unaffected git commit, which is not a version and
is ignored. CVE-2026-119718..119722 (published 2026-10-30, NVD "Awaiting
Analysis") come from the same CNA (VulnCheck) as Blender Foundation / Blender
with two lines, 0 .. <4.5.15 and 5.0.0 .. <5.2.3; both spellings are
paired, and a 4.5 LTS host is shown the 4.5.15 fix. "n/a" stays out: the 2022
records there write prose ("Blender versions prior to 2.83.19, 2.93.8 and
3.1"). Names are 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.
Known limit: CVE-2026-60103's range covers the 4.5 LTS line, and a fix
backported there closes the finding only once the record names it.
GnuPG (gnupg:gnupg on NVD, no cvelistV5 row). The inventory names GnuPG's
own Windows installer "GNU Privacy Guard". NVD bounds each branch
(CVE-2025-30258: < 2.4.8 and 2.5.0 .. < 2.5.5); MITRE's cvelistV5 records
write one range over all of them (0 .. < 2.5.5, and 0 .. < 2.5.17 for the
2.5-only CVE-2026-24882), which would flag a 2.4.8 that is fixed or never
affected. Gpg4win carries its own version line (NVD gpg4win:gpg4win) and is
not matched. Distro packages stay Wazuh's.
Ghostscript (artifex:ghostscript on NVD, key
ghostscript on cvelistV5). Field case 2026-10-16: Wazuh's "GPL Ghostscript"
10.05.1 (Artifex Software Inc.) got no CVE-2026-39919, which NVD had without a
configuration; Artifex's record states 0 .. lessThan "10.08.0". Only the
Windows installer's name is matched (optionally with version or bitness), so
the distro package "ghostscript", "GPL Ghostscript Fonts", GhostPCL and
GhostXPS stay out. artifex:gpl_ghostscript is not asked: NVD bounds seven
2018 CVEs there at < 9.26 and under ghostscript at the real fix 9.24
(CVE-2018-15909), so a 9.24/9.25 host would collect them; what that name alone
holds predates 9.21. cvelistV5 pairs the three Artifex spellings only. Red Hat's 2019-2020 records write the fixed
release as the affected one ("9.27" for "before 9.27") under "n/a" and "The
ghostscript Project", and prose ("ghostscript versions prior to 9.28") under
"Artifex Software"; the exact-version fallback would put either on the fixed
build. The first are unpaired, the second dropped in build_product_index
(only clean dotted bounds for this key). CVE-2018-16863 (9.07 as shipped with
RHEL 7) stays: NVD files it under artifex:ghostscript:9.07 too. The real
index of 2026-10-16 holds 9 entries, every other key unchanged.
DBeaver and CloudBeaver (dbeaver:dbeaver on NVD, keys dbeaver and
cloudbeaver on cvelistV5). Field case 2026-10-20: Wazuh and Intune list
"DBeaver 26.2.2" (DBeaver Corp), and neither product had a row. DBeaver is
the desktop client; its one CVE, CVE-2021-3836 (< 21.2.3), is NVD-analysed.
Its huntr record ("dbeaver / dbeaver/dbeaver") floors at unspecified, which
compares to nothing on the cvelistV5 path, so NVD flags it. CloudBeaver is the
web server: CVE-2026-108742 and -108745 (0 .. <= 25.3.5, "DBeaver /
CloudBeaver") sat "Deferred" at NVD without a configuration on 2026-10-20, so
cvelistV5 is their only source. Both products use one year.minor.patch
numbering, and each key pairs only its own product: a DBeaver 25.3.0 desktop
never gets a CloudBeaver CVE. Names are anchored to the Community Edition
("DBeaver", "DBeaver Community", "DBeaver CE", optional version and
"(current user)"; "CloudBeaver" likewise). Enterprise, Ultimate, Lite and Team
Edition are not matched: no record says they carry the CE fix at the same
version. A CloudBeaver Docker image is not an inventory entry and is not scanned.
PostgreSQL (key postgresql on cvelistV5, postgresql:postgresql on
NVD). Field case 2026-10-20: Wazuh and Intune list "PostgreSQL 18" 18.6-5
(PostgreSQL Global Development Group), the EDB Windows installer; "-5" is the
installer build and is trimmed (pkg_ver). The PostgreSQL CNA states every
CVE per major, vendor "n/a": CVE-2026-16238 and CVE-2026-14676 are
18 .. < 18.6, and "versions before PostgreSQL 18 are unaffected". NVD's
configuration for CVE-2026-16238 was not updated after the CNA corrected the
record on 2026-08-29: it still says 14.0 .. < 14.24 up to 18.0 .. < 18.5,
which would flag every 14-17 server and clear an affected 18.5. So a CVE the
cvelistV5 index holds for this key is decided by the CNA alone
(postgresql_nvd_hits), and NVD adds only the older CVEs, before the CNA took
over in 2024. Of those, a 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, not 10. Disputed records (CVE-2019-9193, COPY TO PROGRAM) are dropped.
Without a loaded index (the Intune sync's scan, or an index built before the
key existed) NVD is not asked for PostgreSQL; the next full scan decides.
On cvelistV5 only lessThan ranges are indexed: the Red Hat era records write
prose ("PostgreSQL 15.3, PostgreSQL 14.8, ...") 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 on both
paths: a distro package ("postgresql" 17+278) hides backports and stays
Wazuh's. Names are anchored ("PostgreSQL", "PostgreSQL 18", "PostgreSQL 18
(x64)", "PostgreSQL 9.4 x86"), so pgAdmin, the ODBC/JDBC drivers and EDB
Postgres Advanced Server stay out.
TightVNC (key tightvnc on cvelistV5, tightvnc:tightvnc on NVD).
Field case 2026-10-20: Wazuh and Intune list "TightVNC" 2.8.11.0 (GlavSoft
LLC.), the Windows MSI. NVD carries CVE-2021-42785 (tvnviewer.exe, <= 2.8.59)
and CVE-2023-27830 (< 2.8.75); the rest of tightvnc:tightvnc is the Unix
1.2 / 1.3 line as exact versions. CVE-2024-42049 (Server for Windows, before
2.8.84) has no NVD configuration, and its CNA record says "n/a / n/a". Only
CISA's ADP container states it ("tightvnc / tightvnc", 0 .. < 2.8.84). A
registry row with "adp": True lets the index build read ADP affected[], but
only when no CNA entry of the record names a curated product, and only for the
keys that opt in. A CNA that names the product always decides. Windows hosts
only on both paths: a distro "tightvnc" is the Unix 1.3 line, below every
Windows bound. Names are anchored ("TightVNC"), so the Java Viewer,
"tightvncserver" and TigerVNC stay out.
FileZilla Client (key filezilla on cvelistV5,
filezilla-project:filezilla_client on NVD). Field case 2026-10-30: Wazuh and
Intune list "FileZilla Client 3.46.3" and "FileZilla Client 3.19.0" (Tim
Kosse), with no finding. The row asked NVD for filezilla:filezilla, a CPE NVD
has never used. NVD files every client CVE under 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). cvelistV5 holds no usable range for
them: MITRE writes "n/a", and CVE-2019-5429 names only the fix, "3.41.0-rc1". So
NVD is the source. Windows and macOS hosts only on both paths: a distro
"filezilla" hides backported fixes and stays Wazuh's. Names are anchored ("FileZilla Client 3.46.3", "FileZilla 3.67.0",
"FileZilla", "FileZilla Pro 3.66.4"). FileZilla Pro takes the client's ranges:
NVD names only the client, but Pro carries the same version numbers since
3.25.2, and its own version history lists the same fixes (Terrapin in 3.66.4,
P-521 in 3.67.0). FileZilla Server (filezilla_server, its own 0.9.x/1.x
numbering) and the Pro CLI stay out.
SoapUI (smartbear:soapui on NVD, key soapui on cvelistV5). Field case
2026-10-30: Wazuh and Intune list "SoapUI 5.5.0" and "SoapUI 5.9.1" (SmartBear
Software). NVD holds 4 CVEs: CVE-2014-1202 <= 4.6.3, CVE-2017-16670 = 5.3.0,
CVE-2019-12180 <= 5.5, CVE-2024-7565 = .5.7.0 (NVD's typo for 5.7.0). NVD
compares the queried version itself and reads 5.5.0 as past <= 5.5, so
virtualMatchString=...:soapui:5.5.0 answers nothing; SoapUI is asked for
without a version (_NVD_UNVERSIONED) and _in_range decides. cvelistV5:
MITRE's records are "n/a / n/a", ZDI states CVE-2024-7565 as "SoapUI 5.7.0",
read as exactly 5.7.0. Names are anchored ("SoapUI 5.5.0"), so ReadyAPI
(formerly SoapUI Pro, smartbear:readyapi) stays out.
Microsoft Authenticator for Android (microsoft:authenticator on NVD, key
ms-authenticator-android on cvelistV5). Field case 2026-10-30: Intune lists the Android app by its
package id, com.azure.authenticator 6.2608.5658. CVE-2026-80097 is
< 6.2606.4246 on both sources: NVD 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, before NVD is asked.
Microsoft Authenticator for iOS (same CPE, key ms-authenticator-ios on
cvelistV5). Field case 2026-11-02: Intune lists it by its home-screen name,
"Authenticator" with no vendor, 6.8.57 on an iPhone and 6.8.56 on an iPad; the
bundle id com.microsoft.azureauthenticator matches too. CVE-2026-26123
(< 6.8.40) and CVE-2026-41615 (< 6.8.47) name both apps: NVD as two
entries, target_sw iphone_os and android, cvelistV5 as "Microsoft
Authenticator for IOS" and "... for Android", no platforms. MSRC's "for iOS"
is the one app on iPhones and iPads, so _platform_ok reads NVD's iphone_os
as holding for iPadOS too (for every iOS app, not only this one). The Android
bound is on the other line (6.8.57 < 6.2511.7533) and stays off Apple devices
because each entry keeps its own target_sw. Both scans call
authenticator_on_own_os: the Android package id on Android, the iOS names on
iOS/iPadOS at version 6 or later. Google Authenticator also shows as
"Authenticator" on iOS, on its own 4.x line.
The two apps number differently: Android 6.YYMM.NNNN, iOS 6.N.N, so every
iOS build sorts below every Android one. CVE-2024-21390 (field case
2026-11-02, iPhone on 6.8.57) names no platform anywhere: NVD
microsoft:authenticator target_sw * < 6.2401.0617, MSRC platform "-",
cvelistV5 "Microsoft Authenticator" platforms ["Unknown"]. The bound is an
Android build; read on iOS it would hold for every version ever shipped and
never close. authenticator_same_line drops an NVD hit whose fix is on the
other app's line, judged by the asset's OS (Android: four-digit minor). Android
builds before 6.2401.0617 keep it. The open iPhone finding closes on the next
app scan through the normal reconcile. cvelistV5 does
not match the record at all: neither key pairs the bare product name.
Cisco Webex (cisco:webex_teams / cisco:webex_meetings on NVD, keys
webex-app / webex-meetings on cvelistV5). Field case 2026-10-31: Intune
lists the Webex App (formerly Webex Teams) as "Webex" 41.10.0.20213, Wazuh the
Meetings desktop app as "Cisco Webex Meetings" 42.12.0. Same numbering, two
products. NVD: CVE-2022-20863 webex_teams < 42.7; CVE-2026-20233 is 56 exact
webex_meetings versions 39.6.0 .. 45.4.0 (42.12.0 among them). CVE-2020-3155
is webex_meetings:* with no bound and stays out (_in_range): no upgrade
could close it. cvelistV5: Cisco writes Meetings versions short ("42.12"), each
read as exactly that version, so this path also carries CVE-2023-20133/-20180
and CVE-2026-20184 for 42.12; the Webex App is "Cisco Webex Teams"
(CVE-2025-20236, 44.6 builds). CVE-2022-20863 there is "Meetings Desktop App",
"n/a": NVD only. Most webex_meetings CVEs are fixes in Cisco's cloud service
("no customer action is needed"); NVD and Cisco still list the client version.
Windows and macOS only: Intune names the iOS app plain "Webex" too (Linux
is left out with it, as for FileZilla and Nextcloud).
Productivity Tools, Meetings Server and the VDI app are not matched.
Git for Windows (git-scm:git + git:git +
git_for_windows_project:git_for_windows on NVD, key git on cvelistV5).
Field case 2026-10-31: Intune lists "Git" 2.38.1 (The Git Development
Community) and nothing matched: the old row asked only git:git, and only for
"Git for Windows" / "Git version control". NVD splits Git's CVEs over three
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), the Windows build's name 5
(CVE-2023-29011, < 2.40.1). cvelistV5: GitHub's CNA writes one range per
maintenance line into the version field (">= 2.38.0, < 2.38.4", "< 2.30.8",
"= 2.45.0", "-rc0" floors); core records are vendor git, the Windows-only
ones git-for-windows. "= X" names exactly X; the "=" used to reach the shown
fix as ">= X". Windows only: a distro "git" hides backports. GitHub Desktop,
TortoiseGit, Git LFS and git-gui (j6t records) are not matched.
KeePassXC (keepassxc:keepassxc on NVD, key keepassxc on cvelistV5).
Field case 2026-10-31: Intune lists "KeePassXC" 2.7.12 (KeePassXC Team) and
nothing matched CVE-2026-118673 (">= 2.3.0, < 2.8.0"). NVD holds the old
records (CVE-2023-35866 "<= 2.7.5", CVE-2024-33900 exactly 2.7.7) but left the
2026 ones "Awaiting Analysis" with no CPE, so cvelistV5 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"). CVE-2026-69150 ("< 2.8.0-beta1",
KeePass1 import) is read as "< 2.8.0": a bound at the train's first alpha or
beta keeps an open floor, while a later one ("< 5.0.0-beta3", Wazuh
CVE-2026-67307) or any rc gets its own train as floor (_ranges_from_affected).
Windows only: a distro "keepassxc" hides backports, and ZDI's record does not
say which platform its OpenSSL search path is (macOS not checked yet,
FileZilla-style). KeePass Password Safe,
KeePassDX and KeePassXC-Browser are not matched.
Dell SupportAssist (dell:supportassist_for_home_pcs /
dell:supportassist_for_business_pcs on NVD, keys dell-supportassist-home /
dell-supportassist-business on cvelistV5). Field case 2026-11-02: Intune
lists "Dell SupportAssist" 3.14.0.91 (Dell Inc.) and nothing matched
CVE-2024-52535 (Home < 4.6.2, Business < 4.5.1). The two editions share
version numbers (Home 3.0 .. 3.14 then 4.x, Business 3.0 .. 3.5 then 4.x) but
not bounds, so the name decides: plain "Dell SupportAssist" is Home, Business
registers as "Dell SupportAssist for Business PCs" (uninstaller listings, not
yet seen in the field; a Business install under the plain name would be judged
by Home bounds). cvelistV5 records say
"SupportAssist for Home PCs" (older: "SupportAssist Client Consumer") and
"... for Business PCs"; the newer ones write the floor as "N/A", which these
two keys read as 0 (NVD reads the same records with no floor). Elsewhere a
word floor still compares to nothing. CVE-2022-34389 ("SupportAssist ", both
editions' bounds in one string) stays on NVD only. Remediation, OS Recovery
Plugin and SupportAssistAgent are not matched.
UltraVNC (uvnc:ultravnc on NVD, no cvelistV5 row). Field case
2026-10-21: Wazuh and Intune list "UltraVnc" 1.3.8.1 and 1.2.2.2 (uvnc bvba).
NVD holds 37 CVEs, all ranges but one: the Kaspersky batch (CVE-2018-15361,
CVE-2019-8258 .. -8277, -8280) < 1.2.2.3, CVE-2022-24750 < 1.3.8.1,
CVE-2020-37132/-37133 <= 1.2.4.0, CVE-2026-4962 1.6.0.0 .. <= 1.6.4.0,
CVE-2026-3787 = 1.6.4.0, and the 2026 viewer/repeater batch (CVE-2026-7828 ..
-7831, -7838 .. -7840, CVE-2026-44040 .. -44042) <= 1.8.2.2. The Kaspersky cvelistV5 records
name 1.2.2.3, the fix, as "affected", and NVD has the uvnc records already, so
there is no cvelistV5 key. Names are anchored ("UltraVnc", "UltraVNC 1.4.3.6"),
so Repeater, SC and the mirror driver stay out.
Firebird (key firebird on cvelistV5, firebirdsql:firebird on NVD).
Field cases 2026-10-20: Intune lists "Firebird 2.0.7.13318 (win32)", Wazuh
"Firebird 2.5.2.26540 (x64)", a bundled install "Firebird-Server 4.0 x64
()" 4.0. NVD states each release line (CVE-2026-40342: < 3.0.14,
4.0.0 .. < 4.0.7, 5.0.0 .. < 5.0.4) and no range bound with a build, so the
CPE row compares three parts (ver_parts=3: 2.0.3.12981 is 2.0.3, inside
<= 2.0.3). The GitHub CNA writes bounds with the build (< 4.0.6.3183), so
the cvelistV5 row compares the whole version. It also writes a bare < X per
line (CVE-2025-24975: < 6.0.0.609, < 5.0.2.1610, < 4.0.6.3183); with
"line_floor": True such a bound starts at its own major when the entry has
more than one range, so a patched 5.0.3 stays out of the 6.0 line. Lines older
than the lowest major (2.5 under < 3.0.14) are NVD's. A lone < X keeps its
open floor: CVE-2025-65104 says < 4.0.0 (every FB3 client, fixed by the FB4
client), where NVD says < 3.0.14. As for PostgreSQL, a CVE the index holds is
the CNA's (firebird_nvd_hits), but only from its lowest floor up
(cna_floors): below it NVD still decides, so 2.5 keeps CVE-2026-28212 and
3.x does not get CVE-2025-24975 (ExtConnPool, 4.0 on, at both sources).
Without a loaded index NVD is not asked, so the Intune sync's scan leaves
Firebird to the next full scan. "4.0" names no build
and is skipped on both paths. The >= X, < Y form in the version field (GitHub
CNA: Firebird, Wazuh, Vim, Notepad++) is read by _ranges_from_affected for
every key; before, it read as one version no install equals. An exclusive floor
(> X, < Y), a floor with no ceiling or a bound that is no version makes the
whole entry unreadable (no range at all), because suppress_false_positives
takes the ranges it gets as the complete answer. CVE-2008-0467 also carries an
entry limited to update "rc1" (<= 2.1); _in_range drops entries whose
update field is a prerelease (alpha/beta/rc/pre/preview) for a release build,
so the patched 2.0.7 stays out. Windows hosts only. Anchored names keep the
ODBC driver, the .NET provider and "Mozilla Firebird" out.
Intel PROSet/Wireless (key intel-proset-wireless on cvelistV5,
intel:proset\/wireless_wifi plus proset\/wireless, proset\/wireless_software
and proset\/wireless_software_and_drivers on NVD). Field case 2026-10-20:
Intune lists "Intel® PROSet/Wireless Software" 20.20.3 (Intel Corporation).
NVD's escaped slash has to go out as %5C (_query_nvd_cpe); a raw backslash
gets a 400, which reads as "no CVEs". CVE-2018-12159 (<= 20.50) is NVD-only,
because its CNA record says "n/a / n/a". NVD left most 2023-2025 records
"Deferred" (CVE-2023-38417, -40536: before 23.20). For those, the CNA is the
only source. Intel writes a dozen product spellings under vendor "n/a", so a
_PRODUCT_PATTERNS row matches them, and only names that say "software". The
Windows 7/8.1 firmware ("WiFi Advisory", CVE-2020-0559), drivers, UEFI and
"for " records ("for linux") 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 (Hot Fix)") is dropped, and NVD has those.
Names are anchored, so the Bluetooth package and Killer stay out.
Intel Rapid Storage Technology (intel:rapid_storage_technology on NVD,
key intel-rst on cvelistV5). Field case 2026-11-02: Intune lists "Intel(R)
Rapid Storage Technology" 17.5.9.1040 (Intel Corporation) and nothing matched
CVE-2022-43456. NVD bounds each release line (17.0 .. < 17.11.3.1010.2). The
CNA writes vendor "n/a" and "Intel(R) RST", "... RST software" or "Intel Rapid
Storage Technology software," (a _PRODUCT_PATTERNS row), 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 into 0 .. < bound, and
line_floor starts it at its own major; a single bound has no floor, as on
NVD (CVE-2023-39230). 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, so its 18.x
lines are NVD-only. Its 17.x fix also differs (CNA 17.9.0.34, NVD
17.9.1.1009.5); each finding shows its own. RSTe (CVE-2019-0130, 5.x), the RST "Application"
(CVE-2025-24327, its own numbering: 18.1.1042 / 20.0.1021) and "installation
software" ("See references") are not matched.
IntelliJ IDEA (jetbrains:intellij_idea on NVD, key intellij-idea on
cvelistV5). Field case 2026-11-02: 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 (name_ver). A name without one (macOS: "IntelliJ IDEA CE") takes the
field only when it is a release (field_ver_re, ^20\d{2}\.\d+); a build is
asked of no source. Community and Ultimate share the CPE. Most JetBrains records are 0 .. < 2026.2.1;
jetbrains_affected handles the rest: several lines 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, which caught a patched
2023.2.8). Each bound but the lowest starts at its own YYYY.N line, a bound
that opens its line ("2026.2" in CVE-2026-59792) is dropped, as are EAP bounds
and build numbers ("<173"). On NVD, CVE-2026-41882 lists the five fixes as
exact affected versions; exact IntelliJ entries are ignored, since JetBrains
bounds every record. NVD also has CVE-2024-37051 without its 2023.1 floor and
CVE-2025-32054 ("before 2024.3, 2024.2.4") as 2024.2.4 .. < 2024.3, so, as for
PostgreSQL, a CVE the intellij-idea index holds is the CNA's alone; NVD
decides the rest, and all of them when no index is loaded.
PDF-XChange Editor and PDF24 Creator (pdf-xchange:pdf-xchange_editor and
pdf24:pdf24_creator on NVD, no cvelistV5 row). Field case 2026-10-20: Intune
lists "PDF-XChange Editor" 9.5.368.0 (Tracker Software Products (Canada) Ltd.)
and "PDF24 Creator 11.18.0" (geek software GmbH). NVD holds 288 Editor CVEs;
most are ZDI records stated as the one tested version (CVE-2023-40471:
9.5.368.0), the rest as ranges (CVE-2025-0901: < 10.4.1.389, CVE-2024-7352:
<= 10.3.0.385). tracker-software:pdf-xchange_editor holds none. The 283
cvelistV5 records each name one tested version and nothing else, and NVD has
all of them but CVE-2026-2040 (updater LPE, "Deferred", record: 10.7.2.400).
That one comes from the vendor's security bulletin, which names 10.7.2.400
affected and 10.7.3.401 the fix: a registry advisories row
(_PDFX_CVE_2026_2040, read by _advisory_cves as on the extension registry)
states it as 0 .. < 10.7.3.401, the way NVD read the analysed ZDI records of
this product. The row decides its CVE alone, also once NVD analyses it. A
cvelistV5 key would add nothing but exact tested versions, so there is none.
PDF24 Creator has one
CVE, CVE-2023-49147 (< 11.15.2, CNA "n/a / n/a"). Names are anchored:
PDF-XChange Viewer, PRO, Lite, the printer drivers and "Editor Plus" are not
matched.
Nightly jobs wait for the Intune sync
The 02:10 Intune sync (with Defender TVM) can run for hours on a large estate — field report 16.10.2026: 07:38–12:17 for 1639 assets. APScheduler runs every job on its thread pool, so before this the 03:00 EOL, 03:10 M365, 03:20 app-CVE and 03:50 MSRC jobs simply started alongside it. All four write the same assets and findings the sync writes (its per-device EOL/M365/app-scan pass, Defender merging into MSRC rows): two writers in different orders → deadlock, and the app scan reads an inventory still being refreshed.
Each of those jobs now calls scheduler._wait_for_intune_sync first: it probes
the sync's advisory lock (intune_service._SYNC_ADVISORY_LOCK_KEY, take +
release at once — never blocks a sync) every 60 s, for at most 2 h, with a
warning when it starts waiting and an info line with the wait when it ends.
After 2 h it logs a warning and runs anyway — a lost night for the whole estate
is worse than one overlap.
They are one job now. As four crons that each waited, they all started the
moment an overrunning sync ended, in no order: the MSRC job's remediation
catch-up ran before the app scan whose Edge findings it is for (those stayed
without a KB until the next night), and both wrote Windows findings on the same
assets at once. post_inventory_scans_nightly (03:00) waits once, then runs
EOL → M365 → app-CVE scan → MSRC in turn; each step keeps its own job slot and
wait (a no-op unless a sync starts mid-chain), and a step that raises costs
only itself. Test: tests/test_nightly_post_inventory_chain.py.
A GUI/API app-CVE scan does not wait; it logs a warning and returns
intune_sync_running: true in its stats when it starts during a sync.
Test: tests/test_nightly_waits_for_intune_sync.py.
Overlapping jobs: queued, never dropped or raced
Scheduler. app/scheduler.py gives APScheduler its own 64-thread pool
and misfire_grace_time=None, coalesce=True, max_instances=1. The defaults
(1 s grace, the event loop's min(32, cpus+4) pool) dropped jobs: the grace is
checked again in the worker thread, so a job that queued for a thread, or came
due while the event loop was blocked for a second, was skipped ("Run time of
job … was missed by"), not run late. A long Intune night plus four jobs waiting
for it filled the pool, and every job after that was lost. Now a late job runs
late. A job still running at its next slot (hourly Wazuh scan > 1 h) skips
that slot (max_instances=1); a backlog runs once (coalesce). Jobs are in
memory: a slot that passes while the app is down is not caught up.
Same job, two starters. The nightly jobs and the Data-jobs / sync buttons
call the same scans. database.job_slot(name) (one advisory lock per kind,
JOB_SLOT_KEYS) makes a second run wait for the first, then run: EOL, M365,
app-CVE scan, MSRC scan, MSRC refresh, exploit intel, Vulnrichment, vuln-index rebuild,
Wazuh vulnerability sync (scheduled + button), Nessus (scheduled, button,
launch-and-import). It is taken before the job opens its Session, so a waiting
run holds no rows; after 6 h it starts anyway with a warning. Different kinds
still run in parallel. A synchronous endpoint (/sync/wazuh, /m365-check,
/exploit-intel/refresh, ...) clicked during the nightly run answers when its
own run is done. The integration syncs (Intune, vCenter, IGEL, Netdisco,
Dockhand) keep their own try-lock: a second trigger is skipped with "another
sync already running", which the settings page shows as a warning.
Test: tests/test_job_concurrency.py.
Mobile Device Security (Intune)
Runs inline during the Intune sync — no extra Graph calls beyond the
device dict and detectedApps already fetched.
- Device EOL/EOS (
mobile_eol_service.py) — Apple (iPhone/iPad) fuzzy- matches endoflife.date's full release list by marketing name, except the few names endoflife spells differently (_APPLE_ALIASES: first-generation iPhone SE and iPad Air/mini/Pro without "(1st generation)", 12.9" Pro 6th gen, iPhone Air;tests/test_apple_model_names.py). Apple announces no end dates, so a current model gets its finding only once endoflife marks it EOL; Samsung needs a curated SM-code → release-name table (no textual bridge exists between Intune's model code and endoflife's marketing name in either dataset) — ~120 models, all verified against the live endoflife API. A re-release that keeps the base code gets a suffixed row above it (Tab A9+ 2025 = SM-X216R/SM-X210R; SM-X216B/U and SM-X210 are 2023). A device is one model, so the mobile slugs supersede: a resolved model retires any other model's finding on the asset even when it raises none itself (tests/test_galaxy_tab_a9plus_2025.py). - OS-level CVEs — iOS/iPadOS/macOS via NVD-CPE (
app_cve_scanner_service), with a platform check (CPEtarget_swtoken) so e.g. a Firefox-for-iOS CVE can't match a desktop Firefox install. NVD often has no configuration for a fresh Apple CVE for days, socvelistv5_scan_service.scan_asset_os_applereads Apple's own records too. Apple states each train as a zero-floor range; the host's own train (same major) answers. With no range on it, a bound on the next major counts: CVE-2026-86918 is only "iOS and iPadOS < 27", so a 26.5 iPad is affected and the fix is 27. Older trains (iOS 18, macOS 15 before the jump to 26) get no verdict from a 26/27-only record. Test:tests/test_apple_os_train.py. - Windows OS CVEs — Graph reports
operatingSystem: "Windows"plus the build, never 10/11 or the edition._os_stringplaces the family from the build (10.0.22000+ = 11) so the cvelistV5 Windows pass (_win_family) can scan Intune-only clients; a bare "Windows" is skipped there. Edition does not matter for CVEs (Microsoft's records are per release + architecture). Only formanagementAgentcontaining "mdm": Defender-managed servers are listed too and 10.0.26100 is also Server 2025, so those stay bare. Test:tests/test_intune_windows_family.py. - Windows edition → EOL — edition decides the end of support: 11 24H2
Home/Pro ("W") ended 2026-10-13, Enterprise/Education ("E") runs to
2027-10-12, LTSC longer still. Only beta
managedDevicecarriesskuNumber, so the device list is read from beta with it in$select— same call, no extra requests; v1.0 is the fallback if beta refuses the query._os_stringappends the edition Wazuh-style ("Microsoft Windows 11 Pro"), andeol_service._pick_windows_client_releasepicks the endoflife.date release by build (latest= "10.0.26100") plus edition suffix (-w/-e/-e-lts/-iot-lts). No edition known → the latest date on that build, LTSC included (never reports EOL early); build not in endoflife.date yet, or a bare "Windows" (Defender-managed servers, 10.0.26100 is also Server 2025) → no verdict. Only a 400 from beta falls back to v1.0 — 429/5xx/auth fail as before, no second read. An edition-less Intune name does not overwrite Wazuh's "… 11 Pro" (_merge_os_name). Before this, codename "11" prefix-matched the newest release, so no Windows 11 was ever EOL. Test:tests/test_windows_edition_eol.py. - Android patch-level staleness — Intune's
androidSecurityPatchLevelvs. today; graduated severity (≥90/180/365 days → low/medium/high). - Android per-CVE detection — two sources, Samsung preferred:
- Samsung SMR (
samsung_smr_service.py) —securityUpdate.smsb?year=YYYYserves the full year's ~12 monthly sections server-side (the accordion UI is pure CSS/JS, doesn't gate content); parses eachSMR-MMM-YYYYblock's Google Critical/High list minus "Not applicable to Samsung devices" (chipset-specific CVEs Samsung's own page excludes) plus Samsung Semiconductor fixes. Precise per-device applicability. - Google ASB (
android_cve_service.py) — fallback for non-Samsung Android or months SMR doesn't cover. Section-aware parser keeps only AOSP sections (Framework/System/Kernel/...), drops SoC/vendor sections (Qualcomm/MediaTek/...) that only apply to that specific chipset. URL format changed in 2026 (/bulletin/{year}/{month}vs. the older flat/bulletin/{month}) — both tried. - Both cache per-month/year in a Setting; empty/404 months are negatively cached (short TTL) so a not-yet-published month doesn't trigger a re-fetch on every device sync.
- Samsung SMR (
Dashboard: a dedicated "Mobile Security · EOL & Patch Level" widget,
kept separate from the desktop-software EOL widget, sorted so a reached
vendor-EOL outranks patch-level staleness. GET /api/v1/vulnerabilities? finding_type=mobile backs both the widget and its "View All".
Advisory Awareness Feed
Independent of asset findings — a rolling view of what's actively
exploited in the wild (CISA KEV), so 0-days are visible before any scanner
flags an affected asset. Reuses the KEV catalog enrichment already
fetches/caches (24h). GET /api/v1/advisories/kev-recent → dashboard
widget with an "in inventory / not seen" badge per CVE (one grouped query,
no per-CVE lookup).
The page is Threat Intel in the sidebar (URL stays /advisories, which
redirects to the first tab), split in two because the halves are different
kinds of thing:
/advisories/kev— Exploited (KEV): CISA KEV + ENISA EUVD + VulnCheck KEV, matched against the inventory and alertable. "Exploited & open in your environment" (the alert list) comes first, "Latest KEV additions" below. Each catalog is cached 24h insettings; the header shows every source's own*_updated_at(source_status). "Refresh catalogs" →POST /api/v1/advisories/kev/refreshforces all three past the cache./advisories/feeds— Advisory Feeds: the RSS/Atom sources, not matched, not alerted. One cached row (advisory_feeds_cache), refreshed every 6h at :20 or by hand; opening or expanding a feed fetches nothing.
Keep each tab's freshness stamp and refresh button inside that tab. The page
used to show the RSS fetched_at as "Last fetch" in the shared header, where
it read as the age of the KEV list above it.
Assets: filter by sync source
GET /api/v1/assets?source=... now filters by the actual scanner
linkage (wazuh_agent_id / nessus_host_uuid / intune_device_id /
defender_machine_id / a vuln row whose sources names that scanner) —
not the creation-time source enum, which never updates after an asset is
matched by a second scanner post-creation.
Out of scope (future ideas)
- Samsung-proprietary SVE CVEs (no ASB equivalent; would need
security.samsungmobile.com's per-device JS-loaded detail view, not scrapeable without a browser) - Android bulletin OEM coverage beyond Samsung (Google Pixel / others) — ASB-only fallback already covers them, just without a vendor-specific applicability filter
- SAP (Business Client / GUI / Analysis for MS Office) CVE detection needs a patch-level (SP/PL) aware matcher — NVD's CPE covers a whole minor version with no SP granularity, so a naive match false-positives on already-patched installs. Needs a curated per-CVE fixed-SP table.
- Microsoft Teams classic EOL flag — no endoflife.date product exists; would need a hardcoded retirement-date exotic (same shape as the existing Silverlight/VC++ redistributable entries)
Out of scope (future ideas)
- LDAP password change flow (currently read-only — users change pw in AD)
- SCIM v2 endpoint for IdP-driven provisioning (push instead of JIT pull)
- WebAuthn / FIDO2 as second factor after the password (passwordless passkeys exist)
- Passkeys for LDAP users — needs a directory check (disabled / group change) at sign-in
- Risk-based MFA (require TOTP only from unknown IP / unknown device)
- LDAP referral chasing across multiple forests
- Encrypted SAML assertions (currently only signed)
- OIDC back-channel logout
Data sources added for CVEs that never reach NVD / cvelistV5
Three upstreams publish CVEs that the usual feeds do not carry. Each is version-range checked like any other source, so a patched host is never flagged.
| Source | Covers | Why it is needed |
|---|---|---|
MSRC CVRF (msrc_scan_service) |
Windows Server, SharePoint, Microsoft Edge, SQL Server 2017–2025 | Microsoft ships no version ranges; the CVRF's FixedBuild is the only machine-readable "which build carries the fix". Edge CVEs are absent from NVD and cvelistV5 entirely. |
GitHub repository advisories (github_repo_advisory_service) |
projects that self-publish: Notepad++, Wazuh, Nextcloud Desktop, NanaZip | The per-repo endpoint (unlike the global /advisories) carries vulnerable_version_range + patched_versions. |
Mozilla MFSA (mozilla_advisory_service) |
Firefox severity | Mozilla rates impact before NVD/cvelistV5 have a score. |
Chrome Releases blog (chrome_releases_service) |
Google Chrome (desktop + Android) | Google posts the fix list with the release; the CVE records follow days later. Chrome 154 (22.09.2026, 108 fixes) had no record two days on. |
Notes:
-
Chrome Releases blog = the record, early. Only "Stable Channel Update for Desktop" posts; the lowest build in the head ("154.0.8037.57 (Linux) 154.0.8037.57/.58 Windows/Mac" → .57) is the
lessThanGoogle's CNA writes into the record later (checked on CVE-2026-93374). Merged into the cvelistV5chromekey at the end of the index build (_merge_chrome_releases), only for CVEs no record has given a Chrome range yet — the record decides as soon as it lands, with the same bound, so the finding just continues. A head with two majors is skipped; ChromeOS / Extended / Early Stable / iOS posts are never read. Blog unreachable or layout changed → the last parse (chrome_releases_index) is served, so findings do not close for a night. Test:tests/test_chrome_releases_source.py. -
SQL Server 2017, 2019, 2022, 2025 (keys
sql2017…sql2025, package pass of the app scan). MSRC states each fix per release and servicing path: CVE-2026-70189 is 16.0.1210.3 on "SQL Server 2022 for x64-based Systems (GDR)" and 16.0.4305.5 on "(CU27)" (also spelled "(CU 27)"). The GDR path (RTM + security fixes only) and the CU path are separate build lines, told apart by the third part: 2017 GDR 14.0.1000–2999 / CU from 3000, 2019 GDR 15.0.2000–3999 / CU from 4000, 2022 and 2025 GDR x.0.1000–3999 / CU from 4000 (2025's CU9 is 17.0.5005.3, still the CU path). Only fixes on the host's own path count, so a patched GDR host is never held to the CU fix. Per CVE the oldest fix on the path decides, unlike Edge: builds on a path are numbered in release order, and MSRC adds newer builds to a revised CVE without dropping the first ones (CVE-2026-32176 rev 2.0 lists 16.0.1175.1 and 16.0.1210.3; the newest would flag every host in between). The build comes from "Microsoft SQL Server 2022 Setup (English)", which every GDR/CU raises; the patch entries ("GDR 1175 for SQL Server 2022 (KB5084815) (64-bit)", "Hotfix 4295 für SQL Server 2022 (KB5104824) (64-bit)") only raise it and never flag alone. The instance entry "Microsoft SQL Server 2022 (64-bit)" (no version) must be listed as well: setup writes its Setup entry first and leaves it behind after an aborted install, which would flag a host without SQL Server. The reconcile compares a finding's fix only on the host's own path, so a host reinstalled from CU to GDR does not stay below a CU fix forever. Browser, RsFx Driver and VSS Writer stay at RTM (16.0.1000.6) after every patch and are never read. A build that fits neither path (other major, pre-release) gets no verdict. One Setup entry per release and host: with several instances the newest patched one decides, so a lagging second instance is missed, never wrongly flagged. 2016 is not scanned (EOL 2026-07-14, the EOL finding covers it); LocalDB, the ODBC/OLE DB /JDBC drivers and SSMS are other MSRC products and are not matched. A new release needs its floors added to_PRODUCTS. Field check against the real CVRF (2025-01 … 2026-10): every SQL fix build fits a path; 16.0.1175.1 → 78 CVEs, 16.0.4295.3 (CU27 without October's CU27 + GDR) → 21, 16.0.1210.3 and 16.0.4305.5 → none. Test:tests/test_msrc_sql_server.py. -
Edge is never scored off Chrome data. Edge 150.0.4078.99 rides on Chromium 150.0.7871.187 — the schemes are unrelated, so a
google:chromerange says nothing about an Edge build. WebView2 is excluded for the same reason (own package, own version). -
The MSRC index is cached under
msrc_product_index_v3. Bump that key whenever_PRODUCTSchanges — a cache built by an older version simply has no entry for the new product, andload_index(allow_stale=True)will serve it anyway. -
The MSRC index is rebuilt at 01:30 by
vuln_index_refresh_nightly, with the other indexes the app scan decides from — that scan's package pass is what creates the Edge findings. It used to be rebuilt by the MSRC job, i.e. after that scan, so a Chromium CVE that MSRC files under Edge on day D (CVE-2026-84324, published 03.09. 17:00) reached the host on D+2. The MSRC step reuses the 01:30 build viaensure_index(max_age=6h)and rebuilds only when it is missing or a day old; a failed build keeps the cached index and logs a warning. Test:tests/test_msrc_edge_chromium_cve.py. -
…and once more by the nightly app scan, right before it decides (
app_cve_scan_nightly). 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 are local time, so in summer (CEST) 23:50 UTC is 01:50, after the 01:30 build. The 03:00 chain reused that build, so those Edge CVEs reached hosts a night late. The rebuild downloads only documents revised since 01:30, and a failed one keeps the 01:30 index. The MSRC step reuses it (6h). The 01:00–02:45 UTC batch that revises about half the documents every night is not the reason for this: it is Azure Linux (azl3packages, "Information published") and touches no product in this index. Checked 31.10.2026 against 2026-Feb/Sep/Oct: no Windows Server/SharePoint/SQL revision before 07:00 UTC. The only Edge revisions in that batch (7 in September, none in October) are Chromium CVEs that azl3 lists too (V8 innodejs). -
MSRC API errors (
msrc_service._get). MSRC throttles with 429 but documents no quota, offers no API key (CVRF v3.0 is anonymous) and sends noRetry-Afteror rate-limit headers; on 29.10.2026 it refused the single/updatescall of the 04:34 remediation catch-up, three hours after the last request. Every MSRC call goes through_get:- 429, 5xx and network errors are retried after 20/60/180 s, or after
Retry-Afterwhen MSRC sends one. - Other 4xx raise at once.
- After the last retry it raises
MsrcUnavailable, and both CVRF walks stop instead of hammering on.
A failed
/updatesfalls back to the last listing that worked (/tmp/truevuln-msrc-cvrf/updates.json), so a walk whose documents are cached needs no call. That is safe for the index: documents never leave/updates, so the old listing still names every month any build saw. CVRF documents stay cached per revision for 6 days, not 4 h, so the 01:30 build downloads only revised documents instead of all 18 (~20–30 MB each, served uncompressed). Detection does not depend on the cap: every revised CVE moves its document'sCurrentReleaseDate(checked 31.10.2026: in six documents the newest CVE revision equalled the listed revision to the second). Microsoft revises often (9 of 18 that night), so this saves about half the downloads. The on-demand Update Guide call caches only "unknown CVE" (200 with an empty list, or 404), never a 429. Test:tests/test_msrc_api_errors.py. - 429, 5xx and network errors are retried after 20/60/180 s, or after
-
Every scan reads the index through
ensure_index(max_age=SCAN_INDEX_MAX_AGE), neverload_index.load_indexserves the stored blob at any age, which is right for the nightly chain (one 01:30 build for the app scan and MSRC) and wrong for a run started by hand: after the field report above the operator pressed "MSRC Enrich" and "App CVE Scan" and still got no Edge finding, because neither button rebuilds this index — MSRC Enrich ismsrc_service(per-CVE remediation text, a different cache entirely). 6h keeps the nightly chain on one build and makes a button pressed at 14:00 answer from 14:00. -
The GUI button for a forced rebuild is MSRC Scan (
POST /msrc-scan/start,rebuild_index=trueby default, background job + status poll like the app scan — the CVRF pull outlives the browser's HTTP patience). It rebuilds the index and runs the OS pass; the Edge/SharePoint half belongs to the app scan, which then reuses that build. -
Vendor severity badge.
severityfollows the CVSS band; the vendor's own rating sits next to it on the detail page, only when the two disagree. Google (Chromium) comes from the cvelistV5 description, Mozilla from its advisories (appended todescription). Everything else goes throughVulnerability.set_vendor_severityintovulnerabilities.vendor_severity(JSON per vendor, Alembic 058):- Microsoft — highest CVRF Threat of Type 3 ("Max Severity"), or
severityfrom the Update Guide on the per-CVE path; stamped on every finding with that CVE id, whatever scanner found it. - TeamViewer — the bulletin's Priority (
sevprefers the CVSS band). - GitHub repo advisories — the advisory's severity, labelled with the repo.
- Android ASB / Samsung SMR — the bulletin's rating.
- IGEL has none: its "(High)" is the CVSS band.
New findings merge the ratings of their siblings (
apply_canonical_from_siblings). Important→high, Moderate→medium. Its own column because M365 and Nessus rewritedescriptionnightly. Test:tests/test_vendor_severity.py.
- Microsoft — highest CVRF Threat of Type 3 ("Max Severity"), or
-
Repo-advisory version ranges are maintainer free-text (
<= v8.9.6.4,old versions - 8.8.1,v8.9.4 & v8.9.5, bare versions).patched_versionsis the hard safety net: at/past the patched release is never flagged, and an explicit version list matches exactly, never "everything below".
One finding, several affected products
A finding is unique per (cve_id, asset_id) (index uq_vuln_cve_asset), so a
CVE hitting two products on one host — CVE-2026-16417 affects Chrome and
Edge — is ONE row carrying BOTH products, not two rows (splitting would break
dedup, source reconciliation and every count). The per-product detail lives in
vulnerability_packages and is written via
audit_events.record_affected_package(); the API returns it as packages and
the CVE detail page renders one card per entry.
The helper is idempotent per (finding, package name) and must never break a
scan: a plain query does not see rows added earlier in the same unflushed
transaction, so it also scans db.new, inserts inside a savepoint, and swallows
what is left.
Cross-source contract (do not weaken it)
Every scanner retracts only its own source; a finding closes when no source
is left. An inventory-based override that closed over a lone Wazuh claim was
removed after producing false negatives four different ways: multi-stream fixes
(MySQL 8.0.23 vs a 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. A single stored
fixed_version cannot validate an arbitrary inventory version string.
Reopens are governed by the same idea from the other side: a source may not
reopen a closed finding with data that is OLDER than the close
(audit_events._REOPEN_GRACE). The lag belongs to the inventory, not to the
scanner — app_scan and msrc fetch nothing themselves, they decide from
whatever software list the run handed them, so a scan declares that list with
audit_events.scanning_inventory() / use_inventory() and the grace follows it:
| Inventory | Lag before its "still vulnerable" claim is believed again |
|---|---|
Wazuh syscollector (4.x API; on 5 wazuh-states-inventory-packages, synced the same way) |
none — close to live |
| Defender TVM software list | none — follows device telemetry |
Defender TVM CVE feed (defender) |
3 days |
Intune detectedApps |
7 days |
Past the lag the reopen goes through, so a host that really did regress
resurfaces. Field report 06.09.2026: two Intune+Defender hosts with no Wazuh
agent — Defender closed a dozen Edge CVEs as remediated at 02:10, the 03:20 app
scan read Intune's week-old detectedApps and the MSRC pass reopened all of them,
every night until Intune caught up (tests/test_reopen_stale_inventory.py).
Related guards:
- The app-scan reconcile only runs on ACTIVE assets. A disconnected Wazuh agent still serves its last stored syscollector data, so "no longer detected" proves nothing — and Wazuh's own vuln sync skips inactive agents, so nothing would reopen the findings.
- A host in Wazuh and Intune takes Wazuh's software list, unless the agent is
dead:
wazuh_last_seen(keepalive) outside the grace window andintune_last_seennewer (audit_events.asset_inventory). Then Intune is the host's own inventory and may close app-scan findings. The same reconcile retracts the frozen "wazuh" source there (Wazuh's vuln sync never asks a dead agent), so a finding closes once no other source reports it (tests/test_dead_wazuh_agent_retracts_wazuh_source.py). A live agent never loses it, so the precedence does not follow the order the syncs ran in (field report 2026-10-16,tests/test_dead_wazuh_agent_falls_back_to_intune.py). - Products whose ARP version is an MSI build (Python, .NET) are marked
name_ver=Trueso the semantic version is read from the display name. - An NVD outage (503/429 after the retries,
_TransientNVD) is no verdict.lookup_cvesraises it instead of returning[], and the caller putsNVD_NO_VERDICTinto the asset'stouchedset. Both reconciles that read that set then skip the asset for this run: the app-scan one, and_resolve_stale_appliance(Netdisco, IGEL, vSphere and the other appliance passes). Before, the outage read as "NVD lists nothing", which closed every CVE only NVD carries (the AOS-S 2023 trio on every switch), and the next healthy run reopened them (tests/test_nvd_outage_no_autoclose.py). The failed (product, version) is remembered in memory forNVD_RETRY_AFTER(15 min): further hosts on it get the same no-verdict without asking. Uncached, every host retried with back-off — 80 switches on two versions made 320 identical requests and ~40 min of sleep in one sync (tests/test_nvd_outage_asked_once.py). - A missing cvelistV5 index is no verdict either.
build_product_indexraises when it has neither a ZIP nor a cached index, or when the walk yields nothing; the app scan then skips its reconcile for the run (resolve_skipped_cve5_unavailable,tests/test_cvelistv5_outage_no_autoclose.py). The walk readscves/**/CVE-*.jsononly and skips a record it cannot read with a warning naming it. Field report 2026-11-02:deltaLog.json(a list) passed the vendor pre-filter once "git" was curated, every build died on it, and the reconcile closed the cvelistV5-only findings (tests/test_cvelistv5_index_walk.py).
What identifies a device, per source
A name is not an identifier. Factory names ("RackPDU", "Switch"), Windows defaults (DESKTOP-XXXXXXX) and userless mobile batches repeat across an estate, and every sync match chain ends on the hostname. Each chain therefore carries a discriminator — the thing that says "this name belongs to another device" — and it is chosen per source, because the wrong choice breaks the case the hostname stage exists for.
| Source | Pin | Hostname stage matches unless… | Why that discriminator |
|---|---|---|---|
| Netdisco | netdisco_serial, then netdisco_device_ip (skipped when the device reports a serial the row lacks and a different name) |
the asset carries another device's netdisco_device_ip, or any igel_unit_id |
serial survives rename and re-address (053) |
| Nessus | nessus_host_uuid |
…another nessus_host_uuid |
uuid is stable per host |
| vCenter | vmware_uuid |
…another vmware_uuid |
hardware UUID survives a vCenter rebuild |
| XenServer | xen_host_uuid |
…another xen_host_uuid |
XAPI host UUID survives a rename in XenCenter |
| IGEL | igel_unit_id, then igel_serial |
…another igel_unit_id, or any netdisco_device_ip |
unitID = mac; the serial (deviceSerialNumber) catches a board/NIC swap, but only takes an asset whose unit ID is absent from the same run — two UD Pockets on one PC share a host serial (055) |
| Intune | intune_device_id + intune_serial |
the asset carries another intune_serial |
the device id is an enrolment fact and changes on re-enrol; the serial is the hardware (054) |
| Wazuh | wazuh_agent_id, unless the id was reused (below) |
the name is held by an agent this same run reports | authd <force> replaces a same-named agent, so a re-registered host returns under a new id and the old one is gone from the list |
| Defender | defender_machine_id (fill-only), aad_device_id |
— no pin guard, see below | no serial in the machines API, and several machine records legitimately map to one asset |
Three rules hold everywhere: an asset with no pin of that source stays
matchable (that is the wanted consolidation — Wazuh creates the host, Netdisco
finds the same device by name); a device that matches nothing gets its own
asset rather than none; and when the name stage leaves more than one
candidate, the address the source reports picks one — if it does not,
nothing matches (asset_matching.pick_one, Defender and Wazuh included). The
name is compared literally (_ and % are escaped) but case-insensitively,
in every source: Intune listing "PC-LAB01" and the Wazuh agent reporting
"pc-lab01" is one device (Wazuh compared exactly until 2026-10-12 and forked
such hosts; the other direction always worked). Two Wazuh agents that differ
only in case and are both in the run stay two assets — the live-agent rule
above, unchanged. Neither hostname nor address blocks creating an asset by
hand. A Wazuh cluster node never takes an asset an agent holds. IGEL and
Netdisco never consolidate with each other: a thin client is not a network
device, so a name or address shared across the two is two devices
(asset_matching._not_pinned_by, field report 2026-09-15).
A reused Wazuh agent id is not a pin. Agents that re-enroll on a fresh
Wazuh server (5.0 is a new deployment, see "Wazuh 5.0") get ids from 001
again, and the new 001 is a different host than the old 001.
asset_lifecycle.unlink_reused_wazuh_id drops the id from the asset when
both of these hold:
- the asset's name and the agent's
nameare different hosts (asset_matching.different_hosts: case, FQDN against short name, and a nameless side do not count as different), and - the agent's
dateAddis later than the registration we know: thewazuh_registered_atstored by earlier syncs (066), or on a row synced before 066, itswazuh_last_seen. No registration of the same agent can come after a keepalive of that agent.
Either condition alone keeps the asset. A different name with the same
registration is a rename by another source (Intune renames the asset, the
agent keeps its enrolment name). The same name with a newer registration is a
migrated identity, or a host that re-enrolled and happened to get its own
id back. The stored date matters because the usual migration runs both
servers in parallel: host B enrolls on the new server and gets 001 while host
A, the old 001, still checks in with the old one, so A's last keepalive is
later than B's dateAdd. Only the stored date catches that. The fallback
catches a cutover where the old server was switched off first. The asset sync
and the Wazuh vulnerability sync both apply the rule (the latter skips the
agent until the next asset sync places it). An unlink writes an
ASSET_UPDATED audit entry "Wazuh agent id … reused by another host" and a
WARNING, and is counted as wazuh_id_reused in the asset sync stats. The
agent then goes through the hostname stage like any new agent. The old asset
keeps its history and findings, ages out on last_seen, and is re-adopted by
name when its own host re-enrolls. Not covered: a reused id whose new host
has the same name as the old one, and a manual rename that happens to match
the new host. A name match on a reused id is treated as the same host
(tests: tests/test_wazuh_reused_agent_id.py).
Renames: Netdisco, vCenter, XenServer, IGEL and Intune adopt the name the source reports
when the asset was matched on a pin. That goes through
asset_lifecycle.rename, which logs it (INFO) and writes an ASSET_UPDATED
audit entry "Asset renamed by sync: old → new" (old_value = old
name). Wazuh, Nessus, Defender and the container imports never rename.
Creates: every sync that adds an asset (Wazuh agents and manager nodes,
Nessus, vCenter, XenServer, IGEL, Intune, Netdisco, the container imports) calls
asset_lifecycle.created, one ASSET_CREATED audit entry per asset: "Asset
created by sync: ", the same event a manual create writes.
Defender never creates.
Defender is the deliberate exception. A re-imaged device keeps its old
machine entry, so Defender reports two machine ids for one host with the same
computerDnsName — and the CVE reconcile is built for exactly that
(seen_by_asset: a finding may only close once every machine behind the
asset has been asked). The machines API carries no serial, so nothing separates
"re-imaged" from "two hosts, one name"; guarding on the machine id would send
the live machine's findings to unmatched and a re-imaged device would stop
being scanned. The discriminator is aadDeviceId instead, applied after
matching (_drop_old_enrolments): machines behind one asset that report
different Entra device ids are different installs, and only the one with the
newest lastSeen counts — the old install's CVEs describe a build that is
gone. Machines sharing one Entra id (one install registered twice) or
reporting none stay in the union. defender_machine_id is re-pinned when it
points at a machine that was dropped or is no longer listed (field report
2026-10-19).
Re-imaged under a new name. Intune keeps the old record when nobody
deletes it, so one serial shows up under two names. _dedupe_reenrolled keys
on the serial (the name only when there is none) and keeps the newest
lastSyncDateTime — the device's own MDM check-in, independent of who logs
on. The asset then carries the live record's device id, Entra id and name; the
old record is not processed at all. Before this, both records reached the
serial stage and renamed the asset back and forth every sync.
Case forks from before 2026-10-12 are not merged automatically. The
lowercase Wazuh row holds the agent id, so the id stage keeps finding it.
Delete that row (Assets → delete; its findings go with it); the next Wazuh
sync adopts the existing asset by name and the next vulnerability sync puts
the agent's findings there, as OPEN — triage done on the deleted row's
findings (accepted risk, false positive, assignment) does not carry over, so
heal early. Candidates:
SELECT lower(hostname), array_agg(id || ':' || hostname || ':' || source::text) FROM assets GROUP BY lower(hostname) HAVING count(DISTINCT hostname) > 1;
What is NOT healed by any of this: rows that merged before these rules
existed. Each source keeps its own pin, so both syncs still find the shared
row. tools/find_merged_assets.py lists the candidates (status flip-flop in
the audit log, plus every row carrying more than one pin); the repair is to
NULL the pin of the source that does not belong there, after which that
source's next sync creates its own asset and the finding history stays with
the surviving device. Which source stays is a human decision.
A stale pin has a second effect worth knowing: reconcile_<source>_by_seen_ids
treats every row carrying that source's pin as its own, so a row holding a
pin for a device the source no longer reports is inactivated on every run —
while the source that really owns the row re-activates it. That flip-flop is
the same symptom the merge itself produced, and it outlives a manual rename.
Clear the pin, not the name.
Background jobs (GUI)
Long scans return immediately and are polled, so the browser never times out:
| Start | Status |
|---|---|
POST /api/v1/vulnerabilities/eol-check/start |
GET /api/v1/vulnerabilities/eol-check/status |
POST /api/v1/vulnerabilities/override/vulnrichment/start |
GET .../vulnrichment/status/{job_id} |
POST /api/v1/vulnerabilities/msrc/refresh |
GET /api/v1/vulnerabilities/msrc/refresh/status |
Job state lives in process memory — a backend restart clears it (and aborts the
run). The synchronous POST /eol-check remains for API clients.
Settings (encrypted at rest)
| Key | Purpose |
|---|---|
github_pat |
Optional GitHub token. Lifts GHSA and the MFSA/repo-advisory fetches from 60 to 5000 req/h. Fine-grained, no scopes needed (public data). |
UI
- Themes: light / mid (soft dark) / dark, switchable at the bottom of the
sidebar, stored in
localStorage('theme')and applied to<html data-theme>by a pre-paint script inapp/layout.tsx(no white flash). Pages use hardcoded light utilities, soglobals.cssremaps them underhtml[data-theme="mid"|"dark"]in an un-layered block — un-layered author CSS beats Tailwind's@layer utilitieswithout!important. Opacity and arbitrary-value variants (bg-gray-50/50,bg-[#F3F4F6]/95) are their own class names and need their own remap. - Audit log:
GET /audit/logstakessearch,sort,orderand setsX-Total-Count(already CORS-exposed) for real pagination.
VMware vSphere — vCenter connector, ESXi/vCenter CVEs, lifecycle
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. This closes that gap.
What it adds
| Piece | File |
|---|---|
| vSphere SOAP client (pyVmomi) | app/integrations/vcenter_client.py |
| Inventory sync → assets + EOL + CVE | app/services/vcenter_service.py |
| Release identity / build comparison | app/services/vmware_release_service.py |
| CVE matching pass | cvelistv5_scan_service.scan_asset_vmware |
| HTTP endpoints | app/routers/vcenter.py |
| Schema | Alembic 045 — AssetSource.VCENTER, assets.vmware_uuid, assets.vmware_build; 056 — assets.vmware_serial |
The sync registers the vCenter appliance itself and every ESXi host in
its inventory as assets (source=VCENTER), each with hostname, management
IP, hardware model, product version and build, and for an ESXi host its
hardware serial (serialNumber, else the SMBIOS serial / service tag). The
serial is shown in the edit dialog and the CSV export; it is not a match key.
Why the build number is the whole story
NVD carries nothing usable for current VMware advisories — CVE-2026-47876 and CVE-2026-59310 both sit there with no configuration at all. cvelistV5 has the full records, but states its bounds like this:
ESX version "9.1.x.x" lessThan "ESXi-9.1.0.0200-25557999"
ESX version "8.0" lessThan "ESXi80U3k-25595708"
vCenter version "9.0.x.x" lessThan "9.0.2.0100"
vCenter version "8.0" lessThan "8.0 U3k"
None of those is a version number, so _is_version rejected them,
_ranges_from_affected dropped the entries, and the CVEs never entered the
index. The one quantity both sides state comparably is the build: ESXi bounds
carry it inline, vCenter bounds name a release whose build comes from the
Broadcom KBs, and a live host reports its own via about.build /
config.product.build.
Two catalogs, one per product, from the two KB articles:
| Product | KB | Role |
|---|---|---|
| vCenter | 326316 | Required. A vCenter bound is a release name (8.0 U3k) and nothing else — without the table there is no build to compare. Seeded in full for 7.0/8.0/9.x so it works with no outbound access. |
| ESXi | 316595 | Not required for a verdict — every ESXi bound published since 7.0 carries its build inline (checked across 189 vSphere CVE records). Fetched anyway so a future release-name bound resolves. Not seeded: losing it costs nothing today. |
Both are re-read weekly, cached in settings, merged never replaced, and
served stale on failure — an outage can only fail to add releases.
A bound that cannot be resolved to a build produces no verdict and is
logged — never a guess. That is what keeps the 6.x-era patch-bundle bounds
(ESXi650-202005401-SG, a 9-digit YYYYMMxxx id, not a build) from
manufacturing findings.
A host with no build recorded gets no verdict either, and says so in the log. The vCenter connector always records one; a Nessus- or hand-registered ESXi asset may not, and answering the coarse question for it ("a whole update line behind") is exactly the guess the U2f case below forbids.
Update lines — why a plain build compare is not enough
A VMSA names one fix per update line, and builds are not monotonic across
them. CVE-2025-22224 states two fixes for the 8.0 line at once —
ESXi80U3d-24585383 for 8.0.3 and ESXi80U2d-24585300 for 8.0.2 — and the
host on build 24585300 is patched even though 24585383 is higher. Comparing
everything against the highest build on the line reports it vulnerable.
The other direction fails too. vCenter 8.0 Update 2f (build 25624321) shipped 2026-08-03, four days after the 8.0 U3k fix (25600417) on the older update line. Broadcom ships those async patches so customers who cannot move line still get the fix, so a build cut after the fix carries it — flagging U2f on update line alone would be a false positive.
So the decision is scoped to the update line (the third version component:
8.0.3 → line 3) and then still checked against the build:
- No bound on the host's major line → not affected.
- A bound on the host's own update line → it decides alone,
installed_build < bound_build. Precise. - Otherwise → affected iff the host is both on a lower update line and older than the newest fix on its major line.
Same shape as the SAP release/patch-level rule and the Apple _same_train
guard: a range only ever answers "patched or not" inside the host's own line.
A host with a version but no build (Nessus-discovered, hand-registered) still gets rule 3 — "a whole update line behind the fix" is knowable — and is refused rule 2, where only the build could tell 8.0 U3d from U3k.
False positives: what must never resolve
The same advisory files the suite alongside the component:
VMware Cloud Foundation (vCenter Server) versions 5.x / 4.x
vSphere Foundation versions 9.1.x.x
VMware ESXi, Workstation, and Fusion one prose blob
Those carry the suite's version numbers. Letting any of them resolve would
compare a vCenter 8.0.3 appliance against a VCF 5.x range — a fabricated
critical finding on the most important asset in the estate. The product regexes
are anchored and exact, and tests/test_vmware_vsphere.py asserts every bundle
spelling in the corpus stays out. Same on the asset side: VMware Tools, vCenter
Converter and vCenter Orchestrator never enter the vSphere pass.
EOL
endoflife.date carries both products (esxi, vcenter) keyed by major line,
which is also the honest granularity — VMware publishes one end-of-general-
support date per line, not per update. ESXi/vCenter 7.0 ended 2025-10-02
and 6.7/6.5 back in 2022-10-15; 8.0 runs to 2027-10-11, 9.0 to 2027-09-17,
9.1 to 2028-08-12. Both slugs are in _SINGLE_RELEASE_SLUGS, so upgrading off
a dead line retires the old finding instead of leaving both open.
Configuration
Settings key vcenter_config (encrypted at rest):
| Field | Meaning |
|---|---|
host, port, username, password |
vCenter FQDN + credentials |
verify_ssl |
default true; vCenter ships a self-signed cert until you replace it |
auto_create_assets |
register unknown hosts (default true) |
sync_hosts |
also walk the ESXi inventory (default true) |
The vSphere account needs read-only rights: the built-in Read-only role
(System.View / System.Read) propagated from the root folder. This client
never calls a method that changes anything.
Endpoints
| Method | Path |
|---|---|
POST |
/api/v1/integrations/vcenter/test |
POST |
/api/v1/integrations/vcenter/sync (202, poll below) |
GET |
/api/v1/integrations/vcenter/sync/status |
POST |
/api/v1/integrations/vcenter/catalog/refresh |
Nightly job vcenter_sync_nightly runs at 02:20 — after the Intune
sync, before the app-CVE scan (03:00 chain), because the inventory has to exist
before anything scans it. It refreshes the build catalog first.
Asset matching + lifecycle
Hardware UUID first, hostname second, management IP third. UUID first because an ESXi host gets renamed far more often than its mainboard is swapped, and a rename must not fork the asset — the finding history hangs off it.
A host vCenter reports as DISCONNECTED still appears in the inventory, so it counts as seen and keeps its findings: a disconnected hypervisor did not patch itself, and dropping its CVEs when the management link breaks would hide the risk exactly when nobody can act on it (same reasoning as the six-week grace window for offline Wazuh agents). Only a host vCenter no longer knows at all is reconciled to INACTIVE.
Known scope
- Structured bounds only, i.e. ESXi/vCenter 7.0 and newer. The 6.x-era records state everything as prose in one field ("6.5 before ESXi650-202005401-SG", "vCenter Server 5.5.x, 6.0.x, 6.5.x") and name a patch bundle rather than an update line, so a per-host verdict would be a guess. Those lines have been end-of-life since 2022 and get a CRITICAL EOL finding instead, which is the actionable message anyway.
- A host needs a build. No build → no CVE verdict (EOL still applies).
- Findings are enriched (EPSS/KEV) and mailed through the same path as every other scan source, so a critical hypervisor CVE notifies like any other.
Verification
# 1. Migrate
docker compose exec backend alembic upgrade head # expect 045
# 2. Configure vcenter_config in the Settings UI, then Test connection.
# Expect: {"ok":true,"vcenter":{...,"build":"25600417"},"host_count":N}
# 3. First sync
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/integrations/vcenter/sync
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/integrations/vcenter/sync/status
# → {hosts, assets_matched, assets_created, disconnected_hosts,
# eol_findings, cve_findings, assets_inactivated, assets_reactivated}
# 4. Offline check of the matching rules (no vCenter needed)
python tests/test_vmware_vsphere.py
python -m app.services.vmware_release_service
XenServer — pool coordinator, pending security updates as CVEs
XenServer is a hypervisor like ESXi, with the same coverage gap: no agent, no MDM, and Nessus only with credentials. Unlike vSphere, no public CVE source can decide a XenServer host, so the verdict comes from the pool itself.
What it adds
| Piece | File |
|---|---|
XAPI client (XML-RPC + GET /updates) |
app/integrations/xenserver_client.py |
| Pool sync → assets + CVE verdict | app/services/xenserver_service.py |
| HTTP endpoints | app/routers/xenserver.py |
| Schema | Alembic 064 — AssetSource.XENSERVER, assets.xen_host_uuid |
One connection per pool, to the pool coordinator: it answers for every
host, and a pool member refuses the login with HOST_IS_SLAVE (the error
names the coordinator). The client follows that name and logs in there, on
every connect, so the configured address may be any pool host and a
coordinator switch (HA failover, pool-designate-new-master) needs no edit.
Once only: a second HOST_IS_SLAVE means the pool is mid-transition and the
next sync retries. Several pool hosts may be configured, comma-separated, for
the case that the configured host is the one that died. 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, and failed logins count towards a lockout. The
connection test lists the entries it skipped. With
"Verify SSL" on, a member names the coordinator by IP while the certificate
carries names: the client looks the IP up in reverse DNS and connects by that
name, but only if the name resolves back to the same IP. No PTR record, or one
pointing elsewhere, fails that entry (nothing is sent to the bare IP) and the
next configured host is tried; listing the coordinators by name works without
PTR. Trusting the IP because the member's own certificate was valid would send
the credentials over an unverified connection, so there is no such fallback. Each host becomes an asset (source=XENSERVER) with
its name label, management address and product version ("8.4"); the Xen
version (4.17.7-2) goes into the description and the finding.
Why the public sources cannot decide it (checked 2026-10-23)
| Source | What it states | Why it cannot decide a host |
|---|---|---|
| cvelistV5 | vendor "Xen", version "consult Xen advisory XSA-517", status unknown |
no version at all |
| XSA / NVD | upstream Xen versions | XenServer backports into its own builds (xen-hypervisor-4.17.7-4.xs8); an upstream range would be the "everything below X" match |
| Citrix bulletin (CTX697170) | CVEs + product line | no fixed version: "update to the latest version from your channel" |
software_version |
product_version_text_short: 8.4, build_number: stream |
identical on every host, patched or not |
The verdict: an update fixing the CVE is pending on the host
GET https://<coordinator>/updates lists per host the ids of the updates the
pool has synced and the host has not applied, plus the update records. A
SecurityFix record names its CVEs (real 8.4 payload, verbatim):
"id": "XENSERVER-2026-HCP-2526", "type": "SecurityFix", "severity": "High",
"URL": "https://support.citrix.com/…?articleNumber=CTX697170",
"description": "This update contains security fixes for the following issues:\r\n
* CVE-2026-79610\r\n* CVE-2026-79611\r\n* CVE-2026-102700\r\n\r\n
For more information, see the related Security Bulletin CTX697170.\r\n\r\n
This update contains upstream code changes for:\r\n* CVE-2026-79609"
- A host has a CVE exactly while an update fixing it is pending on that host. Applying it removes it from the host's list and the next sync closes the finding. Every detection has its own way to close.
- Only the bullets under "security fixes for the following issues" count, up
to the first blank line. "Upstream code changes for" (CVE-2026-79609 above)
names code XenServer carries without calling it a flaw in its product, and
is not read. Updates of any other type (Improvement, Bugfix, Foundational)
never make findings. A line in the list that is not exactly one
* CVE-…bullet makes the whole update unreadable, never a shorter list: the CVEs after it would be dropped, and closed if open. - Installed is not running.
/updatesdrops an update as soon as its RPMs are installed, but the old Xen runs until the host reboots (the security updates' guidance isRebootHost). While XAPI listsreboot_hostorrestart_toolstackin the host'spending_guidances(mandatory) orpending_guidances_recommended, findings are written but not closed (hosts_awaiting_reboot).pending_guidances_fullis not read: it also carries the reboot a Foundational update asks for, which fixes no CVE. - Finding: package
XenServer, version8.4 (Xen 4.17.7-2), fixed version = the update id, the CTX bulletin as reference, the update severity as the vendor rating (XenServer: High). It is never the row severity: it rates the bundle, and CTX697170 puts a guest-only crash next to host compromise. CVSS/EPSS/KEV, and so the severity, come from the normal enrichment. - Source
xenserver, notapp-scan: a Dom0 running a Wazuh agent has a package inventory, and the app scan's reconcile closes every app-scan finding it did not re-detect itself. Closing retracts onlyxenserver(cross-source contract); a CVE Nessus also reports stays open. Every open finding carryingxenserveris retracted, whatever product name it was filed under, because Nessus may have written the row first.
What it cannot see, and says so
- Freshness = the pool's last sync with its update channel. A fix
published after that sync is not in
/updates. The pool'slast_update_syncis read; older than 8 days, or never synced, the run carries an error (amber in Sync Health, no mail). The findings it did write are still true. - No verdict on an incomplete list. A host whose pending list names an update id the payload does not describe, or that has a SecurityFix pending whose CVEs cannot be parsed (a format change), gets nothing written and nothing closed, plus an error naming the update. The same goes for a host the coordinator returns no update data for. Closing on a gap would be a silent false negative.
- XenServer 8.4 and 9 only.
/updatesbelongs to the repository-based update model; Citrix Hypervisor 8.2 CU1 answers 404 (and has been end of life since 2025, without security bulletins). - One pool per configuration. The lifecycle reconcile treats every XenServer-known asset as part of the configured pool.
Configuration
Settings key xenserver_config (encrypted at rest): host (one or
more pool hosts, comma-separated; a member hands over to the coordinator),
port (443), username, password, verify_ssl (default
true; hosts ship a self-signed certificate until replaced),
auto_create_assets. Verified with root on 8.4. Whether a XAPI RBAC
read-only user may read /updates is not verified yet. Everything the
client calls is read-only, so try that role first.
Endpoints + schedule
| Method | Path |
|---|---|
POST |
/api/v1/integrations/xenserver/test |
POST |
/api/v1/integrations/xenserver/sync (202, poll below) |
GET |
/api/v1/integrations/xenserver/sync/status |
Nightly xenserver_sync_nightly at 02:55. It reads no index another job
refreshes, only the coordinator's own data, and enriches/notifies inline.
Asset matching: host UUID, then hostname (exact, short, short.%), then
address, the vCenter order. Neither name nor address takes an asset that
carries a vCenter, IGEL or Netdisco pin. A host that is down stays in the
pool's host list and keeps its findings; only a host removed from the pool is
reconciled to INACTIVE. One host failing during the sync is rolled back on its
own, the rest of the pool still syncs, and the lifecycle reconcile is skipped
for that run.
Verification
docker compose exec backend alembic upgrade head # expect 064
# Configure xenserver_config in Settings → Integrations, then Test:
# "OK — pool <name>, 5 hosts, 8 updates pending, last channel sync …"
python tests/test_xenserver.py # offline, real 8.4 /updates payload
Sync run ledger — what counts as a failed run
Every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL, Netdisco, XenServer; button or
scheduler) is one sync_runs row, written by record_sync_run() in
app/services/sync_run_service.py from its own session. The Scan Jobs page
shows them as Sync Health (per source) and Sync Runs (the ledger); the
dashboard shows a banner while a source is failed or stale; a failed run mails
the sync-alert recipients once per source per 24h.
Failed vs. completed with errors
A run that ended without an exception can still be a failure. The rules live
in fail_if_source_broken():
| Situation | Status | |
|---|---|---|
| A whole phase raised — the Wazuh asset sync could not log in, whatever the vulnerability phase did | failed |
yes |
| Every agent failed (0 synced, N errors) | failed |
yes |
A source-level error among the agents: login refused, 5xx, connection refused/reset, DNS, SSL — even with 40 agents through |
failed |
yes |
| Other per-agent errors (an agent Wazuh no longer knows, one timeout) | completed, amber N ERR in Sync Health |
no |
No agent returned a single CVE (outage from reconcile_empty_agents()) |
failed |
yes |
Why the first rule exists: the Wazuh manager API (:55000) and the indexer
(:9200) are two services. The asset sync logs in to the API; the per-agent
vulnerability sync reads the indexer. The API refused every login for a day
while 64 agents "synced" fine every hour, and the run showed
COMPLETED · 1 ERR with the error folded into a list. Nobody was mailed until
a manual sync hit the same 500.
SOURCE_FAILURE_RE is the classifier. It is deliberately about the
source: authentication, Server error '5xx', connection/DNS/SSL, "Wazuh
sync failed", missing config. A single agent's timeout is not in it.
Nessus follows the same rules, with scans in place of agents: the
scheduler and POST /nessus/sync call fail_if_source_broken(run, scans_processed, errors), and scans_processed counts only the scans that were
actually read. Until 2026-10 neither did. An expired licence (Nessus 403 on /scans/157: {"error":"Expired license."} on every scan) showed as
COMPLETED · 8 ERR with an amber card and no mail, while not one scan had been
imported. One unreadable scan among readable ones stays completed with errors.
The other half of the same source: a wrong INDEXER password
The rules above only see errors that reach a caller — and the indexer read
did not produce one. query_vulnerabilities_from_indexer() logged the error
and returned [], so with only the indexer password wrong (the manager API
still answering, so no phase raised) every agent looked like a host with no
CVEs: agents_synced += 1, "deferring backfill", and at the end "every agent
returned 0 CVEs — treating as an outage" as a WARNING in the container log.
The ledger said completed, Sync Health stayed green, the sync dialog said
Synchronization Complete, no mail — and nothing had been synced.
Three things follow from it, 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
WazuhAPIErrorinstead of returning[]. Every caller already catches per agent (the scheduler,_autoscan,rescan_asset,verify_patch_with_rescan— that last one used to mark the CVE patched when a 401 gave it an empty list). - A refused agent is neither synced nor empty. In
_run_wazuh_vulnerability_sync()it becomes a FAILED per-asset scan row and an entry inerrors; it never reachesempty_agents. Otherwise one agent answering would letreconcile_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 and the button.
run_wazuh_vulnerability_sync()callsfail_if_source_broken()(it never did — only/scans/autoscanand the scheduler did) and raises502when the run failed, so the dialog goes red instead of "Complete".
reconcile_empty_agents() also writes run_stats["outage"] when it takes
the "nobody returned anything" branch; every caller turns that into a failed
run. That is the backstop for an indexer that answers 200 with nothing —
a wrong index name, a cluster still coming up — where no error is raised at
all.
Jobs that are not a sync but log in to Wazuh anyway
The nightly app CVE scan and EOL check (both in the 03:00 chain) and
the SCA refresh (02:00) read the manager API per asset. Each calls
probe_wazuh_api() once up front: a refused login is recorded as a failed
Wazuh run (phase = app-scan packages / sca-refresh / eol-check) and
mailed under the same cooldown, instead of one warning per asset and a job
that "completed". A login that works writes nothing — a probe is not a sync
and must not count as a success. The API dying mid-run is caught the same
way afterwards (source_failure() over the fetch errors,
report_source_failure()).
The app scan goes on without Wazuh inventory when the probe fails: every Wazuh asset then reports an empty inventory and keeps its findings (the auto-resolve guard), Intune-only assets are scanned as usual.
Verification
venv/bin/python -m pytest tests/test_sync_runs.py -q
The scheduler-seam test there (test_scheduled_scan_with_dead_manager_api_is_failed_and_mailed)
replays the incident: asset sync refuses to log in, two agents go through the
indexer, the run must be failed with agents_synced=2 and one mail.
The indexer-side incident is replayed by three more: every agent refused
(failed, agents_synced=0, 502 at the button, one mail), one agent
refused while another answers (the refused host keeps its findings), and an
indexer that answers nothing for everybody (failed via outage).
Netdisco — switch/router inventory, Aruba + Cisco + Extreme firmware CVEs
A switch runs no agent. Wazuh cannot reach it, no MDM enrols it, and a Nessus scan without SNMP credentials sees an open SSH port and no version — so the devices every other asset is plugged into had no vulnerability coverage at all. Netdisco already asks each of them over SNMP and records the firmware; this turns that inventory into assets and findings.
What it adds
| Piece | File |
|---|---|
Netdisco REST client (/api/v1) |
app/integrations/netdisco_client.py |
| Inventory sync → assets + CVE pass | app/services/netdisco_service.py |
| Aruba family / version / branch logic | cvelistv5_scan_service.aruba_* |
| Cisco family / version logic | cvelistv5_scan_service.cisco_* |
| Extreme family / version logic | cvelistv5_scan_service.extreme_* |
| NetScaler build / FIPS-line logic | cvelistv5_scan_service.netscaler_* |
| LoadMaster release-line logic | cvelistv5_scan_service.loadmaster_* |
| Sophos hotfix / advisory table | cvelistv5_scan_service.sophos_*, _SFOS_ADVISORIES |
| QNAP QTS / QuTS hero version, floor and bound readers | cvelistv5_scan_service.qnap_* |
| CVE matching pass (CNA records) | cvelistv5_scan_service.scan_asset_aruba / …_cisco / …_extreme / …_netscaler / …_loadmaster / …_sophos / …_qnap |
| CVE matching pass (NVD CPE) | app_cve_scanner_service._OS_REGISTRY |
| HTTP endpoints | app/routers/netdisco.py |
| Schema | Alembic 052 — AssetSource.NETDISCO, assets.netdisco_device_ip; 053 — assets.netdisco_serial |
The sync registers every device Netdisco has discovered as an asset
(source=NETDISCO) with hostname, canonical IP, location, model, serial and
firmware version. CVEs are detected for six firmware lines — the three HPE
Aruba ones, Cisco IOS XE / IOS XR and Extreme Networks EXOS; everything else
gets the asset and no CVE verdict (see Known scope).
Cisco IOS XE / IOS XR: exact, not compared
This is the only matching mode in the codebase that does not compare two versions, and that is not caution layered on top of the data — it is the data. Cisco publishes no ranges for these products. Both sources enumerate the affected releases one at a time:
| Source | CVE-2026-20267 (IOS XE hardening, Aug 2026) |
|---|---|
| NVD | 267 cpeMatch entries, exact cpe:2.3:o:cisco:ios_xe:17.15.4c, no versionStart*/versionEnd* anywhere |
| cvelistV5 | 268 bare version entries, defaultStatus: unknown |
So "affected" is set membership. Comparing would be strictly worse, because
the numbers are not the release: Cisco ships 17.15.4, 17.15.4a, 17.15.4b,
17.15.4c and 17.15.4d as five separate releases and a given record names
some and not others — but _vtuple reads all five as (17,15,4).
Two changes in the shared matcher follow from that, and the split between them is deliberate:
_in_range's exact-version branch now requires_vsuffix(installed) == _vsuffix(bound)on top of numeric equality. This is a tightening and applies to every product — it can only ever reject, so it cannot invent a finding. 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_versionstays dotted-numeric-only; the two Cisco registry entries carry"lettered": Trueand get_lettered_versioninstead. That rule has thirteen call sites across six services and every one of them compares ranges, where_vtupledrops the letters — letting1.1.1wthrough there would hand a letter-blind comparison a version whose letter is the release (OpenSSL:1.1.1a…1.1.1wall read as(1,1,1)). Only the exact path can honour a suffix, so only the exact path gets one.
_lettered_version is also where the canonical spelling is decided —
lowercased, zero-padding dropped — and cvelistv5_scan_service.cisco_version
delegates to it, so the two halves cannot drift into spelling the same release
differently ("17.09.05A" and "17.9.5a" are one build; only the second
exists in either source).
Three consequences worth stating outright:
- No fix version is reported. A Cisco record lists what is broken and says
nothing about what is not, and neither source carries the "fixed in 17.15.6"
that the advisory page states. The finding still has its path to close: a
device upgraded to a release the record does not enumerate stops matching,
and
_resolve_stale_appliancecloses it on the next pass. - An unlisted release is not flagged.
defaultStatusisunknown, notunaffected— a release Cisco did not list is a release Cisco did not judge, and this reads that as "no verdict". A brand-new rebuild is therefore under-reported until the record is updated. Flagging it instead would mean flagging every device forever, which is not a verdict. NVD's own CPE data makes the same choice. - Both sources genuinely earn their place, the same as for Aruba. Verified
08.09.2026: the IOS XE batch (CVE-2026-20267…20273) was fully enriched at
NVD, while the IOS XR batch of the following month (CVE-2026-20274…20280) was
still
Awaiting Analysiswith zero CPE entries and existed structurally only in Cisco's own CNA records. Conversely CVE-2019-12660 states its affected release in the product name ("Cisco IOS XE Software 3.2.11aSG", version"unspecified"), so it exists only on the NVD half — where it turned out to be a permanent false positive, see below. - A floor with no ceiling is not a verdict. CVE-2019-12660's entire NVD
configuration is one entry:
cisco:ios_xewithversionStartIncluding 16.1.1and no end bound at all. Read literally that is every IOS XE release Cisco will ever ship; Cisco fixed it in 2019 ("at the time of publication ... 16.x and later") and the 17.15 train did not exist until 2024. Every router in an estate carried it with "fixed in: n/a", run after run, with no way to patch out of it._in_rangenow drops an entry that has a start bound and no end bound — per entry, so a record carrying a bounded entry too still reports through that one. This is the rule_ranges_from_affectedandvmware_entriesalready applied; the NVD half was the outlier. - Where Cisco enumerates, Cisco's list wins over an NVD range. NVD does
not always copy the list: 89 of its 560 IOS XE CVEs carry ranges, and
CVE-2025-20363's whole IOS XE configuration is one envelope,
3.2.0sg .. 17.17.1inclusive — Cisco's first and last listed release with every train in between. Cisco's own list stops 17.15 at 17.15.3, and 17.15.6 is the fix, yet it sits inside the envelope. Measured 12.10.2026 against cvelistV5: on every one of those CVEs where Cisco also enumerates IOS XE, the range adds only releases Cisco did not list (CVE-2025-20196: 102). On most of the 89 Cisco enumerates nothing and the range is the only data, so ranges are not dropped wholesale:scan_asset_osskips an NVD hit only whencvelistv5_scan_service.cisco_unlistedsays Cisco's record for that family exists and leaves this release out. Without a loaded cvelistV5 index (a Netdisco sync before the first nightly build) the NVD half decides alone, as before.
The product regexes are anchored, and here that matters more than anywhere
else in the registry: 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), Cisco IOS XR Software (13),
Cisco IOS XE Software (456), Cisco Adaptive Security Appliance (ASA) Software (220) and Cisco Firepower Threat Defense Software (94). Matched
loosely, a router collects an ASA's release list.
Extreme EXOS: floored ranges, and no branch guard
Extreme is the plain one of the three, and the only reason it needs a pass of its own is the reconcile (a switch has no software inventory, so the app scan's own reconcile never reaches it). The versions are dotted numbers and the CNA writes proper ranges — CVE-2026-8169 states four on one record:
version |
lessThan |
|---|---|
0 |
31.7.4 |
32.0.0 |
32.7.4.15 |
33.0.0 |
33.1.100 |
33.2.0 |
33.7.1 |
defaultStatus is unaffected, so unlike Cisco's records a release outside
all four is one Extreme judged and cleared. _ranges_from_affected reads these
as they stand, which is why there is no Extreme branch in build_product_index.
scan_asset_extreme deliberately does not apply Aruba's branch guard, and
that difference is the whole design note. HPE leaves the floor implicit and
states one range per branch, so an unfloored < 10.17.1021 there is a record
talking 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: every EXOS box older than the current train.
Two normalisations, both at the connector:
- SNMP::Info reads the version out of
sysDescr, so a row can carry the whole sentence (ExtremeXOS (X440G2-24t-10GE4) version 31.7.2.4) — the first dotted-numeric run is the release, the same rule asaruba_version(), whichextreme_version()calls rather than copies. - A patch build spells itself
22.7.1.1-patch1-11and the tag is dropped. Neither source ever states one as a bound: NVD's CPE versions and Extreme's own bounds are plain releases, and Extreme fixes these by moving to the bound's release, not by patching below it.
Both sources, and here the NVD half is the load-bearing one. Extreme's CNA only
started filing in 2025, so every EXOS CVE before that is a MITRE record
whose affected[] is {vendor: "n/a", product: "n/a", version: "n/a"} — no
structured data at all. NVD also files the same firmware under two product
names and splits the CVEs between them, so both are queried (verified live,
08.09.2026):
| CPE | Carries |
|---|---|
cpe:2.3:o:extremenetworks:extremexos |
CVE-2024-27453, CVE-2020-18305, the 2017 batch |
cpe:2.3:o:extremenetworks:exos |
CVE-2023-43118…43121 (Chalet traversal + privilege escalation) |
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.
NetScaler ADC / Gateway: a build within a line
Netdisco row: vendor citrix, os netscaler, version 14.1-72.61. The asset
becomes NetScaler ADC / 14.1-72.61, both CVE paths key off that.
| Source | Shape | Trap |
|---|---|---|
| cvelistV5 | version "14.1", lessThan "43.56" (the build within 14.1) |
the generic reader takes it as 14.1 ≤ x < 43.56 and flags every 14.1 box |
| cvelistV5 | CVE-2026-19489/-19490: lessThanOrEqual "73.32" |
bulletin CTX696939 and NVD both say before 73.32; read as lessThan |
| cvelistV5 | CVE-2026-88771..88778 (CTX697096): vendor Citrix NetScaler, version "0", lessThan "14.1-73.37" / "14.1-73.37 FIPS" |
a third vendor spelling, and the line sits in the bound; only a bare line-build bound applies to a version "0" entry |
| NVD | netscaler_application_delivery_controller + netscaler_gateway, 14.1 .. 14.1-73.32 |
sw_edition fips/ndcpp entries have their own bounds and are not filtered by virtualMatchString (a patched 73.32 got CVE-2026-19490 back); skip_editions drops them |
FIPS and NDcPP builds are separate lines the inventory cannot see. On 13.1 and
12.1 they live on build 37.x and 55.x, below every normal fix, so those builds
get no verdict (_NS_FIPS_BUILDS); a model or version naming FIPS/NDcPP gets
none either. Some records omit the FIPS line the bulletin lists, so gating on
the record is not enough. Only version equal to the line applies; NetScaler
Console files version "14.1" too and is kept out by the anchored product
pattern. The pre-2023 CPE names answer nothing for 13.1/14.1 builds.
Kemp LoadMaster: one numbering, several release lines
Netdisco row: vendor kemp, os LMOS, version
7.2.63.3.23e1735.RELEASE.20260625-1143. The asset becomes Kemp LoadMaster /
7.2.63.3; build hash and date are in neither source. Before Netdisco had a
LoadMaster device class the row read vendor Kemp, os LoadMaster; both are
accepted. The device name plays no part in the decision.
GA (7.2.55 .. 7.2.63), LTSF (7.2.54.x, earlier 7.2.48.x) and Multi Tenant (7.1.35.x) share one numbering, and every advisory names one fix per line. Both sources are right, and both are wrong when read one range at a time:
| Source | Shape (CVE-2026-59687 / CVE-2024-6658) | Trap |
|---|---|---|
| cvelistV5 | 7.0.8 < 7.2.63.3 and 7.0.8 < 7.2.54.19 |
patched LTSF 7.2.54.19 sits inside the GA range |
| cvelistV5 | CVE-2024-6658: only All Previous Versions < 7.2.60.1 |
no LTSF fix named, so every LTSF box looks affected |
| NVD | loadmaster < 7.2.54.19 / 7.2.55.0 .. < 7.2.63.3; 7.1.35 fixes under multi-tenant_loadmaster/_hypervisor |
a patched 7.1.35.13 is below <= 7.2.48.12 |
loadmaster_fix decides per line, for both sources: a range ending on the
build's own line decides alone; else a 7.1.35 build gets no verdict; else a GA
build takes any containing range; else an LTSF build takes only a range ending
on a later long-term line (EOL 7.2.48 below the 7.2.54 fix). The long-term lines
are hand-kept (_LM_LONG_TERM).
Where no record names a long-term fix, _LM_BULLETIN does: per CVE a (start,
fix) per line from the Progress release notes (release-notes_loadmaster-<build>
→ Security Updates), and a CVE with a row is decided by the row alone, through
the same rules. It covers GA-only records (CVE-2024-7591, CVE-2025-1758,
CVE-2026-3517/-3518/-3519/-4048), bounds nobody parses ("7.2.54.9 ( LoadMaster
LTSF)": CVE-2024-2448/-2449, CVE-2024-3544) and NVD entries under
a:progress:loadmaster, which the o: query does not return. A new advisory
whose record names only the GA fix needs a row; until then long-term boxes get
no verdict for it.
NVD vs. CNA on CVE-2026-8037 is not a contradiction: the CNA starts the GA range
at 7.2.60.0, NVD at 7.2.55.0, bulletin 000301124 says "GA v7.2.63.1 and all
prior versions". Findings are the union, so 7.2.55–7.2.59 come from NVD.
CVE-2026-33691 in the same bulletin is an OWASP CRS record (vendor
coreruleset) without LoadMaster and is not detected; bulletins are not
ingested. ECS Connection Manager, ObjectScale Connection Manager and MOVEit WAF
share the records and are not matched.
Sophos Firewall: hotfixes the version does not show
Netdisco row: vendor sophos, os sfos, version 22.0.1 MR-1-Build490. The
asset becomes Sophos Firewall with that string; both passes read it as
22.0.1.490 (release 22.0, MR 1, build 490). EAP builds and strings whose
GA/MR label contradicts the release are not read.
Both sources bound the MR and are right about where the fix first shipped:
| Source | CVE-2025-6704 | CVE-2022-3236 |
|---|---|---|
| NVD | firewall_firmware < 21.0.2 |
a:sophos:firewall <= 19.0.1 |
| cvelistV5 | lessThan "21.0 MR2 (21.0.2)" |
<= "18.5 MR4", <= "19.0 MR1" |
| Advisory | hotfix 19.0 MR2, 20.0 MR2/MR3, 21.0 GA/MR1, 21.5 GA; fix in 21.0 MR2 | hotfix 17.0 .. 19.0 MR1; fix in 18.5 MR5, 19.0 MR2, 19.5 GA |
Read as ranges, both flag a firewall that is patched. Sophos installs hotfixes
automatically (the default) and the firmware version stays the same, so the
hotfix is visible only on the box (system diagnostics show version-info).
NVD's single bound also ignores the per-line fix (18.5 MR5). The advisories are
the only place that names hotfixed releases, and they are HTML only (no CSAF;
the RSS feed has titles only), so they are not scraped. _SFOS_ADVISORIES
keeps, per CVE, the releases with the fix and the releases that got a hotfix,
read off every SFOS advisory from 2022 to 2025 on 16.09.2026. A source match
becomes 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.
A CVE without a row gets no verdict: the next advisory will hotfix most
releases again, and a finding on every patched firewall is worse than none.
A new SFOS advisory needs a row. A 2021+ CVE without one is named in the
run's errors (sophos: no _SFOS_ADVISORIES row for …), on the Netdisco
sync-run ledger and in the app-CVE scan stats, so it does not sit in the log
alone. The fix shown is the lowest fixed release above the box, so an 18.5 box
sees 18.5 MR5 where one exists.
NVD is queried under firewall_firmware and a:sophos:firewall only. The
other names (o:sophos:firewall, sfos, xg_firewall_firmware) hold
CVE-2022-1807, which cvelistV5 carries, and pre-2021 CVEs on 17.x/18.0 that no
row covers. A box still on those end-of-life releases gets no verdict for them.
QNAP QTS and QuTS hero: two OSes, one numbering
Netdisco rows: vendor qnap, os qts, version 5.2.5.3145; os quts hero,
version h5.2.10.3577. The assets become QNAP QTS / QNAP QuTS hero with the
version as NVD writes it (a trailing build 20250526 is dropped, the h kept). The leading h is hero's spelling and QTS never has one, so
a row whose os and h disagree gets an asset and no verdict. Both OSes share
the release numbers and the build counter, and a QTS box judged against hero
ranges (or the reverse) is a finding on the wrong host. Each has its own NVD CPE
and its own cvelistV5 key.
| Source | CVE-2025-66273 (QSA-26-10) | Shape |
|---|---|---|
| NVD | o:qnap:qts 5.2.0.2737 .. < 5.2.9.3410; o:qnap:quts_hero h5.2 / h5.3 / h6.0, each floored |
recent records floored ranges; older ones every build as an exact CPE (qts:5.0.1.2034:build_20220515), no fix named |
| cvelistV5 | QTS 5.2.0 < 5.2.9.3410 build 20260214; hero h5.2.0, h5.3.0 and ? < h6.0.0.3397 build … |
one range per branch; floors 5.2.x, 5.0.*, 5.x, 4.5.x, 4.4.x, ?, unspecified; bounds sometimes QuTS hero h5.2.0.2851 build …; 2019-20 records put < 4.5.1.1456 in version |
The ? floor is the trap. Read literally, < h6.0.0.3397 contains every patched
h5.x box. scan_asset_qnap therefore applies an entry without a floor only on
the bound's own branch (major.minor), the same rule as Aruba. The cost is a
missed branch that a record left implicit, never a verdict on the wrong branch.
NVD answers a hero virtualMatchString only with the h, so the NVD pass keeps
it; the comparison reads the numbers either way.
Both sources earn their place. Checked 23.09.2026 against the whole cvelistV5
archive and every NVD QTS / QuTS hero record: QTS 5.2.5.3145 gets 71 CVEs, 70
from both; CVE-2025-66274 is QTS only in the CNA record (NVD lists hero h5.3
builds), CVE-2025-48725 the reverse. QTS 5.2.9.3499 and QuTS hero h5.2.10.3577
are clean. CVE-2025-59382 names QTS and QuTS hero as unaffected and gives no
finding. The exact-build NVD matches carry no fix; the CNA pass supplies it.
QNAP's advisory pages (qnap.com/security-advisory/qsa-…) are rendered by
JavaScript and attach exactly the CNA records as JSON, so they are not ingested.
QuTScloud (c5.x) and the NAS apps (File Station, Qsync Central, Photo Station
…) are not in Netdisco's inventory and are not matched.
Authentication and transport
One endpoint is read: GET /api/v1/report/device/deviceinventory. Three auth
modes, all the same mechanism seen from different ends:
- API key —
Authorization: <key>(Netdisco's spelling: noBearer). What a production instance should use. - Username + password — POSTed once to
/login, which returns exactly such a key. So "key or credentials" is not two code paths. - Neither — the public demo answers the report unauthenticated. Supported so the connector can be tested before it is pointed at production.
http and https are both configurable, because Netdisco's own web
server (the Docker image included) speaks plain HTTP on port 5000 and cannot
do TLS at all; a production deployment fronts it with a reverse proxy. Hence
port 5000 + HTTP as the default, and verify_ssl as a separate switch for a
proxy carrying an internal CA's certificate.
The version is the point, and the letters are not part of it
HPE prefixes a firmware build with a two-letter code line naming the
hardware family the image is for — WC.16.11.0016 on a 2930F, YA.16.11.0027
on a 2530, PL.10.13.1005 on a CX 6300. HPE's own advisories bound the
numbers only ("KB/WC/YA/YB/YC.16.11.0015 and below"), so aruba_version()
keeps the numeric run and drops the rest. That is also what makes the version
usable at all: _clean_version rejects anything not dotted-numeric, so
WC.16.11.0016 would otherwise mean no version, which means no scan.
The raw string stays on the asset description — it is what an operator matches against HPE's download page.
Three families, and why they must never cross
| Asset OS label | Firmware | cvelistV5 product | NVD CPE |
|---|---|---|---|
ArubaOS-CX |
10.13.1005 |
AOS-CX, HPE Aruba Networking AOS-CX (also misspelt Networkign) |
cpe:2.3:o:hpe:arubaos-cx (+ arubanetworks:aos-cx, arubanetworks:aos-cx_firmware) |
ArubaOS-Switch |
16.11.0016 |
ArubaOS-Switch, ArubaOS-S Switch, ArubaOS-Switch Devices, AOS-Switch (AOS-S) |
cpe:2.3:o:hpe:arubaos-switch |
ArubaOS (Mobility) |
8.13.1.1 / 10.7.2.2 |
HPE Aruba Networking Wireless Operating System(s) (AOS) / (AOS-8 & AOS-10), ArubaOS (AOS), HPE Aruba Networking AOS, Aruba OS, ArubaOS, Aruba Mobility Conductor (formerly Mobility Master); Aruba Mobility Controllers; WLAN Gateways and SD-WAN Gateways managed by Aruba Central, ArubaOS Wi-Fi Controllers and Campus/Remote Access Points |
cpe:2.3:o:arubanetworks:arubaos (+ hp:arubaos) |
HPE renames the product between batches, and the product patterns are
anchored, so a new spelling is a silent false negative: the October 2026
AOS-S batch (HPESBNW05156, CVE-2026-76741..76749, "AOS-Switch (AOS-S)") sat
at NVD as "Awaiting Analysis" with no CPE, so neither half saw it. When an
Aruba CVE is missing, check the record's product against these patterns
first. Old hardware series lists ("Aruba CX 6200F Switch Series; …",
"Aruba Intelligent Edge Switch Series 2540, …") are deliberately not matched;
NVD covers all but six of them (2019-2021) through the CPEs above.
The guard. Every index build collects the HPE product names that look like
Aruba firmware (aos, arubaos, wireless operating system, mobility controller, …) and match no key; aruba_unmatched_note() hands them to the
Netdisco sync, which puts them on its run's errors (Sync Health: amber, no
mail), and the build logs them as a warning. Against today's corpus it is
silent: the five names it would report — four Instant/AOS-10 AP lines and the
9200/9000 series record NVD covers — are in _ARUBA_KNOWN_UNMATCHED. A name
goes there only after the record has been looked at. Limit: the list is held
in memory, so after a restart it is empty until the next 01:30 build.
A CX switch on 10.13.1005 and a Mobility controller on 10.7.2.2 are both
"10.x" and share nothing else. netdisco_service.aruba_family() therefore
requires three things to agree before it claims a family — the SNMP::Info
os slug, the vendor, and the shape of the version:
arubaos-cx+ three components → CX. SNMP::Info'sLayer3::ArubaCX.hp(that is what a ProVision switch answers) + an Aruba vendor + a 15.x/16.x major → AOS-S. The major check is what keeps a non-switch HP device, which answers the same slug with some other numbering, out.aos-w/airos+ an Aruba vendor + four components → Mobility AOS. Ubiquiti's AirOS answers the same slug; the vendor is what separates them.
Anything else gets an asset and no verdict. The asset's OS string is written as one of the three canonical labels, never as the device's own answer — a Nessus- or hand-created asset for the same box would spell it a third way.
At scan time a second guard applies: an index entry that states no floor must have the same release branch (major.minor) as the installed version. 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), so a bound read across branches would tell a 10.13 switch to install an image its hardware may not take. Entries that do carry a floor are already confined by it.
ArubaOS-Switch is stated as prose
AOS-CX and the controllers come as proper semver ranges. The switch records do
not — every branch is a sentence inside the version field:
"ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below"
"ArubaOS-Switch 16.09.xxxx: All versions."
aruba_switch_entries() parses the first shape (branch as the floor, the
number as an inclusive bound) and deliberately skips the second. The
2022-2024 Mobility controller records use the same prose ("ArubaOS
10.5.x.x: 10.5.1.0 and below") and go through it as well; a compound SD-WAN
build in the same record ("8.7.0.0-2.3.0.8 and below") is not read as a bound.
The second shape is skipped on purpose: "All versions" has no bound, so it can neither be compared nor cleared by an
upgrade inside that branch — a finding with no fix to reach is the noise this
scanner exists to avoid. The cost is under-reporting on an abandoned branch,
never a wrong verdict. The two-letter code lines in the prose are ignored;
HPE ships one build number across all of them, and the one record that splits
them states the other lines as "All versions" anyway.
Handing the prose to the shared extractor would be worse than useless: read as
digits, that sentence parses to (16,11,16,11,12). Both the index build and
the scan reject a bound that is not plain dotted-numeric.
A third shape spans all three families: the operator inside the bound and, on
the controllers, the floor as prose —
lessThanOrEqual: "<=10.6.0.2", version: "Version 10.5.0.0: 10.6.0.2 and below" (CVE-2024-42501..42507, CVE-2025-25042, -27080; none has an NVD CPE).
aruba_affected() strips the operator and reads the floor before any Aruba
entry is built. It is Aruba-only on purpose — _clean_bound serves every
vendor — and a floor it cannot read becomes no floor, which the branch guard
then confines to the bound's own branch.
Configuration
netdisco_config (encrypted at rest, secrets redacted on GET):
{"host":"netdisco.local","port":5000,"use_https":false,"verify_ssl":true,
"api_key":"…","username":"","password":"","auto_create_assets":true}
Only host is required. Settings card: Netdisco (switches & routers).
Endpoints
POST /api/v1/integrations/netdisco/test admin — reachability + auth
POST /api/v1/integrations/netdisco/sync editor — 202, runs in a thread
GET /api/v1/integrations/netdisco/sync/status editor — poll target
Nightly at 02:40 (netdisco_sync_nightly), after the IGEL sync and
before the app-CVE scan (03:00 chain); the cvelistV5 index it reads is rebuilt at
01:30. The app-CVE scan runs scan_asset_aruba, …_cisco and …_extreme over
every asset too, so a device also known to another source is not left to the
connector alone.
Asset matching + lifecycle
Pinned on the serial number (assets.netdisco_serial), then Netdisco's
canonical device IP (assets.netdisco_device_ip), then hostname, then IP.
The address came first originally, and for a real reason — it is the opposite
of the IGEL rule because a switch's management address is static and its NAME
is what changes, while a thin client holds a DHCP lease that can be recycled.
But it is only ever a stand-in for the identity, and not every Netdisco device
is a switch: field report 2026-09-10, a UniFi AP updated its firmware, took a
new DHCP lease with it, and forked a second asset — same name, same serial —
while the reconcile put the old row INACTIVE. The hostname fallback could not
rescue it either, because that stage is pinned on netdisco_device_ip too, so
the asset holding the OLD address was filtered out of exactly the lookup meant
to catch it.
The serial survives both a rename and a re-address, and Netdisco reports it per
device. It is pinned once and then never overwritten: stack members
(VSF/IRF) report one name and one address per chassis but a serial EACH and are
one logical device, so the asset keeps the first serial it saw and the other
members go on matching on the shared address. Placeholder serials ("N/A",
all-zeros, anything under four characters) identify nothing and are dropped
before the lookup — matching on one would merge every device that gives the
same non-answer onto a single asset, which is the wrong-host finding and worse
than the fork. See asset_matching.stable_serial (shared with the Intune chain).
No backfill: the first sync after 053 matches every device on its unchanged
address and stamps the serial then. Assets that forked before the fix are
not merged — findings and audit trail hang off both rows, so that stays a
manual decision.
The address pin outlives its device (an AP that moved, a pre-053 fork, a switch
removed from Netdisco), and the next device to get that address used to take
the row — decommissioned rows included. The netdisco-ip stage now passes over
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. A serial-less device
on a stale pin cannot be told apart and still takes the row; delete such rows.
Rows already holding two devices are not split — delete them and let both
syncs recreate their own.
reconcile_netdisco_by_seen_ids inactivates only devices Netdisco no longer
lists at all — an unreachable switch stays in Netdisco's inventory, so a
reboot or a maintenance window never reaches the reconcile. Fail-open on an
empty sync.
Known scope
- HPE Aruba, Cisco IOS XE / IOS XR and Extreme EXOS only. Cumulus, NX-OS and the rest are inventoried without a CVE verdict; each needs its own curated matching, and a generic version comparison across vendors is how a scanner produces a page of wrong findings.
- Extreme's Fabric Engine (VOSS) is not covered, and the reason is
inventory, not data. Both sources carry its CVEs (CVE-2025-11192 has a CPE
and a CNA range), but SNMP::Info has no VOSS class: a VSP is classed as
Layer3::Passportand answers ospassport, vendoravaya— precisely what an Avaya/Nortel ERS switch answers, whose 5.9.x firmware is not Fabric Engine at all. Nothing else on the row separates them, andbefore 9.3handed to an ERS numbered 5.x matches every one of them. The day a row can be told apart, the key is a few lines. ExtremeWare (the 2010-era firmware, os slugextremeware) is out for the ordinary reason: a different product line whose NVD bounds stop at 11.1. - Classic Cisco IOS is not covered. It numbers releases
15.2(7)E3and12.2(33)CY2, which NVD escapes into the CPE as15.2\(7\)e3and no version scheme here reads. A classic-IOS device gets the asset and no verdict; its OS lands as"cisco ios", which matches neither the anchored Cisco entries nor Apple'siOSone. - No vendor bulletins. HPE's own (hpesbnw*) and Cisco's Security Advisories
carry the same data the CNA records already state structurally, so a scraper
would add an opinion and no CVE. Cisco's advisories do carry one thing the
CVE records do not — the fixed release — but scraping
sec.cloudapps.cisco.comfor it is a third source with its own failure modes; add it when the missing fix version is the thing actually blocking someone, not before. - No EOL/EOM findings. End-of-maintenance is the finding that matters most for a switch — a firmware line that no longer gets security fixes cannot be patched out of anything — but HPE publishes those dates per hardware product in a support portal, and endoflife.date carries no ArubaOS product at all. Nothing is claimed until a source that states EOM per OS release is found; an invented date would retire real assets on a guess.
- A device without a version gets an asset and no verdict (counted as
no_versionin the sync stats — "could not look" must not read as "clean"). - Instant APs share the Mobility version line. An Aruba Instant AP answers
the same os slug with the same four-component 8.x/10.x numbering as a
controller, so it is read as
ArubaOS— which is broadly right (HPE ships one 8.x codebase and its advisories name both), but a controller-only advisory can reach an AP this way. Split it out when a record is found that bounds them differently; NVD keeps a separatearubanetworks:instantCPE for that day.
Verification
# 1. Migrate — entrypoint.sh already runs `alembic upgrade head` on every
# container start, so this only CHECKS. Running upgrade again right after
# `up -d` races that start-up run and fails with "expected to match one row
# when updating '050' to '051'" (the migration still applied, in the other
# process).
docker compose exec backend alembic current # expect 052 (head)
# 2. Configure netdisco_config in the Settings UI, then Test connection.
# Expect: {"ok":true,"device_count":N,"fw_count":M,"auth":"api-key"}
# 3. First sync
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/integrations/netdisco/sync
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/v1/integrations/netdisco/sync/status
# → {devices, assets_matched, assets_created, fw_devices, no_version,
# cve_findings, assets_inactivated, assets_reactivated}
# fw_devices counts the devices on a firmware line we can decide (Aruba +
# Cisco IOS XE/XR), i.e. the ones that can produce findings at all.
# 4. Offline check of the matching rules (no Netdisco needed)
venv/bin/python tests/test_aruba_firmware.py
venv/bin/python tests/test_cisco_firmware.py
venv/bin/python tests/test_netdisco_inventory.py
Container images — Trivy / Grype push
POST /api/v1/vulnerabilities/sync/container (editor) takes ONE image report,
Trivy --format json or Grype -o json, detected by shape. Query:
min_severity (default high), only_fixed (default true). A report that
is not an image scan is rejected with 422. tools/container_scan_push.sh runs
on the Docker host: scans every running image and pushes the reports.
- One asset per image, source
CONTAINER(Alembic 059), hostname = the normalisedrepo:tag(docker.io/library/nginx→nginx:latest). All containers of one image share it, so a CVE counts once per image. The digest lands in the description; a rebuilt tag stays the same asset, so the rescan closes what the rebuild fixed. - Sources are
trivy/grype. Both scanners merge on(cve_id, asset_id)and each retracts only its own source. - Aliases: a GHSA / distro advisory is stored under the CVE(s) the report
itself names (Trivy
VendorIDs, GryperelatedVulnerabilities). No lookup; an advisory naming no CVE keeps its own id. - Baseline: the first report of an image repository sets
vulnerabilities.baseline(059) and sends no new-finding mail; the nightly digest skips those rows too. A new tag of a known repository is not a baseline, so an upgrade that brings a CVE in does alert. Notnotification_suppressed: that is the operator's mute and would also silence SLA mails. KEV flips are covered bykev_alert_service. - KEV bypasses the filter: a CVE on any known-exploited catalog (CISA KEV,
ENISA EUVD, VulnCheck KEV via
kev_service.get_kev_catalog) is imported even belowmin_severityor without a fix (kev_kept). Enrichment setskev_listed→ priority bonus and the KEV alert mail; a catalog that lists a CVE later reaches existing findings through the periodic re-enrichment. - Filter vs. retraction: the filter decides what is imported; retraction
uses the whole report. A CVE hidden by the filter is still reported and never
closes. That is why the push script does not pass
--severity/--ignore-unfixedor ignore files to the scanner. A report with no findings at all retracts nothing (retraction_skipped), as in the Nessus sync. - Closing: absent from the scanner's report → source dropped → patched when no source is left. An image that is no longer pushed goes INACTIVE via the time-based asset reconcile.
- Accepted risks: false-positive / accepted-risk in the UI; the import neither
reopens nor closes those. Not
.trivyignore: an ignored CVE drops out of the report and would be closed as patched. - Not built: severity-rise alerts (scanner labels are distro ratings and would flap against the CVSS band), a raw archive of filtered entries, per-base-image grouping, VEX.
Test: tests/test_container_scan.py.
Dockhand pull
Settings → Integrations → Dockhand (dockhand_config, encrypted: base_url,
token, verify_ssl, min_severity, only_fixed, scan_nightly). Nightly at
02:50 (dockhand_sync_nightly) or Sync now. app/services/dockhand_service.py.
Dockhand setup (Dockhand UI, not ours):
- Settings → General → Default Grype arguments:
-o json --by-cve -v {image}(Dockhand's default lacks--by-cve; see "What the export lacks"). - Settings → Environments → edit → Security: scanning on, scanner Grype.
- API token with
images:view,environments:view, andimages:inspectfor the rescan.
Rescan nightly (scan_nightly, default off). 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 show up. With the flag, the
nightly job calls POST /api/vulnerabilities/scan-all?env=<id> per environment
(Accept: application/json → Dockhand answers synchronously when done; read
timeout 3 h) 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 (minutes per host).
Reads GET /api/environments, then GET /api/vulnerabilities/export?env=<id>&format=json
per environment (Dockhand ≥ 1.0.37), with Authorization: Bearer dh_…
(images:view, environments:view). Every image goes through the same
ingest_image as the push, source dockhand, so the assets, baseline, filter
and retraction are the ones above.
- Where it runs: the asset description lists, per Dockhand environment,
the environment name (with the Docker host's own hostname from
GET /api/host, i.e.docker infoName, when it differs, and connection type, host, public IP when set; no Docker daemon id — Dockhand does not expose it and Podman has none), the short image id, the containers (name + 12-char id, asdocker psshows) and stacks. Rewritten every sync. Container ids change on every recreate, so they are shown but never matched on; a host still on the old build shows a different image id. - Docker host ↔ image (
container_placements, 060): where the Docker host's hostname names exactly one non-container, non-decommissioned asset (exact, else FQDN ↔ short form; several → the environment's public IP / host must decide, else no link), the image is linked to that host asset. The asset list hides images by default (Show container images, APIinclude_containers=false); a host row shows an N images badge →GET /assets/{id}/containers, an image row names its hosts. The findings list filtered by the host (asset_id=<host>, the host link in Assets) also shows the findings of its placed images. Rewritten every sync, so an image gone from a host loses its link with the next run. Only images with findings: clean ones are not in Dockhand's export. 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. - Union over environments. One image on two hosts is one asset. Retraction compares against the findings of all environments, and one failing environment aborts the sync before anything is written — otherwise the host that updated the image closes what the other still reports, every night.
- What the export lacks: no CVSS (severity label until enrichment fills
the score), no aliases (Grype needs
--by-cvein Dockhand's scanner arguments,-o json --by-cve -v {image}, or GHSA ids will not merge with other sources' CVEs), and no clean images — an image without findings is not listed. - Images missing from the export (
images_gone,images_clean): Dockhand exports only images still on a host, andGET /api/imageslists every tag there. A tag on no host any more (deleted, or moved to a new name) loses itsdockhandsource at once and the image goes INACTIVE (if Dockhand saw it last). A tag still on a host but not exported is either clean or not scanned yet; it closes only after a nightly rescan that covered every environment without a failure. Skipped entirely when an environment lists no images (unreachable or empty, cannot tell) or an image failed to import. - Unreachable Docker host: Dockhand then exports every scan it ever
stored, deleted images included, and
/api/imagesanswers[]. Findings but no images → the sync aborts instead of reviving deleted images. - After switching Dockhand's scanner, the old scanner's last scan of an image stays in the export until the image is gone; its findings cannot close before.
- Untagged images (bare
sha256:ids) and rows without an id are skipped.
Test: tests/test_dockhand_sync.py.
Patch plan + Jev config hint
/patch-plan (API GET /api/v1/vulnerabilities/patch-plan) answers "where do
I start patching": open findings (open, patch_failed) on ACTIVE assets,
grouped by the product to update (vulnerability_packages rows, else
package_name, else the CVE id). Rank: CVEs exploited in the wild (KEV /
EUVD) first, then summed priority_score. A CVE hitting two products counts
toward both. Fixed versions are listed as reported, never compared.
The endpoint returns the whole plan (?limit=N truncates to the top N);
the GUI sorts and pages it client-side with the shared Pagination bar, so no
action is hidden behind a cap. GET /patch-plan/export streams the same plan
as CSV in rank order, named patch_plan_YYYYMMDD_HHMMSS.csv like the assets
export. The Jev hint is independent of both: it walks every open finding on
ACTIVE assets (open_work), one question per unique unanswered CVE, with no
per-run cap — never "the rows the GUI shows". A systemic failure (auth, quota,
network) still stops the run, and answers commit every 50 CVEs, so a stopped
run keeps what it paid for and the next night picks up the rest.
Group by package (Vulnerabilities page chip, deep link ?group=package,
API GET /api/v1/vulnerabilities/by-package, patch_plan_service.group_by_package)
is the plan's grouping under the findings list's own filters
(_apply_list_filters: asset, status, severity, search, …), so with one asset
selected it lists that asset's affected products with findings, CVEs,
severities, exploited count and installed/fixed versions. One GROUP BY in the
database, no paging; the header total is distinct findings (one finding can sit
in two groups). A row click returns to the flat list with ?package=<key>
(filter_by_package): exactly that group's findings by their package rows —
not the search, whose ILIKE on the summary package_name misses a finding's
second product. Ranked like the plan (exploited first, then summed priority);
the column headers re-sort client-side (max priority, max CPR, severity, …),
a third click restores that order. The client-only "PUBL-EXPL" chip does not
narrow it. Defender labels
carry their version ("apache log4j 2.14.0.0"), so fleet-wide one product with
several versions is several rows — the label is never stripped by guesswork.
Jev config hint (jev_triage_service, nightly 06:00, off by default —
jev_triage_enabled setting or JEV_TRIAGE_ENABLED, plus a key). A TypeSafe
key (typesafe_api_key / TYPESAFE_API_KEY, encrypted at rest) goes straight
to api.typesafe.ai/v1/systemone (jev-1.13.0); without one, the OpenRouter
key goes to OpenRouter Decisions (typesafe/jev-1.13). Same body, same
answer; the model is pinned per endpoint. One question per CVE text: only
exploitable with an optional feature / non-default configuration? The
probability lands in vulnerabilities.jev_config_dependent (migration 061);
≥ 0.7 lists the CVE under "Check config" on the patch plan.
What Jev deliberately does not do:
- Close findings. A finding closes when its scanners retract it (Cross-source contract). Auto-closing on a model's false-positive guess would hide real findings — accuracy over coverage.
- Enter the score. Exploitation, exploitability and attack vector already come from KEV / EUVD / EPSS / SSVC and the CVSS vector; guessing them from prose would be a worse copy.
- Judge exposure. Internet-facing is a property of the host, not the CVE
text —
exposure_serviceowns it.
Every run that did something writes ONE audit entry (AI_ANALYSIS_REQUESTED,
resource_type="jev_triage", user System/Auto): "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 and skipped_cves / error. Hosts = ACTIVE
assets carrying an open finding for one of those CVEs. Tokens come from the
endpoint's usage block if it sends one; when it does not, the entry says
"tokens not reported by the endpoint" rather than claiming 0. A run with nothing
pending writes no entry.
Answered once per CVE; a failing API (auth, credit, rate limit, network, 5xx) stops the run without stamping, so the next night retries. A CVE-specific failure (other 4xx, unusable answer) skips only that CVE; five in a row stop the run as systemic (a changed schema rejects every CVE alike). Only public CVE text is sent.
Tests: tests/test_patch_plan.py, tests/test_jev_triage.py.
Wazuh 5.0
The client (app/integrations/wazuh_client.py) reads Wazuh 4.8–4.14 and 5.0
from the same configuration. It picks the layout on its own, so there is no
setting for it. It reads the version once per client from GET /
(api_version). If the manager API does not answer, it searches the indexer
for wazuh-states-vulnerabilities instead: 5 creates that index at setup,
and 4.x never has one. The CVE sync reads only the indexer and keeps running
while the API is down, on 5 as on 4.x.
What 5 changed, and what the client does about it (checked against
wazuh/wazuh@v5.0.0-rc2 api/api/spec/spec.yaml and
wazuh/wazuh-indexer-plugins@v5.0.0-rc2 templates/states/*.json):
| 4.x | 5.0 | |
|---|---|---|
| CVEs | wazuh-states-vulnerabilities-* |
wazuh-states-vulnerabilities, the exact name |
| agent field in every states index | agent.id |
wazuh.agent.id (the mappings are dynamic: strict, so a query on the wrong field returns nothing and raises nothing) |
| "Solved" filter | wazuh-alerts-*, latest data.vulnerability.status |
none: 5 has no alerts index, and it deletes a solved detection from the states index |
| packages / ports | GET /syscollector/{id}/packages, /ports |
wazuh-states-inventory-packages / -ports, mapped back to the API's item shape (path → location, type → format, source.* → local.*, …) |
| browser extensions | wazuh-states-inventory-browser-extensions-* |
wazuh-states-inventory-browser-extensions |
| SCA | GET /sca/{id}, /sca/{id}/checks/{policy} |
wazuh-states-sca, one document per check. The client counts the summary itself: Passed → pass, Failed → fail, anything else (Not applicable, Not run) → invalid, as 4.x counted it. end_scan is the newest state.modified_at. Check results are lower-cased like 4.x's. |
| manager nodes | /cluster/nodes, falls back to /manager/info |
/cluster/nodes: every 5 server is a cluster node, and /manager/* is gone |
On 5, the indices are queried by exact name. The documented migration is a new deployment, not an in-place upgrade, and it restores the 4.x indices next to the new ones as read-only history. A pattern would read that history too, and findings the host patched long ago would never close.
The fixed version comes from vulnerability.scanner.condition on both
versions, in the forms Package less than X (4.x, 5 up to rc1) and
Fixed in X (5 rc2 on). No other wording names a fixed version, so any other
condition gives none. Before this change the client read
vulnerability.condition, which neither mapping has, so 4.x findings never
got a fixed version from the indexer. The first sync after the update fills
it in.
No data is not a clean host. A 5 server drops the inventory sync of every
agent before 5.0 (migration-to-5x/wazuh-agents). It keeps no packages, CVEs
or SCA for those agents, and mid-migration that is most of the estate. A 5.x
agent also has none until its first inventory sync. An empty answer for an
agent without a software inventory therefore raises per agent ("has no
software inventory in the Wazuh 5 indexer"): its findings stay open, and the
run completes with errors instead of patching the host. The wording is not a
source_failure(), so one such agent does not fail the run. If every agent
answers that way (a freshly migrated server whose agents are all still 4.x),
the run fails, because nothing was synced.
Other changes on 5:
PUT /agents/{id}/restart("Verify patch") works only for agents on 5.0 or later.GET /agents/{id}is in neither the 4.x nor the 5 spec, so the client callsGET /agents?agents_list=<id>. Before, the wait for a rescan in "Verify patch" never saw the agent.- The indexer is now required for everything on 5, not only for CVEs.
- The indexer user needs read access to
wazuh-states-*. 5 ships no read-only user, so create a role withcluster_composite_ops_roand indexreadonwazuh-states-*.
Migrate the agent identities. Carry client.keys and the global.db
agent rows over, or run tools/migration/wazuh-migrate-identity.py; both
keep the agent IDs. Agents that re-enroll on a fresh 5 server get new IDs
starting again at 001. The sync detects a reused ID when the agent's name
differs from the asset's and its dateAdd is later than the one stored on
the asset ("A reused Wazuh agent id is not a pin" in "What identifies a
device, per source"). It cannot tell a reused ID whose new host carries the
old host's name. Run one Wazuh asset sync on 4.x after deploying revision
066, so every asset has its dateAdd stored before the cutover; without it,
only a cutover with the old server already off is detected.
Known gaps:
- An agent upgraded to 5 whose inventory is already synced, but whose first vulnerability scan is not yet written, reads as clean for that window. Its Wazuh findings close and reopen on the next sync. No field shows a scan as pending.
- 5.0.0 is still rc2. Re-check the templates and the condition wording against the GA tag.
vulnerability.descriptionis mapped but not written on 5, so findings arrive without a title until enrichment adds one.- RC packages report
5.0.0-1, the same as GA, so an RC install is judged as 5.0.0.
Tests: tests/test_wazuh_v5.py, which builds its documents from the 5
template field paths, and wazuh5_components() in
tests/test_wazuh_components.py.
