66 KiB
TrueVuln — Dev Branch Features
Threat-Intel-Enrichment, Risk-Based Prioritization & Multi-Provider Auth
Branch-only changes that are not yet on main. See README.md for the stable feature set.
What this branch adds
Six feature groups on top of stable main:
1. Threat-intel enrichment — extends the priority score beyond CVSS + Wazuh exploit flags with multiple independent threat-intelligence sources, plus a separate CPR (Cybersecurity Priority Risk) score.
| Feature | Source | Notes |
|---|---|---|
| EPSS | FIRST.org | Probability of exploitation in next 30 days |
| CISA KEV | CISA.gov | US-side known-exploited catalog (~1500 CVEs) |
| ENISA EUVD | euvd.enisa.europa.eu | EU exploited + ENISA-flagged critical (~1600 CVEs) |
| CISA Vulnrichment | 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 | 2nd-stage fallback for CVEs Vulnrichment hasn't analysed yet (rate-limit-bounded, ≤ 100 per run) |
| cvelistV5 | 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 algorithm |
All sources are free, no API key required.
2. Multi-provider authentication — Strategy-Pattern auth stack with local (TOTP-MFA), LDAPS, OIDC (Entra ID / Okta / Keycloak / Google) and SAML 2.0 running in parallel, plus an admin UI for group→role mapping.
3. Nessus integration — Tenable Nessus scan import alongside Wazuh on the same (cve_id, asset_id) row. Source badges, cross-confirmation, per-host scan rows in the scan history, OS auto-fill from plugin 11936.
4. Compliance via Wazuh SCA — per-policy pass/fail/N/A summary stored in compliance_results. Dashboard widget + dedicated /compliance page with per-asset modal, nightly auto-refresh.
5. Unified Risk Score (URS) — single 0-100 figure per asset combining Asset Vulnerability Score (AVS, from CPR) and Asset Security Score (ASS, from impact-weighted SCA), multiplied by per-asset criticality. Daily snapshots feed a trend arrow. CSV-importable CIS-Benchmark impact weights drive the weighting.
6. Built-in App→CVE detection + Mobile Device Security — a curated OSV/NVD-CPE/cvelistV5 scanner maps installed software to real CVEs (and suppresses Wazuh's loose-CPE false positives) for hosts with no real vulnerability scanner; a parallel track handles mobile devices — EOL/EOS for Samsung/Apple models, Android patch-level staleness, and per-CVE Android detection via Samsung's own SMR page (preferred, precise) or Google's ASB (fallback). Plus a CISA-KEV "actively exploited" advisory feed, independent of asset findings.
Priority Score (current formula)
exploit_bonus = max(
4.0 if (CISA KEV listed) OR (ENISA EUVD listed),
3.0 × EPSS_probability,
4.0 if Wazuh exploit_available else 2.0 if Wazuh exploitable,
SSVC exploitation_status:
widespread 5.0 | active 4.0 | poc 2.0,
Nessus exploit_code_maturity:
High 4.0 | Functional 3.0 | PoC 1.5,
)
# — five sources, max() to prevent double-count
ssvc_addon = (
+2.0 if SSVC technical_impact == 'total'
+1.0 if SSVC automatable == 'yes'
) # capped at +3; adds ON TOP of exploit_bonus
# because technical_impact + automatable describe
# DIFFERENT dimensions than exploitation signals
base = (CVSS + exploit_bonus + ssvc_addon)
× asset_criticality_factor + age_bonus
catalog_bonus =
+10 if CISA KEV listed
+5 if KEV ransomware-use known
+10 if ENISA EUVD listed
+3 if ENISA-flagged critical
priority = min(base + catalog_bonus, 100)
# CPR — JacquesKruger algorithm (percentile-weighted, capped 100)
cpr_score = (CVSS × 10 × 0.6) + (EPSS_Percentile × 100 × 0.4)
CPR was previously a simple CVSS × EPSS × 10 which collapsed to
near-zero for the 99% of CVEs with EPSS < 1% — useless for triage.
Switch to JacquesKruger's percentile blend lands in 6dc182f.
What does this mean in practice?
- CVE in both CISA KEV and ENISA EUVD ⇒ +20 stacking bonus = highest confidence, shoots to the top
- High EPSS but no catalog listing ⇒ still scored aggressively (continuous 0–3 signal)
- SSVC
total + automatable + active⇒ adds 3 + 4 = 7 base bonus, marked "patch right now" - No external data ⇒ falls back to old behaviour (CVSS + Wazuh flags + asset/age)
- CVE-2026-8390 (CVSS 9.8, EPSS percentile 11.9) ⇒ CPR 63.56 (Medium) — old formula was 0.04
UI changes
Vulnerabilities list (/vulnerabilities) gains:
| Column / Filter | What it shows |
|---|---|
| EPSS column | percentile-coloured probability % |
| KEV column | red badge + RANSOM sub-badge for ransomware-use CVEs |
| EUVD column | blue badge + EU-CRIT sub-badge for ENISA-flagged critical |
| CPR column | combined CVSS × EPSS × 10 score with mini-bar |
| Filter KEV | only CVEs in CISA KEV catalog |
| Filter EUVD | only CVEs in ENISA EUVD catalog |
| Filter EU Critical | only ENISA-flagged critical CVEs |
| Filter KEV + EUVD | intersection — confirmed by both US and EU sources |
| Filter EPSS ≥ | minimum EPSS probability in percent |
| Button Refresh Threat Intel | manual bulk-enrichment trigger |
The vuln detail page shows three threat-intel sections (CISA KEV, ENISA EUVD, EPSS) plus a Priority Score breakdown that itemises every component, plus the CPR score on a separate line.
When is enrichment triggered?
- Daily scheduler job — runs every 24 h, refreshes EPSS + KEV + EUVD for all open vulnerabilities. First run 5 minutes after backend startup.
- After Wazuh sync — newly discovered CVEs are auto-enriched before they reach the UI.
- Manual "Refresh Threat Intel" button — bulk-enriches every open vuln on demand.
- 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):
{
"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 KEVeuvd_only=true— only ENISA EUVDeu_critical=true— only ENISA-flagged criticalin_any_catalog=true— KEV ∪ EUVDin_both_catalogs=true— KEV ∩ EUVDepss_min=0.5— minimum EPSS score (raw 0.0-1.0, UI sendsvalue / 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:
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
# 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/referencesfields - NVD enrichment for missing CVSS vectors / CWE-IDs
- Custom catalog ingestion (user-uploaded CSV of "internally exploited" CVEs)
Verification snippets
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):
- Click Enable MFA → confirm current password
- 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 - Enter the 6-digit code your app shows → Activate MFA
- 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:
- Looks up
(auth_provider, external_id)— exact subject match - Falls back to lookup by email → if a local user matches, auto-link
(per requirements). Logged as
USER_AUTO_LINKED. - Otherwise JIT-creates a new user stub with
password_hash = NULL, role from group mapping,is_active=true. Logged asJIT_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:
curl -X PUT https://<host>/api/v1/auth-config/role-mappings \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"mappings": {
"ldap": [
{"pattern": "CN=TrueVuln-Admins,*", "role": "admin"},
{"pattern": "CN=TrueVuln-Editors,*", "role": "editor"}
],
"oidc": [
{"pattern": "<azure-group-uuid-admin>", "role": "admin"}
],
"saml": [
{"pattern": "TrueVuln-Admins", "role": "admin"}
]
}
}'
Rules with no match fall through to AUTH_JIT_DEFAULT_ROLE (default: readonly).
Admin UI
/admin/auth (RequireAdmin) — runtime control of the runtime-controllable parts.
Endpoint config still lives in env (restart required for those changes).
- Global panel — lookup order, JIT on/off, default role, crypto-key health check
- Provider status cards — Enabled / Configured badges + user-count per provider
- Test buttons:
- LDAP: opens TLS connection, binds with service account, optionally searches a sample username to verify filter + attribute mapping
- OIDC: fetches discovery doc + JWKS, reports issuer / endpoints / key count
- SAML: parses IdP metadata, returns SSO URL + entity ID + cert presence
- Mapping editor — per-provider rule list, add / edit / reorder / remove, save via PUT
New API endpoints
| Method | Path | Role | Purpose |
|---|---|---|---|
GET |
/auth/providers |
public | Provider discovery for the login UI |
POST |
/auth/mfa/setup |
self | Begin TOTP enrolment |
POST |
/auth/mfa/activate |
self | Confirm enrolment |
POST |
/auth/mfa/disable |
self | Disable enrolment |
POST |
/auth/mfa/verify |
login flow | Second factor after /auth/login |
POST |
/auth/users/{user_id}/reset-mfa |
admin | Force-reset MFA for any user (lost-device escape hatch) |
GET |
/auth/oidc/login |
public | Start OIDC SP-initiated flow |
GET |
/auth/oidc/callback |
IdP-redirect | Complete OIDC flow |
GET |
/auth/saml/login |
public | Start SAML SP-initiated flow |
POST |
/auth/saml/acs |
IdP-POST | SAML Assertion Consumer Service |
GET |
/auth/saml/metadata |
public | Auto-generated SP descriptor |
GET |
/api/v1/auth-config/status |
admin | Provider readiness + user counts |
GET |
/api/v1/auth-config/role-mappings |
admin | Current rules |
PUT |
/api/v1/auth-config/role-mappings |
admin | Replace rules (validated) |
POST |
/api/v1/auth-config/ldap/test |
admin | Bind + optional sample search |
POST |
/api/v1/auth-config/oidc/test |
admin | Probe discovery + JWKS |
POST |
/api/v1/auth-config/saml/test |
admin | Probe IdP metadata |
Database fields (Alembic 009)
users table gains:
| Column | Type | Purpose |
|---|---|---|
auth_provider |
enum (local/ldap/saml/oidc), indexed | Strategy that owns this identity |
external_id |
string, indexed | IdP subject / objectGUID / nameID |
external_groups |
text JSON | Raw groups snapshot from last login |
last_provider_sync |
timestamp | Last refresh from IdP |
totp_secret |
string (encrypted) | Fernet-encrypted base32, NULL for SSO users |
totp_enabled |
bool | Activated after /auth/mfa/activate |
password_hash |
string, now nullable | NULL for non-local users |
New audit-event values: LOGIN_LDAP_SUCCESS, LOGIN_SSO_SUCCESS,
AUTH_PROVIDER_FAILED, JIT_USER_CREATED, EXTERNAL_ROLE_MAPPED,
USER_AUTO_LINKED, MFA_ENABLED, MFA_DISABLED, MFA_VERIFIED, MFA_FAILED.
Configuration
Provider enablement is comma-separated:
AUTH_PROVIDERS=local,ldap,oidc,saml # subset = disabled
AUTH_LOOKUP_ORDER=local,ldap # credential-strategy chain
AUTH_JIT_PROVISIONING=true
AUTH_JIT_DEFAULT_ROLE=readonly
AUTH_PROVIDER_CRYPTO_KEY=<fernet-key> # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
Account lockout
Login is rate-limited (5/min/IP) and locks a local account after
ACCOUNT_LOCKOUT_THRESHOLD (5) failed attempts. Two modes:
- Temporary (default) — auto-unlocks after
ACCOUNT_LOCKOUT_DURATION_MIN(15 min). No admin action, no self-lockout risk. - Permanent — set the
auth_lockout_permanentsetting (Settings → General) totrue. The account stays locked until an admin clears it viaPOST /auth/users/{id}/unlock(Unlock button in User Management). Safeguard:_is_last_active_admin()prevents permanently locking the last recoverable admin (it falls back to the temporary window) so a brute-force on the admin login can't lock the whole system out. Columns:users.locked,locked_at(migration 037).
Audit-log → syslog / SIEM forwarding
syslog_service.py mirrors every audit_logs insert to an external syslog
server (RFC-5424, UDP or TCP) when the syslog_config setting is enabled
({enabled, host, port, protocol, facility}; admin card under Settings). A
SQLAlchemy after_insert hook enqueues onto a bounded queue drained by one
daemon worker — never blocks the request/flush, bursts drain sequentially.
Per-event severity: FAILED/DENIED/LOCK → warning, SECURITY_ALERT/escalation →
alert, deletes/config-change → notice, else info. Disabled = cheap no-op.
No TLS yet (UDP/TCP). Test probe: POST /api/v1/settings/syslog/test.
See .env.example 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 vialdap3.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_accountto 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 onRelayStateto 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
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:
# Discovery endpoint should now list enabled providers
curl -s http://<host>/auth/providers | jq
# Local login still works
curl -s -X POST http://<host>/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}'
# Admin: provider status snapshot
TOKEN=$(curl -s -X POST http://<host>/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -s http://<host>/api/v1/auth-config/status -H "Authorization: Bearer $TOKEN" | jq
# Test LDAP bind (after configuring LDAP_* env vars)
curl -s -X POST http://<host>/api/v1/auth-config/ldap/test \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"sample_username":"jdoe"}'
# Test OIDC discovery
curl -s -X POST http://<host>/api/v1/auth-config/oidc/test -H "Authorization: Bearer $TOKEN" | jq
Audit log (/admin/audit-logs) shows LOGIN_LDAP_SUCCESS, LOGIN_SSO_SUCCESS,
JIT_USER_CREATED, EXTERNAL_ROLE_MAPPED, and MFA_* events as users start
logging in via the new paths.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Multiple head revisions are present on alembic upgrade |
Two migrations with the same revision number landed on dev (we hit this when 008-EUVD and 008-auth collided) |
Renumber yours linearly. See git log for the conflict-resolution commit (fix(alembic): rename ...). |
LookupError: '<x>' is not among the defined enum values for authprovider |
SQLAlchemy default maps enums by NAME but the postgres authprovider type was created with lowercase VALUES |
Use SQLEnum(AuthProvider, values_callable=lambda x: [e.value for e in x]) — already applied to users.auth_provider. Same pattern for any future enum where name ≠ value. |
ValueError: malformed bcrypt hash (checksum must be …) on login when username is wrong |
The constant-time dummy verify in LocalAuthStrategy had a fake/placeholder hash |
Already fixed — we generate a real hash once at module import. |
MFA setup returns 500 AUTH_PROVIDER_CRYPTO_KEY env var is not set |
Var added to .env but docker compose restart backend doesn't reload env into the existing container |
Use docker compose up -d --force-recreate backend. Verify with docker compose exec backend printenv AUTH_PROVIDER_CRYPTO_KEY. |
MFA setup returns 404 HTML with x-nextjs-cache: HIT |
Next.js had no proxy route for /auth/mfa/* so it served them as page routes |
Catch-all frontend/app/auth/[...path]/route.ts is now in place. Frontend rebuild required. |
| MFA card not visible in Settings | Frontend container has the pre-MFA build | docker compose build frontend && docker compose up -d frontend. Hard reload (Ctrl+Shift+R). |
| LDAP test bind fails immediately | Bootstrap password loaded once and encrypted in DB; subsequent decrypts fail if you rotated AUTH_PROVIDER_CRYPTO_KEY |
Clear the cached bind: DELETE FROM settings WHERE key='ldap_bind_password_encrypted'; then restart. The next start re-bootstraps from LDAP_BIND_PASSWORD_BOOTSTRAP. |
| No mail after a sync (Wazuh/Nessus/app-scan/Defender) that found new CVEs | Recipient cascade empty and the fallback also empty (finding/asset unassigned, notification_default_recipients unset, and no active admin has an email) OR severity below notification_min_severity OR notifications suppressed on the vuln OR notification_schedule=nightly (per-sync sends are deferred to the nightly roundup) |
Give an admin an email, set notification_default_recipients, assign the asset, lower the threshold, un-suppress via the bell icon, or check the delivery schedule. Verify with docker compose logs backend | grep -iE 'digest|notifications'. (SLA-breach mails have no fallback — they still require an assignee; see Asset / Finding assignment.) |
New-Vulnerability Digest Notifications
Replaces the old per-CVE inline email send during Wazuh sync. Default mode for new installs is digest: one summary mail per recipient at the end of each sync run, with a styled HTML table of every CVE above the severity threshold relevant to them. No SMTP rate-limit problems with large syncs.
How it triggers
Every sync source routes new findings through the same dispatcher
(dispatch_new_vuln_notifications()): Wazuh, Nessus, the App→CVE scanner,
and Defender TVM. (Earlier only Wazuh/Nessus did — app-scan and Defender
findings were silently skipped; fixed.) Each source calls the dispatcher after
it enriches its freshly-created findings.
After a sync completes:
- Backend collects the sync's newly-created vuln ids
- Loads them, applies
notification_min_severityfilter - Resolves recipient per CVE via the cascade
(
vuln.assigned_user→vuln.assigned_group→asset.assigned_user→asset.assigned_groups), else the default-recipients/admins fallback - Groups CVEs by recipient email
- Sends one digest mail per recipient (rows sorted by CPR desc)
- Writes one
NotificationLogper recipient (anchor vuln + total count)
If a sync created 0 new findings, no mails fire (no noise on routine syncs).
Delivery schedule (per-sync vs nightly roundup)
notification_schedule controls when mail goes out:
per_sync(default) — dispatch sends at the end of each sync run.nightly— per-sync sends are suppressed (dispatch_new_vuln_notificationsreturns early whenrespect_schedule=True). A scheduler job (new_vuln_digest_nightly, registered hourly, self-gates onnotification_nightly_hour+ the schedule flag) then callssend_nightly_new_vuln_digest()which aggregates all active findings detected since the last run — windowed via thenotification_nightly_last_runsetting so nothing double-sends or is missed — into ONE mail per recipient. Fewer mails → friendlier to provider anti-spam.
Send rate limiting
The dispatch send-loop paces mail per get_email_rate_limit():
email_rate_delay_seconds (sleep between mails) and email_max_per_run
(hard cap per dispatch; 0 = unlimited). Guards against provider bursting.
Settings
| Setting key | Default | Purpose |
|---|---|---|
notification_mode |
digest |
digest (one mail per recipient) or single (legacy, one mail per CVE) |
notification_schedule |
per_sync |
per_sync (send after each sync) or nightly (suppress per-sync, one roundup) |
notification_nightly_hour |
6 |
Hour (0–23, server time) the nightly roundup fires |
notification_nightly_last_run |
— | Internal — ISO timestamp of the last nightly roundup (window anchor) |
notification_min_severity |
critical |
Skip CVEs below this severity (critical / high / medium / low) |
notification_default_recipients |
— | Fallback recipients (comma-separated) for new-CVE mails when a finding has no assignee. Empty → all active admins. |
email_rate_delay_seconds |
0 |
Seconds to sleep between mails in a dispatch (anti-spam pacing) |
email_max_per_run |
0 |
Max mails per dispatch run (0 = unlimited) |
smtp_config |
— | Required. Without SMTP no mails go out. |
All editable via Settings → Notifications (no env-var needed).
Mail content
Subject: [TRUEVULN] N new vulnerabilities detected
Body: severity-count badges, then a table sorted by CPR descending with
columns CVE (deep link) · Severity · CVSS · CPR · Host · #Systems · Package,
and an "Open in dashboard" button. #Systems = distinct assets affected by that
CVE across the whole inventory (_affected_counts()). Default template lives in
email_service.py:DEFAULT_DIGEST_TEMPLATE, editable in Settings →
Notifications → New Vulnerability Digest Template (the email_template_new_vuln_digest
setting). The email_template_new_vuln (single-mode) template is separate and
only used when notification_mode='single' — the UI badges show which is active.
Digest top-level variables: {{total}}, {{rows}} (the pre-rendered table,
CPR/#Systems/links baked in), {{count_critical|high|medium|low}},
{{recipient_name}}, {{detected_at}}, {{dashboard_url}}.
Single-mode variables (per CVE): {{cve_id}}, {{severity_upper}},
{{cvss_score}}, {{cpr_score}}, {{affected_assets_count}},
{{asset_hostname}}, {{package_name}}, {{description}}, {{detected_at}},
{{dashboard_url}}, and ready-made deep links {{cve_link}}, {{asset_link}},
{{cve_on_asset_link}}.
Fresh-install behaviour
Out-of-box flow for a clean deployment:
- SMTP configured in Settings → Email
- A recipient exists: either the asset/finding is assigned to a user/group
with a populated
users.email, or a fallback applies (see Asset / Finding assignment below) — an active admin email, or the configurednotification_default_recipients notification_min_severityset (defaultcriticalworks for most)- First Wazuh sync runs (manual or scheduled)
- 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
docker compose logs --tail=200 backend | grep -iE "Created:|new vuln|notifications:|digest"
Expected on a sync that found new CVEs assigned to people:
Sync: New vulnerability CVE-2026-XXXX on host ... severity=critical
Digest email sent to alice@example.com: 12 CVEs
Wazuh sync notifications: 3 sent, 0 failed, 3 recipients
/notifications UI shows the resulting log row(s) per recipient.
Asset / Finding assignment — what it actually does
Assignment (a user or group, set on an asset or on an individual finding) is purely a responsibility / notification tag. It does not gate scanning, scoring, the SLA calculation itself, report contents, or visibility — RBAC is role-based, so anyone with view rights sees every CVE regardless of who it's assigned to.
Recipient cascade (both mail paths, first match wins):
finding.assigned_user → finding.assigned_group → asset.assigned_user
→ asset.assigned_groups.
What assignment controls:
| Effect | Behaviour |
|---|---|
New-CVE digest (email_service._resolve_recipients_for_vuln) |
Goes to the assignee via the cascade. Fallback when nothing is assigned: notification_default_recipients, or — if that's empty — every active admin. So "the admin gets everything" works without any assignment. |
SLA-breach digest (scheduler.check_sla_breaches) |
Goes to the assignee via the cascade only — no fallback. An unassigned SLA breach sends no mail to anyone. |
| "Assigned To" column (Assets & Vulnerabilities pages) | Display + sort only. |
| Cleanup | Deleting a user/group nulls their assignments automatically. |
Runbook recommendation: assign each asset to a system owner (a user or group with a populated email). It's the only way to (a) route new-CVE mails to the real owner instead of blanket-to-admins, and (b) make SLA-breach mails reach anyone at all — the admin fallback does not apply to SLA breaches. For a catch-all on new-CVE mails without per-asset ownership, set Settings → Notifications → Default Recipient(s) instead.
Nessus Integration (multi-scanner unified view)
Wazuh has structural detection gaps (Adobe Acrobat/Reader, MS Office /
M365, .NET 5–10, EOL/EOS software, network appliances). Nessus closes
those gaps. This branch imports Tenable Nessus scan findings via the
REST API and merges them onto the existing (cve_id, asset_id) rows so
the same UI shows the union, deduplicated. Cross-scanner confirmation
becomes a confidence signal.
Architecture
┌──────────────────────────┐
│ POST /sync/wazuh │ Wazuh agent loop
└──────────┬───────────────┘
│ ┌─────────────────────────────┐
├──►│ (cve_id, asset_id) merge │ ◄── one row, sources=[…]
│ │ - add_source('wazuh') │
│ │ - severity = max(existing, │
│ │ nessus, wazuh) │
│ └─────────────────────────────┘
┌──────────┴───────────────┐
│ POST /sync/nessus │ Nessus scan iteration
└──────────────────────────┘
A single Vulnerability row carries sources = ["wazuh","nessus"] when
both scanners reported the same CVE on the same asset.
cross_confirmed becomes True. Non-CVE Nessus findings (EOL,
Compliance, Cipher) are stored under a pseudo-CVE id
NESSUS-PLUGIN-{plugin_id} and rendered with a NON-CVE badge so they
remain visible in the existing Vulns list without polluting CVE-based
reports.
Schema delta (Alembic 010)
| Column | Where | Purpose |
|---|---|---|
vulnerabilities.sources |
TEXT (JSON list) | scanners that detected this finding |
vulnerabilities.nessus_plugin_id |
string indexed | Nessus plugin reference |
vulnerabilities.nessus_finding_uuid |
string | per-host-per-plugin stable id |
vulnerabilities.first_detected_by |
string | which scanner first reported (wazuh/nessus/manual) |
vulnerabilities.cve_id |
widened 20 → 50 | room for NESSUS-PLUGIN-* pseudo-IDs |
vulnerabilities.nessus_vpr_score |
float indexed (mig. 011) | Tenable VPR 0–10 alongside our own priority |
vulnerabilities.exploitation_status |
string indexed (mig. 012) | SSVC: `none |
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:
assets.nessus_host_uuid(pinned after first successful match)assets.hostname(case-insensitive)assets.ip_address(exact)- If still no match AND
nessus_config.auto_create_assetsis true → create a new asset withsource=manual. Default is off (safer).
Unmatched hosts are returned in the sync response under
unmatched_hosts so admins can spot drift between Nessus targets and
the TrueVuln inventory.
Merge logic per (cve_id, asset_id)
existing = find(cve, asset)
if existing:
existing.add_source("nessus")
# Nessus wins when it provides a value (typical case: Wazuh dropped
# a placeholder 10.0 on the row, Nessus has the real per-plugin score).
if nessus_cvss is not None:
existing.cvss_score = nessus_cvss
if nessus_severity and nessus_severity != "none":
existing.severity = nessus_severity
else:
existing.severity = max(existing.severity, nessus_severity)
existing.nessus_plugin_id = plugin_id
existing.nessus_vpr_score = vpr_score # always latest
if existing.status == patched:
existing.status = open # Nessus sees it again → reopen
else:
create new with sources=["nessus"], first_detected_by="nessus"
Override safety: if Nessus has no CVSS for a CVE (None), the existing
Wazuh value is preserved — no data wipe. Same for severity = none.
When a previously-Nessus-flagged CVE is missing from this scan run, we
only drop "nessus" from the sources list. If the list empties
(no scanner sees it anymore), status flips to patched. Wazuh findings
are never removed by a Nessus sync.
Manual overrides — 3-stage CVSS correction cascade
app/services/vuln_override_service.py corrects CVSS/severity/SSVC
data from authoritative feeds in a three-stage cascade. Each stage
only fetches CVEs the previous stage missed, so the cost stays
bounded even for full-DB corrections.
┌─ Stage 1 — CISA Vulnrichment ──────────────────────────────────┐
│ github.com/cisagov/vulnrichment │
│ • ZIP snapshot when > 25 CVEs (~250 MB, no cache, ~2 min) │
│ • per-CVE raw fetch otherwise │
│ • parses BOTH containers.cna AND containers.adp metrics │
│ (Microsoft, Mozilla etc. supply CVSS in cna; CISA adds │
│ SSVC decision points in adp — both contribute) │
└─────────────────────────────────────────────────────────────────┘
│ CVEs Vulnrichment doesn't have
▼
┌─ Stage 2 — NVD REST API ───────────────────────────────────────┐
│ services.nvd.nist.gov/rest/json/cves/2.0?cveId=... │
│ • bounded to ≤ 100 missing CVEs per run (rate-limit safe) │
│ • throttled 1 req/sec │
│ • picks cvssMetricV31 → V30 first hit │
└─────────────────────────────────────────────────────────────────┘
│ still missing after NVD
▼
┌─ Stage 3 — cvelistV5 (MITRE/CVE.org) ──────────────────────────┐
│ github.com/CVEProject/cvelistV5 │
│ • 557 MB ZIP, disk-cached 12h at │
│ /tmp/truevuln-cvelistv5-cache.zip │
│ • exhaustive — every published CVE (~250k+) │
│ • same CVE-5 JSON shape as Vulnrichment, parser reused │
└─────────────────────────────────────────────────────────────────┘
Verified records carry source ∈ {vulnrichment, nvd, cvelistv5} so
the operator can tell where each correction came from. All three are
treated as authoritative — Nessus sync respects an
exploitation_source of any of the three and won't clobber the
corrected score with its plugin-bundle CVSS on the next sync.
Endpoints
POST /api/v1/vulnerabilities/override/check— dry-run, lists rows that would changePOST /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" buttonPOST /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.
{
"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 loopnessus→run_nessus_sync()usingnessus_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).
✓×2emerald badge when both scanners reported. AmberNON-CVEbadge 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_positiveand 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
# 1. Migrate
git pull origin dev
docker compose build backend frontend && docker compose up -d backend frontend
docker compose exec backend alembic upgrade head # 009 → 012
docker compose exec backend alembic current # 012 (head)
# 2. Backfill check
docker compose exec postgres psql -U vulnmanager -d vulnmanager -c "
SELECT first_detected_by, COUNT(*) FROM vulnerabilities GROUP BY first_detected_by;"
# wazuh = <existing>, NULL = 0, nessus = 0 (until first sync)
# 3. Set nessus_config via the Settings UI, then Test connection.
# 4. First sync
TOKEN=$(curl -sk -X POST http://<host>/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"…"}' | jq -r .access_token)
curl -X POST http://<host>/api/v1/vulnerabilities/nessus/sync \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
# returns {scans_processed, hosts_synced, vulns_created, vulns_merged,
# scores_overridden, vulns_marked_patched, unmatched_hosts[],
# notifications:{…}}
# 5. Cross-confirmation listing (CVEs in both scanners)
curl -s "http://<host>/api/v1/vulnerabilities?cross_confirmed=true&limit=5" \
-H "Authorization: Bearer $TOKEN" \
| jq '.items[] | {cve_id, sources, first_detected_by, priority_score}'
# 6. False-positive flow
curl -X PATCH http://<host>/api/v1/vulnerabilities/123/false-positive \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason":"Vendor confirmed N/A in our config"}'
UI smoke test:
- Source column shows correct badges per row
- Filter "Nessus only" shows only
sources=["nessus"]entries - Filter "Cross-confirmed" shows only multi-scanner entries
- "Sync now" button surfaces stats inline on the Settings card
- Click NoSymbolIcon → prompt → vuln moves to FALSE_POSITIVE status
Nessus — out of scope for v1
- Triggering Nessus scans from TrueVuln (we only import existing scans)
- Two-way sync of false-positive status back to Nessus
- Network discovery / SNMP-based asset auto-creation beyond the hostname/IP-match step
- Scheduled-sync configuration UI for
scanner_type='nessus'(backend is ready; UI hook still pending)
Compliance + Unified Risk Score (URS)
End-to-end compliance scoring built on Wazuh's Security Configuration Assessment (SCA) and the colleague's impact-weighted approach. Two layers:
- SCA refresh pulls
/sca/{agent_id}from Wazuh per asset and stores per-policy pass / fail / N/A counts. - 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
Compliancebetween Assets and Scan Jobs /compliancepage:- 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),
↻ recomputebutton - 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 Criticalitydropdown with multiplier labels (×0.7 .. ×1.5) for transparency.
Verification
# 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
complianceblock parsing for cross-framework mapping (cis_csc_v8, iso_27001, pci_dss, nist_800_53 etc.) — Wazuh emits it; we don't persist the mapping yet - Time-decay weight when
last_synced > 7 days(data confidence)
App→CVE Detection Engine & Mobile Device Security
Closes the coverage gap where a device has no real vulnerability scanner (Intune-only endpoints, mobile devices) or where the scanner's own CPE matching is too loose (false positives) or too narrow (false negatives).
Built-in App CVE Scanner
Maps installed software (Wazuh syscollector packages, Intune detectedApps)
to real CVEs via two independent, complementary sources:
| Source | Module | Strength |
|---|---|---|
| OSV.dev | app_cve_scanner_service.py |
Precise server-side version matching for language ecosystems (npm, PyPI, ...) |
| NVD-CPE | app_cve_scanner_service.py |
Broad desktop-app coverage via curated CPE registry + own version-range check (cpeMatch start/end incl/excl) |
| cvelistV5 range match | cvelistv5_scan_service.py |
Catches CVEs NVD hasn't CPE'd yet, or filed under a CPE product string we didn't curate (e.g. a TeamViewer CVE under teamviewer:remote, not teamviewer:teamviewer) — matches directly against the CNA's own affected[].vendor/product + version ranges |
Both registries are curated (name-regex → vendor/product), not fuzzy —
unknown software is skipped rather than guessed, to keep false-positives
near zero. Findings upsert as source='app-scan' with real CVE ids, so the
normal EPSS/KEV/CVSS enrichment and multi-source cross-confirm apply.
The cvelistV5 path needs a reverse index ({vendor,product} → CVE ranges) built by walking the ~557 MB cvelistV5 ZIP once; cached in a
Setting, rebuilt by the nightly job. A manual "App CVE Scan" run builds it
on demand if missing (slower on first run, then cached).
False-positive suppression (same cvelistV5 data, inverse direction):
Wazuh's own CPE matching sometimes over-reports across product editions
(e.g. flags a SQL Server 2019 host with a CVE that only affects 2022/2025).
cvelistv5_scan_service.suppress_false_positives marks a Wazuh finding
false_positive only when the installed version is provably outside
every clean cvelistV5 range for the matched product — conservative by
design (Wazuh-sourced only, ≥2 shared significant tokens required to scope
a product, any unbounded/ambiguous range aborts the check).
Endpoints: POST /api/v1/vulnerabilities/app-cve-scan,
POST /api/v1/vulnerabilities/suppress-false-positives (both scoped to
asset_id optionally). Nightly job runs the scan, rebuilds the cvelistV5
index, then runs suppression.
Mobile Device Security (Intune)
Runs inline during the Intune sync — no extra Graph calls beyond the
device dict and detectedApps already fetched.
- Device EOL/EOS (
mobile_eol_service.py) — Apple (iPhone/iPad) fuzzy- matches endoflife.date's full release list by marketing name; Samsung needs a curated SM-code → release-name table (no textual bridge exists between Intune's model code and endoflife's marketing name in either dataset) — ~120 models, all verified against the live endoflife API. - OS-level CVEs — iOS/iPadOS/macOS via NVD-CPE (
app_cve_scanner_service), with a platform check (CPEtarget_swtoken) so e.g. a Firefox-for-iOS CVE can't match a desktop Firefox install. - Android patch-level staleness — Intune's
androidSecurityPatchLevelvs. today; graduated severity (≥90/180/365 days → low/medium/high). - Android per-CVE detection — two sources, Samsung preferred:
- Samsung SMR (
samsung_smr_service.py) —securityUpdate.smsb?year=YYYYserves the full year's ~12 monthly sections server-side (the accordion UI is pure CSS/JS, doesn't gate content); parses eachSMR-MMM-YYYYblock's Google Critical/High list minus "Not applicable to Samsung devices" (chipset-specific CVEs Samsung's own page excludes) plus Samsung Semiconductor fixes. Precise per-device applicability. - Google ASB (
android_cve_service.py) — fallback for non-Samsung Android or months SMR doesn't cover. Section-aware parser keeps only AOSP sections (Framework/System/Kernel/...), drops SoC/vendor sections (Qualcomm/MediaTek/...) that only apply to that specific chipset. URL format changed in 2026 (/bulletin/{year}/{month}vs. the older flat/bulletin/{month}) — both tried. - Both cache per-month/year in a Setting; empty/404 months are negatively cached (short TTL) so a not-yet-published month doesn't trigger a re-fetch on every device sync.
- Samsung SMR (
Dashboard: a dedicated "Mobile Security · EOL & Patch Level" widget,
kept separate from the desktop-software EOL widget, sorted so a reached
vendor-EOL outranks patch-level staleness. GET /api/v1/vulnerabilities? finding_type=mobile backs both the widget and its "View All".
Advisory Awareness Feed
Independent of asset findings — a rolling view of what's actively
exploited in the wild (CISA KEV), so 0-days are visible before any scanner
flags an affected asset. Reuses the KEV catalog enrichment already
fetches/caches (24h). GET /api/v1/advisories/kev-recent → dashboard
widget with an "in inventory / not seen" badge per CVE (one grouped query,
no per-CVE lookup).
Assets: filter by sync source
GET /api/v1/assets?source=... now filters by the actual scanner
linkage (wazuh_agent_id / nessus_host_uuid / intune_device_id /
defender_machine_id / a vuln row whose sources names that scanner) —
not the creation-time source enum, which never updates after an asset is
matched by a second scanner post-creation.
Out of scope (future ideas)
- Samsung-proprietary SVE CVEs (no ASB equivalent; would need
security.samsungmobile.com's per-device JS-loaded detail view, not scrapeable without a browser) - Android bulletin OEM coverage beyond Samsung (Google Pixel / others) — ASB-only fallback already covers them, just without a vendor-specific applicability filter
- SAP (Business Client / GUI / Analysis for MS Office) CVE detection needs a patch-level (SP/PL) aware matcher — NVD's CPE covers a whole minor version with no SP granularity, so a naive match false-positives on already-patched installs. Needs a curated per-CVE fixed-SP table.
- Microsoft Teams classic EOL flag — no endoflife.date product exists; would need a hardcoded retirement-date exotic (same shape as the existing Silverlight/VC++ redistributable entries)
Out of scope (future ideas)
- LDAP password change flow (currently read-only — users change pw in AD)
- SCIM v2 endpoint for IdP-driven provisioning (push instead of JIT pull)
- WebAuthn / FIDO2 second factor as alternative to TOTP
- Risk-based MFA (require TOTP only from unknown IP / unknown device)
- LDAP referral chasing across multiple forests
- Encrypted SAML assertions (currently only signed)
- OIDC back-channel logout
