mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 21:48:45 +00:00
feat(security): gate deploys on exploitation risk, not just severity (#1432)
Scan-policy deploy gates can now block on a known-exploited CVE (CISA KEV) and on a fixable Critical/High finding, in addition to an optional severity threshold. New policies default risk-first (KEV and fixable on, severity off); existing policies keep their severity-only behavior. CVSS stays captured for context but is never the sole basis for a block, and a finding whose exploitability cannot be confirmed is treated as risky rather than safe (incomplete scan detail fails closed on KEV/fixable inputs). The decision logic is shared between the pre-deploy gate and the informational post-scan banner via a pure helper, so the two never disagree. Block messages and the block dialog now name the conditions an image matched. Backend and frontend gates move together, the new inputs replicate across the fleet, and a blocking policy with no active input is rejected on both sides.
This commit is contained in:
@@ -31,14 +31,23 @@ interface PolicyFormState {
|
||||
max_severity: VulnSeverity;
|
||||
block_on_deploy: boolean;
|
||||
enabled: boolean;
|
||||
block_on_severity: boolean;
|
||||
block_on_kev: boolean;
|
||||
block_on_fixable: boolean;
|
||||
}
|
||||
|
||||
// New policies default risk-first (block on known-exploited and on fixable
|
||||
// Critical/High), with the raw severity threshold off until the operator turns
|
||||
// it on. Existing policies keep whatever they were saved with.
|
||||
const EMPTY_FORM: PolicyFormState = {
|
||||
name: '',
|
||||
stack_pattern: '',
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: false,
|
||||
enabled: true,
|
||||
block_on_severity: false,
|
||||
block_on_kev: true,
|
||||
block_on_fixable: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -184,6 +193,9 @@ export function ScanPolicyManager() {
|
||||
max_severity: policy.max_severity,
|
||||
block_on_deploy: policy.block_on_deploy === 1,
|
||||
enabled: policy.enabled === 1,
|
||||
block_on_severity: policy.block_on_severity === 1,
|
||||
block_on_kev: policy.block_on_kev === 1,
|
||||
block_on_fixable: policy.block_on_fixable === 1,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -193,6 +205,12 @@ export function ScanPolicyManager() {
|
||||
toast.error('Policy name is required');
|
||||
return;
|
||||
}
|
||||
// A blocking policy with no active input would block nothing; the backend
|
||||
// rejects it too. Catch it here so the operator gets immediate feedback.
|
||||
if (form.block_on_deploy && !form.block_on_severity && !form.block_on_kev && !form.block_on_fixable) {
|
||||
toast.error('Enable at least one input (severity, KEV, or fixable) to block on deploy.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -201,6 +219,9 @@ export function ScanPolicyManager() {
|
||||
max_severity: form.max_severity,
|
||||
block_on_deploy: form.block_on_deploy ? 1 : 0,
|
||||
enabled: form.enabled ? 1 : 0,
|
||||
block_on_severity: form.block_on_severity ? 1 : 0,
|
||||
block_on_kev: form.block_on_kev ? 1 : 0,
|
||||
block_on_fixable: form.block_on_fixable ? 1 : 0,
|
||||
};
|
||||
const url = editingId ? `/security/policies/${editingId}` : '/security/policies';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
@@ -338,7 +359,7 @@ export function ScanPolicyManager() {
|
||||
<SettingsCallout
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
title="No scan policies configured"
|
||||
subtitle="Add one to enforce severity thresholds across your fleet."
|
||||
subtitle="Add one to gate deploys on known-exploited, fixable, or high-severity findings across your fleet."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -349,9 +370,21 @@ export function ScanPolicyManager() {
|
||||
<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_severity === 1 && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
max: {policy.max_severity}
|
||||
</Badge>
|
||||
)}
|
||||
{policy.block_on_kev === 1 && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
KEV
|
||||
</Badge>
|
||||
)}
|
||||
{policy.block_on_fixable === 1 && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
Fixable
|
||||
</Badge>
|
||||
)}
|
||||
{policy.block_on_deploy === 1 && (
|
||||
<Badge variant="destructive" className="text-[10px] shrink-0">
|
||||
block
|
||||
@@ -439,21 +472,62 @@ export function ScanPolicyManager() {
|
||||
</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 })}
|
||||
/>
|
||||
<Label>Block conditions</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
What makes this policy flag an image. Enable at least one to block on deploy. CVSS is always captured for context but never the sole basis.
|
||||
</p>
|
||||
<div className="rounded-lg border border-glass-border px-3 py-2.5 space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Severity threshold</Label>
|
||||
<p className="text-xs text-muted-foreground">Flag an image whose highest finding meets or exceeds the chosen severity.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
aria-label="Severity threshold"
|
||||
checked={form.block_on_severity}
|
||||
onChange={(c) => setForm({ ...form, block_on_severity: c })}
|
||||
/>
|
||||
</div>
|
||||
{form.block_on_severity && (
|
||||
<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 gap-3 rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Known-exploited (KEV)</Label>
|
||||
<p className="text-xs text-muted-foreground">Flag an image carrying a CVE on the CISA known-exploited list.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
aria-label="Known-exploited (KEV)"
|
||||
checked={form.block_on_kev}
|
||||
onChange={(c) => setForm({ ...form, block_on_kev: c })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Fixable Critical/High</Label>
|
||||
<p className="text-xs text-muted-foreground">Flag an image with a Critical or High finding that has a fix available.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
aria-label="Fixable Critical/High"
|
||||
checked={form.block_on_fixable}
|
||||
onChange={(c) => setForm({ ...form, block_on_fixable: c })}
|
||||
/>
|
||||
</div>
|
||||
</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.
|
||||
Reject a deploy before containers start when any image matches the block conditions above. With this off, the policy only evaluates and raises an alert.
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
aria-label="Block on deploy"
|
||||
checked={form.block_on_deploy}
|
||||
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* configured".
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/context/LicenseContext');
|
||||
@@ -70,3 +71,55 @@ it('shows the empty state when there are genuinely no policies', async () => {
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
const riskPolicy = {
|
||||
id: 1, name: 'risk-gate', node_id: null, node_identity: '', stack_pattern: null,
|
||||
max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1,
|
||||
block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1,
|
||||
replicated_from_control: 0, created_at: 1, updated_at: 1,
|
||||
};
|
||||
|
||||
it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => {
|
||||
setup({ isPaid: true });
|
||||
mockedFetch.mockImplementation((url: string) =>
|
||||
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])),
|
||||
);
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('risk-gate')).toBeInTheDocument());
|
||||
expect(screen.getByText('KEV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fixable')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^max:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the risk-first defaults (KEV + fixable on, severity off) when creating a policy', async () => {
|
||||
setup({ isPaid: true });
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Add policy'));
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'new-gate' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = mockedFetch.mock.calls.find(([url, opts]) => url === '/security/policies' && opts?.method === 'POST');
|
||||
expect(call).toBeTruthy();
|
||||
const body = JSON.parse((call![1] as { body: string }).body);
|
||||
expect(body).toMatchObject({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a save that turns on block-on-deploy with no active input', async () => {
|
||||
setup({ isPaid: true });
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Add policy'));
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'empty-gate' } });
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Known-exploited (KEV)' })); // KEV off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Fixable Critical/High' })); // fixable off
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'Block on deploy' })); // block-on-deploy on
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/at least one input/i));
|
||||
expect(mockedFetch.mock.calls.some(([url, opts]) => url === '/security/policies' && opts?.method === 'POST')).toBe(false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user