feat(images): Trivy-powered vulnerability scanning (#635)

* feat(images): Trivy-powered vulnerability scanning

Scan container images for known CVEs via Trivy. On-demand scanning and
severity badges are available on every tier; scheduled scans, scan
policies, SBOM generation, and scan history are gated to Skipper+.

- New TrivyService (binary detection, per-image scan, SBOM, digest cache)
- Three new tables: vulnerability_scans, vulnerability_details, scan_policies
- 12 routes under /api/security (scan, results, summaries, SBOM, policies, compare)
- Post-deploy async scans wired into all five deploy paths, with a
  per-deploy opt-out toggle in the App Store deploy sheet
- "scan" action type added to SchedulerService for fleet-wide recurring scans
- Frontend: severity badges in Resources Hub with animated cursor detail,
  scan results drawer with vulnerability table and filters, and a new
  Security section in Settings for scan policy CRUD
- Policy threshold violations dispatch a warning or critical alert based on
  the policy's block_on_deploy flag; deploys themselves are never blocked

* fix(security): compute scan age in useEffect to satisfy react-hooks/purity
This commit is contained in:
Anso
2026-04-16 15:03:36 -04:00
committed by GitHub
parent 4c5aa73196
commit c9cd6990d2
23 changed files with 3452 additions and 18 deletions
+29 -3
View File
@@ -6,7 +6,8 @@ import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from "lucide-react";
import { toast } from "@/components/ui/toast-store";
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
@@ -78,9 +79,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const [newEnvKey, setNewEnvKey] = useState('');
const [portsInUse, setPortsInUse] = useState<Record<string, PortInUseInfo>>({});
const [newEnvVal, setNewEnvVal] = useState('');
const [autoScan, setAutoScan] = useState(true);
const [trivyAvailable, setTrivyAvailable] = useState(false);
useEffect(() => {
fetchTemplates();
apiFetch('/security/trivy-status')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setTrivyAvailable(!!d.available); })
.catch((err) => {
console.error('Failed to fetch Trivy status:', err);
});
}, []);
const fetchTemplates = async () => {
@@ -215,7 +224,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
body: JSON.stringify({
stackName: stackName.trim(),
template: modifiedTemplate,
envVars: finalEnvVars
envVars: finalEnvVars,
skip_scan: !autoScan
})
});
const data = await res.json();
@@ -546,7 +556,23 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
</ScrollArea>
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full gap-3">
{trivyAvailable && (
<div className="flex items-center gap-2">
<Checkbox
id="auto-scan"
checked={autoScan}
onCheckedChange={(checked) => setAutoScan(!!checked)}
/>
<Label
htmlFor="auto-scan"
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
>
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
Scan images for vulnerabilities after deploy
</Label>
</div>
)}
<Button
onClick={handleDeploy}
disabled={isDeploying || !stackName.trim() || !can('stack:create')}
+172 -4
View File
@@ -18,6 +18,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2 } from 'lucide-react';
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import type { ScanSummary, VulnSeverity } from '@/types/security';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
@@ -273,6 +277,86 @@ function ManagedBadge({ status, managedBy }: {
return null;
}
// ── Severity Badge ─────────────────────────────────────────────────────────────
const SEVERITY_BADGE_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive',
HIGH: 'border-warning/25 bg-warning/8 text-warning',
MEDIUM: 'border-info/25 bg-info/8 text-info',
LOW: 'border-border bg-muted/30 text-muted-foreground',
UNKNOWN: 'border-border bg-muted/20 text-muted-foreground',
CLEAN: 'border-success/25 bg-success/8 text-success',
};
const SEVERITY_DOT_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
CRITICAL: 'bg-destructive',
HIGH: 'bg-warning',
MEDIUM: 'bg-info',
LOW: 'bg-muted-foreground/60',
UNKNOWN: 'bg-muted-foreground/40',
CLEAN: 'bg-success',
};
function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) {
const key: VulnSeverity | 'CLEAN' = summary.highest_severity ?? 'CLEAN';
const label = key === 'CLEAN' ? 'Clean' : key;
const [relative, setRelative] = useState<string>('');
useEffect(() => {
const compute = () => {
const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000);
setRelative(
scanAge < 1 ? 'just now'
: scanAge < 60 ? `${scanAge}m ago`
: scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago`
: `${Math.round(scanAge / 1440)}d ago`,
);
};
compute();
const id = setInterval(compute, 60000);
return () => clearInterval(id);
}, [summary.scanned_at]);
return (
<CursorProvider>
<CursorContainer className="inline-flex">
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
SEVERITY_BADGE_CLASSES[key],
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
{label}
</button>
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow side="bottom" align="end" sideOffset={8}>
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
<div className="font-mono tabular-nums text-xs space-y-1">
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
<div className="text-stat-value">{relative}</div>
{summary.total > 0 && (
<div className="flex gap-3 mt-1">
{summary.critical > 0 && <span className="text-destructive">{summary.critical}C</span>}
{summary.high > 0 && <span className="text-warning">{summary.high}H</span>}
{summary.medium > 0 && <span className="text-info">{summary.medium}M</span>}
{summary.low > 0 && <span className="text-muted-foreground">{summary.low}L</span>}
</div>
)}
{summary.total === 0 && (
<div className="text-success">No vulnerabilities</div>
)}
</div>
</div>
</CursorFollow>
</CursorProvider>
);
}
// ── Quick Clean Prune Button ───────────────────────────────────────────────────
interface PruneButtonProps {
@@ -380,13 +464,20 @@ export default function ResourcesView() {
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false);
// Vulnerability scanning state
const trivy = useTrivyStatus();
const [scanSummaries, setScanSummaries] = useState<Record<string, ScanSummary>>({});
const [scanningImageRef, setScanningImageRef] = useState<string | null>(null);
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
const fetchAllData = async () => {
setIsLoading(true);
try {
const [usageRes, resourcesRes, orphansRes] = await Promise.all([
const [usageRes, resourcesRes, orphansRes, summariesRes] = await Promise.all([
apiFetch('/system/docker-df'),
apiFetch('/system/resources'),
apiFetch('/system/orphans'),
apiFetch('/security/image-summaries').catch(() => null),
]);
if (usageRes.ok) setUsage(await usageRes.json());
@@ -400,6 +491,10 @@ export default function ResourcesView() {
setOrphans(await orphansRes.json());
setSelectedOrphans([]);
}
if (summariesRes && summariesRes.ok) {
const data = await summariesRes.json();
setScanSummaries(data ?? {});
}
} catch (err) {
console.error('Failed to fetch data', err);
toast.error('Failed to load resources data');
@@ -494,6 +589,48 @@ export default function ResourcesView() {
}
};
const handleScanImage = async (imageRef: string, force = false) => {
setScanningImageRef(imageRef);
const loadingId = toast.loading(`Scanning ${imageRef}...`);
try {
const res = await apiFetch('/security/scan', {
method: 'POST',
body: JSON.stringify({ imageRef, force }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || 'Failed to start scan');
const scanId = data.scanId as number;
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 3000));
const poll = await apiFetch(`/security/scans/${scanId}`);
if (!poll.ok) continue;
const poll_data = await poll.json();
if (poll_data.status !== 'in_progress') {
if (poll_data.status === 'failed') {
throw new Error(poll_data.error || 'Scan failed');
}
toast.success(`Scan complete: ${poll_data.total_vulnerabilities} vulnerabilities found`);
setInspectScanId(scanId);
const summariesRes = await apiFetch('/security/image-summaries');
if (summariesRes.ok) {
const summaries = await summariesRes.json();
setScanSummaries(summaries ?? {});
}
return;
}
}
throw new Error('Scan timed out');
} catch (error) {
const err = error as { message?: string };
toast.error(err?.message || 'Scan failed');
} finally {
toast.dismiss(loadingId);
setScanningImageRef(null);
}
};
const handleCreateNetwork = async () => {
setIsCreatingNetwork(true);
try {
@@ -720,12 +857,36 @@ export default function ResourcesView() {
{img.Containers > 0 ? "In Use" : "Unused"}
</Badge>
<ManagedBadge status={img.managedStatus} managedBy={img.managedBy} />
{(() => {
const tag = img.RepoTags?.[0];
const summary = tag ? scanSummaries[tag] : undefined;
if (!summary) return null;
return <SeverityBadge summary={summary} onClick={() => setInspectScanId(summary.scan_id)} />;
})()}
</div>
</TableCell>
<TableCell className="text-right">
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
<div className="flex items-center justify-end gap-1">
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
disabled={scanningImageRef === img.RepoTags[0]}
onClick={() => handleScanImage(img.RepoTags![0])}
title="Scan for vulnerabilities"
>
{scanningImageRef === img.RepoTags[0] ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
) : (
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
)}
</Button>
)}
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
</div>
</TableCell>
</TableRow>
))}
@@ -1306,6 +1467,13 @@ export default function ResourcesView() {
</ScrollArea>
</SheetContent>
</Sheet>
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }}
canGenerateSbom={isPaid}
/>
</div>
);
}
+9 -2
View File
@@ -16,7 +16,7 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
import {
Shield, Activity, Bell, Code, Server, Package, X,
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route,
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route, ShieldCheck,
} from 'lucide-react';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
@@ -33,6 +33,7 @@ import {
NotificationsSection,
NotificationRoutingSection,
WebhooksSection,
SecuritySection,
DeveloperSection,
AppStoreSection,
SupportSection,
@@ -44,7 +45,8 @@ import type { PatchableSettings, SectionId } from './settings';
const GLOBAL_ONLY_SECTIONS: ReadonlySet<SectionId> = new Set<SectionId>([
'account', 'license', 'users', 'sso', 'api-tokens', 'registries',
'labels', 'notifications', 'notification-routing', 'webhooks', 'nodes', 'appstore',
'labels', 'notifications', 'notification-routing', 'webhooks', 'security',
'nodes', 'appstore',
]);
interface SettingsModalProps {
@@ -305,6 +307,8 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
return <NotificationRoutingSection />;
case 'webhooks':
return <WebhooksSection isPaid={isPaid} />;
case 'security':
return <SecuritySection isPaid={isPaid} />;
case 'developer':
return (
<DeveloperSection
@@ -395,6 +399,9 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
{!isRemote && (
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
)}
{!isRemote && isAdmin && (
<NavButton section="security" icon={<ShieldCheck className="w-4 h-4 mr-2" />} label="Security" locked={!isPaid} />
)}
<NavButton
section="developer"
icon={<Code className="w-4 h-4 mr-2" />}
@@ -0,0 +1,408 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ShieldCheck,
ExternalLink,
ChevronLeft,
ChevronRight,
RefreshCw,
Download,
Loader2,
Check,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import type {
VulnerabilityScan,
VulnerabilityDetail,
VulnSeverity,
} from '@/types/security';
interface VulnerabilityScanSheetProps {
scanId: number | null;
onClose: () => void;
onRescan?: (imageRef: string) => void;
canGenerateSbom?: boolean;
}
type SeverityFilter = 'ALL' | VulnSeverity;
const PAGE_SIZE = 25;
const SEVERITY_CLASSES: Record<VulnSeverity, string> = {
CRITICAL: 'text-destructive border-destructive/40 bg-destructive/10',
HIGH: 'text-warning border-warning/40 bg-warning/10',
MEDIUM: 'text-info border-info/40 bg-info/10',
LOW: 'text-muted-foreground border-border bg-muted/30',
UNKNOWN: 'text-muted-foreground border-border bg-muted/20',
};
function SeverityChip({ severity }: { severity: VulnSeverity }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-wide',
SEVERITY_CLASSES[severity],
)}
>
{severity}
</span>
);
}
export function VulnerabilityScanSheet({
scanId,
onClose,
onRescan,
canGenerateSbom = false,
}: VulnerabilityScanSheetProps) {
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
const [loading, setLoading] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
const [page, setPage] = useState(0);
const [downloadingSbom, setDownloadingSbom] = useState(false);
const load = useCallback(async () => {
if (scanId == null) return;
setLoading(true);
try {
const [scanRes, detailsRes] = await Promise.all([
apiFetch(`/security/scans/${scanId}`),
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=500`),
]);
if (!scanRes.ok) throw new Error('Failed to fetch scan');
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
const scanData = await scanRes.json();
const detailsData = await detailsRes.json();
setScan(scanData);
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
setPage(0);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load scan');
} finally {
setLoading(false);
}
}, [scanId]);
useEffect(() => {
if (scanId != null) load();
else {
setScan(null);
setDetails([]);
setSeverityFilter('ALL');
setPage(0);
}
}, [scanId, load]);
const filtered = useMemo(() => {
if (severityFilter === 'ALL') return details;
return details.filter((d) => d.severity === severityFilter);
}, [details, severityFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = filtered.length > PAGE_SIZE;
const downloadSbom = useCallback(
async (format: 'spdx-json' | 'cyclonedx') => {
if (!scan) return;
setDownloadingSbom(true);
try {
const res = await apiFetch('/security/sbom', {
method: 'POST',
body: JSON.stringify({ imageRef: scan.image_ref, format }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to generate SBOM');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-sbom-${format}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('SBOM downloaded');
} catch (err) {
toast.error((err as Error)?.message || 'SBOM generation failed');
} finally {
setDownloadingSbom(false);
}
},
[scan],
);
const exportCsv = useCallback(() => {
if (!scan || details.length === 0) return;
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
const rows = details
.map((d) =>
[
escape(d.vulnerability_id),
escape(d.pkg_name),
escape(d.severity),
escape(d.installed_version),
escape(d.fixed_version ?? ''),
escape(d.primary_url ?? ''),
].join(','),
)
.join('\n');
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}, [scan, details]);
return (
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
<SheetHeader className="p-6 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 pr-6">
<ShieldCheck className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">
{scan?.image_ref ?? 'Loading...'}
</span>
</SheetTitle>
</SheetHeader>
{loading && !scan && (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
)}
{scan && (
<div className="flex flex-col flex-1 min-h-0">
{/* Summary stats */}
<div className="px-6 py-4 border-b space-y-3">
<div className="flex flex-wrap gap-2">
{scan.critical_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.CRITICAL)}>
{scan.critical_count} CRITICAL
</span>
)}
{scan.high_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.HIGH)}>
{scan.high_count} HIGH
</span>
)}
{scan.medium_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.MEDIUM)}>
{scan.medium_count} MEDIUM
</span>
)}
{scan.low_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.LOW)}>
{scan.low_count} LOW
</span>
)}
{scan.total_vulnerabilities === 0 && (
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase">
No vulnerabilities
</span>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Total</div>
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Fixable</div>
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Triggered</div>
<div className="font-mono text-stat-value">{scan.triggered_by}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Scanned</div>
<div className="font-mono text-stat-value">
{new Date(scan.scanned_at).toLocaleString()}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-2">
{onRescan && (
<Button
variant="outline"
size="sm"
onClick={() => onRescan(scan.image_ref)}
disabled={scan.status === 'in_progress'}
>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
Re-scan
</Button>
)}
{canGenerateSbom && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={downloadingSbom}>
{downloadingSbom ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
SBOM
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => downloadSbom('spdx-json')}>
SPDX JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadSbom('cyclonedx')}>
CycloneDX
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button variant="outline" size="sm" onClick={exportCsv} disabled={details.length === 0}>
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
CSV
</Button>
</div>
</div>
{/* Severity filter tabs */}
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
<Button
key={s}
variant={severityFilter === s ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => {
setSeverityFilter(s);
setPage(0);
}}
>
{s}
</Button>
))}
{needsPagination && (
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
{details.length === 0
? 'No vulnerabilities found.'
: 'No vulnerabilities match the selected filter.'}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">CVE</TableHead>
<TableHead>Package</TableHead>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[110px]">Installed</TableHead>
<TableHead className="w-[110px]">Fixed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-mono text-xs">
{d.primary_url ? (
<a
href={d.primary_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
</TableCell>
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={d.pkg_name}>
{d.pkg_name}
</TableCell>
<TableCell>
<SeverityChip severity={d.severity} />
</TableCell>
<TableCell className="font-mono text-xs">{d.installed_version}</TableCell>
<TableCell className="font-mono text-xs">
{d.fixed_version ? (
<span className="inline-flex items-center gap-1 text-success">
<Check className="w-3 h-3" strokeWidth={1.5} />
{d.fixed_version}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
);
}
export { SeverityChip };
export type { VulnerabilityScanSheetProps };
@@ -0,0 +1,357 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { PaidGate } from '@/components/PaidGate';
import { TierBadge } from '@/components/TierBadge';
import { ShieldCheck, Plus, Trash2, Pencil } from 'lucide-react';
import type { ScanPolicy, VulnSeverity } from '@/types/security';
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
{ value: 'CRITICAL', label: 'Critical' },
{ value: 'HIGH', label: 'High' },
{ value: 'MEDIUM', label: 'Medium' },
{ value: 'LOW', label: 'Low' },
];
interface PolicyFormState {
name: string;
stack_pattern: string;
max_severity: VulnSeverity;
block_on_deploy: boolean;
enabled: boolean;
}
const EMPTY_FORM: PolicyFormState = {
name: '',
stack_pattern: '',
max_severity: 'CRITICAL',
block_on_deploy: false,
enabled: true,
};
export function SecuritySection({ isPaid }: { isPaid: boolean }) {
const [policies, setPolicies] = useState<ScanPolicy[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<PolicyFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const fetchPolicies = async () => {
try {
const res = await apiFetch('/security/policies', { localOnly: true });
if (res.ok) {
const data = await res.json();
setPolicies(Array.isArray(data) ? data : []);
}
} catch (err) {
console.error('Failed to load scan policies:', err);
toast.error('Failed to load scan policies');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isPaid) fetchPolicies();
else setLoading(false);
}, [isPaid]);
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (policy: ScanPolicy) => {
setEditingId(policy.id);
setForm({
name: policy.name,
stack_pattern: policy.stack_pattern ?? '',
max_severity: policy.max_severity,
block_on_deploy: policy.block_on_deploy === 1,
enabled: policy.enabled === 1,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('Policy name is required');
return;
}
setSaving(true);
try {
const payload = {
name: form.name.trim(),
stack_pattern: form.stack_pattern.trim() || null,
max_severity: form.max_severity,
block_on_deploy: form.block_on_deploy ? 1 : 0,
enabled: form.enabled ? 1 : 0,
};
const url = editingId ? `/security/policies/${editingId}` : '/security/policies';
const method = editingId ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
localOnly: true,
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save policy');
}
toast.success(editingId ? 'Policy updated' : 'Policy created');
setDialogOpen(false);
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to save policy');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (deleteId == null) return;
try {
const res = await apiFetch(`/security/policies/${deleteId}`, {
method: 'DELETE',
localOnly: true,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to delete policy');
}
toast.success('Policy deleted');
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete policy');
} finally {
setDeleteId(null);
}
};
if (!isPaid) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Security <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Define vulnerability scan policies that gate or warn on deploys.
</p>
</div>
<PaidGate featureName="Scan Policies">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</PaidGate>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Security <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Policies evaluate every post-deploy scan and alert (or block) when severity exceeds the threshold.
</p>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Policy
</Button>
</div>
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
)}
{!loading && policies.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scan policies configured.</p>
<p className="text-xs text-muted-foreground mt-1">
Add one to enforce severity thresholds across your fleet.
</p>
</div>
)}
{!loading &&
policies.map((policy) => (
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm truncate">{policy.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
max: {policy.max_severity}
</Badge>
{policy.block_on_deploy === 1 && (
<Badge variant="destructive" className="text-[10px] shrink-0">
block
</Badge>
)}
{policy.enabled === 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
disabled
</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(policy)}
>
<Pencil className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteId(policy.id)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
Scope: {policy.stack_pattern ? (
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">{policy.stack_pattern}</code>
) : (
<span className="italic">all stacks</span>
)}
</div>
</div>
))}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle>
<DialogDescription className="sr-only">
Configure the severity threshold and scope for this scan policy.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="policy-name">Name</Label>
<Input
id="policy-name"
placeholder="Production block on critical"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
<Input
id="policy-pattern"
placeholder="e.g. prod-* or leave blank for all"
value={form.stack_pattern}
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
</p>
</div>
<div className="space-y-2">
<Label>Max severity</Label>
<Combobox
options={SEVERITY_OPTIONS}
value={form.max_severity}
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Block on deploy</Label>
<p className="text-xs text-muted-foreground">
Emit a critical alert when this policy is violated after a deploy.
</p>
</div>
<Switch
checked={form.block_on_deploy}
onCheckedChange={(c) => setForm({ ...form, block_on_deploy: c })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Enabled</Label>
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(c) => setForm({ ...form, enabled: c })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle>
<AlertDialogDescription>
This removes the policy immediately. Existing scans are not affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -4,6 +4,7 @@ export { UsersSection } from './UsersSection';
export { SystemSection } from './SystemSection';
export { NotificationsSection } from './NotificationsSection';
export { WebhooksSection } from './WebhooksSection';
export { SecuritySection } from './SecuritySection';
export { DeveloperSection } from './DeveloperSection';
export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection';
@@ -37,6 +37,7 @@ export type SectionId =
| 'system'
| 'notifications'
| 'webhooks'
| 'security'
| 'developer'
| 'nodes'
| 'appstore'