mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
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:
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user