backend+web: vulnerability summary carries a priority breakdown (act/attend/track); the exposure panel shows Act and Attend tiles and every severity or priority chip filters the top-CVE list (D-218)

Act equals the KEV set by construction (CvePriority: KEV => act), so the KEV tile becomes the Act tile and Attend joins it instead of a duplicate count. The breakdown is counted in the backend over every CVE in the window, like the severity breakdown, because the list is top-N. Both panel consumers read the generated CveExposureSummary type; the MCP tool description names the new block. Wire types regenerated for web and MCP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-06 22:30:36 +02:00
parent 2c7efa2733
commit e7df69efe4
14 changed files with 257 additions and 69 deletions
@@ -88,6 +88,24 @@ public class VulnerabilitySummaryAggregatorTests
Assert.Equal(4, r.DistinctCves);
}
[Fact]
public void BuildsPriorityBreakdownFromTheMergedBand_legacyRowsCountInNone()
{
var act = Row("CVE-A", "s1", "Critical", 9.8, kev: true); act.Priority = "act";
var attendHigh = Row("CVE-B", "s1", "Medium", 5.0); attendHigh.Priority = "attend";
var attendLow = Row("CVE-B", "s2", "Medium", 5.0); attendLow.Priority = "track"; // merged band = attend
var track = Row("CVE-C", "s2", "Low", 2.0); track.Priority = "TRACK"; // case-insensitive
var legacy = Row("CVE-D", "s3", "High", 7.5); // empty priority
var r = VulnerabilitySummaryAggregator.Aggregate(new[] { act, attendHigh, attendLow, track, legacy }, false, 20, 30, false, Now);
Assert.Equal(1, r.PriorityBreakdown.Act);
Assert.Equal(1, r.PriorityBreakdown.Attend);
Assert.Equal(1, r.PriorityBreakdown.Track);
Assert.Equal(4, r.DistinctCves);
Assert.Equal(r.KevCves, r.PriorityBreakdown.Act);
}
[Fact]
public void RanksTopCvesByAffectedSessionsThenScore_andRespectsTopN()
{
@@ -663,6 +663,7 @@ public class VulnerabilityWireParityTests
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
{
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 };
var topCves = new List<TopCve>
{
new TopCve
@@ -687,6 +688,7 @@ public class VulnerabilityWireParityTests
distinctCves = 3,
kevCves = 1,
severityBreakdown = breakdown,
priorityBreakdown = priorities,
topCves,
truncated = false,
},
@@ -698,6 +700,7 @@ public class VulnerabilityWireParityTests
DistinctCves = 3,
KevCves = 1,
SeverityBreakdown = breakdown,
PriorityBreakdown = priorities,
TopCves = topCves,
Truncated = false,
});
@@ -707,6 +710,7 @@ public class VulnerabilityWireParityTests
public void CveExposureSummary_omits_totalAffectedTenants_on_a_tenant_scoped_read()
{
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>();
AssertParity(
@@ -718,6 +722,7 @@ public class VulnerabilityWireParityTests
distinctCves = 0,
kevCves = 0,
severityBreakdown = breakdown,
priorityBreakdown = priorities,
topCves,
truncated = true,
},
@@ -729,6 +734,7 @@ public class VulnerabilityWireParityTests
DistinctCves = 0,
KevCves = 0,
SeverityBreakdown = breakdown,
PriorityBreakdown = priorities,
TopCves = topCves,
Truncated = true,
});
@@ -100,6 +100,13 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
Low = groups.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)),
};
var totalAffectedSessions = inWindow.Select(r => r.SessionId)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
@@ -118,6 +125,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
DistinctCves = groups.Count,
KevCves = groups.Count(c => c.IsKev),
SeverityBreakdown = breakdown,
PriorityBreakdown = priorities,
TopCves = topCves,
Truncated = truncated,
};
@@ -1206,6 +1206,7 @@ export interface CveExposureSummary {
distinctCves: number;
kevCves: number;
severityBreakdown: SeverityBreakdown;
priorityBreakdown: PriorityBreakdown;
topCves: TopCve[];
truncated: boolean;
}
@@ -3087,6 +3088,13 @@ export interface PreviewWhitelistTenantEntry {
tenantId: string;
}
/** Distinct-CVE counts grouped by their highest remediation priority (act/attend/track). Rows written before priorities existed carry no band and are counted in none - the three add up to at most DistinctCves. act equals the KEV set by construction (CvePriority.Compute: KEV ⇒ act). */
export interface PriorityBreakdown {
act: number;
attend: number;
track: number;
}
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
export interface ProgressGetSessionEventsResponse {
success: boolean;
@@ -1823,7 +1823,8 @@ 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, and the top CVEs ranked by how ' +
'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 ' +
'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, ' +
@@ -336,6 +336,7 @@ namespace AutopilotMonitor.Shared.Models
public int DistinctCves { get; set; }
public int KevCves { get; set; }
public SeverityBreakdown SeverityBreakdown { get; set; } = default!;
public PriorityBreakdown PriorityBreakdown { get; set; } = default!;
public IReadOnlyList<TopCve> TopCves { get; set; } = default!;
public bool Truncated { get; set; }
}
@@ -350,6 +351,20 @@ namespace AutopilotMonitor.Shared.Models
public int Low { get; set; }
}
/// <summary>
/// Distinct-CVE counts grouped by their highest remediation priority (act/attend/track). Rows
/// written before priorities existed carry no band and are counted in none - the three add up
/// to at most <see cref="CveExposureSummary.DistinctCves"/>. act equals the KEV set by
/// construction (CvePriority.Compute: KEV ⇒ act).
/// </summary>
// Declaration order == wire order.
public class PriorityBreakdown
{
public int Act { get; set; }
public int Attend { get; set; }
public int Track { get; set; }
}
/// <summary>
/// A single CVE ranked by how many devices it affects in scope. <see cref="EpssScore"/>
/// is the highest EPSS probability seen across the rows (null = unscored / legacy rows);
@@ -10,6 +10,7 @@ import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type {
AdminConfiguration as WireAdminConfiguration,
CveExposureSummary,
GetVulnerabilitySyncStatusResponse,
TriggerEpssSyncResponse,
TriggerMsrcSyncResponse,
@@ -18,26 +19,6 @@ import type {
UpdateAdminConfigurationResponse,
} from "@/utils/wire-types.generated";
interface VulnerabilityTopCve {
cveId: string;
cvssSeverity: string;
cvssScore: number;
isKev: boolean;
affectedSessions: number;
sampleSoftware: string[];
}
interface VulnerabilitySummary {
windowDays: number;
totalAffectedSessions: number;
totalAffectedTenants: number | null;
distinctCves: number;
kevCves: number;
severityBreakdown: { critical: number; high: number; medium: number; low: number };
topCves: VulnerabilityTopCve[];
truncated: boolean;
}
interface VulnerabilityDataSectionProps {
adminConfig: AdminConfiguration | null;
loadingConfig: boolean;
@@ -94,7 +75,7 @@ export function VulnerabilityDataSection({
} | null>(null);
// Fleet exposure overview (read-only; cross-tenant via /api/global/metrics/vulnerability)
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
const [summaryDays, setSummaryDays] = useState(30);
const [loadingSummary, setLoadingSummary] = useState(false);
@@ -144,7 +125,7 @@ export function VulnerabilityDataSection({
const refreshSummary = useCallback(async () => {
setLoadingSummary(true);
try {
setSummary(await fetchJson<VulnerabilitySummary>(api.metrics.globalVulnerability(summaryDays, 10), getAccessToken));
setSummary(await fetchJson<CveExposureSummary>(api.metrics.globalVulnerability(summaryDays, 10), getAccessToken));
} catch {
// Non-critical: the overview is auxiliary; config + sync still render.
setSummary(null);
@@ -4,7 +4,8 @@ import { useEffect, useState } from "react";
import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { scopedApi } from "@/lib/scopedApi";
import VulnerabilityExposurePanel, { type VulnerabilitySummary } from "@/components/VulnerabilityExposurePanel";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import type { CveExposureSummary } from "@/utils/wire-types.generated";
import type { SoftwareTabScope, TimeRange } from "./types";
import { rangeToDays } from "./types";
import { ApiError, fetchJson } from "@/lib/apiClient";
@@ -17,7 +18,7 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
const { addNotification } = useNotifications();
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
const [loading, setLoading] = useState(true);
// GA without a specific tenant selected sees the cross-tenant aggregate (incl. affected tenants).
@@ -32,7 +33,7 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
try {
setLoading(true);
const url = scopedApi.vulnerability(scope, days, TOP_N);
const summary = await fetchJson<VulnerabilitySummary>(url, getAccessToken);
const summary = await fetchJson<CveExposureSummary>(url, getAccessToken);
if (cancelled) return;
setSummary(summary);
} catch (err) {
@@ -1,34 +1,21 @@
"use client";
import { severityChipClass, KEV_BADGE_CLASS, CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
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";
export interface VulnerabilityTopCve {
cveId: string;
cvssSeverity: string;
cvssScore: number;
isKev: boolean;
/** FIRST EPSS probability (0..1); null/absent when unscored or for rows written before EPSS. */
epssScore?: number | null;
/** act / attend / track; empty for rows written before priorities existed. */
priority?: string;
affectedSessions: number;
sampleSoftware: string[];
}
export interface VulnerabilitySummary {
windowDays: number;
totalAffectedSessions: number;
totalAffectedTenants: number | null;
distinctCves: number;
kevCves: number;
severityBreakdown: { critical: number; high: number; medium: number; low: number };
topCves: VulnerabilityTopCve[];
truncated: boolean;
}
interface Props {
summary: VulnerabilitySummary | null;
summary: CveExposureSummary | null;
loading?: boolean;
/** Show the "Affected tenants" KPI (cross-tenant / Global Admin views only). */
showTenantCount?: boolean;
@@ -41,9 +28,23 @@ interface Props {
}
/**
* Read-only fleet/tenant vulnerability exposure: affected-device + CVE/KEV KPIs, a severity
* breakdown, 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.
* 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.
*/
export default function VulnerabilityExposurePanel({
summary,
@@ -56,6 +57,20 @@ export default function VulnerabilityExposurePanel({
title = "Vulnerability Exposure",
}: Props) {
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;
return (
<div className="bg-emerald-50 dark:bg-gray-700 border border-emerald-200 dark:border-emerald-600 rounded-lg p-4">
@@ -99,33 +114,76 @@ export default function VulnerabilityExposurePanel({
</div>
) : (
<div className="space-y-4">
{/* KPI tiles */}
<div className={`grid grid-cols-2 ${showTenantCount ? "sm:grid-cols-4" : "sm:grid-cols-3"} gap-3`}>
{/* 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!.kevCves} label="Known-Exploited (KEV)" />
<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 breakdown */}
<div className="flex flex-wrap gap-2">
{/* 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]) => (
<span key={label} className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full border text-xs font-medium ${severityChipClass(label)}`}>
{label}: {count.toLocaleString()}
</span>
<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="text-xs font-medium text-emerald-800 dark:text-emerald-200 mb-2">Top CVEs by affected devices</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">
{summary!.topCves.map((c) => (
{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">
@@ -157,6 +215,7 @@ export default function VulnerabilityExposurePanel({
</div>
))}
</div>
)}
</div>
{summary!.truncated && (
@@ -170,11 +229,42 @@ export default function VulnerabilityExposurePanel({
);
}
function Kpi({ value, label }: { value: number; label: string }) {
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">
<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>
);
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { severityChipClass, KEV_BADGE_CLASS } from "../vulnerabilitySeverity";
import { severityChipClass, priorityChipClass, KEV_BADGE_CLASS } from "../vulnerabilitySeverity";
describe("severityChipClass", () => {
it("maps known severities case-insensitively", () => {
@@ -14,6 +14,14 @@ describe("severityChipClass", () => {
});
});
describe("priorityChipClass", () => {
it("paints Act in the KEV red and the other bands neutral", () => {
expect(priorityChipClass("Act")).toBe(KEV_BADGE_CLASS);
expect(priorityChipClass("attend")).toContain("gray");
expect(priorityChipClass("track")).toContain("gray");
});
});
describe("KEV_BADGE_CLASS", () => {
it("is a red-toned chip", () => {
expect(KEV_BADGE_CLASS).toContain("red");
@@ -42,6 +42,16 @@ export function epssTitle(score: number, percentile?: number | null): string {
return percentile != null ? `${base} — higher than ${(percentile * 100).toFixed(1)}% of all scored CVEs` : base;
}
/**
* Chip classes for a priority band. Act reuses the KEV red (it IS the KEV set by definition);
* Attend and Track stay on the neutral outline so priority never reads as a second severity scale.
*/
export function priorityChipClass(priority: string): string {
return priority.toLowerCase() === "act"
? KEV_BADGE_CLASS
: "bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600";
}
/** Human label for the remediation priority band. */
export function priorityLabel(priority?: string | null): string | null {
switch ((priority || "").toLowerCase()) {
@@ -6110,6 +6110,13 @@
"name": "SeverityBreakdown"
}
},
{
"name": "priorityBreakdown",
"type": {
"kind": "ref",
"name": "PriorityBreakdown"
}
},
{
"name": "topCves",
"type": {
@@ -14466,6 +14473,33 @@
}
]
},
"PriorityBreakdown": {
"kind": "object",
"doc": "Distinct-CVE counts grouped by their highest remediation priority (act/attend/track). Rows written before priorities existed carry no band and are counted in none - the three add up to at most DistinctCves. act equals the KEV set by construction (CvePriority.Compute: KEV ⇒ act).",
"fields": [
{
"name": "act",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "attend",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "track",
"type": {
"kind": "primitive",
"name": "number"
}
}
]
},
"ProgressGetSessionEventsResponse": {
"kind": "object",
"doc": "Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed.",
@@ -1206,6 +1206,7 @@ export interface CveExposureSummary {
distinctCves: number;
kevCves: number;
severityBreakdown: SeverityBreakdown;
priorityBreakdown: PriorityBreakdown;
topCves: TopCve[];
truncated: boolean;
}
@@ -3087,6 +3088,13 @@ export interface PreviewWhitelistTenantEntry {
tenantId: string;
}
/** Distinct-CVE counts grouped by their highest remediation priority (act/attend/track). Rows written before priorities existed carry no band and are counted in none - the three add up to at most DistinctCves. act equals the KEV set by construction (CvePriority.Compute: KEV ⇒ act). */
export interface PriorityBreakdown {
act: number;
attend: number;
track: number;
}
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
export interface ProgressGetSessionEventsResponse {
success: boolean;