Threat-Intel-Enrichment, Risk-Based Prioritization & Multi-Provider Auth
Branch-only changes that are not yet on main. See README.md for the stable feature set.
---
## What this branch adds
Six feature groups on top of stable `main`:
**1. Threat-intel enrichment** — extends the priority score beyond CVSS + Wazuh exploit flags with multiple independent threat-intelligence sources, plus a separate CPR (Cybersecurity Priority Risk) score.
| Feature | Source | Notes |
|---|---|---|
| **EPSS** | [FIRST.org](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:///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:///auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"PASS"}' | jq -r .access_token)
# Trigger bulk enrichment
curl -X POST http:///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:///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:///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:///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": "", "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 IP column is still the frontend
container's: the proxy cannot forward the client IP safely, because Next.js
keeps a client-supplied `X-Forwarded-For`.
## 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= # 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`.
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:///auth/providers | jq
# Local login still works
curl -s -X POST http:///auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}'
# Admin: provider status snapshot
TOKEN=$(curl -s -X POST http:///auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -s http:///api/v1/auth-config/status -H "Authorization: Bearer $TOKEN" | jq
# Test LDAP bind (after configuring LDAP_* env vars)
curl -s -X POST http:///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:///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: '' 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 │
│ • exhaustive — every published CVE (~250k+) │
│ • same CVE-5 JSON shape as Vulnrichment, parser reused │
└─────────────────────────────────────────────────────────────────┘
```
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 = , 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:///auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -X POST http:///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:///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:///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 UTC | `compliance_sca_nightly` | Wazuh SCA refresh for every linked asset |
| 03:00 UTC | `vulnrichment_nightly` | CISA Vulnrichment ZIP snapshot CVSS/SSVC correction |
| 04:00 UTC | `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.
**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.
## 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.
- **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`.
- **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** | 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`.
- **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_v2`. **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 03:20 app scan decides from — that scan's package pass
is what creates the Edge findings. It used to be rebuilt by the MSRC job at
03:50, 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
03:50 job 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 03:20 and 03:50) 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.
- 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.
## 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 |
| 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 `` 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), and 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, IGEL and Intune adopt the name the source reports
when the asset was matched on a pin. That goes through
`asset_lifecycle.rename`, which logs it (INFO) and writes an `ASSET_UPDATED`
audit entry "Asset renamed by sync: old → new" (`old_value` = old
name). Wazuh, Nessus, Defender and the container imports never rename.
Creates: every sync that adds an asset (Wazuh agents and manager nodes,
Nessus, vCenter, IGEL, Intune, Netdisco, the container imports) calls
`asset_lifecycle.created`, one `ASSET_CREATED` audit entry per asset: "Asset
created by sync: ", the same event a manual create writes.
Defender never creates.
**Defender is the deliberate exception.** A re-imaged device keeps its old
machine entry, so Defender reports two machine ids for one host with the same
`computerDnsName` — and the CVE reconcile is built for exactly that
(`seen_by_asset`: a finding may only close once *every* machine behind the
asset has been asked). The machines API carries no serial, so nothing separates
"re-imaged" from "two hosts, one name"; guarding on the machine id would send
the live machine's findings to `unmatched` and a re-imaged device would stop
being scanned. Left as is on purpose. If duplicate DNS names ever become real
here, the discriminator has to come from somewhere — `aadDeviceId` per
machine, or Intune's serial via the shared asset — not from the machine id.
**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__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
`` 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 UTC** — after the Intune
sync, before the app-CVE scan at 03:20, 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
```
---
# Sync run ledger — what counts as a failed run
Every sync execution (Wazuh, Nessus, Intune, vCenter, IGEL, Netdisco; 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** (03:20), **SCA refresh** (02:00) and **EOL
check** (03: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.
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-`
→ 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: ` (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` | `cpe:2.3:o:hpe:arubaos-cx` |
| `ArubaOS-Switch` | `16.11.0016` | `ArubaOS-Switch`, `ArubaOS-S Switch` | `cpe:2.3:o:hpe:arubaos-switch` |
| `ArubaOS` (Mobility) | `8.13.1.1` / `10.7.2.2` | `HPE Aruba Networking Wireless Operating System (AOS)` | `cpe:2.3:o:arubanetworks:arubaos` (+ `hp:arubaos`) |
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: "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.
## 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 UTC** (`netdisco_sync_nightly`), after the IGEL sync and
before the 03:20 app-CVE scan; 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 UTC (`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=` 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=&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.
**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`.