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
@@ -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 (
<Modal open onClose={onClose} title="Accept finding">
<div className="space-y-4">
<div className="rounded border border-border bg-well px-3 py-2 font-mono text-xs text-text-secondary">
{finding.cve_id} · {finding.package_name} on {serverName}
<br />
{finding.fixed_in ? `fixed in ${finding.fixed_in}` : "no fix published"}
</div>
<label className="block">
<span className="mb-1 block text-xs font-medium text-text-secondary">Reason</span>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="Why this cannot be fixed now"
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-text-secondary">Reopens on</span>
<input
type="date"
value={until}
onChange={(e) => setUntil(e.target.value)}
className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
{!validUntil && <span className="mt-1 block text-xs text-danger">Pick a future date.</span>}
</label>
<p className="text-xs text-text-tertiary">
The finding is hidden from counts and alerts until this date, then reopens automatically. Your name and reason are recorded in
the audit log.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button disabled={!canSubmit} onClick={() => onAccept(reason.trim(), untilDate.toISOString())}>
{pending ? "Accepting…" : "Accept"}
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,44 @@
import type { VulnSummary } from "@/lib/api";
import { ageHours, relativeTime } from "./SeverityVisuals";
/*
* Database freshness sits with the findings, not in settings.
*
* A fleet scanned against a three-week-old database must say so where its
* findings are read. Quietly reporting "0 open" against stale data is the same
* class of lie as reporting zero findings for a distribution we hold no feed
* for — it looks exactly like good news.
*/
// Past this the banner stops being informational and starts being a warning.
// trivy-db rebuilds every six hours, so a day without a successful pull already
// means something is wrong.
const STALE_AFTER_HOURS = 24;
export function DBFreshness({ summary }: { summary?: VulnSummary }) {
if (!summary) return null;
const age = ageHours(summary.pulled_at);
const stale = age === null || age > STALE_AFTER_HOURS;
if (!stale && !summary.last_error) {
return (
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
Vulnerability database v{summary.db_version ?? "?"} · pulled {relativeTime(summary.pulled_at)}
{summary.last_full_scan_at && <> · last scan {relativeTime(summary.last_full_scan_at)}</>}
</p>
);
}
return (
<div className="rounded-lg border border-warning/50 bg-warning/10 px-4 py-3">
<p className="text-sm font-medium text-warning">
{age === null ? "No vulnerability database has been pulled yet" : `Vulnerability database is ${relativeTime(summary.pulled_at)}`}
</p>
<p className="mt-1 text-xs text-text-secondary">
Findings below are matched against that data. Counts may be incomplete until a fresh pull succeeds.
</p>
{summary.last_error && <p className="mt-2 break-words font-mono text-xs text-text-tertiary">{summary.last_error}</p>}
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding, VulnGroup } from "@/lib/api";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per CVE, expandable to the servers it affects.
*
* The grouping is the point. The same CVE across forty servers is one decision
* — patch it, or accept it and say why — and a flat list of forty findings
* makes it look like forty decisions, which is how a board stops being read.
*/
interface Props {
group: VulnGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
// the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.findings.some((f) => f.fixed_in);
return (
<div className="rounded-lg border border-border bg-surface">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-surface-2"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
<span className="ml-auto whitespace-nowrap text-xs text-text-secondary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && <span className="whitespace-nowrap text-xs text-text-tertiary">no fix published</span>}
</button>
{open && (
<div className="border-t border-border">
{group.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0">
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
{serverName(f.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto flex gap-2">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
{f.fixed_in && f.state !== "fixed" && (
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
Apply updates
</Button>
)}
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Button, Card } from "@/components/ui";
import { vulnerabilities, type ServerPackages } from "@/lib/api";
import { SeverityBadge, SEVERITY_ORDER, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One server's findings and package inventory.
*
* The three states this must keep apart, because they look identical if you are
* careless and only one of them is good news:
*
* - the agent has never reported → "no inventory yet"
* - the distribution has no advisory feed → "unsupported"
* - scanned, nothing found → "no known vulnerabilities"
*/
function hasReported(p: ServerPackages | { reported: false } | undefined): p is ServerPackages {
return !!p && !("reported" in p);
}
export function ServerVulnerabilities({ serverId }: { serverId: string }) {
const findings = useQuery({
queryKey: ["vulnerabilities", "server", serverId],
queryFn: () => vulnerabilities.forServer(serverId),
});
const packages = useQuery({
queryKey: ["packages", serverId],
queryFn: () => vulnerabilities.packagesForServer(serverId),
});
const pkg = hasReported(packages.data) ? packages.data : undefined;
const open = (findings.data ?? []).filter((f) => f.state === "open");
const counts = SEVERITY_ORDER.map((s) => ({ severity: s, n: open.filter((f) => f.severity === s).length })).filter((c) => c.n > 0);
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Vulnerabilities</h2>
<Link href="/vulnerabilities">
<Button variant="ghost" size="sm">
Fleet board
</Button>
</Link>
</div>
<div className="px-6 py-5">
{packages.isLoading || findings.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !pkg ? (
<p className="text-sm text-text-secondary">
No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance&apos;s
licence.
</p>
) : pkg.status === "unsupported" ? (
<>
<p className="text-sm text-warning">
{pkg.os.family} {pkg.os.version_id} has no advisory feed, so this server cannot be scanned.
</p>
<p className="mt-1 text-xs text-text-tertiary">
This is not the same as having no vulnerabilities it means we cannot answer the question for this distribution.
</p>
</>
) : (
<>
<div className="flex flex-wrap items-center gap-4">
{counts.length === 0 ? (
<p className="text-sm text-success">No known vulnerabilities.</p>
) : (
counts.map((c) => (
<span key={c.severity} className="flex items-center gap-2">
<SeverityBadge severity={c.severity} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{c.n}</span>
</span>
))
)}
</div>
<p className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
{pkg.packages.length} packages · {pkg.os.family} {pkg.os.version_id} · collected {relativeTime(pkg.collected_at)}
{pkg.scan_pending && " · rescan queued"}
</p>
{open.length > 0 && (
<ul className="mt-4 space-y-2">
{open.slice(0, 10).map((f) => (
<li key={f.id} className="flex flex-wrap items-center gap-3 border-b border-border-soft pb-2 last:border-b-0">
<SeverityBadge severity={f.severity} />
<span className="font-mono text-sm text-text-primary">{f.cve_id}</span>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
</li>
))}
{open.length > 10 && (
<li className="pt-1 text-xs text-text-tertiary">and {open.length - 10} more on the fleet board.</li>
)}
</ul>
)}
</>
)}
</div>
</Card>
);
}
@@ -0,0 +1,67 @@
import { Badge } from "@/components/ui";
import type { Severity, FindingState } from "@/lib/api";
/*
* One place that knows how a severity looks, because the board, the server tab
* and the digest counts all draw the same five words and must not drift.
*
* Colour is never the whole message: every pill carries its word, and the three
* state variants add a dot, so the distinction survives a monochrome screen.
*/
export const SEVERITY_ORDER: Severity[] = ["critical", "high", "medium", "low", "unknown"];
const severityVariant: Record<Severity, "danger" | "warning" | "accent" | "neutral"> = {
critical: "danger",
high: "danger",
medium: "warning",
low: "accent",
unknown: "neutral",
};
export function severityRank(s: Severity): number {
switch (s) {
case "critical":
return 4;
case "high":
return 3;
case "medium":
return 2;
case "low":
return 1;
default:
return 0;
}
}
export function SeverityBadge({ severity }: { severity: Severity }) {
return <Badge variant={severityVariant[severity]}>{severity}</Badge>;
}
export function StateBadge({ state }: { state: FindingState }) {
if (state === "fixed") return <Badge variant="success">fixed</Badge>;
if (state === "accepted") return <Badge variant="warning">accepted</Badge>;
return <Badge variant="danger">open</Badge>;
}
/** relativeTime renders an age in the coarsest honest unit. */
export function relativeTime(iso?: string): string {
if (!iso) return "never";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "unknown";
const mins = Math.floor((Date.now() - then) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 48) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
/** ageHours is how the freshness banner decides whether to raise its voice. */
export function ageHours(iso?: string): number | null {
if (!iso) return null;
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return null;
return (Date.now() - then) / 3600000;
}
@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge, Button, Card } from "@/components/ui";
import { api, vulnerabilities, type Severity, type VulnAlertRule } from "@/lib/api";
import { SEVERITY_ORDER, SeverityBadge } from "./SeverityVisuals";
/*
* Alert rules live beside the channels they consume.
*
* A rule fires once per scan, not once per finding: the scheduler batches a
* whole tick into one digest, which is what stops a database refresh opening
* five hundred findings and sending five hundred messages.
*/
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";
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
export function VulnAlertRulesCard() {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
const rules = useQuery({ queryKey: ["vuln-rules"], queryFn: () => vulnerabilities.listRules() });
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const invalidate = () => qc.invalidateQueries({ queryKey: ["vuln-rules"] });
const remove = useMutation({
mutationFn: (id: string) => vulnerabilities.deleteRule(id),
onSuccess: invalidate,
});
const toggle = useMutation({
mutationFn: (r: VulnAlertRule) =>
vulnerabilities.updateRule(r.id, {
name: r.name,
enabled: !r.enabled,
min_severity: r.min_severity,
tags: r.tags,
channel_ids: r.channel_ids,
}),
onSuccess: invalidate,
});
const channelName = (id: string) => channels.data?.find((c) => c.channel_id === id)?.name ?? id;
return (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Vulnerability alerts</h2>
<p className="mt-0.5 text-sm text-text-secondary">
One digest per rule per scan, summarising what newly opened never one message per finding.
</p>
</div>
{!adding && (
<Button size="sm" variant="secondary" onClick={() => setAdding(true)}>
New rule
</Button>
)}
</div>
{adding && <RuleForm onDone={() => setAdding(false)} />}
{rules.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !rules.data || rules.data.length === 0 ? (
<p className="text-sm text-text-secondary">
No rules yet. Without one, findings appear on the board but nobody is told about them.
</p>
) : (
<ul className="divide-y divide-border-soft">
{rules.data.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-3 py-3">
<span className="text-sm font-medium text-text-primary">{r.name}</span>
<Badge variant={r.enabled ? "success" : "neutral"}>{r.enabled ? "enabled" : "disabled"}</Badge>
<span className="flex items-center gap-1.5 text-xs text-text-secondary">
at or above <SeverityBadge severity={r.min_severity} />
</span>
{r.tags && Object.keys(r.tags).length > 0 && (
<span className="font-mono text-xs text-text-tertiary">
{Object.entries(r.tags)
.map(([k, v]) => `${k}:${v}`)
.join(" ")}
</span>
)}
<span className="text-xs text-text-tertiary"> {r.channel_ids.map(channelName).join(", ")}</span>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="ghost" onClick={() => toggle.mutate(r)}>
{r.enabled ? "Disable" : "Enable"}
</Button>
<Button size="sm" variant="ghost" onClick={() => remove.mutate(r.id)}>
Delete
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
);
}
function RuleForm({ onDone }: { onDone: () => void }) {
const qc = useQueryClient();
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const [name, setName] = useState("");
const [minSeverity, setMinSeverity] = useState<Severity>("high");
const [selected, setSelected] = useState<string[]>([]);
const [tags, setTags] = useState("");
const create = useMutation({
mutationFn: () =>
vulnerabilities.createRule({
name: name.trim(),
enabled: true,
min_severity: minSeverity,
tags: parseTags(tags),
channel_ids: selected,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["vuln-rules"] });
onDone();
},
});
const canSave = name.trim().length > 0 && selected.length > 0 && !create.isPending;
return (
<div className="mb-5 space-y-3 rounded-lg border border-border bg-surface-2 p-4">
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Production criticals" />
</div>
<div>
<label className={labelClass}>Minimum severity</label>
<select className={inputClass} value={minSeverity} onChange={(e) => setMinSeverity(e.target.value as Severity)}>
{SEVERITY_ORDER.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Server tags (optional)</label>
<input className={inputClass} value={tags} onChange={(e) => setTags(e.target.value)} placeholder="env:prod role:web" />
<p className="mt-1 text-xs text-text-tertiary">
Space-separated key:value pairs. Resolved the same way a workflow resolves its targets.
</p>
</div>
<div>
<label className={labelClass}>Channels</label>
<div className="space-y-1">
{channels.data?.map((c) => (
<label key={c.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={selected.includes(c.channel_id)}
onChange={(e) =>
setSelected((prev) => (e.target.checked ? [...prev, c.channel_id] : prev.filter((id) => id !== c.channel_id)))
}
/>
{c.name} <span className="text-text-tertiary">({c.type})</span>
</label>
))}
{channels.data && channels.data.length === 0 && (
<p className="text-xs text-text-tertiary">No channels configured yet. A rule needs at least one.</p>
)}
</div>
</div>
{create.error && <p className="text-sm text-danger">{(create.error as Error).message}</p>}
<div className="flex justify-end gap-2">
<Button size="sm" variant="secondary" onClick={onDone}>
Cancel
</Button>
<Button size="sm" disabled={!canSave} loading={create.isPending} onClick={() => create.mutate()}>
Create
</Button>
</div>
</div>
);
}
/** parseTags reads "env:prod role:web" into a map. A pair without a colon is
* dropped rather than guessed at. */
function parseTags(raw: string): Record<string, string> | undefined {
const out: Record<string, string> = {};
for (const part of raw.split(/\s+/)) {
const idx = part.indexOf(":");
if (idx <= 0) continue;
out[part.slice(0, idx)] = part.slice(idx + 1);
}
return Object.keys(out).length > 0 ? out : undefined;
}