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"} +
+ +