Commit Graph

29 Commits

Author SHA1 Message Date
vulncheck 37cf6c17b2 chore(migration): alembic 028 reconcile legacy Nessus assets
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>
2026-06-02 14:10:06 +02:00
vulncheck 7e34d740da fix(migration): drop backfill UPDATE — PG forbids new enum in same txn
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.
2026-06-02 09:33:52 +02:00
vulncheck 4660e0320f fix(migration): uppercase AssetSource enum values to match live DB
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.
2026-06-02 09:22:11 +02:00
vulncheck 808246f0a9 fix(feedback-2026-06-01): sync-driven INACTIVE, EOL naming, sortable assets
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.
2026-06-02 08:58:51 +02:00
vulncheck f6ea2f1e17 feat(assets): network-exposure risk dimension from Wazuh ports (feedback #5)
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.
2026-06-01 08:40:36 +02:00
vulncheck e656701293 feat(exploit-intel): public exploit catalogs (EDB / PoC-in-GitHub / Metasploit)
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.
2026-05-27 18:49:13 +02:00
vulncheck bb5f4cc1ec fix(vulns): materialise priority_score + cpr_score for global SQL sort
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.
2026-05-25 19:38:06 +02:00
vulncheck 527c708814 feat(vulns): per-package detail table + UI for multi-package CVEs
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.
2026-05-25 19:08:01 +02:00
vulncheck 06f1615446 fix(migrations): replace 020 with no-op + display-map at api layer
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.
2026-05-20 14:26:09 +02:00
vulncheck f3a5e1e89c fix(override): pin exploitation_source on any field change, not just cvss
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).
2026-05-20 14:13:45 +02:00
vulncheck 5e18262213 chore(scans): drop ScanType.MANUAL — no writer remains after stub removal
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
2026-05-20 08:43:40 +02:00
vulncheck d7ad8a78dc fix(migrations): split 019 — alter type + dml must run in 2 migrations
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.
2026-05-20 08:36:16 +02:00
vulncheck 49b55e0066 fix(scans): unify wazuh scan_type → 'WAZUH' for clear source labelling
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.
2026-05-20 08:33:06 +02:00
vulncheck 0a2803ceed fix(migrations): self-heal compliance id sequences (018)
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.
2026-05-19 15:39:22 +02:00
vulncheck de37863cc2 feat(urs): schema for unified risk score — impacts, snapshots, criticality
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.
2026-05-19 14:14:54 +02:00
vulncheck 89e74daae1 feat(compliance): add compliance_results + compliance_checks schema
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.
2026-05-19 08:12:03 +02:00
vulncheck 61cb56c546 fix(scans): add uppercase NESSUS to scantype enum (case mismatch)
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.
2026-05-18 11:54:02 +02:00
vulncheck 4bca41e63e feat(scans): nessus sync now writes per-host scan history rows
Tester noticed that scheduled + manual Nessus syncs left no trace in
the Scan-Jobs page — only Wazuh autoscan was visible there. Made
it impossible to verify Nessus scheduler runs after the fact.

Changes:

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

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

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

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

Migration required on deploy:
    docker compose exec backend alembic upgrade head    # 013 → 014
2026-05-18 11:43:01 +02:00
vulncheck 70840e0d0a feat(ssvc): persist + display technical_impact and automatable
CISA Vulnrichment scores each CVE on three SSVC decision points:
Exploitation, Technical Impact, Automatable. We were already
persisting Exploitation (exploitation_status), but the other two
were parsed and thrown away — exactly the signal the tester wanted
to use to spot 'attacker takes total control + mass-exploitable'
CVEs at a glance.

Adds:

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

- Model: matching SQLAlchemy columns on Vulnerability.

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

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

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

Frontend list column for these will follow once we have CPR bonus
weighting (next commit), so the operator sees the score uplift
alongside the badge in one motion.
2026-05-17 11:50:18 +02:00
vulncheck 3074820737 feat: CVSS score override service with CISA Vulnrichment support
- Add VulnOverrideService to correct erroneous Wazuh CVSS scores (e.g. 10.0 placeholder)
- Add CISA Vulnrichment integration for verified CVSSv3.1 scores and SSVC exploitation status
- Add new API endpoints:
  - GET /override/check - find incorrect scores
  - POST /override/nessus/{asset_id} - override from Nessus
  - POST /override/vulnrichment - override from CISA (all CVEs)
  - GET /override/stats - score error statistics
- Add exploitation_status (SSVC: none/poc/active/widespread) and exploitation_source columns
- Add 'Correct CVSS' button in vulnerabilities frontend
- Add SSVC badge display in vulnerability table
- Update Vulnerability model and types

Fixes: CVE-2026-8390 incorrect CVSS 10.0 → 7.3 HIGH
2026-05-14 20:36:51 +02:00
vulncheck 0011a6364d fix(alembic): make 011 idempotent via Postgres IF NOT EXISTS
The previous inspect-based guard didn't catch the case where
Base.metadata.create_all() added nessus_vpr_score on app start
before alembic_version reached 011 — leading to DuplicateColumn
on upgrade.

Switch to raw SQL with IF NOT EXISTS for both column and index,
so the migration is a true no-op when the column already exists.
2026-05-14 17:26:06 +02:00
vulncheck 9ae16a3668 feat(nessus): import VPR, exploit_available, maturity, description, solution
Nessus plugin payloads carry more than just severity + CVE — we now
extract and store everything actionable:

- nessus_vpr_score (new column, migration 011): Tenable's VPR rating
  (0-10), independent from our own priority_score
- exploit_available (existing boolean): True when Nessus knows of a
  public exploit; only escalates, never overwrites Wazuh-confirmed True
- exploit_maturity (existing string): Unproven / PoC / Functional / High
- description: prose description + 'Solution:' section, only set when
  empty so hand-written notes survive
- references: see_also URL list as JSON, only when existing is null

Create path sets all fields directly; merge path backfills empties so
existing Nessus findings get enriched on the next sync. API response
adds nessus_vpr_score; frontend Vulnerability type mirrors it.
2026-05-14 17:24:08 +02:00
vulncheck ed72d67e87 fix(alembic 010): make Nessus migration idempotent
Server hit 'column sources of relation vulnerabilities already exists'
because Base.metadata.create_all() pre-created the columns when the
container ran ENV=development at some point. Re-running alembic upgrade
then crashed on the first add_column.

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

No new behaviour, no rollback needed for installs that already ran
parts of 010.
2026-05-13 23:19:39 +02:00
vulncheck ec81ec609f feat(nessus): schema + model for multi-source vuln tracking
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.
2026-05-13 23:04:01 +02:00
vulncheck 68b5604cef fix(alembic): rename 008 multi-provider-auth to 009 (conflict with 008_add_euvd_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.
2026-05-12 19:23:22 +02:00
vulncheck 25c8ef0866 feat(auth): multi-provider auth foundation (Strategy pattern + TOTP)
Phase 1 of the LDAPS / SAML 2.0 / OIDC integration. Lays the abstraction
so subsequent provider strategies (LDAP, OIDC, SAML) plug in without
re-wiring the login endpoint.

Backend changes:
- Alembic migration 008 adds users.auth_provider (enum: local/ldap/saml/oidc),
  external_id (indexed), external_groups (JSON), totp_secret, totp_enabled,
  last_provider_sync. password_hash becomes nullable for external users.
  Audit event enum extended with LOGIN_LDAP_SUCCESS, LOGIN_SSO_SUCCESS,
  AUTH_PROVIDER_FAILED, JIT_USER_CREATED, EXTERNAL_ROLE_MAPPED,
  USER_AUTO_LINKED, MFA_ENABLED/DISABLED/VERIFIED/FAILED.
- app/auth/strategies/ — Strategy Pattern: AuthStrategy ABC + ExternalIdentity
  portable identity + AuthResult. LocalAuthStrategy refactors existing
  bcrypt login. Constant-time dummy verify on user-not-found to defeat
  enumeration.
- app/auth/totp.py — RFC 6238 helpers (pyotp). Secret encrypted at rest
  with Fernet (key from AUTH_PROVIDER_CRYPTO_KEY env var). Never logged.
- app/auth/role_mapper.py — fnmatch-based external-groups -> UserRole
  mapping; rules in settings.auth_role_mappings JSON, admin-editable
  (Phase 5 UI to follow).
- app/auth/jit_provisioner.py — JIT user creation with auto-link by email
  on first SSO login (per requirements). Re-evaluates role on every login.
- app/auth/orchestrator.py — chains credential strategies in configurable
  AUTH_LOOKUP_ORDER. Generic safe message for every failure (audit logs
  the real reason).
- /auth/login refactored to use orchestrator. MFA gate: returns
  mfa_required=true + short-lived mfa_token if user has TOTP enabled;
  client POSTs /auth/mfa/verify with code to complete login.
- New endpoints: /auth/mfa/setup, /auth/mfa/activate, /auth/mfa/disable,
  /auth/providers (public — frontend uses to render correct buttons).
- requirements.txt: pyotp, cryptography, ldap3, authlib, itsdangerous,
  python3-saml, lxml.
- Dockerfile: libxml2-dev, libxmlsec1-dev, libxmlsec1-openssl, pkg-config
  for python3-saml; libsasl2/libldap/libssl-dev for future python-ldap.

Phase 2 (LDAPS), Phase 3 (OIDC), Phase 4 (SAML), Phase 5 (Admin UI for
provider config + role-mapping) will follow as separate commits.
2026-05-12 19:02:47 +02:00
vulncheck 356bf7b97f feat(risk-score): add ENISA EUVD enrichment + CPR score
EUVD (EU Vulnerability Database, ENISA) integration as second
authoritative catalog alongside CISA KEV. EU-Compliance use cases
benefit from a non-US source; CVEs confirmed by both catalogs get
the highest priority via score stacking.

Two ENISA endpoints are merged into one cached map (24h TTL):
- /exploitedvulnerabilities (analogous to CISA KEV)
- /criticalvulnerabilities (ENISA Critical flag)

Priority-Score formula:
- Exploit-Signal now triggered by KEV OR EUVD listing
- Catalog-Bonus stacking: KEV +10, EUVD +10, KEV-ransomware +5,
  EU-Critical +3. A CVE in both catalogs adds +20 base.

CPR Score (Cybersecurity Priority Risk = CVSS x EPSS x 10) added
as separate metric next to Priority, per JacquesKruger/EPSS-Server
convention. Calculated on-the-fly, no DB column needed.

New API filters: euvd_only, eu_critical, in_any_catalog,
in_both_catalogs. Setting toggle enrichment_euvd_enabled (default true).

Frontend: new EUVD column (blue badge, EU-CRIT sub-badge), CPR column
with mini-bar, four catalog filter checkboxes. Detail page splits
threat intel into CISA KEV / ENISA EUVD / EPSS sections; breakdown
shows EUVD bonus row and CPR score with both-catalogs hint.
2026-05-11 19:47:37 +02:00
vulncheck 42f867a58b feat(risk-score): enrich priority with EPSS + CISA KEV
Risk score now pulls from multiple threat intel sources instead of
only AI/CVSS data:

- EPSS (FIRST.org) — probability of exploitation in next 30 days
- CISA KEV — known actively exploited vulnerabilities (with ransomware flag)
- Existing Wazuh exploit flags as fallback

Adds DB columns (epss_score, epss_percentile, kev_listed, kev_*,
enrichment_sources, enrichment_updated_at), an enrichment_service
with cached KEV catalog (24h TTL in settings table) and batched
EPSS lookups, manual + bulk + KEV-refresh endpoints, automatic
enrichment after Wazuh sync, and a daily scheduler job to refresh
scores.

Frontend gets KEV badges, EPSS column with percentile, KEV-only +
EPSS-min filters, a "Refresh Threat Intel" button, and a priority
score breakdown card on the detail page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:37:53 +02:00
vulncheck 6969d0c62e Initial release v1.0.0
VulnCheck - Open Source Vulnerability Management for Wazuh

Features:
- Vulnerability management with Wazuh integration
- AI-powered CVE analysis (OpenAI, Anthropic, Google, DeepSeek, Ollama, Infomaniak)
- SLA policy enforcement with automated email alerts
- Automated patch verification via Wazuh Syscollector
- Role-based access control (Admin, Editor, Readonly)
- PDF/CSV reporting for compliance workflows
- Full audit trail

https://gitea.isuit.ch/vulncheck/vulncheck
2026-02-08 10:15:20 +01:00