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:
+152
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user