VulnCheck — Dev Branch Features
Threat-Intel-Enrichment, Risk-Based Prioritization & Multi-Provider Auth
Branch-only changes that are not yet on main. See README.md for the stable feature set.
---
## What this branch adds
Five 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.
---
## 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=VulnCheck-Admins,*", "role": "admin"},
{"pattern": "CN=VulnCheck-Editors,*", "role": "editor"}
],
"oidc": [
{"pattern": "", "role": "admin"}
],
"saml": [
{"pattern": "VulnCheck-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`.
## 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())"
```
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
`Dockerfile` adds `libxml2-dev`, `libxmlsec1-dev`, `libxmlsec1-openssl`, `pkg-config`
(for `python3-saml`), and `libsasl2-dev`, `libldap2-dev`, `libssl-dev`,
`ca-certificates` (TLS + future python-ldap if ever needed).
## 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 Wazuh sync that found new CVEs | Recipient cascade returned empty (vuln/asset not assigned to any user or group with an email) OR severity below `notification_min_severity` threshold OR notifications suppressed on the vuln | Assign asset/vuln, lower threshold in Settings, or un-suppress via the bell icon. Verify with `docker compose logs backend | grep 'Wazuh sync notifications'`. |
---
# 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
Both sync paths route through the same dispatcher:
- **Manual:** `POST /api/v1/vulnerabilities/sync/wazuh` (UI button or curl)
- **Scheduled:** the APScheduler-driven sync jobs (Settings → Scan Schedules)
After the sync completes:
1. Backend collects `newly_created_vuln_ids` during the sync
2. Loads them, applies `notification_min_severity` filter
3. Resolves recipient per CVE via the existing cascade
(`vuln.assigned_user` → `vuln.assigned_group` → `asset.assigned_user`
→ `asset.assigned_groups`)
4. Groups CVEs by recipient email
5. Sends one digest mail per recipient via `dispatch_new_vuln_notifications()`
6. Writes one `NotificationLog` per recipient (anchor vuln + total count)
If `vulns_created = 0` (Wazuh reported only updates), no mails fire. Designed
this way to avoid noise on routine syncs.
## Settings
| Setting key | Default | Purpose |
|---|---|---|
| `notification_mode` | `digest` | `digest` (one mail per recipient) or `single` (legacy, one mail per CVE) |
| `notification_min_severity` | `critical` | Skip CVEs below this severity (`critical` / `high` / `medium` / `low`) |
| `smtp_config` | — | Required. Without SMTP no mails go out. |
All three editable via Settings UI (no env-var needed).
## Mail content
Subject: `[VULNCHECK] N new vulnerabilities detected`
Body: severity-count badges (Critical / High / Medium / Low), table of CVE +
Severity + CVSS + Host + Package, "Open in dashboard" button. Template lives
in `app/services/email_service.py:DEFAULT_DIGEST_TEMPLATE` and is overridable
via the `email_template_new_vuln_digest` setting (custom HTML/Jinja-light
variables `{{total}}`, `{{count_critical}}`, `{{count_high}}`, `{{rows}}`,
`{{detected_at}}`, `{{recipient_name}}`, `{{dashboard_url}}`).
## Fresh-install behaviour
Out-of-box flow for a clean deployment:
1. SMTP configured in Settings → Email
2. Asset assigned to a user or group with a populated `users.email`
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.
---
# 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 VulnCheck 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/vulncheck-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 VulnCheck (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)
---
## 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