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, Info } from 'lucide-react'; import { SettingsCallout } from '@/components/settings/SettingsCallout'; import { SettingsPrimaryButton } from '@/components/settings/SettingsActions'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import type { FleetRole, 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, }; /** * Deploy-enforcement scan policies (block-on-deploy severity thresholds), the * honor-suppressions toggle, and the replica "managed by control" state. This * is the paid governance surface for the Security page Policies tab; it returns * null for Community (no enforcement management) so the catalog is all a * Community operator sees. Policies are control-governed: fetched localOnly and * shown only on the local node, mirroring how the rest of the fleet-governance * UI behaves. */ export function ScanPolicyManager() { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { status: trivy, refresh: refreshTrivy } = useTrivyStatus(); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); 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 [honorBusy, setHonorBusy] = 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 fetchPolicies = async () => { setLoadError(false); try { const res = await apiFetch('/security/policies', { localOnly: true }); if (!res.ok) { // A non-OK response must not read as "no policies configured", which // would falsely imply nothing is enforcing. setLoadError(true); return; } 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'); setLoadError(true); } finally { setLoading(false); } }; useEffect(() => { if (!isPaid || isRemote) { setLoading(false); return; } fetchPolicies(); }, [isPaid, isRemote]); useEffect(() => { if (!isPaid || isRemote) return; void refreshTrivy(); }, [isPaid, isRemote, activeNode?.id, refreshTrivy]); useEffect(() => { if (!isPaid || 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; }; }, [isPaid, isRemote]); const handleHonorSuppressionsToggle = async (enabled: boolean) => { setHonorBusy(true); 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 { setHonorBusy(false); } }; 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); } }; // Enforcement management is a paid governance surface; Community sees only the // policy-pack catalog above it. if (!isPaid) return null; return (

Deploy enforcement policies

{isAdmin && !isRemote && !isReplica && ( Add policy )}
{isRemote && (
)} {!isRemote && isReplica && (
)} {!isRemote && fleetRoleProbeFailed && !isReplica && (
)} {!isRemote && loading && (
)} {!isRemote && !loading && loadError && ( } title="Couldn't load scan policies" subtitle="Scan policies failed to load. Try again shortly." /> )} {!isRemote && !loading && !loadError && 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 )}
{isAdmin && !isReplica && (
)}
Scope: {policy.stack_pattern ? ( {policy.stack_pattern} ) : ( all stacks )}
))} {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.

)}
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 every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again.

); }