One-shot data migration that flips ACTIVE NESSUS-sourced assets with
nessus_host_uuid IS NULL to INACTIVE, with audit log entries. Closes
the backlog that the runtime path cannot reach (it only matches by
pinned UUID). Idempotent — clean DBs are a no-op. Downgrade is a
no-op (operator decision to revive).
Apply with:
docker compose exec backend alembic upgrade head
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PostgreSQL rule: an enum value added via ALTER TYPE ... ADD VALUE
cannot be used in the same transaction that added it. The
previously-attempted UPDATE backfill in this migration therefore
fails with:
psycopg2.errors.UnsafeNewEnumValueUsage
unsafe use of new value 'NESSUS' of enum type assetsource
HINT: New enum values must be committed before they can be used.
The backfill is now performed lazily at runtime in
app/services/asset_lifecycle.reconcile_missing_from_sync() which
runs in a later transaction (per-Nessus-sync). Migration 027 is now
purely additive — it only extends the two enum types.
The existing PostgreSQL assetsource enum was created with UPPERCASE
labels (WAZUH / MANUAL) — confirmed via:
SELECT enumlabel FROM pg_enum WHERE enumtypid='assetsource'::regtype;
The Python AssetSource enum was using lowercase string values
('wazuh' / 'manual' / 'nessus'), which PG rejected with:
invalid input value for enum assetsource: 'manual'
Align everything to UPPERCASE so the model and the migration speak
the same case as the existing column. Migration 027 now adds
'NESSUS' to the live enum and the backfill UPDATE uses 'MANUAL' /
'NESSUS' literals.
Tester feedback round 2026-06-01 — 8 issues grouped into 4 fixes.
**A. Sync-driven INACTIVE reconciliation** (highest priority)
- AssetSource.NESSUS enum value added; pre-existing MANUAL assets with
a nessus_host_uuid re-tagged in 027 migration.
- New `reconcile_missing_from_sync()` helper in asset_lifecycle flips
ACTIVE assets of a given source (WAZUH/NESSUS) to INACTIVE when their
id (wazuh_agent_id or nessus_host_uuid) is missing from the latest
sync. Vice-versa reactivates INACTIVE assets that re-appear.
- Hooked from nessus_sync.run_nessus_sync (after per-host loop, before
commit) and from POST /api/v1/assets/sync_wazuh (after the agents
loop). Both return assets_inactivated/assets_reactivated counts in
their response.
- INACTIVE-asset CVEs now hidden from list_vulnerabilities and
get_dashboard_statistics by default (new
`include_inactive_assets` query param opts back in).
- AuditEventType gains ASSET_DEACTIVATED + ASSET_REACTIVATED so the
transitions are filterable in the audit UI.
**B. EOL pseudo-CVE naming for Nessus + endoflife.date**
- nessus_sync: new `_office_pseudo_cve()` returns
EOL-MS-OFFICE-{YEAR} for Office variants, else EOL-NESSUS-{pid}.
`_normalise_office_pkg()` collapses MUI/Proofing strings to "MS
Office". Title suffix "OSX MUI..." trimmed for Office.
- nessus_sync: end-of-pass sweep collapses pre-existing
EOL-NESSUS-{pid} Office rows into the unified EOL-MS-OFFICE-{year}
anchor (status=patched + audit).
- eol_service: _PRODUCT_SLUGS now maps microsoftoffice[proofing/osxmui
/osxmuigerman/formac] -> ms-office. _YEAR_KEYED_SLUGS includes
ms-office. _pseudo_cve_id produces EOL-MS-OFFICE-{year} for free.
- eol_service: existing-row branch bumps detected_at so the
sort_by=detected_at widget ranks freshest finding first.
**C. Asset view icon asymmetry + dashboard panel fix**
- assets/page.tsx: coverage-gap icon now shows for any asset with
wazuh_agent_id OR nessus_host_uuid.
- nessus_sync._find_or_create_asset: backfills ip_address on existing
assets (UUID / hostname match) so the "Launch targeted Nessus scan"
button (already gated only on asset.ip_address) lights up.
- frontend types + backend AssetResponse expose nessus_host_uuid +
source.
- page.tsx CVE-id cell: `break-all` -> `whitespace-nowrap` so the
free L/R dashboard space isn't wasted on character-wrapped ids.
**D. Assets table sortable columns**
- list_assets: new sort_by / sort_order with whitelist-driven ORDER BY.
Last-scan sort uses nulls_last(). Joins Policy/User only for those
columns. Unknown sort_by falls back to hostname asc.
- assets/page.tsx: sortBy/sortOrder state, handleSort toggler, SortArrow
indicator, clickable headers with cursor-pointer + hover style, and
a useEffect re-fetch on change. Mirrors the pattern in
vulnerabilities/page.tsx.
**Out of scope (flagged)**
- `delete_asset` remains a hard delete with cascade. The user's
"previously-deleted asset not re-created by Scan+Sync" expectation is
intentional design — the auto-create gate (auto_create_assets) and
the new INACTIVE flip cover the rest of the use cases.
- DECOMMISSIONED AssetStatus is still unreachable through automation.
- Vulnerability-count column in assets table stays non-sortable (it
is a Python-side count, not a SQL column).
**Migration**
- 027_add_nessus_source_and_audit_events.py: ALTER TYPE for both
enums + UPDATE backfill. Run `alembic upgrade head` once on prod.
Tester: use Wazuh IT-hygiene (open listeners) as an extra risk
indicator — a host exposing VNC/RDP/Telnet is network-vulnerabler
regardless of CVE count.
Schema (migration 026)
- assets.network_exposure_score (FLOAT 0-100, partial-indexed >0)
- assets.exposed_services (JSON [{port, proto, service, risk, ip}])
- assets.exposure_updated_at
Service (exposure_service.py)
- get_ports() added to WazuhClient (/syscollector/{agent}/ports).
- analyze_ports(): classifies LISTENING sockets against a risky-port
table (Telnet 40, RDP/VNC/SMB 30-35, FTP/rsh 28-30, DB ports 22-26,
SSH 8, …). Loopback-bound listeners excluded. Score = strongest
listener at full weight + 40% of each additional, capped 100.
- refresh_all_exposure() walks Wazuh-linked assets, persists.
API + scheduler
- POST /api/v1/assets/refresh-exposure (on-demand).
- Nightly job 02:30 UTC.
- AssetResponse exposes network_exposure_score + exposed_services
(JSON parsed to list) + exposure_updated_at.
Frontend
- Assets list: new Exposure column — coloured score badge (red ≥60,
orange ≥30, yellow else) with the top services inline + full list
in the tooltip.
- "⚡ Exposure" toolbar button triggers a refresh.
Informational only — does NOT auto-change asset criticality (operator
owns that). Surfaces the data so the operator can raise criticality
on heavily-exposed hosts.
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 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 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.
Colleague's fresh deploy hit psycopg2.errors.UnsafeNewEnumValueUsage
on migration 020:
UPDATE scans SET scan_type='WAZUH'
WHERE scan_type IN ('FULL','SYSCOLLECTOR')
Postgres rejects DML using a newly-added enum value on the same
connection that added it, even after the ADD VALUE transaction
commits — until the connection is closed and reopened. Alembic
runs migrations 019 and 020 on the same connection, so 020 saw
the cached pre-WAZUH enum and refused the UPDATE.
Previous fix attempts (op.execute('COMMIT'), autocommit isolation)
either broke alembic_version tracking or only worked on specific
psycopg2 versions. Rather than chase another connection-level hack
the backfill is dropped:
- Migration 020 is now a no-op. Historic rows in DB stay as FULL /
SYSCOLLECTOR. Migration chain 018 → 019 → 020 → 021 → 022 stays
linear so 021/022 can still run.
- app/routers/scans.py adds _display_scan_type() helper that
collapses 'full' + 'syscollector' + 'manual' → 'wazuh' before
serialising to the API response. Frontend sees the unified label.
- ScanRunSummary.scan_type loosened from ScanType enum to str so
the display-mapped string passes Pydantic validation.
Operators stuck mid-failure (alembic_version=019, restart loop)
recover by pulling this commit + rebuild + restart — migration 020
now succeeds silently and the chain advances past it.
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).
ScanType.MANUAL was the label for the removed 'New Scan' stub button
(commit 8a82158). Nothing has written MANUAL since then. Cleanup:
- python ScanType enum: MANUAL removed
- frontend types: scan_type union narrowed to 'wazuh' | 'nessus'
plus legacy 'full' | 'syscollector' for old history rows that
predate migration 020
- migration 021: DELETE FROM scans WHERE scan_type='MANUAL' so the
one leftover PENDING row tester reported is gone
Postgres enum value 'MANUAL' stays in scantype — DROP VALUE is not
supported cleanly and unused values are harmless.
Deploy:
alembic upgrade head # 020 → 021
Previous 019 tried to ALTER TYPE ADD VALUE and UPDATE rows using
that value in the same alembic migration. Postgres requires the new
enum value to be committed before DML can reference it. The hack
of calling op.execute('COMMIT') mid-migration broke alembic's own
transaction handling — it then failed to UPDATE alembic_version
with 'expected to match one row when updating 018 to 019; 0 found'
and left the DB half-migrated.
Now split cleanly:
019 — only adds the WAZUH enum value (idempotent IF NOT EXISTS).
Lands without DML so alembic's transaction is clean.
020 — runs the UPDATE backfill (FULL + SYSCOLLECTOR → WAZUH) on
scan rows. By this point WAZUH is a fully committed enum
value from migration 019.
Both naturally idempotent. After deploy, the Scan Jobs page renders
'wazuh' / 'nessus' / 'manual' for both new and historic rows.
If your DB is currently stuck at the half-applied 019:
- enum value WAZUH is already added (visible in `\\dT+ scantype`)
- alembic_version still says 018
Run `alembic stamp 019` then `alembic upgrade head` to skip past
the now-empty 019 and land at 020.
Tester noticed the Scan Jobs history showed 'Full' for every scheduled
Wazuh run and 'Syscollector' for every autoscan. Operator can't tell
which scanner produced the row at a glance — both labels are opaque
implementation details, not the source.
Consolidates to canonical source-named ScanType.WAZUH:
- scheduler.py (was FULL)
- routers/scans.py autoscan (was SYSCOLLECTOR)
Nessus path already uses ScanType.NESSUS so the table now reads
'wazuh' / 'nessus' / 'manual' — matches what the operator expects.
Migration 019:
1. ALTER TYPE scantype ADD VALUE IF NOT EXISTS 'WAZUH' (uppercase
to match SQLAlchemy Enum.name binding used by existing values).
2. UPDATE scans SET scan_type='WAZUH' WHERE scan_type IN
('FULL','SYSCOLLECTOR') — historic rows pick up the cleaner
label too, so the operator doesn't see mixed history.
Old FULL/SYSCOLLECTOR enum values remain in the type — dropping
Postgres enum values is destructive. They're flagged as legacy in
the Python enum comment and never written by new code.
Frontend display is unchanged — already uses {run.scan_type} with
.capitalize, so 'wazuh' → 'Wazuh' renders automatically.
Tester hit the same 'null value in id' NotNullViolation across
multiple deploys because Base.metadata.create_all() at app startup
sometimes creates the table without the SERIAL sequence (or a prior
failed migration left the table without one).
Migration 018 is fully idempotent and runs at every alembic upgrade.
For each of the 4 affected tables (compliance_results,
compliance_checks, compliance_impacts, asset_risk_snapshots) it:
1. Skips if the table doesn't exist (older DB).
2. CREATE SEQUENCE IF NOT EXISTS table_id_seq.
3. ALTER COLUMN id SET DEFAULT nextval(seq).
4. ALTER SEQUENCE OWNED BY table.id (drop-cascades cleanly).
5. setval(seq, MAX(id)+1).
Wrapped in a DO $$ ... $$ block so the EXECUTE statements survive
re-runs without errors.
After this lands the manual recovery SQL is no longer needed —
every `alembic upgrade head` ensures all four tables have working
auto-increment IDs.
Plan E commit 1/8 — three model additions backing the Unified Risk
Score (URS) feature described by the tester:
1. ComplianceImpact — per-(cis_id, benchmark) impact rating 0-100.
Loaded via CSV import (next commit). Weighting source for the
impact-aware ASS score.
2. AssetRiskSnapshot — daily snapshot of (AVS, ASS, URS, severity)
per asset. Drives the trend arrow on the dashboard + asset detail
and survives recomputations so we can compare against historic.
3. Asset.criticality (low/normal/high/critical) + Asset.compliance_
frameworks (JSON list). Criticality multiplies URS (low ×0.7,
normal ×1.0, high ×1.3, critical ×1.5 capped at 100). Frameworks
list lets the per-framework URS breakdown highlight what matters
for this asset.
4. ComplianceResult.weighted_score — companion to existing `score`,
uses impact weights when available. Falls back to None when no
impacts loaded.
Migration 017 idempotent via column + table existence guards.
First commit of Plan D — compliance/SCA module. Two tables:
- compliance_results: per (asset, policy) summary
asset_id, policy_id, policy_name, policy_description,
total_checks, pass_count, fail_count, not_applicable_count,
score (pass / (pass+fail) × 100, nullable when no scoring data),
end_scan (Wazuh-side scan timestamp), last_synced (our refresh ts)
Unique on (asset_id, policy_id) so refresh upserts cleanly.
- compliance_checks: per individual check, optional deep-dive
result_id, check_id, title, description, rationale, remediation,
result (passed|failed|not applicable), severity
Only populated when /compliance/{asset_id}/{policy_id}/checks is
hit — the asset-detail card reads compliance_results summaries.
Migration 016 idempotent via IF NOT EXISTS table check.
Wazuh-API client extension + service + router + frontend follow in
the next commits of this plan.
Migration 014 added 'nessus' (lowercase) but SQLAlchemy's SQLEnum
binding serializes enum members by NAME — which is uppercase
('NESSUS'). Inserts therefore fail with:
invalid input value for enum scantype: "NESSUS"
The initial-schema migration (001) created the enum with uppercase
values ('FULL', 'SYSCOLLECTOR', 'MANUAL'), so the additional
'NESSUS' value matches that convention.
Idempotent ADD VALUE IF NOT EXISTS. The dead lowercase 'nessus' from
014 stays in the enum (Postgres has no clean DROP VALUE) but is
harmless — nothing writes it.
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 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.
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.
Migration 010 + model updates that prep VulnCheck to merge Nessus
findings with existing Wazuh-sourced vulnerabilities on the same
(cve_id, asset_id) row instead of creating parallel duplicates.
Schema (alembic/versions/010_add_nessus_integration.py):
- vulnerabilities.sources JSON list of scanners that detected
this finding, e.g. ["wazuh","nessus"]
- vulnerabilities.nessus_plugin_id Nessus plugin ID for the finding
- vulnerabilities.nessus_finding_uuid stable per-finding identifier
- vulnerabilities.first_detected_by which scanner first reported it
- vulnerabilities.cve_id widened 20 -> 50 chars so non-CVE Nessus
findings can be stored as
NESSUS-PLUGIN-{plugin_id} pseudo-CVEs
- assets.nessus_host_uuid pin Nessus host after first match
- scan_schedules.scanner_type wazuh|nessus, default wazuh for
backwards compat
- Backfill: every existing vuln sources = ["wazuh"],
first_detected_by = wazuh
Model helpers:
- Vulnerability.source_list / cross_confirmed / is_pseudo_cve properties
- Vulnerability.add_source(name) / remove_source(name) (no commit)
- Asset.nessus_host_uuid column
- ScanSchedule.scanner_type column
No behaviour change yet — Phase 2 will add the NessusClient + sync
function that actually populate these fields.
Both branches of work added a migration with revision id 008 — the
threat-intel EUVD migration (008_add_euvd_fields, committed first) and
the multi-provider-auth migration (008_multi_provider_auth, committed
later). Alembic refused to start with 'Multiple head revisions are
present for given argument head'.
Resolution: multi-provider-auth becomes 009, chained after 008_add_euvd_fields.
No schema content changes; only revision/down_revision identifiers and
the filename.
EUVD (EU Vulnerability Database, ENISA) integration as second
authoritative catalog alongside CISA KEV. EU-Compliance use cases
benefit from a non-US source; CVEs confirmed by both catalogs get
the highest priority via score stacking.
Two ENISA endpoints are merged into one cached map (24h TTL):
- /exploitedvulnerabilities (analogous to CISA KEV)
- /criticalvulnerabilities (ENISA Critical flag)
Priority-Score formula:
- Exploit-Signal now triggered by KEV OR EUVD listing
- Catalog-Bonus stacking: KEV +10, EUVD +10, KEV-ransomware +5,
EU-Critical +3. A CVE in both catalogs adds +20 base.
CPR Score (Cybersecurity Priority Risk = CVSS x EPSS x 10) added
as separate metric next to Priority, per JacquesKruger/EPSS-Server
convention. Calculated on-the-fly, no DB column needed.
New API filters: euvd_only, eu_critical, in_any_catalog,
in_both_catalogs. Setting toggle enrichment_euvd_enabled (default true).
Frontend: new EUVD column (blue badge, EU-CRIT sub-badge), CPR column
with mini-bar, four catalog filter checkboxes. Detail page splits
threat intel into CISA KEV / ENISA EUVD / EPSS sections; breakdown
shows EUVD bonus row and CPR score with both-catalogs hint.
Risk score now pulls from multiple threat intel sources instead of
only AI/CVSS data:
- EPSS (FIRST.org) — probability of exploitation in next 30 days
- CISA KEV — known actively exploited vulnerabilities (with ransomware flag)
- Existing Wazuh exploit flags as fallback
Adds DB columns (epss_score, epss_percentile, kev_listed, kev_*,
enrichment_sources, enrichment_updated_at), an enrichment_service
with cached KEV catalog (24h TTL in settings table) and batched
EPSS lookups, manual + bulk + KEV-refresh endpoints, automatic
enrichment after Wazuh sync, and a daily scheduler job to refresh
scores.
Frontend gets KEV badges, EPSS column with percentile, KEV-only +
EPSS-min filters, a "Refresh Threat Intel" button, and a priority
score breakdown card on the detail page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>