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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-01 19:35:09 +02:00
parent 54334de378
commit dd5534b0c9
23 changed files with 1924 additions and 264 deletions
@@ -0,0 +1,24 @@
using AutopilotMonitor.Functions.Services.Vulnerability;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <see cref="CpeMatchScorer.ProductKey"/> turns a finding's CPE ("base CPE:installed version")
/// into the product identity the exposure summary groups by.
/// </summary>
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));
}
@@ -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<Dictionary<string, object>> { 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"));
}
}
@@ -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<CveExposureEntry> { 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<CveExposureEntry> { 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<CveExposureEntry>();
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<string>()));
}
@@ -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<TopSoftware>
{
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<string> { "7-Zip 24.08 (x64)", "7-Zip 25.01 (x64)" },
Cves = new List<SoftwareCve>
{
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<TopCve>();
var topSoftware = new List<TopSoftware>();
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,
});
}
@@ -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.
/// <summary>
@@ -17,6 +17,32 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
/// </summary>
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.
/// <summary>
/// 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.
/// </summary>
public const int MaxKevAdditions = 25;
/// <summary>Products ranked by devices, before the KEV additions (the list is min(topN, this) long).</summary>
public const int MaxSoftware = 20;
/// <summary>Cap on the products with a KEV CVE appended below the product cut.</summary>
public const int MaxKevSoftwareAdditions = 10;
/// <summary>CVEs carried per product entry; <see cref="TopSoftware.CveCount"/> keeps the full count.</summary>
public const int MaxCvesPerSoftware = 3;
/// <summary>Raw titles carried per product entry.</summary>
public const int MaxTitlesPerSoftware = 3;
private static readonly HashSet<string> 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,
};
}
/// <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
/// affected sessions stays ranked.
/// </summary>
internal static List<T> WithKevAdditions<T>(IReadOnlyList<T> ranked, int topN, Func<T, bool> isKev, int maxAdditions)
=> ranked.Take(topN)
.Concat(ranked.Skip(topN).Where(isKev).Take(maxAdditions))
.ToList();
/// <summary>
/// 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.
/// </summary>
internal static string DisplayName(IReadOnlyList<string> 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;
}
}
/// <summary>"24.08", "v1.2.3", "127.0.6533.74" — dotted digits, optional leading v. A bare integer ("2016", "7") is part of the name.</summary>
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<string> TrimTrailingSeparators(List<string> 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<CveExposureEntry> rows)
=> rows.Select(r => r.SessionId)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
private static CveFold FoldCve(string cveId, List<CveExposureEntry> 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);
}
/// <summary>
/// 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.
/// </summary>
private static List<TopSoftware> FoldSoftware(List<CveExposureEntry> 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();
}
/// <summary>One CVE folded across its rows (the whole scope, or one product's rows).</summary>
private sealed record CveFold(
string CveId,
string Severity,
int SeverityRank,
double CvssScore,
bool IsKev,
double? EpssScore,
string Priority,
int AffectedSessions,
List<CveExposureEntry> Rows);
}
}
@@ -291,8 +291,9 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// 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" (<see cref="Vulnerability.CpeMatchScorer.ProductKey"/>).
/// </summary>
internal static List<TableEntity> BuildCveIndexEntities(string tenantId, string sessionId, List<Dictionary<string, object>> 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,
@@ -148,6 +148,23 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
return (parts[2], parts[3], parts[4]);
}
/// <summary>
/// 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.
/// </summary>
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 != "-";
}
/// <summary>
/// Build a CpeMappingEntry from an NVD CPE entry for saving as a custom mapping.
/// </summary>
@@ -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;
@@ -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,
},
@@ -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;
/// <summary>CPE "vendor:product" the correlation matched; empty on legacy rows and findings without a CPE.</summary>
public string Product { get; set; } = string.Empty;
public double CvssScore { get; set; }
public string CvssSeverity { get; set; } = string.Empty;
public bool IsKev { get; set; }
@@ -334,10 +334,30 @@ namespace AutopilotMonitor.Shared.Models
public int? TotalAffectedTenants { get; set; }
public int DistinctCves { get; set; }
/// <summary>Distinct affected products, grouped as in <see cref="TopSoftware"/>, with at least one CVE in the window.</summary>
public int DistinctSoftware { get; set; }
public int KevCves { get; set; }
/// <summary>Distinct affected sessions that carry at least one CISA KEV (known-exploited) CVE.</summary>
public int KevAffectedSessions { get; set; }
public SeverityBreakdown SeverityBreakdown { get; set; } = default!;
public PriorityBreakdown PriorityBreakdown { get; set; } = default!;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<TopCve> TopCves { get; set; } = default!;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<TopSoftware> TopSoftware { get; set; } = default!;
public bool Truncated { get; set; }
}
@@ -383,6 +403,56 @@ namespace AutopilotMonitor.Shared.Models
public IReadOnlyList<string> SampleSoftware { get; set; } = default!;
}
/// <summary>
/// One affected product: every CveIndex row of the product folded together. Severity, score
/// and priority are the worst values across its CVEs; <see cref="AffectedSessions"/> counts
/// distinct sessions.
/// </summary>
// Declaration order == wire order.
public class TopSoftware
{
/// <summary>
/// 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.
/// </summary>
public string Product { get; set; } = default!;
/// <summary>Display name derived from the detected titles (shared leading words, or the most common title without its version and architecture).</summary>
public string Name { get; set; } = default!;
public int AffectedSessions { get; set; }
public int CveCount { get; set; }
public int KevCount { get; set; }
/// <summary>Highest CVSS severity band across the product's CVEs.</summary>
public string CvssSeverity { get; set; } = default!;
public double CvssScore { get; set; }
/// <summary>Highest act/attend/track band across the product's CVEs (empty when only legacy rows carry it).</summary>
public string Priority { get; set; } = default!;
/// <summary>Raw display names the product was detected under, most sessions first (at most 3).</summary>
public IReadOnlyList<string> Titles { get; set; } = default!;
/// <summary>The product's most urgent CVEs (priority, then EPSS, then CVSS), at most 3; <see cref="CveCount"/> is the full count.</summary>
public IReadOnlyList<SoftwareCve> Cves { get; set; } = default!;
}
/// <summary>One CVE of a <see cref="TopSoftware"/> entry; <see cref="AffectedSessions"/> counts that product's sessions only.</summary>
// 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 ──
/// <summary>Body of POST vulnerability/cpe-mapping/auto-resolve.</summary>
@@ -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<ExposureFilter>(null);
const hasFindings = summary != null && summary.distinctCves > 0;
const [filter, setFilter] = useState<ListFilter>(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 (
<div className="bg-emerald-50 dark:bg-gray-700 border border-emerald-200 dark:border-emerald-600 rounded-lg p-4">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 className="text-sm font-medium text-emerald-900 dark:text-emerald-100">{title}</h3>
{(onDaysChange || onRefresh) && (
<div className="space-y-4">
{showHeader && (
<div className="flex flex-wrap items-center justify-between gap-2">
{title ? <h3 className="text-base font-semibold text-gray-900">{title}</h3> : <span />}
<div className="flex items-center gap-2">
{onDaysChange && (
<select
value={days}
onChange={(e) => onDaysChange(Number(e.target.value))}
className="text-xs rounded-md border border-emerald-300 dark:border-emerald-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-emerald-500"
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500"
aria-label="Time window in days"
>
<option value={7}>Last 7 days</option>
@@ -96,175 +66,48 @@ export default function VulnerabilityExposurePanel({
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md text-emerald-700 dark:text-emerald-300 border border-emerald-400 dark:border-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{refreshing ? "Refreshing..." : "Refresh"}
</button>
)}
</div>
)}
</div>
</div>
)}
{loading ? (
<div className="py-6 text-center text-sm text-emerald-800 dark:text-emerald-200">Loading…</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="py-6 text-center text-sm text-emerald-800 dark:text-emerald-200">
No vulnerability findings in this window. An empty result means no detected CVEs — it does
not by itself confirm the fleet is unaffected (vulnerability scanning must be enabled).
<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>
) : (
<div className="space-y-4">
{/* KPI tiles — the priority ladder (Act = KEV by definition, Attend = next tier) sits next to the totals */}
<div className={`grid grid-cols-2 ${showTenantCount ? "sm:grid-cols-5" : "sm:grid-cols-4"} gap-3`}>
<Kpi value={summary!.totalAffectedSessions} label="Affected devices" />
<Kpi value={summary!.distinctCves} label="Distinct CVEs" />
<Kpi value={summary!.priorityBreakdown.act} label="Act (Known-Exploited)" title={PRIORITY_TITLE} />
<Kpi value={summary!.priorityBreakdown.attend} label="Attend" title={PRIORITY_TITLE} />
{showTenantCount && <Kpi value={summary!.totalAffectedTenants ?? 0} label="Affected tenants" />}
</div>
{/* Severity + priority breakdowns — every chip filters the list below */}
<div className="flex flex-wrap items-center gap-2">
{([
["Critical", summary!.severityBreakdown.critical],
["High", summary!.severityBreakdown.high],
["Medium", summary!.severityBreakdown.medium],
["Low", summary!.severityBreakdown.low],
] as Array<[string, number]>).map(([label, count]) => (
<FilterChip
key={label}
label={label}
count={count}
className={severityChipClass(label)}
active={isActive("severity", label.toLowerCase())}
onClick={() => toggleFilter("severity", label.toLowerCase())}
title={`Show only ${label} CVEs`}
/>
))}
<span className="w-px h-4 bg-emerald-300 dark:bg-emerald-700 mx-1" aria-hidden="true" />
{([
["Act", summary!.priorityBreakdown.act],
["Attend", summary!.priorityBreakdown.attend],
["Track", summary!.priorityBreakdown.track],
] as Array<[string, number]>).map(([label, count]) => (
<FilterChip
key={label}
label={label}
count={count}
className={priorityChipClass(label)}
active={isActive("priority", label.toLowerCase())}
onClick={() => toggleFilter("priority", label.toLowerCase())}
title={PRIORITY_TITLE}
/>
))}
</div>
{/* Top CVEs */}
<div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className="text-xs font-medium text-emerald-800 dark:text-emerald-200">Top CVEs by affected devices</span>
{filterLabel && (
<>
<span className="text-xs text-emerald-700 dark:text-emerald-300">· {filterLabel} only</span>
<button
type="button"
onClick={() => setFilter(null)}
className="text-xs text-emerald-700 dark:text-emerald-300 underline hover:text-emerald-900 dark:hover:text-emerald-100"
>
Clear filter
</button>
</>
)}
</div>
{visibleCves.length === 0 ? (
<div className="py-4 text-center text-xs text-emerald-800 dark:text-emerald-200">
None of the top {summary!.topCves.length} CVEs is {filterLabel}. The chip counts cover every CVE in the
window; the list shows only the CVEs affecting the most devices.
</div>
) : (
<div className="space-y-1.5">
{visibleCves.map((c) => (
<div key={c.cveId} className="flex items-center justify-between gap-3 bg-white dark:bg-gray-800 border border-emerald-100 dark:border-emerald-800 rounded-md px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<a href={`https://nvd.nist.gov/vuln/detail/${encodeURIComponent(c.cveId)}`} target="_blank" rel="noopener noreferrer"
className="font-mono text-xs text-emerald-700 dark:text-emerald-300 underline hover:text-emerald-900 dark:hover:text-emerald-100">
{c.cveId}
</a>
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium ${severityChipClass(c.cvssSeverity)}`}>
{c.cvssSeverity || "Unknown"}{c.cvssScore > 0 ? ` ${c.cvssScore.toFixed(1)}` : ""}
</span>
{c.isKev && (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-semibold ${KEV_BADGE_CLASS}`}>KEV</span>
)}
{c.epssScore != null && (
<span className={CONTEXT_PILL_CLASS} title={epssTitle(c.epssScore)}>{formatEpss(c.epssScore)}</span>
)}
{priorityLabel(c.priority) && (
<span className={CONTEXT_PILL_CLASS} title={PRIORITY_TITLE}>{priorityLabel(c.priority)}</span>
)}
</div>
{c.sampleSoftware.length > 0 && (
<TruncatedLabel text={c.sampleSoftware.join(", ")} className="block text-[11px] text-gray-500 dark:text-gray-400 mt-0.5" />
)}
</div>
<div className="text-right flex-shrink-0">
<div className="text-sm font-semibold text-emerald-900 dark:text-emerald-100">{c.affectedSessions.toLocaleString()}</div>
<div className="text-[10px] text-emerald-700 dark:text-emerald-300">devices</div>
</div>
</div>
))}
</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 && (
<div className="text-[11px] text-amber-700 dark:text-amber-400">
Counts are a lower bound — the underlying scan hit its cap. Narrow the window for exact figures.
</div>
<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>
);
}
function Kpi({ value, label, title }: { value: number; label: string; title?: string }) {
return (
<div className="bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-700 rounded-lg p-3" title={title}>
<div className="text-2xl font-bold text-emerald-900 dark:text-emerald-100">{value.toLocaleString()}</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">{label}</div>
</div>
);
}
/** A breakdown chip that doubles as a list-filter toggle; the active one carries a ring. */
function FilterChip({
label,
count,
className,
active,
onClick,
title,
}: {
label: string;
count: number;
className: string;
active: boolean;
onClick: () => void;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
title={title}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full border text-xs font-medium transition-shadow ${className} ${
active ? "ring-2 ring-emerald-500 ring-offset-1 ring-offset-emerald-50 dark:ring-offset-gray-700" : "hover:ring-1 hover:ring-emerald-400"
}`}
>
{label}: {count.toLocaleString()}
</button>
);
}
@@ -0,0 +1,137 @@
import type { ReactNode } from "react";
import type { CveExposureSummary } from "@/utils/wire-types.generated";
const ICON_PROPS = {
viewBox: "0 0 24 24",
className: "h-5 w-5",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
const SHIELD = "M12 21.5s7.5-3.6 7.5-9.4V5.4L12 2.5 4.5 5.4v6.7c0 5.8 7.5 9.4 7.5 9.4z";
/**
* Headline numbers of the exposure panel. Known exploited turns red only when there is
* something to act on; Affected tenants exists only on the cross-tenant aggregate.
*/
export default function ExposureKpiRow({
summary,
showTenantCount,
}: {
summary: CveExposureSummary;
showTenantCount: boolean;
}) {
const kev = summary.kevCves;
const kevDevices = summary.kevAffectedSessions;
// The key is absent on tenant-scoped reads, e.g. while a scope switch is still loading.
const tenants = showTenantCount ? summary.totalAffectedTenants : undefined;
return (
<div className={`grid grid-cols-2 gap-4 ${tenants != null ? "sm:grid-cols-3 lg:grid-cols-5" : "lg:grid-cols-4"}`}>
<KpiTile
label="Affected devices"
value={summary.totalAffectedSessions}
caption="with at least one known CVE"
icon={
<svg {...ICON_PROPS}>
<rect x="2.5" y="3.5" width="19" height="13" rx="2" />
<path d="M8 20.5h8M12 16.5v4" />
</svg>
}
/>
<KpiTile
label="Distinct CVEs"
value={summary.distinctCves}
caption={`detected in the last ${summary.windowDays} days`}
icon={
<svg {...ICON_PROPS}>
<path d={SHIELD} />
</svg>
}
/>
<KpiTile
label="Known exploited"
value={kev}
caption={kev > 0 ? `CISA KEV · on ${kevDevices.toLocaleString()} ${kevDevices === 1 ? "device" : "devices"}` : "none on the CISA KEV list"}
danger={kev > 0}
icon={
<svg {...ICON_PROPS}>
<path d={SHIELD} />
<path d="M12 8v4.5M12 15.8h.01" />
</svg>
}
/>
<KpiTile
label="Exposed software"
value={summary.distinctSoftware}
caption="products with a known CVE"
icon={
<svg {...ICON_PROPS}>
<path d="M20.5 7.5 12 3 3.5 7.5 12 12z" />
<path d="M3.5 7.5v9L12 21l8.5-4.5v-9M12 12v9" />
</svg>
}
/>
{tenants != null && (
<KpiTile
className="col-span-2 sm:col-span-1"
label="Affected tenants"
value={tenants}
caption="with at least one affected device"
icon={
<svg {...ICON_PROPS}>
<rect x="4.5" y="2.5" width="15" height="19" rx="1" />
<path d="M9.5 21.5v-4h5v4M8.5 6.5h.01M12 6.5h.01M15.5 6.5h.01M8.5 10h.01M12 10h.01M15.5 10h.01M8.5 13.5h.01M12 13.5h.01M15.5 13.5h.01" />
</svg>
}
/>
)}
</div>
);
}
function KpiTile({
label,
value,
caption,
icon,
danger = false,
className = "",
}: {
label: string;
value: number;
caption: string;
icon: ReactNode;
danger?: boolean;
className?: string;
}) {
return (
<div className={`bg-white rounded-lg shadow p-4 ${className}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm text-gray-500">{label}</div>
<div
className={`mt-1 text-3xl font-semibold tracking-tight tabular-nums ${
danger ? "text-red-600 dark:text-red-400" : "text-gray-900"
}`}
>
{value.toLocaleString()}
</div>
</div>
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-md border ${
danger
? "border-red-200 bg-red-50 text-red-600 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300"
: "border-gray-200 bg-gray-50 text-gray-500"
}`}
>
{icon}
</span>
</div>
<div className="mt-2 text-xs text-gray-500">{caption}</div>
</div>
);
}
@@ -0,0 +1,291 @@
"use client";
import { useState } from "react";
import type { CveExposureSummary, SoftwareCve, TopCve, TopSoftware } from "@/utils/wire-types.generated";
import { SegmentedControl } from "@/components/SegmentedControl";
import TruncatedLabel from "@/components/TruncatedLabel";
import {
barPercent,
filterLabel,
matchesFilter,
sortExposure,
type ExposureFilter,
type ExposureSort,
} from "@/lib/vulnerabilityExposure";
import { AttendPill, CveLink, EpssPill, KevBadge, SeverityChip } from "./VulnerabilityChips";
type Lens = "software" | "cves";
/** Gray outline like the context pills, but a block so a long title ends in an ellipsis. */
const TITLE_PILL_CLASS =
"inline-block max-w-full truncate rounded border border-gray-300 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-600 dark:text-gray-300";
const SORT_OPTIONS = [
{ value: "priority", label: "Priority first" },
{ value: "devices", label: "Most devices" },
] as const;
/** Bar fill by priority: known-exploited in the KEV red, everything else neutral. */
function barFill(priority: string): string {
switch ((priority || "").toLowerCase()) {
case "act": return "bg-red-500";
case "attend": return "bg-gray-500";
default: return "bg-gray-400";
}
}
function DeviceBar({ value, max, priority }: { value: number; max: number; priority: string }) {
return (
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-200">
<div className={`h-full rounded-full ${barFill(priority)}`} style={{ width: `${barPercent(value, max)}%` }} />
</div>
);
}
/**
* 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.
*/
export default function MostExposedCard({
summary,
filter,
onClearFilter,
}: {
summary: CveExposureSummary;
filter: ExposureFilter;
onClearFilter: () => void;
}) {
const [lens, setLens] = useState<Lens>("software");
const [sort, setSort] = useState<ExposureSort>("priority");
// The most urgent product starts open so the card shows what a row holds.
const [expanded, setExpanded] = useState<Set<string>>(() => {
const first = sortExposure(summary.topSoftware, "priority")[0];
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 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 toggle = (product: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(product)) next.delete(product);
else next.add(product);
return next;
});
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">
<SegmentedControl
options={[
{ value: "software", label: `Software (${software.length})` },
{ value: "cves", label: `CVEs (${cves.length})` },
]}
value={lens}
onChange={(v) => setLens(v as Lens)}
/>
<SegmentedControl options={SORT_OPTIONS} value={sort} onChange={(v) => setSort(v as ExposureSort)} />
</div>
</div>
{filter && (
<div className="flex flex-wrap items-center gap-2 px-5 pb-3 text-xs text-gray-500">
<span className="inline-flex items-center gap-1 rounded-full border border-gray-300 bg-gray-100 py-0.5 pl-2.5 pr-1 text-gray-700">
{label}
{isSoftware ? (filter.kind === "severity" ? " (most severe CVE)" : " (highest priority)") : ""}
<button
type="button"
onClick={onClearFilter}
aria-label="Clear filter"
className="rounded-full px-1 text-gray-500 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500"
>
×
</button>
</span>
<span>
{rows} of {isSoftware ? `${summary.topSoftware.length} products` : `${summary.topCves.length} CVEs`}
</span>
</div>
)}
<div className="flex items-center justify-between border-y border-gray-200 bg-gray-50 px-5 py-1.5 text-[11px] font-medium uppercase tracking-wider text-gray-500">
<span>{isSoftware ? "Product" : "CVE"}</span>
<span>Devices</span>
</div>
{rows === 0 ? (
<div className="px-5 py-10 text-center text-sm text-gray-500">{emptyMessage(isSoftware, filter, label, summary.distinctCves)}</div>
) : isSoftware ? (
<ul className="divide-y divide-gray-200">
{software.map((s) => (
<SoftwareRow
key={s.product}
item={s}
max={maxSoftware}
open={expanded.has(s.product)}
onToggle={() => toggle(s.product)}
/>
))}
</ul>
) : (
<ul className="divide-y divide-gray-200">
{cves.map((c) => (
<CveRow key={c.cveId} item={c} max={maxCves} />
))}
</ul>
)}
</section>
);
}
/** 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 {
if (!filter) return `No ${isSoftware ? "products" : "CVEs"} to list.`;
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 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.`;
}
function SoftwareRow({
item,
max,
open,
onToggle,
}: {
item: TopSoftware;
max: number;
open: boolean;
onToggle: () => void;
}) {
const meta = [
`${item.cveCount.toLocaleString()} ${item.cveCount === 1 ? "CVE" : "CVEs"}`,
item.kevCount > 0 ? `${item.kevCount.toLocaleString()} known exploited` : null,
]
.filter(Boolean)
.join(" · ");
const hidden = item.cveCount - item.cves.length;
return (
<li>
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="w-full px-5 py-3 text-left hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-green-500"
>
<div className="flex items-start gap-3">
<svg
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className={`mt-0.5 h-4 w-4 shrink-0 text-gray-400 transition-transform motion-reduce:transition-none ${open ? "rotate-90" : ""}`}
>
<path
fillRule="evenodd"
d="M7.2 14.8a.75.75 0 0 1 0-1.1L11.2 10 7.2 6.3a.75.75 0 1 1 1.1-1.1l4.5 4.3a.75.75 0 0 1 0 1.1l-4.5 4.2a.75.75 0 0 1-1.1 0z"
clipRule="evenodd"
/>
</svg>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="break-words text-sm font-medium text-gray-900">{item.name || item.product}</span>
<SeverityChip severity={item.cvssSeverity} score={item.cvssScore} />
{item.kevCount > 0 && <KevBadge count={item.kevCount} />}
<AttendPill priority={item.priority} />
</div>
<div className="mt-0.5 text-xs text-gray-500">{meta}</div>
<div className="mt-2 flex">
<DeviceBar value={item.affectedSessions} max={max} priority={item.priority} />
</div>
</div>
<div className="w-16 shrink-0 text-right">
<div className="text-sm font-semibold text-gray-900 tabular-nums">{item.affectedSessions.toLocaleString()}</div>
<div className="text-[11px] text-gray-500">{item.affectedSessions === 1 ? "device" : "devices"}</div>
</div>
</div>
</button>
{open && (
<div className="pb-4 pl-12 pr-5">
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200">
{item.cves.map((c) => (
<SoftwareCveLine key={c.cveId} item={c} />
))}
</ul>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-gray-500">
{hidden > 0 && (
<span className="mr-2">
+{hidden.toLocaleString()} more {hidden === 1 ? "CVE" : "CVEs"}
</span>
)}
{item.titles.length > 0 && (
<>
<span>Detected as</span>
{item.titles.map((t) => (
<span key={t} title={t} className={TITLE_PILL_CLASS}>
{t}
</span>
))}
</>
)}
</div>
</div>
)}
</li>
);
}
function SoftwareCveLine({ item }: { item: SoftwareCve }) {
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<CveLink cveId={item.cveId} />
<SeverityChip severity={item.cvssSeverity} score={item.cvssScore} />
{item.isKev && <KevBadge />}
{item.epssScore != null && <EpssPill score={item.epssScore} />}
<AttendPill priority={item.priority} />
</div>
<span className="shrink-0 text-xs text-gray-600 tabular-nums">{item.affectedSessions.toLocaleString()}</span>
</li>
);
}
function CveRow({ item, max }: { item: TopCve; max: number }) {
const software = item.sampleSoftware.join(", ");
return (
<li className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-5 py-2.5 sm:flex-nowrap">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<CveLink cveId={item.cveId} />
<SeverityChip severity={item.cvssSeverity} score={item.cvssScore} />
{item.isKev && <KevBadge />}
{item.epssScore != null && <EpssPill score={item.epssScore} />}
<AttendPill priority={item.priority} />
</div>
{software && <TruncatedLabel text={software} className="mt-0.5 block text-xs text-gray-500" />}
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-40">
<DeviceBar value={item.affectedSessions} max={max} priority={item.priority} />
<span className="w-12 text-right text-sm font-semibold text-gray-900 tabular-nums">
{item.affectedSessions.toLocaleString()}
</span>
</div>
</li>
);
}
@@ -0,0 +1,87 @@
"use client";
import { donutSegments } from "@/lib/vulnerabilityExposure";
/** The four CVSS bands in ring order, coloured like the severity chips. Class strings stay literal for Tailwind. */
export const SEVERITY_BANDS = [
{ key: "critical", label: "Critical", stroke: "stroke-red-600 dark:stroke-red-500", dot: "bg-red-600 dark:bg-red-500" },
{ key: "high", label: "High", stroke: "stroke-orange-500", dot: "bg-orange-500" },
{ key: "medium", label: "Medium", stroke: "stroke-amber-400", dot: "bg-amber-400" },
{ key: "low", label: "Low", stroke: "stroke-gray-400 dark:stroke-gray-500", dot: "bg-gray-400 dark:bg-gray-500" },
] as const;
export type SeverityKey = (typeof SEVERITY_BANDS)[number]["key"];
const SIZE = 164;
const CENTER = SIZE / 2;
const RADIUS = 64;
const RING = 20;
const GAP = 2.5;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
/**
* Ring of distinct CVEs per severity band with the total in the middle. The ring is a mouse
* shortcut only; the legend next to it carries the same filter for keyboard and screen readers.
*/
export default function SeverityDonut({
counts,
total,
highlight,
onSelect,
onHover,
}: {
counts: Record<SeverityKey, number>;
total: number;
/** Band to emphasise (hovered or filtered); the others fade. */
highlight: string | null;
onSelect: (band: SeverityKey) => void;
onHover: (band: SeverityKey | null) => void;
}) {
const segments = donutSegments(
SEVERITY_BANDS.map((b) => ({ key: b.key, count: counts[b.key] })),
CIRCUMFERENCE,
GAP
);
return (
<div className="relative h-40 w-40 shrink-0">
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} className="h-40 w-40" aria-hidden="true">
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
strokeWidth={RING}
className="stroke-gray-100 dark:stroke-slate-700"
/>
{segments.map((s) => {
const band = SEVERITY_BANDS.find((b) => b.key === s.key)!;
return (
<circle
key={s.key}
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
strokeWidth={RING}
strokeDasharray={`${s.dash} ${CIRCUMFERENCE - s.dash}`}
strokeDashoffset={-s.offset}
transform={`rotate(-90 ${CENTER} ${CENTER})`}
opacity={highlight != null && highlight !== s.key ? 0.25 : 1}
className={`${band.stroke} cursor-pointer transition-opacity motion-reduce:transition-none`}
onClick={() => onSelect(band.key)}
onMouseEnter={() => onHover(band.key)}
onMouseLeave={() => onHover(null)}
>
<title>{`${band.label}: ${s.count.toLocaleString()} CVEs`}</title>
</circle>
);
})}
</svg>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-semibold tracking-tight text-gray-900 tabular-nums">{total.toLocaleString()}</span>
<span className="text-[11px] text-gray-500">distinct CVEs</span>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import type { CveExposureSummary } from "@/utils/wire-types.generated";
import { priorityChipClass, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
import { isFilterActive, type ExposureFilter } from "@/lib/vulnerabilityExposure";
import SeverityDonut, { SEVERITY_BANDS, type SeverityKey } from "./SeverityDonut";
/** The remediation ladder, top to bottom. Wording mirrors CvePriority.Compute. */
const LADDER = [
{ key: "act", label: "Act", title: "Known exploited", description: "Listed in CISA KEV. Patch first." },
{ key: "attend", label: "Attend", title: "High exploit risk", description: "EPSS ≥ 10% or CVSS ≥ 9.0" },
{ key: "track", label: "Track", title: "Everything else", description: "Patch in the regular cycle" },
] as const;
/**
* Left card of the exposure panel: the severity donut with its legend and the remediation
* priority ladder. Every legend row and ladder step toggles the shared list filter. Counts
* cover every CVE in the window, not just the listed ones.
*/
export default function SeverityPriorityCard({
summary,
filter,
onToggle,
}: {
summary: CveExposureSummary;
filter: ExposureFilter;
onToggle: (kind: "severity" | "priority", value: string) => void;
}) {
const [hovered, setHovered] = useState<SeverityKey | null>(null);
const severity = summary.severityBreakdown;
const counts: Record<SeverityKey, number> = {
critical: severity.critical,
high: severity.high,
medium: severity.medium,
low: severity.low,
};
const banded = counts.critical + counts.high + counts.medium + counts.low;
const highlight = hovered ?? (filter?.kind === "severity" ? filter.value : null);
return (
<section
aria-label="Severity and remediation priority"
className="bg-white rounded-lg shadow lg:col-span-2 lg:sticky lg:top-20"
>
<div className="p-5">
<h3 className="text-base font-semibold text-gray-900">Exposure by severity</h3>
<p className="mt-0.5 text-xs text-gray-500">
Distinct CVEs by their highest CVSS band. Select a band to filter the list.
</p>
<div className="mt-4 flex flex-col items-center gap-5 sm:flex-row lg:flex-col xl:flex-row">
<SeverityDonut
counts={counts}
total={summary.distinctCves}
highlight={highlight}
onSelect={(band) => onToggle("severity", band)}
onHover={setHovered}
/>
<div className="w-full min-w-0 space-y-0.5">
{SEVERITY_BANDS.map((band) => {
const n = counts[band.key];
const active = isFilterActive(filter, "severity", band.key);
return (
<button
key={band.key}
type="button"
aria-pressed={active}
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"
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${band.dot}`} aria-hidden="true" />
<span className="flex-1 text-gray-700">{band.label}</span>
<span className="font-semibold text-gray-900 tabular-nums">{n.toLocaleString()}</span>
<span className="w-10 text-right text-xs text-gray-400 tabular-nums">
{banded > 0 ? `${Math.round((n / banded) * 100)}%` : "–"}
</span>
</button>
);
})}
</div>
</div>
</div>
<div className="border-t border-gray-200 p-5">
<h3 className="text-base font-semibold text-gray-900" title={PRIORITY_TITLE}>
Remediation priority
</h3>
<p className="mt-0.5 text-xs text-gray-500">What to fix first. Ranked by exploitation evidence, not CVSS alone.</p>
<div className="mt-3 space-y-2">
{LADDER.map((step) => {
const n = summary.priorityBreakdown[step.key];
const active = isFilterActive(filter, "priority", step.key);
return (
<button
key={step.key}
type="button"
aria-pressed={active}
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"
}`}
>
<span className="w-14 shrink-0">
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${priorityChipClass(step.key)}`}
>
{step.label}
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-gray-900">{step.title}</span>
<span className="block text-xs text-gray-500">{step.description}</span>
</span>
<span
className={`text-lg font-semibold tabular-nums ${
step.key === "act" && n > 0 ? "text-red-600 dark:text-red-400" : "text-gray-900"
}`}
>
{n.toLocaleString()}
</span>
</button>
);
})}
</div>
</div>
</section>
);
}
@@ -0,0 +1,65 @@
import {
CONTEXT_PILL_CLASS,
KEV_BADGE_CLASS,
PRIORITY_TITLE,
epssTitle,
formatEpss,
severityChipClass,
severityLabel,
} from "@/lib/vulnerabilitySeverity";
const CHIP = "inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] whitespace-nowrap";
/** "High 8.8" in the band's colour; the score is left out when NVD gave none. */
export function SeverityChip({ severity, score }: { severity: string; score: number }) {
return (
<span className={`${CHIP} font-medium ${severityChipClass(severity)}`}>
{severityLabel(severity)}
{score > 0 ? ` ${score.toFixed(1)}` : ""}
</span>
);
}
/** CISA Known Exploited badge; a product with several KEV CVEs shows the count. */
export function KevBadge({ count = 1 }: { count?: number }) {
return (
<span
className={`${CHIP} font-semibold ${KEV_BADGE_CLASS}`}
title="Listed in the CISA Known Exploited Vulnerabilities catalog: exploitation is confirmed"
>
{count > 1 ? `${count} × KEV` : "KEV"}
</span>
);
}
export function EpssPill({ score }: { score: number }) {
return (
<span className={CONTEXT_PILL_CLASS} title={epssTitle(score)}>
{formatEpss(score)}
</span>
);
}
/** Act is already said by the KEV badge and Track is the default, so only Attend gets a pill. */
export function AttendPill({ priority }: { priority: string }) {
if ((priority || "").toLowerCase() !== "attend") return null;
return (
<span className={CONTEXT_PILL_CLASS} title={PRIORITY_TITLE}>
Attend
</span>
);
}
/** Opens the CVE's NVD page in a new tab. */
export function CveLink({ cveId }: { cveId: string }) {
return (
<a
href={`https://nvd.nist.gov/vuln/detail/${encodeURIComponent(cveId)}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-sm font-mono text-[13px] text-gray-900 hover:text-green-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500"
>
{cveId}
</a>
);
}
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import {
barPercent,
donutSegments,
filterLabel,
isFilterActive,
matchesFilter,
sortExposure,
toggleFilter,
} from "../vulnerabilityExposure";
describe("matchesFilter", () => {
const high = { cvssSeverity: "HIGH", priority: "act" };
it("passes everything without a filter", () => {
expect(matchesFilter(high, null)).toBe(true);
});
it("compares the band case-insensitively against the lower-case filter value", () => {
expect(matchesFilter(high, { kind: "severity", value: "high" })).toBe(true);
expect(matchesFilter(high, { kind: "severity", value: "critical" })).toBe(false);
expect(matchesFilter(high, { kind: "priority", value: "act" })).toBe(true);
expect(matchesFilter({ cvssSeverity: "Low", priority: "" }, { kind: "priority", value: "track" })).toBe(false);
});
});
describe("toggleFilter / isFilterActive / filterLabel", () => {
it("selects, replaces and clears", () => {
const high = toggleFilter(null, "severity", "high");
expect(high).toEqual({ kind: "severity", value: "high" });
expect(isFilterActive(high, "severity", "high")).toBe(true);
const act = toggleFilter(high, "priority", "act");
expect(act).toEqual({ kind: "priority", value: "act" });
expect(isFilterActive(act, "severity", "high")).toBe(false);
expect(toggleFilter(act, "priority", "act")).toBeNull();
});
it("labels severity and priority bands", () => {
expect(filterLabel(null)).toBeNull();
expect(filterLabel({ kind: "severity", value: "medium" })).toBe("Medium");
expect(filterLabel({ kind: "priority", value: "attend" })).toBe("Attend");
});
});
describe("sortExposure", () => {
const rows = [
{ id: "wide-track", priority: "track", affectedSessions: 1400 },
{ id: "kev-small", priority: "act", affectedSessions: 9 },
{ id: "kev-mid", priority: "act", affectedSessions: 123 },
{ id: "attend", priority: "attend", affectedSessions: 50 },
{ id: "legacy", priority: "", affectedSessions: 2000 },
];
it("puts the highest band first, then the most devices", () => {
expect(sortExposure(rows, "priority").map((r) => r.id)).toEqual(["kev-mid", "kev-small", "attend", "wide-track", "legacy"]);
});
it("ranks by devices alone and leaves the input untouched", () => {
expect(sortExposure(rows, "devices").map((r) => r.id)).toEqual(["legacy", "wide-track", "kev-mid", "attend", "kev-small"]);
expect(rows[0].id).toBe("wide-track");
});
});
describe("barPercent", () => {
it("scales to the maximum and keeps small values visible", () => {
expect(barPercent(50, 100)).toBe(50);
expect(barPercent(1, 1400)).toBe(1.5);
expect(barPercent(0, 100)).toBe(0);
expect(barPercent(5, 0)).toBe(0);
});
});
describe("donutSegments", () => {
const circumference = 400;
it("splits the ring by share, leaves a gap after each arc and drops empty bands", () => {
const segs = donutSegments(
[
{ key: "critical", count: 1 },
{ key: "high", count: 3 },
{ key: "medium", count: 0 },
],
circumference,
2
);
expect(segs.map((s) => s.key)).toEqual(["critical", "high"]);
expect(segs[0]).toMatchObject({ offset: 0, dash: 98 });
expect(segs[1]).toMatchObject({ offset: 100, dash: 298 });
});
it("keeps a sliver for a tiny share", () => {
const [tiny] = donutSegments([{ key: "low", count: 1 }, { key: "high", count: 999 }], circumference, 2);
expect(tiny.dash).toBeGreaterThan(0);
expect(tiny.dash).toBeLessThanOrEqual(1.5);
});
it("closes the ring for a single band and is empty without counts", () => {
expect(donutSegments([{ key: "high", count: 7 }, { key: "low", count: 0 }], circumference, 2)).toEqual([
{ key: "high", count: 7, dash: circumference, offset: 0 },
]);
expect(donutSegments([{ key: "high", count: 0 }], circumference, 2)).toEqual([]);
});
});
@@ -1,5 +1,42 @@
import { describe, it, expect } from "vitest";
import { severityChipClass, priorityChipClass, KEV_BADGE_CLASS } from "../vulnerabilitySeverity";
import {
severityChipClass,
priorityChipClass,
KEV_BADGE_CLASS,
formatEpss,
severityLabel,
priorityRank,
} from "../vulnerabilitySeverity";
describe("formatEpss", () => {
it("shows one decimal in the middle range", () => {
expect(formatEpss(0.04063)).toBe("EPSS 4.1%");
});
it("shows two decimals below 1% and from 99.95% up", () => {
expect(formatEpss(0.00742)).toBe("EPSS 0.74%");
expect(formatEpss(0.99979)).toBe("EPSS 99.98%");
expect(formatEpss(0.999)).toBe("EPSS 99.9%");
});
});
describe("severityLabel", () => {
it("title-cases the band whatever case it arrives in", () => {
expect(severityLabel("HIGH")).toBe("High");
expect(severityLabel("critical")).toBe("Critical");
expect(severityLabel("")).toBe("Unknown");
expect(severityLabel(undefined)).toBe("Unknown");
});
});
describe("priorityRank", () => {
it("orders act > attend > track > unknown", () => {
expect(priorityRank("ACT")).toBe(3);
expect(priorityRank("attend")).toBe(2);
expect(priorityRank("track")).toBe(1);
expect(priorityRank("")).toBe(0);
});
});
describe("severityChipClass", () => {
it("maps known severities case-insensitively", () => {
@@ -0,0 +1,99 @@
/**
* Pure list logic of the vulnerability exposure panel (filters, sorting, bar and donut
* geometry), kept out of the components so it is unit tested.
*/
import { priorityLabel, priorityRank, severityLabel } from "@/lib/vulnerabilitySeverity";
/**
* One active list filter: a severity band or a priority band, both lower-case wire values.
* The legend and the priority ladder toggle it; selecting the active band again clears it.
*/
export type ExposureFilter = { kind: "severity" | "priority"; value: string } | null;
export type ExposureSort = "priority" | "devices";
/** The fields a filter reads; a TopCve, a TopSoftware entry and a SoftwareCve all carry them. */
interface Banded {
cvssSeverity: string;
priority: string;
}
/**
* Whether an entry matches the filter by its severity or priority band. For a CVE that is its
* own band; a product carries only its worst values, so it matches by its most severe CVE and
* its highest priority.
*/
export function matchesFilter(item: Banded, filter: ExposureFilter): boolean {
if (!filter) return true;
const actual = filter.kind === "severity" ? item.cvssSeverity : item.priority;
return (actual || "").toLowerCase() === filter.value;
}
/** Selecting the active band clears the filter; any other band replaces it. */
export function toggleFilter(prev: ExposureFilter, kind: "severity" | "priority", value: string): ExposureFilter {
return prev && prev.kind === kind && prev.value === value ? null : { kind, value };
}
export function isFilterActive(filter: ExposureFilter, kind: "severity" | "priority", value: string): boolean {
return filter != null && filter.kind === kind && filter.value === value;
}
/** Human label of the active filter ("High", "Act"), or null without one. */
export function filterLabel(filter: ExposureFilter): string | null {
if (!filter) return null;
return filter.kind === "priority" ? priorityLabel(filter.value) ?? filter.value : severityLabel(filter.value);
}
/**
* "priority": highest band first, then affected devices. "devices": affected devices only.
* The sort is stable, so ties keep the server's order.
*/
export function sortExposure<T extends { priority: string; affectedSessions: number }>(
items: readonly T[],
sort: ExposureSort
): T[] {
return [...items].sort(
(a, b) =>
(sort === "priority" ? priorityRank(b.priority) - priorityRank(a.priority) : 0) ||
b.affectedSessions - a.affectedSessions
);
}
/** Width of a device bar in percent of the list maximum; a non-zero value stays visible. */
export function barPercent(value: number, max: number): number {
if (max <= 0 || value <= 0) return 0;
return Math.max((value / max) * 100, 1.5);
}
export interface DonutSegment {
key: string;
count: number;
/** Visible arc length along the ring. */
dash: number;
/** Arc length from the ring's start (12 o'clock) to where this segment begins. */
offset: number;
}
/**
* Arcs of a donut ring for the given counts. Every non-empty segment loses `gap` at its end so
* neighbours stay apart, but keeps at least a sliver; a single non-empty segment closes the ring.
* Empty segments are left out.
*/
export function donutSegments(
counts: ReadonlyArray<{ key: string; count: number }>,
circumference: number,
gap: number
): DonutSegment[] {
const total = counts.reduce((sum, c) => sum + Math.max(c.count, 0), 0);
if (total <= 0) return [];
const nonEmpty = counts.filter((c) => c.count > 0);
if (nonEmpty.length === 1) return [{ key: nonEmpty[0].key, count: nonEmpty[0].count, dash: circumference, offset: 0 }];
let start = 0;
return nonEmpty.map((c) => {
const length = (c.count / total) * circumference;
const segment = { key: c.key, count: c.count, dash: Math.max(length - gap, Math.min(length, 1.5)), offset: start };
start += length;
return segment;
});
}
@@ -30,10 +30,29 @@ export const KEV_BADGE_CLASS =
export const CONTEXT_PILL_CLASS =
"inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300";
/** "EPSS 12.3%" — one decimal, or two when the score is below 1% so small scores don't read as 0. */
/**
* "EPSS 12.3%" — one decimal, or two below 1% so small scores don't read as 0 and from 99.95%
* up so a near-certain score doesn't round to an impossible "100.0%".
*/
export function formatEpss(score: number): string {
const pct = score * 100;
return `EPSS ${pct < 1 ? pct.toFixed(2) : pct.toFixed(1)}%`;
return `EPSS ${pct < 1 || pct >= 99.95 ? pct.toFixed(2) : pct.toFixed(1)}%`;
}
/** "High" for "HIGH" / "high"; the wire carries the band in whatever case NVD delivered it. */
export function severityLabel(severity: string | null | undefined): string {
const s = (severity || "").trim().toLowerCase();
return s ? s.charAt(0).toUpperCase() + s.slice(1) : "Unknown";
}
/** Rank for "highest band first" sorting: act 3, attend 2, track 1, unknown 0. */
export function priorityRank(priority: string | null | undefined): number {
switch ((priority || "").toLowerCase()) {
case "act": return 3;
case "attend": return 2;
case "track": return 1;
default: return 0;
}
}
/** Tooltip for the EPSS pill; percentile optional (0..1). */
@@ -6310,6 +6310,14 @@
"name": "number"
}
},
{
"name": "distinctSoftware",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Distinct affected products, grouped as in TopSoftware, with at least one CVE in the window."
},
{
"name": "kevCves",
"type": {
@@ -6317,6 +6325,14 @@
"name": "number"
}
},
{
"name": "kevAffectedSessions",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Distinct affected sessions that carry at least one CISA KEV (known-exploited) CVE."
},
{
"name": "severityBreakdown",
"type": {
@@ -6339,7 +6355,19 @@
"kind": "ref",
"name": "TopCve"
}
}
},
"doc": "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."
},
{
"name": "topSoftware",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "TopSoftware"
}
},
"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": "truncated",
@@ -20595,6 +20623,62 @@
}
]
},
"SoftwareCve": {
"kind": "object",
"doc": "One CVE of a TopSoftware entry; AffectedSessions counts that product's sessions only.",
"fields": [
{
"name": "cveId",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "cvssSeverity",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "cvssScore",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "isKev",
"type": {
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "epssScore",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true
},
{
"name": "priority",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "affectedSessions",
"type": {
"kind": "primitive",
"name": "number"
}
}
]
},
"SoftwareInventoryResponse": {
"kind": "object",
"doc": "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.",
@@ -23760,6 +23844,94 @@
}
]
},
"TopSoftware": {
"kind": "object",
"doc": "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.",
"fields": [
{
"name": "product",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "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."
},
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Display name derived from the detected titles (shared leading words, or the most common title without its version and architecture)."
},
{
"name": "affectedSessions",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "cveCount",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "kevCount",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "cvssSeverity",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Highest CVSS severity band across the product's CVEs."
},
{
"name": "cvssScore",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "priority",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Highest act/attend/track band across the product's CVEs (empty when only legacy rows carry it)."
},
{
"name": "titles",
"type": {
"kind": "array",
"element": {
"kind": "primitive",
"name": "string"
}
},
"doc": "Raw display names the product was detected under, most sessions first (at most 3)."
},
{
"name": "cves",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "SoftwareCve"
}
},
"doc": "The product's most urgent CVEs (priority, then EPSS, then CVSS), at most 3; CveCount is the full count."
}
]
},
"TpmPssUnsupportedItem": {
"kind": "object",
"doc": "One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED.",
@@ -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;