mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
54119be0c2
Rebalance several capabilities from the paid tier to the free Community tier: - Managed Trivy auto-update toggle is admin-only, no longer tier-gated. - Node labels (assign, view, manage) are available on every tier; cordon and FleetSync anchor reset stay paid. - Fleet topology layout modes (Hub, Grouped, Free) are available on Community. - Single-scan SBOM export (SPDX and CycloneDX) is admin-only on Community; SARIF export stays paid via a dedicated canExportSarif capability split out from the former shared SBOM flag. Backend route guards and frontend affordances are updated together, with tier and admin-role tests covering the Community-allowed and still-paid paths.
664 lines
25 KiB
TypeScript
664 lines
25 KiB
TypeScript
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<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 { activeNode } = useNodes();
|
|
const isRemote = activeNode?.type === 'remote';
|
|
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
|
|
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'honor-suppressions'>(null);
|
|
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
|
const [fleetRole, setFleetRole] = useState<FleetRole>('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 (
|
|
<div className="space-y-6">
|
|
{isPaid && isAdmin && !isRemote && !isReplica && (
|
|
<div className="flex justify-end">
|
|
<SettingsPrimaryButton size="sm" onClick={openCreate}>
|
|
<Plus className="w-4 h-4" />
|
|
Add policy
|
|
</SettingsPrimaryButton>
|
|
</div>
|
|
)}
|
|
|
|
{!isRemote && isReplica && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className="flex items-start justify-between gap-3 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
|
<div className="text-sm">
|
|
<div className="font-medium">Managed by control node</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => setDemoteConfirm(true)}
|
|
disabled={demoteBusy}
|
|
>
|
|
Demote to control
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!isRemote && fleetRoleProbeFailed && !isReplica && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
|
>
|
|
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
|
<div className="text-sm">
|
|
<div className="font-medium">Fleet role could not be determined</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Treating this instance as a control. Refresh the page to retry.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel 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">Vulnerability Scanner</span>
|
|
<Badge variant={TRIVY_SOURCE_BADGES[trivy.source].variant} className="text-[10px] shrink-0">
|
|
{TRIVY_SOURCE_BADGES[trivy.source].label}
|
|
</Badge>
|
|
{updateCheck?.updateAvailable && (
|
|
<Badge variant="secondary" className="text-[10px] shrink-0">
|
|
Update available to v{updateCheck.latest}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{isAdmin && trivy.source === 'none' && (
|
|
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
|
|
{trivyBusy === 'install' ? (
|
|
<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} />
|
|
)}
|
|
Install Trivy
|
|
</SettingsPrimaryButton>
|
|
)}
|
|
{isAdmin && trivy.source === 'managed' && updateCheck?.updateAvailable && (
|
|
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
|
|
{trivyBusy === 'update' ? (
|
|
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
|
)}
|
|
Update
|
|
</Button>
|
|
)}
|
|
{isAdmin && trivy.source === 'managed' && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
onClick={() => setUninstallConfirm(true)}
|
|
disabled={trivyBusy !== null}
|
|
>
|
|
Uninstall
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{trivy.source === 'managed' && trivy.version && (
|
|
<div className="text-xs text-stat-subtitle font-mono">Version: v{trivy.version}</div>
|
|
)}
|
|
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && (
|
|
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
|
|
)}
|
|
|
|
{trivy.source === 'managed' && isAdmin && (
|
|
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
|
<div>
|
|
<Label className="text-sm">Auto-update Trivy</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Check daily and install newer Trivy releases automatically.
|
|
</p>
|
|
</div>
|
|
<TogglePill
|
|
checked={trivy.autoUpdate}
|
|
onChange={handleAutoUpdateToggle}
|
|
disabled={trivyBusy !== null}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isRemote && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
|
>
|
|
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
|
<div className="text-sm">
|
|
<div className="font-medium">Scanner is per-node</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isRemote && loading && (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-20 w-full rounded-lg" />
|
|
<Skeleton className="h-20 w-full rounded-lg" />
|
|
</div>
|
|
)}
|
|
|
|
{isPaid && !isRemote && !loading && policies.length === 0 && (
|
|
<SettingsCallout
|
|
icon={<ShieldCheck className="h-4 w-4" />}
|
|
title="No scan policies configured"
|
|
subtitle="Add one to enforce severity thresholds across your fleet."
|
|
/>
|
|
)}
|
|
|
|
{isPaid && !isRemote && !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>
|
|
{isAdmin && !isReplica && (
|
|
<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>
|
|
))}
|
|
|
|
{isPaid && isAdmin && !isRemote && (
|
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-4 py-3">
|
|
<div className="min-w-0">
|
|
<Label className="text-sm">Honor suppressions in deploy blocks</Label>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
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.
|
|
</p>
|
|
</div>
|
|
<TogglePill
|
|
checked={trivy.honorSuppressionsOnDeploy}
|
|
onChange={handleHonorSuppressionsToggle}
|
|
disabled={trivyBusy !== null}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
|
|
|
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
|
|
|
|
{isPaid && (
|
|
<>
|
|
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
|
<ModalHeader
|
|
kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
|
|
title={editingId ? 'Edit policy' : 'New policy'}
|
|
description="Configure the severity threshold and scope for this scan policy."
|
|
/>
|
|
<ModalBody>
|
|
<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">
|
|
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.
|
|
</p>
|
|
</div>
|
|
<TogglePill
|
|
checked={form.block_on_deploy}
|
|
onChange={(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>
|
|
<TogglePill
|
|
checked={form.enabled}
|
|
onChange={(c) => setForm({ ...form, enabled: c })}
|
|
/>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter
|
|
secondary={
|
|
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
}
|
|
primary={
|
|
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
|
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
|
</SettingsPrimaryButton>
|
|
}
|
|
/>
|
|
</Modal>
|
|
|
|
<ConfirmModal
|
|
open={deleteId != null}
|
|
onOpenChange={(open) => !open && setDeleteId(null)}
|
|
variant="destructive"
|
|
kicker="SECURITY · DELETE · IRREVERSIBLE"
|
|
title="Delete scan policy"
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDelete}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Removes the policy immediately. Existing scans are not affected.
|
|
</p>
|
|
</ConfirmModal>
|
|
</>
|
|
)}
|
|
|
|
<ConfirmModal
|
|
open={uninstallConfirm}
|
|
onOpenChange={setUninstallConfirm}
|
|
variant="destructive"
|
|
kicker="TRIVY · REMOVE · IRREVERSIBLE"
|
|
title="Remove Trivy"
|
|
confirmLabel="Remove"
|
|
onConfirm={handleUninstallTrivy}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
|
|
</p>
|
|
</ConfirmModal>
|
|
|
|
<ConfirmModal
|
|
open={demoteConfirm}
|
|
onOpenChange={setDemoteConfirm}
|
|
variant="destructive"
|
|
kicker="FLEET · DEMOTE · IRREVERSIBLE"
|
|
title="Demote replica to control"
|
|
confirmLabel={demoteBusy ? 'Demoting...' : 'Demote'}
|
|
onConfirm={handleDemote}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Removes every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again.
|
|
</p>
|
|
</ConfirmModal>
|
|
</div>
|
|
);
|
|
}
|