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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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>
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>
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>
'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>
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>
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>
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>
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>
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>
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>
'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>
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>
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>
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.
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.
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.
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.
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.
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.
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.
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.
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'.
- 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.
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.
- 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'.
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).
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.
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.
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.