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