From dd5534b0c9bb6f5d550aa9e80032eebf9c0103e2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 1 Oct 2026 19:35:09 +0200 Subject: [PATCH] Vulnerabilities tab shows a severity donut, the priority ladder and the most exposed software The exposure summary groups CVE index rows by product: every new row carries the finding's CPE vendor:product as Product, and a row without it borrows the product of a row with the same title. The summary adds topSoftware, distinctSoftware and kevAffectedSessions, and known-exploited CVEs and products stay listed below the topN cut (capped so the MCP result stays under its 50k cap). The shared panel is rebuilt: KPI tiles, a severity donut and the Act/Attend/Track ladder as list filters, and a Most exposed card with Software and CVEs lenses. get_vulnerability_summary describes the new fields. D-307. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CpeProductKeyTests.cs | 24 ++ .../CveIndexEntityMergeTests.cs | 18 + .../VulnerabilitySummaryAggregatorTests.cs | 187 ++++++++++- .../VulnerabilityWireParityTests.cs | 59 +++- .../VulnerabilitySummaryAggregator.cs | 309 +++++++++++++++--- .../Services/TableStorageService.AgentApi.cs | 11 +- .../Services/Vulnerability/CpeMatchScorer.cs | 17 + .../src/generated/wire-types.generated.ts | 38 +++ .../autopilot-monitor-mcp/src/tools/admin.ts | 12 +- .../Models/Vulnerability/CveExposureEntry.cs | 2 + .../Models/VulnerabilityApiModels.cs | 70 ++++ .../components/VulnerabilityExposurePanel.tsx | 249 +++----------- .../vulnerability/ExposureKpiRow.tsx | 137 ++++++++ .../vulnerability/MostExposedCard.tsx | 291 +++++++++++++++++ .../vulnerability/SeverityDonut.tsx | 87 +++++ .../vulnerability/SeverityPriorityCard.tsx | 134 ++++++++ .../vulnerability/VulnerabilityChips.tsx | 65 ++++ .../__tests__/vulnerabilityExposure.test.ts | 105 ++++++ .../__tests__/vulnerabilitySeverity.test.ts | 39 ++- .../lib/vulnerabilityExposure.ts | 99 ++++++ .../lib/vulnerabilitySeverity.ts | 23 +- .../utils/shared-manifests.json | 174 +++++++++- .../utils/wire-types.generated.ts | 38 +++ 23 files changed, 1924 insertions(+), 264 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/CpeProductKeyTests.cs create mode 100644 src/Web/autopilot-monitor-web/components/vulnerability/ExposureKpiRow.tsx create mode 100644 src/Web/autopilot-monitor-web/components/vulnerability/MostExposedCard.tsx create mode 100644 src/Web/autopilot-monitor-web/components/vulnerability/SeverityDonut.tsx create mode 100644 src/Web/autopilot-monitor-web/components/vulnerability/SeverityPriorityCard.tsx create mode 100644 src/Web/autopilot-monitor-web/components/vulnerability/VulnerabilityChips.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/vulnerabilityExposure.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/vulnerabilityExposure.ts diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CpeProductKeyTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CpeProductKeyTests.cs new file mode 100644 index 00000000..a359df0c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CpeProductKeyTests.cs @@ -0,0 +1,24 @@ +using AutopilotMonitor.Functions.Services.Vulnerability; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// turns a finding's CPE ("base CPE:installed version") +/// into the product identity the exposure summary groups by. +/// +public class CpeProductKeyTests +{ + [Theory] + [InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")] + [InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version + [InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")] + [InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full + [InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key + [InlineData("cpe:2.3:a:*:*:1.0", "")] + [InlineData("cpe:2.3:a", "")] + [InlineData("", "")] + [InlineData(null, "")] + public void ProductKey_IsLowerCaseVendorAndProduct(string? cpe, string expected) + => Assert.Equal(expected, CpeMatchScorer.ProductKey(cpe)); +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs index eaf81799..0e2977ab 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CveIndexEntityMergeTests.cs @@ -122,4 +122,22 @@ public class CveIndexEntityMergeTests Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings)); } + + [Fact] + public void Product_IsTheCpeVendorAndProduct_ofTheWinningOccurrence() + { + var zip = Finding("7-Zip 24.08 (x64 edition)", "high", Vuln("CVE-1", 7.8, "HIGH"), Vuln("CVE-2", 3.3, "LOW")); + zip["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:24.08"; + var teams = Finding("Microsoft Teams", "high", Vuln("CVE-1", 8.8, "HIGH")); + teams["cpeUri"] = "cpe:2.3:a:microsoft:teams:1.6.00.4472"; + var noCpe = Finding("Contoso Tool", "high", Vuln("CVE-3", 7.0, "HIGH")); + + var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, new List> { zip, teams, noCpe }); + + var cve1 = entities.Single(x => x.GetString("CveId") == "CVE-1"); + Assert.Equal("Microsoft Teams", cve1.GetString("SoftwareName")); // name and product move together + Assert.Equal("microsoft:teams", cve1.GetString("Product")); + Assert.Equal("7-zip:7-zip", entities.Single(x => x.GetString("CveId") == "CVE-2").GetString("Product")); + Assert.Equal("", entities.Single(x => x.GetString("CveId") == "CVE-3").GetString("Product")); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs index 30666e9e..9dc507f8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilitySummaryAggregatorTests.cs @@ -14,13 +14,14 @@ public class VulnerabilitySummaryAggregatorTests private static CveExposureEntry Row( string cve, string session, string severity, double score, bool kev = false, string tenant = "t1", string software = "Acme App", - DateTime? detectedAt = null) + DateTime? detectedAt = null, string product = "") => new() { TenantId = tenant, CveId = cve, SessionId = session, SoftwareName = software, + Product = product, CvssScore = score, CvssSeverity = severity, IsKev = kev, @@ -168,6 +169,190 @@ public class VulnerabilitySummaryAggregatorTests Assert.True(r.Truncated); Assert.Equal(365, r.WindowDays); // clamped from 9999 Assert.Empty(r.TopCves); + Assert.Empty(r.TopSoftware); Assert.Equal(0, r.TotalAffectedSessions); + Assert.Equal(0, r.DistinctSoftware); + Assert.Equal(0, r.KevAffectedSessions); } + + [Fact] + public void TopCves_AppendKevCvesBelowTheCut_inDeviceOrder() + { + var rows = new[] + { + Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0), Row("CVE-WIDE", "s3", "High", 7.0), + Row("CVE-MID", "s1", "High", 8.0), Row("CVE-MID", "s2", "High", 8.0), + Row("CVE-KEV", "s4", "High", 7.8, kev: true), + }; + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now); + + Assert.Equal(new[] { "CVE-WIDE", "CVE-KEV" }, r.TopCves.Select(c => c.CveId)); + } + + [Fact] + public void KevAdditions_AreCapped() + { + var rows = new List { Row("CVE-WIDE", "s1", "High", 7.0), Row("CVE-WIDE", "s2", "High", 7.0) }; + rows.AddRange(Enumerable.Range(0, VulnerabilitySummaryAggregator.MaxKevAdditions + 10) + .Select(i => Row($"CVE-KEV-{i:D3}", $"k{i}", "High", 7.5, kev: true))); + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now); + + Assert.Equal(1 + VulnerabilitySummaryAggregator.MaxKevAdditions, r.TopCves.Count); + Assert.Equal("CVE-WIDE", r.TopCves[0].CveId); + Assert.All(r.TopCves.Skip(1), c => Assert.True(c.IsKev)); + } + + [Fact] + public void KevAffectedSessions_CountsDistinctSessionsWithAKevCve() + { + var rows = new[] + { + Row("CVE-K1", "s1", "High", 8.8, kev: true), + Row("CVE-K2", "s1", "High", 8.1, kev: true), // same session, counted once + Row("CVE-K2", "s2", "High", 8.1, kev: true), + Row("CVE-N", "s3", "High", 7.0), + }; + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now); + + Assert.Equal(2, r.KevAffectedSessions); + Assert.Equal(3, r.TotalAffectedSessions); + } + + [Fact] + public void TopSoftware_FoldsAProductAcrossTitlesAndCves() + { + const string zip = "7-zip:7-zip"; + var rows = new[] + { + Row("CVE-A", "s1", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip), + Row("CVE-B", "s1", "LOW", 3.3, software: "7-Zip 24.08 (x64 edition)", product: zip), + Row("CVE-A", "s2", "HIGH", 7.8, software: "7-Zip 25.01 (x64)", product: zip), + Row("CVE-A", "s3", "HIGH", 7.8, software: "7-Zip 24.08 (x64 edition)", product: zip), + Row("CVE-T", "s4", "MEDIUM", 5.0, software: "Microsoft Teams", product: "microsoft:teams"), + }; + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now); + + Assert.Equal(2, r.DistinctSoftware); + var p = r.TopSoftware[0]; + Assert.Equal(zip, p.Product); + Assert.Equal("7-Zip", p.Name); + Assert.Equal(3, p.AffectedSessions); + Assert.Equal(2, p.CveCount); + Assert.Equal(0, p.KevCount); + Assert.Equal("HIGH", p.CvssSeverity); + Assert.Equal(7.8, p.CvssScore); + Assert.Equal(new[] { "7-Zip 24.08 (x64 edition)", "7-Zip 25.01 (x64)" }, p.Titles); // most sessions first + var a = p.Cves.Single(c => c.CveId == "CVE-A"); + Assert.Equal(3, a.AffectedSessions); + Assert.Equal(1, p.Cves.Single(c => c.CveId == "CVE-B").AffectedSessions); + } + + [Fact] + public void TopSoftware_RowWithoutProductBorrowsItFromTheSameTitle_elseUsesTheTitle() + { + var rows = new[] + { + Row("CVE-A", "s1", "High", 7.8, software: "7-Zip 24.08 (x64 edition)", product: "7-zip:7-zip"), + Row("CVE-A", "s2", "High", 7.8, software: "7-zip 24.08 (X64 Edition)"), // legacy row, same title + Row("CVE-C", "s3", "High", 7.1, software: "Contoso Tool 1.0"), // legacy row, title never seen with a product + Row("CVE-D", "s4", "High", 7.1, software: ""), // neither: left out of the product view + }; + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now); + + Assert.Equal(2, r.DistinctSoftware); + var zip = r.TopSoftware.Single(s => s.Product == "7-zip:7-zip"); + Assert.Equal(2, zip.AffectedSessions); + var contoso = r.TopSoftware.Single(s => s.Product == "Contoso Tool 1.0"); + Assert.Equal("Contoso Tool", contoso.Name); + Assert.Equal(3, r.DistinctCves); // the row without a title still counts as a CVE + } + + [Fact] + public void TopSoftware_CarriesTheMostUrgentCvesFirst_cappedWithTheFullCount() + { + const string chrome = "google:chrome"; + var act = Row("CVE-ACT", "s1", "High", 8.1, kev: true, software: "Google Chrome", product: chrome); act.Priority = "act"; + var attendEpss = Row("CVE-ATT-E", "s1", "Medium", 6.5, software: "Google Chrome", product: chrome); attendEpss.Priority = "attend"; attendEpss.EpssScore = 0.4; + var attendCvss = Row("CVE-ATT-C", "s1", "Critical", 9.6, software: "Google Chrome", product: chrome); attendCvss.Priority = "attend"; attendCvss.EpssScore = 0.01; + var rows = new List { act, attendEpss, attendCvss }; + for (var i = 0; i < 5; i++) + { + var track = Row($"CVE-TRK-{i}", "s1", "High", 7.0 + i / 10.0, software: "Google Chrome", product: chrome); + track.Priority = "track"; + rows.Add(track); + } + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, 20, 30, false, Now); + + var p = Assert.Single(r.TopSoftware); + Assert.Equal(8, p.CveCount); + Assert.Equal(1, p.KevCount); + Assert.Equal("act", p.Priority); + Assert.Equal("Critical", p.CvssSeverity); // worst band, with the score of that CVE + Assert.Equal(9.6, p.CvssScore); + Assert.Equal(VulnerabilitySummaryAggregator.MaxCvesPerSoftware, p.Cves.Count); + Assert.Equal(new[] { "CVE-ACT", "CVE-ATT-E", "CVE-ATT-C" }, p.Cves.Select(c => c.CveId)); // act, then EPSS before CVSS + } + + [Fact] + public void TopSoftware_IsCappedIndependentlyOfTopN_plusCappedKevProducts() + { + var rows = new List(); + for (var p = 0; p < VulnerabilitySummaryAggregator.MaxSoftware + 5; p++) + for (var s = 0; s <= p; s++) // product p has p + 1 sessions + rows.Add(Row($"CVE-P{p}", $"p{p}-s{s}", "High", 7.0, software: $"Product {p}", product: $"acme:p{p}")); + for (var k = 0; k < VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2; k++) + rows.Add(Row($"CVE-K{k}", $"k{k}", "High", 7.8, kev: true, software: $"Kev Product {k}", product: $"acme:k{k}")); + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 100, days: 30, crossTenant: false, nowUtc: Now); + + Assert.Equal( + VulnerabilitySummaryAggregator.MaxSoftware + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions, + r.TopSoftware.Count); + Assert.Equal("acme:p24", r.TopSoftware[0].Product); // most sessions first + Assert.All(r.TopSoftware.Skip(VulnerabilitySummaryAggregator.MaxSoftware), s => Assert.True(s.KevCount > 0)); + Assert.Equal(VulnerabilitySummaryAggregator.MaxSoftware + 5 + VulnerabilitySummaryAggregator.MaxKevSoftwareAdditions + 2, r.DistinctSoftware); + } + + [Fact] + public void TopSoftware_AppendsProductsWithAKevCveBelowTheCut() + { + var rows = new[] + { + Row("CVE-W", "s1", "High", 7.0, software: "Wide App", product: "acme:wide"), + Row("CVE-W", "s2", "High", 7.0, software: "Wide App", product: "acme:wide"), + Row("CVE-M", "s3", "High", 9.0, software: "Mid App", product: "acme:mid"), + Row("CVE-K", "s4", "High", 7.8, kev: true, software: "WinRAR 6.22 (64-bit)", product: "rarlab:winrar"), + }; + + var r = VulnerabilitySummaryAggregator.Aggregate(rows, false, topN: 1, days: 30, crossTenant: false, nowUtc: Now); + + Assert.Equal(new[] { "acme:wide", "rarlab:winrar" }, r.TopSoftware.Select(s => s.Product)); + Assert.Equal("WinRAR", r.TopSoftware[1].Name); + Assert.Equal(3, r.DistinctSoftware); + } + + [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")] + [InlineData("Microsoft Teams", "Microsoft Teams", "MSTeams", "Teams Machine-Wide Installer")] + [InlineData("Microsoft Teams", "Microsoft Teams", "Microsoft 365 Apps")] // one shared word is not a name + [InlineData("WinRAR", "WinRAR 6.22 (64-bit)")] + [InlineData("Mozilla Firefox", "Mozilla Firefox (x64 en-US)")] + [InlineData("Google Chrome", "Google Chrome 127.0.6533.74")] + [InlineData("Microsoft Office 2016", "Microsoft Office 2016")] // a bare year or major version stays + [InlineData("PowerShell 7-x64", "PowerShell 7-x64")] + [InlineData("Contoso Agent", "Contoso Agent v2.4.1 x64")] + [InlineData("1.2.3", "1.2.3")] // never stripped to nothing + public void DisplayName_UsesSharedWordsOrTheMostCommonTitle_withoutVersionAndArchitecture(string expected, params string[] titles) + => Assert.Equal(expected, VulnerabilitySummaryAggregator.DisplayName(titles)); + + [Fact] + public void DisplayName_OfNoTitlesIsEmpty() + => Assert.Equal("", VulnerabilitySummaryAggregator.DisplayName(Array.Empty())); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs index af56ad31..34f53ee2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityWireParityTests.cs @@ -655,12 +655,12 @@ public class VulnerabilityWireParityTests } // ---- GetVulnerabilitySummary / GetGlobalVulnerabilitySummary ------------------------- - // The endpoint always serialized the named CveExposureSummary record (never anonymous); - // it only gained the IApiResponse marker. The anonymous side here replicates the record's - // established wire shape to pin key names, order and the TotalAffectedTenants omission. + // The endpoint always serialized the named CveExposureSummary record (never anonymous). + // The anonymous side pins the wire shape: key names, order and the TotalAffectedTenants / + // EpssScore omission. D-307 added distinctSoftware, kevAffectedSessions and topSoftware. [Fact] - public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker() + public void CveExposureSummary_pins_the_cross_tenant_wire_shape() { var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 }; var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 }; @@ -686,10 +686,32 @@ public class VulnerabilityWireParityTests totalAffectedSessions = 11, totalAffectedTenants = (int?)3, distinctCves = 3, + distinctSoftware = 2, kevCves = 1, + kevAffectedSessions = 7, severityBreakdown = breakdown, priorityBreakdown = priorities, topCves, + topSoftware = new[] + { + new + { + product = "7-zip:7-zip", + name = "7-Zip", + affectedSessions = 7, + cveCount = 2, + kevCount = 1, + cvssSeverity = "critical", + cvssScore = 9.8, + priority = "act", + titles = new[] { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" }, + cves = new object[] + { + new { cveId = "CVE-2026-12345", cvssSeverity = "critical", cvssScore = 9.8, isKev = true, epssScore = (double?)0.94, priority = "act", affectedSessions = 7 }, + new { cveId = "CVE-2026-23456", cvssSeverity = "low", cvssScore = 3.3, isKev = false, epssScore = (double?)null, priority = "track", affectedSessions = 2 }, + }, + }, + }, truncated = false, }, new CveExposureSummary @@ -698,10 +720,32 @@ public class VulnerabilityWireParityTests TotalAffectedSessions = 11, TotalAffectedTenants = 3, DistinctCves = 3, + DistinctSoftware = 2, KevCves = 1, + KevAffectedSessions = 7, SeverityBreakdown = breakdown, PriorityBreakdown = priorities, TopCves = topCves, + TopSoftware = new List + { + new TopSoftware + { + Product = "7-zip:7-zip", + Name = "7-Zip", + AffectedSessions = 7, + CveCount = 2, + KevCount = 1, + CvssSeverity = "critical", + CvssScore = 9.8, + Priority = "act", + Titles = new List { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" }, + Cves = new List + { + new SoftwareCve { CveId = "CVE-2026-12345", CvssSeverity = "critical", CvssScore = 9.8, IsKev = true, EpssScore = 0.94, Priority = "act", AffectedSessions = 7 }, + new SoftwareCve { CveId = "CVE-2026-23456", CvssSeverity = "low", CvssScore = 3.3, IsKev = false, EpssScore = null, Priority = "track", AffectedSessions = 2 }, + }, + }, + }, Truncated = false, }); } @@ -712,6 +756,7 @@ public class VulnerabilityWireParityTests var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 }; var priorities = new PriorityBreakdown { Act = 0, Attend = 0, Track = 0 }; var topCves = new List(); + var topSoftware = new List(); AssertParity( new @@ -720,10 +765,13 @@ public class VulnerabilityWireParityTests totalAffectedSessions = 0, totalAffectedTenants = (int?)null, distinctCves = 0, + distinctSoftware = 0, kevCves = 0, + kevAffectedSessions = 0, severityBreakdown = breakdown, priorityBreakdown = priorities, topCves, + topSoftware, truncated = true, }, new CveExposureSummary @@ -732,10 +780,13 @@ public class VulnerabilityWireParityTests TotalAffectedSessions = 0, TotalAffectedTenants = null, DistinctCves = 0, + DistinctSoftware = 0, KevCves = 0, + KevAffectedSessions = 0, SeverityBreakdown = breakdown, PriorityBreakdown = priorities, TopCves = topCves, + TopSoftware = topSoftware, Truncated = true, }); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs index 2a29e100..81acfeca 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryAggregator.cs @@ -7,7 +7,7 @@ using AutopilotMonitor.Shared.Models.Vulnerability; namespace AutopilotMonitor.Functions.Functions.Vulnerability { - // The wire types (CveExposureSummary, SeverityBreakdown, TopCve) live in + // The wire types (CveExposureSummary, SeverityBreakdown, TopCve, TopSoftware) live in // AutopilotMonitor.Shared.Models/VulnerabilityApiModels.cs so the manifest export sees them. /// @@ -17,6 +17,32 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability /// public static class VulnerabilitySummaryAggregator { + // The caps keep the response under the MCP tool's 50k-character result cap at topN = 100 + // (a TopCve is ~210 characters, a TopSoftware entry ~800); the MCP passes it through verbatim. + + /// + /// Cap on the KEV CVEs appended below the topN cut. Known-exploited CVEs are "patch first" + /// whatever their device count, so a list ranked by devices must not drop them. + /// + public const int MaxKevAdditions = 25; + + /// Products ranked by devices, before the KEV additions (the list is min(topN, this) long). + public const int MaxSoftware = 20; + + /// Cap on the products with a KEV CVE appended below the product cut. + public const int MaxKevSoftwareAdditions = 10; + + /// CVEs carried per product entry; keeps the full count. + public const int MaxCvesPerSoftware = 3; + + /// Raw titles carried per product entry. + public const int MaxTitlesPerSoftware = 3; + + private static readonly HashSet ArchitectureTokens = new(StringComparer.OrdinalIgnoreCase) + { + "x64", "x86", "amd64", "arm64", "64-bit", "32-bit", + }; + private static int SeverityRank(string? severity) => severity?.Trim().ToLowerInvariant() switch { "critical" => 4, @@ -44,41 +70,20 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability var inWindow = rows .Where(r => !r.DetectedAt.HasValue || r.DetectedAt.Value >= cutoff) .ToList(); + var withCve = inWindow.Where(r => !string.IsNullOrEmpty(r.CveId)).ToList(); - var groups = inWindow + var cves = withCve .GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase) - .Where(g => !string.IsNullOrEmpty(g.Key)) - .Select(g => - { - var topSeverityRow = g.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First(); - return new - { - CveId = g.Key, - Severity = topSeverityRow.CvssSeverity, - SeverityRank = SeverityRank(topSeverityRow.CvssSeverity), - CvssScore = g.Max(r => r.CvssScore), - IsKev = g.Any(r => r.IsKev), - // Max over a nullable ignores nulls and yields null when nothing is scored. - EpssScore = g.Max(r => r.EpssScore), - Priority = g.Select(r => r.Priority) - .Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best), - AffectedSessions = g.Select(r => r.SessionId) - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct(StringComparer.OrdinalIgnoreCase).Count(), - SampleSoftware = g.Select(r => r.SoftwareName) - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) - .Take(3).ToList(), - }; - }) + .Select(g => FoldCve(g.Key, g.ToList())) .ToList(); - var topCves = groups + var rankedCves = cves .OrderByDescending(c => c.AffectedSessions) .ThenByDescending(c => c.CvssScore) .ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase) - .Take(topN) + .ToList(); + + var topCves = WithKevAdditions(rankedCves, topN, c => c.IsKev, MaxKevAdditions) .Select(c => new TopCve { CveId = c.CveId, @@ -88,29 +93,40 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability EpssScore = c.EpssScore, Priority = c.Priority, AffectedSessions = c.AffectedSessions, - SampleSoftware = c.SampleSoftware, + 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 software = FoldSoftware(withCve); + var rankedSoftware = software + .OrderByDescending(s => s.AffectedSessions) + .ThenByDescending(s => CvePriority.Rank(s.Priority)) + .ThenByDescending(s => s.CvssScore) + .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(s => s.Product, StringComparer.OrdinalIgnoreCase) + .ToList(); + var topSoftware = WithKevAdditions( + rankedSoftware, Math.Min(topN, MaxSoftware), s => s.KevCount > 0, MaxKevSoftwareAdditions); + var breakdown = new SeverityBreakdown { - Critical = groups.Count(c => c.SeverityRank == 4), - High = groups.Count(c => c.SeverityRank == 3), - Medium = groups.Count(c => c.SeverityRank == 2), - Low = groups.Count(c => c.SeverityRank == 1), + Critical = cves.Count(c => c.SeverityRank == 4), + High = cves.Count(c => c.SeverityRank == 3), + Medium = cves.Count(c => c.SeverityRank == 2), + Low = cves.Count(c => c.SeverityRank == 1), }; var priorities = new PriorityBreakdown { - Act = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)), - Attend = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)), - Track = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)), + Act = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)), + Attend = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)), + Track = cves.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)), }; - var totalAffectedSessions = inWindow.Select(r => r.SessionId) - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct(StringComparer.OrdinalIgnoreCase).Count(); - int? totalAffectedTenants = crossTenant ? inWindow.Select(r => r.TenantId) .Where(t => !string.IsNullOrEmpty(t)) @@ -120,15 +136,220 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability return new CveExposureSummary { WindowDays = windowDays, - TotalAffectedSessions = totalAffectedSessions, + TotalAffectedSessions = DistinctSessions(inWindow), TotalAffectedTenants = totalAffectedTenants, - DistinctCves = groups.Count, - KevCves = groups.Count(c => c.IsKev), + DistinctCves = cves.Count, + DistinctSoftware = software.Count, + KevCves = cves.Count(c => c.IsKev), + KevAffectedSessions = DistinctSessions(withCve.Where(r => r.IsKev)), SeverityBreakdown = breakdown, PriorityBreakdown = priorities, TopCves = topCves, + TopSoftware = topSoftware, Truncated = truncated, }; } + + /// + /// 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 + /// affected sessions stays ranked. + /// + internal static List WithKevAdditions(IReadOnlyList ranked, int topN, Func isKev, int maxAdditions) + => ranked.Take(topN) + .Concat(ranked.Skip(topN).Where(isKev).Take(maxAdditions)) + .ToList(); + + /// + /// Display name for a product from its detected titles, most sessions first: the leading + /// words every title shares when that is at least two words, else the most common title. + /// A trailing version or architecture suffix is dropped ("7-Zip 24.08 (x64 edition)" → + /// "7-Zip"); a name that would end up empty keeps its title. + /// + internal static string DisplayName(IReadOnlyList titles) + { + var cleaned = titles.Select(t => t?.Trim() ?? string.Empty).Where(t => t.Length > 0).ToList(); + if (cleaned.Count == 0) return string.Empty; + + var words = cleaned.Select(t => t.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToList(); + var shared = 0; + while (shared < words[0].Length + && words.All(w => w.Length > shared && string.Equals(w[shared], words[0][shared], StringComparison.OrdinalIgnoreCase))) + shared++; + + var prefix = TrimTrailingSeparators(words[0].Take(shared).ToList()); + var baseName = cleaned.Count > 1 && prefix.Count >= 2 ? string.Join(" ", prefix) : cleaned[0]; + var stripped = StripVersionSuffix(baseName); + return stripped.Length > 0 ? stripped : baseName; + } + + private static string StripVersionSuffix(string name) + { + var current = name.Trim(); + while (true) + { + var before = current; + + // A trailing parenthetical: "(x64 edition)", "(64-bit)", "(x64 en-US)". + if (current.EndsWith(")", StringComparison.Ordinal)) + { + var open = current.LastIndexOf('('); + if (open > 0) current = current.Substring(0, open).TrimEnd(); + } + + var tokens = current.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).ToList(); + if (tokens.Count > 1 && (IsVersionToken(tokens[^1]) || ArchitectureTokens.Contains(tokens[^1]))) + tokens.RemoveAt(tokens.Count - 1); + current = string.Join(" ", TrimTrailingSeparators(tokens)); + + if (current == before) return current; + } + } + + /// "24.08", "v1.2.3", "127.0.6533.74" — dotted digits, optional leading v. A bare integer ("2016", "7") is part of the name. + private static bool IsVersionToken(string token) + { + var s = token.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? token.Substring(1) : token; + if (s.Length < 3 || !char.IsDigit(s[0]) || !char.IsDigit(s[^1])) return false; + var dots = 0; + foreach (var ch in s) + { + if (ch == '.') dots++; + else if (!char.IsDigit(ch)) return false; + } + return dots > 0; + } + + private static List TrimTrailingSeparators(List tokens) + { + var list = tokens.ToList(); + while (list.Count > 0) + { + var last = list[^1].TrimEnd('-', ':', ',', '|', '/', '–', '—'); + if (last.Length == 0) { list.RemoveAt(list.Count - 1); continue; } + list[^1] = last; + break; + } + return list; + } + + private static int DistinctSessions(IEnumerable rows) + => rows.Select(r => r.SessionId) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct(StringComparer.OrdinalIgnoreCase).Count(); + + private static CveFold FoldCve(string cveId, List rows) + { + var topSeverityRow = rows.OrderByDescending(r => SeverityRank(r.CvssSeverity)).First(); + return new CveFold( + CveId: cveId, + Severity: topSeverityRow.CvssSeverity, + SeverityRank: SeverityRank(topSeverityRow.CvssSeverity), + CvssScore: rows.Max(r => r.CvssScore), + IsKev: rows.Any(r => r.IsKev), + // Max over a nullable ignores nulls and yields null when nothing is scored. + EpssScore: rows.Max(r => r.EpssScore), + Priority: rows.Select(r => r.Priority) + .Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best), + AffectedSessions: DistinctSessions(rows), + Rows: rows); + } + + /// + /// Folds the rows per product. A row without a product (written before the index carried + /// it, or a finding without a CPE) borrows the product of another row with the same title — + /// so the transition converges as new sessions are correlated — and otherwise is keyed by + /// its title. Rows with neither are left out of the product view. + /// + private static List FoldSoftware(List rows) + { + var productByTitle = rows + .Where(r => !string.IsNullOrEmpty(r.Product) && !string.IsNullOrWhiteSpace(r.SoftwareName)) + .GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.GroupBy(r => r.Product, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(p => p.Count()) + .ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase) + .First().Key, + StringComparer.OrdinalIgnoreCase); + + string ProductKey(CveExposureEntry r) + { + if (!string.IsNullOrEmpty(r.Product)) return r.Product; + var title = r.SoftwareName?.Trim() ?? string.Empty; + return title.Length > 0 && productByTitle.TryGetValue(title, out var product) ? product : title; + } + + return rows + .Select(r => (Row: r, Key: ProductKey(r))) + .Where(x => x.Key.Length > 0) + .GroupBy(x => x.Key, x => x.Row, StringComparer.OrdinalIgnoreCase) + .Select(g => + { + var productRows = g.ToList(); + var productCves = productRows + .GroupBy(r => r.CveId, StringComparer.OrdinalIgnoreCase) + .Select(cg => FoldCve(cg.Key, cg.ToList())) + .ToList(); + var worst = productCves + .OrderByDescending(c => c.SeverityRank) + .ThenByDescending(c => c.CvssScore) + .First(); + var titles = productRows + .Where(r => !string.IsNullOrWhiteSpace(r.SoftwareName)) + .GroupBy(r => r.SoftwareName.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(tg => (Title: tg.Key, Sessions: DistinctSessions(tg))) + .OrderByDescending(t => t.Sessions) + .ThenBy(t => t.Title, StringComparer.OrdinalIgnoreCase) + .Select(t => t.Title) + .ToList(); + + return new TopSoftware + { + Product = g.Key, + Name = DisplayName(titles), + AffectedSessions = DistinctSessions(productRows), + CveCount = productCves.Count, + KevCount = productCves.Count(c => c.IsKev), + CvssSeverity = worst.Severity, + CvssScore = worst.CvssScore, + Priority = productCves.Select(c => c.Priority) + .Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best), + Titles = titles.Take(MaxTitlesPerSoftware).ToList(), + Cves = productCves + .OrderByDescending(c => CvePriority.Rank(c.Priority)) + .ThenByDescending(c => c.EpssScore ?? -1) + .ThenByDescending(c => c.CvssScore) + .ThenByDescending(c => c.AffectedSessions) + .ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase) + .Take(MaxCvesPerSoftware) + .Select(c => new SoftwareCve + { + CveId = c.CveId, + CvssSeverity = c.Severity, + CvssScore = c.CvssScore, + IsKev = c.IsKev, + EpssScore = c.EpssScore, + Priority = c.Priority, + AffectedSessions = c.AffectedSessions, + }) + .ToList(), + }; + }) + .ToList(); + } + + /// One CVE folded across its rows (the whole scope, or one product's rows). + private sealed record CveFold( + string CveId, + string Severity, + int SeverityRank, + double CvssScore, + bool IsKev, + double? EpssScore, + string Priority, + int AffectedSessions, + List Rows); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index 0da3e310..1aa23497 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -291,8 +291,9 @@ namespace AutopilotMonitor.Functions.Services /// /// Collapses report findings to one entity per CVE. Merge rule: the occurrence with the - /// highest CVSS score supplies score/severity/software name (ties keep report order), - /// IsKev is OR-ed, OverallRisk is the highest risk level seen across occurrences. + /// highest CVSS score supplies score/severity/software name/product (ties keep report + /// order), IsKev is OR-ed, OverallRisk is the highest risk level seen across occurrences. + /// Product is the finding's CPE "vendor:product" (). /// internal static List BuildCveIndexEntities(string tenantId, string sessionId, List> findings) { @@ -302,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services foreach (var finding in findings) { var softwareName = finding.TryGetValue("softwareName", out var sn) ? sn?.ToString() ?? "" : ""; + var product = Vulnerability.CpeMatchScorer.ProductKey(finding.TryGetValue("cpeUri", out var cu) ? cu?.ToString() : null); var overallRisk = finding.TryGetValue("riskLevel", out var rl) ? rl?.ToString() ?? "" : ""; if (!finding.TryGetValue("vulnerabilities", out var vulnsObj) || vulnsObj == null) continue; @@ -330,6 +332,7 @@ namespace AutopilotMonitor.Functions.Services existing["CvssScore"] = cvssScore; existing["CvssSeverity"] = cvssSeverity; existing["SoftwareName"] = softwareName; + existing["Product"] = product; } existing["IsKev"] = existing.GetBoolean("IsKev") == true || isKev; if (epssScore.HasValue && epssScore.Value > (existing.GetDouble("EpssScore") ?? -1)) @@ -347,6 +350,7 @@ namespace AutopilotMonitor.Functions.Services ["TenantId"] = tenantId, ["CveId"] = cveId, ["SoftwareName"] = softwareName, + ["Product"] = product, ["CvssScore"] = cvssScore, ["CvssSeverity"] = cvssSeverity, ["IsKev"] = isKev, @@ -1380,7 +1384,7 @@ namespace AutopilotMonitor.Functions.Services var select = new[] { - "SessionId", "TenantId", "CveId", "SoftwareName", + "SessionId", "TenantId", "CveId", "SoftwareName", "Product", "CvssScore", "CvssSeverity", "IsKev", "EpssScore", "Priority", "OverallRisk", "DetectedAt", }; @@ -1406,6 +1410,7 @@ namespace AutopilotMonitor.Functions.Services CveId = e.GetString("CveId") ?? string.Empty, SessionId = e.GetString("SessionId") ?? e.RowKey, SoftwareName = e.GetString("SoftwareName") ?? string.Empty, + Product = e.GetString("Product") ?? string.Empty, CvssScore = e.GetDouble("CvssScore") ?? 0, CvssSeverity = e.GetString("CvssSeverity") ?? string.Empty, IsKev = e.GetBoolean("IsKev") ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CpeMatchScorer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CpeMatchScorer.cs index 1f0a1ced..0f8b2397 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CpeMatchScorer.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CpeMatchScorer.cs @@ -148,6 +148,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability return (parts[2], parts[3], parts[4]); } + /// + /// Product identity of a CPE 2.3 name as "vendor:product" in lower case + /// ("cpe:2.3:a:7-zip:7-zip:24.08" → "7-zip:7-zip"); empty when the name has no concrete + /// vendor and product. CveIndex rows carry it so the exposure summary can fold every title + /// and version of one product together. + /// + public static string ProductKey(string? cpeName) + { + var parts = ParseCpeComponents(cpeName ?? ""); + if (parts == null) return ""; + var (_, vendor, product) = parts.Value; + if (!IsConcrete(vendor) || !IsConcrete(product)) return ""; + return $"{vendor}:{product}".ToLowerInvariant(); + + static bool IsConcrete(string value) => value.Length > 0 && value != "*" && value != "-"; + } + /// /// Build a CpeMappingEntry from an NVD CPE entry for saving as a custom mapping. /// 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 0d3bf8b3..cdf13ba4 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 @@ -1247,10 +1247,17 @@ export interface CveExposureSummary { /** Distinct affected tenants; null (key omitted) on tenant-scoped reads. */ totalAffectedTenants?: number; distinctCves: number; + /** Distinct affected products, grouped as in TopSoftware, with at least one CVE in the window. */ + distinctSoftware: number; kevCves: number; + /** Distinct affected sessions that carry at least one CISA KEV (known-exploited) CVE. */ + kevAffectedSessions: number; severityBreakdown: SeverityBreakdown; priorityBreakdown: PriorityBreakdown; + /** The topN CVEs by affected sessions, plus the KEV CVEs that rank below them (at most 25 extra), all ordered by affected sessions. The list can therefore be longer than topN. */ 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[]; truncated: boolean; } @@ -4477,6 +4484,17 @@ export interface SleepSpan { kind: string; } +/** One CVE of a TopSoftware entry; AffectedSessions counts that product's sessions only. */ +export interface SoftwareCve { + cveId: string; + cvssSeverity: string; + cvssScore: number; + isKev: boolean; + epssScore?: number; + priority: string; + affectedSessions: number; +} + /** Shared response of GET vulnerability/software-inventory (Global Admin, ?tenantId=) and GET metrics/software-inventory (caller's own tenant): the raw SoftwareInventory rows plus matched/unmatched counts. Consumed by the MCP get_software_inventory tool — key names are part of that contract. */ export interface SoftwareInventoryResponse { success: boolean; @@ -5214,6 +5232,26 @@ export interface TopFailingApp { successRate: number; } +/** One affected product: every CveIndex row of the product folded together. Severity, score and priority are the worst values across its CVEs; AffectedSessions counts distinct sessions. */ +export interface TopSoftware { + /** Grouping key: the CPE "vendor:product" the correlation matched (e.g. "7-zip:7-zip"). A row written before the index carried it borrows the product of another row with the same title; failing that, its raw title is the key. */ + product: string; + /** Display name derived from the detected titles (shared leading words, or the most common title without its version and architecture). */ + name: string; + affectedSessions: number; + cveCount: number; + kevCount: number; + /** Highest CVSS severity band across the product's CVEs. */ + cvssSeverity: string; + cvssScore: number; + /** Highest act/attend/track band across the product's CVEs (empty when only legacy rows carry it). */ + priority: string; + /** Raw display names the product was detected under, most sessions first (at most 3). */ + titles: string[]; + /** The product's most urgent CVEs (priority, then EPSS, then CVSS), at most 3; CveCount is the full count. */ + cves: SoftwareCve[]; +} + /** One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface TpmPssUnsupportedItem { serialNumber: string; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 6a981849..1e4302fc 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -1967,15 +1967,17 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo title: 'Vulnerability Summary', description: 'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' + - 'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, a priority breakdown ' + - '(act/attend/track counts over every CVE in the window), and the top CVEs ranked by how ' + - 'many devices they affect. Each top CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' + + 'distinct CVE and product counts, KEV (CISA Known-Exploited) count and the devices carrying one, a severity breakdown, ' + + 'a priority breakdown (act/attend/track counts over every CVE in the window), the top CVEs ranked by how ' + + 'many devices they affect (every KEV CVE is added even below topN), and topSoftware: the most exposed products ' + + '(grouped by CPE vendor:product, KEV products always added) with devices, CVE and KEV counts, worst severity and ' + + 'priority, and their most urgent CVEs. Each CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' + 'probability of exploitation within 30 days, 0-1, null when unscored) and priority ("act" = in CISA KEV, ' + '"attend" = EPSS >= 0.1 or CVSS >= 9.0, "track" = everything else) — rank remediation by priority, then EPSS, ' + 'not by CVSS alone. ' + (ga ? 'The cross-tenant overview also returns the affected tenant count. ' : '') + 'Use this to answer "how exposed is the fleet / this ' + - 'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' + + 'tenant?", "which CVEs affect the most devices?" and "which software to update first?" — for the device list of a single CVE use search_sessions_by_cve. ' + 'If "truncated" is true, the underlying index scan hit its cap and counts are a lower bound (narrow with tenantId). ' + 'Requires vulnerability scanning to be enabled (an empty summary means no findings, not necessarily "not affected").', inputSchema: { @@ -1983,7 +1985,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo days: z.coerce.number().int().min(1).max(365).optional().default(30) .describe(daysDescription(30, 365, 'Filters CVEs by detection time.')), topN: z.coerce.number().int().min(1).max(100).optional().default(20) - .describe('How many top CVEs to return, ranked by affected device count (1-100, default 20).'), + .describe('How many top CVEs to return, ranked by affected device count (1-100, default 20); also sizes topSoftware (at most 20).'), }, annotations: READ_ONLY, }, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs b/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs index be274d22..7c5f2df3 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Vulnerability/CveExposureEntry.cs @@ -14,6 +14,8 @@ namespace AutopilotMonitor.Shared.Models.Vulnerability public string CveId { get; set; } = string.Empty; public string SessionId { get; set; } = string.Empty; public string SoftwareName { get; set; } = string.Empty; + /// CPE "vendor:product" the correlation matched; empty on legacy rows and findings without a CPE. + public string Product { get; set; } = string.Empty; public double CvssScore { get; set; } public string CvssSeverity { get; set; } = string.Empty; public bool IsKev { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs index 4d04b2fc..47900a59 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/VulnerabilityApiModels.cs @@ -334,10 +334,30 @@ namespace AutopilotMonitor.Shared.Models public int? TotalAffectedTenants { get; set; } public int DistinctCves { get; set; } + + /// Distinct affected products, grouped as in , with at least one CVE in the window. + public int DistinctSoftware { get; set; } + public int KevCves { get; set; } + + /// Distinct affected sessions that carry at least one CISA KEV (known-exploited) CVE. + public int KevAffectedSessions { get; set; } + public SeverityBreakdown SeverityBreakdown { get; set; } = default!; public PriorityBreakdown PriorityBreakdown { get; set; } = default!; + + /// + /// The topN CVEs by affected sessions, plus the KEV CVEs that rank below them (at most 25 + /// extra), all ordered by affected sessions. The list can therefore be longer than topN. + /// public IReadOnlyList TopCves { get; set; } = default!; + + /// + /// 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. + /// + public IReadOnlyList TopSoftware { get; set; } = default!; + public bool Truncated { get; set; } } @@ -383,6 +403,56 @@ namespace AutopilotMonitor.Shared.Models public IReadOnlyList SampleSoftware { get; set; } = default!; } + /// + /// One affected product: every CveIndex row of the product folded together. Severity, score + /// and priority are the worst values across its CVEs; counts + /// distinct sessions. + /// + // Declaration order == wire order. + public class TopSoftware + { + /// + /// Grouping key: the CPE "vendor:product" the correlation matched (e.g. "7-zip:7-zip"). + /// A row written before the index carried it borrows the product of another row with the + /// same title; failing that, its raw title is the key. + /// + public string Product { get; set; } = default!; + + /// Display name derived from the detected titles (shared leading words, or the most common title without its version and architecture). + public string Name { get; set; } = default!; + + public int AffectedSessions { get; set; } + public int CveCount { get; set; } + public int KevCount { get; set; } + + /// Highest CVSS severity band across the product's CVEs. + public string CvssSeverity { get; set; } = default!; + + public double CvssScore { get; set; } + + /// Highest act/attend/track band across the product's CVEs (empty when only legacy rows carry it). + public string Priority { get; set; } = default!; + + /// Raw display names the product was detected under, most sessions first (at most 3). + public IReadOnlyList Titles { get; set; } = default!; + + /// The product's most urgent CVEs (priority, then EPSS, then CVSS), at most 3; is the full count. + public IReadOnlyList Cves { get; set; } = default!; + } + + /// One CVE of a entry; counts that product's sessions only. + // Declaration order == wire order. + public class SoftwareCve + { + public string CveId { get; set; } = default!; + public string CvssSeverity { get; set; } = default!; + public double CvssScore { get; set; } + public bool IsKev { get; set; } + public double? EpssScore { get; set; } + public string Priority { get; set; } = default!; + public int AffectedSessions { get; set; } + } + // ── Request bodies ── /// Body of POST vulnerability/cpe-mapping/auto-resolve. diff --git a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx index 752a8c3e..83fd7c86 100644 --- a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx +++ b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx @@ -1,18 +1,11 @@ "use client"; import { useState } from "react"; -import type { CveExposureSummary, TopCve } from "@/utils/wire-types.generated"; -import { - severityChipClass, - priorityChipClass, - KEV_BADGE_CLASS, - CONTEXT_PILL_CLASS, - formatEpss, - epssTitle, - priorityLabel, - PRIORITY_TITLE, -} from "@/lib/vulnerabilitySeverity"; -import TruncatedLabel from "@/components/TruncatedLabel"; +import type { CveExposureSummary } from "@/utils/wire-types.generated"; +import { toggleFilter, type ExposureFilter } from "@/lib/vulnerabilityExposure"; +import ExposureKpiRow from "@/components/vulnerability/ExposureKpiRow"; +import SeverityPriorityCard from "@/components/vulnerability/SeverityPriorityCard"; +import MostExposedCard from "@/components/vulnerability/MostExposedCard"; interface Props { summary: CveExposureSummary | null; @@ -24,27 +17,16 @@ interface Props { onDaysChange?: (days: number) => void; onRefresh?: () => void; refreshing?: boolean; + /** Header title; the header row renders only with a title or controls. */ title?: string; } /** - * One active list filter: a severity band or a priority band, both lower-case wire values. - * Chips toggle it; clicking the active chip again clears it. - */ -type ListFilter = { kind: "severity" | "priority"; value: string } | null; - -function matchesFilter(c: TopCve, filter: ListFilter): boolean { - if (!filter) return true; - const actual = filter.kind === "severity" ? c.cvssSeverity : c.priority; - return (actual || "").toLowerCase() === filter.value; -} - -/** - * Read-only fleet/tenant vulnerability exposure: affected-device, CVE and priority KPIs, a - * severity and a priority breakdown (both filter the list), and the top CVEs ranked by affected - * devices. Shared by the GA admin section and the tenant-facing Software › Vulnerabilities tab so - * both render identical chips/CVE rows. The breakdown counts cover every CVE in the window while - * the list is top-N, so a filter can legitimately leave the list empty. + * 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. */ export default function VulnerabilityExposurePanel({ summary, @@ -54,35 +36,23 @@ export default function VulnerabilityExposurePanel({ onDaysChange, onRefresh, refreshing = false, - title = "Vulnerability Exposure", + title, }: Props) { + const [filter, setFilter] = useState(null); const hasFindings = summary != null && summary.distinctCves > 0; - const [filter, setFilter] = useState(null); - - const toggleFilter = (kind: "severity" | "priority", value: string) => - setFilter((prev) => (prev && prev.kind === kind && prev.value === value ? null : { kind, value })); - - const isActive = (kind: "severity" | "priority", value: string) => - filter != null && filter.kind === kind && filter.value === value; - - const visibleCves = hasFindings ? summary!.topCves.filter((c) => matchesFilter(c, filter)) : []; - const filterLabel = filter - ? filter.kind === "priority" - ? priorityLabel(filter.value) ?? filter.value - : filter.value.charAt(0).toUpperCase() + filter.value.slice(1) - : null; + const showHeader = title != null || onDaysChange != null || onRefresh != null; return ( -
-
-

{title}

- {(onDaysChange || onRefresh) && ( +
+ {showHeader && ( +
+ {title ?

{title}

: }
{onDaysChange && (