diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs index 9dc507f8..35058adf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs @@ -337,6 +337,61 @@ public class VulnerabilitySummaryAggregatorTests Assert.Equal(3, r.DistinctSoftware); } + [Fact] + public void BandCves_AddEachBandsTopEntriesOutsideTheList_onlyWhenAsked() + { + var rows = new List + { + 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")] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs index 34f53ee2..75c7a858 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs @@ -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 { "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 { "Contoso Viewer 1.0" }, + }; + var topCves = new List { wide }; + var topSoftware = new List(); + var bandCves = new List { attend }; + var bandSoftware = new List(); + + 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() { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs index 81acfeca..55f67c98 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs @@ -38,6 +38,12 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability /// Raw titles carried per product entry. public const int MaxTitlesPerSoftware = 3; + /// + /// Upper bound of the per-band representatives (). + /// Only the portal asks for them; the MCP tool never does, so its response stays within its cap. + /// + public const int MaxPerBand = 25; + private static readonly HashSet 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, + }; + + /// + /// When above 0, the summary also carries and + /// : per severity and priority band its top + /// entries outside the lists, so a band filter never comes up empty while the band has entries. + /// public static CveExposureSummary Aggregate( IReadOnlyList 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? bandCves = null; + List? 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(), + }; + + /// + /// For every band of every banding (severity, priority): the band's first + /// entries of the ranked list, minus the entries already + /// . Entries without a band add nothing; the ranked order is kept. + /// + internal static List BandRepresentatives( + IReadOnlyList ranked, + IEnumerable listed, + int perBand, + params Func[] bandings) where T : class + { + var alreadyListed = new HashSet(listed, ReferenceEqualityComparer.Instance); + var picked = new SortedSet(); + foreach (var bandOf in bandings) + { + var taken = new Dictionary(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(); + } + /// /// The first entries of a ranked list plus the KEV entries below the /// cut (at most ). The input order is kept, so a list ranked by diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs index e930d922..0971a4f8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs @@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability } /// - /// 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). /// [Function("GetVulnerabilitySummary")] @@ -47,7 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability } /// - /// 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. /// [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); diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index cdf13ba4..7d6514ac 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -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; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs index 47900a59..f056cab1 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs @@ -358,6 +358,19 @@ namespace AutopilotMonitor.Shared.Models /// public IReadOnlyList TopSoftware { get; set; } = default!; + /// + /// For list filters: per CVSS severity band and per priority band, the band's top CVEs by + /// affected sessions that does not already hold (at most perBand per + /// band). Only on request (perBand above 0, which the portal sends); null (key omitted) otherwise. + /// + public IReadOnlyList? BandCves { get; set; } + + /// + /// The same for products, banded by their most severe CVE and their highest priority, minus + /// the entries of . Only on request; null (key omitted) otherwise. + /// + public IReadOnlyList? BandSoftware { get; set; } + public bool Truncated { get; set; } } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx index 77f1bc4c..9ea2e871 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx @@ -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(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(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(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({ { 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(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 ; + return ( + + ); } diff --git a/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx b/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx index e6e64efd..ea1be75e 100644 --- a/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx +++ b/src/Web/autopilot-monitor-web/components/CalculatingCard.tsx @@ -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 ( -
-
-
-
-

{title}

-

{subtitle}

- -
+
+
+
+

{title}

+

{subtitle}

+
); } + +export function CalculatingCard(props: CalculatingDialogProps) { + return ( +
+ +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx index 83fd7c86..ba0fdb00 100644 --- a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx +++ b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx @@ -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(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 ? ( +
+ +
+ ) : ( +
+ Loading… +
+ ); + } else if (!hasFindings) { + body = ( +
+ 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. +
+ ); + } 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 = ( +
+ {loading && progress && ( +
+
+
+ )} +
+ + +
+ setFilter((prev) => toggleFilter(prev, kind, value))} + /> + setFilter(null)} /> +
+ +

+ CVSS from NVD, confirmed exploitation from the CISA KEV catalog, exploitation probability from FIRST EPSS. +

+ {summary!.truncated && ( +

+ Counts are a lower bound: the underlying scan hit its cap. Narrow the window for exact figures. +

+ )} +
+
+ ); + } return (
@@ -74,40 +152,7 @@ export default function VulnerabilityExposurePanel({
)} - - {loading && summary == null ? ( -
Loading…
- ) : !hasFindings ? ( -
- {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."} -
- ) : ( - // While a new window or scope loads, the previous figures stay (dimmed) so the lens, - // sort and open rows survive the refetch. -
- - -
- setFilter((prev) => toggleFilter(prev, kind, value))} - /> - setFilter(null)} /> -
- -

- CVSS from NVD, confirmed exploitation from the CISA KEV catalog, exploitation probability from FIRST EPSS. -

- {summary!.truncated && ( -

- Counts are a lower bound: the underlying scan hit its cap. Narrow the window for exact figures. -

- )} -
- )} + {body}
); } diff --git a/src/Web/autopilot-monitor-web/components/vulnerability/MostExposedCard.tsx b/src/Web/autopilot-monitor-web/components/vulnerability/MostExposedCard.tsx index deb967e6..898b7007 100644 --- a/src/Web/autopilot-monitor-web/components/vulnerability/MostExposedCard.tsx +++ b/src/Web/autopilot-monitor-web/components/vulnerability/MostExposedCard.tsx @@ -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 (
-
-
-

Most exposed

-

- {sort === "priority" ? "Known exploited first, then by affected devices." : "Ranked by affected devices."} - {isSoftware ? " Expand a product to see its most urgent CVEs." : ""} -

-
-
+ {/* Controls always sit below the text: a subtitle that changes length must not move them. */} +
+

Most exposed

+

+ {sort === "priority" ? "Known exploited first, then by affected devices." : "Ranked by affected devices."} + {isSoftware ? " Expand a product to see its most urgent CVEs." : ""} +

+
- - {rows} of {isSoftware ? `${summary.topSoftware.length} products` : `${summary.topCves.length} CVEs`} - + {filterSummary(isSoftware, rows, inBand, label)}
)} @@ -129,7 +137,9 @@ export default function MostExposedCard({
{rows === 0 ? ( -
{emptyMessage(isSoftware, filter, label, summary.distinctCves)}
+
+ {emptyMessage(isSoftware, filter, label, inBand, summary.distinctCves)} +
) : isSoftware ? (
    {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({ diff --git a/src/Web/autopilot-monitor-web/components/vulnerability/SeverityPriorityCard.tsx b/src/Web/autopilot-monitor-web/components/vulnerability/SeverityPriorityCard.tsx index 7d238245..4c1db489 100644 --- a/src/Web/autopilot-monitor-web/components/vulnerability/SeverityPriorityCard.tsx +++ b/src/Web/autopilot-monitor-web/components/vulnerability/SeverityPriorityCard.tsx @@ -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" }`} >