mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+1
-1
Submodule internal updated: bc834c33b3...2b113df6a1
@@ -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,
|
||||
});
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user