VulnCheck Logo

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.

Branch Status

--- ## 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