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
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.
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.
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;
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.
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.
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.
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.
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.
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.
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).
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.
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.
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>
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.
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>
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>
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>
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>
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>
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>
- 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>
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>
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.
- 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.
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
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.
- 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
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.
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.
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.
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)'.
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.
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.
- 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.
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.
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
- 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.
- 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.
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.
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.
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.