Commit Graph
183 Commits
Author SHA1 Message Date
vulncheckandClaude Opus 5 45248001ec fix(ui): report what the Intune sync did, same as the app scan
The backend log carried OS-EOL and mobile-EOL findings, reactivations, and
Defender's resolved count; the UI showed none of them. A sync that closed 241
Defender CVEs and surfaced 44 mobile EOL issues read as '144 devices, 144
matched, 0 created, 380 app findings' — nothing about the work it had done.

Non-zero counts are appended now, zeros stay hidden so a quiet sync stays
short. Defender gets its own group: new, resolved, unmatched devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:00:50 +02:00
vulncheckandClaude Opus 5 119e64fc05 fix(ui): report what the scan did, not only what it found
The button said "160 assets, 0 findings (0 new)." for a run that had just
auto-resolved 345 stale findings and pruned hundreds of package rows. That
reads as "nothing happened" — indistinguishable from a scan that genuinely
did nothing, and it is exactly the run an operator wants confirmation of after
patching an estate.

The numbers were in the backend log the whole time; the button simply never
showed them. Now it appends whatever is non-zero: auto-resolved findings,
pruned package rows, MSRC findings. A quiet run stays quiet — nothing is
appended when all three are zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:42:21 +02:00
vulncheckandClaude Opus 5 ad85ccd98d fix(ui): wrap long package names instead of clipping them
'Microsoft Exchange Server Subscription Edition' lost its distinguishing half
to an ellipsis — and that half is exactly what separates it from plain
'Microsoft Exchange Server', which sits on the very same host with a
different build. The name now wraps, and carries a title attribute for the
hover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:26:32 +02:00
vulncheckandClaude Opus 5 3f6a7dc3e5 fix(scoring): score CVEs the vendor never gave a CVSS
Both scores multiply the CVSS in, so a CVE without one came out at zero:
priority 0, CPR "—". A Critical Chrome CVE therefore sorted below a medium
that happened to carry a score, in every priority-ordered list and in the
digest mail. Google states "Chromium security severity: Critical" in prose and
NVD frequently never scores those records, so this is not a rare corner.

The severity band's FLOOR now stands in when no score exists — critical 9.0,
high 7.0, medium 4.0, low 0.1. The floor, not the middle: the estimate can
only ever understate a real score, never inflate one. An unscored critical
lands at CPR 74 against 78.8 for a real 9.8, and above a scored medium, which
is the ordering that was wrong. A real CVSS always wins over the estimate.

Also silences the Settings page for read-only users: every
/api/v1/settings/<key> is admin-only and the page requests a dozen on open,
one 403 each. Same central gate as /auth/users.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:59:07 +02:00
vulncheckandClaude Opus 5 4879610dea fix(ui): stop asking for data a read-only user may not have
Opening Assets or Vulnerabilities as a read-only user fired /auth/users on
every page load, which the backend correctly refused — a steady stream of
403s in the log for a call whose result that role can never use. The
assignment dropdowns it feeds are already hidden for readonly.

Gated centrally in the API client rather than by rearranging three pages:
the role is picked up from whichever /auth/me response passes through the
response interceptor — AppShell issues one on startup — and a request
interceptor drops the call before it is sent. No page changes, no extra
round-trip, and until the first /auth/me lands the role is unknown and
everything behaves exactly as before.

The rejection carries no HTTP response, so it cannot trip the 401
refresh-retry path, and all four callers already fall back to an empty list —
which is the correct answer for this role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:06:11 +02:00
vulncheckandClaude Opus 5 099bce723e fix(exposure): count a service once, not once per socket
Reading ESTABLISHED sockets as evidence (943569f) exposed a dedup key that
was too narrow: it included the protocol, which was harmless while only
listeners counted, but a busy host has many established sockets on the same
service port, and tcp vs tcp6 already made a single listener look like two
services.

The tester's domain controller listed "RDP :3389" four times and LDAP four
times. Since each additional entry adds 40% of its weight, the exposure score
inflated to 100 for what is one RDP and one LDAP service. Deduplicating by
port alone fixes both: one service, one entry, one weight.

Also feeds Recent Critical from four narrow server-side queries (critical,
high, KEV, EUVD) instead of filtering the 300 newest rows in the browser. A
batch of low-severity CVEs — Chrome publishes dozens at once — fills that
window completely and empties the widget, which no amount of extra depth
fixes; only filtering before the limit does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:49:42 +02:00
vulncheckandClaude Opus 5 77317a5435 fix(ai): show what the model actually replied when parsing fails
Running an AI Audit that produced no recommendations looked exactly like
never having run one — "No AI recommendations available yet. Try running an
AI Audit above." — which is the opposite of what happened and leaves nothing
to act on.

The backend already handles a model that ignores the JSON schema: it puts the
raw reply in global_strategy and returns an empty list. The empty-list branch
ran first and swallowed it. That reply is now shown, labelled as what it is,
with the hint that weaker models fail this way.

The error hint was stale too. It told everyone to check Infomaniak settings
and set the model to 'llama3' — wrong advice on DeepSeek, OpenAI or
Anthropic, pointing at a setting that was already correct. It now points at
the AI Integration screen and at verifying the model still exists.

Also dropped the window.location.reload() that sat on the empty state: it
threw away the result the user had just waited for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:19:09 +02:00
vulncheckandClaude Opus 5 8257110764 feat(ai): load the model list from the provider instead of hardcoding it
The model dropdown was a hand-written list per provider, so it was wrong the
day after every release: DeepSeek showed V3 while V3.2 was current, Anthropic
still seeded claude-3-5-sonnet-20240620, Gemini seeded 1.5-pro. Worse, picking
a model the provider had since retired failed only later, at generation time,
with an error that pointed nowhere near this screen.

A "Load models" button next to the dropdown now asks the provider what it
serves right now — OpenAI, DeepSeek, OpenRouter, Groq, Mistral (all
OpenAI-shaped /models), Anthropic, Gemini, and Ollama's local /api/tags. The
live list replaces the static options once loaded; the static ones remain as
the fallback for providers with no listing endpoint and for before the first
fetch, and the per-provider defaults are now clearly seeds, not choices.

The key is sent in the request body, never a query string — URLs end up in
proxy and access logs. An empty key falls back to the saved one so the list
loads without retyping it, and provider errors are surfaced verbatim (401
reads as "rejected the API key", not a generic failure). Switching providers
clears the list so one provider's models are never offered under another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:49:22 +02:00
vulncheckandClaude Opus 5 eb595b9e7b fix(dashboard): let Recent Critical see CVEs that carry no CVSS
The widget judged criticality on the score alone, so a CVE without a CVSS
could never qualify no matter how severe. Chrome CVEs are exactly that case —
Google ships no metrics block and states "Chromium security severity:
Critical" in prose, and NVD frequently never scores them at all. The tester's
dashboard read "No data" under Recent Critical while a batch of fresh Chrome
CVEs sat right next to it in Newly Published.

Where a CVSS exists it still decides, and severity is kept in sync with it.
Only when there is no score does the vendor's own severity get a say. Reading
the severity out of Chrome's prose landed in 6b3744e; this is the half that
makes it visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:35:21 +02:00
vulncheckandClaude Opus 5 eca15f09be chore(ui): tagline reads Vulnerability Intelligence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:33:42 +02:00
vulncheckandClaude Opus 5 b5ade33835 chore(ui): name the product what it does in the sidebar
'Enterprise Security' says nothing; 'Vulnerability Management' says what the
tool is. The full 'Enterprise Vulnerability Management' overflows the 288px
sidebar at this tracking, so the tagline drops the marketing word rather than
wrapping to two lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:33:02 +02:00
vulncheckandClaude Opus 5 b0254bb497 fix(scan): run the app CVE scan as a background job
Same failure the EOL check had before dabec89: the scan ran inside the
request, so a full estate — plus the cvelistV5 and MSRC index builds on first
use — outlived the browser's HTTP patience. The GUI showed "Backend
connection failed" while the backend ran happily to completion, with no way
to tell whether anything had happened.

POST /app-cve-scan/start spawns the run on its own DB session and returns 202;
GET /app-cve-scan/status reports running/result/error, and the button polls it.
Starting a second run while one is active attaches to the running job instead
of queueing a duplicate. The synchronous POST /app-cve-scan stays for API
clients.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:06:34 +02:00
vulncheckandClaude Opus 5 786071386f fix(ui): show detection provenance on every affected package
The "via …" line under an affected package was missing or wrong in three
constellations, so a finding's provenance could not be read off the detail
page:

- A package confirmed by two scanners kept only the one that wrote first —
  record_affected_package never merged `source` on an existing row.
- Findings that only carry the parent summary (OS-level rows, pseudo-CVEs,
  data written before per-package tracking) fell into a fallback block that
  renders no source at all.
- Rows with a NULL source rendered nothing instead of falling back to the
  finding's own sources.

Package sources are now cumulative ("app-scan,msrc", widened to VARCHAR(60)
in migration 039), the API synthesises a package entry from the parent
summary when no child rows exist, and the UI renders every source as a chip
— never blank.

Also makes HOST / PACKAGE / ASSIGNED TO sortable; the assignee sorts by
username, not by id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:21:07 +02:00
vulncheck dabec89021 feat(eol): run the EOL check as a background job with live progress
The EOL check ran synchronously, so on a full estate it outlived the
browser's HTTP patience — the tester saw 'Backend connection failed'
while the backend was still happily working, with no way to tell whether
anything had happened.

Now POST /eol-check/start spawns the run on its own DB session and returns
202 immediately; GET /eol-check/status reports running/stage/done/total
plus the final stats or the error. The per-asset loop updates the progress
counters, so the button shows 'N/M assets — hostname' while it works and
the final summary (including how many stale MS-lifecycle findings were
closed) when it finishes. Starting a second run while one is active
attaches to the running job instead of queueing a duplicate.

The synchronous POST /eol-check stays for API clients and small installs.
2026-07-27 12:28:58 +02:00
vulncheck da325bca77 fix(eol): close stale MS-lifecycle FPs from any path; stop the export refetch storm
1) The Edge→'Azure Stack Edge' false positive survived every nightly run.
   c3cd62c added a reconcile, but it lives in run_eol_for_packages while
   the EOL-check button has its own loop and never called it — so a
   finding produced by a since-corrected name match stayed open forever.
   New revalidate_ms_lifecycle_findings() re-asks the resolver for every
   OPEN EOL-MS-LIFECYCLE finding and closes the ones that no longer
   match, independent of which path ran. Called at the end of the EOL
   check; audited with the host name.

2) The endless GET .../lifecycle/products/export/ run: the in-process
   memo was only set on SUCCESS, so once the fetch or the xlsx-link
   lookup failed, every one of the thousands of scanned packages
   re-fetched the page — which is also why the UI eventually gave up with
   'Backend connection failed' while the backend kept going. Failures are
   now memoised too (negative cache), so a broken source costs one
   request per hour instead of one per package.

3) EOL dialog text was stale: it named only endoflife.date and said
   nothing about the MS-lifecycle export, the built-in exotics, the OS
   check, auto-closing, or that Intune-only devices are covered elsewhere.

4) CVSS-correction dialog: one numbered source per line, as requested.
2026-07-27 12:10:31 +02:00
vulncheck 767c109816 fix(scan): per-package record must never abort the scan; refresh scan dialog text
1) The per-package helper added in 24eebd3 crashed the whole App CVE scan
   with a 502: a plain query does not see rows added earlier in the SAME
   unflushed transaction, and one run legitimately records the same
   (finding, product) twice — the tester's log shows vulnerability 68148
   getting 'Microsoft Edge' twice in one batch — so the second INSERT hit
   uq_vulnpkg_vuln_package and the IntegrityError took the request down.

   Now: also scan db.new for a pending row (update it instead of adding a
   second), wrap the insert in a savepoint so a lost race rolls back only
   that statement, and swallow any remaining error — this is display data
   and must never break a scan.

2) The App-CVE-scan confirm dialog still described only OSV / NVD-CPE.
   It now names what actually runs (OSV/NVD, cvelistV5 incl. Windows OS
   builds, MSRC fixed-builds incl. Edge, GitHub repo advisories, M365,
   endoflife.date) and answers the tester's question explicitly: every
   asset with Wazuh OR Intune inventory is scanned, not just Wazuh ones.
2026-07-26 12:14:08 +02:00
vulncheck 7851b31af7 fix(dashboard): Recent Critical widget starved to 2 rows — distinct_cve + deeper feed
Published-date-desc feeder returned the newest 300 per-asset rows, mostly
low/medium; after the CVSS≥8/KEV/EUVD filter only 2 criticals survived.
Same starving Newly Published had — same cure: distinct_cve=true collapses
per-asset duplicates server-side and the deeper window (300) reliably
fills the 10 slots.
2026-07-24 15:35:31 +02:00
vulncheck 1476318624 fix(wazuh): refresh asset OS build in the vuln sync; recency for Recent Critical widget
1) Windows-OS CVEs stuck open: only the separate ASSET sync refreshed
   asset.operating_system/os_version; the Wazuh VULN sync (the flow that
   actually runs regularly) never did. So scan_asset_os kept judging OS
   CVEs against a stale build — tester: Server 2016 host already on
   .9339 (the fix build per Wazuh dashboard) while findings showed
   installed .9140 and stayed open. The vuln sync now refreshes OS
   name/build from the agent record; the existing stale-reconcile then
   closes the findings on the next app scan.

2) Recent Critical CVEs widget pinned the same old 2021 KEV heavyweights
   (priority-sorted, recency ignored). Feeder now sorts by CVE published
   date desc (filter unchanged: CVSS ≥ 8 or KEV or EUVD), same-day ties
   broken by CPR desc. Subtitle + View-All link match.
2026-07-24 14:32:44 +02:00
vulncheck f7f627ee5c fix(ui): dark-theme remaps for opacity/arbitrary-value classes
Tester (dark theme): dashboard widget headers ('Recent Critical CVEs' …)
and the sticky vulnerabilities list header stayed light-gray under light
text. Cause: opacity and arbitrary-value utilities are their OWN class
names — bg-gray-50/50, bg-[#F3F4F6]/95, bg-white/50 — which the plain
.bg-gray-50/.bg-white remaps never touch.

Add escaped-selector remaps for those, kill the pastel indigo gradient on
the AI widgets (background-image none + dark surface) with matching
indigo text/border/badge tints, bg-base-200, and the group-hover blue
tint on the sticky actions column. Dark-theme autofill now carries
!important + caret-color so it actually beats the light-scheme autofill
rule (which is !important). Verified via computed styles in the browser:
all previously-light classes resolve to dark surfaces in mid/dark.
2026-07-24 12:02:33 +02:00
vulncheck 3af68fcddc fix(ui): put the theme switcher where it's actually visible — the sidebar
The previous commit added the theme button to shell/Header.tsx, which is
imported NOWHERE (dead component — the app shell renders its own top bar,
desktop has none at all), so the tester couldn't find any button.

New ThemeSwitcher: segmented Light / Mid / Dark control at the bottom of
the sidebar (desktop sidebar + mobile drawer, above the user box) —
always visible on every page. Dead Header.tsx deleted so the next change
doesn't land there again.
2026-07-24 11:17:55 +02:00
vulncheck 5233a65103 feat(ui): three-way theme — light / mid (soft dark) / dark (full dark)
The old theme toggle only flipped daisyui variables, which the hardcoded
light utility classes (bg-white, text-gray-900, …) never react to — so
it visibly did nothing. Instead of rewriting every page with dark:
variants, add an UN-layered override block in globals.css that remaps
the light-surface utilities (plus tinted badges, borders, inputs,
scrollbars) under html[data-theme='mid'|'dark'] via CSS variables —
un-layered author CSS beats Tailwind's @layer utilities without
!important. Sidebar (bg-gray-800/900) and brand colors intentionally
untouched.

useTheme now cycles light → mid → dark (legacy 'corporate'/'dark'
values migrate), the Header button shows the active theme (sun / moon /
solid moon + label), and an inline pre-paint script in layout.tsx
applies the stored theme before hydration so dark users get no white
flash. Verified all three themes render correctly in the browser.
2026-07-24 11:05:09 +02:00
vulncheck 86159f44b1 feat(audit): search, sortable columns, real pagination + page size
The audit log only did fixed 100-row 'load more' with no total, search or
sort. Backend GET /audit/logs now accepts search (free-text over
description, event type, resource, IP, and username via outerjoin), sort
+ order over a whitelisted column set, and sets X-Total-Count (already
CORS-exposed) so the UI can page. Frontend rewritten to match the CVE
view: debounced search box, clickable sort headers, rows-per-page
selector (25/50/100/250), and First/Prev/Next/Last with 'X-Y of N'.
2026-07-24 10:27:43 +02:00
vulncheck 6f9f2ae10b fix(mfa,ms-lifecycle,vendor): 500 on expired MFA, export refetch storm, Intune vendor
- MFA verify: decode_token returns None on an expired 5-min challenge
  (it doesn't raise), so the exception-only guard let None.get() throw →
  500 'Internal Server Error'. Guard None → clean 401 'MFA session
  expired'. Login page routes that message back to the credentials step
  instead of stranding the user on a dead MFA form.
- MS lifecycle: the 24h DB cache still let every synced device re-download
  the export before the first commit landed (tester saw dozens of
  identical GET .../lifecycle/products/export/). Add an in-process memo so
  one sync fetches at most once.
- Intune live software inventory (/assets/{id}/software) hardcoded
  vendor='' — now uses detectedApps.publisher, so the Vendor column fills
  like Wazuh's.
2026-07-24 10:08:15 +02:00
vulncheck 56c0335839 fix(auth): silent token refresh so active sessions aren't logged out
The 30-min access token had no client-side renewal: on any 401 the SPA
redirected straight to /login, so an actively-used session was bounced
every 30 minutes even though a valid 7-day refresh cookie existed.

- /auth/refresh now falls back to the HttpOnly refresh_token COOKIE when
  no body is sent — the browser can't read that cookie to put it in the
  body, so the cookie path is what makes SPA silent refresh possible.
- axios interceptor: on 401 (non-auth endpoints) try one /auth/refresh
  then replay the original request; redirect only when refresh itself
  fails (refresh expired/revoked = genuinely inactive). Single-flight
  guard shares one refresh across concurrent 401s, since the backend
  rotates (revokes) the refresh token on use.

Net: active users stay logged in up to the 7-day refresh window (all
roles); only real inactivity logs out.
2026-07-23 22:29:48 +02:00
vulncheck 32c064e3f2 fix(cvss-cascade): CVE-only input, wire NVD key, key-aware cap, honest UI
- Only real CVE-IDs enter the correction cascade now (was: everything
  except NESSUS-). Drops GHSA-only/pseudo ids that can never resolve —
  spares wasted lookups and shrinks the missing set that gates NVD.
- _load_via_nvd now uses the NVD_API_KEY env (apiKey header, same as the
  enrichment service) and throttles ~45/1s instead of 5/1s with a key.
- Stage-2 NVD cap scales with the key: 1500 (key) vs 100 (unauth), so a
  key actually gets used instead of always falling through to cvelistV5.
- Correct-CVSS button label/confirm/tooltip now describe the real
  cascade (Vulnrichment → NVD → cvelistV5 → GHSA, each filling only what
  the previous left empty, CVE-IDs only) instead of 'CISA Vulnrichment'.
2026-07-23 22:29:36 +02:00
vulncheck 97562abed0 feat(vendor): capture + show software vendor from Intune/Defender/Wazuh
Answers 'is vendor readable via the Intune/Defender API?' — yes:
- Intune detectedApps.publisher was being dropped in _map_apps; now kept
  as the package vendor.
- Defender softwareVendor was folded into the package label; now stored
  separately.
- Wazuh syscollector already carries vendor; now threaded through.

New vulnerabilities.package_vendor column (migration 038, idempotent),
populated at the package-finding chokepoints (app_cve_scanner._upsert +
cvelistv5 scan_asset via pkg vendor, defender _upsert_cve) and shown in
the Affected Package card on the CVE detail page.

Scope: single-package findings. Per-package (vulnerability_packages) and
Nessus/m365 vendor left as follow-up — Nessus rolls vendor into the
plugin name and m365/OS vendor is implicit (Microsoft).
2026-07-23 14:06:05 +02:00
vulncheck cab46fd819 feat(enrich): add GitHub Advisories (GHSA) as CVSS/severity fallback
Stage 4 of the score cascade (vulnrichment → NVD → cvelistV5 → GHSA).
GHSA carries advisories for CVEs still missing from NVD and cvelistV5
when very fresh — the gap the tester hit on new Firefox/Notepad++ CVEs.
Fills CVSS/severity/description/references only; GHSA 'unreviewed'
advisories carry no affected-version range (verified against the live
API), so no fix/detection data is derived — this is enrichment, not
new-CVE detection.

Optional github_pat setting (encrypted at rest, admin-only Settings
card) lifts the GitHub rate limit 60 → 5000 req/h; the loop stops
cleanly when the limit is hit.
2026-07-23 13:50:32 +02:00
vulncheck 64204f41b7 feat(advisories): Security Advisory Feeds page (ZDI/CERT-EU/BSI/Cisco + custom)
New early-warning surface the tester asked for: the newest 7-Zip advisory was
on ZDI before NVD or cvelistV5 had it. A dedicated Advisories page now shows
the CISA-KEV latest additions plus configurable RSS/Atom sources.

- advisory_feed_service: fetch + parse (RSS and Atom), cached in a setting so
  the page serves instantly; per-feed errors recorded, never fail the run.
  Default feeds verified LIVE before shipping (ZDI published/upcoming, CERT-EU,
  BSI/CERT-Bund WID, Cisco PSIRT); Cisco emits junk after the XML root, so a
  lenient per-item fallback parses it anyway. Security: any DOCTYPE/ENTITY
  declaration is refused outright (XXE / billion-laughs) — feeds never need
  DTDs, no defusedxml dependency required.
- GET /api/v1/advisories/feeds (cache; first call fetches),
  POST /feeds/refresh (editor). Config = advisory_feeds_config setting,
  editable from the page (admin): enable/disable, rename, custom URLs.
- Scheduler refresh every 6h; 'Advisories' nav item for all roles (read-only
  surface; Refresh for editor+, Configure admin-only).

Parser self-checked against rss/atom/cisco-junk/DTD-refusal fixtures.
2026-07-21 15:03:44 +02:00
vulncheck 8fd09d1f81 fix(msrc): host-aware KB pick on shared build lines + refresh status polling
1. Wrong KB suggested: a Win11 24H2 host at 10.0.26100.8655 was shown KB5099536
   build 10.0.26100.33158 — the Windows Server 2025 update. The remediation
   display collapsed each build branch to its NEWEST revision before knowing the
   host, but one branch can carry several revision sequences (26100 = 24H2 at
   ~8xxx AND Server 2025 at ~33xxx), so the Server KB always won. Now all
   candidates per branch are kept and, once the host is known, the pick per
   update-type is the SMALLEST fix revision still above the installed one —
   the host's own servicing sequence (cumulative updates make smallest-above
   the fixing one). Host past everything -> newest as reference.
   Verified: 26100.8655 -> KB5087539 (.8875); 26100.32690 -> KB5099536
   (.33158, .8875 correctly skipped as below installed); 26200 line untouched.

2. MSRC Enrich button was stuck on 'Started in background' forever. Track the
   refresh state module-side, expose GET /msrc/refresh/status, and let the
   button poll every 4s until it can show the real outcome (stats or error) —
   same pattern as the Intune sync status.
2026-07-21 14:14:32 +02:00
vulncheck 56454dff2a feat(notifications): aggregate the digest to one row per CVE
A CVE on 200 hosts was 200 rows and the counters counted each occurrence
(tester saw 'Neue Schwachstellen 11848' with 'Kritisch 467, Hoch 11381' —
per-(CVE,asset), not distinct). Collapse the digest to one row per CVE:

- {{rows}} is now one line per CVE, sorted by CPR desc, columns CVE | Severity |
  CVSS | CPR | #Systems | Package. The CVE links to ?cve_id= (no asset_id), i.e.
  the vulnerability view listing every affected asset; #Systems says how many.
- Severity counts and {{total}} are per distinct CVE, so 'Neue Schwachstellen'
  and the tiles no longer multiply by host count. {{affected_assets_count}}
  stays distinct assets across the digest.
- default digest template + preview sample + settings hint updated (Host column
  dropped — meaningless once aggregated). Notification-history subject/body show
  distinct-CVE count.

Verified on the tester's shape: 47× CVE-2026-50387 + 2× another -> 2 rows,
counters {critical:1, high:1}, #Systems 47.
2026-07-16 09:58:35 +02:00
vulncheck e19c8d9da7 fix(app-scan): stop cross-release Windows false positives
Regression from 5703dfa. A Server 2016 host (10.0.14393.9234) was flagged with
CVE-2021-26432 'fixed in 10.0.17763.2114' — a Server 2019 build. My claim that
cvelistV5 ranges are release-bounded came from checking ONE modern record, and
older ones are not:

  CVE-2021-26432  version=10.0.0        lessThan=10.0.17763.2114   (generic floor)
  CVE-2026-47291  version=10.0.14393.0  lessThan=10.0.14393.9234   (release-bounded)

A '10.0.0' floor makes the range swallow every lower build, so a 14393 host fell
inside a 17763 fix. The OS scan now requires the floor and the fix to sit on the
same build line and skips the rest — such an entry carries no release info at
all, and the OS string alone can't supply it. Costs nothing in practice: Windows
servicing is cumulative, so a host behind on an old CVE is already flagged by
that line's newer ones. Existing false positives self-heal on the next scan via
the app-scan auto-resolve.

Also:
- scheduler job renamed to '(Windows OS)' — it covers client too, not just Server
- dashboard: AI Audit History hidden from read-only (AI endpoints are editor+)
- dashboard: 'Export Report' had no onClick at all (dead for every role) — now
  links to the reports page
- compliance: the Impact-CSV upload card is admin-only (the import endpoint is
  RequireAdmin), so it no longer 403s silently for other roles
2026-07-14 15:36:10 +02:00
vulncheck 7e15d6552a feat(notifications): keep lifecycle/EOL findings out of CVE mails
New notification_lifecycle_mode setting (default 'exclude'): pseudo-findings
that aren't real CVEs — EOL-* (endoflife.date), ANDROID-PATCH-* (patch-level
staleness), NESSUS-PLUGIN-* — no longer mix into the vulnerability mails. The
rule is 'anything not CVE-*' so future pseudo prefixes are covered without a
code change. Applies to both delivery modes (single/digest) and both schedules
(per-sync/nightly); 'include' restores the legacy mixed behaviour. Excluded
findings stay fully visible in the dashboard — only the mail routing changes.
2026-07-14 11:34:02 +02:00
vulncheck 1609d3edbc fix(wazuh): scroll the states index (fixes 400 + lost findings on big agents)
Paging used from/size, which OpenSearch hard-caps at index.max_result_window
(default 10000). Agents with more findings 400'd at from=10000, the whole agent
then read as '0 vulns' and its backfill was skipped — so hosts with 24k/27k
findings silently contributed nothing. Switch full syncs to the scroll API (no
window ceiling, no unique-sort requirement), with a fallback to windowed paging
if the indexer refuses scroll; explicit-offset callers keep from/size but stop
at the window instead of erroring.

Also hide the dashboard AI Audit controls + recommendations panel from
read-only users (the audit endpoint is editor-gated and threw 'Access denied').
2026-07-14 11:30:06 +02:00
vulncheck 904a3ec1ca fix(notifications): provide {{affected_assets_count}} in the digest template
The digest only exposed the per-CVE count (inside {{rows}}), so a top-level
{{affected_assets_count}} in the digest body stayed literal in sent mail (the
preview substituted it from sample data, masking the gap). Add it to the digest
variables = distinct assets across the whole digest ('affected systems' tile).
Docs + template hint updated.
2026-07-13 14:28:00 +02:00
vulncheck 93e2b1912c fix(ui): readonly AI card, clipboard fallback, template preview vars
- Hide the AI Remediation section for read-only users (Generate hits an
  editor-only endpoint; output isn't persisted, nothing to show them).
- Copy buttons: navigator.clipboard is undefined over plain http://<ip>
  (non-secure context) so copies silently failed — add a textarea+execCommand
  fallback.
- Email template Preview: add the new sample variables (cpr_score,
  affected_assets_count, cve/asset deep links) and a sample {{rows}} HTML table
  so previews render real values instead of literal {{...}} placeholders.
2026-07-13 13:15:48 +02:00
vulncheck 65d6c62561 fix(rbac): show assignee as text for read-only (not a dead 'Unassigned' select)
Read-only users can't fetch /auth/users (admin-only), so the disabled assign
<select> resolved to 'Unassigned' even when the finding/asset WAS assigned — the
head icon showed assigned but the name was hidden. Render the assignee name
straight from the payload (assigned_user_name / group name) as read-only text
instead. Applied on both the vulnerabilities and assets lists. Also fix a
pre-existing invalid title= prop on the Nessus <svg> (use a <title> child).
2026-07-13 13:09:00 +02:00
vulncheck 867498c3d3 fix(vulns): hide the Actions column entirely for read-only users
Read-only saw an empty Actions column with just a '—' placeholder. Gate the
header, the per-row cell, and the empty-state colSpan on role so the column
disappears for read-only instead of hanging around blank.
2026-07-13 10:39:12 +02:00
vulncheck f9d9919615 feat(audit): forward audit-log events to syslog/SIEM (UDP/TCP, RFC-5424)
Every audit_logs insert is mirrored as an RFC-5424 syslog message with a
per-event severity (login failures/lockouts/access-denied → warning, security
alerts → alert, config/deletes → notice, else info). A SQLAlchemy after_insert
hook enqueues onto a bounded queue drained by one daemon worker (never blocks
the request/flush; bursty syncs drain sequentially). Config cached 30s; TCP
keeps a persistent socket with reconnect. Disabled by default → no-op until
enabled. Admin-only config card (host/port/UDP-TCP/facility) with a Test button;
POST /api/v1/settings/syslog/test sends a probe. No TLS yet (UDP+TCP).
2026-07-13 10:33:29 +02:00
vulncheck a289c07ccb feat(auth): permanent account lockout + admin unlock
Opt-in via the auth_lockout_permanent setting: after 5 failed logins an account
is locked until an admin clears it (post-incident review), instead of the
temporary 15-min auto-unlock. Safeguard: the last active admin is NEVER
permanently locked (falls back to the temporary window) so a brute-force on the
admin login can't lock everyone out for good.

- migration 037: users.locked + locked_at
- local.py: permanent-lock check + threshold escalation with last-admin guard
- POST /auth/users/{id}/unlock (admin, audit-logged); list_users exposes locked
- Settings UI: 'Permanent account lockout' toggle + per-user Unlock button/badge
2026-07-13 10:30:05 +02:00
vulncheck 5e85a5cf25 fix(rbac): hide vuln write-actions from read-only + scope notification log
- Vulnerabilities list: the per-row Change-Status / Suppress / Mark-False-
  Positive buttons and the assign dropdown were shown to read-only users and
  threw 'Access denied. Required role: editor'. Gate them (editor+); assign
  select disabled for read-only.
- CVE detail page: Dismiss/Reactivate now shown only to editor+ (fetches
  /auth/me for the role).
- Notification log (/notifications): GET /log returned EVERY sent mail —
  recipient emails + which CVE went to whom — to any authenticated user (privacy
  leak). Now admins see all; everyone else sees only notifications addressed to
  them (user_id == own).
2026-07-13 09:41:21 +02:00
vulncheck 4a25a5d1c0 feat(notifications): add CPR, affected-systems count & deep-link template vars
Both single and digest new-CVE emails now expose risk/priority context and
ready-made links so operators don't hand-build URLs:
- cpr_score: the contextual priority rating (risk-led triage)
- affected_assets_count: distinct assets carrying this CVE (blast radius),
  computed once per batch via a grouped query
- cve_link / asset_link / cve_on_asset_link: prebuilt deep links into the
  vulnerabilities view (?cve_id=, ?asset_id=, both)
Digest rows gain CPR + #Systems columns and a clickable CVE; the single-mode
default template shows CPR, affected count and two action buttons. Settings
variable hints updated.
2026-07-13 09:37:25 +02:00
vulncheck 702dfcbfb3 feat(notifications): editable digest template + nightly roundup + rate limit
#2 The digest template (email_template_new_vuln_digest, actually sent in the
default digest mode) was not editable in the UI — only the single-mode template
was — so a customized template appeared unused. Add a digest-template editor in
Settings (mode-aware Active/Inactive badges clarify which applies).

#3 Add a nightly aggregated roundup: notification_schedule='nightly' suppresses
per-sync mails and a self-gated hourly scheduler job sends ONE digest per
recipient of all CVEs since the last run (windowed via
notification_nightly_last_run), at notification_nightly_hour. Plus GUI-config
send rate limiting (email_rate_delay_seconds, email_max_per_run) applied in the
dispatch loop against provider anti-spam.
2026-07-13 07:53:25 +02:00
vulncheck 01e766dace fix(vulns): hide Refresh Threat Intel from read-only users
Every other admin toolbar button on the vulnerabilities page is hidden for
read-only (role !== 'readonly'), but Refresh Threat Intel was only disabled —
so it showed greyed-out and dead. Hide it like the rest for consistency.
2026-07-11 10:46:05 +02:00
vulncheck 96e6072a41 fix(assets): hide write-actions from read-only users (no more 403 alerts)
A read-only user saw every row action (Wazuh/Nessus/app rescan, edit, delete,
assign) and Add Asset, but the backend gates those (RequireEditor/RequireAdmin)
so clicking threw 'Access denied. Required role: editor'. Fetch the current
role and show mutating actions only to editor+ (delete to admin); read actions
(installed software, coverage gap, exposure) stay for everyone.
2026-07-10 14:07:31 +02:00
vulncheck 4aac1bfd06 fix(settings): hide OpenRouter/Intune config from non-admin users
Every other card in the System-config column is already gated behind
userRole==='admin', but the OpenRouter and Intune cards rendered
unconditionally, so a read-only user saw empty admin config with dead
Save/Test buttons (backend already 403s them via RequireAdmin, so no data
leak — but confusing). Gate both like the rest.
2026-07-10 14:02:59 +02:00
vulncheck ae52cfe293 feat(assets): on-demand installed-software view per asset
New GET /assets/{id}/software pulls the live software inventory (Wazuh
syscollector packages, else Intune detectedApps) — the same inventory the
app-CVE scanner consumes — without persisting it. A list-icon button on each
Wazuh/Intune-backed asset row opens a modal with name/version/vendor.
Read-only, deduped, sorted. No DB schema (ponytail: on-demand until a
searchable/reporting inventory is actually needed).
2026-07-10 13:37:47 +02:00
vulncheck c3b9bb6cbf fix(app-scan): exclude OpenSSL FIPS builds (FP) + per-asset app re-scan button
- OpenSSL FIPS provider builds (e.g. Veeam's 'OpenSSL v3.0.0 FIPS') were
  matched against OpenSSL CVEs, but the advisories explicitly carve the FIPS
  modules OUT (vulnerable code is outside the FIPS boundary) and they carry a
  separate 4-part build version — pure false positive (CVE-2025-15467). Exclude
  via negative lookahead 'openssl(?!.*fips)'. Existing FP self-heals: next scan
  no longer detects it -> auto-resolve marks it patched (now audit-logged).
- Add an 'App CVE re-scan' action button on asset rows (Wazuh- or Intune-backed
  assets) that hits the existing POST /vulnerabilities/app-cve-scan?asset_id=,
  analogous to the Wazuh rescan button.
2026-07-10 13:33:25 +02:00
vulncheck 572b6fe217 fix(settings): handle skipped/empty sync result + clearer status colors
The sync-status poll showed 'Sync done — undefined devices…' in green when a
run was skipped by the advisory lock (result = {skipped}). Branch on skipped
(amber 'already running'), guard the stat fields with ?? 0, and give states
distinct colors: loading=blue pulse, warning=amber, success=green, error=red.
2026-07-09 15:16:54 +02:00
vulncheck 381c21034c fix(dashboard): show 10 distinct CVEs in Newly-Published/EOL widgets
The widgets fetched a per-asset-duplicated list and deduped by cve_id
client-side. When a CVE sits on many assets, a handful of CVEs x N assets fill
the whole fetch window, so dedup starved and the widget showed ~4 of 10. With
131 assets no limit can guarantee 10 distinct (10x131 > 1000 cap).

Add a distinct_cve query param to the vulnerability list: a row_number() window
partitioned by cve_id keeps one representative row per CVE (newest published,
then most recent) server-side, composing with every sort_by. Newly-Published
and EOL widgets pass distinct_cve=true (Mobile stays per-asset by design).
2026-07-09 14:02:49 +02:00
vulncheck e78b4fef80 feat(intune): sync status endpoint + GUI polls for completion
The manual sync is fire-and-forget (202), so the GUI got stuck on 'Sync started
in background' forever. Track last/current sync state in the router and expose
GET /sync/status; the settings page now polls every 3s and shows the real
result (devices/matched/created/app-findings + Defender new CVEs) or the error.
2026-07-09 13:59:00 +02:00