Vulnerabilities band filters always find entries, list controls stay put, first load shows the progress dialog

The summary endpoint takes perBand: above 0 it adds bandCves and bandSoftware, each severity and
priority band's top entries outside the lists. The portal asks for 10 per band and shows them only
under a filter, so Attend lists its 7 CVEs instead of nothing; the MCP tool never asks and keeps its
size. The Most exposed controls sit below the title in every state, bands with 0 entries are
disabled, and the first load shows CalculatingDialog (the Geographic Performance dialog without its
page) with separate estimates for all tenants and one tenant. D-308.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-01 20:13:47 +02:00
parent 43dfb731f1
commit fae6aa0b92
18 changed files with 516 additions and 110 deletions
@@ -337,6 +337,61 @@ public class VulnerabilitySummaryAggregatorTests
Assert.Equal(3, r.DistinctSoftware);
}
[Fact]
public void BandCves_AddEachBandsTopEntriesOutsideTheList_onlyWhenAsked()
{
var rows = new List<CveExposureEntry>
{
Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0),
Row("CVE-HIGH-2", "s1", "High", 7.5), Row("CVE-HIGH-2", "s2", "High", 7.5),
Row("CVE-ATT-1", "s1", "Critical", 9.8), Row("CVE-ATT-1", "s4", "Critical", 9.8),
Row("CVE-ATT-2", "s5", "Critical", 9.1),
Row("CVE-ATT-3", "s6", "Medium", 5.0),
Row("CVE-LOW", "s7", "Low", 2.0),
};
foreach (var r in rows)
r.Priority = r.CveId.StartsWith("CVE-ATT", StringComparison.Ordinal) ? "attend" : "track";
var plain = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now);
Assert.Null(plain.BandCves);
Assert.Null(plain.BandSoftware);
var r2 = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 2);
Assert.Equal(new[] { "CVE-WIDE" }, r2.TopCves.Select(c => c.CveId));
// critical: ATT-1, ATT-2 · high: WIDE (listed), HIGH-2 · medium: ATT-3 · low: LOW · attend: ATT-1, ATT-2 · track: WIDE, HIGH-2
Assert.Equal(new[] { "CVE-ATT-1", "CVE-HIGH-2", "CVE-ATT-2", "CVE-ATT-3", "CVE-LOW" }, r2.BandCves!.Select(c => c.CveId));
Assert.All(r2.BandCves!, c => Assert.NotEmpty(c.SampleSoftware));
}
[Fact]
public void BandSoftware_BandsProductsByTheirWorstValues_outsideTopSoftware()
{
var wide1 = Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"); wide1.Priority = "track";
var wide2 = Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"); wide2.Priority = "track";
var crit = Row("CVE-C", "s3", "Critical", 9.6, software: "Edge Tool", product: "acme:edge"); crit.Priority = "attend";
var low = Row("CVE-L", "s4", "Low", 2.1, software: "Small Tool", product: "acme:small"); low.Priority = "track";
var r = VulnerabilitySummaryAggregator.Aggregate(
new[] { wide1, wide2, crit, low }, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 1);
Assert.Equal(new[] { "acme:wide" }, r.TopSoftware.Select(s => s.Product));
// critical + attend → edge; low → small; high + track are already held by wide.
Assert.Equal(new[] { "acme:edge", "acme:small" }, r.BandSoftware!.Select(s => s.Product));
}
[Fact]
public void PerBand_IsClamped()
{
var rows = Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxPerBand + 10)
.Select(i => Row($"CVE-{i:D3}", $"s{i}", "Medium", 5.0))
.ToList();
var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now, perBand: 999);
Assert.Equal(VulnerabilitySummaryAggregator.MaxPerBand - 1, r.BandCves!.Count); // the band's first entry is already listed
}
[Theory]
[InlineData("7-Zip", "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)", "7-Zip 26.00 (x64 edition)")]
[InlineData("Cisco Secure Client", "Cisco Secure Client - AnyConnect VPN ", "Cisco Secure Client - Cloud Management")]
@@ -750,6 +750,62 @@ public class VulnerabilityWireParityTests
});
}
[Fact]
public void CveExposureSummary_carries_the_band_lists_after_topSoftware_when_requested()
{
var breakdown = new SeverityBreakdown { Critical = 1, High = 1, Medium = 0, Low = 0 };
var priorities = new PriorityBreakdown { Act = 0, Attend = 1, Track = 1 };
var wide = new TopCve
{
CveId = "CVE-2026-11111", CvssSeverity = "HIGH", CvssScore = 7.5, IsKev = false, EpssScore = 0.01,
Priority = "track", AffectedSessions = 40, SampleSoftware = new List<string> { "Contoso Agent 2.1" },
};
var attend = new TopCve
{
CveId = "CVE-2026-22222", CvssSeverity = "CRITICAL", CvssScore = 9.6, IsKev = false, EpssScore = null,
Priority = "attend", AffectedSessions = 2, SampleSoftware = new List<string> { "Contoso Viewer 1.0" },
};
var topCves = new List<TopCve> { wide };
var topSoftware = new List<TopSoftware>();
var bandCves = new List<TopCve> { attend };
var bandSoftware = new List<TopSoftware>();
AssertParity(
new
{
windowDays = 30,
totalAffectedSessions = 42,
distinctCves = 2,
distinctSoftware = 2,
kevCves = 0,
kevAffectedSessions = 0,
severityBreakdown = breakdown,
priorityBreakdown = priorities,
topCves,
topSoftware,
bandCves,
bandSoftware,
truncated = false,
},
new CveExposureSummary
{
WindowDays = 30,
TotalAffectedSessions = 42,
TotalAffectedTenants = null,
DistinctCves = 2,
DistinctSoftware = 2,
KevCves = 0,
KevAffectedSessions = 0,
SeverityBreakdown = breakdown,
PriorityBreakdown = priorities,
TopCves = topCves,
TopSoftware = topSoftware,
BandCves = bandCves,
BandSoftware = bandSoftware,
Truncated = false,
});
}
[Fact]
public void CveExposureSummary_omits_totalAffectedTenants_on_a_tenant_scoped_read()
{
@@ -38,6 +38,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
/// <summary>Raw titles carried per product entry.</summary>
public const int MaxTitlesPerSoftware = 3;
/// <summary>
/// Upper bound of the per-band representatives (<see cref="CveExposureSummary.BandCves"/>).
/// Only the portal asks for them; the MCP tool never does, so its response stays within its cap.
/// </summary>
public const int MaxPerBand = 25;
private static readonly HashSet<string> ArchitectureTokens = new(StringComparer.OrdinalIgnoreCase)
{
"x64", "x86", "amd64", "arm64", "64-bit", "32-bit",
@@ -52,15 +58,39 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
_ => 0,
};
private static string SeverityKey(int rank) => rank switch
{
4 => "critical",
3 => "high",
2 => "medium",
1 => "low",
_ => string.Empty,
};
private static string PriorityKey(string? priority) => CvePriority.Rank(priority) switch
{
3 => CvePriority.Act,
2 => CvePriority.Attend,
1 => CvePriority.Track,
_ => string.Empty,
};
/// <param name="perBand">
/// When above 0, the summary also carries <see cref="CveExposureSummary.BandCves"/> and
/// <see cref="CveExposureSummary.BandSoftware"/>: per severity and priority band its top
/// entries outside the lists, so a band filter never comes up empty while the band has entries.
/// </param>
public static CveExposureSummary Aggregate(
IReadOnlyList<CveExposureEntry> rows,
bool truncated,
int topN,
int days,
bool crossTenant,
DateTime nowUtc)
DateTime nowUtc,
int perBand = 0)
{
if (topN < 1) topN = 1;
perBand = Math.Clamp(perBand, 0, MaxPerBand);
var windowDays = Math.Clamp(days, 1, 365);
var cutoff = nowUtc.AddDays(-windowDays);
@@ -83,23 +113,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
.ToList();
var topCves = WithKevAdditions(rankedCves, topN, c => c.IsKev, MaxKevAdditions)
.Select(c => new TopCve
{
CveId = c.CveId,
CvssSeverity = c.Severity,
CvssScore = c.CvssScore,
IsKev = c.IsKev,
EpssScore = c.EpssScore,
Priority = c.Priority,
AffectedSessions = c.AffectedSessions,
SampleSoftware = c.Rows.Select(r => r.SoftwareName)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.Take(3).ToList(),
})
.ToList();
var listedCves = WithKevAdditions(rankedCves, topN, c => c.IsKev, MaxKevAdditions);
var software = FoldSoftware(withCve);
var rankedSoftware = software
@@ -112,6 +126,21 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
var topSoftware = WithKevAdditions(
rankedSoftware, Math.Min(topN, MaxSoftware), s => s.KevCount > 0, MaxKevSoftwareAdditions);
// A product is banded by its worst values, the only ones it carries.
List<TopCve>? bandCves = null;
List<TopSoftware>? bandSoftware = null;
if (perBand > 0)
{
bandCves = BandRepresentatives(
rankedCves, listedCves, perBand,
c => SeverityKey(c.SeverityRank), c => PriorityKey(c.Priority))
.Select(ToTopCve)
.ToList();
bandSoftware = BandRepresentatives(
rankedSoftware, topSoftware, perBand,
s => SeverityKey(SeverityRank(s.CvssSeverity)), s => PriorityKey(s.Priority));
}
var breakdown = new SeverityBreakdown
{
Critical = cves.Count(c => c.SeverityRank == 4),
@@ -144,12 +173,59 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
KevAffectedSessions = DistinctSessions(withCve.Where(r => r.IsKev)),
SeverityBreakdown = breakdown,
PriorityBreakdown = priorities,
TopCves = topCves,
TopCves = listedCves.Select(ToTopCve).ToList(),
TopSoftware = topSoftware,
BandCves = bandCves,
BandSoftware = bandSoftware,
Truncated = truncated,
};
}
private static TopCve ToTopCve(CveFold c) => new()
{
CveId = c.CveId,
CvssSeverity = c.Severity,
CvssScore = c.CvssScore,
IsKev = c.IsKev,
EpssScore = c.EpssScore,
Priority = c.Priority,
AffectedSessions = c.AffectedSessions,
SampleSoftware = c.Rows.Select(r => r.SoftwareName)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.Take(3).ToList(),
};
/// <summary>
/// For every band of every banding (severity, priority): the band's first
/// <paramref name="perBand"/> entries of the ranked list, minus the entries already
/// <paramref name="listed"/>. Entries without a band add nothing; the ranked order is kept.
/// </summary>
internal static List<T> BandRepresentatives<T>(
IReadOnlyList<T> ranked,
IEnumerable<T> listed,
int perBand,
params Func<T, string>[] bandings) where T : class
{
var alreadyListed = new HashSet<T>(listed, ReferenceEqualityComparer.Instance);
var picked = new SortedSet<int>();
foreach (var bandOf in bandings)
{
var taken = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < ranked.Count; i++)
{
var band = bandOf(ranked[i]);
if (string.IsNullOrEmpty(band)) continue;
taken.TryGetValue(band, out var count);
if (count >= perBand) continue;
taken[band] = count + 1;
if (!alreadyListed.Contains(ranked[i])) picked.Add(i);
}
}
return picked.Select(i => ranked[i]).ToList();
}
/// <summary>
/// The first <paramref name="topN"/> entries of a ranked list plus the KEV entries below the
/// cut (at most <paramref name="maxAdditions"/>). The input order is kept, so a list ranked by
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
}
/// <summary>
/// GET /api/metrics/vulnerability?days=30&topN=20
/// GET /api/metrics/vulnerability?days=30&topN=20&perBand=10
/// Fleet vulnerability summary for the caller's tenant (MemberRead).
/// </summary>
[Function("GetVulnerabilitySummary")]
@@ -47,7 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
}
/// <summary>
/// GET /api/global/metrics/vulnerability?days=30&topN=20&tenantId=...
/// GET /api/global/metrics/vulnerability?days=30&topN=20&perBand=10&tenantId=...
/// Cross-tenant summary (Global Admin). With tenantId, scopes to one tenant.
/// </summary>
[Function("GetGlobalVulnerabilitySummary")]
@@ -67,14 +67,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
var query = req.Query;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100);
// Per-band representatives for the portal's band filters; callers that omit it
// (the MCP tool) get the lists without them.
var perBand = QueryParams.Int(query["perBand"], @default: 0, min: 0, max: VulnerabilitySummaryAggregator.MaxPerBand);
_logger.LogInformation(
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})",
crossTenant ? "cross-tenant" : tenantId, days, topN);
"Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN}, perBand={PerBand})",
crossTenant ? "cross-tenant" : tenantId, days, topN, perBand);
var (rows, truncated) = await _sessionRepo.ScanCveIndexAsync(tenantId, MaxScanRows);
var summary = VulnerabilitySummaryAggregator.Aggregate(
rows, truncated, topN, days, crossTenant, DateTime.UtcNow);
rows, truncated, topN, days, crossTenant, DateTime.UtcNow, perBand);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(summary);
@@ -1258,6 +1258,10 @@ export interface CveExposureSummary {
topCves: TopCve[];
/** The top min(topN, 20) products by affected sessions, plus the products with a KEV CVE that rank below them (at most 10 extra), ordered by affected sessions. */
topSoftware: TopSoftware[];
/** For list filters: per CVSS severity band and per priority band, the band's top CVEs by affected sessions that TopCves does not already hold (at most perBand per band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise. */
bandCves?: TopCve[];
/** The same for products, banded by their most severe CVE and their highest priority, minus the entries of TopSoftware. Only on request; null (key omitted) otherwise. */
bandSoftware?: TopSoftware[];
truncated: boolean;
}
@@ -358,6 +358,19 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public IReadOnlyList<TopSoftware> TopSoftware { get; set; } = default!;
/// <summary>
/// For list filters: per CVSS severity band and per priority band, the band's top CVEs by
/// affected sessions that <see cref="TopCves"/> does not already hold (at most perBand per
/// band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise.
/// </summary>
public IReadOnlyList<TopCve>? BandCves { get; set; }
/// <summary>
/// The same for products, banded by their most severe CVE and their highest priority, minus
/// the entries of <see cref="TopSoftware"/>. Only on request; null (key omitted) otherwise.
/// </summary>
public IReadOnlyList<TopSoftware>? BandSoftware { get; set; }
public bool Truncated { get; set; }
}
@@ -9,6 +9,7 @@ import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adm
import { saveAdminConfigChanges } from "@/lib/adminConfigSave";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import type {
AdminConfiguration as WireAdminConfiguration,
CveExposureSummary,
@@ -78,6 +79,9 @@ export function VulnerabilityDataSection({
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
const [summaryDays, setSummaryDays] = useState(30);
const [loadingSummary, setLoadingSummary] = useState(false);
// Same cross-tenant scan as the Software tab's "All tenants" view, so they share the estimate.
const summaryProgress = useFetchProgress("vulnExposure.all.lastFetchMs");
const { begin: summaryProgressBegin, finish: summaryProgressFinish } = summaryProgress;
const refreshSyncStatus = useCallback(async () => {
try {
@@ -124,15 +128,20 @@ export function VulnerabilityDataSection({
const refreshSummary = useCallback(async () => {
setLoadingSummary(true);
summaryProgressBegin();
let succeeded = false;
try {
setSummary(await fetchJson<CveExposureSummary>(api.metrics.globalVulnerability(summaryDays, 10), getAccessToken));
// perBand 10: the band filters of the panel need each band's top entries (bandCves/bandSoftware).
setSummary(await fetchJson<CveExposureSummary>(api.metrics.globalVulnerability(summaryDays, 10, undefined, 10), getAccessToken));
succeeded = true;
} catch {
// Non-critical: the overview is auxiliary; config + sync still render.
setSummary(null);
} finally {
summaryProgressFinish(succeeded);
setLoadingSummary(false);
}
}, [getAccessToken, summaryDays]);
}, [getAccessToken, summaryDays, summaryProgressBegin, summaryProgressFinish]);
useEffect(() => {
const run = async () => {
@@ -640,7 +649,8 @@ export function VulnerabilityDataSection({
<VulnerabilityExposurePanel
title="Fleet Vulnerability Exposure"
summary={summary}
loading={loadingSummary && !summary}
loading={loadingSummary}
progress={{ elapsedMs: summaryProgress.elapsedMs, estimateMs: summaryProgress.estimateMs }}
showTenantCount
days={summaryDays}
onDaysChange={setSummaryDays}
@@ -10,8 +10,11 @@ import type { SoftwareTabScope, TimeRange } from "./types";
import { rangeToDays } from "./types";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { notifyApiError } from "@/contexts/NotificationContext";
import { useFetchProgress } from "@/hooks/useFetchProgress";
const TOP_N = 20;
/** Per severity/priority band, its top entries outside the lists, so every band filter shows something. */
const PER_BAND = 10;
export default function VulnerabilitiesTab({ scope, timeRange }: { scope: SoftwareTabScope; timeRange: TimeRange }) {
const { getAccessToken } = useAuth();
@@ -24,18 +27,25 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
// GA without a specific tenant selected sees the cross-tenant aggregate (incl. affected tenants).
const showTenantCount = isGlobalAdmin && !selectedTenantId;
// The cross-tenant scan takes far longer than one tenant's, so each keeps its own estimate.
const progress = useFetchProgress(showTenantCount ? "vulnExposure.all.lastFetchMs" : "vulnExposure.tenant.lastFetchMs");
const { begin: progressBegin, finish: progressFinish } = progress;
useEffect(() => {
if (!scopeInitialized) return;
let cancelled = false;
const days = rangeToDays(timeRange);
const run = async () => {
let succeeded = false;
try {
setLoading(true);
const url = scopedApi.vulnerability(scope, days, TOP_N);
progressBegin();
const url = scopedApi.vulnerability(scope, days, TOP_N, PER_BAND);
const summary = await fetchJson<CveExposureSummary>(url, getAccessToken);
if (cancelled) return;
setSummary(summary);
succeeded = true;
} catch (err) {
if (cancelled) return;
if (err instanceof ApiError) {
@@ -45,7 +55,11 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
notifyApiError(addNotification, "Backend Not Reachable", err, "vuln-exposure-error", "Unable to load vulnerability exposure.");
}
} finally {
if (!cancelled) setLoading(false);
// A cancelled run leaves the progress to the run that replaced it.
if (!cancelled) {
progressFinish(succeeded);
setLoading(false);
}
}
};
@@ -54,5 +68,12 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, scopeKey, timeRange]);
return <VulnerabilityExposurePanel summary={summary} loading={loading} showTenantCount={showTenantCount} />;
return (
<VulnerabilityExposurePanel
summary={summary}
loading={loading}
showTenantCount={showTenantCount}
progress={{ elapsedMs: progress.elapsedMs, estimateMs: progress.estimateMs }}
/>
);
}
@@ -7,6 +7,7 @@
* The bar is capped at 95% — it never claims "done" before the response actually lands.
*
* CalculatingCard: full-page card (page-level loading states).
* CalculatingDialog: the same card without the page, for a tab whose header stays visible.
* CalculatingInline: compact block for in-place placeholders (tab/table bodies).
*/
@@ -58,27 +59,34 @@ export function CalculatingInline({
);
}
export function CalculatingCard({
title,
subtitle,
elapsedMs,
estimateMs,
}: {
interface CalculatingDialogProps {
title: string;
subtitle: string;
elapsedMs: number;
estimateMs: number;
}) {
}
/**
* The loading dialog itself (spinner, title, subtitle, progress) without a page around it, for
* a tab or section whose header stays visible while its body aggregates.
*/
export function CalculatingDialog({ title, subtitle, elapsedMs, estimateMs }: CalculatingDialogProps) {
return (
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">{title}</h2>
<p className="mt-2 text-sm text-gray-600">{subtitle}</p>
<ProgressBarWithCaption elapsedMs={elapsedMs} estimateMs={estimateMs} />
</div>
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">{title}</h2>
<p className="mt-2 text-sm text-gray-600">{subtitle}</p>
<ProgressBarWithCaption elapsedMs={elapsedMs} estimateMs={estimateMs} />
</div>
</div>
);
}
export function CalculatingCard(props: CalculatingDialogProps) {
return (
<div className="min-h-screen bg-[var(--lp-bg)] flex items-center justify-center p-4">
<CalculatingDialog {...props} />
</div>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react";
import type { CveExposureSummary } from "@/utils/wire-types.generated";
import { toggleFilter, type ExposureFilter } from "@/lib/vulnerabilityExposure";
import { CalculatingDialog } from "@/components/CalculatingCard";
import ExposureKpiRow from "@/components/vulnerability/ExposureKpiRow";
import SeverityPriorityCard from "@/components/vulnerability/SeverityPriorityCard";
import MostExposedCard from "@/components/vulnerability/MostExposedCard";
@@ -19,14 +20,16 @@ interface Props {
refreshing?: boolean;
/** Header title; the header row renders only with a title or controls. */
title?: string;
/** Elapsed/estimate of the running fetch (useFetchProgress); without it the loading state is plain text. */
progress?: { elapsedMs: number; estimateMs: number };
}
/**
* Read-only fleet/tenant vulnerability exposure: headline KPIs, a severity donut and the
* remediation-priority ladder (both filter the list), and the most exposed products or CVEs.
* Shared by the tenant-facing Software › Vulnerabilities tab and the GA admin section. The
* breakdown counts cover every CVE in the window while the lists are top-N plus every
* known-exploited entry, so a filter can legitimately leave a list empty.
* breakdown counts cover every CVE in the window; the lists hold the entries on the most
* devices, every known-exploited entry and, under a filter, the band's own top entries.
*/
export default function VulnerabilityExposurePanel({
summary,
@@ -37,10 +40,85 @@ export default function VulnerabilityExposurePanel({
onRefresh,
refreshing = false,
title,
progress,
}: Props) {
const [filter, setFilter] = useState<ExposureFilter>(null);
const hasFindings = summary != null && summary.distinctCves > 0;
const showHeader = title != null || onDaysChange != null || onRefresh != null;
const progressPercent = progress ? Math.min(95, (progress.elapsedMs / progress.estimateMs) * 100) : 0;
let body;
if (!hasFindings && loading) {
// Nothing worth keeping on screen: the loading dialog of the other aggregation pages.
body = progress ? (
<div className="flex justify-center py-10" aria-busy="true">
<CalculatingDialog
title="Calculating vulnerability exposure…"
subtitle={
showTenantCount
? "Aggregating the detected CVEs, affected devices and software across all tenants."
: "Aggregating the detected CVEs, affected devices and software."
}
elapsedMs={progress.elapsedMs}
estimateMs={progress.estimateMs}
/>
</div>
) : (
<div className="bg-white rounded-lg shadow p-8 text-center text-sm text-gray-500" aria-busy="true">
Loading…
</div>
);
} else if (!hasFindings) {
body = (
<div className="bg-white rounded-lg shadow p-8 text-center text-sm text-gray-500">
No vulnerability findings in this window. An empty result means no detected CVEs; it does not by itself confirm
the fleet is unaffected, because vulnerability scanning must be enabled.
</div>
);
} else {
// While a new window or scope loads, the previous figures stay (dimmed) so the lens, sort
// and open rows survive the refetch; a slim bar above them shows the fetch is alive.
body = (
<div aria-busy={loading} className="space-y-4">
{loading && progress && (
<div
role="progressbar"
aria-label="Updating vulnerability exposure"
aria-valuenow={Math.round(progressPercent)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1 w-full overflow-hidden rounded-full bg-gray-200"
>
<div
className="h-full rounded-full bg-blue-600 transition-all duration-500 motion-reduce:transition-none"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
<div className={`space-y-4 ${loading ? "opacity-60" : ""}`}>
<ExposureKpiRow summary={summary!} showTenantCount={showTenantCount} />
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-5">
<SeverityPriorityCard
summary={summary!}
filter={filter}
onToggle={(kind, value) => setFilter((prev) => toggleFilter(prev, kind, value))}
/>
<MostExposedCard summary={summary!} filter={filter} onClearFilter={() => setFilter(null)} />
</div>
<p className="text-xs text-gray-500">
CVSS from NVD, confirmed exploitation from the CISA KEV catalog, exploitation probability from FIRST EPSS.
</p>
{summary!.truncated && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Counts are a lower bound: the underlying scan hit its cap. Narrow the window for exact figures.
</p>
)}
</div>
</div>
);
}
return (
<div className="space-y-4">
@@ -74,40 +152,7 @@ export default function VulnerabilityExposurePanel({
</div>
</div>
)}
{loading && summary == null ? (
<div className="bg-white rounded-lg shadow p-8 text-center text-sm text-gray-500">Loading…</div>
) : !hasFindings ? (
<div className="bg-white rounded-lg shadow p-8 text-center text-sm text-gray-500" aria-busy={loading}>
{loading
? "Loading…"
: "No vulnerability findings in this window. An empty result means no detected CVEs; it does not by itself confirm the fleet is unaffected, because vulnerability scanning must be enabled."}
</div>
) : (
// While a new window or scope loads, the previous figures stay (dimmed) so the lens,
// sort and open rows survive the refetch.
<div aria-busy={loading} className={`space-y-4 ${loading ? "opacity-60" : ""}`}>
<ExposureKpiRow summary={summary!} showTenantCount={showTenantCount} />
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-5">
<SeverityPriorityCard
summary={summary!}
filter={filter}
onToggle={(kind, value) => setFilter((prev) => toggleFilter(prev, kind, value))}
/>
<MostExposedCard summary={summary!} filter={filter} onClearFilter={() => setFilter(null)} />
</div>
<p className="text-xs text-gray-500">
CVSS from NVD, confirmed exploitation from the CISA KEV catalog, exploitation probability from FIRST EPSS.
</p>
{summary!.truncated && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Counts are a lower bound: the underlying scan hit its cap. Narrow the window for exact figures.
</p>
)}
</div>
)}
{body}
</div>
);
}
@@ -5,8 +5,10 @@ import type { CveExposureSummary, SoftwareCve, TopCve, TopSoftware } from "@/uti
import { SegmentedControl } from "@/components/SegmentedControl";
import TruncatedLabel from "@/components/TruncatedLabel";
import {
bandCount,
barPercent,
filterLabel,
listPool,
matchesFilter,
sortExposure,
type ExposureFilter,
@@ -44,8 +46,9 @@ function DeviceBar({ value, max, priority }: { value: number; max: number; prior
/**
* Right card of the exposure panel: the most exposed products or CVEs, filtered by the band
* chosen on the left. Both lists come ranked by devices from the backend, which also adds every
* known-exploited entry below the cut, so "Priority first" always shows all of them on top.
* chosen on the left. Both lists come ranked by devices from the backend, which adds every
* known-exploited entry below the cut and, for the band filters, each band's own top entries
* (bandCves / bandSoftware), so a band with entries never shows an empty list.
*/
export default function MostExposedCard({
summary,
@@ -64,13 +67,21 @@ export default function MostExposedCard({
return new Set(first ? [first.product] : []);
});
const software = sortExposure(summary.topSoftware.filter((s) => matchesFilter(s, filter)), sort);
const cves = sortExposure(summary.topCves.filter((c) => matchesFilter(c, filter)), sort);
const software = sortExposure(
listPool(summary.topSoftware, summary.bandSoftware, filter).filter((s) => matchesFilter(s, filter)),
sort
);
const cves = sortExposure(
listPool(summary.topCves, summary.bandCves, filter).filter((c) => matchesFilter(c, filter)),
sort
);
// Band representatives rank below the lists, so the list maxima scale every bar.
const maxSoftware = Math.max(0, ...summary.topSoftware.map((s) => s.affectedSessions));
const maxCves = Math.max(0, ...summary.topCves.map((c) => c.affectedSessions));
const isSoftware = lens === "software";
const rows = isSoftware ? software.length : cves.length;
const label = filterLabel(filter);
const inBand = bandCount(summary, filter);
const toggle = (product: string) =>
setExpanded((prev) => {
@@ -82,15 +93,14 @@ export default function MostExposedCard({
return (
<section aria-label="Most exposed" className="bg-white rounded-lg shadow min-w-0 lg:col-span-3">
<div className="flex flex-wrap items-start justify-between gap-3 px-5 pt-5 pb-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-gray-900">Most exposed</h3>
<p className="mt-0.5 text-xs text-gray-500">
{sort === "priority" ? "Known exploited first, then by affected devices." : "Ranked by affected devices."}
{isSoftware ? " Expand a product to see its most urgent CVEs." : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Controls always sit below the text: a subtitle that changes length must not move them. */}
<div className="px-5 pt-5 pb-3">
<h3 className="text-base font-semibold text-gray-900">Most exposed</h3>
<p className="mt-0.5 text-xs text-gray-500">
{sort === "priority" ? "Known exploited first, then by affected devices." : "Ranked by affected devices."}
{isSoftware ? " Expand a product to see its most urgent CVEs." : ""}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SegmentedControl
options={[
{ value: "software", label: `Software (${software.length})` },
@@ -117,9 +127,7 @@ export default function MostExposedCard({
×
</button>
</span>
<span>
{rows} of {isSoftware ? `${summary.topSoftware.length} products` : `${summary.topCves.length} CVEs`}
</span>
<span>{filterSummary(isSoftware, rows, inBand, label)}</span>
</div>
)}
@@ -129,7 +137,9 @@ export default function MostExposedCard({
</div>
{rows === 0 ? (
<div className="px-5 py-10 text-center text-sm text-gray-500">{emptyMessage(isSoftware, filter, label, summary.distinctCves)}</div>
<div className="px-5 py-10 text-center text-sm text-gray-500">
{emptyMessage(isSoftware, filter, label, inBand, summary.distinctCves)}
</div>
) : isSoftware ? (
<ul className="divide-y divide-gray-200">
{software.map((s) => (
@@ -153,14 +163,29 @@ export default function MostExposedCard({
);
}
/** "7 of 7 Attend CVEs" / "10 of 33 Medium CVEs, those on the most devices" / "3 products". */
function filterSummary(isSoftware: boolean, rows: number, inBand: number | null, label: string | null): string {
if (isSoftware) return `${rows.toLocaleString()} ${rows === 1 ? "product" : "products"}`;
if (inBand == null) return `${rows.toLocaleString()} ${rows === 1 ? "CVE" : "CVEs"}`;
const base = `${rows.toLocaleString()} of ${inBand.toLocaleString()} ${label} ${inBand === 1 ? "CVE" : "CVEs"}`;
return rows < inBand ? `${base}, those on the most devices` : base;
}
/** Why a filtered list is empty: products match by their worst values, CVEs by their own band. */
function emptyMessage(isSoftware: boolean, filter: ExposureFilter, label: string | null, distinctCves: number): string {
function emptyMessage(
isSoftware: boolean,
filter: ExposureFilter,
label: string | null,
inBand: number | null,
distinctCves: number
): string {
if (!filter) return `No ${isSoftware ? "products" : "CVEs"} to list.`;
if (inBand === 0) return `No CVE in this window is ${label}.`;
if (isSoftware) {
const measure = filter.kind === "severity" ? "most severe CVE" : "highest priority";
return `No listed product has ${label} as its ${measure}. The CVEs list shows the listed CVEs in this band.`;
return `No product has ${label} as its ${measure}. The CVEs view lists the ${label} CVEs.`;
}
return `No listed CVE is ${label}. The counts on the left cover all ${distinctCves.toLocaleString()} CVEs; this list holds the CVEs on the most devices plus every known-exploited one.`;
return `No listed CVE is ${label}. The counts on the left cover all ${distinctCves.toLocaleString()} CVEs.`;
}
function SoftwareRow({
@@ -65,13 +65,14 @@ export default function SeverityPriorityCard({
key={band.key}
type="button"
aria-pressed={active}
disabled={n === 0}
onClick={() => onToggle("severity", band.key)}
onMouseEnter={() => setHovered(band.key)}
onMouseLeave={() => setHovered(null)}
onFocus={() => setHovered(band.key)}
onBlur={() => setHovered(null)}
className={`flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 ${
active ? "bg-gray-50 ring-1 ring-gray-300" : "hover:bg-gray-50"
n === 0 ? "cursor-default opacity-50" : active ? "bg-gray-50 ring-1 ring-gray-300" : "hover:bg-gray-50"
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${band.dot}`} aria-hidden="true" />
@@ -101,9 +102,14 @@ export default function SeverityPriorityCard({
key={step.key}
type="button"
aria-pressed={active}
disabled={n === 0}
onClick={() => onToggle("priority", step.key)}
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 ${
active ? "border-gray-400 bg-gray-50 ring-1 ring-gray-400" : "border-gray-200 hover:bg-gray-50"
n === 0
? "cursor-default border-gray-200 opacity-50"
: active
? "border-gray-400 bg-gray-50 ring-1 ring-gray-400"
: "border-gray-200 hover:bg-gray-50"
}`}
>
<span className="w-14 shrink-0">
@@ -1,14 +1,43 @@
import { describe, it, expect } from "vitest";
import {
bandCount,
barPercent,
donutSegments,
filterLabel,
isFilterActive,
listPool,
matchesFilter,
sortExposure,
toggleFilter,
} from "../vulnerabilityExposure";
describe("bandCount", () => {
const breakdowns = {
severityBreakdown: { critical: 5, high: 204, medium: 33, low: 7 },
priorityBreakdown: { act: 6, attend: 7, track: 236 },
};
it("reads the filtered band from the matching breakdown", () => {
expect(bandCount(breakdowns, { kind: "severity", value: "medium" })).toBe(33);
expect(bandCount(breakdowns, { kind: "priority", value: "attend" })).toBe(7);
});
it("is null without a filter or for an unknown band", () => {
expect(bandCount(breakdowns, null)).toBeNull();
expect(bandCount(breakdowns, { kind: "severity", value: "none" })).toBeNull();
});
});
describe("listPool", () => {
it("adds the band representatives only under a filter", () => {
const listed = ["wide"];
const band = ["attend-1", "attend-2"];
expect(listPool(listed, band, null)).toEqual(["wide"]);
expect(listPool(listed, band, { kind: "priority", value: "attend" })).toEqual(["wide", "attend-1", "attend-2"]);
expect(listPool(listed, undefined, { kind: "priority", value: "attend" })).toEqual(["wide"]);
});
});
describe("matchesFilter", () => {
const high = { cvssSeverity: "HIGH", priority: "act" };
+6 -4
View File
@@ -341,10 +341,12 @@ export const api = {
`${API_BASE_URL}/api/metrics/rule-hit-sessions${qs({ ruleId, days: days?.toString(), tenantId })}`,
globalRuleStats: (startDate?: string, endDate?: string, ruleType?: string, tenantId?: string) =>
`${API_BASE_URL}/api/global/metrics/rule-stats${qs({ startDate, endDate, ruleType, tenantId })}`,
vulnerability: (days?: number, topN?: number) =>
`${API_BASE_URL}/api/metrics/vulnerability${qs({ days: days?.toString(), topN: topN?.toString() })}`,
globalVulnerability: (days?: number, topN?: number, tenantId?: string) =>
`${API_BASE_URL}/api/global/metrics/vulnerability${qs({ days: days?.toString(), topN: topN?.toString(), tenantId })}`,
// perBand: per severity/priority band, its top entries outside the lists (bandCves/bandSoftware),
// so the panel's band filters never come up empty while the band has entries.
vulnerability: (days?: number, topN?: number, perBand?: number) =>
`${API_BASE_URL}/api/metrics/vulnerability${qs({ days: days?.toString(), topN: topN?.toString(), perBand: perBand?.toString() })}`,
globalVulnerability: (days?: number, topN?: number, tenantId?: string, perBand?: number) =>
`${API_BASE_URL}/api/global/metrics/vulnerability${qs({ days: days?.toString(), topN: topN?.toString(), tenantId, perBand: perBand?.toString() })}`,
// Tenant-scoped installed-software inventory (JWT-scoped; MemberRead).
softwareInventory: () =>
`${API_BASE_URL}/api/metrics/software-inventory`,
@@ -51,10 +51,10 @@ export const scopedApi = {
? api.metrics.globalGeographic(days, groupBy, globalTenantParam(sel))
: api.metrics.geographic(sel.effectiveTenantId, days, groupBy),
vulnerability: (sel: TenantScopeSelection, days: number, topN: number) =>
vulnerability: (sel: TenantScopeSelection, days: number, topN: number, perBand?: number) =>
sel.routeGlobal
? api.metrics.globalVulnerability(days, topN, globalTenantParam(sel))
: api.metrics.vulnerability(days, topN),
? api.metrics.globalVulnerability(days, topN, globalTenantParam(sel), perBand)
: api.metrics.vulnerability(days, topN, perBand),
appsList: (sel: TenantScopeSelection, days: number) =>
sel.routeGlobal ? api.apps.globalList(days, globalTenantParam(sel)) : api.apps.list(sel.effectiveTenantId, days),
@@ -38,6 +38,31 @@ export function isFilterActive(filter: ExposureFilter, kind: "severity" | "prior
return filter != null && filter.kind === kind && filter.value === value;
}
/**
* How many distinct CVEs the window holds in the filtered band, from the summary's breakdowns
* (null without a filter or for a band the breakdowns do not know).
*/
export function bandCount(
breakdowns: {
severityBreakdown: { critical: number; high: number; medium: number; low: number };
priorityBreakdown: { act: number; attend: number; track: number };
},
filter: ExposureFilter
): number | null {
if (!filter) return null;
const counts: Record<string, number> =
filter.kind === "severity" ? { ...breakdowns.severityBreakdown } : { ...breakdowns.priorityBreakdown };
return Object.prototype.hasOwnProperty.call(counts, filter.value) ? counts[filter.value] : null;
}
/**
* The entries a list shows: without a filter its own list; under a filter also the per-band
* representatives (bandCves / bandSoftware), so a band with entries never shows an empty list.
*/
export function listPool<T>(listed: readonly T[], band: readonly T[] | undefined, filter: ExposureFilter): readonly T[] {
return filter && band && band.length > 0 ? [...listed, ...band] : listed;
}
/** Human label of the active filter ("High", "Act"), or null without one. */
export function filterLabel(filter: ExposureFilter): string | null {
if (!filter) return null;
@@ -6369,6 +6369,30 @@
},
"doc": "The top min(topN, 20) products by affected sessions, plus the products with a KEV CVE that rank below them (at most 10 extra), ordered by affected sessions."
},
{
"name": "bandCves",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "TopCve"
}
},
"optional": true,
"doc": "For list filters: per CVSS severity band and per priority band, the band's top CVEs by affected sessions that TopCves does not already hold (at most perBand per band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise."
},
{
"name": "bandSoftware",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "TopSoftware"
}
},
"optional": true,
"doc": "The same for products, banded by their most severe CVE and their highest priority, minus the entries of TopSoftware. Only on request; null (key omitted) otherwise."
},
{
"name": "truncated",
"type": {
@@ -1258,6 +1258,10 @@ export interface CveExposureSummary {
topCves: TopCve[];
/** The top min(topN, 20) products by affected sessions, plus the products with a KEV CVE that rank below them (at most 10 extra), ordered by affected sessions. */
topSoftware: TopSoftware[];
/** For list filters: per CVSS severity band and per priority band, the band's top CVEs by affected sessions that TopCves does not already hold (at most perBand per band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise. */
bandCves?: TopCve[];
/** The same for products, banded by their most severe CVE and their highest priority, minus the entries of TopSoftware. Only on request; null (key omitted) otherwise. */
bandSoftware?: TopSoftware[];
truncated: boolean;
}