Commit Graph

94 Commits

Author SHA1 Message Date
vulncheck 4bca41e63e feat(scans): nessus sync now writes per-host scan history rows
Tester noticed that scheduled + manual Nessus syncs left no trace in
the Scan-Jobs page — only Wazuh autoscan was visible there. Made
it impossible to verify Nessus scheduler runs after the fact.

Changes:

- New enum value ScanType.NESSUS + idempotent migration 014.

- run_nessus_sync (services/nessus_sync.py) now opens a Scan row
  per host at the top of the per-host loop:
      scan_type   = NESSUS
      status      = RUNNING → COMPLETED / FAILED
      started_at  = now
      asset_id    = matched VulnCheck asset
  and closes it after the backfill, setting
      vulnerabilities_found = len(seen_cves_for_asset)
      completed_at = now
  The skip-backfill defensive branch (host returned 0 findings)
  marks the row COMPLETED with an explanatory error_message so the
  operator sees the run happened but produced nothing.

- Scheduler path inherits this for free — app/scheduler.py already
  calls run_nessus_sync for scanner_type='nessus' schedules.

- Existing /scans/summary endpoint groups consecutive Scan rows of
  the same type into a "run", so the UI shows one entry per Nessus
  sync (covering all hosts).

Migration required on deploy:
    docker compose exec backend alembic upgrade head    # 013 → 014
2026-05-18 11:43:01 +02:00
vulncheck ec752b4a4a fix(mfa): accept legacy plain-base32 totp secrets + re-encrypt on use
Tester reported "Invalid MFA Code" on every login after the
AUTH_PROVIDER_CRYPTO_KEY upgrade. Root cause: users enrolled BEFORE
that key was a requirement still had their TOTP secrets stored as
plain pyotp.random_base32() strings. decrypt_secret blindly fed them
to Fernet.decrypt → InvalidToken → login rejected. The only
workaround was to factory-reset + re-enrol MFA, losing the original
device pairing.

Two-layer fix:

1) decrypt_secret recognises a stored value that matches the
   pyotp base32 pattern (^[A-Z2-7=]{16,64}$). On Fernet failure it
   returns the value as plain instead of raising — login works.

2) verify_for_user accepts an optional db Session. When it sees the
   returned secret equals the stored secret (i.e. we hit the legacy
   path), it transparently re-encrypts with the current Fernet key
   and commits, so the next read goes through the modern path. One-
   shot per user, invisible to them.

Router updated to pass `db=db` so the migration actually fires on
the existing MFA verify endpoint.

A wrong AUTH_PROVIDER_CRYPTO_KEY (typo, regenerated key) still
rejects login because the stored ciphertext is real Fernet data and
will not match the plain-base32 regex — no security regression.
2026-05-18 11:39:18 +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 9cb9051854 fix(cvss): clamp out-of-range scanner values to None at extraction
Tester saw cvss_score=-1 in the DB plus cpr_score=-9.3 in the UI for
some CVEs. CPR math is purely multiplicative, so a negative CPR can
only come from a negative CVSS. Source confirmed: Wazuh-Indexer emits
-1 as a placeholder when it could not score a package, and we passed
it through to the DB unchanged. The Nessus plugin extractor had the
same gap.

Both clients now validate the extracted base_score with a
'0.0 <= f <= 10.0' guard and return None on anything outside the
CVSSv3 spec range. Downstream CPR + priority calculations already
handle None correctly (calculate_cpr_score returns None when either
cvss_score or epss_score is missing). No DB clean-up required —
new syncs overwrite the bad values.

For existing -1 rows, ops can wipe them once:
    UPDATE vulnerabilities SET cvss_score = NULL
    WHERE cvss_score < 0 OR cvss_score > 10;
2026-05-18 11:29:48 +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 5b18310cf8 feat(override): async job + status endpoint for vulnrichment correction
The synchronous /override/vulnrichment endpoint blocks the browser
request for the full duration of the correction — 23 minutes on the
tester's instance, well past every reasonable client timeout. Result:
'backend connection failed' modal and zero feedback about whether
the work actually ran.

New endpoints (sync one kept untouched for single-CVE backward compat):

  POST /override/vulnrichment/start
       Returns {job_id, status: 'queued', poll_url} immediately.
       Spawns a daemon thread that does the heavy lift.

  GET  /override/vulnrichment/status/{job_id}
       Returns the live snapshot: status, stage, total, done,
       updated/checked/not_found, error if any.

  GET  /override/vulnrichment/jobs
       Recent jobs ringbuffer for an admin overview.

State lives in an in-memory dict (override_jobs.py) — survives
requests, not backend restarts. That's fine; a restart would have
killed the worker thread anyway and the user re-clicks. Ringbuffer
caps at 50 entries, oldest finished gets evicted.

Worker owns its own SessionLocal() so it does not collide with the
trigger request's DB session. Stages narrate what's currently
happening ('downloading vulnrichment ZIP snapshot', 'fetching N
CVEs from github raw', etc.) so the frontend can show a meaningful
progress message even though we don't get per-CVE callbacks from
inside correct_vulnerability_scores.

Frontend modal that polls the status endpoint comes in the next commit.
2026-05-17 11:41:49 +02:00
vulncheck 06c715c1d7 perf(override): vulnrichment ZIP snapshot for batches > 25 cves
The tester measured 23 minutes for a full 'Correct CVSS' run against
the live DB. Root cause: per-CVE GitHub raw fetch issues a separate
HTTP request for every CVE, most of which 404 (CISA lags for new CVEs)
and burn round-trip time. For ~2 000 CVEs that's 2 000 × ~700 ms.

New routing in load_cisa_vulnrichment_data:

  ≤ 25 CVEs → per-CVE raw fetch (unchanged, snappy for single calls)
  >  25 CVEs → download cisagov/vulnrichment archive .zip once,
               walk the in-zip paths for the wanted CVE files

Snapshot path:
- Stream-download to a temp file (no 249 MB in RAM)
- zipfile.namelist() + open(in_zip_path) — no full extraction
- Random tempdir per call, auto-cleaned via TemporaryDirectory ctx
- Same parser as raw path (_parse_vulnrichment_record), same result
  shape (Dict[cve_id, VerifiedCVEData])

Expected runtime for a 2 000-CVE 'correct everything' call: ~2 min
(60 s download + 30 s parse) instead of ~23 min. Falls back to per-
CVE on ZIP fetch error so the feature still works if GitHub serves
errors on the archive endpoint.
2026-05-17 11:38:23 +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
vulncheck 12cd7014b4 fix(nessus): prefer cve-specific plugins over catch-all 'Patch Report'
Tester reported CVE-2026-34480 (Apache Log4j XmlLayout) showed up in
VulnCheck with title 'Patch Report' and a generic 'remote host is
missing one or more security patches' description instead of the
real Log4j plugin's CVE-specific text. Root cause: Nessus emits two
plugins per CVE in this case — a catch-all 'Patch Report' plugin and
the CVE-specific scanner plugin. First-write-wins in the merge path,
so whichever Nessus returned first locked in the bad metadata.

Adds a _is_specific() heuristic — a plugin name is 'specific' to a
CVE if the CVE-ID appears in its plugin_name. The merge path now
overwrites existing title / package_name / description /
nessus_plugin_id when:

  - the existing row's title does NOT mention the CVE-ID
  - AND the incoming plugin's name DOES

That promotes the proper Log4j plugin over 'Patch Report' regardless
of import order. Plugins that came in the right order first are not
disturbed because their existing title already passes _is_specific.
2026-05-17 11:14:59 +02:00
vulncheck b5cc30331f fix(sync): preserve vulnrichment-corrected scores on subsequent nessus sync
Tester reported that after running 'Correct CVSS' on CVE-2026-8390
(score dropped 10.0 → 7.3 from Vulnrichment), the next Nessus sync
silently overwrote it with the plugin-level 10.0 again. Root cause:
the Nessus merge path applies a 'Nessus wins' rule unconditionally on
CVSS and severity, so any manual Vulnrichment correction had a
shelf-life of one scan.

Two-part fix:

- vuln_override_service._apply_single_override now stamps
  vulnerability.exploitation_source = 'vulnrichment' whenever it
  writes cvss_score or severity. Existing column from migration 010
  was previously unused for this purpose.

- nessus_sync.run_nessus_sync merge path checks
  existing.exploitation_source == 'vulnrichment' and skips its CVSS +
  severity override in that case. All other fields (sources list,
  plugin_id, VPR score, descriptions, status reopen) still merge
  normally — only the authoritative score stays pinned.

No schema change required.
2026-05-17 11:13:31 +02:00
vulncheck 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
vulncheck 03eef00f31 security: harden auth, secrets, headers and email rendering
Closes 10 findings from the automated security scan (1 critical, 4 high,
5 medium). Operator action required before redeploy — see deploy notes
in chat or README.DEV.md.

Critical:
- TOTP/LDAP Fernet key (AUTH_PROVIDER_CRYPTO_KEY) is now env-only.
  Removed the DB fallback that co-located the key with the ciphertext
  it protects.

High:
- Rate limiter no longer trusts X-Forwarded-For from arbitrary peers.
  TRUSTED_PROXY_CIDRS gates which direct peers may rewrite the client
  IP, and ProxyHeadersMiddleware trusted_hosts is narrowed from "*"
  to FORWARDED_ALLOW_IPS.
- TOTP codes are single-use within their 90s validation window.
  In-memory replay cache keyed on (user_id, code).
- JWTs carry a jti claim; logout revokes both access and refresh JTIs,
  refresh rotates (revokes the presented token), and get_current_user
  rejects any revoked JTI. In-memory store with TTL = token exp.
- Sensitive setting values (wazuh_config, smtp_config, nessus_config)
  are encrypted at rest with an enc:v1: prefix. All read sites go
  through read_setting_value(); legacy plaintext rows still readable
  until next write. GET responses redact secret subfields so admins
  cannot accidentally exfiltrate stored credentials.

Medium:
- Email template rendering HTML-escapes all dynamic values. The "rows"
  variable is whitelisted as pre-escaped HTML. Severity CSS class is
  whitelisted to prevent attribute breakout via crafted package data.
- Request logging redacts sensitive query parameters (token, password,
  code, mfa_token, ...). Validation-error handler no longer logs or
  returns the offending request body.
- /health returns only {"status":"healthy"} — environment and version
  no longer leak to unauthenticated callers.
- SETUP_ADMIN_TOKEN comparison uses hmac.compare_digest.
- Settings PUT denylists auth_provider_crypto_key (env-only) and
  refuses to store the "***set***" redaction placeholder back into
  protected configs.
2026-05-16 09:25:22 +02:00
vulncheck b3d09b3b96 fix(api): expose multi-scanner fields in VulnerabilityResponse schema
Tester reported the VPR score never showed up on the vuln detail page
even though the list view rendered it after the previous commit.
Root cause: the detail endpoint declares response_model=
VulnerabilityDetailResponse → Pydantic strips any dict keys not
listed on the parent VulnerabilityResponse schema. _build_vuln_response
correctly emitted sources/cross_confirmed/first_detected_by/
nessus_plugin_id/nessus_vpr_score/exploitation_status, but the schema
silently dropped them. The list endpoint has no response_model so it
passed through untouched — which is why the badge appeared there but
not in detail.

Adds the missing fields to VulnerabilityResponse so detail + PATCH
responses now include them. is_pseudo_cve and assigned_group_name
were also missing and are added in the same pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:50:14 +02:00
vulncheck 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
vulncheck 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
vulncheck 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
vulncheck 3d9706a506 fix(sync): correct variable name in per-agent rescan (raw_vulns)
Mass-patch guard from 662742f copied the variable name from
run_wazuh_vulnerability_sync() (where the var is wazuh_vulns) into
sync_agent_vulnerabilities() — but that function uses raw_vulns
internally. Result: every scheduled per-agent rescan crashed with
'NameError: name wazuh_vulns is not defined' since yesterday.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:13:58 +02:00
vulncheck 662742f8e2 fix(sync): skip backfill when scanner returns 0 findings (mass-patch guard)
After fixing an unreachable Wazuh manager, a sync run came back with an
empty agent vulnerability list (indexer warming up) and the
source-aware backfill happily marked 1500+ findings as patched because
every CVE was 'no longer reported'. Same vector exists in Nessus sync
when a host's response is partial.

Guard added in all three sync paths:
- run_wazuh_vulnerability_sync (bulk per-agent loop)
- sync_agent_vulnerabilities (per-agent rescan)
- run_nessus_sync (per-host loop)

If active_cves AND raw findings list are both empty for the
agent/host, log a warning, touch last_scan, and continue — backfill
is skipped. A scanner reporting a host as truly clean from one
moment to the next is far rarer than an API hiccup, so the default
must be 'don't wipe history'.

Recovery for vulns already wiped today (sources=[], status=patched,
recent patched_at) is handled with a one-off UPDATE; the README's
verification block stays valid post-fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 08:37:15 +02:00
vulncheck 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 68ed824d5f docs(README.DEV): document Nessus override + SSVC + migrations 011/012
- Merge logic block reflects new Nessus-wins behaviour (cvss/severity
  override during sync, not max-merge)
- Adds Manual Overrides section covering vuln_override_service.py +
  /override/check, /override/nessus, /override/vulnrichment endpoints
- Schema table grows nessus_vpr_score (011) and exploitation_status (012)
- Verification block updated to alembic head 012 and the new
  scores_overridden stat in sync response

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 08:04:27 +02:00
vulncheck 97a951d42c feat(nessus): auto-override CVSS/severity from Nessus during sync
When a CVE already exists on an asset (e.g. seen by Wazuh first) and
Nessus reports its own CVSS, the Nessus value now wins instead of the
previous max-merge behaviour. Fixes the common case where Wazuh assigns
a placeholder 10.0 but Nessus has the accurate per-plugin score (e.g.
7.4). Falls back to keeping the existing value when Nessus has no CVSS
or severity, so we never wipe valid Wazuh data.

Adds scores_overridden counter to sync stats + log line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 08:00:48 +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 7d2ef482f3 fix(notifications): replace asset.assigned_group_id with asset.groups
Asset model removed assigned_group_id in favour of M2M groups relation.
Manual notification endpoint was still accessing the non-existent column
causing AttributeError for any asset that has groups but no assigned user.
2026-05-14 17:54:57 +02:00
vulncheck e569b01e93 fix: wazuh_severity fallback in bulk sync + rate-limit MFA endpoints
- Bulk sync was calling map_severity(score) without wazuh_severity
  fallback — vulns without a CVSS score got severity=none instead of
  using Wazuh's own severity string (High/Medium/Low) as fallback.
  Per-agent sync (sync_agent_vulnerabilities) already did this correctly.

- mfa/setup, mfa/activate, mfa/disable had no rate limiting.
  With a stolen JWT an attacker could brute-force the password on
  mfa/disable (no per-user lockout tracks POST /auth/mfa/* attempts).
  Added: setup=5/min, activate=10/min, disable=5/min.
2026-05-14 17:50:46 +02:00
vulncheck 2f7f0f0714 fix(wazuh-bulk-sync): mirror source-aware fixes to bulk sync path
The bulk sync endpoint (POST /api/v1/vulnerabilities/sync) duplicates
the same logic as sync_agent_vulnerabilities and had the same bugs:

- db.rollback() on IntegrityError replaced with db.begin_nested()
  savepoint — full rollback was losing all prior inserts for an agent
- New vulns now set sources='["wazuh"]' and first_detected_by='wazuh'
- existing.add_source('wazuh') on merge path for cross-confirmation
- Wazuh no longer reopens accepted_risk/false_positive/deferred vulns
  (patched → open, but other manual statuses preserved)
- Patching logic is now source-aware: remove 'wazuh' from sources,
  mark patched only when source_list is empty. Nessus-only findings
  are not touched.
- Enrichment query scoped to newly_created_vuln_ids instead of
  'all vulns without enrichment_updated_at' which could be thousands
2026-05-14 17:46:48 +02:00
vulncheck 37698189e2 fix(wazuh-sync): source-aware backfill + savepoint + first_detected_by
Three bugs mirrored from the Nessus sync review:

1. db.rollback() on IntegrityError replaced with db.begin_nested()
   savepoint — the full rollback was wiping all previous inserts in the
   same agent sync run when a race-duplicate was encountered.

2. New Wazuh vulns now set sources='["wazuh"]' and
   first_detected_by='wazuh' explicitly. Previously only the SQLAlchemy
   Python default fired (sources) and first_detected_by was always NULL
   for Wazuh-created entries after migration 010.

3. Wazuh patching logic now source-aware: instead of bulk-marking every
   open vuln not in active_cves as PATCHED (which silently wiped
   Nessus-only findings), we now iterate wazuh-sourced open vulns,
   remove 'wazuh' from sources, and only flip status=patched when
   source_list is empty. Nessus-only findings are untouched.
   Also: existing.add_source('wazuh') called on merge path so
   cross-confirmation is tracked when Wazuh finds a Nessus-only CVE.
2026-05-14 17:44:17 +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 0011a6364d fix(alembic): make 011 idempotent via Postgres IF NOT EXISTS
The previous inspect-based guard didn't catch the case where
Base.metadata.create_all() added nessus_vpr_score on app start
before alembic_version reached 011 — leading to DuplicateColumn
on upgrade.

Switch to raw SQL with IF NOT EXISTS for both column and index,
so the migration is a true no-op when the column already exists.
2026-05-14 17:26:06 +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 178f2bd28f feat(nessus): drop pseudo-CVE creation + cleanup endpoint
Per product decision, only CVE-tagged Nessus findings are imported.
EOL / Compliance / Cipher / informational plugins without a real CVE
are now skipped (counted in stats.non_cve_skipped).

Adds POST /api/v1/vulnerabilities/nessus/cleanup-pseudo-cves to remove
existing NESSUS-PLUGIN-* rows from earlier syncs.
2026-05-14 17:05:16 +02:00
vulncheck a971b069f1 fix(nessus): clear error when Essentials drops alt_targets launch
Nessus Essentials silently rejects POST /scans/{id}/launch with
alt_targets — the connection is dropped instead of a 4xx. We now
detect this case (drop + status didn't move to running) and raise
a NessusAPIError that explicitly mentions the Essentials limitation
and points the user at the manual workaround: launch from the
Nessus UI, then click 'Sync Data (Nessus)'.
2026-05-14 16:48:32 +02:00
vulncheck 57302fa80c fix(nessus): handle dropped-connection quirk on scan launch
Nessus 10.x (notably Essentials) sometimes accepts a POST /scans/{id}/launch
and then drops the TCP connection before sending a response, surfacing as
httpx.RemoteProtocolError. The scan itself starts fine.

After a drop we now wait 2s and GET the scan; if its status is running/
pending/resuming the launch did succeed and we return a synthetic OK
(with verified_by_status_check=True). Only if the status never moved do
we raise — so the rescan button no longer spuriously fails.
2026-05-14 16:45:47 +02:00
vulncheck 557bbcc387 fix(nessus): auto-pick scan when default_scan_ids unset
Targeted host rescan failed hard if the admin had not configured
nessus_config.default_scan_ids. Now we fall back to listing scans
and using the first available one (with a log warning telling the
admin to pin a default in Settings).

Also routes the existing inner HTTPException through the outer
try/except so it surfaces as 400/422 instead of being swallowed
into a generic 500 by the catch-all handler.

Response now includes auto_picked=true and a hint in the message
so the user knows which scan was used.
2026-05-14 16:41:24 +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 f61de08d95 fix(nessus): extract CVSS + CVEs from modern Nessus payload structure
Modern Nessus (10.x+) nests CVSS scores under pluginattributes.risk_information
and CVEs under pluginattributes.ref_information.ref[?].values.value (where
name=='cve'). Previous code only checked the flat legacy locations, so:

- CVSS was always None for Nessus-only findings -> blank CVSS column,
  blank CPR (CPR = CVSS x EPSS x 10 requires both)
- CVEs only found via regex fallback over the description text — missed
  CVEs that were only listed in the structured ref array

Both extractors now check the modern path first, then the legacy flat
field, then regex as last resort. Existing Nessus findings get CVSS
populated on the next sync via the existing severity/cvss merge path.
2026-05-14 16:32:17 +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 0d0b92d4ff fix(bugs): asset.assigned_group_id AttributeError + nessus IntegrityError rollback
- scheduler.py: asset.assigned_group_id does not exist (M2M removed it).
  Replace with asset.groups iteration so SLA breach mails reach group
  members without crashing.
- nessus_sync.py: db.rollback() on IntegrityError wiped the entire sync
  transaction. Switch to db.begin_nested() savepoint so only the
  duplicate insert is discarded, all other changes survive.
2026-05-14 08:24:59 +02:00
vulncheck 5c4a9a8bf9 fix(ui): clarify Nessus rescan confirm dialog wording 2026-05-14 08:19:07 +02:00
vulncheck 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 4683f203b3 feat(notifications): SLA-breach digest mode
Mirrors the new-vulnerability digest done in commit e12f111. Hourly
SLA-breach check now collects all overdue findings into per-recipient
buckets and sends one summary mail per person instead of one mail per
(vuln, recipient). Toggled by the existing notification_mode setting
(no separate switch — same dropdown in Settings).

100 overdue vulns × 3 recipients used to fan out to 300 mails per
hour; in digest mode that collapses to 3.

Implementation:
- email_service.send_sla_breach_digest() + render_sla_digest_rows():
  styled red HTML table with severity counts, CVE/severity/host/
  detected/overdue columns, dashboard CTA. Custom template via setting
  email_template_sla_breach_digest.
- scheduler.check_sla_breaches() refactored into two phases:
    1) classify breaches, resolve recipients via the same cascade as
       new-vuln digests, bucket per email
    2) dispatch via send_sla_breach_digest (mode='digest') or fall back
       to per-vuln send_email (mode='single', legacy)
- Per-vuln 24h throttle preserved by writing one NotificationLog row
  per vuln per recipient in both modes — so an hourly tick can't repeat
  the same finding inside the same recipient's digest.
- Mode comes from email_service.get_notification_mode() (reused). No
  new env vars, no migration.
2026-05-13 23:24:45 +02:00
vulncheck ed72d67e87 fix(alembic 010): make Nessus migration idempotent
Server hit 'column sources of relation vulnerabilities already exists'
because Base.metadata.create_all() pre-created the columns when the
container ran ENV=development at some point. Re-running alembic upgrade
then crashed on the first add_column.

Migration 010 now introspects the live schema via sa.inspect(bind) and
skips any column / index that already exists. cve_id widen wrapped in
try/except for the same reason. Backfill UPDATE is naturally
idempotent (filtered by sources='[]' / NULL / '').

No new behaviour, no rollback needed for installs that already ran
parts of 010.
2026-05-13 23:19:39 +02:00
vulncheck 7a4ac3d032 docs(README.DEV): Nessus integration section
Architecture, schema delta (Alembic 010), asset matching cascade,
merge logic per (cve, asset), full endpoint table, configuration JSON
shape, scheduled-sync routing via scanner_type, UI tour, verification
snippets, and Nessus-specific 'out of scope for v1' list.
2026-05-13 23:16:08 +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