Groups, policies, scan schedules, finding assignment and suppression, and
every sync, scan, refresh and connection-test button wrote no audit row,
so "who did this, and what changed" had no answer for them.
Records that change now write one row naming each field old -> new
(audit_trail.record_change; a create passes before={}, a delete after={},
nothing changed writes nothing), in the same transaction as the change.
Defer, undefer and false-positive also record the date, reason and
suppression they set or clear; an admin's email change is logged.
Buttons take the route dependency audited("What"): one row once the
endpoint returned, before the response goes out, so a refused or raising
call writes none and a failing audit write is a 500 rather than a gap. It
keeps the query parameters and only the named body fields, because request
bodies carry passwords: the test-mail recipient and SMTP host, the syslog
target, the AI provider and base URL, the Nessus asset and scan.
A guard test fails for any new POST/PUT/PATCH/DELETE route that does not
audit itself. No new event types, so no migration: groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, told apart by
resource_type and the description.
3660 lines
203 KiB
Markdown
3660 lines
203 KiB
Markdown
<p align="center">
|
||
<img src="frontend/public/logo.png" alt="TrueVuln Logo" width="120" />
|
||
</p>
|
||
|
||
<h1 align="center">TrueVuln — Dev Branch Features</h1>
|
||
|
||
<p align="center">
|
||
<strong>Threat-Intel-Enrichment, Risk-Based Prioritization & Multi-Provider Auth</strong><br>
|
||
Branch-only changes that are not yet on <code>main</code>. See <a href="README.md">README.md</a> for the stable feature set.
|
||
</p>
|
||
|
||
<p align="center">
|
||
<img src="https://img.shields.io/badge/Branch-dev-orange.svg" alt="Branch" />
|
||
<img src="https://img.shields.io/badge/Status-Testing-yellow.svg" alt="Status" />
|
||
</p>
|
||
|
||
---
|
||
|
||
## 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](https://www.first.org/epss/) | Probability of exploitation in next 30 days |
|
||
| **CISA KEV** | [CISA.gov](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) | US-side known-exploited catalog (~1500 CVEs) |
|
||
| **ENISA EUVD** | [euvd.enisa.europa.eu](https://euvd.enisa.europa.eu/) | EU exploited + ENISA-flagged critical (~1600 CVEs) |
|
||
| **CISA Vulnrichment** | [cisagov/vulnrichment](https://github.com/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](https://services.nvd.nist.gov/rest/json/cves/2.0) | 2nd-stage fallback for CVEs Vulnrichment hasn't analysed yet (rate-limit-bounded, ≤ 100 per run) |
|
||
| **cvelistV5** | [CVEProject/cvelistV5](https://github.com/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](https://github.com/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)
|
||
```
|
||
|
||
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?
|
||
|
||
1. **Daily scheduler job** — runs every 24 h, refreshes EPSS + KEV + EUVD for all open vulnerabilities. First run 5 minutes after backend startup.
|
||
2. **After Wazuh sync** — newly discovered CVEs are auto-enriched before they reach the UI.
|
||
3. **Manual "Refresh Threat Intel" button** — bulk-enriches every open vuln on demand.
|
||
4. **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):
|
||
```json
|
||
{
|
||
"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 KEV
|
||
- `euvd_only=true` — only ENISA EUVD
|
||
- `eu_critical=true` — only ENISA-flagged critical
|
||
- `in_any_catalog=true` — KEV ∪ EUVD
|
||
- `in_both_catalogs=true` — KEV ∩ EUVD
|
||
- `epss_min=0.5` — minimum EPSS score (raw 0.0-1.0, UI sends `value / 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:
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
# 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` / `references` fields
|
||
- NVD enrichment for missing CVSS vectors / CWE-IDs
|
||
- Custom catalog ingestion (user-uploaded CSV of "internally exploited" CVEs)
|
||
|
||
---
|
||
|
||
## Verification snippets
|
||
|
||
```bash
|
||
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):
|
||
|
||
1. Click **Enable MFA** → confirm current password
|
||
2. 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
|
||
3. Enter the 6-digit code your app shows → **Activate MFA**
|
||
4. 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
|
||
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.
|
||
|
||
## Just-In-Time provisioning + auto-link
|
||
|
||
On first SSO/LDAP login, the orchestrator:
|
||
|
||
1. Looks up `(auth_provider, external_id)` — exact subject match
|
||
2. Falls back to lookup by email → if a local user matches, **auto-link**
|
||
(per requirements). Logged as `USER_AUTO_LINKED`.
|
||
3. Otherwise JIT-creates a new user stub with `password_hash = NULL`,
|
||
role from group mapping, `is_active=true`. Logged as `JIT_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:
|
||
|
||
```bash
|
||
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 for any user (lost-device escape hatch) |
|
||
| `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:
|
||
|
||
```env
|
||
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_permanent` setting (Settings → General)
|
||
to `true`. The account stays locked until an admin clears it via
|
||
`POST /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_certificates` setting; the generic
|
||
`PUT /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 `lifespan` **before** 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 without `keyUsage` or a
|
||
server certificate without Authority Key Identifier fails there. The image
|
||
is 3.11, which does not set the flag.
|
||
|
||
### 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.py`
|
||
writes `server.pem`/`server.key` to `TLS_DIR` (volume `tls_data`, mounted
|
||
read-only by nginx) and `ca.pem`/`ca.key` to `TLS_CA_DIR` (volume `tls_ca`,
|
||
backend only — nginx faces the network). Keys 0600; all files of a change are
|
||
written to `.tmp` first, then renamed. `nginx/40-truevuln-tls.sh` waits for the
|
||
pair at start and polls inode/mtime/size every 30 s; on change it runs
|
||
`nginx -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 exactly `server_names()` = `TLS_SERVER_NAMES` +
|
||
`DASHBOARD_URL` host + 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 in `lifespan` and nightly at 03:45 (`https_certificate_check`).
|
||
A CA created or replaced writes a system `CONFIG_CHANGE` audit row.
|
||
An uploaded certificate whose key no longer matches falls back to auto
|
||
(nginx would not start with it).
|
||
- **Custom mode**: `install_custom_certificate` takes 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 the `DASHBOARD_URL` host, 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.
|
||
`DELETE` reissues from the CA (`force_auto`).
|
||
- API `GET/POST/DELETE /api/v1/https-certificate`, `GET …/ca.pem` — admin only,
|
||
reads included; upload/reset write `CONFIG_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.
|
||
|
||
See [`.env.example`](.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 via `ldap3.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_account` to
|
||
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 on `RelayState` to 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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
1. Backend collects the sync's newly-created vuln ids
|
||
2. Loads them, applies `notification_min_severity` filter
|
||
3. 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
|
||
4. Groups CVEs by recipient email
|
||
5. Sends one digest mail per recipient (rows sorted by CPR desc)
|
||
6. Writes one `NotificationLog` per 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_notifications`
|
||
returns early when `respect_schedule=True`). A scheduler job
|
||
(`new_vuln_digest_nightly`, registered hourly, self-gates on
|
||
`notification_nightly_hour` + the schedule flag) then calls
|
||
`send_nightly_new_vuln_digest()` which aggregates **all** active findings
|
||
detected since the last run — windowed via the `notification_nightly_last_run`
|
||
setting 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:
|
||
|
||
1. SMTP configured in Settings → Email
|
||
2. 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
|
||
configured `notification_default_recipients`
|
||
3. `notification_min_severity` set (default `critical` works for most)
|
||
4. First Wazuh sync runs (manual or scheduled)
|
||
5. 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
|
||
|
||
```bash
|
||
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|poc|active|widespread` from Vulnrichment |
|
||
| `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:
|
||
|
||
1. `assets.nessus_host_uuid` (pinned after first successful match)
|
||
2. `assets.hostname` (case-insensitive)
|
||
3. `assets.ip_address` (exact)
|
||
4. If still no match AND `nessus_config.auto_create_assets` is true →
|
||
create a new asset with `source=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 change
|
||
- `POST /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" button
|
||
- `POST /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**.
|
||
|
||
```json
|
||
{
|
||
"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 loop
|
||
- `nessus` → `run_nessus_sync()` using `nessus_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). `✓×2` emerald badge when both scanners
|
||
reported. Amber `NON-CVE` badge 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_positive` and 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
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
1. **SCA refresh** pulls `/sca/{agent_id}` from Wazuh per asset and
|
||
stores per-policy pass / fail / N/A counts.
|
||
2. **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 `Compliance` between Assets and Scan Jobs
|
||
- `/compliance` page:
|
||
- 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), `↻ recompute` button
|
||
- **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 Criticality` dropdown with
|
||
multiplier labels (×0.7 .. ×1.5) for transparency.
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
# 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 `compliance` block 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.
|
||
|
||
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`).
|
||
|
||
**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. Only the vendor's spelling `blender / blender` is paired; the 2022
|
||
records name the vendor "n/a" and 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: the 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.
|
||
|
||
**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
|
||
(<instance>)" 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 <any OS but Windows>" 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.
|
||
|
||
**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; 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.
|
||
- **OS-level CVEs** — iOS/iPadOS/macOS via NVD-CPE (`app_cve_scanner_service`),
|
||
with a platform check (CPE `target_sw` token) 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, so `cvelistv5_scan_service.scan_asset_os_apple`
|
||
reads 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_string` places 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 for `managementAgent` containing "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 `managedDevice` carries `skuNumber`,
|
||
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_string`
|
||
appends the edition Wazuh-style ("Microsoft Windows 11 Pro"), and
|
||
`eol_service._pick_windows_client_release` picks 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 `androidSecurityPatchLevel`
|
||
vs. 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=YYYY`
|
||
serves the full year's ~12 monthly sections server-side (the accordion
|
||
UI is pure CSS/JS, doesn't gate content); parses each `SMR-MMM-YYYY`
|
||
block'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.
|
||
|
||
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 in `settings`; the header shows every source's
|
||
own `*_updated_at` (`source_status`). "Refresh catalogs" →
|
||
`POST /api/v1/advisories/kev/refresh` forces 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 second factor as alternative to TOTP
|
||
- 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 `lessThan` Google's CNA writes
|
||
into the record later (checked on CVE-2026-93374). Merged into the cvelistV5
|
||
`chrome` key 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:chrome` range 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 `_PRODUCTS` changes** — a cache built by an older version simply has
|
||
no entry for the new product, and `load_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 via `ensure_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`.
|
||
- **Every scan reads the index through `ensure_index(max_age=SCAN_INDEX_MAX_AGE)`,
|
||
never `load_index`.** `load_index` serves 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 is `msrc_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=true` by 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.** `severity` follows 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 to `description`). Everything else goes through
|
||
`Vulnerability.set_vendor_severity` into `vulnerabilities.vendor_severity`
|
||
(JSON per vendor, Alembic 058):
|
||
- Microsoft — highest CVRF Threat of Type 3 ("Max Severity"), or `severity`
|
||
from 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 (`sev` prefers 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
|
||
rewrite `description` nightly. Test: `tests/test_vendor_severity.py`.
|
||
- 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_versions`
|
||
is 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 | 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 and
|
||
`intune_last_seen` newer (`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=True` so the semantic version is read from the display name.
|
||
- An NVD outage (503/429 after the retries, `_TransientNVD`) is no verdict.
|
||
`lookup_cves` raises it instead of returning `[]`, and the caller puts
|
||
`NVD_NO_VERDICT` into the asset's `touched` set. 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 for
|
||
`NVD_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`).
|
||
|
||
## 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` | 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).
|
||
|
||
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 <source> 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 <source> sync: <hostname>", 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 in `app/layout.tsx` (no white
|
||
flash). Pages use hardcoded light utilities, so `globals.css` remaps them
|
||
under `html[data-theme="mid"|"dark"]` in an **un-layered** block — un-layered
|
||
author CSS beats Tailwind's `@layer utilities` without `!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/logs` takes `search`, `sort`, `order` and sets
|
||
`X-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](https://knowledge.broadcom.com/external/article/326316/build-numbers-and-versions-of-vmware-vce.html) | **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](https://knowledge.broadcom.com/external/article/316595/build-numbers-and-versions-of-vmware-esx.html) | 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**:
|
||
|
||
1. No bound on the host's major line → **not affected**.
|
||
2. A bound on the host's **own** update line → it decides alone,
|
||
`installed_build < bound_build`. Precise.
|
||
3. 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
|
||
|
||
```bash
|
||
# 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.** `/updates` drops an update as soon as its RPMs
|
||
are installed, but the old Xen runs until the host reboots (the security
|
||
updates' guidance is `RebootHost`). While XAPI lists `reboot_host` or
|
||
`restart_toolstack` in the host's `pending_guidances` (mandatory) or
|
||
`pending_guidances_recommended`, findings are written but not closed
|
||
(`hosts_awaiting_reboot`). `pending_guidances_full` is not read: it also
|
||
carries the reboot a Foundational update asks for, which fixes no CVE.
|
||
- Finding: package `XenServer`, version `8.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`, not `app-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 only `xenserver`
|
||
(cross-source contract); a CVE Nessus also reports stays open. Every open
|
||
finding carrying `xenserver` is 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's
|
||
`last_update_sync` is 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.** `/updates` belongs 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
|
||
|
||
```bash
|
||
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 | Mail |
|
||
|---|---|---|
|
||
| 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:
|
||
|
||
1. **The client raises.** An agent whose CVE list could not be read is not an
|
||
agent with no CVEs, so the indexer query raises `WazuhAPIError` instead of
|
||
returning `[]`. Every caller already catches per agent (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).
|
||
2. **A refused agent is neither synced nor empty.** In
|
||
`_run_wazuh_vulnerability_sync()` it becomes a FAILED per-asset scan row
|
||
and an entry in `errors`; it never reaches `empty_agents`. Otherwise one
|
||
agent answering would let `reconcile_empty_agents()` close every Wazuh
|
||
finding on the refused host — a mass patch on the strength of a 401.
|
||
3. **The manual sync fails the run and the button.**
|
||
`run_wazuh_vulnerability_sync()` calls `fail_if_source_broken()` (it never
|
||
did — only `/scans/autoscan` and the scheduler did) and raises `502` when
|
||
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
|
||
|
||
```bash
|
||
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_version`
|
||
stays dotted-numeric-only; the two Cisco registry entries carry
|
||
`"lettered": True` and get `_lettered_version` instead. That rule has
|
||
thirteen call sites across six services and every one of them compares
|
||
*ranges*, where `_vtuple` drops the letters — letting `1.1.1w` through there
|
||
would hand a letter-blind comparison a version whose letter is the release
|
||
(OpenSSL: `1.1.1a`…`1.1.1w` all read as `(1,1,1)`). Only the exact path can
|
||
honour a suffix, so only 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_appliance` closes it on the next pass.
|
||
- **An unlisted release is not flagged.** `defaultStatus` is `unknown`, not
|
||
`unaffected` — 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 Analysis` with **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_xe` with `versionStartIncluding
|
||
16.1.1` and 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_range` now 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_affected` and
|
||
`vmware_entries` already 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.1` inclusive — 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_os` skips an NVD hit only when
|
||
`cvelistv5_scan_service.cisco_unlisted` says 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 as `aruba_version()`, which
|
||
`extreme_version()` calls rather than copies.
|
||
- A patch build spells itself `22.7.1.1-patch1-11` and 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: no `Bearer`).
|
||
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's `Layer3::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):
|
||
|
||
```json
|
||
{"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::Passport` and answers os `passport`, vendor `avaya` — 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, and `before 9.3` handed 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 slug
|
||
`extremeware`) 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)E3` and
|
||
`12.2(33)CY2`, which NVD escapes into the CPE as `15.2\(7\)e3` and 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's `iOS` one.
|
||
- **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.com` for 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_version` in 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 separate `arubanetworks:instant` CPE for
|
||
that day.
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
# 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
|
||
normalised `repo: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`, Grype `relatedVulnerabilities`). 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. Not
|
||
`notification_suppressed`: that is the operator's mute and would also
|
||
silence SLA mails. KEV flips are covered by `kev_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 below `min_severity` or without a fix (`kev_kept`). Enrichment sets
|
||
`kev_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-unfixed` or 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`, and `images:inspect` for
|
||
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 info` Name, 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, as `docker ps` shows) 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**, API
|
||
`include_containers=false`); a host row shows an **N images** badge →
|
||
`GET /assets/{id}/containers`, an image row names its hosts. 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-cve` in 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, so it retracts
|
||
nothing. Gone images go INACTIVE through the time-based asset reconcile.
|
||
- **Unreachable Docker host:** Dockhand then exports every scan it ever
|
||
stored, deleted images included, and `/api/images` answers `[]`. 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. 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_service` owns 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`.
|