feat(security): make CVE suppressions optionally honored by deploy-block policies (#1269)

* feat(security): make CVE suppressions optionally honored by deploy-block policies

Block-on-deploy policies evaluate the raw scan result, so a CVE an admin
has accepted in CVE Suppressions still blocks the deploy. Add an opt-in,
per-instance toggle ("Honor suppressions in deploy blocks", Settings ->
Security) that, when on, re-derives each image's severity from the
suppression-filtered findings before comparing to the policy threshold. A
deploy that proceeds only because suppressions dropped it below the gate is
recorded in the audit log. Default off, so the strict raw-scan behavior is
unchanged unless an operator enables it.

The setting governs the instance that runs the deploy and is not
fleet-replicated. The gate fails safe: a suppression-read error or an
empty detail set falls back to raw scan severity rather than dropping it.

Also surface a previously swallowed error in the CVE suppressions and
misconfig acknowledgement settings panels so a failed list load shows a
toast instead of an empty list.

* fix(security): gate on raw severity when preflight detail rows are truncated

The suppression-aware deploy gate re-derived image severity from the stored
vulnerability_details rows, assuming any non-empty set was complete. A cached
pre-deploy scan keeps the full aggregate counts but copies only a bounded slice
of detail rows, so recomputing from that slice could drop an unsuppressed
blocking CVE below the threshold and let a deploy through.

Guard the recompute: when the loaded detail rows do not match the scan's total
finding count, gate on the raw scan severity (never drops severity). Suppression
awareness still applies for scans whose details are stored in full, which is the
common case.
This commit is contained in:
Anso
2026-06-01 13:39:46 -04:00
committed by GitHub
parent d8f73f8203
commit 085267b466
14 changed files with 736 additions and 22 deletions
@@ -46,10 +46,9 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
const load = useCallback(async () => {
try {
const res = await apiFetch('/security/misconfig-acks', { localOnly: true });
if (res.ok) {
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
}
if (!res.ok) throw new Error('Failed to load acknowledgements');
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
} catch (err) {
console.error('Failed to load misconfig acknowledgements:', err);
toast.error('Failed to load acknowledgements');
@@ -74,7 +74,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
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'>(null);
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);
@@ -132,6 +132,25 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
}
};
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 });
@@ -501,6 +520,22 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</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} />}
@@ -48,10 +48,9 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const load = useCallback(async () => {
try {
const res = await apiFetch('/security/suppressions', { localOnly: true });
if (res.ok) {
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
}
if (!res.ok) throw new Error('Failed to load suppressions');
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
} catch (err) {
console.error('Failed to load suppressions:', err);
toast.error('Failed to load suppressions');
@@ -0,0 +1,75 @@
/**
* Coverage for MisconfigAckPanel load behavior.
*
* Mirrors the SuppressionsPanel regression: a non-ok acknowledgements response
* must surface an error toast rather than presenting an empty list as if there
* were no acknowledgements.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { MisconfigAcknowledgement } from '@/types/security';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { MisconfigAckPanel } from '../MisconfigAckPanel';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
function ack(overrides: Partial<MisconfigAcknowledgement> = {}): MisconfigAcknowledgement {
return {
id: 1,
rule_id: 'DS026',
stack_pattern: null,
reason: 'reverse proxy needs root',
created_by: 'admin',
created_at: 1_700_000_000_000,
expires_at: null,
replicated_from_control: 0,
active: true,
...overrides,
};
}
describe('MisconfigAckPanel', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedToast.error.mockReset();
});
it('surfaces an error toast when the acknowledgements load fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) });
render(<MisconfigAckPanel isReplica={false} />);
await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith('Failed to load acknowledgements'));
});
it('renders acknowledgements and does not toast on a successful load', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => [ack()] });
render(<MisconfigAckPanel isReplica={false} />);
await waitFor(() => expect(screen.getByText('DS026')).toBeInTheDocument());
expect(mockedToast.error).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
/**
* Coverage for SuppressionsPanel load behavior.
*
* Locks the regression fix where a non-ok suppressions response was swallowed
* silently: the panel must surface an error toast so a failed load is visible
* rather than presenting an empty list as if there were no suppressions.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { CveSuppression } from '@/types/security';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SuppressionsPanel } from '../SuppressionsPanel';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
function suppression(overrides: Partial<CveSuppression> = {}): CveSuppression {
return {
id: 1,
cve_id: 'CVE-2026-0001',
pkg_name: null,
image_pattern: null,
reason: 'accepted after review',
created_by: 'admin',
created_at: 1_700_000_000_000,
expires_at: null,
replicated_from_control: 0,
active: true,
...overrides,
};
}
describe('SuppressionsPanel', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedToast.error.mockReset();
});
it('surfaces an error toast when the suppressions load fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) });
render(<SuppressionsPanel isReplica={false} />);
await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith('Failed to load suppressions'));
});
it('renders suppressions and does not toast on a successful load', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => [suppression()] });
render(<SuppressionsPanel isReplica={false} />);
await waitFor(() => expect(screen.getByText('CVE-2026-0001')).toBeInTheDocument());
expect(mockedToast.error).not.toHaveBeenCalled();
});
});