Commit Graph
163 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 8592ad2f9e feat(audit): CSV export endpoint + admin UI button
Tester asked for full-audit-log export as CSV for revisionssicher
quarterly/yearly compliance dumps.

Backend
- GET /api/v1/audit/logs/export streams CSV with same filter set as
  the JSON endpoint (user_id, resource_type, resource_id) plus
  optional `since` / `until` ISO timestamps for date-range exports.
- Admin-only by default; editors can export when they scope to a
  single resource (mirrors GET /logs auth rule).
- yield_per(500) keeps memory bounded for million-row dumps.
- Filename auto-stamped with timestamp.

Frontend
- "CSV" button in the audit-log page header. Triggers a plain
  anchor download — JWT travels via the existing access_token
  cookie so the streaming response works without a custom fetch.

Note: Next.js proxy currently buffers the response (proxyRequest
uses arrayBuffer). Fine for typical exports; a future tweak can
pipe-through if multi-million-row dumps become routine.
2026-05-26 14:18:35 +02:00
vulncheck 405357252d feat(audit): paginated audit-log UI + raise default page size
Tester reported audit-log webgui caps at 50 entries with no way to
load more.

- Backend default `limit` raised to 100 (cap 1000 via Query
  validator).
- Frontend audit-log page paginates via "Load next 100" button —
  appends, doesn't replace, until backend returns < 100 (= end).
- Loading state on the button so back-to-back clicks don't double-
  fetch.

Note for the reverse-proxy IP issue (172.18.0.2 instead of real
client IP): the openresty container's own IP must be listed in
`FORWARDED_ALLOW_IPS` for the ProxyHeadersMiddleware to honor the
X-Forwarded-For header. Add it to .env alongside the docker bridge
gateways and force-recreate backend. Documented in next deploy
guide update.
2026-05-26 13:21:43 +02:00
vulncheck 994043f2a3 fix(ui): source column fallback + add audit-log retention prune
Two tester reports:

1. Source column is empty in the vulns list view for rows that were
   auto-patched by the scanner stale-pass. Cause: when the Nessus /
   Wazuh sync no longer sees a CVE on a host, remove_source() empties
   the `sources` array — list view's badge loop renders nothing.
   Detail view still shows the scanner via `first_detected_by`.

   Fix: when sources[] is empty, fall back to first_detected_by and
   render it faded + italic so the operator can still tell which
   scanner originally reported the finding. Tooltip explains.

2. Audit-log retention was unbounded. Compliance frameworks
   (ISO 27001 / SOX / DSGVO Art.5) want 3-7 year retention.

   Add nightly prune job at 03:30 UTC keyed off setting
   `audit_log_retention_days` (default 1825 = ~5 years; 0 = keep
   forever). Slots between Vulnrichment (03:00) and URS (04:00).
2026-05-26 11:26:09 +02:00
vulncheck c446473b94 feat(ui): visual feedback on threat-intel refresh
Refresh button silently 200'd before — operator could not tell whether
the click did anything when upstream EPSS/KEV/EUVD values were
unchanged. Now:

- Button cycles Refresh → Refreshing… → ✓ Refreshed (2.5s) → Refresh
- 'Last refreshed' timestamp under the section header always reflects
  enrichment_updated_at, so a click visibly bumps the time even when
  the underlying scores stay identical.
2026-05-26 09:29:32 +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 e144ef6586 feat(vulns): status filter groups (active / closed) with SOC default
Tester asked whether the existing 'All Statuses' filter pulling in
FP / accepted / deferred rows was intended. It was, but the SOC
workflow always starts from 'show me what still needs action'.

Backend (GET /vulnerabilities ?status=...)
- 'active' (NEW) → open + pending_verification + patch_failed
- 'closed' (NEW) → patched + false_positive + accepted_risk + deferred
- 'all'         → no filter
- literal enum   → unchanged
- absent param   → defaults to 'active' (was 'open')

Frontend
- Dropdown is now optgroup'd: Groups / Active states / Closed states.
- Default state changed from 'open' to 'active' so the list shows
  the full actionable surface at first load.
- Send the literal value to the backend (including 'all') instead of
  treating 'all' as "omit param".
2026-05-25 19:46:09 +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 7f29492994 fix(auth): allow /mfa-setup page in AppShell session guard
AppShell does an /auth/me check on every mount; on 401 (no
session yet — exactly the forced-MFA flow) it router.push()'s
to /login. /mfa-setup got bounced for that reason — page
rendered for a frame then AppShell kicked the user back to
the login form.

Treat /mfa-setup like /login: render children without the
session gate.
2026-05-25 11:19:20 +02:00
vulncheck 3c2e51307c fix(auth): whitelist /auth/mfa/* and /mfa-setup page in 401 interceptor
axios 401 handler was bouncing the user to /login whenever the
forced-setup endpoint returned 401 (expected for invalid/expired
setup tokens). That made the /mfa-setup page silently redirect
back to /login the moment its useEffect fired — user saw the
login form again on /mfa-setup URL.
2026-05-25 11:13:47 +02:00
vulncheck 746cf131fa fix(auth): use promise chain instead of IIFE in mfa-setup useEffect
Next.js 16 webpack was tree-shaking the IIFE form away — the
/auth/mfa/forced-setup/start URL literal vanished from the
production bundle (verified via grep on .next/server output).
Plain promise chain stays in the bundle.
2026-05-25 11:08:13 +02:00
vulncheck a275a93adc fix(auth): correct lib/api import depth after page move 2026-05-25 11:03:23 +02:00
vulncheck a7d0519cc7 chore: gitignore .claude/ harness folder 2026-05-25 11:01:38 +02:00
vulncheck bb6b47deef fix(auth): move forced-mfa-setup page out of /auth/ to escape proxy catch-all
The /auth/[...path]/route.ts catch-all in the Next.js frontend
proxies *every* /auth/* request to the backend, including the
forced-mfa-setup page route. Backend has no GET endpoint for that
path → 404 → page never serves → user sees blank or bounces back
to /login.

Move the page to /mfa-setup (outside /auth/) and update redirects:
- login page.tsx redirect
- OIDC + SAML callback redirects
2026-05-25 11:01:24 +02:00
vulncheck 36b9b27b97 fix(auth): use bundled qrcode.react instead of external QR service
api.qrserver.com is a browser-side external dep — fails behind air-
gapped reverse proxies and content filters. qrcode.react is already
in package.json (used by Settings → MFA Card) so reuse it.

Drops one outbound HTTPS dependency, works in fully isolated deploys.
2026-05-25 10:41:05 +02:00
vulncheck e248886559 fix(auth): replace useSearchParams with window.location in forced-mfa-setup
Next.js 16 production hung the page on the Suspense fallback when
useSearchParams() was used at the top level — useEffect never fired,
no /auth/mfa/forced-setup/start request went out, page stayed blank.

Read the token straight from window.location.search inside useEffect
(client-only, no Suspense needed). The forced-mfa-setup page now
mounts immediately, posts the start request, and renders the QR.
2026-05-24 14:16:40 +02:00
vulncheck 9f0854965a fix(auth): wrap forced-mfa-setup in Suspense for Next.js 16
useSearchParams() in Next.js 16 must live inside a Suspense boundary
or the page silently fails on the client and loops back to /login —
which is exactly what tester hit: login returned mfa_setup_required
but the redirected page never rendered the QR form.
2026-05-24 14:01:54 +02:00
vulncheck 0c34321a00 docs: refresh ARCHITECTURE / DATABASE_SCHEMA / PROJECT_OVERVIEW
Previous docs dated Feb 1-2 — pre multi-provider auth, pre Nessus,
pre compliance/URS. Full rewrite covering dev-branch state at
migration 022:

- ARCHITECTURE.md: current stack table, scheduler job list, auth
  strategy diagram, OWASP mitigation matrix, deployment + perf notes.
- DATABASE_SCHEMA.md: all 20+ tables with columns, indexes, enums,
  FK CASCADE semantics, full migration 001-022 history.
- PROJECT_OVERVIEW.md: feature list (5 threat-intel sources, multi-
  scanner, multi-auth, compliance/URS), workflow examples, debugging
  commands, current limitations.

README.DEV.md still ahead of these — keeps the long-form feature
deep-dives.
2026-05-24 13:54:47 +02:00
vulncheck 4036350794 feat(auth): policy-driven MFA enforcement with forced enrolment
Settings → Security card lets admins require TOTP for selected roles
(admin/editor by default). Affected local users hit a dedicated
forced-enrolment page at next login with no session issued until they
scan the QR and submit a valid code. SSO/LDAP users exempt by default;
opt-in via mfa_enforce_sso_users=true redirects them to the same page
after the IdP callback.

Backend
- app/auth/mfa_policy.py: reads mfa_enforced / mfa_enforced_roles /
  mfa_enforce_sso_users from the settings KV. Fail-open on any DB
  hiccup to avoid lockouts.
- AuthResult gains mfa_setup_required (mutually exclusive with
  mfa_required). Orchestrator sets it in both credential and SSO paths.
- /auth/login short-circuits to a setup-token (10 min TTL) before any
  session cookies are issued.
- New endpoints POST /auth/mfa/forced-setup/{start,activate}.
  Activate issues a full session — the user has just proven a fresh
  TOTP code and authenticated with their password seconds earlier.
- OIDC + SAML callbacks redirect to /auth/forced-mfa-setup?token=...
  when the enforced-SSO toggle is on.

Frontend
- Login page handles mfa_setup_required by redirecting.
- New page /auth/forced-mfa-setup shows QR + secret + code form.
- Settings → Security card with three toggles (enforced / roles /
  enforce-sso). Saves to the existing settings KV — no new endpoint.

Three new settings keys, no schema migration. Existing deploys stay
unchanged until an admin flips mfa_enforced=true.
2026-05-24 13:54:34 +02:00
vulncheckandClaude Opus 4.7 3184433c00 fix(models): cascade delete asset → risk_snapshots + compliance
backref relationships on ComplianceResult, ComplianceCheck and
AssetRiskSnapshot missed cascade/passive_deletes, so SQLAlchemy issued
UPDATE asset_id=NULL on dependents before deleting the asset, tripping
the NOT NULL constraint on asset_risk_snapshots. DB-side ON DELETE
CASCADE was already in place — told the ORM to trust it.

Reported by tester: DELETE /api/v1/assets/32 returned 500
"null value in column asset_id of relation asset_risk_snapshots
violates not-null constraint".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:53:41 +02:00
vulncheck 495a69ed48 feat(ui): badge legend in priority column help popover
Tester asked what the POC / TI-TOTAL / AUTO / VPR / EXPLOITABLE /
PATCH AVAILABLE pills next to the Priority score mean. The help
popover only explained the score formula, not the per-row badges.

Priority column popover now appends a 'Badge legend' section with
each pill shown inline + a one-line description:

  EXPLOITABLE      Wazuh/Nessus saw a public exploit (bool)
  VPR <n>          Tenable VPR (0-10) from Nessus
  POC              SSVC Exploitation = poc
  ACTIVE           SSVC Exploitation = active
  WIDESPREAD       SSVC Exploitation = widespread
  TI-TOTAL         SSVC Technical Impact = total
  AUTO             SSVC Automatable = yes
  PATCH AVAILABLE  fixed_version known

Footer line spells out the "patch right now" combination
(TI-TOTAL + AUTO + ACTIVE/WIDESPREAD).

Popover widened from w-80 to w-96 to fit the legend without
wrapping the badge column. Only renders for column === 'priority',
other column popovers stay short.
2026-05-22 08:37:06 +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 f30bce470f fix(api): expose exploitation_status + exploitation_source in vuln response
Tester showed CVE-2024-29506 with 'Exploit (ssvc) +2.0' in the
Priority Breakdown (so exploitation_status='poc' in DB) yet no
POC pill on the vulns-list main page next to KEV/EUVD.

Root cause: VulnerabilityResponse Pydantic schema declared
exploitation_status and exploitation_source, but
_build_vuln_response dict never populated them. Pydantic silently
nulled both → API always returned exploitation_status=null → the
frontend pill guard `vuln.exploitation_status && != 'none'` was
always false → no badge rendered. The SSVC fields ssvc_technical_
impact + ssvc_automatable WERE in the response (added in 70840e0),
which is why TI-TOTAL and AUTO pills did render — but the simpler
POC/ACTIVE/WIDESPREAD pill from the original SSVC enrichment never
showed.

Two lines added to _build_vuln_response. Also adds exploitation_
source to the Pydantic schema so the frontend can show 'corrected
via vulnrichment' vs 'via nvd' / 'cvelistv5' tooltips down the
line.

Pure response-shape fix — no DB or migration. After deploy +
hard-reload tester sees POC pills next to KEV/EUVD on every CVE
that Vulnrichment classified as poc/active/widespread.
2026-05-22 08:26:23 +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 3d1e7b19c2 fix(sla): respect disabled policies + add master toggle
Tester disabled all Security Policies but SLA breach emails kept
arriving. Two reasons:

1. The hourly check looked up Policy by asset.policy_id but never
   inspected policy.status. PolicyStatus.DISABLED rows still
   contributed their SLA-day values, so 'disabled' was cosmetic.

2. Assets with no policy_id at all silently fell through to a
   hard-coded default_sla map — even when every policy in the DB
   was disabled by the operator, default_sla still triggered mails.

Fix:

- Policy lookup skips the vuln entirely (continue) when the
  policy's status is DISABLED — no SLA breach evaluation, no
  notification.
- New master toggle setting sla_breach_enabled (default behaviour
  unchanged — on). Setting value to 'false' makes the entire
  check_sla_breaches job a no-op, regardless of policies. Quick
  opt-out for operators who want to silence default_sla too.

Operator can set the toggle now via:
  docker compose exec postgres psql -U vulnmanager -d vulnmanager -c "
  INSERT INTO settings (key, value) VALUES ('sla_breach_enabled', 'false')
  ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;"

UI control for the toggle (Settings → Notifications) follows in a
later commit.
2026-05-21 21:37:35 +02:00
vulncheck e71341bcdd feat(dashboard): split recent vulns into Critical + Newly Published widgets
Colleague asked for more than 5 entries and proposed splitting the
Recent Vulnerabilities table into two focused widgets so 'what
changed risk recently' and 'what just got published' don't fight
for the same 5 rows.

Backend:
- sort_map gains 'updated_at' (Vulnerability.updated_at) so the
  Critical widget can sort by 'risk-relevant DB write timestamp'
  — picks up Vulnrichment corrections, KEV listings, manual status
  changes etc.

Frontend (page.tsx):
- new renderVulnWidget() helper renders a compact 6-column table
  (CVE / Sev / CVSS / PRIO / CPR / Flags) instead of the previous
  wide 7-column one. Used twice on the dashboard:
    'Recent Critical CVEs'  — CVSS ≥ 8 OR KEV OR EUVD, top 10,
                              sort_by=updated_at desc
    'Newly Published CVEs'  — top 10, sort_by=published_date desc
- Flags column surfaces KEV / EUVD / SSVC status (WIDESP/ACTIVE/POC)
  pills inline so the operator can see actionable indicators at a
  glance without opening the detail page.
- Side-by-side on desktop (lg:grid-cols-2), stacked on mobile.

Both widgets dedup by cve_id client-side and pull a wider window
(80 entries) so 10 distinct CVEs land even when a hot CVE hits many
assets. Click on a row jumps to the filtered Vulns list.

Increased from 5 → 10 entries per widget (configurable via the loop
guard if 15 is preferred — set both to 15 and the limit=80 fetch
still covers worst case).
2026-05-21 21:36:26 +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 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 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 d550027117 docs(readme): document 3-stage cvss correction cascade (plan f)
Adds CVEProject/cvelistV5 + NVD REST API to the threat-intel sources
table and rewrites the 'Manual overrides' section as a labelled
cascade diagram so the operator can see the resolution order at a
glance:

  Stage 1 — CISA Vulnrichment (ZIP or per-CVE raw, CNA+ADP parsed)
  Stage 2 — NVD REST API (≤100 missing, rate-limit-bounded)
  Stage 3 — cvelistV5 ZIP (557 MB, disk-cached 12h, ~250k CVEs)

Also notes the CNA-container parser fix (commit 0b6a593) — earlier
the parser only walked containers.adp[] and missed CVSS supplied by
vendor CNAs like Microsoft. Explains that all three sources are
treated as authoritative — Nessus sync's exploitation_source lock
extended to recognise vulnrichment / nvd / cvelistv5 alike so the
corrected score isn't clobbered on the next plugin sync.

Async-job endpoint /override/vulnrichment/start added to the
endpoint list for parity with the existing UI button flow.
2026-05-20 09:58:25 +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 f6ee3ff2fa fix(wazuh): paginate indexer query past 5000-hit cap
Tester saw the log:
  Agent 083: Indexer returned 5000 hits (total: 10000)
  Agent 083: Has 10000 vulnerabilities but limit is 5000.
              Some vulnerabilities may be missing!

5000 of 10000 vulns silently dropped on agents with very large
finding sets. Affected every Plan B+ sync of a long-history agent.

query_vulnerabilities_from_indexer now paginates in PAGE_SIZE=5000
chunks via OpenSearch from+size until total_hits is drained or
MAX_TOTAL=50000 safety cap is reached. track_total_hits=true added
so total_hits is accurate beyond the default 10k cutoff. Per-page
log line shows accumulated count, so the operator can verify all
hits land.

50k cap is generous — Wazuh agents rarely exceed 10-15k findings;
the few that do (long-uptime servers with many packages) still cap
well below memory pressure. Beyond 50k would need scroll/search_after
API which adds complexity for a vanishingly small population.
2026-05-20 09:04:06 +02:00
vulncheck 346204ee0e fix(dashboard): dedup recent vulnerabilities by cve-id
Tester reported the Recent Vulnerabilities table showed the same CVE
multiple times when the CVE hit multiple assets (e.g. one Microsoft
Edge CVE on 4 hosts filled 4 out of 5 rows). Operator only sees 2
distinct vulnerabilities instead of 5.

Fetch widened from limit=5 to limit=50; client-side dedup keeps the
first occurrence per cve_id (already the most-recent thanks to
sort_by=published_date desc) and stops at 5 distinct CVEs. Each row
still links through to /vulnerabilities?cve_id=... where every
affected host is visible.

Pure frontend fix — no API change.
2026-05-20 09:02:40 +02:00
vulncheck 32a30d80dc fix(scheduler): correct next_run for CRON nessus schedules
Tester saw next_run column show '+24h' for a cron '* * * * *' nessus
schedule even though APScheduler was correctly firing every minute.
Cosmetic-only, but confusing — DB looked broken while the job was
actually working.

Root cause: the Nessus branch of execute_scheduled_scan computed
next_run via INTERVAL_MAP.get(schedule.interval, {'days': 1}). For
ScheduleInterval.CRON the lookup misses → fallback +24h. Wrong for
every cron expression that fires sooner than daily.

Fix: ask APScheduler directly for the trigger's next_run_time via
scheduler.get_job(...).next_run_time. Works for both interval and
cron triggers because every trigger type implements get_next_fire_time.
Falls back to the old map-lookup if the job lookup fails (defensive,
should never happen for an actively-firing schedule).

Wazuh branch will keep using the old logic — its INTERVAL_MAP
fallback was always correct because Wazuh schedules don't use CRON.
2026-05-20 08:56:06 +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 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 78b7cd8f54 docs(readme): plan d + e — compliance/sca + unified risk score
Documents the two latest feature drops on the dev branch.

Header summary expanded from "2 feature groups" to 5: threat-intel
enrichment (now includes Vulnrichment + VPR), auth, Nessus, SCA
compliance, URS scoring.

Priority Score section updated:
- exploit_bonus now max() across 5 sources (KEV/EUVD, EPSS, Wazuh,
  SSVC exploitation_status, Nessus exploit_code_maturity)
- new ssvc_addon (technical_impact total +2, automatable yes +1)
  stacks on top because it describes different dimensions
- CPR formula corrected to the percentile-weighted JacquesKruger
  variant — old multiplicative formula collapsed to ~0 for sub-1%
  EPSS CVEs and was useless for triage

New large 'Compliance + Unified Risk Score (URS)' section covers:
- architecture diagram
- schema delta (alembic 013-017)
- exact formulas with severity bands
- endpoint table (with :int converter note)
- CSV import header heuristics + impact rescaling rules
- asset criticality + framework fields
- nightly scheduler order (02:00 SCA → 03:00 vulnrichment → 04:00 URS)
- UI walkthrough
- verification bash
- explicit out-of-scope list (per-framework URS, concentration
  penalty, risk-acceptance per check, framework mapping persistence,
  time-decay weight)
2026-05-19 15:30:15 +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 336e8170e4 fix(compliance): require int path converter on asset_id routes
Route order in routers/compliance.py declared the catch-all
GET /{asset_id} BEFORE the more specific GET /urs and
GET /impacts/stats endpoints. Starlette/FastAPI matches the first
declared route, so requests to /urs and /impacts/stats hit the
catch-all with asset_id='urs' or 'impacts' and immediately 422 with
'path.asset_id: Input should be a valid integer'.

Fix: add the Starlette int converter to the catch-all paths:

  GET  /{asset_id:int}
  GET  /{asset_id:int}/{policy_id}/checks
  POST /{asset_id:int}/refresh
  GET  /urs/{asset_id:int}

Now /urs and /impacts/stats fall through to their dedicated handlers
because the int converter rejects non-numeric segments at match time.
No route reordering needed and existing integer paths keep working.

This was the root cause of the React #31 the tester reported — the
backend was returning ValidationError JSON arrays for every /urs and
/impacts request, which the frontend tried to render as React
children. Error-formatter from the previous commit catches it
gracefully now, but the backend should never have been emitting
those in the first place.
2026-05-19 15:22:26 +02:00
vulncheck 2690a4c39b fix(urs): pydantic-v2 pattern= + render-safe api error formatter
Two-pronged fix for the 'React error #31 — object with keys
{type,loc,msg,input}' crash the tester reported on /compliance.

1) backend — FastAPI/Pydantic v2 dropped Query(regex=) in favour of
   Query(pattern=). The old kwarg silently logs a deprecation but
   then ignores the constraint; in our environment it caused a 422
   validation failure on the avs_mode parameter, returning the
   standard ValidationError shape (the array of {type,loc,msg,input}
   dicts). Renamed regex= → pattern= on all three URS endpoints.

2) frontend — added formatApiError() helper that flattens any
   FastAPI 422 detail array into a readable string before passing
   to setErr. Now wraps every error path on /compliance:
   - fetchAll
   - refreshAll / refreshAsset
   - uploadCsvs
   - recomputeURS
   With this in place even a future ValidationError surface is shown
   as text instead of crashing React.

Deploy after pull:
  docker compose build backend frontend && docker compose up -d
2026-05-19 15:13:49 +02:00