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 { TogglePill } from '@/components/ui/toggle-pill'; 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 { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react'; import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { SuppressionsPanel } from './SuppressionsPanel'; 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, }; const TRIVY_SOURCE_BADGES: Record<'managed' | 'host' | 'none', { label: string; variant: 'outline' | 'secondary' }> = { managed: { label: 'Installed (managed)', variant: 'outline' }, host: { label: 'Installed (host)', variant: 'outline' }, none: { label: 'Not installed', variant: 'secondary' }, }; const TRIVY_SOURCE_DESCRIPTIONS: Record<'managed' | 'host' | 'none', string | null> = { managed: null, host: 'Managed externally via the host binary. Install and updates are handled outside Sencho.', none: "Install Trivy into Sencho's data volume to enable image vulnerability scanning. No host mounts required.", }; const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: string; success: string }> = { install: { loading: 'Installing Trivy...', success: 'Trivy installed' }, update: { loading: 'Updating Trivy...', success: 'Trivy updated' }, uninstall: { loading: 'Removing Trivy...', success: 'Trivy removed' }, }; export function SecuritySection({ isPaid }: { isPaid: boolean }) { const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); const { license } = useLicense(); const isAdmiral = isPaid && license?.variant === 'admiral'; const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus(); const [trivyBusy, setTrivyBusy] = useState(null); const [uninstallConfirm, setUninstallConfirm] = useState(false); const [fleetRole, setFleetRole] = useState('control'); const isReplica = fleetRole === 'replica'; const runTrivyOp = async ( op: 'install' | 'update' | 'uninstall', path: string, method: 'POST' | 'DELETE', ) => { const { loading, success } = TRIVY_OP_LABELS[op]; setTrivyBusy(op); const toastId = toast.loading(loading); try { const res = await apiFetch(path, { method }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || `Trivy ${op} failed`); } toast.success(success); await Promise.all([refreshTrivy(), refreshUpdateCheck()]); } catch (err) { toast.error((err as Error)?.message || `Trivy ${op} failed`); } finally { toast.dismiss(toastId); setTrivyBusy(null); } }; const handleInstallTrivy = () => runTrivyOp('install', '/security/trivy-install', 'POST'); const handleUpdateTrivy = () => runTrivyOp('update', '/security/trivy-update', 'POST'); const handleUninstallTrivy = async () => { setUninstallConfirm(false); await runTrivyOp('uninstall', '/security/trivy-install', 'DELETE'); }; const handleAutoUpdateToggle = async (enabled: boolean) => { setTrivyBusy('auto-update'); try { const res = await apiFetch('/security/trivy-auto-update', { method: 'PUT', body: JSON.stringify({ enabled }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || 'Failed to update setting'); } await refreshTrivy(); } catch (err) { toast.error((err as Error)?.message || 'Failed to update setting'); } finally { setTrivyBusy(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) { setLoading(false); return; } if (isRemote) { setPolicies([]); setLoading(false); return; } fetchPolicies(); }, [isPaid, isRemote]); useEffect(() => { void refreshTrivy(); }, [activeNode?.id, refreshTrivy]); useEffect(() => { if (isRemote) return; let cancelled = false; (async () => { try { const res = await apiFetch('/fleet/role', { localOnly: true }); if (!res.ok) return; const data = await res.json(); if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) { setFleetRole(data.role); } } catch { /* fallback: treat as control if the check fails */ } })(); return () => { cancelled = true; }; }, [isRemote]); 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); } }; useMastheadStats( loading ? null : [ { label: 'POLICIES', value: `${policies.length}` }, { label: 'TRIVY', value: trivy.source === 'none' ? 'missing' : trivy.source, tone: trivy.source === 'none' ? 'warn' : 'value', }, ], ); if (!isPaid) { return (
); } return (
{!isRemote && !isReplica && (
Add policy
)} {!isRemote && isReplica && (
)}
Vulnerability Scanner {TRIVY_SOURCE_BADGES[trivy.source].label} {updateCheck?.updateAvailable && ( Update available to v{updateCheck.latest} )}
{isAdmiral && (
{trivy.source === 'none' && ( {trivyBusy === 'install' ? ( ) : ( )} Install Trivy )} {trivy.source === 'managed' && updateCheck?.updateAvailable && ( )} {trivy.source === 'managed' && ( )}
)}
{trivy.source === 'managed' && trivy.version && (
Version: v{trivy.version}
)} {TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && (
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}
)} {trivy.source === 'managed' && isAdmiral && (

Check daily and install newer Trivy releases automatically.

)}
{isRemote && (
)} {!isRemote && loading && (
)} {!isRemote && !loading && policies.length === 0 && ( } title="No scan policies configured" subtitle="Add one to enforce severity thresholds across your fleet." /> )} {!isRemote && !loading && policies.map((policy) => (
{policy.name} max: {policy.max_severity} {policy.block_on_deploy === 1 && ( block )} {policy.enabled === 0 && ( disabled )}
{!isReplica && (
)}
Scope: {policy.stack_pattern ? ( {policy.stack_pattern} ) : ( all stacks )}
))} {!isRemote && } {editingId ? 'Edit Policy' : 'New Policy'} Configure the severity threshold and scope for this scan policy.
setForm({ ...form, name: e.target.value })} />
setForm({ ...form, stack_pattern: e.target.value })} />

Glob-style pattern matched against stack names. Leave blank to apply to all stacks.

setForm({ ...form, max_severity: v as VulnSeverity })} />

Emit a critical alert when this policy is violated after a deploy.

setForm({ ...form, block_on_deploy: c })} />

Disabled policies are skipped during evaluation.

setForm({ ...form, enabled: c })} />
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
!open && setDeleteId(null)}> Delete scan policy? This removes the policy immediately. Existing scans are not affected. Cancel Delete Remove Trivy? This removes the managed Trivy binary. Vulnerability scanning will stop working until Trivy is reinstalled or a host binary is provided. Cancel Remove
); }