Tester feedback: 'Last Vulnerabilities' on the dashboard should be
sorted by CVE-ID descending (newest CVE number first) — not by
detected_at. Backend already exposes sort_by=cve_id.
Tester also missed the Tenable VPR Score because it was only rendered
on the vuln detail page. Adds a small colored VPR badge in the
Priority column of the vulns list when nessus_vpr_score is present,
reusing the colour scale from the detail page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous load_cisa_vulnrichment_data() hit a non-existent feed
URL (cisa.gov/sites/default/files/feeds/vulnrichment.json) and parsed
a flat shape that does not match the real Vulnrichment 2.x format.
Result: the 'Fix CVSS' button silently corrected nothing, e.g.
CVE-2026-8390 stayed at the Wazuh placeholder 10.0 instead of the
authoritative 7.3 HIGH from CISA.
This change:
- Fetches per-CVE JSON from
raw.githubusercontent.com/cisagov/vulnrichment/develop/{year}/{N}xxx/
{CVE-YYYY-N}.json. 404 = CVE not yet analysed by CISA (very common
for new CVEs) and is treated as 'no data', not an error.
- New _parse_vulnrichment_record() walks containers.adp[].metrics[] to
extract cvssV3_1.baseScore / baseSeverity plus the SSVC Exploitation
option (none|poc|active|widespread).
- correct_vulnerability_scores() now returns a not_found counter so the
UI can distinguish 'feed unreachable' from 'CVE not in feed yet'.
- Vulns page alert appends 'N CVEs noch nicht im Feed' when not_found>0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the generic round-shield-with-thin-check mark with a logo that
matches the actual brand palette:
- Shield body uses the vulncheck-blue (#0066FF) primary token with a
subtle vertical gradient to deeper royal blue. Reads well on the
bg-gray-900 sidebar and the bg-gray-50 login screen alike.
- Bold white check (44pt stroke) with a soft inner highlight — no toy /
crosshair vibe.
- Small securis-success (#10B981) ping at the check terminus = the
"vulnerability verified / cleared" semantic.
- Stays crisp at 24px (favicon, browser tab) up to 240px (login).
No code changes required — all consumers (layout.tsx favicon, login
page, AppShell, Drawer) already point at /logo.svg.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- plugin_cvss() now uses _plugin_attrs() helper (eliminates duplicate
inline path extraction — single place to update if Nessus changes payload)
- non_cve_skipped initialised to 0 in stats dict so the field is always
present in the sync response even when every finding has a CVE
- vuln detail page: show title + description separately instead of
title || description (description was silently hidden when title existed)
- vuln detail page: add "Detection Sources" card — source badges (WAZUH /
NESSUS), cross-confirmed indicator, first_detected_by, Nessus plugin ID
(links to tenable.com/plugins), and Tenable VPR score with colour coding
Nessus plugin payloads carry more than just severity + CVE — we now
extract and store everything actionable:
- nessus_vpr_score (new column, migration 011): Tenable's VPR rating
(0-10), independent from our own priority_score
- exploit_available (existing boolean): True when Nessus knows of a
public exploit; only escalates, never overwrites Wazuh-confirmed True
- exploit_maturity (existing string): Unproven / PoC / Functional / High
- description: prose description + 'Solution:' section, only set when
empty so hand-written notes survive
- references: see_also URL list as JSON, only when existing is null
Create path sets all fields directly; merge path backfills empties so
existing Nessus findings get enriched on the next sync. API response
adds nessus_vpr_score; frontend Vulnerability type mirrors it.
- nessus_sync: new findings now store plugin_name as package_name so the
Vulns table's PACKAGE column shows the affected software for Nessus-only
rows (was blank). Merge path backfills package_name / title on existing
rows that were created before this fix.
- vulnerabilities router: sort_by='source' maps to first_detected_by,
so users can group rows by scanner from the table header.
- Vulns page: Source column header is now clickable with the same
hover-style and SortArrow as the other sortable columns.
LDAP users can now enable/disable app-side TOTP just like local users.
Password confirmation during setup/disable is verified by re-binding to
LDAP as the user — the password itself is never stored.
- orchestrator: MFA gate no longer restricted to local accounts; any
credential-authenticated user with totp_enabled is challenged
- auth router: _verify_user_password helper routes to password_hash
(local) or LDAP rebind (ldap); SAML/OIDC remain rejected
- MfaCard: shows enrolment UI for LDAP users with a hint that the LDAP
password is verified via directory rebind
- ScanSchedule: scanner_type field now exposed in API schema + UI
modal dropdown (Wazuh / Nessus); existing schedules default to wazuh
- Schedule list shows WAZUH/NESSUS badge per row
- NessusClient: _post() helper + launch_scan(scan_id, alt_targets)
uses Nessus POST /scans/{id}/launch with alt_targets override
- POST /api/v1/vulnerabilities/nessus/scan-host: resolves asset IP,
picks scan template from config/request, launches targeted Nessus
scan on that single host — useful for post-patch rescans
- Assets page: purple globe button per asset with IP address triggers
targeted Nessus scan via scan-host endpoint
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two feedback items from a tester running Windows 11 in Dark Mode:
1. Form inputs (login fields, search boxes, date pickers, autofill)
were rendered with the browser's dark color scheme on top of the
app's light-gray background, making text + placeholders unreadable.
Set color-scheme: light globally (:root + html/body) so the browser
chrome stays light regardless of OS preference. Hardened autofill
styling for Chromium so OS dark-mode can't tint autofilled inputs.
Forced placeholder color to gray-400 for consistent contrast.
2. Dashboard 'Recent Vulnerabilities' table was sorted by priority
(backend default) instead of newest-first. Changed the dashboard
fetch to ?sort_by=detected_at&sort_order=desc so the list now
shows the most-recently detected CVEs at the top, matching the
intent of the section title.
Neither change touches role-based UI hiding from the previous commit;
the sidebar mapping stays as-is.
Sidebar now respects the logged-in user's role and hides items that
would 401/403 anyway when clicked. Mapping:
Dashboard, Vulnerabilities, Assets, Reports, Notifications, Settings
── all roles
Scan Jobs, Policies
── editor + admin
Groups, Audit Logs, Auth Providers
── admin only
Implementation:
- /auth/me read on mount; role cached in component state
- nav items get an optional 'requires: admin | editor' marker; items
hidden when current role rank is below required rank
(readonly < editor < admin)
- pre-resolve render shows only items without 'requires' to avoid
the admin-only entries flashing on slow networks before /auth/me
returns
- Management section header is hidden entirely when no items in it
pass the role check (cleaner UI for readonly users)
This is a UX hide, not a security boundary — the backend already
enforces RequireAdmin / RequireEditor on the routes. Typing the URL
manually still hits a 403.
Adds a parallel 'Sync Data (Nessus)' button next to the existing
'Sync Data (Wazuh)' button on /scans. Same UX pattern: confirm dialog,
spinning icon during run, toast with stats (created / merged / hosts /
unmatched) afterwards.
POSTs /api/v1/vulnerabilities/nessus/sync with an empty body, so it
uses nessus_config.default_scan_ids from Settings. If Nessus isn't
configured the backend returns 400 and the toast surfaces the
message.
Backend (app/routers/vulnerabilities.py):
- _build_vuln_response includes sources[], cross_confirmed,
first_detected_by, nessus_plugin_id, is_pseudo_cve
- New query params on GET /api/v1/vulnerabilities:
?source=wazuh|nessus|manual — filter by scanner attribution
?cross_confirmed=true — only multi-scanner findings
Implementation uses SQL LIKE on the sources JSON text (rows with a
comma in the JSON list have >=2 sources, sufficient for our scale).
- New endpoints:
PATCH /api/v1/vulnerabilities/{id}/false-positive
PATCH /api/v1/vulnerabilities/{id}/unmark-false-positive
Both audit-logged. False-positive marks set
notification_suppressed=true automatically (no SLA-breach spam).
Frontend:
- frontend/types/index.ts: sources, cross_confirmed, first_detected_by,
nessus_plugin_id, is_pseudo_cve fields
- frontend/app/vulnerabilities/page.tsx:
* Filter bar gains a Source dropdown (all/wazuh/nessus/manual) and
a Cross-confirmed checkbox
* New 'Source' column between Status and Assigned-To with per-scanner
badges (Wazuh green, Nessus purple, manual gray), ✓×2 emerald
badge when cross-confirmed, NON-CVE amber badge for pseudo-CVE
(Nessus EOL / Compliance / Cipher findings)
* NoSymbolIcon button in Actions: click → prompt for reason →
PATCH /false-positive. Re-click on a false-positive vuln reverts
back to open.
This makes the Wazuh ∪ Nessus merged view actionable: admins see which
findings have multi-scanner confirmation and can mark Nessus-only false
alarms without affecting Wazuh-confirmed entries.
Adds a Tenable Nessus integration card to Settings → Integrations Status
mirroring the existing Wazuh entry:
- Card shows Active/Config-Required badge based on whether base_url +
access_key are set
- 'Configure' opens a modal with: base URL, access + secret keys (secret
field is password type), default scan IDs (comma-separated), Verify
SSL toggle, Auto-create-assets toggle
- 'Test connection' button hits POST /api/v1/vulnerabilities/nessus/test
and reports server version + plugin set + visible-scan count
- 'Sync now' button on the card (visible only once configured) hits
POST /api/v1/vulnerabilities/nessus/sync and surfaces the stats
- Status row reflects last sync result (created / merged / hosts /
unmatched) inline on the card
API client uses the existing /api/v1/[...path] catch-all proxy — no new
route files needed.
Saving normalises default_scan_ids: comma string → number[] before PUT.
Existing config is parsed back into the same form on next page load.
Replaces the per-CVE inline email send during Wazuh sync with a batched
digest dispatcher that runs once at the end of the sync.
- Old behavior (now opt-in via notification_mode=single): one email per
CVE per recipient. A 100-CVE sync with 3 assignees = 300 mails, often
triggering SMTP rate limits (Gmail/Outlook/Proton ~20/min).
- New default (notification_mode=digest): one email per recipient with
a styled HTML table listing every CVE relevant to them (severity
counts at the top, sorted rows below, button to dashboard). 100 CVEs
× 3 assignees collapse to 3 mails.
Implementation:
- email_service.dispatch_new_vuln_notifications(db, new_vulns):
resolves recipients per vuln (vuln.assigned > group > asset.assigned
> asset.groups), applies notification_min_severity, groups by email,
and sends. Logs one NotificationLog per recipient.
- email_service.send_new_vulnerability_digest() renders new
DEFAULT_DIGEST_TEMPLATE (responsive HTML, severity badges, table).
- email_service.get_notification_mode() reads notification_mode setting.
- Sync paths (run_wazuh_vulnerability_sync, sync_agent_vulnerabilities)
now accumulate newly_created_vuln_ids and call the dispatcher once
after the loop instead of mailing inline.
- Settings UI gains a 'Delivery Mode' dropdown next to the severity
threshold.
Adds an admin-only escape hatch for the 'user lost their device'
scenario:
- POST /auth/users/{user_id}/reset-mfa (RequireAdmin)
Clears totp_enabled + totp_secret. User can then log in with
password only, will be prompted to re-enrol via /settings.
Audit-logged as MFA_DISABLED with admin actor.
- Settings → User Management gets a 'Reset MFA' button next to
Reset PW / Edit / Delete. Confirmation prompt explains the
security implication.
- frontend/app/auth/users/[userId]/reset-mfa/route.ts proxies the
request to backend (matches existing reset-password proxy pattern).
Note: the user-facing 'Disable MFA' flow in /settings still requires
password confirmation; this new endpoint is for admins resetting
someone else.
POST /auth/mfa/setup returned 404 with x-nextjs-cache: HIT because the
Next.js app proxied each /auth/* endpoint via an individual route.ts
file (login, logout, me, register, users) and had no entry for the new
sub-paths added in the multi-provider auth work.
Solution: a catch-all [...path] route under /auth that forwards anything
not handled by an explicit route to the backend via proxyRequest().
Covers /auth/mfa/{setup,activate,disable,verify}, /auth/providers,
/auth/oidc/{login,callback}, /auth/saml/{login,acs,metadata}.
Specific routes (login, me, users, ...) keep priority — Next.js matches
exact segments before catch-alls.
Adds a self-service MFA setup UI for local-auth users:
- frontend/components/auth/MfaCard.tsx — three-stage card:
1. Idle → 'Enable MFA' / 'Disable MFA' (state-aware)
2. Setup → confirm password → POST /auth/mfa/setup, receive secret+URI
3. Activate → QR (qrcode.react SVG, all client-side, no external service)
+ readable secret fallback + 6-digit code field → POST /auth/mfa/activate
Disable flow: password confirm → POST /auth/mfa/disable. Card hides
itself for non-local users (their IdP handles MFA).
- frontend/app/settings/page.tsx renders MfaCard above the existing
'Your Profile' panel.
- /auth/me now returns auth_provider and mfa_enabled so the card can
decide which state to show without an extra fetch.
- qrcode.react dependency added (~20KB, MIT). QR renders locally as SVG;
the otpauth secret never leaves the browser.
Phase 6: gives admins runtime control over the parts of the multi-provider
auth stack that should be editable at runtime, while keeping credentials/
endpoint config in environment variables (where they belong).
Backend (app/routers/auth_admin.py):
- GET /api/v1/auth-config/status provider enablement + readiness + user counts
- GET /api/v1/auth-config/role-mappings current mapping rules
- PUT /api/v1/auth-config/role-mappings replace mapping rules (validated against
AuthProvider + UserRole enums)
- POST /api/v1/auth-config/ldap/test opens LDAPS conn, binds service account,
optionally searches a sample username
- POST /api/v1/auth-config/oidc/test fetches discovery doc + JWKS, reports
issuer / endpoints / key count
- POST /api/v1/auth-config/saml/test parses IdP metadata, returns SSO URL
+ entityID + cert presence
All RequireAdmin. PUT logs CONFIG_CHANGE audit event.
Frontend (frontend/app/admin/auth/page.tsx):
- Global config panel (lookup order, JIT, default role, crypto-key set?)
- Provider status cards with enabled/configured badges + per-provider
Test button (with output dump)
- Inline role-mapping editor per provider: add/edit/reorder/remove rules,
validates client-side (role dropdown), saves via PUT
Sidebar: new 'Auth Providers' entry under /admin/auth (KeyIcon).
Phase 2-4 of multi-provider authentication. All three providers slot
into the AuthOrchestrator from phase 1; no further changes to
/auth/login are needed.
LDAPS (app/auth/strategies/ldap_strategy.py):
- ldap3 with strict TLS cert validation (CERT_REQUIRED + CA bundle)
- Service-account search-then-bind flow (DN never exposed to caller)
- Filter chars escaped via ldap3.utils.conv.escape_filter_chars
- AD-flavored defaults: sAMAccountName / memberOf / objectGUID
- Refuses if filter returns >1 entry (anti-impersonation safety)
- Bind password encrypted at rest (Fernet), bootstrapped from env on
first start then stored in settings table
OIDC (app/auth/strategies/oidc_strategy.py + app/routers/auth_oidc.py):
- Authorization Code + PKCE (S256)
- Strict ID-Token validation via Authlib: signature (JWKS w/ auto-refresh
on rotation), iss (essential), aud (essential, must == client_id),
exp (essential), nonce (replay protection)
- State/PKCE-verifier/nonce stored in signed itsdangerous cookie (no
server-side session store needed)
- Discovery + JWKS cached in-process; JWKS auto-refetched on key miss
- Groups merged from both id_token claims and userinfo endpoint
- Hardened: prompt=select_account to defeat silent IdP reuse
SAML 2.0 (app/auth/strategies/saml_strategy.py + app/routers/auth_saml.py):
- python3-saml (OneLogin) with strict=true; xmlsec1 handles signature
validation. XSW attacks mitigated via strict assertion/response
signature position checks plus wantAssertionsSigned=true
- SP-initiated (/auth/saml/login) + IdP-initiated (POST /auth/saml/acs)
- /auth/saml/metadata serves signed SP descriptor
- RelayState same-origin check to prevent open redirect
- IdP metadata loaded from URL or file at startup
Wiring:
- app/main.py imports auth_oidc/auth_saml routers behind try/except so
the app still starts when authlib or python3-saml aren't installed
- Frontend login page fetches /auth/providers and renders matching
redirect buttons (Sign in with Entra ID / SAML SSO) plus the local
credentials form. Adds MFA second-step screen with 6-digit OTP input
when /auth/login returns mfa_required=true.
.env.example: full provider config blocks with worked examples for
Entra ID, Okta, Keycloak, Google. Each block is commented with the
exact format the corresponding admin needs.
Column help popovers, scroll controls, enrichment confirm/alert dialogs,
and aria-labels were in German — translated to English for consistency
with the rest of the app.
Page was capped at 1280px (max-w-7xl), wasting horizontal space on
wide monitors. Now uses w-full so table expands with viewport. The
AppShell already provides responsive horizontal padding (px-4 sm:px-6
lg:px-8), so we just let the table flow into all of it.
Sticky header bar also extended with matching responsive -mx/-px so
its background fills the visible width.
Reduces wasted vertical space before the table:
- Heading h2 text-3xl -> text-xl, mb-6 -> mb-2
- Counter inline with heading ("N total · M shown") instead of separate row
- Filter bar padding p-4 -> p-3
- Filter bar wrapped in sticky top-0 div with backdrop-blur so it stays
accessible when scrolling down through results. No need to scroll back
up to change a filter.
Table row data stays scrollable as before — sticky thead inside an
overflow-x-auto container is a CSS conflict (axis bleed) so we skip
that for now.
Replaces title-attribute tooltip with a proper React-controlled popover.
Click on the (i) icon now opens a 320px popover with formatted text,
close button, click-outside-to-close behaviour. Hover tooltip kept as
fallback via title attribute for quick peek.
Adds ← → arrow buttons above the table that appear only when horizontal
scroll is needed and disable themselves at the scroll boundary. Solves
the case where a mouse doesn't have a horizontal scroll wheel and the
Actions column was unreachable.
Also forces a visible horizontal scrollbar via .scroll-visible-x CSS
(macOS hides overlay scrollbars by default), so users can grab and
drag the scroll thumb directly.
Adds InformationCircleIcon next to each metric column with a hover-tooltip
explaining what the score means, value range, and source. Saves users
having to research EPSS / CISA KEV / ENISA EUVD / CPR externally.
Click on the icon stops propagation so sort toggle doesn't fire when
user just wants to read the explanation.
Sticky Actions cell had no z-index and used inline conditional bg,
which let the Assigned-To dropdown render behind it visually. Now uses
z-10, left border, stronger shadow, and Tailwind group-hover for proper
hover state matching the row.
Also reduced Assigned select maxWidth 140 -> 120 so its dropdown
doesn't extend into the sticky-right reserved space.
EUVD (EU Vulnerability Database, ENISA) integration as second
authoritative catalog alongside CISA KEV. EU-Compliance use cases
benefit from a non-US source; CVEs confirmed by both catalogs get
the highest priority via score stacking.
Two ENISA endpoints are merged into one cached map (24h TTL):
- /exploitedvulnerabilities (analogous to CISA KEV)
- /criticalvulnerabilities (ENISA Critical flag)
Priority-Score formula:
- Exploit-Signal now triggered by KEV OR EUVD listing
- Catalog-Bonus stacking: KEV +10, EUVD +10, KEV-ransomware +5,
EU-Critical +3. A CVE in both catalogs adds +20 base.
CPR Score (Cybersecurity Priority Risk = CVSS x EPSS x 10) added
as separate metric next to Priority, per JacquesKruger/EPSS-Server
convention. Calculated on-the-fly, no DB column needed.
New API filters: euvd_only, eu_critical, in_any_catalog,
in_both_catalogs. Setting toggle enrichment_euvd_enabled (default true).
Frontend: new EUVD column (blue badge, EU-CRIT sub-badge), CPR column
with mini-bar, four catalog filter checkboxes. Detail page splits
threat intel into CISA KEV / ENISA EUVD / EPSS sections; breakdown
shows EUVD bonus row and CPR score with both-catalogs hint.
Filter input previously expected the raw 0.0-1.0 EPSS value while
the table column displayed percent — confusing UX. Input now matches
the column format: typing "60" filters for EPSS >= 60%.
Add a settings-page dropdown (Critical / High+ / Medium+ / Low+) that
controls which severities trigger a 'new vulnerability' email during
Wazuh sync. Stored in the existing settings KV table under
'notification_min_severity', default 'critical' (no behaviour change
on upgrade).
Backend:
- email_service.should_notify_for_severity(db, severity) reads the
threshold from settings and compares using a severity rank.
- Both sync paths in vulnerabilities.py now delegate to the helper
instead of the hard-coded 'critical only' check (the comment
already lied about 'CRITICAL or HIGH').
Frontend:
- New control in the Notification Settings section, persists via the
generic /api/v1/settings/{key} PUT endpoint.
Risk score now pulls from multiple threat intel sources instead of
only AI/CVSS data:
- EPSS (FIRST.org) — probability of exploitation in next 30 days
- CISA KEV — known actively exploited vulnerabilities (with ransomware flag)
- Existing Wazuh exploit flags as fallback
Adds DB columns (epss_score, epss_percentile, kev_listed, kev_*,
enrichment_sources, enrichment_updated_at), an enrichment_service
with cached KEV catalog (24h TTL in settings table) and batched
EPSS lookups, manual + bulk + KEV-refresh endpoints, automatic
enrichment after Wazuh sync, and a daily scheduler job to refresh
scores.
Frontend gets KEV badges, EPSS column with percentile, KEV-only +
EPSS-min filters, a "Refresh Threat Intel" button, and a priority
score breakdown card on the detail page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
router.push('/') performs client-side navigation, which causes AppShell
to mount and fire /auth/me before the browser has committed the
Set-Cookie from the login response. The result was a 401 on the first
/auth/me, bouncing the user back to /login as if the credentials were
wrong; only after a couple of retries (and an eventual hard reload)
would the cookie be picked up and the dashboard load.
Switch to window.location.href = '/' so the browser performs a full
navigation. The cookie jar is guaranteed to be committed before the new
page loads, so the very first /auth/me succeeds.
Axios interceptor redirected to /login on any 401, including the
/auth/me probe itself, causing infinite reload loop when the session
cookie was missing or expired. Skip redirect for auth-check endpoints
and when already on /login.
Perf:
- AppShell: auth check once on mount instead of every pathname change
- Dashboard: replace bare <a> with Next Link for prefetch
Security:
- Migrate python-jose to PyJWT (CVE-2024-33663, CVE-2024-33664)
- JWT exp/iat now UTC-aware via datetime.now(timezone.utc)
- Drop default 'changeme' fallback for DEFAULT_ADMIN_PASSWORD
- Force POSTGRES_PASSWORD env in docker-compose
- Add Wazuh severity string as fallback when CVSS score is missing,
preventing critical vulns from being classified as "none"
- Fix solved-filter to check only the LATEST alert status per CVE
instead of filtering all CVEs that were ever solved (fixes re-emerged
vulns being incorrectly hidden)
- Try both score.base and score.base_score for indexer compatibility
- Add detailed sync logging (severity breakdown, new/updated counts)
- Show time alongside date for detected_at in frontend and PDF reports