Commit Graph
204 Commits
Author SHA1 Message Date
vulncheck 0089ecbc54 fix(advisories): show every feed item, not the first 15
The feed panel counted f.items.length in its header but rendered
f.items.slice(0, 15). With the "Items per feed" limit at 20 the heise
Security Alerts feed therefore announced "20 items" and listed 15 —
the five newest-but-sixteenth-onward advisories were fetched, cached
and silently dropped at render time.

The slice is older than the configurable limit: it shipped with the
original page (64204f4), back when 15 was the only cap there was. Once
advisory_feeds_max_items became the operator's knob, the hardcoded 15
turned into a second, invisible ceiling that no setting could raise.

Root cause confirmed: one hardcoded display cap contradicting the
server-side limit that the same header reports. Removed it — how many
items a feed keeps is now decided in exactly one place (the setting,
clamped 1-500 in the service), and the header count and the list can
no longer disagree.
2026-08-19 20:18:17 +02:00
vulncheck 442a7ad147 fix(assets): name the CSV export like the audit-log export
The assets CSV download ignored the server's Content-Disposition and built
its own filename from `new Date().toISOString()` — always UTC, so an export
started at 09:06:39 CEST landed as `assets_2026-08-18-07-06-39.csv`, two
hours off and in a different format than `audit_log_20260818_090842.csv`.

The audit-log export is a plain `<a href>`, so the browser honours the
server's header and gets server-local time. The assets export is an XHR
(auth headers + filter params), so it now reads the same header instead of
inventing a name: both exports get `<prefix>_YYYYMMDD_HHMMSS.csv` in server
time from one source of truth (the router).

Content-Disposition is added to the CORS expose_headers so the header is
readable when the frontend runs on a different origin than the API.
2026-08-18 09:57:55 +02:00
vulncheck 5a0d37cded feat(assets): export full inventory as CSV with all fields
Adds GET /api/v1/assets/export - a streaming, unpaginated CSV export of
the whole (optionally filtered) asset inventory with every DB column plus
denormalised policy/assignee/groups and open-vulnerability counts.

- Extract the list endpoint's filter logic into _apply_asset_filters so
  list + export share one source of truth.
- Use selectinload (yield_per-safe) for policy/assignee/groups to avoid
  N+1 lazy loads on large exports.
- Add an "Export CSV" toolbar button that downloads the export, honouring
  the current search / source / "show decommissioned" filters.
- Add a regression test for the CSV JSON column formatters.

Also drops the auto-generated AGENTS.md/CLAUDE.md (gitnexus tooling files)
from tracking and gitignores them.
2026-08-17 15:56:51 +02:00
vulncheck 7be3a4ba10 feat(assets): show VMware build number in OS column 2026-08-17 15:03:44 +02:00
vulncheck 7078f5e024 feat(vsphere): read vCenter + every ESXi host, and detect their CVEs
A hypervisor runs no agent. Wazuh cannot reach it, Intune does not know it,
and Nessus only sees it if someone scoped a credentialed scan at it — so the
machines whose compromise takes every VM on them down were the ones with no
vulnerability coverage at all.

The connector reads the vCenter appliance and every ESXi host in its
inventory over the vSphere SOAP API (pyVmomi): hostname, management IP,
hardware model, product version and BUILD. One PropertyCollector pass, so a
500-host estate is one round trip. Read-only rights are enough.

The build is the point. NVD carries nothing usable — CVE-2026-47876 and
CVE-2026-59310 both sit there with no configuration — and the bounds
cvelistV5 does state are build identifiers, not versions:

    ESX      8.0       lessThan "ESXi80U3k-25595708"
    vCenter  8.0       lessThan "8.0 U3k"

_is_version rejects both, so _ranges_from_affected dropped the entries and
every vSphere CVE was invisible. They are now indexed verbatim and resolved
to build numbers: ESXi bounds carry one inline, vCenter bounds name a release
whose build comes from Broadcom KB 326316 (seeded in full, re-read weekly,
merged never replaced). A bound that resolves to no build produces no
verdict, and neither does a host with no build recorded.

Comparing builds alone is wrong in both directions. CVE-2025-22224 names two
fixes for the 8.0 line at once — U3d (24585383) for 8.0.3 and U2d (24585300)
for 8.0.2 — so the U2d host is patched despite the higher number existing.
And vCenter 8.0 U2f shipped four days AFTER the U3k fix on the older update
line, with a higher build, because Broadcom ships async patches there. So the
decision is scoped to the update line first, then still checked against the
build.

What must never resolve is asserted in the tests: the same advisories file
"VMware Cloud Foundation (vCenter Server)" and "vSphere Foundation" with the
SUITE's version numbers, and matching those would compare a vCenter 8.0.3
appliance against a VCF 5.x range.

EOL comes from endoflife.date (esxi / vcenter), keyed by major line — which
is also the honest granularity, one end-of-support date per line. 7.0 ended
2025-10-02, 6.7/6.5 in 2022. Both slugs are single-release, so an upgrade
retires the old finding. The OS patterns are shared with the CVE pass so a
machine cannot be a hypervisor for its CVEs and something else for its dates.

Checked against all 189 vSphere CVE records currently in cvelistV5: 64 bounds
indexed, none unresolvable, and every current release comes out clean while
each one behind gets its own line's fix named.

Migration 045 adds AssetSource.VCENTER, assets.vmware_uuid (the pin) and
assets.vmware_build.
2026-08-17 11:54:51 +02:00
vulncheck 04d59d31e6 feat(euvd): link every CVE to its ENISA EUVD record
The EUVD link on the detail page only appeared when enrichment had
stored an euvd_id, which only happens for rows the EUVD KEV feed
covers. Every other CVE showed no EU record at all.

The EUVD resolves a CVE id to the same page as its own EUVD-ID
(/vulnerability/CVE-2026-68820 == /vulnerability/EUVD-2026-56468), so
the link needs no id mapping: use the EUVD-ID when we know it, the CVE
id otherwise. Label keeps the EUVD-ID in parentheses when present.
2026-08-17 10:16:42 +02:00
vulncheck 4daf28113e fix(kev): give EUVD-only exploited CVEs their product name back
An "Actively Exploited" row that only ENISA lists rendered as a bare CVE
id with no text: the EUVD parser kept the date, the exploited flag and the
EUVD-ID and threw away everything that says WHAT is affected. CISA rows
looked fine because CISA ships vendor/product/name — so the gap only showed
on the EU-only entries, exactly the ones nobody can recognise by number.

ENISA does carry it, nested: enisaIdProduct[].product.name (+ its vendor)
and a free-text description. Parse those, merge them like the other fields,
and let the KEV catalog fall back to them when CISA has nothing — CISA
still wins where both list a CVE, because its short vuln NAME beats a
paragraph on a one-line row. No CVE is dropped or filtered by any of this.

Caches written before these fields are refetched once, same as the pre-
`exploited` ones: an entry with no product would keep the blank rows for
another 24h. Descriptions are clipped to 300 chars — the cache is a single
JSON blob in a Setting row, and ~1700 full ENISA texts add ~0.5 MB to
every read for text no row ever shows.

Detail view now links the ENISA EUVD record under External References
whenever we know the EUVD-ID, so the EU-authoritative page and the
corresponding EUVD-ID are one click away instead of copy-paste.
2026-08-15 16:57:48 +02:00
vulncheck 9e81436391 chore(frontend): drop next-auth, lift next/axios out of their advisories
next-auth was never imported — the backend issues the JWTs itself — so a
package carrying a critical advisory sat in the tree doing nothing.
Removing it beats upgrading it.

next 16.1.5 → 16.3.1 also clears postcss, sharp, nanoid, form-data,
follow-redirects and uuid, which came in transitively. eslint-config-next
moves in lockstep with next, otherwise it pins the old toolchain.

npm audit --omit=dev: 9 findings (1 critical) → 0.
2026-08-15 09:47:19 +02:00
vulncheck 57ed0b1534 feat(kev): alert on exploited CVEs that are open in our own environment
Actively-exploited CVEs were shown from CISA KEV only, on a dashboard
somebody had to be looking at. ENISA's EUVD exploited catalog was already
being fetched for enrichment but never surfaced as a KEV source.

- kev_service merges CISA KEV + ENISA EUVD (exploited only — an EU-Critical
  entry is a priority list, not an exploitation claim) into one entry per
  CVE carrying both sources.
- Inventory impact is counted honestly: active assets only (matching the
  vulnerabilities list), open findings apart from remediated ones, and an
  asset with both an open and a closed row counted once, as open. That gap
  is what made the badge say 64 where the filtered view listed 62.
- kev_alert_service mails the CVEs with open findings here — hostnames, IPs,
  counts, sources — once per CVE, again when more systems are affected.
  Hourly, plus straight after each threat-intel refresh.
- Dashboard pins what is open here above the newest listings; Advisories
  gains source filters, an alert panel and its config.

EUVD entries now carry an explicit `exploited` flag; caches predating it
cannot answer the question for an entry that is also EU-Critical, so they
are treated as stale and refetched once.
2026-08-15 09:37:16 +02:00
vulncheck 042e5aa6c7 feat(advisories): make the per-feed item cap configurable
30 items per feed was a constant. It is now the setting
`advisory_feeds_max_items`, editable in the feed configuration panel, applied
to every feed and taking effect on the next refresh.

The range is 1–500 rather than unbounded, and that ceiling is load-bearing:
every item lands in the cached JSON of ONE settings row that the page reads
in full, so "no limit" means a row that grows forever and a page that stops
rendering. 500 per feed is a few hundred KB per source — a quarter of the BSI
advisories in one sitting, and still a page that loads.

The value is written through the generic settings API, which stores whatever
a text field produced, so the read path treats it as untrusted: "", "abc",
"50 ", '"120"', 0, -5, 999999, "inf", true, None and an unreachable database
all resolve to something usable — junk falls back to 30, out-of-range is
clamped — and it never raises, because a feed run must not fail over its own
limit. The editor rejects the same values up front with the bounds named,
rather than saving something that quietly becomes a different number.

Also bounds the feed download at 16 MB. The item limit is the operator's to
raise; the size of a stranger's response is not.
2026-08-13 12:14:40 +02:00
vulncheck 1d7f647ec3 fix(gui): make URS rows open the asset like the compliance table
The hostname was styled as a link (blue, bold) but carried no handler, so
clicking it did nothing — the identical table below already routes through
openAsset.
2026-08-12 14:13:34 +02:00
vulncheck b634e0658f feat(gui): link GitHub advisories from the CVE detail view
NVD and cvelistV5 have no record for a lot of the ids this scanner
produces, and the GLOBAL GitHub advisory database does not have them
either: github.com/advisories/GHSA-8c6v-7g3w-prrq is a 404 while
wazuh/wazuh's own advisory page serves it. Repo-published advisories
only exist on the repo.

So every CVE now offers a GitHub chip. When the affected product maps to
a repo that publishes its own advisories (the existing _REPO_MAP), the
link points at that repo — the detail endpoint upgrades it to the
advisory itself when the cached repo index knows the GHSA id, and falls
back to the repo's advisory search otherwise. Everything else gets the
global advisory search. All of them are search pages or verified
records, so none can 404.

Two things fixed in passing:

- The GHSA branch of the External References block was dead code: the
  block was guarded on /^CVE-/, so a GHSA-only finding (the ones added
  in 6304b66) showed no references at all.
- The `references` column has been written by the Nessus sync since it
  landed and was never rendered anywhere. Those vendor advisories are
  often the only page describing the flaw, and for a NESSUS-PLUGIN- row
  they are the only reference it can have — the block now renders for
  those rows too, with the catalogue links still restricted to real CVE
  ids so no NVD link is built from a plugin id.
2026-08-12 13:05:14 +02:00
vulncheck 6304b661f9 feat(scan): findings for advisories that carry no CVE number
4 of Notepad++'s 18 advisories and 10 of Wazuh's 60 have only a GHSA id. They
were skipped, on the reasoning that the pipeline keys on CVE numbers — which
made them invisible to every source we have, since NVD and cvelistV5 are keyed
on CVE numbers by definition. A published flaw with a version range is a
finding whether or not a number was ever assigned.

The GHSA id stands in as the identifier, the way NESSUS-PLUGIN- and EOL- ids
already do in this schema, and it fits the column with room to spare.

What such a finding does not get is threat intel. KEV, EPSS and the CVSS
enrichment all look up a CVE number and skip anything else on their own
(enrichment_service filters on the prefix), so it simply carries none — honest,
since none exists for it. The detail page swaps the NVD and CVE.org links,
which would 404, for the advisory itself.

Measured against the versions in the test environment, this adds nothing
today: at Wazuh 4.14.1 and Notepad++ 8.9.5 every matching advisory happens to
have a CVE id. The CVE-less ones are on other release lines (5.0.0-beta). So
this is coverage for the case rather than a fix for a current gap.

The same run shows the component filter doing its job: at 4.14.1 the agent
gets 1 finding and the manager 13, from the same 60 advisories.
2026-08-12 10:11:23 +02:00
vulncheck be3c111a93 fix(gui): match the audit-log pager to Vulnerabilities and Assets
Audit logs had its own pagination bar: a "Rows per page:" label, 25/50/100/250
steps and full-word First/Prev/Next/Last buttons. The Vulnerabilities and Assets
tables use "Per page" with 50/100/250/500/1000 and compact «/‹ Prev/Next ›/»
controls. Same bar everywhere now; 1000 is the cap on /audit/logs.
2026-08-11 22:37:54 +02:00
vulncheck 9558129bc1 feat(gui): show the affected asset as its own audit-log column
Naming the host inside the description was not enough: that column truncates
at max-w-xs, and these rows are long — "Vulnerability EOL-CHROME-150 status
changed: open → patched (Superseded — asset moved to a newer chrome release
(EOL-CH…" cuts off before it. So the machine was still only reachable by
exporting the CSV and reading new_value by hand.

It has been in new_value's JSON all along, so the column reads it from there
and no backend change is needed. Rows written before the hostname fix show a
dash rather than a guess.
2026-08-10 14:39:57 +02:00
vulncheck 591e27bbe9 feat: keep the vendor's own severity visible, and name the host in the audit log
VENDOR RATING NO LONGER DISAPPEARS

Chrome and Firefox CVEs ship with no CVSS at all, so the vendor's own rating
is the only signal at first: Google states it in the record ("Chromium
security severity: Critical"), Mozilla in its advisories. Once a real score
arrives the severity column is recalculated from it and that rating was gone —
CVE-2026-19137 shows HIGH because CVSS 8.3 is High, while Google calls it
Critical.

Neither is wrong. They answer different questions: Chromium "Critical" means
escape from the sandbox, by Google's own criteria; 8.3 is High on the FIRST
scale. So the vendor's rating now sits next to ours instead of being replaced,
and only when the two differ. Sorting and priority stay on CVSS.

No schema change — Google already writes it into the description, and Mozilla's
is appended there the same way, so the badge reads both from text that is
already stored.

AUDIT ROWS NAME THE MACHINE

log_vulnerability_change has taken a hostname from the start, but the automated
paths (EOL supersede and reconcile, app-scan auto-resolve) never passed one, so
their rows said which CVE changed and not on which system — the tester could
only get at it by exporting the CSV and reading new_value. It is now looked up
when the caller does not supply it, rather than threaded through every call
site: these are single-row writes and the id is indexed.
2026-08-10 14:13:49 +02:00
vulncheck 2a831e4678 feat(scan): report a fix floor when the advisory names no patched build
Adobe writes CVE-2026-48294 as "lessThanOrEqual 26.5.2.2", so no fixed build
exists to show and the finding read "not announced" — which hid something the
tester rightly pointed out twice: if 26.5.2.2 and older are affected, anything
above it is not. That is a valid statement, and an admin can act on "newer
than 26.5.2.2" even when the vendor named no build.

Stored as ">26.5.2.2" and rendered as "not announced — need newer than
26.5.2.2". The prefix carries both facts at once, which is why it is not
written as a bare version: fixed_version also drives the "patch available"
badge, and lessThanOrEqual means the CNA named no patched release, often
because none has shipped. has_fix reads the prefix and does not claim one
exists. A later record with a real lessThan replaces the floor.

No schema change: the existing column carries it, and nothing compares
fixed_version numerically (only equality, in has_fix).
2026-08-10 14:02:24 +02:00
vulncheck 9fac0ae92f feat(gui): per-page selector on the vulnerabilities table
The page size was hard-coded at 100, so 59k findings could only be walked 591
pages at a time while the assets table has offered 50/100/250/500/1000 all
along. Same control, same choices, same default, so both tables behave alike.

The pagination bar now appears whenever there are results at all. It used to
be gated on totalCount > pageSize, which would have hidden the selector as
soon as the chosen size covered everything — leaving no way back to a smaller
one. Changing the size returns to page 1, otherwise page 5 of 1000-per-page
lands past the end of a 50-per-page list.

The API already accepted up to 1000 (limit le=1000); nothing changed there.
2026-08-10 11:20:06 +02:00
vulncheck c042e9d549 feat(gui): report every counter a scan returns, not a hand-picked few
Each scan button carried its own list of which counters to display, so a
counter the backend reported but the list did not name was invisible: the log
said "11 FP-suppressed" and the button said "0 findings (0 new)". Adding a
counter to a scan service meant editing several buttons, and it got forgotten
every time. The MSRC button was worse — it sliced the result to the first four
entries and printed the rest as raw backend field names.

lib/scanStats.ts formats whatever the backend actually returned. Known keys
get a readable label, anything else renders from its own name, so a new
counter shows up in the GUI with no frontend change. Zero counters are
dropped, since "0 auto-resolved" is noise.

Applied to the app scan, the per-asset re-scan, the EOL check, the MSRC
enrich and the M365 check.
2026-08-06 13:42:09 +02:00
vulncheck 816a684bf1 fix(gui): end the scan poll only on an explicit terminal status
The app-scan and EOL pollers had two branches — running, or finished — so
every answer that was not a readable status object fell into "finished". The
empty result then rendered as "0 assets, 0 findings (0 new)" and the button
went idle while the backend scan carried on and logged the real numbers.

The tester hit this after ~15-30 minutes with several tabs open, which is when
the access token expires mid-scan: the poll 401s, the interceptor refreshes and
replays it, and anything that comes back other than a clean status object —
a login-page body from a redirect, a second 401 — landed in the finished
branch. The 401/refresh/200 sequence in the log is the healthy path, not the
bug; the poller's reading of it was.

Now only status "completed" or "failed" stops the poll. Anything else keeps
polling, with a ~2 min counter of unusable answers so a backend restart
mid-scan (the job lives in process memory) ends it with a clear message
instead of spinning forever. Same counter on the catch branch, which could
poll a dead backend indefinitely.

Also surfaces fp_suppressed in the result line, now that suppression runs
inside the scan.
2026-08-06 10:47:33 +02:00
vulncheckandClaude Opus 5 5d01854130 fix(ui): don't report a sync result we never saw
The Intune sync button threw away the POST's answer. That answer says either
"started" or "already_running" — and in the second case we are watching
somebody else's run (the nightly job, or a second click on a long sync), whose
stats may well be gone by the time we poll. The status endpoint then returns
no result and no error, and the button fell into a branch that simply said
"Sync completed." — announcing a success it had not witnessed, with none of
the numbers.

Attaching to a running sync is now stated up front, and a missing result is
reported as a warning that points at the log instead of a green success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:57:57 +02:00
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