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
+5
View File
@@ -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() {
</dl>
</Card>
<div className="lg:col-span-2">
<ServerVulnerabilities serverId={server.server_id} />
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
@@ -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) => <ChannelRow key={ch.channel_id} ch={ch} />)
)}
</Card>
{/* Beside the channels it consumes rather than on its own page: a rule is
a routing decision about destinations configured directly above it. */}
<div className="mt-6">
<VulnAlertRulesCard />
</div>
</div>
);
}
+169
View File
@@ -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<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [accepting, setAccepting] = useState<VulnFinding | null>(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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-text-primary">Vulnerabilities</h1>
<p className="mt-1 text-sm text-text-secondary">
Installed packages matched against distribution security advisories.
</p>
</div>
{isAdmin && (
<Button variant="secondary" loading={rescan.isPending} onClick={() => rescan.mutate()}>
Rescan fleet
</Button>
)}
</div>
<DBFreshness summary={summary.data} />
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
{SEVERITY_ORDER.map((s) => (
<button
key={s}
onClick={() => setSeverity(severity === s ? "" : s)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${
severity === s ? "bg-surface-2" : "hover:bg-surface-2"
}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
</button>
))}
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
{total} open
</span>
</div>
<div className="flex gap-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
className={`rounded border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
>
{s}
</button>
))}
</div>
{groups.isLoading && <p className="text-sm text-text-secondary">Loading</p>}
{groups.error && <p className="text-sm text-danger">{(groups.error as Error).message}</p>}
{groups.data && groups.data.length === 0 && (
<div className="rounded-lg border border-border bg-surface px-5 py-8 text-center">
<p className="text-sm text-text-secondary">No {state} findings.</p>
<p className="mt-1 text-xs text-text-tertiary">
Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own
page rather than counted here.
</p>
</div>
)}
<div className="space-y-2">
{groups.data?.map((g) => (
<FindingRow
key={g.cve_id}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))}
</div>
{accepting && (
<AcceptDialog
finding={accepting}
serverName={serverName(accepting.server_id)}
pending={accept.isPending}
onClose={() => setAccepting(null)}
onAccept={(reason, until) => accept.mutate({ id: accepting.id, reason, until })}
/>
)}
</div>
);
}
+13
View File
@@ -121,9 +121,22 @@ function StepsIcon() {
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062zM12 8.25v3.75m0 3h.008v.008H12v-.008z"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
@@ -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;
}
+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> {