feat: vulnerability findings UI

Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.

The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.

Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
This commit is contained in:
2026-08-06 14:40:37 +01:00
parent 5dda3b5c4a
commit 84dfcfeac7
11 changed files with 963 additions and 0 deletions
+152
View File
@@ -902,6 +902,158 @@ export interface LicenseInfo {
deployment: string;
}
export type Severity = "critical" | "high" | "medium" | "low" | "unknown";
export type FindingState = "open" | "fixed" | "accepted";
export interface Acceptance {
by: string;
reason: string;
until: string;
at: string;
}
export interface VulnFinding {
id: string;
server_id: string;
cve_id: string;
package_name: string;
installed_version: string;
/** Absent means no vendor fix is published — a real state, not missing data. */
fixed_in?: string;
severity: Severity;
cvss_score?: number;
title?: string;
references?: string[];
state: FindingState;
first_seen: string;
last_seen: string;
fixed_at?: string;
accepted?: Acceptance;
}
/** One CVE across every server it affects. The board groups by CVE because the
* same CVE on forty servers is one decision, not forty rows. */
export interface VulnGroup {
cve_id: string;
severity: Severity;
title?: string;
server_count: number;
findings: VulnFinding[];
}
export interface VulnSummary {
counts: Partial<Record<Severity, number>>;
db_version?: number;
pulled_at?: string;
last_full_scan_at?: string;
last_error?: string;
}
export interface InstalledPackage {
name: string;
version: string;
epoch?: number;
arch: string;
source_name?: string;
}
export interface ServerPackages {
server_id: string;
os: { family: string; version_id: string; arch: string };
hash: string;
packages: InstalledPackage[];
collected_at: string;
scan_pending: boolean;
scanned_at?: string;
/** "ok" | "unsupported". Unsupported must never read as "clean". */
status: string;
db_version: number;
}
export interface PackageHit {
server_id: string;
name: string;
version: string;
}
export interface VulnAlertRule {
id: string;
name: string;
enabled: boolean;
min_severity: Severity;
tags?: Record<string, string>;
channel_ids: string[];
created_at: string;
updated_at: string;
}
export interface VulnAlertRuleInput {
name: string;
enabled: boolean;
min_severity: Severity;
tags?: Record<string, string>;
channel_ids: string[];
}
export const vulnerabilities = {
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
const q = new URLSearchParams();
if (params?.severity) q.set("severity", params.severity);
if (params?.state) q.set("state", params.state);
if (params?.server) q.set("server", params.server);
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
const qs = q.toString();
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
},
summary(): Promise<VulnSummary> {
return request<VulnSummary>("/vulnerabilities/summary");
},
rescan(): Promise<{ queued: number }> {
return request<{ queued: number }>("/vulnerabilities/rescan", { method: "POST" });
},
accept(id: string, reason: string, until: string): Promise<VulnFinding> {
return request<VulnFinding>(`/vulnerabilities/${id}/accept`, {
method: "POST",
body: JSON.stringify({ reason, until }),
});
},
unaccept(id: string): Promise<VulnFinding> {
return request<VulnFinding>(`/vulnerabilities/${id}/accept`, { method: "DELETE" });
},
forServer(serverId: string): Promise<VulnFinding[]> {
return request<VulnFinding[]>(`/servers/${serverId}/vulnerabilities`);
},
packagesForServer(serverId: string): Promise<ServerPackages | { reported: false }> {
return request<ServerPackages | { reported: false }>(`/servers/${serverId}/packages`);
},
searchPackages(name: string): Promise<PackageHit[]> {
return request<PackageHit[]>(`/packages/search?name=${encodeURIComponent(name)}`);
},
listRules(): Promise<VulnAlertRule[]> {
return request<VulnAlertRule[]>("/vuln-rules");
},
createRule(input: VulnAlertRuleInput): Promise<VulnAlertRule> {
return request<VulnAlertRule>("/vuln-rules", { method: "POST", body: JSON.stringify(input) });
},
updateRule(id: string, input: VulnAlertRuleInput): Promise<{ status: string }> {
return request<{ status: string }>(`/vuln-rules/${id}`, { method: "PUT", body: JSON.stringify(input) });
},
deleteRule(id: string): Promise<{ status: string }> {
return request<{ status: string }>(`/vuln-rules/${id}`, { method: "DELETE" });
},
};
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {