From 84dfcfeac766c2bcc1d4a220789721a76c837e60 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 6 Aug 2026 14:40:37 +0100 Subject: [PATCH] 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. --- web/app/(app)/servers/[id]/page.tsx | 5 + web/app/(app)/settings/notifications/page.tsx | 7 + web/app/(app)/vulnerabilities/page.tsx | 169 +++++++++++++++ web/components/Sidebar.tsx | 13 ++ .../vulnerabilities/AcceptDialog.tsx | 86 ++++++++ .../vulnerabilities/DBFreshness.tsx | 44 ++++ web/components/vulnerabilities/FindingRow.tsx | 104 +++++++++ .../vulnerabilities/ServerVulnerabilities.tsx | 113 ++++++++++ .../vulnerabilities/SeverityVisuals.tsx | 67 ++++++ .../vulnerabilities/VulnAlertRulesCard.tsx | 203 ++++++++++++++++++ web/lib/api.ts | 152 +++++++++++++ 11 files changed, 963 insertions(+) create mode 100644 web/app/(app)/vulnerabilities/page.tsx create mode 100644 web/components/vulnerabilities/AcceptDialog.tsx create mode 100644 web/components/vulnerabilities/DBFreshness.tsx create mode 100644 web/components/vulnerabilities/FindingRow.tsx create mode 100644 web/components/vulnerabilities/ServerVulnerabilities.tsx create mode 100644 web/components/vulnerabilities/SeverityVisuals.tsx create mode 100644 web/components/vulnerabilities/VulnAlertRulesCard.tsx diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx index cb8105a..6d70617 100644 --- a/web/app/(app)/servers/[id]/page.tsx +++ b/web/app/(app)/servers/[id]/page.tsx @@ -9,6 +9,7 @@ import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; import { useLicense } from "@/lib/useLicense"; import { TagChips } from "@/components/servers/TagChips"; +import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities"; function statusVariant(status: ServerStatus) { switch (status) { @@ -549,6 +550,10 @@ export default function ServerDetailPage() { +
+ +
+
diff --git a/web/app/(app)/settings/notifications/page.tsx b/web/app/(app)/settings/notifications/page.tsx index 1b8a19e..0845d8d 100644 --- a/web/app/(app)/settings/notifications/page.tsx +++ b/web/app/(app)/settings/notifications/page.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api"; import { Badge, Button, Card } from "@/components/ui"; +import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard"; const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; @@ -182,6 +183,12 @@ export default function NotificationSettingsPage() { channels.map((ch) => ) )} + + {/* Beside the channels it consumes rather than on its own page: a rule is + a routing decision about destinations configured directly above it. */} +
+ +
); } diff --git a/web/app/(app)/vulnerabilities/page.tsx b/web/app/(app)/vulnerabilities/page.tsx new file mode 100644 index 0000000..6f15535 --- /dev/null +++ b/web/app/(app)/vulnerabilities/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api"; +import { useAuth } from "@/components/AuthProvider"; +import { Button } from "@/components/ui"; +import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog"; +import { DBFreshness } from "@/components/vulnerabilities/DBFreshness"; +import { FindingRow } from "@/components/vulnerabilities/FindingRow"; +import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals"; + +/* + * The fleet vulnerability board. + * + * Grouped by CVE, defaulting to open findings, with database freshness always + * on screen. The three things this page must never do: imply freshness it does + * not have, present an unsupported distribution as clean, or make one CVE on + * forty servers look like forty problems. + */ + +const STATES: FindingState[] = ["open", "accepted", "fixed"]; + +export default function VulnerabilitiesPage() { + const { isAdmin } = useAuth(); + const qc = useQueryClient(); + + const [state, setState] = useState("open"); + const [severity, setSeverity] = useState(""); + const [accepting, setAccepting] = useState(null); + + const groups = useQuery({ + queryKey: ["vulnerabilities", state, severity], + queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }), + }); + + const summary = useQuery({ + queryKey: ["vulnerabilities", "summary"], + queryFn: () => vulnerabilities.summary(), + }); + + const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + + const serverName = useMemo(() => { + const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname])); + // Falls back to the raw id rather than an empty cell: an unnamed row is + // worse than an ugly one. + return (id: string) => byId.get(id) ?? id; + }, [servers.data]); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["vulnerabilities"] }); + }; + + const rescan = useMutation({ + mutationFn: () => vulnerabilities.rescan(), + onSuccess: invalidate, + }); + + const accept = useMutation({ + mutationFn: ({ id, reason, until }: { id: string; reason: string; until: string }) => vulnerabilities.accept(id, reason, until), + onSuccess: () => { + setAccepting(null); + invalidate(); + }, + }); + + const unaccept = useMutation({ + mutationFn: (id: string) => vulnerabilities.unaccept(id), + onSuccess: invalidate, + }); + + const applyUpdates = useMutation({ + mutationFn: (serverId: string) => api.applyUpdates(serverId), + }); + + const counts = summary.data?.counts ?? {}; + const total = SEVERITY_ORDER.reduce((n, s) => n + (counts[s] ?? 0), 0); + + return ( +
+
+
+

Vulnerabilities

+

+ Installed packages matched against distribution security advisories. +

+
+ {isAdmin && ( + + )} +
+ + + +
+ {SEVERITY_ORDER.map((s) => ( + + ))} + + {total} open + +
+ +
+ {STATES.map((s) => ( + + ))} +
+ + {groups.isLoading &&

Loading…

} + {groups.error &&

{(groups.error as Error).message}

} + + {groups.data && groups.data.length === 0 && ( +
+

No {state} findings.

+

+ Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own + page rather than counted here. +

+
+ )} + +
+ {groups.data?.map((g) => ( + unaccept.mutate(f.id)} + onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)} + applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined} + /> + ))} +
+ + {accepting && ( + setAccepting(null)} + onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })} + /> + )} +
+ ); +} diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index d71b466..24c401a 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -121,9 +121,22 @@ function StepsIcon() { ); } +function ShieldIcon() { + return ( + + + + ); +} + const navItems: NavItem[] = [ { href: "/servers", label: "Servers", icon: }, { href: "/monitors", label: "Monitors", icon: }, + { href: "/vulnerabilities", label: "Vulnerabilities", icon: }, { href: "/keys", label: "SSH Keys", icon: }, { href: "/secrets", label: "Secrets", icon: }, { href: "/workflows", label: "Workflows", icon: }, diff --git a/web/components/vulnerabilities/AcceptDialog.tsx b/web/components/vulnerabilities/AcceptDialog.tsx new file mode 100644 index 0000000..3214142 --- /dev/null +++ b/web/components/vulnerabilities/AcceptDialog.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { Button, Modal } from "@/components/ui"; +import type { VulnFinding } from "@/lib/api"; + +/* + * Accepting a finding needs a reason and an expiry, and the API refuses without + * both. The expiry is the point: a permanent dismissal is where risk goes to be + * forgotten, and it is exactly what an auditor asks to see. This dialog says so + * in as many words, because someone clicking it a year later needs to know the + * finding will come back on its own. + */ + +const DEFAULT_DAYS = 30; + +function defaultUntil(): string { + const d = new Date(); + d.setDate(d.getDate() + DEFAULT_DAYS); + return d.toISOString().slice(0, 10); +} + +interface Props { + finding: VulnFinding; + serverName: string; + onClose: () => void; + onAccept: (reason: string, untilISO: string) => void; + pending?: boolean; +} + +export function AcceptDialog({ finding, serverName, onClose, onAccept, pending }: Props) { + const [reason, setReason] = useState(""); + const [until, setUntil] = useState(defaultUntil()); + + const untilDate = new Date(`${until}T23:59:59`); + const validUntil = !Number.isNaN(untilDate.getTime()) && untilDate.getTime() > Date.now(); + const canSubmit = reason.trim().length > 0 && validUntil && !pending; + + return ( + +
+
+ {finding.cve_id} · {finding.package_name} on {serverName} +
+ {finding.fixed_in ? `fixed in ${finding.fixed_in}` : "no fix published"} +
+ +