Commit Graph
45 Commits
Author SHA1 Message Date
vulncheck 0963607b90 fix(nessus): extract installed + fixed version from per-host plugin output
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.
2026-05-26 14:22:40 +02:00
vulncheck 74eeb66aac feat(audit): revisionssicher status-change trail + UI history panel
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.
2026-05-26 08:57:11 +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 26df8d8491 fix(override): parse operator-prefixed version strings + multi-word product names
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.
2026-05-25 19:30:58 +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 40366a18fe fix(override): only treat exclusive upper bounds as fixed_version
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.
2026-05-25 18:57:17 +02:00
vulncheck dbaa98ef5f fix(override): extract fixed_version from vulnrichment + nvd + cvelistv5
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.
2026-05-22 08:35:05 +02:00
vulncheck 68572542ee fix: populate fixed_version + fqdn/short hostname asset dedup
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.
2026-05-22 08:23:11 +02:00
vulncheck a5c5361cea fix: wazuh sync scan-row + ssvc badges + compliance disconnected diag
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.
2026-05-20 15:13:07 +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 dd16697602 fix(override): cascade re-falls-through for cvss when stage 1 returns ssvc-only
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.
2026-05-20 10:06:31 +02:00
vulncheck 504c90fa45 chore(override): log nvd skip reason when missing > 100 cap
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.
2026-05-20 10:03:04 +02:00
vulncheck e48a977215 feat(override): cvelistV5 as 3rd-stage cvss fallback + nessus lock
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.
2026-05-20 09:56:17 +02:00
vulncheck 0b6a59377f fix(override): parse CVSS from cna container too, not just adp
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.
2026-05-20 09:49:34 +02:00
vulncheck a99e131326 feat(override): nvd cvss fallback when vulnrichment misses a cve
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.
2026-05-20 09:05:26 +02:00
vulncheck 444bcd0d1d fix(import): substring fallback for exotic cis_id headers
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)
2026-05-19 15:33:51 +02:00
vulncheck 9cda754276 fix(import): loose cis_id matching + diagnostic preview on 0-rows
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.
2026-05-19 15:25:43 +02:00
vulncheck 3b482b3560 feat(urs): avs + ass + urs calculation service
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.
2026-05-19 14:24:58 +02:00
vulncheck 62a14fcf52 feat(urs): csv import for compliance impact ratings
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.
2026-05-19 14:16:47 +02:00
vulncheck c652a12351 feat(compliance): service layer — refresh per asset / all + on-demand checks
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.
2026-05-19 08:15:30 +02:00
vulncheck e7942d15e1 feat(nessus): import operating system from host info (plugin 11936)
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.
2026-05-18 11:44:58 +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 5b18310cf8 feat(override): async job + status endpoint for vulnrichment correction
The synchronous /override/vulnrichment endpoint blocks the browser
request for the full duration of the correction — 23 minutes on the
tester's instance, well past every reasonable client timeout. Result:
'backend connection failed' modal and zero feedback about whether
the work actually ran.

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

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

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

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

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

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

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

New routing in load_cisa_vulnrichment_data:

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

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

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

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

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

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

Two-part fix:

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

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

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

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

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

Medium:
- Email template rendering HTML-escapes all dynamic values. The "rows"
  variable is whitelisted as pre-escaped HTML. Severity CSS class is
  whitelisted to prevent attribute breakout via crafted package data.
- Request logging redacts sensitive query parameters (token, password,
  code, mfa_token, ...). Validation-error handler no longer logs or
  returns the offending request body.
- /health returns only {"status":"healthy"} — environment and version
  no longer leak to unauthenticated callers.
- SETUP_ADMIN_TOKEN comparison uses hmac.compare_digest.
- Settings PUT denylists auth_provider_crypto_key (env-only) and
  refuses to store the "***set***" redaction placeholder back into
  protected configs.
2026-05-16 09:25:22 +02:00
vulncheckandClaude Opus 4.7 f70bffe6f6 fix(override): use github raw URL + correct vulnrichment 2.x parser shape
The previous load_cisa_vulnrichment_data() hit a non-existent feed
URL (cisa.gov/sites/default/files/feeds/vulnrichment.json) and parsed
a flat shape that does not match the real Vulnrichment 2.x format.
Result: the 'Fix CVSS' button silently corrected nothing, e.g.
CVE-2026-8390 stayed at the Wazuh placeholder 10.0 instead of the
authoritative 7.3 HIGH from CISA.

This change:

- Fetches per-CVE JSON from
  raw.githubusercontent.com/cisagov/vulnrichment/develop/{year}/{N}xxx/
  {CVE-YYYY-N}.json. 404 = CVE not yet analysed by CISA (very common
  for new CVEs) and is treated as 'no data', not an error.
- New _parse_vulnrichment_record() walks containers.adp[].metrics[] to
  extract cvssV3_1.baseScore / baseSeverity plus the SSVC Exploitation
  option (none|poc|active|widespread).
- correct_vulnerability_scores() now returns a not_found counter so the
  UI can distinguish 'feed unreachable' from 'CVE not in feed yet'.
- Vulns page alert appends 'N CVEs noch nicht im Feed' when not_found>0.

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

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

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

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

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

Adds scores_overridden counter to sync stats + log line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 08:00:48 +02:00
vulncheck 3074820737 feat: CVSS score override service with CISA Vulnrichment support
- Add VulnOverrideService to correct erroneous Wazuh CVSS scores (e.g. 10.0 placeholder)
- Add CISA Vulnrichment integration for verified CVSSv3.1 scores and SSVC exploitation status
- Add new API endpoints:
  - GET /override/check - find incorrect scores
  - POST /override/nessus/{asset_id} - override from Nessus
  - POST /override/vulnrichment - override from CISA (all CVEs)
  - GET /override/stats - score error statistics
- Add exploitation_status (SSVC: none/poc/active/widespread) and exploitation_source columns
- Add 'Correct CVSS' button in vulnerabilities frontend
- Add SSVC badge display in vulnerability table
- Update Vulnerability model and types

Fixes: CVE-2026-8390 incorrect CVSS 10.0 → 7.3 HIGH
2026-05-14 20:36:51 +02:00
vulncheck d903ca41cb fix(nessus): bug fixes from code review
- plugin_cvss() now uses _plugin_attrs() helper (eliminates duplicate
  inline path extraction — single place to update if Nessus changes payload)
- non_cve_skipped initialised to 0 in stats dict so the field is always
  present in the sync response even when every finding has a CVE
- vuln detail page: show title + description separately instead of
  title || description (description was silently hidden when title existed)
- vuln detail page: add "Detection Sources" card — source badges (WAZUH /
  NESSUS), cross-confirmed indicator, first_detected_by, Nessus plugin ID
  (links to tenable.com/plugins), and Tenable VPR score with colour coding
2026-05-14 17:38:05 +02:00
vulncheck 9ae16a3668 feat(nessus): import VPR, exploit_available, maturity, description, solution
Nessus plugin payloads carry more than just severity + CVE — we now
extract and store everything actionable:

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

Create path sets all fields directly; merge path backfills empties so
existing Nessus findings get enriched on the next sync. API response
adds nessus_vpr_score; frontend Vulnerability type mirrors it.
2026-05-14 17:24:08 +02:00
vulncheck 178f2bd28f feat(nessus): drop pseudo-CVE creation + cleanup endpoint
Per product decision, only CVE-tagged Nessus findings are imported.
EOL / Compliance / Cipher / informational plugins without a real CVE
are now skipped (counted in stats.non_cve_skipped).

Adds POST /api/v1/vulnerabilities/nessus/cleanup-pseudo-cves to remove
existing NESSUS-PLUGIN-* rows from earlier syncs.
2026-05-14 17:05:16 +02:00
vulncheck 515c9e3084 fix(nessus): populate package_name from plugin_name + sortable source column
- nessus_sync: new findings now store plugin_name as package_name so the
  Vulns table's PACKAGE column shows the affected software for Nessus-only
  rows (was blank). Merge path backfills package_name / title on existing
  rows that were created before this fix.
- vulnerabilities router: sort_by='source' maps to first_detected_by,
  so users can group rows by scanner from the table header.
- Vulns page: Source column header is now clickable with the same
  hover-style and SortArrow as the other sortable columns.
2026-05-14 16:37:21 +02:00
vulncheck 0d0b92d4ff fix(bugs): asset.assigned_group_id AttributeError + nessus IntegrityError rollback
- scheduler.py: asset.assigned_group_id does not exist (M2M removed it).
  Replace with asset.groups iteration so SLA breach mails reach group
  members without crashing.
- nessus_sync.py: db.rollback() on IntegrityError wiped the entire sync
  transaction. Switch to db.begin_nested() savepoint so only the
  duplicate insert is discarded, all other changes survive.
2026-05-14 08:24:59 +02:00
vulncheck 4683f203b3 feat(notifications): SLA-breach digest mode
Mirrors the new-vulnerability digest done in commit e12f111. Hourly
SLA-breach check now collects all overdue findings into per-recipient
buckets and sends one summary mail per person instead of one mail per
(vuln, recipient). Toggled by the existing notification_mode setting
(no separate switch — same dropdown in Settings).

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

Implementation:
- email_service.send_sla_breach_digest() + render_sla_digest_rows():
  styled red HTML table with severity counts, CVE/severity/host/
  detected/overdue columns, dashboard CTA. Custom template via setting
  email_template_sla_breach_digest.
- scheduler.check_sla_breaches() refactored into two phases:
    1) classify breaches, resolve recipients via the same cascade as
       new-vuln digests, bucket per email
    2) dispatch via send_sla_breach_digest (mode='digest') or fall back
       to per-vuln send_email (mode='single', legacy)
- Per-vuln 24h throttle preserved by writing one NotificationLog row
  per vuln per recipient in both modes — so an hourly tick can't repeat
  the same finding inside the same recipient's digest.
- Mode comes from email_service.get_notification_mode() (reused). No
  new env vars, no migration.
2026-05-13 23:24:45 +02:00
vulncheck 12eec232eb feat(nessus): API client, sync service, router, scheduler routing
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.
2026-05-13 23:07:12 +02:00
vulncheck e12f11134e feat(notifications): digest mode for new-vulnerability emails
Replaces the per-CVE inline email send during Wazuh sync with a batched
digest dispatcher that runs once at the end of the sync.

- Old behavior (now opt-in via notification_mode=single): one email per
  CVE per recipient. A 100-CVE sync with 3 assignees = 300 mails, often
  triggering SMTP rate limits (Gmail/Outlook/Proton ~20/min).
- New default (notification_mode=digest): one email per recipient with
  a styled HTML table listing every CVE relevant to them (severity
  counts at the top, sorted rows below, button to dashboard). 100 CVEs
  × 3 assignees collapse to 3 mails.

Implementation:
- email_service.dispatch_new_vuln_notifications(db, new_vulns):
  resolves recipients per vuln (vuln.assigned > group > asset.assigned
  > asset.groups), applies notification_min_severity, groups by email,
  and sends. Logs one NotificationLog per recipient.
- email_service.send_new_vulnerability_digest() renders new
  DEFAULT_DIGEST_TEMPLATE (responsive HTML, severity badges, table).
- email_service.get_notification_mode() reads notification_mode setting.
- Sync paths (run_wazuh_vulnerability_sync, sync_agent_vulnerabilities)
  now accumulate newly_created_vuln_ids and call the dispatcher once
  after the loop instead of mailing inline.
- Settings UI gains a 'Delivery Mode' dropdown next to the severity
  threshold.
2026-05-12 21:13:55 +02:00
vulncheck 0f24231066 fix(enrichment): EUVD uses paginated search API for full catalog
ENISA's bare /exploitedvulnerabilities endpoint returns a curated 4-entry
"recent" widget, not the full catalog. The actual full catalog lives at
/api/search?exploited=true (1500+ CVEs, paginated).

Also fix date parsing — ENISA returns 'Apr 29, 2026, 3:10:37 PM' style,
not ISO. Try multiple formats, fall back to raw string when unknown.
Prefer exploitedSince over datePublished as date_added when available.

Parser now correctly handles ENISA item shape (aliases as newline-separated
string, not array).
2026-05-11 19:55:46 +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 14356c9a07 feat(notifications): configurable severity threshold for new-vuln emails
Add a settings-page dropdown (Critical / High+ / Medium+ / Low+) that
controls which severities trigger a 'new vulnerability' email during
Wazuh sync. Stored in the existing settings KV table under
'notification_min_severity', default 'critical' (no behaviour change
on upgrade).

Backend:
- email_service.should_notify_for_severity(db, severity) reads the
  threshold from settings and compares using a severity rank.
- Both sync paths in vulnerabilities.py now delegate to the helper
  instead of the hard-coded 'critical only' check (the comment
  already lied about 'CRITICAL or HIGH').

Frontend:
- New control in the Notification Settings section, persists via the
  generic /api/v1/settings/{key} PUT endpoint.
2026-05-11 15:41:40 +02:00
vulncheckandClaude Opus 4.7 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