<p align="center">
  <img src="frontend/public/logo.png" alt="VulnCheck Logo" width="120" />
</p>

<h1 align="center">VulnCheck — Dev Branch Features</h1>

<p align="center">
  <strong>Threat-Intel-Enrichment, Risk-Based Prioritization &amp; Multi-Provider Auth</strong><br>
  Branch-only changes that are not yet on <code>main</code>. See <a href="README.md">README.md</a> for the stable feature set.
</p>

<p align="center">
  <img src="https://img.shields.io/badge/Branch-dev-orange.svg" alt="Branch" />
  <img src="https://img.shields.io/badge/Status-Testing-yellow.svg" alt="Status" />
</p>

---

## What this branch adds

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://<host>/api/v1/settings/enrichment_euvd_enabled \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"value":"false"}'
```

---

## Deployment / upgrade path

```bash
# 1. Pull dev branch
git pull origin dev

# 2. Rebuild containers
docker compose build backend frontend && docker compose up -d backend frontend

# 3. Run migrations (007 + 008)
docker compose exec backend alembic upgrade head
docker compose exec backend alembic current   # expect: 008 (head)

# 4. Wait 5 min for the first scheduler-triggered enrichment, OR press
#    "Refresh Threat Intel" in the UI for an immediate run.
```

### Outbound HTTPS required

The backend container must be able to reach:

- `https://api.first.org` (EPSS)
- `https://www.cisa.gov` (KEV feed)
- `https://euvdservices.enisa.europa.eu` (EUVD search + critical)

If you operate behind a proxy, set `HTTPS_PROXY` on the backend service.

---

## Out of scope (future ideas)

- CIRCL aggregator KEV (CISA + CIRCL + EUVD in one endpoint via `cvepremium.circl.lu`)
- EPSS bulk CSV download (`epss_scores-current.csv.gz`) for very large deployments
- ENISA EUVD descriptions / references backfill into our `description` / `references` fields
- NVD enrichment for missing CVSS vectors / CWE-IDs
- Custom catalog ingestion (user-uploaded CSV of "internally exploited" CVEs)

---

## Verification snippets

```bash
TOKEN=$(curl -sk -X POST http://<host>/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"PASS"}' | jq -r .access_token)

# Trigger bulk enrichment
curl -X POST http://<host>/api/v1/vulnerabilities/enrich/bulk \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"only_open": true}' | jq

# Show fields on one CVE
curl -s "http://<host>/api/v1/vulnerabilities?limit=1" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.items[0] | {cve_id, cvss_score, epss_score, kev_listed, euvd_listed, cpr_score, priority_score}'

# List only CVEs confirmed by both catalogs
curl -s "http://<host>/api/v1/vulnerabilities?in_both_catalogs=true&limit=5" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.items[] | {cve_id, kev_listed, euvd_listed, priority_score, cpr_score}'

# Watch enrichment in the logs
docker compose logs -f backend | grep -iE "epss|kev|euvd|enrich"
```

Expected log lines on a fresh refresh:
```
EUVD: fetching fresh catalogs from ENISA
EUVD: cached ~1700 unique CVEs from ENISA (exploited pages processed=N, critical entries=4)
KEV: fetching fresh catalog from CISA
KEV: cached ~1500 entries from CISA
EPSS: fetched N scores for N CVEs
Enrichment done: ... epss_updated=N, kev_marked=M, euvd_marked=K
```

---

# Multi-Provider Authentication

LDAPS + OIDC + SAML 2.0 running in parallel alongside local username/password,
with optional TOTP MFA, Just-In-Time provisioning, and runtime-editable group→role
mappings.

## Architecture (Strategy Pattern)

```
┌──────────────────┐
│  /auth/login     │  username + password
└────────┬─────────┘
         │
         ▼
┌────────────────────┐       ┌──────────────────────┐
│  AuthOrchestrator  │ ───►  │  LocalAuthStrategy   │  bcrypt + TOTP gate
│  (AUTH_LOOKUP_ORDER│       └──────────────────────┘
│   chain, e.g.      │       ┌──────────────────────┐
│   local → ldap)    │ ───►  │  LdapStrategy        │  search-then-bind, LDAPS
└────────┬───────────┘       └──────────────────────┘
         │
         ▼
┌────────────────────┐
│  JITProvisioner    │ ─► auto-create User on first SSO/LDAP login,
│  + RoleMapper      │    auto-link existing email, re-eval role each login
└────────┬───────────┘
         ▼
       JWT issued (httpOnly cookie + bearer)

┌──────────────────────────┐
│  /auth/oidc/login        │ ──► PKCE redirect to IdP
│  /auth/oidc/callback     │ ──► JWKS-validated id_token → orchestrator.complete_sso_login()
└──────────────────────────┘

┌──────────────────────────┐
│  /auth/saml/login        │ ──► python3-saml AuthnRequest
│  /auth/saml/acs          │ ──► strict signature + XSW check → orchestrator.complete_sso_login()
│  /auth/saml/metadata     │ ──► auto-generated SP descriptor
└──────────────────────────┘
```

Each strategy lives in `app/auth/strategies/`. Adding a new IdP type is a single new file
that implements `AuthStrategy` — no changes to `/auth/login` needed.

## Providers

| Provider | Endpoint(s) | Notes |
|---|---|---|
| **Local** | `POST /auth/login` | bcrypt; optional TOTP via `/auth/mfa/*` |
| **LDAPS** | `POST /auth/login` (chain) | `ldap3`, strict TLS, AD-flavoured defaults (sAMAccountName/memberOf/objectGUID) |
| **OIDC** | `GET /auth/oidc/login` + `/callback` | Authlib, PKCE S256, strict id_token validation (iss/aud/exp/nonce/sig via JWKS) |
| **SAML 2.0** | `GET /auth/saml/login` + `POST /auth/saml/acs` + `/metadata` | `python3-saml`, `strict=true` (XSW + AudienceRestriction + Recipient + Replay) |

`GET /auth/providers` is the public discovery endpoint the login UI reads to render
the right mix of buttons.

## TOTP (RFC 6238) MFA

Optional second factor for local accounts only — SSO users get MFA from their IdP.

### Self-service flow (UI)

Visit `/settings` → **Two-factor authentication** card (top of the right column):

1. Click **Enable MFA** → confirm current password
2. Scan the rendered QR (SVG, generated client-side via `qrcode.react` — secret
   never leaves the browser session) or paste the base32 secret manually into
   your authenticator app
3. Enter the 6-digit code your app shows → **Activate MFA**
4. Badge flips to `ENABLED`. Next login asks for the code as a second step.

To turn it off: same card, **Disable MFA** → password.

For non-local users (LDAP/OIDC/SAML) the card shows a hint that MFA is managed
by the upstream IdP and offers no buttons.

### Admin reset (lost device)

Settings → User Management → orange **Reset MFA** button next to the user clears
`totp_enabled` + `totp_secret`. The user can then log in with password alone and
re-enrol via `/settings`. Logged as `MFA_DISABLED` audit event with the admin
as actor.

### Endpoints

| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /auth/mfa/setup` | self | Verify current password → return `secret` + `provisioning_uri` (otpauth://) |
| `POST /auth/mfa/activate` | self | Confirm enrolment with a fresh code, flip `totp_enabled` |
| `POST /auth/mfa/disable` | self | Disable, requires password |
| `POST /auth/mfa/verify` | login flow | Second-factor step after `/auth/login` returns `mfa_required: true` |
| `POST /auth/users/{user_id}/reset-mfa` | admin | Force-reset MFA for any user (no password required, audit-logged) |

Secrets are stored Fernet-encrypted in `users.totp_secret`. Key in env:
`AUTH_PROVIDER_CRYPTO_KEY` — losing it invalidates every stored TOTP enrolment.

### Frontend proxy note

Next.js needs an explicit `route.ts` for every backend path it forwards.
A catch-all at `frontend/app/auth/[...path]/route.ts` covers the new
`/auth/mfa/*`, `/auth/providers`, `/auth/oidc/*`, `/auth/saml/*` endpoints
in one file. Specific routes (e.g. `/auth/login`, `/auth/me`,
`/auth/users/{id}/reset-password`, `/auth/users/{id}/reset-mfa`) keep
priority — Next.js prefers more specific segments over catch-alls.

## Just-In-Time provisioning + auto-link

On first SSO/LDAP login, the orchestrator:

1. Looks up `(auth_provider, external_id)` — exact subject match
2. Falls back to lookup by email → if a local user matches, **auto-link**
   (per requirements). Logged as `USER_AUTO_LINKED`.
3. Otherwise JIT-creates a new user stub with `password_hash = NULL`,
   role from group mapping, `is_active=true`. Logged as `JIT_USER_CREATED`.

On every subsequent login, the role is **re-evaluated** from current external
groups — promotions/demotions in the IdP propagate immediately. Logged as
`EXTERNAL_ROLE_MAPPED`.

## Group → Role mapping

Patterns are `fnmatch` style (`*`, `?`), case-insensitive, **first match wins**.
Rules are admin-editable via the UI at `/admin/auth` or via the API:

```bash
curl -X PUT https://<host>/api/v1/auth-config/role-mappings \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "mappings": {
      "ldap": [
        {"pattern": "CN=VulnCheck-Admins,*", "role": "admin"},
        {"pattern": "CN=VulnCheck-Editors,*", "role": "editor"}
      ],
      "oidc": [
        {"pattern": "<azure-group-uuid-admin>", "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=<fernet-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://<host>/auth/providers | jq

# Local login still works
curl -s -X POST http://<host>/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"…"}'

# Admin: provider status snapshot
TOKEN=$(curl -s -X POST http://<host>/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -s http://<host>/api/v1/auth-config/status -H "Authorization: Bearer $TOKEN" | jq

# Test LDAP bind (after configuring LDAP_* env vars)
curl -s -X POST http://<host>/api/v1/auth-config/ldap/test \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"sample_username":"jdoe"}'

# Test OIDC discovery
curl -s -X POST http://<host>/api/v1/auth-config/oidc/test -H "Authorization: Bearer $TOKEN" | jq
```

Audit log (`/admin/audit-logs`) shows `LOGIN_LDAP_SUCCESS`, `LOGIN_SSO_SUCCESS`,
`JIT_USER_CREATED`, `EXTERNAL_ROLE_MAPPED`, and `MFA_*` events as users start
logging in via the new paths.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `Multiple head revisions are present` on alembic upgrade | Two migrations with the same revision number landed on `dev` (we hit this when 008-EUVD and 008-auth collided) | Renumber yours linearly. See git log for the conflict-resolution commit (`fix(alembic): rename ...`). |
| `LookupError: '<x>' is not among the defined enum values` for `authprovider` | SQLAlchemy default maps enums by NAME but the postgres `authprovider` type was created with lowercase VALUES | Use `SQLEnum(AuthProvider, values_callable=lambda x: [e.value for e in x])` — already applied to `users.auth_provider`. Same pattern for any future enum where name ≠ value. |
| `ValueError: malformed bcrypt hash (checksum must be …)` on login when username is wrong | The constant-time dummy verify in `LocalAuthStrategy` had a fake/placeholder hash | Already fixed — we generate a real hash once at module import. |
| MFA setup returns 500 `AUTH_PROVIDER_CRYPTO_KEY env var is not set` | Var added to `.env` but `docker compose restart backend` doesn't reload env into the existing container | Use `docker compose up -d --force-recreate backend`. Verify with `docker compose exec backend printenv AUTH_PROVIDER_CRYPTO_KEY`. |
| MFA setup returns 404 HTML with `x-nextjs-cache: HIT` | Next.js had no proxy route for `/auth/mfa/*` so it served them as page routes | Catch-all `frontend/app/auth/[...path]/route.ts` is now in place. Frontend rebuild required. |
| MFA card not visible in Settings | Frontend container has the pre-MFA build | `docker compose build frontend && docker compose up -d frontend`. Hard reload (Ctrl+Shift+R). |
| LDAP test bind fails immediately | Bootstrap password loaded once and encrypted in DB; subsequent decrypts fail if you rotated `AUTH_PROVIDER_CRYPTO_KEY` | Clear the cached bind: `DELETE FROM settings WHERE key='ldap_bind_password_encrypted';` then restart. The next start re-bootstraps from `LDAP_BIND_PASSWORD_BOOTSTRAP`. |
| No mail after a 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 = <existing>, NULL = 0, nessus = 0  (until first sync)

# 3. Set nessus_config via the Settings UI, then Test connection.

# 4. First sync
TOKEN=$(curl -sk -X POST http://<host>/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"…"}' | jq -r .access_token)

curl -X POST http://<host>/api/v1/vulnerabilities/nessus/sync \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
# returns {scans_processed, hosts_synced, vulns_created, vulns_merged,
#          scores_overridden, vulns_marked_patched, unmatched_hosts[],
#          notifications:{…}}

# 5. Cross-confirmation listing (CVEs in both scanners)
curl -s "http://<host>/api/v1/vulnerabilities?cross_confirmed=true&limit=5" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.items[] | {cve_id, sources, first_detected_by, priority_score}'

# 6. False-positive flow
curl -X PATCH http://<host>/api/v1/vulnerabilities/123/false-positive \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Vendor confirmed N/A in our config"}'
```

UI smoke test:
- Source column shows correct badges per row
- Filter "Nessus only" shows only `sources=["nessus"]` entries
- Filter "Cross-confirmed" shows only multi-scanner entries
- "Sync now" button surfaces stats inline on the Settings card
- Click NoSymbolIcon → prompt → vuln moves to FALSE_POSITIVE status

## Nessus — out of scope for v1

- Triggering Nessus scans from 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
