Tester: when a system is removed from Wazuh + Nessus (org's "system
no longer exists" process), VulnCheck kept a data corpse needing
manual deletion. Hard-delete would lose the vuln history + break the
revisionssicher audit trail. Compromise: SOFT-inactive.
Service (asset_lifecycle.py)
- reconcile_asset_lifecycle(db): asset whose last_scan (any source
sync) is older than `asset_inactive_after_days` (setting, default
30, 0=disabled) → status INACTIVE. Recently-seen INACTIVE assets
auto-revive to ACTIVE. DECOMMISSIONED is operator-final, never
auto-revived. Both transitions audit-logged (user_id=None).
Vuln rows + audit survive — only the default views hide it.
Scheduler + endpoint
- Nightly job at 04:15 (after URS).
- POST /api/v1/assets/reconcile-lifecycle for on-demand run.
Asset list
- Default now shows ACTIVE only (was active+inactive). New
include_inactive=true param surfaces inactive + decommissioned.
Frontend
- "Show inactive" checkbox on the assets page (re-fetches on toggle).
- Status badge: active=green, inactive=amber, decommissioned=grey,
each with an explanatory tooltip.
Configurable retention: set asset_inactive_after_days in settings
(0 disables auto-inactivation entirely for orgs that prefer manual).
Two tester findings.
1. Windows Server 2016 wrongly flagged as EOL (HIGH) though it still
gets monthly CUs until 2027-01. Cause: is_eoas (active-support
ended 2022) was treated the same as is_eol → MEDIUM/critical-ish
finding even while security patches still flow.
New three-tier model in _build_eol_status():
- is_eol : security support ENDED (eolFrom past, no ESU) →
HIGH, cvss 9.0, title "EOL".
- is_eol_soon : eolFrom within EOL_SOON_DAYS (90) but future →
MEDIUM, cvss 5.5, title "EOL SOON (Nd)".
- is_eoas only: mainstream support ended, security patches still
flow → LOW, cvss 3.0, title "end-of-active-support".
Server 2016 (eoas 2022, eol 2027-01) now → LOW today, flips to
MEDIUM "EOL SOON" ~90d before Jan 2027, HIGH after.
_days_until() handles eolFrom given as a bool (endoflife quirk).
Both check_eol + check_os_eol share _build_eol_status now.
2. Nessus "Scan + Import" failed with "Server disconnected without
sending a response" on launch. That's the Nessus Essentials (free)
API-launch limitation — it drops the connection and the scan never
enters 'running'. The job now catches the launch NessusAPIError: if
the scan already has completed/imported results it imports those
(flagged via job.launch_warning + a clear stage message); otherwise
it fails with an actionable hint ("this edition may not allow
API-triggered scans — launch in the Nessus UI then Sync now").
eol-check loops (router + scheduler) updated to also surface
is_eol_soon findings.
Tester referenced PacketFence's Nessus scan-engine integration:
trigger a scan FROM the tool instead of manually launching in
Nessus then clicking Sync. VulnCheck already had scan-host (launch
only) + sync (import only) — Plan F chains them server-side.
Client
- NessusClient.get_scan_status(scan_id) → lower-cased status string.
Job (override_jobs.py — reuses the in-memory tracker)
- start_nessus_scan_import_job(user_id, asset_id?, scan_id?):
spawns a worker thread, returns job_id.
- worker: resolve scan_id (explicit → default_scan_ids[0] → first
visible) → launch_scan (alt_targets = asset IP when asset_id set)
→ poll get_scan_status every 15s up to 1h → on completed/imported
run run_nessus_sync(scan_ids=[id]). Terminal states
completed/imported/canceled/aborted/empty; canceled/aborted abort
the import. Timeout raises with the last seen status.
Endpoints
- POST /nessus/scan-and-import {asset_id?, scan_id?} → {job_id}
- GET /nessus/scan-and-import/status/{job_id} → live job state
(stage / nessus_status / polls / result / error)
Frontend
- Settings → Tenable Nessus row gains a purple "Scan + Import"
button next to "Sync now". Polls the status endpoint every 5s,
surfaces the stage string inline, terminal state shows the
created/merged/patched summary. User can navigate away — work is
server-side.
Out of scope (later):
- Per-asset rescan button on the vuln detail / asset page wired to
the same job (currently only the global Settings trigger).
- Scan-template picker UI (still uses default_scan_ids[0] / first
visible scan).
Plan E — extend endoflife.date detection from installed packages to
the OS itself. Tester confirmed via PowerShell that windows-server
2008-r2 is EOL on endoflife.date; Wazuh syscollector packages don't
carry the OS so we read asset.operating_system + os_version.
Service (eol_service.py)
- resolve_os_to_eol(os_name, os_version) maps Wazuh OS strings to
(endoflife_slug, release_codename):
"Microsoft Windows Server 2008 R2" → (windows-server, 2008-r2)
"...Server 2016 Datacenter" → (windows-server, 2016)
"Microsoft Windows 10 Pro" → (windows, 10)
build >= 22000 → (windows, 11)
"Ubuntu" 22.04.3 LTS → (ubuntu, 22.04)
Debian / RHEL / CentOS → numeric major
- check_os_eol() matches the codename against release.name (exact,
then prefix) — NOT the numeric _pick_release used for packages,
because OS releases use codenames not versions.
Endpoint + scheduler
- /vulnerabilities/eol-check now runs the OS check per asset BEFORE
the package loop (so an asset whose package fetch errors still
gets its OS evaluated). New stat: os_eol_findings.
- eol_check_nightly mirrors the same OS-first logic.
OS EOL findings reuse the EOL-* pseudo-CVE machinery: severity HIGH
(EOL) / MEDIUM (EOAS), cve_id EOL-WINDOWS-SERVER-2008-R2, fixed_version
= latest supported release.
Tester report: "Network error" on the Exploit Intel button.
Backend was iterating every CVE-JSON file in nomi-sec/PoC-in-GitHub
(~30k requests across 6 year folders), each ~200ms = 100+ min,
guaranteed GitHub rate-limit (60 req/h unauthenticated) AND axios
client timeout long before backend finishes.
Fix
- fetch_pocs_github_cve_map() now REQUIRES known_cves: Set[str] and
filters each year's directory listing to filenames matching the
set before issuing per-CVE downloads. ~30k → typically 50-500
requests per run.
- refresh_all_exploit_intel() loads vulns once up front, builds the
CVE-id set, passes it in. Removed the second (duplicate) query.
- Cache file path now suffixed with sha1(known_cves) so a smaller DB
scope can't serve a stale-larger result and vice versa.
- 403/429 from GitHub trigger an early-exit warning instead of
swallowing the rate-limit silently.
Exploit-DB CSV + Metasploit metadata fetchers are still global
(small, one HTTP call each, no per-CVE iteration).
Plan M — close the "exploit-db is only a link, not in the score"
gap. Three new enrichment sources mapped per-CVE, written to
dedicated columns, displayed as badges, weighted in the priority
breakdown.
Schema (migration 025)
- vulnerabilities.exploit_db_ids (JSON list) + exploit_db_count
- vulnerabilities.pocs_github_urls (JSON list) + pocs_github_count
- vulnerabilities.metasploit_modules (JSON list) + metasploit_module_count
- vulnerabilities.exploit_intel_updated_at
- partial indexes on the *_count columns where > 0 for fast
"show me CVEs with exploits" filters.
Service (app/services/exploit_intel_service.py)
- fetch_exploit_db_cve_map(): downloads gitlab.com/exploit-database/
exploitdb/files_exploits.csv (cached 24h), parses CVE refs from
the `codes` column → {cve: [edb_id, ...]}.
- fetch_pocs_github_cve_map(): walks nomi-sec/PoC-in-GitHub yearly
folders via the GitHub contents API, builds {cve: [repo_url, ...]}.
- fetch_metasploit_cve_map(): downloads rapid7's
modules_metadata_base.json, extracts CVE refs per module.
- refresh_all_exploit_intel(): bulk writer, supports only_open + per-
source toggles, marks/clears counts so a removed reference doesn't
leave a stale flag behind.
Priority weighting
- New exploit signals folded into the existing max() block of
exploit_bonus:
metasploit_module_count > 0 → 4.5
exploit_db_count > 0 → 4.0
pocs_github_count > 0 → 2.5
exploit_source label expanded with metasploit / exploit_db /
poc_github so the breakdown UI shows which source drove the score.
API + Scheduler
- POST /api/v1/vulnerabilities/exploit-intel/refresh (editor) —
on-demand pull with `only_open`, `fetch_pocs`, `fetch_msf` flags.
- New nightly job at 03:45 UTC (between vulnrichment 03:00 + URS
04:00 so the score boost lands before URS recomputes).
Frontend
- Three list-row badges (MSF n / EDB n / PoC n) coloured red →
orange → yellow, ordered by weight, with tooltips.
- Vuln-detail page gets a "Public Exploit Catalogs" card under
Threat Intelligence — Metasploit module paths, clickable EDB-id
links to www.exploit-db.com, GitHub PoC links.
- "Exploit Intel" toolbar button next to EOL Check.
Out of scope (Plan N or later):
- GHSA integration (GitHub Security Advisory Database).
- Nuclei template count.
- Per-source toggles in Settings UI.
Tester report: EOL check 500'd with
duplicate key value violates unique constraint 'ix_settings_key'
Key (key)=(eol_cache_mssqlserver) already exists.
Cause: a single asset has many SQL-Server-related packages (engine,
setup bootstrap, VSS writer, …) all mapping to the same slug
'mssqlserver'. _cache_get returned None for each in quick succession
(autoflush quirk on this session), each path added a Setting row,
the eventual flush hit the unique constraint.
Fix
- _PROCESS_MEMO dict in module scope — first fetch per slug per
backend-process is the only one that talks to httpx + the DB. All
subsequent lookups for the same slug return the in-RAM dict.
- _cache_put rewritten as PostgreSQL ON CONFLICT DO UPDATE upsert
(sqlalchemy.dialects.postgresql.insert) so even races between
concurrent requests can't duplicate-key.
- Failed cache write rolls back its own attempt instead of poisoning
the outer transaction.
Tester request: close the unsupported-software gap Wazuh has vs
Nessus plugin 64784 (Microsoft SQL Server Unsupported Version
Detection). PowerShell prototype against endoflife.date API
confirmed the approach.
Backend
- New app/services/eol_service.py
* Hand-curated product slug map (Microsoft / Mozilla / Adobe /
runtimes / databases / Linux distros).
* resolve_product_slug() normalises Wazuh/Nessus product names and
matches against the map.
* fetch_product() hits api/v1/products/{slug} with 24h cache in
the settings table (negative-caches 404s).
* check_eol() picks the longest-prefix release for the installed
version, evaluates eolFrom / eoasFrom / eoesFrom against today.
* upsert_eol_vulnerability() writes one pseudo-CVE per (asset,
product, release) with cve_id of the form
EOL-{SLUG}-{RELEASE} so re-runs converge.
- Vulnerability.is_pseudo_cve also matches EOL-* now.
- Vulnerability.is_eol_finding new property + API field.
API + scheduler
- POST /vulnerabilities/eol-check (editor) — optional asset_id query,
walks syscollector packages, upserts EOL pseudo-CVEs. Returns
stats {assets_scanned, packages_checked, products_unmapped,
eol_findings_total, eol_findings_new, errors[]}.
- New scheduler job eol_check_nightly at 03:15 UTC.
Frontend
- Vulnerability type gains is_eol_finding.
- List page renders purple "EOL" badge instead of amber "NON-CVE"
for endoflife rows.
Out of scope (later iteration):
- OS-level EOL (Windows / Ubuntu releases) — would parse Asset.os
+ os_version separately.
- Per-package detail (VulnerabilityPackage child rows) for EOL
pseudo-CVEs — current row already carries name/version/latest.
- Settings-UI for the product-slug mapping table.
Tester reported Adobe CVEs (CVE-2018-4990, CVE-2020-9715, ...) not
matching because the version strings drift between sources:
cvelistV5: "2017.011.30079" (4-digit year)
NVD CPE: "17.011.30079" (year truncated)
Wazuh/Nessus: "17.009.20044" (year truncated, real install)
Without normalisation the picker treated `2017.*` and `17.*` as
different release streams → no match → fell back to the first
candidate → wrong fix target.
Fix
- _version_tuple now strips the leading 4-digit year (1990-2099)
when the version has 3+ segments. Adobe convention covers
Acrobat / Reader / DC / ColdFusion (date.build.patch). Firefox
150.0.3 stays unchanged (150 < 1990).
- _version_major reuses _version_tuple so both helpers share the
same normalisation, keeping picker comparisons consistent.
- New _normalize_for_display() returns the short form
("2018.011.20055" → "18.011.20055") for values WRITTEN to the DB
so propagated fixes match the format Wazuh / Nessus actually
report (cosmetic — no more "fixed in 2018.x" alongside "installed
17.x").
Picker now resolves:
installed=17.011.30079 + cvelistV5 candidates
(2017.* → 2018.011.20055)
(2020.* → 2020.013.20064)
→ returns 2018.011.20055, displayed as 18.011.20055.
Out of scope (would need NVD CPE-lookup + product mapping):
- Resolving cases where the product itself is ambiguous (Acrobat
vs Acrobat Reader DC vs Acrobat Pro DC each have their own CVE
`affected[]` entries with the same version range).
Tester report: "Same CVE shows different CVSS depending on sort
order (PRIO vs CPR desc)". Cause: per-(cve_id, asset_id) rows drift
apart over time — override service touched some assets but not the
ones detected later by a fresh scan.
Strategy
- Define _CANONICAL_FIELDS = CVE-intrinsic columns that should NEVER
differ between sibling rows of the same CVE:
cvss_score, cvss_vector, severity,
exploitation_status, exploitation_source,
ssvc_technical_impact, ssvc_automatable.
- fixed_version EXPLICITLY excluded — Plan I (multi-stream picker)
writes per-package fixes that legitimately differ between hosts
running different release streams (Firefox ESR 115 vs 140 vs 150).
Propagation paths
- propagate_canonical_to_siblings(db, vuln):
every override touch now mirrors the new canonical values onto
all other vuln rows sharing the cve_id. Refreshes their
priority_score + cpr_score so sort order converges.
- apply_canonical_from_siblings(db, vuln):
every fresh sync insert (Wazuh + Nessus) pulls the freshest
sibling's canonical values so the new row starts at the correct
CVSS instead of Wazuh's 10.0 placeholder.
Backfill endpoint
- POST /vulnerabilities/canonicalize-cve-metadata (editor) — one-off
pass over existing data. For every CVE with > 1 row, pick the
source-pinned / most-recent vuln and propagate. Cheap; one query
per distinct CVE.
What this fixes in practice
- New Wazuh agent reports an already-overridden CVE → row starts with
the canonical 7.3 score, not 10.0.
- Override re-run on one asset propagates the corrected CVSS to all
10 hosts that share the CVE.
- Sort-by-PRIO / sort-by-CPR now keeps all instances of a CVE
adjacent in the listing.
Tester reported Firefox CVE-2026-8974/8975 picking the FIRST lessThan
entry from the CVE-5 affected[] list ("Fixed in 140.*" or "115.*")
even when the installed version was 150.0.3 — the parser ignored
which ESR/mainline stream the install belonged to.
Approach
- FixCandidate dataclass captures every (version_start, less_than,
less_than_or_equal, product, vendor) tuple from CVE-5 affected[]
and NVD cpeMatch[].
- _parse_vulnrichment_record + _load_via_nvd now populate
VerifiedCVEData.fix_candidates with the full list.
- pick_fix_for_installed(candidates, installed) picks:
1. same-major candidate whose less_than > installed (closest patch)
2. same-major catch-all
3. catch-all (version_start = "0") with less_than > installed
4. first exclusive bound (fallback / no installed)
- _apply_single_override calls the picker per child
VulnerabilityPackage so multi-package CVEs (PuTTY + WinSCP on
CVE-2023-48795) get per-package fix targets. Parent column also
uses the picker scoped to the legacy package_version summary.
- _version_major / _version_tuple helpers handle SemVer, ESR
suffixes ("140.0esr" → 140), Adobe date.build (first segment as
major), and bail out on garbage.
Backward compat
- fixed_version still populated with the first exclusive candidate
for callers that don't iterate fix_candidates.
- _merge_cvss_into propagates candidates across cascade stages.
What this fixes in practice
- Firefox 150.0.3 + CVE listing ESR 115 / ESR 140 / mainline → now
picks the 151.0 target.
- Vim/filelock single-stream operator-prefix versions ("< 9.2.0450")
still work via _fix_from_version_string.
- Ghostscript inclusive-bound case still produces no false PATCH
AVAILABLE.
Out of scope (separate plan):
- Adobe date.build.patch normalization (CVE-2020-9715: cvelistV5
"2017.011.30079" vs NVD CPE "17.011.30079" — need product mapping).
- Cross-product canonical CVSS when same CVE has divergent scores
per-asset.
Tester reported CVE-2026-8948 (Mozilla Firefox < 151.0) detail view
showed INSTALLED: — and FIXED IN: not announced, even though the
Nessus plugin output clearly carried:
Path : C:\Program Files\Mozilla Firefox
Installed version : 150.0.3
Fixed version : 151.0
Root cause: per-host outputs live in
plugin_payload["outputs"][i]["plugin_output"]
NOT in the info dict that plugin_fixed_version() was scanning. So
only `info.solution` (and the rarely-set `info.plugin_output`) ever
reached the regex.
Fix
- plugin_fixed_version() now also scans every entry in
plugin_payload["outputs"][i]["plugin_output"] — same regex set.
Firefox solution "Upgrade to Mozilla Firefox version 151.0 or
later." already matched via the multi-word regex from 26df8d8,
but the per-host "Fixed version : 151.0" line is a more reliable
exact match.
- New plugin_installed_version() pulls "Installed version : X" from
the per-host outputs.
- nessus_sync writes installed_version into Vulnerability.package_
version on insert (was None) AND backfills it on existing rows
where the column is empty.
Tester asked for "echte Revisionssicherheit" — WHO patched WHAT,
WHEN, WHY — and reported that Nessus auto-patched CVEs (rows that
disappeared from a follow-up Nessus scan) were not visible in any
status anymore: not OPEN, not PATCHED, just gone-feeling.
Backend
- VulnerabilityUpdateRequest gains a status-agnostic `reason: str`
field (defer_reason kept for legacy callers).
- log_vulnerability_change() now persists a JSON payload in
audit_logs.new_value: {status, reason, source, cve_id}. user_id
is nullable so automated transitions are clearly marked.
- Both auto-patch paths now write audit-log entries:
* Nessus stale_nessus_vulns loop → "Nessus rescan {scan_id} no
longer reports this CVE on {hostname}"
* Wazuh stale_wazuh loop (both per-agent + full-sync) → same
pattern with the agent_id.
- /audit/logs accepts resource_type + resource_id filters and lets
editors query a single resource (admins still see everything).
old_value + new_value added to the response so the frontend can
parse the JSON payload.
Frontend
- Status-change modal sends `reason` (status-agnostic) instead of
always overwriting defer_reason. Deferred status keeps the legacy
fields too.
- Vuln detail page renders a "Change History" panel below Timeline:
per-entry old→new transition, source tag (manual / nessus_sync /
wazuh_sync), free-text reason, username + timestamp. Nessus auto-
patched CVEs now have a clear paper trail.
No migration — audit_logs schema already had old_value / new_value
columns; we're just populating them with structured JSON now.
Tester reported "Spaltensortierung gilt nur teilweise für die
aktuell angezeigten CVES." Cause: priority/cpr were computed in
Python from calculate_priority_breakdown() at response time. The
list endpoint paginated FIRST in SQL by an inaccurate proxy then
re-sorted only the current page by the real score → page 1 looked
fine, pages 2+ were out of order.
Schema (migration 024)
- Add indexed columns `priority_score` + `cpr_score` (FLOAT) to
vulnerabilities.
Model
- `Vulnerability.refresh_scores()` recomputes + persists both
columns. Cheap, no I/O.
Write paths now call refresh_scores():
- Wazuh sync (sync_agent_vulnerabilities) — both insert + update branches
- Nessus sync (run_nessus_sync) — both merge + create branches
- Enrichment (refresh_threat_intel_enrichment) — per-vuln after EPSS/KEV/EUVD
- Override service (_apply_single_override) — after the source pin
Sort
- /vulnerabilities?sort_by=priority|cpr now SQL-sorts on the
materialised columns (NULLs last on desc, first on asc, with
id tiebreaker). Whole filter result is in correct order across
pages.
Backfill
- POST /vulnerabilities/recompute-scores (editor) — one-off pass
over all rows. Run once after the upgrade so existing data
picks up scores. Nightly Vulnrichment + URS jobs would converge
the rest naturally.
Status filter UX (open question to operator)
- Tester also wondered if "All Statuses" should include closed
states (false_positive / accepted_risk / deferred). Current
behaviour matches the dropdown label literally and is left
unchanged here — separate decision.
Tester reported four more fixed_version extraction misses:
1. Vim CVE-2026-45130: CVE-5 declares the affected range inside the
version string itself ("version": "< 9.2.0450") with no structured
lessThan field. Parser saw lessThan=None and gave up. Now uses a
regex helper to pull the exclusive upper bound from the version
string. Inclusive ("<= X") still yields no fix (Ghostscript rule).
2. filelock CVE-2025-68146: same shape ("< 3.20.1"). Same fix.
3. Nessus Firefox plugins: solution text "Upgrade to Mozilla Firefox
version 151.0 or later." failed the previous regex because it only
tolerated ONE word between "Upgrade to" and "version". Multi-word
product names (Mozilla Firefox, Adobe Acrobat Reader DC, ...) now
match via `{0,6}` quantifier. Also added a fallback pattern that
accepts "Upgrade to <product> X.Y.Z" without the literal "version"
keyword.
Still out of scope (will land in a follow-up):
- Multi-stream picking: Firefox CVE listing 115/140/150 lessThan
entries — parser takes first match instead of the one whose major
matches the installed version. Needs installed↔stream comparison.
- Adobe date.build.patch normalization (CVE-2020-9715 etc.) — needs
NVD CPE lookup + product mapping.
- Same-CVE / divergent-CVSS rows when sorted by priority vs CPR —
per-asset rows hold per-asset scores; will require a "canonical
CVSS" join.
Tester reported CVE-2023-48795 hitting PuTTY 0.73 AND WinSCP 6.1.2
on the same host — both joined into a single
`vulnerabilities.package_name` string ("PuTTY..., WinSCP...") with
ONE `fixed_version`. Per-package fix tracking impossible.
Schema (migration 023)
- New table `vulnerability_packages` (vuln_id, package_name,
package_version, fixed_version, source, first_detected_at,
last_seen_at). Unique on (vuln_id, package_name).
- Backfill creates one child per existing vuln carrying the joined
string verbatim (no comma-split — joined names may contain commas).
- ON DELETE CASCADE + ORM passive_deletes so asset deletes propagate.
Sync (Wazuh)
- sync_agent_vulnerabilities collects per-package rows in a dict
keyed by package_name and upserts VulnerabilityPackage children
after the parent insert/update.
- last_seen_at updated each sync; future enhancement can prune
packages Wazuh stopped reporting (mirror parent-row stale logic).
Override service
- _apply_single_override propagates Vulnrichment/NVD/cvelistV5
fixed_version into every child package row that has none AND
whose installed_version differs from the proposed fix
(Ghostscript inclusive-bound case).
API
- VulnerabilityResponse gains `packages: List[PackageInfo]` and
`has_fix_any: bool`. _build_vuln_response emits per-package data.
- Forward refs resolved with model_rebuild().
Frontend
- VulnerabilityPackage type + Vulnerability.packages + has_fix_any
- PATCH AVAILABLE badge on list page now uses has_fix_any +
fixed_version != installed_version check (no more false positives
when supposed fix equals affected version).
- Detail page renders a per-package table (Installed / Fixed in /
FIX indicator / source tag) when child rows exist, falls back to
legacy single-package summary for pseudo-CVEs.
No breaking changes — parent columns (`package_name`,
`package_version`, `fixed_version`) stay for legacy queries. Nessus
sync still writes only the parent row; per-package Nessus support
can land as a follow-up.
Tester reported Ghostscript CVE-2025-59798 et al. showing
"PATCH AVAILABLE / Fixed in 10.05.1" while the installed version
was 10.05.1 — i.e. the supposed fix is the affected version itself.
Root cause: CVE-5 and NVD ranges have two upper-bound forms.
- `lessThan` / `versionEndExcluding` → exclusive → fix at X
- `lessThanOrEqual` / `versionEndIncluding` → inclusive → X is
affected, no fix announced yet
The cascade was treating both as "fix here". Now only the exclusive
form populates `fixed_version`; inclusive bounds leave it None so
the UI no longer falsely claims a patch.
Diagnosed: Wazuh's wazuh-states-vulnerabilities-* indexer does NOT
carry a fix-target version. Tester's raw _source dump shows:
vulnerability: { id, score, severity, description, references,
scanner.condition: 'Package default status', ... }
No fix / fix_version / lessThan field anywhere. Wazuh-side limit,
not a parser bug. My earlier "less than X.Y.Z" regex over
vulnerability.condition matched zero rows because Wazuh's condition
strings are 'Package default status', not version ranges.
Switch to the override service for fixed_version — already running
the 3-stage cascade for CVSS/SSVC corrections, so add fixed_version
to the same pipeline.
VerifiedCVEData gains fixed_version (Optional[str]).
_parse_vulnrichment_record (used by Vulnrichment ZIP + cvelistV5
ZIP, since both are CVE-5 JSON) now walks containers.cna.affected[]
and containers.adp.affected[] for versions[].lessThan /
lessThanOrEqual. First non-'*' / non-'0' match wins.
_load_via_nvd walks cve.configurations[].nodes[].cpeMatch[] for
versionEndExcluding / versionEndIncluding (NVD's equivalent).
NVD returns this for nearly every analysed CVE.
_merge_cvss_into propagates fixed_version through the cascade so a
CVE that Vulnrichment knows but missed the fix gets cvelistV5 /
NVD's value layered on.
apply_overrides writes vuln.fixed_version when the column is empty
(don't trample a Nessus-supplied version, those are typically more
precise — e.g. KB number for Windows). Marked as a change so
exploitation_source gets pinned.
After deploy + 'Correct CVSS' run, the has_fix count goes from 0
(Wazuh-only state) to roughly the same number as
exploitation_source IS NOT NULL — every CVE the cascade reached
gets fixed_version when the upstream has it. PATCH AVAILABLE pill
lights up on Vulns-list for those rows.
Two colleague-reported issues addressed in one commit.
A) PATCH AVAILABLE badge never lit up
Schema column, API field, and frontend pill were all in place
since commit 0e83548, but no sync code ever wrote fixed_version
into the DB. Result: column NULL for every row, badge silent.
Wazuh sync (query_vulnerabilities_from_indexer):
- Tries vulnerability.fix → vulnerability.fixed_version → package.fix
- Falls back to regex over vulnerability.condition / package.condition
("Package less than X.Y.Z" pattern, common in Wazuh 4.x feeds)
- Emits fixed_version on every result dict
Both router paths (run_wazuh_vulnerability_sync and
sync_agent_vulnerabilities) now persist it on create AND backfill
it on update when the column was previously NULL.
Nessus sync (NessusClient.plugin_fixed_version):
- Reads vuln_information.fixed_version / fix_version when present
(Windows MS-Bulletin plugins always carry it)
- Falls back to regex over solution + plugin_output text:
"Fixed version : X", "Upgrade to ... version X", "Upgrade to X"
nessus_sync writes it on new vulns and backfills empty existing
rows on merge.
B) Duplicate assets — Nessus FQDN vs Wazuh short hostname
_find_or_create_asset matched hostnames with strict .ilike(),
so 'host01.umgebung.local' (Nessus) never matched 'host01'
(Wazuh) → second asset created, scoring split, dashboard misleading.
Match order now:
1) nessus_host_uuid pin
2) exact hostname.ilike(full_form)
3) short form (everything before the first '.') against
Asset.hostname
4) reverse — asset stored as FQDN, Nessus reports short →
Asset.hostname.ilike('{short}.%')
5) IP exact
6) auto-create with the SHORT hostname so the next Wazuh sync
consolidates onto the same row
Existing duplicate rows stay — operator can merge via SQL or
delete the Nessus-only asset once everything points at the
Wazuh asset via nessus_host_uuid pinning.
Three colleague-reported issues addressed in one commit.
A) 'Sync Data (Wazuh)' button left no entry in /scans history
while the Nessus button did (since commit 4bca41e). Mirrored the
pattern: run_wazuh_vulnerability_sync now opens a per-agent Scan
row (scan_type=WAZUH, status RUNNING→COMPLETED/FAILED) before
fetching vulns and closes it after the source-aware backfill,
recording vulnerabilities_found = len(active_cves). Skip-backfill
branch records 0 + error_message explaining why.
B) Vulns-list SSVC badge only fired for exploitation_status != 'none'.
~99% of Vulnrichment-curated CVEs have exploitation_status='none'
(CISA flags 'no known exploitation' for most), so colleague's
495 SSVC-source-pinned CVEs showed zero badges. Added two more
pills surfacing the actually-interesting SSVC dimensions:
TI-TOTAL ssvc_technical_impact='total' (attacker → full takeover)
AUTO ssvc_automatable='yes' (reliable mass exploitation)
These render alongside the existing POC/ACTIVE/WIDESPREAD pill.
C) Compliance refresh appears to ignore disconnected agents — likely
not a code filter but Wazuh returning empty /sca/{agent_id}
responses for offline agents. Added per-asset logging when
policies_synced=0 and an assets_no_data counter in the stats so
the operator can see how many agents Wazuh actually has SCA data
for. No code change to the filter — disconnected agents are still
queried, just transparently reported as data-less if Wazuh has
nothing on them.
Tester reported only 4 hits for filter
exploitation_source='vulnrichment' AND exploitation_status != 'none'
while ssvc_technical_impact='total' returned 4682 and
ssvc_automatable='yes' returned 842 rows. Mismatch by orders of
magnitude.
Root cause: _apply_single_override only set vuln.exploitation_source
inside the CVSS and severity change blocks. SSVC writes
(exploitation_status, ssvc_technical_impact, ssvc_automatable) went
through their own branches without touching the source label. So a
CVE whose Wazuh CVSS happened to already match Vulnrichment got SSVC
fields written but exploitation_source stayed NULL.
Two-part fix:
1. _apply_single_override now sets exploitation_source whenever ANY
tracked field changed (single guard at the end of the function
replaces the two redundant assignments inside CVSS/severity blocks
— they still work because changes['has_changes'] is True there).
2. Migration 022 backfills exploitation_source='vulnrichment' on
every row that has ANY SSVC field populated but no source yet.
Idempotent. Existing nvd / cvelistv5 / manual source labels are
not touched (WHERE exploitation_source IS NULL).
After deploy + alembic upgrade head, the tester's filter will
return the real count (~840 SSVC-marked CVEs from vulnrichment,
not just the 4 with CVSS-diff coincidence).
Tester ran 'Correct CVSS' against a populated DB and saw:
vulnrichment: ZIP walk found 237/532
cvelistV5: walk found 13/295
→ only 30 rows written to exploitation_source
Root cause: when Vulnrichment returns a CVE with SSVC decision
points but no CVSS, _parse_vulnrichment_record returns
VerifiedCVEData(cvss_score=None, exploitation_status='...'). The
cascade then saw `cve_id in verified` → True → skipped NVD/cvelistV5
even though the CVE still needed a score. apply_overrides bailed on
the missing CVSS → no write.
Fix:
1. _needs_cvss(cid) helper — a CVE counts as missing if EITHER not
in verified dict OR verified entry has cvss_score=None.
2. _merge_cvss_into() helper — stages 2/3 merge their CVSS into the
existing record without trampling SSVC that stage 1 already set.
Source label upgrades only when the new score actually fills a
previously-None cvss_score, so vulnrichment→nvd churn is avoided.
After deploy + re-run 'Correct CVSS', the SSVC-only CVEs from
Vulnrichment will now pick up CVSS from cvelistV5 + write proper
override rows. Expected: source-count breakdown shows roughly the
same `verified` numbers as the log "found N/M" lines.
Operator running 'Correct CVSS' against a fresh DB sees Stage 1
(Vulnrichment) leave 295 CVEs missing → Stage 2 (NVD) would have
silently been skipped because 295 > 100 cap (rate-limit guard) and
no log entry explained the gap.
Now logs an explicit 'stage 2 (NVD): skipped' line with the cap
mentioned so the operator can see the cascade is functioning, just
falling through deliberately.
Plan F deep-dive on the low cvelistV5 hit rate (13/295) is pending
the path-schema verification the tester is running.
Plan F — adds the official MITRE/CVE.org cvelistV5 feed as the third
stage of the CVSS-correction cascade. The cascade now resolves in
order:
1. CISA Vulnrichment (ZIP > 25 CVEs, raw otherwise)
primary — CISA-curated, CVSSv3 + SSVC
2. NVD REST API (https://services.nvd.nist.gov/rest/json/cves/2.0)
fallback for ≤100 missing CVEs — rate-limit-bounded
3. cvelistV5 GitHub archive (557 MB ZIP, cached 12h on disk)
backstop for everything Vulnrichment + NVD couldn't see;
covers every published CVE (~250k+)
cvelistV5 uses the same CVE-5 JSON shape Vulnrichment does
(containers.cna.metrics / containers.adp.metrics) so the existing
_parse_vulnrichment_record handles both without code duplication.
Parsed records get source='cvelistv5' so the operator can tell where
the correction came from.
Disk cache /tmp/vulncheck-cvelistv5-cache.zip with 12h TTL keeps
re-runs cheap — first call downloads 557 MB, next call within 12h
skips download and walks the cache file (~30s vs ~2 min).
Nessus sync's override-lock extended to recognise all three
authoritative sources (vulnrichment / nvd / cvelistv5) so a
cvelistV5-corrected score doesn't get clobbered by the next Nessus
plugin-bundle.
After deploy, 'Correct CVSS' will now find scores for essentially
every real CVE — only the most recent (hours-old) CVEs without any
public CVSS yet stay uncorrected.
Tester verified CVE-2026-40416 (Microsoft Edge) IS in CISA
Vulnrichment with cvssV3_1.baseScore=4.3 — yet 'Correct CVSS' never
updated the Nessus rows. Root cause confirmed via curl:
containers.cna.metrics[0].cvssV3_1 = { baseScore: 4.3, MEDIUM } ← CNA
containers.adp[0].metrics[0].other = { type: ssvc, ... } ← ADP
Microsoft (the CNA) supplied the CVSS in their own container. CISA
(the ADP) only added SSVC decision points without re-publishing the
score. Our parser only walked the ADP container, so cvss_score
stayed None and apply_overrides treated the CVE as "no verified data".
Fix: walk containers.cna.metrics[] first (vendor authoritative), then
containers.adp[].metrics[] as fallback / for SSVC. Both can supply
the cvssV3_1 score; first non-null wins.
Apply impact: after deploy + re-run 'Correct CVSS', CVE-2026-40416
will drop from Nessus's plugin-bundle 8.3 to the authoritative 4.3
across all affected rows. Same fix benefits every other vendor-CNA
that supplies CVSS without CISA re-publishing it.
Tester reported CVE-2026-40416 (Microsoft Edge spoofing) kept its
Nessus plugin-bundle CVSS 8.3 after 'Correct CVSS' even though NVD
publishes it as 4.3 MEDIUM. Root cause: CISA Vulnrichment lags by
weeks/months for new CVEs — the CVE wasn't in the snapshot, so the
override service had no verified value to compare against and left
the Nessus score alone.
Pipeline now:
1. Vulnrichment ZIP snapshot (>25 CVEs) or per-CVE raw (≤25)
2. For CVE-IDs missing from the Vulnrichment response, fall back
to https://services.nvd.nist.gov/rest/json/cves/2.0
3. Parse cvssMetricV31 → V30 → first found base_score + severity
4. Returned in the same VerifiedCVEData shape so apply_overrides
can't tell which source filled it
Throttle: 1 req / sec via time.sleep(1) every 5 calls — well under
the unauth NVD limit (~5 req / 30 s). Single-correction runs (one
CVE missing) finish instantly; a batch missing 50 takes ~10 s.
For higher throughput we could later add nvd_config setting with
an API key (50 req / 30 s). Out of scope for now.
After deploy, run 'Correct CVSS' again — CVE-2026-40416 should drop
from 8.3 to 4.3 across all affected Nessus rows.
Tester's CSV uses 'cisecurity.org/recommendation' as the id header
— never matched any exact candidate, all 45 rows skipped.
Two-tier matching now:
1) exact candidate list (cis_id, recommendation #, section, …, plus
the new 'cisecurity.org/recommendation' explicitly)
2) substring fallback — any header containing 'recommendation',
'subsection' or 'section' is treated as cis_id when no exact
match was found. Picks up exotic shapes like 'CIS Subsection ID',
'Workbench Recommendation', URL-style 'cisecurity.org/...' etc.
Diagnose label flags substring matches explicitly so the operator
can verify which header was used, e.g.:
ID column used: cisecurity.org/recommendation (matched by substring)
Tester uploaded a 45-row CIS workbench CSV and got 'imported 0,
skipped 45' with no hint why. Two improvements:
1) Parser robustness
- cis_id candidates expanded: cis_id, recommendation #, section #,
control id, ref, # etc. (case-insensitive matched)
- title/impact candidates expanded to common variants
- strict regex still tries first; on miss, loose extractor pulls the
first 'N.N.N' token from cells like 'Section 1.1.1 Ensure ...'
or 'L1 2.3.4.1 ...'
2) Diagnostic stats
import response now also includes:
detected_headers — every column header found in the CSV
id_column_used — which header matched a cis_id candidate
impact_column_used — which header matched an impact candidate
preview — first 3 skipped rows with reason + sample
Frontend alert shows this diagnose block whenever imported = 0,
so the operator can see exactly which column the CSV is missing
without poking around with curl.
If the CSV header is something we still don't recognise (e.g. German
'Empfehlung' instead of 'recommendation'), the operator will see
'ID column used: (none recognised)' + the actual headers — easy to
file a follow-up with the right alias.
services/urs_service.py implements the Unified Risk Score per the
tester's formula:
AVS (Asset Vulnerability Score)
hybrid default: 0.7 × avg(CPR) + 0.3 × max(CPR)
'avg' and 'max' modes also exposed so admin can pick policy
ASS (Asset Security Score)
mean of weighted_score across the asset's policies
weighted_score per policy = Σ(impact × passed) / Σ(impact)
falls back to simple score% when no impacts loaded for the policy
URS = round((AVS + ASS) / 2) × criticality_factor capped at 100
criticality factor: low ×0.7 | normal ×1.0 | high ×1.3 | crit ×1.5
Severity bands per spec:
90-100 CRITICAL | 70-89 HIGH | 40-69 MEDIUM | 1-39 LOW | 0 NONE
compute_urs() optionally writes a daily snapshot (per asset, one row
per snapshot_date) so the dashboard can show a 7-day trend arrow.
compute_urs_for_all() loops every asset — called by the nightly
scheduler job in E8 and after each Wazuh/Nessus/Vulnrichment sync.
get_urs_trend() returns the URS delta vs N days ago (positive =
degrading, negative = improving) — used by the dashboard widget.
Endpoints + UI consume this in the next commits.
services/compliance_impact_import.py — flexible CSV parser that auto-
detects column shape so the tester's CIS-Benchmark exports work
without preprocessing:
cis_id ← cis_id|recommendation|section|id|control|ref
title ← title|description|name|recommendation_title
impact ← impact|score|weight|risk_score|risk
level ← level|profile|tier
benchmark ← benchmark|policy|os (else from filename)
Impact normalisation handles common CIS workbench shapes:
- 0-100 raw int → kept
- 0-10 risk score → auto ×10
- percent strings ('45%') → stripped
- garbage → 50 (neutral fallback)
Decoding tries UTF-8 then cp1252 (Excel on Windows German export),
delimiter sniffed across , ; tab |. Rows whose id doesn't look like
a CIS subsection (e.g. '2.3.4.1') are skipped with a counter.
Two new admin endpoints on the compliance router:
POST /api/v1/compliance/impacts/import multipart upload, RequireAdmin
GET /api/v1/compliance/impacts/stats per-benchmark count + avg
Idempotent upsert by (cis_id, benchmark) — re-uploads update existing
rows. Returns per-file stats so the operator sees exactly which rows
were skipped and why.
services/compliance_service.py provides three public functions:
refresh_asset_compliance(db, asset_id, client=None)
→ pulls /sca/{agent_id}, upserts one compliance_results row per
policy, recalculates score. Returns stats dict.
refresh_all_compliance(db)
→ loops every asset with a wazuh_agent_id and calls the per-asset
refresh. Single shared WazuhClient for the whole loop.
fetch_policy_checks(db, asset_id, policy_id)
→ pulls /sca/{agent_id}/checks/{policy_id}, wipes + rewrites
compliance_checks rows for that result_row. Wipe-and-rewrite is
simplest because check_id is internal to a policy version and
has no good cross-run identity.
Wazuh key normalisation handles both 'pass'/'passed' and 'fail'/
'failed' shapes seen across Wazuh 4.x and 5.x. end_scan is parsed
defensively (ISO with/without microseconds, epoch fallback).
Client ownership: refresh_asset_compliance accepts an optional client
so refresh_all_compliance can share one connection across N assets
instead of opening N clients.
Router + frontend wiring in the next commits.
Tester reported Nessus-only assets had no OS info in VulnCheck even
though Nessus reports it on every scanned host (plugin 11936 'OS
Identification' is included by default). The data was sitting in the
host's 'info' dict but we ignored it.
When a host is processed, asset.operating_system now gets populated
from the first non-empty value of:
info['operating-system']
info['operating_system']
info['os']
Nessus occasionally returns a list of guesses ("Microsoft Windows
Server 2022 Standard\nMicrosoft Windows Server 2019 Datacenter"); we
take the first line. Truncated to 255 chars to fit the column.
Defensive: only fills when asset.operating_system is currently null.
Wazuh-sourced assets already carry structured agent OS data and we
deliberately do not clobber that with Nessus prose — Wazuh tends to
be more accurate for the host where the agent runs.
No schema change — operating_system column has existed since the
initial migration.
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
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 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 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.
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.
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>
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>
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>
- plugin_cvss() now uses _plugin_attrs() helper (eliminates duplicate
inline path extraction — single place to update if Nessus changes payload)
- non_cve_skipped initialised to 0 in stats dict so the field is always
present in the sync response even when every finding has a CVE
- vuln detail page: show title + description separately instead of
title || description (description was silently hidden when title existed)
- vuln detail page: add "Detection Sources" card — source badges (WAZUH /
NESSUS), cross-confirmed indicator, first_detected_by, Nessus plugin ID
(links to tenable.com/plugins), and Tenable VPR score with colour coding
Nessus plugin payloads carry more than just severity + CVE — we now
extract and store everything actionable:
- nessus_vpr_score (new column, migration 011): Tenable's VPR rating
(0-10), independent from our own priority_score
- exploit_available (existing boolean): True when Nessus knows of a
public exploit; only escalates, never overwrites Wazuh-confirmed True
- exploit_maturity (existing string): Unproven / PoC / Functional / High
- description: prose description + 'Solution:' section, only set when
empty so hand-written notes survive
- references: see_also URL list as JSON, only when existing is null
Create path sets all fields directly; merge path backfills empties so
existing Nessus findings get enriched on the next sync. API response
adds nessus_vpr_score; frontend Vulnerability type mirrors it.
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_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.
- 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.
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.
Phase 2 — full Nessus → VulnCheck import path.
app/integrations/nessus_client.py:
- NessusClient with X-ApiKeys auth, httpx + tenacity retry, 429-aware
- list_scans / get_scan / get_host / get_plugin_output
- extract_cves() pulls structured cve[] field + regex fallback over
plugin description/output (handles plugins that mention CVEs only in
text)
- plugin_severity_to_label() maps Nessus 0-4 → VulnerabilitySeverity
- plugin_cvss() prefers v3 base score over v2
app/services/nessus_sync.py:
- run_nessus_sync(db, scan_ids?) iterates configured / requested scans
- Asset matching cascade: nessus_host_uuid (pinned) → hostname →
ip_address → optional auto-create when nessus_config.auto_create_assets
- Per-CVE: if existing (cve, asset) found → add_source('nessus'),
escalate severity (max), bump CVSS if higher, set plugin_id, reopen
if previously patched. Else create new with sources=['nessus'].
- Non-CVE findings (EOL/Compliance/Ciphers) stored under pseudo-CVE
NESSUS-PLUGIN-{plugin_id}
- Backfill: vulns previously tagged 'nessus' that don't appear in this
scan run get 'nessus' dropped from sources; status flips to patched
only when sources list empties (i.e. no scanner sees the finding
anymore — never delete Wazuh data based on Nessus absence)
- Reuses dispatch_new_vuln_notifications + enrich_vulnerabilities so
Nessus-discovered CVEs flow through the existing digest mail + EPSS/
KEV/EUVD enrichment paths
app/routers/nessus.py:
- POST /api/v1/vulnerabilities/nessus/test auth probe
- GET /api/v1/vulnerabilities/nessus/scans list visible scans
- POST /api/v1/vulnerabilities/nessus/sync trigger sync
app/scheduler.py:
- execute_scheduled_scan() now routes by ScanSchedule.scanner_type:
'wazuh' → existing agent loop, 'nessus' → run_nessus_sync()
- Backwards compat: schedules default scanner_type='wazuh' via DB
default from migration 010
app/main.py: register nessus router.
No frontend yet — Phase 3 adds settings UI + sync button. The API is
already usable via curl after Phase 2 deploy + alembic upgrade head.