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 { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; 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 { useNodes } from '@/context/NodeContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { SuppressionsPanel } from './SuppressionsPanel'; import { MisconfigAckPanel } from './MisconfigAckPanel'; import { useAuth } from '@/context/AuthContext'; 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 { isAdmin } = useAuth(); 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 { 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 [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false); const [demoteConfirm, setDemoteConfirm] = useState(false); const [demoteBusy, setDemoteBusy] = useState(false); 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 handleHonorSuppressionsToggle = async (enabled: boolean) => { setTrivyBusy('honor-suppressions'); try { const res = await apiFetch('/security/deploy-block-honor-suppressions', { 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) { if (!cancelled) setFleetRoleProbeFailed(true); return; } const data = await res.json(); if (cancelled) return; if (data?.role === 'control' || data?.role === 'replica') { setFleetRole(data.role); setFleetRoleProbeFailed(false); } else { setFleetRoleProbeFailed(true); } } catch { if (!cancelled) setFleetRoleProbeFailed(true); } })(); return () => { cancelled = true; }; }, [isRemote]); const handleDemote = async () => { setDemoteBusy(true); try { const res = await apiFetch('/fleet/role/demote', { method: 'POST', localOnly: true, body: JSON.stringify({ confirm: true }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err?.error || 'Demote failed'); } toast.success('Replica demoted to control'); setFleetRole('control'); setDemoteConfirm(false); fetchPolicies(); } catch (err) { toast.error((err as Error)?.message || 'Demote failed'); } finally { setDemoteBusy(false); } }; 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 : [ ...(isPaid ? [{ label: 'POLICIES', value: `${policies.length}` }] : []), { label: 'TRIVY', value: trivy.source === 'none' ? 'missing' : trivy.source, tone: trivy.source === 'none' ? 'warn' : 'value' as const, }, ], ); return (
{isPaid && isAdmin && !isRemote && !isReplica && (
Add policy
)} {!isRemote && isReplica && (
)} {!isRemote && fleetRoleProbeFailed && !isReplica && (
)}
Vulnerability Scanner {TRIVY_SOURCE_BADGES[trivy.source].label} {updateCheck?.updateAvailable && ( Update available to v{updateCheck.latest} )}
{isAdmin && trivy.source === 'none' && ( {trivyBusy === 'install' ? ( ) : ( )} Install Trivy )} {isAdmin && trivy.source === 'managed' && updateCheck?.updateAvailable && ( )} {isAdmin && 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' && isPaid && isAdmin && (

Check daily and install newer Trivy releases automatically.

)}
{isRemote && (
)} {!isRemote && loading && (
)} {isPaid && !isRemote && !loading && policies.length === 0 && ( } title="No scan policies configured" subtitle="Add one to enforce severity thresholds across your fleet." /> )} {isPaid && !isRemote && !loading && policies.map((policy) => (
{policy.name} max: {policy.max_severity} {policy.block_on_deploy === 1 && ( block )} {policy.enabled === 0 && ( disabled )}
{isAdmin && !isReplica && (
)}
Scope: {policy.stack_pattern ? ( {policy.stack_pattern} ) : ( all stacks )}
))} {isPaid && isAdmin && !isRemote && (

When on, a suppressed CVE no longer counts toward a block-on-deploy policy, so an accepted finding will not stop a deploy on this instance. Off by default: policies block on the raw scan result.

)} {!isRemote && } {!isRemote && } {isPaid && ( <>
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 })} />

Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert.

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

Disabled policies are skipped during evaluation.

setForm({ ...form, enabled: c })} />
setDialogOpen(false)}> Cancel } primary={ {saving ? 'Saving...' : editingId ? 'Update' : 'Create'} } />
!open && setDeleteId(null)} variant="destructive" kicker="SECURITY · DELETE · IRREVERSIBLE" title="Delete scan policy" confirmLabel="Delete" onConfirm={handleDelete} >

Removes the policy immediately. Existing scans are not affected.

)}

Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.

Removes every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again.

); }