Commit Graph
151 Commits
Author SHA1 Message Date
vulncheck ba466bbf51 feat(compliance): dashboard widget — avg score + check totals + worst 3
Adds a new card above the AI recommendations on the dashboard when
any compliance data exists. Hidden entirely until at least one asset
has SCA results (so first-time users don't see an empty card).

Three columns:
- Avg score (big colour-coded number + progress bar, asset/policy counter)
- Check totals (Pass / Fail / N/A — same colour convention as detail page)
- Worst offenders (top 3 lowest-avg-score assets, click → /compliance)

Hits /api/v1/compliance/summary alongside existing dashboard calls in
a single Promise.all so no extra round-trip when the page renders.
'Open >' link top-right jumps to the full /compliance page.

Colour bands match the compliance page convention:
    >= 90 emerald | >= 70 yellow | >= 40 orange | < 40 red
2026-05-19 08:23:23 +02:00
vulncheck f3aec48c8f feat(compliance): /compliance page + sidebar nav entry
New top-level page at /compliance with:

- 4-card header (Avg score, Assets covered, Pass total, Fail total)
- Worst-performing assets list (top 5) with click-through
- Full assets table sortable by avg score (lowest first)
- Asset detail modal: per-policy bars with pass/fail/N/A counters,
  per-policy refresh button that hits /compliance/{asset_id}/refresh

Score colour bands match security-team convention:
    >= 90 emerald  | >= 70 yellow  | >= 40 orange  | < 40 red

Refresh All button hits /compliance/refresh; per-asset modal has its
own ↻ refresh button. Errors surface inline at the top of the page.

Sidebar nav: 'Compliance' between Assets and Scan Jobs, CheckBadge
icon. Visible to every authenticated user (read endpoints accept
readonly). Editor role still needed for the refresh actions — those
fail with 403 from backend if the user lacks privilege.

Per-check deep-dive view (compliance_checks table) not yet wired —
will land alongside the dashboard widget in the next commit.
2026-05-19 08:21:34 +02:00
vulncheck 06096327a3 fix(dashboard): replace hardcoded 'Sync active' with real schedule count
Tester noticed each stat card on the dashboard always showed the
text 'Sync active' even when both scheduled scan jobs had been
removed in Settings → Scan Schedules. The label was hardcoded with
no actual logic behind it — pure decoration that looked like a
status indicator.

Dashboard now fetches /api/v1/scans/schedules alongside the existing
stats + recent-vulns calls and shows the real state per card:

  enabled count > 0  → emerald pulse dot + 'N scheduled syncs'
  enabled count = 0  → grey dot     + 'No scheduled syncs'
  before fetch lands → grey text    + 'checking…'

Tooltip explains where to enable schedules and notes that without
them the data only updates on a manual sync trigger.

Pure frontend change, no API additions.
2026-05-18 15:11:14 +02:00
vulncheck 6dc182f75a fix(cpr): switch to percentile-weighted formula (JacquesKruger)
Tester's measurements: CVE-2026-8390 (CVSS 9.8, EPSS 0.04%) was
scoring CPR 0.04 — useless for triage even though the CVE itself is
HIGH severity with confirmed corrections. Root cause: the previous
formula was CVSS × EPSS × 10, which collapses to near-zero for the
99% of CVEs whose EPSS probability is sub-percent.

New formula (matches the JacquesKruger/EPSS-Server reference the
tester linked):

    CPR = (CVSS × 10 × 0.6) + (EPSS_Percentile × 100 × 0.4)
        capped at 100.

EPSS_Percentile comes from the existing epss_percentile column when
present (modern enrichment fills it), with a fallback to the raw
epss_score for legacy rows.

Same CVE-2026-8390 example after the change:
    CVSS 9.8 → cvss_pct = 98
    EPSS percentile 0.119 → epss_pct = 11.9
    CPR = 98 × 0.6 + 11.9 × 0.4 = 58.8 + 4.76 = 63.56 (Medium)

Frontend tooltips on the dashboard, vulns list, and detail-page
breakdown updated to advertise the new formula so operators can see
why a row scored what it did.

cpr_score is computed on the fly (not stored), so all existing rows
pick up the new formula on next API read — no DB migration needed.
2026-05-18 11:32:53 +02:00
vulncheck de896f7e28 feat(priority): fold ssvc + exploit_maturity into the priority bonus
Tester asked whether SSVC and Nessus exploit_code_maturity reach the
final priority score — they did not. Now they do, without inflating
the existing CVSS/EPSS/KEV signals.

Two separate contributions:

  exploit_bonus = max(
      kev_signal,      # KEV or EUVD listed                      (≤ 4.0)
      epss_signal,     # 3 × EPSS probability                    (≤ 3.0)
      wazuh_signal,    # wazuh exploit_available / exploitable   (≤ 4.0)
      ssvc_signal,     # SSVC exploitation_status (NEW)
                       # widespread 5.0 | active 4.0 | poc 2.0
      maturity_signal, # Nessus exploit_code_maturity (NEW)
                       # High 4.0 | Functional 3.0 | PoC 1.5
  )

  ssvc_addon (stacks on top, capped +3):
      +2.0  ssvc_technical_impact == "total"
      +1.0  ssvc_automatable == "yes"

Stacking is intentional — Technical Impact + Automatable describe
DIFFERENT dimensions (impact + automation) than the exploitation
likelihood signals above, so they aren't redundant.

priority_breakdown gains ssvc_addon, ssvc_technical_impact,
ssvc_automatable, exploit_maturity, vpr_score so the frontend
breakdown popover can show the operator exactly why a score moved.
exploit_source now ranks all five signals and reports the strongest
('ssvc' and 'maturity' join the existing 'kev'/'euvd'/'epss'/'wazuh').

VPR is reported in the breakdown but deliberately not folded into the
score — Tenable's proprietary black box stays a parallel reference,
not part of the transparent CPR/priority math.
2026-05-17 11:52:29 +02:00
vulncheck 70840e0d0a feat(ssvc): persist + display technical_impact and automatable
CISA Vulnrichment scores each CVE on three SSVC decision points:
Exploitation, Technical Impact, Automatable. We were already
persisting Exploitation (exploitation_status), but the other two
were parsed and thrown away — exactly the signal the tester wanted
to use to spot 'attacker takes total control + mass-exploitable'
CVEs at a glance.

Adds:

- Migration 013 (idempotent): two new nullable + indexed columns
    ssvc_technical_impact  VARCHAR(16)  -- partial | total
    ssvc_automatable       VARCHAR(8)   -- yes | no

- Model: matching SQLAlchemy columns on Vulnerability.

- vuln_override_service:
    * VerifiedCVEData gains both fields.
    * _parse_vulnrichment_record extracts both from the SSVC
      'options' list (alongside Exploitation).
    * _apply_single_override writes them when present, so the same
      'Correct CVSS' run also fills the SSVC enrichment.

- /api/v1/vulnerabilities response (VulnerabilityResponse +
  _build_vuln_response): exposes both fields.

- Frontend types + detail page: new SSVC sub-block under Detection
  Sources card renders Technical Impact + Automatable with red
  emphasis for 'total' and 'yes' (the high-risk values).

Frontend list column for these will follow once we have CPR bonus
weighting (next commit), so the operator sees the score uplift
alongside the badge in one motion.
2026-05-17 11:50:18 +02:00
vulncheck fe080b07ae feat(ui): non-blocking progress card for cvss correction
The 'Correct CVSS' button used to issue a synchronous POST and
freeze the page for up to 23 minutes before either timing out with
'backend connection failed' or showing a one-line alert at the end.
Tester reported zero feedback during the wait.

New flow paired with the async start/status endpoints:

  click → confirm modal → POST /override/vulnrichment/start
        → poll /status/{job_id} every 2 s
        → floating card bottom-right shows stage, progress bar,
          live stats; never blocks any other UI control

The page is fully interactive while the job runs. When the worker
finishes the card flips to green with Updated/Checked/Not-in-feed
counters; on failure it turns red and surfaces the backend error.
Dismissible via × once terminal. Cleanup effect drops the poll
interval on unmount so route changes don't leak the timer.
2026-05-17 11:44:50 +02:00
vulncheck 0e83548450 feat(ui): patch-available badge + fixed-version display
Tester asked whether VulnCheck signals when a patch is already
available. The data was there (vulnerability.fixed_version, populated
by both Wazuh and Nessus syncs) but never surfaced in the UI — only
the raw 'Affected Package' section showed the installed version,
without contrasting it against the fix.

Frontend-only:

- types/index.ts: declare fixed_version on Vulnerability
- vulns list: emerald 'PATCH AVAILABLE' badge in the Priority column
  whenever fixed_version is set and status is still 'open'
- vuln detail page: same badge in the 'Affected Package' header, plus
  a 3-column grid (Package | Installed | Fixed in) so the operator
  sees the upgrade target at a glance

Backend already exposes fixed_version on /api/v1/vulnerabilities
(VulnerabilityResponse.fixed_version, line 63). No API change needed.
2026-05-17 11:30:10 +02:00
vulncheck c5b24ceee5 feat(dashboard): add PRIO and CPR columns to recent vulnerabilities table
Tester requested the dashboard 'Recent Vulnerabilities' widget show
operational scores (Priority + CPR), not just CVSS + severity — those
two are what you act on, CVSS is just one input.

Adds two right-aligned columns with colour-coded thresholds matching
the main vulnerabilities table:

  PRIO  >= 80 red bold | >= 50 orange bold | >= 20 yellow | rest grey
  CPR   >= 50 red bold | >= 20 orange      | rest grey

NULL CVSS / CPR / Priority now render as em-dash instead of literal
'undefined'. The tester reported a -1 sighting which was almost
certainly the old code rendering null as numeric. Both columns expose
the full score in their title attribute on hover.

Frontend-only; no API change needed (priority_score and cpr_score
already shipped on /api/v1/vulnerabilities for months).
2026-05-17 11:15:56 +02:00
vulncheckandClaude Opus 4.7 5e35630abf fix(sort): natural-numeric cve_id sort + published_date option (dashboard)
The previous cve_id sort used Postgres lex collation, which placed
CVE-2026-8401 above CVE-2026-35440 because '8' > '3' character-wise.
Tester reported the correct expectation: the higher numeric suffix
should win regardless of length.

Two fixes:

1. sort_by=cve_id now splits the ID on '-' and casts year + number to
   int, then orders numerically. Pseudo-CVEs (NESSUS-PLUGIN-*) drop
   to the end and tiebreak on the raw string, so the column is still
   sortable in a mixed dataset.

2. New sort_by=published_date uses coalesce(published_date,
   detected_at) — semantically the most defensible "newest CVE" sort,
   since CVE-IDs are not chronological (assigned in batches by CNAs).
   Falls back to detected_at when CISA enrichment has not stamped a
   real published_date yet.

The dashboard 'Recent Vulnerabilities' widget switches to
sort_by=published_date — what users actually mean by 'newest'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 11:11:58 +02:00
vulncheckandClaude Opus 4.7 8a8215821a chore(scans): remove non-functional 'New Scan' stub button + endpoint
The blue 'New Scan' button on the Scans page opened a modal that
called POST /api/v1/scans — which only inserted a Scan row with
status=PENDING and never actually triggered a scan. The comment in
the endpoint admitted as much ('For this prototype, we'll just
record the intent'). Tester reported triggering scans and seeing
nothing happen — those ghost-PENDING rows were the result.

Removed:
- Frontend: blue 'New Scan' button, the modal, handleCreateScan,
  newScan + isModalOpen state
- Backend: POST /api/v1/scans endpoint and ScanCreateRequest schema

The white 'Trigger New Scan' button (calls /scans/autoscan) is the
real entry point and stays. It iterates all Wazuh agents, creates
Scan rows with proper RUNNING → COMPLETED/FAILED lifecycle, and
updates asset.last_scan.

Existing PENDING rows from earlier clicks remain in the DB — admins
can clean them via the existing POST /api/v1/scans/clear endpoint
(status_filter='pending') if desired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:47:50 +02:00
vulncheckandClaude Opus 4.7 6a1fddfd89 feat(ui): cve_id sort default on dashboard + VPR badge in vulns list
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>
2026-05-15 16:22:20 +02:00
vulncheckandClaude Opus 4.7 f70bffe6f6 fix(override): use github raw URL + correct vulnrichment 2.x parser shape
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>
2026-05-15 16:21:11 +02:00
vulncheckandClaude Opus 4.7 4ed1beab8e feat(brand): new logo — brand-blue shield with bold check + verified ping
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>
2026-05-15 08:23:31 +02:00
vulncheck 3074820737 feat: CVSS score override service with CISA Vulnrichment support
- Add VulnOverrideService to correct erroneous Wazuh CVSS scores (e.g. 10.0 placeholder)
- Add CISA Vulnrichment integration for verified CVSSv3.1 scores and SSVC exploitation status
- Add new API endpoints:
  - GET /override/check - find incorrect scores
  - POST /override/nessus/{asset_id} - override from Nessus
  - POST /override/vulnrichment - override from CISA (all CVEs)
  - GET /override/stats - score error statistics
- Add exploitation_status (SSVC: none/poc/active/widespread) and exploitation_source columns
- Add 'Correct CVSS' button in vulnerabilities frontend
- Add SSVC badge display in vulnerability table
- Update Vulnerability model and types

Fixes: CVE-2026-8390 incorrect CVSS 10.0 → 7.3 HIGH
2026-05-14 20:36:51 +02:00
vulncheck d903ca41cb fix(nessus): bug fixes from code review
- 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
2026-05-14 17:38:05 +02:00
vulncheck 9ae16a3668 feat(nessus): import VPR, exploit_available, maturity, description, solution
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.
2026-05-14 17:24:08 +02:00
vulncheck 515c9e3084 fix(nessus): populate package_name from plugin_name + sortable source column
- 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.
2026-05-14 16:37:21 +02:00
vulncheck b91970f3bb feat(mfa): allow LDAP users to enrol TOTP
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
2026-05-14 16:24:12 +02:00
vulncheck 5c4a9a8bf9 fix(ui): clarify Nessus rescan confirm dialog wording 2026-05-14 08:19:07 +02:00
vulncheckandClaude Opus 4.7 7b5d1cdc45 feat(nessus): scanner_type in schedules + targeted host rescan
- 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>
2026-05-14 08:06:23 +02:00
vulncheck 26dd991135 fix(ui): dark-mode form readability + Recent Vulns sort
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.
2026-05-13 23:36:37 +02:00
vulncheck e90bf1c1fa feat(rbac): hide sidebar entries based on user role
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.
2026-05-13 23:32:45 +02:00
vulncheck 7b7fb0ffd8 feat(nessus): Sync Data (Nessus) button on Scan Jobs page
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.
2026-05-13 23:26:03 +02:00
vulncheck 0a67e5bd2a feat(nessus): Vulns list — source column, filters, false-positive workflow
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.
2026-05-13 23:14:28 +02:00
vulncheck 472adbc853 feat(nessus): Settings UI — Nessus config modal + Sync-now button
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.
2026-05-13 23:09:59 +02:00
vulncheck e12f11134e feat(notifications): digest mode for new-vulnerability emails
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.
2026-05-12 21:13:55 +02:00
vulncheck b95c540db6 feat(mfa): admin reset endpoint + Reset MFA button in user management
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.
2026-05-12 20:32:40 +02:00
vulncheck 535f580e43 fix(proxy): add catch-all /auth/[...path] proxy for MFA + SSO endpoints
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.
2026-05-12 20:24:24 +02:00
vulncheck ba66964761 feat(auth-ui): TOTP enrolment card in Settings page
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.
2026-05-12 19:32:31 +02:00
vulncheck 21b17bcb0d feat(auth-admin): admin UI for provider status, role mappings, connection tests
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).
2026-05-12 19:15:17 +02:00
vulncheck b8e8870b29 feat(auth): LDAPS + OIDC + SAML 2.0 strategies and SSO routers
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.
2026-05-12 19:11:32 +02:00
vulncheck c3c5134a4e ux(vulns): drop 'Scroll table' label, slimmer scroll-control bar
Label was redundant — chevron icons are self-explanatory. Bar now
right-aligned and slightly tighter (py-1.5 instead of py-2).
2026-05-12 16:35:01 +02:00
vulncheck 7197a59cfc i18n(vulns): translate all enrichment UI text to English
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.
2026-05-12 16:34:07 +02:00
vulncheck 5067980cb0 ux(vulns): use full available width — remove max-w-7xl cap
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.
2026-05-12 16:27:51 +02:00
vulncheck 3b464df260 ux(vulns): compact header + sticky filter bar
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.
2026-05-12 16:26:47 +02:00
vulncheck a787048533 fix(vulns): help icons open click popover (title-tooltip didn't work on click)
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.
2026-05-12 16:09:20 +02:00
vulncheck b299b1fdfe feat(vulns): horizontal scroll controls + visible scrollbar
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.
2026-05-12 16:08:23 +02:00
vulncheck 09c6904cab feat(vulns): help tooltips on column headers (CVSS, EPSS, KEV, EUVD, CPR, Priority, Severity)
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.
2026-05-12 16:01:05 +02:00
vulncheck c7b881cc2f fix(vulns): sticky Actions column overlap — z-10, border, solid bg
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.
2026-05-12 15:45:22 +02:00
vulncheck 34324996f8 feat(vulns): click-to-sort table headers + sticky Actions column
- Headers (CVE, Severity, CVSS, EPSS, KEV, EUVD, CPR, Priority, Status)
  clickable, toggle asc/desc, arrow indicator. Defaults to priority desc.
- Backend sort_by extended: epss, cpr (CVSS*EPSS proxy in SQL),
  kev, euvd, severity, status, cve_id. NULL values sort last on desc.
- CPR computed in Python (CVSS*EPSS*10), SQL uses coalesce for NULL safety.
- Priority re-sorted Python-side after SQL proxy fetch since real score
  needs policy+age context.
- Actions column sticky right with shadow — stays visible when table
  scrolls horizontally on narrow viewports.
2026-05-12 15:16:34 +02:00
vulncheck 356bf7b97f feat(risk-score): add ENISA EUVD enrichment + CPR score
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.
2026-05-11 19:47:37 +02:00
vulncheck 65f77c9d80 fix(vulns): EPSS filter input now uses percent (0-100)
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%.
2026-05-11 16:04:17 +02:00
vulncheck 14356c9a07 feat(notifications): configurable severity threshold for new-vuln emails
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.
2026-05-11 15:41:40 +02:00
vulncheckandClaude Opus 4.7 42f867a58b feat(risk-score): enrich priority with EPSS + CISA KEV
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>
2026-05-11 15:37:53 +02:00
vulncheck 571c5de3cc fix(auth): force full reload after login to avoid cookie race
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.
2026-05-11 14:28:58 +02:00
vulncheck 662eac673e fix: prevent /auth/me 401 redirect loop
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.
2026-05-11 13:39:18 +02:00
vulncheck 9ada95424a Improve perf and security hardening
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
2026-04-28 16:16:29 +02:00
vulncheck faf8b89168 Fix scan sync: severity fallback, solved-filter, timestamps
- 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
2026-02-15 15:54:07 +01:00
vulncheck 862ef59f4a Allow clearing default group setting 2026-02-08 11:18:15 +01:00