feat(security): add triage status and OpenVEX justification parity (#1615)

* feat(security): add triage status and OpenVEX justification parity

Share triage options across SuppressionsPanel and the scan-sheet suppress dialog, require justification for not_affected and false_positive, and document the fields.

* fix(security): use design-system Select for triage dropdowns

Replace native selects so OpenVEX justification options use themed popover content instead of unreadable OS option lists in dark mode.

* fix(security): keep triage justification Select controlled

Pass an empty string instead of undefined so Radix Select does not flip between uncontrolled and controlled when the placeholder is shown.
This commit is contained in:
Anso
2026-07-11 19:26:26 -04:00
committed by GitHub
parent ce699864c1
commit e7ac496009
10 changed files with 481 additions and 99 deletions
@@ -214,6 +214,44 @@ describe('POST /api/security/suppressions', () => {
.send(minimal);
expect(dup.status).toBe(409);
});
it('defaults the triage status to accepted and justification to null', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(201);
expect(res.body.status).toBe('accepted');
expect(res.body.justification).toBeNull();
});
it('stores a status and justification round trip', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, status: 'not_affected', justification: 'vulnerable_code_not_present' });
expect(res.status).toBe(201);
expect(res.body.status).toBe('not_affected');
expect(res.body.justification).toBe('vulnerable_code_not_present');
});
it('rejects an invalid triage status', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, status: 'bogus' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/triage status/);
});
it('rejects an invalid justification code', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, justification: 'not-a-real-code' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/triage justification/);
});
});
describe('PUT /api/security/suppressions/:id', () => {
@@ -249,6 +287,28 @@ describe('PUT /api/security/suppressions/:id', () => {
expect(res.status).toBe(404);
});
it('updates the triage status and justification', async () => {
const db = DatabaseService.getInstance();
const created = db.createCveSuppression({
cve_id: 'CVE-2024-3001',
pkg_name: null,
image_pattern: null,
reason: 'original reason',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
const res = await request(app)
.put(`/api/security/suppressions/${created.id}`)
.set('Authorization', adminAuthHeader)
.send({ status: 'false_positive', justification: 'component_not_present' });
expect(res.status).toBe(200);
expect(res.body.status).toBe('false_positive');
expect(res.body.justification).toBe('component_not_present');
});
it('rejects writes on replicas', async () => {
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
@@ -440,7 +500,7 @@ describe('Control-side audit log entries (L2)', () => {
expect(entries[0].summary).not.toContain('\n');
expect(entries[0].summary).not.toContain('\r');
// The forged prefix the attacker tried to inject must not appear as a new
// logical entry the sanitiser turns control chars into `?` so the
// logical entry - the sanitiser turns control chars into `?` so the
// string still appears, but on a single line, framed inside the real entry.
expect(entries[0].summary).toContain('cve_suppression.create');
});
+2
View File
@@ -163,6 +163,8 @@ Response rows include an `active` boolean computed from the `expires_at` timesta
| `image_pattern` | string or `null` | no | Glob against image references. `null` applies fleet-wide. |
| `reason` | string | yes | Required justification, up to 2000 characters. Surfaced on every dimmed row. |
| `expires_at` | number or `null` | no | Unix timestamp in milliseconds. `null` for an indefinite suppression. |
| `status` | string | no | Triage decision. Defaults to `accepted`. One of `accepted`, `not_affected`, `false_positive`, `affected`, `needs_review`, `fixed`, `ignored`. |
| `justification` | string or `null` | no | OpenVEX justification code, prompted for by the Sencho UI when `status` is `not_affected` or `false_positive`. One of `vulnerable_code_not_present`, `vulnerable_code_not_in_execute_path`, `component_not_present`, `inline_mitigations_already_exist`. |
```bash
curl -X POST https://your-sencho-instance:1852/api/security/suppressions \
+4 -3
View File
@@ -32,19 +32,20 @@ The dialog has the following fields:
| Field | Description |
|-------|-------------|
| **CVE or advisory ID** | Required. Accepts both `CVE-YYYY-NNNN` and `GHSA-xxxx-xxxx-xxxx`. |
| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, fixed, ignored, or needs review, with an optional OpenVEX justification. A decided state (accepted, not affected, false positive, fixed, ignored) stops the finding from driving the action posture; "needs review" keeps it counted but still actionable. |
| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, affected, fixed, ignored, or needs review. A decided state (accepted, not affected, false positive, fixed, ignored) stops the finding from driving the action posture; "needs review" and "affected" keep it counted but still actionable. |
| **OpenVEX justification** | Required when the triage decision is not affected or false positive. Explains why the vulnerable code is not exploitable, for example vulnerable code not present, vulnerable code not in the execute path, component not present, or inline mitigations already exist. Carried into the OpenVEX export for that decision. |
| **Package (optional)** | Leave blank to suppress every occurrence of this CVE across every package, or pin a specific package name (e.g. `openssl`) to narrow the scope. |
| **Image pattern (optional)** | Glob applied to image references (`*` matches any sequence, case-sensitive). For example, `lscr.io/linuxserver/*` matches every LinuxServer image, and `*alpine*` matches anything containing `alpine`. Leave blank to apply fleet-wide. |
| **Reason** | Required. A short note explaining why the CVE is accepted. Surfaced on every suppressed row and on the hover title in scan results. Do not paste credentials, tokens, or vendor secrets, since reasons replicate fleet-wide. |
| **Expires in (days, optional)** | Number of days after which the suppression stops applying. Useful for "patched in the next release" entries. Leave blank for an indefinite suppression. |
<Frame>
<img src="/images/cve-suppressions/create-dialog.png" alt="New suppression dialog with kicker SUPPRESSIONS · NEW, title New suppression, and all five fields populated with example values for a Git CVE scoped to LinuxServer images" />
<img src="/images/cve-suppressions/create-dialog.png" alt="New suppression dialog with kicker SUPPRESSIONS · NEW, title New suppression, and the form fields populated with example values for a Git CVE scoped to LinuxServer images" />
</Frame>
### Suppressing directly from a scan result
The panel's empty state hints at the faster path: from any vulnerability scan, click the small shield icon at the right edge of a finding's row. The dialog opens pre-filled with the CVE ID and the package name from that row (both read-only in this flow), leaving you to add a Reason, an optional Image pattern, and an optional Expiry. This is the recommended workflow for everyday triage, because it keeps the scope as narrow as the originating finding. To broaden the scope (for example, to suppress across every package), create the rule from the **Security** page → **Suppressions** tab instead.
The panel's empty state hints at the faster path: from any vulnerability scan, click the small shield icon at the right edge of a finding's row. The dialog opens pre-filled with the CVE ID and the package name from that row (both read-only in this flow), leaving you to set the Triage decision (accepted risk by default), add a Reason, an optional Image pattern, and an optional Expiry. Choosing not affected or false positive also requires an OpenVEX justification. This is the recommended workflow for everyday triage, because it keeps the scope as narrow as the originating finding. To broaden the scope (for example, to suppress across every package), create the rule from the **Security** page → **Suppressions** tab instead.
### How specificity is resolved
+18
View File
@@ -9,6 +9,24 @@ class MockResizeObserver {
}
globalThis.ResizeObserver = globalThis.ResizeObserver ?? MockResizeObserver;
// jsdom does not implement these, but Radix Select's trigger reads them on
// pointerdown/keyboard open, so without a stub the click crashes before the
// listbox ever renders.
if (typeof Element !== 'undefined') {
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = () => {};
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = () => {};
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
}
type StorageName = 'localStorage' | 'sessionStorage';
class TestStorage {
@@ -39,6 +39,7 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ScanComparisonSheet } from './ScanComparisonSheet';
import { fetchAllScanVulnerabilities } from './VulnerabilityScanSheet.export';
import { apiFetch } from '@/lib/api';
@@ -57,17 +58,7 @@ import type {
ScanDetailTab,
TriageStatus,
} from '@/types/security';
// Triage decision options for the suppress dialog (value -> label). 'accepted'
// is the default: a plain suppress is an accepted risk.
const TRIAGE_STATUS_OPTIONS: ReadonlyArray<{ value: TriageStatus; label: string }> = [
{ value: 'accepted', label: 'Accepted risk' },
{ value: 'not_affected', label: 'Not affected' },
{ value: 'false_positive', label: 'False positive' },
{ value: 'needs_review', label: 'Needs review' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'ignored', label: 'Ignored until expiry' },
];
import { TRIAGE_STATUS_OPTIONS, TRIAGE_JUSTIFICATION_OPTIONS, TRIAGE_STATUS_HINT, openVexRequiresJustification, justificationForStatus } from '@/lib/triage';
interface VulnerabilityScanSheetProps {
scanId: number | null;
@@ -93,6 +84,7 @@ interface SuppressDialogState {
reason: string;
expiresInDays: string;
status: TriageStatus;
justification: string;
}
interface AckDialogState {
@@ -378,6 +370,7 @@ export function VulnerabilityScanSheet({
reason: '',
expiresInDays: '',
status: 'accepted',
justification: '',
});
}, []);
@@ -388,6 +381,11 @@ export function VulnerabilityScanSheet({
toast.error('A reason is required.');
return;
}
const justification = suppressForm.justification.trim();
if (openVexRequiresJustification(suppressForm.status) && !justification) {
toast.error('An OpenVEX justification is required for this triage decision.');
return;
}
const days = suppressForm.expiresInDays.trim();
let expiresAt: number | null = null;
if (days) {
@@ -410,6 +408,7 @@ export function VulnerabilityScanSheet({
reason,
expires_at: expiresAt,
status: suppressForm.status,
justification: openVexRequiresJustification(suppressForm.status) ? (justification || null) : null,
}),
});
if (!res.ok) {
@@ -1151,22 +1150,48 @@ export function VulnerabilityScanSheet({
</div>
<div className="space-y-2">
<Label htmlFor="suppress-status">Triage decision</Label>
<select
id="suppress-status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
<Select
value={suppressForm.status}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, status: e.target.value as TriageStatus } : f))
}
onValueChange={(v) => {
const status = v as TriageStatus;
setSuppressForm((f) =>
f ? { ...f, status, justification: justificationForStatus(status, f.justification) } : f,
);
}}
>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<SelectTrigger id="suppress-status" aria-label="Triage decision" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable.
{TRIAGE_STATUS_HINT}
</p>
</div>
{openVexRequiresJustification(suppressForm.status) && (
<div className="space-y-2">
<Label htmlFor="suppress-justification">OpenVEX justification</Label>
<Select
value={suppressForm.justification}
onValueChange={(v) =>
setSuppressForm((f) => (f ? { ...f, justification: v } : f))
}
>
<SelectTrigger id="suppress-justification" aria-label="OpenVEX justification" className="w-full">
<SelectValue placeholder="Select a justification" />
</SelectTrigger>
<SelectContent>
{TRIAGE_JUSTIFICATION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="suppress-reason">Reason</Label>
<textarea
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
const mockedFetch = vi.mocked(apiFetch);
const ok = (body: unknown) => ({ ok: true, json: async () => body }) as Response;
const VULN = {
id: 1, scan_id: 1, vulnerability_id: 'CVE-2026-1000', pkg_name: 'openssl', installed_version: '1.0',
fixed_version: null, severity: 'HIGH', title: null, description: null, primary_url: null,
};
const SCAN = {
id: 1, node_id: 1, image_ref: 'img:1', image_digest: null, scanned_at: Date.now(),
total_vulnerabilities: 1, critical_count: 0, high_count: 1, medium_count: 0, low_count: 0,
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
highest_severity: 'HIGH', os_info: null, trivy_version: '0.50.0', scan_duration_ms: 1, triggered_by: 'manual',
status: 'completed', error: null, stack_context: null, policy_evaluation: null,
};
function postSuppressionCall() {
return mockedFetch.mock.calls.find(
([url, opts]) => /\/security\/suppressions$/.test(String(url)) && (opts as { method?: string } | undefined)?.method === 'POST',
);
}
beforeEach(() => {
mockedFetch.mockReset();
(toast.error as ReturnType<typeof vi.fn>).mockClear();
mockedFetch.mockImplementation(((url: string, opts?: { method?: string }) => {
if (opts?.method === 'POST' && /\/security\/suppressions$/.test(url)) {
return Promise.resolve(ok({}));
}
if (/\/fleet\/role/.test(url)) return Promise.resolve(ok({ role: 'control' }));
if (/\/vulnerabilities\?limit=1000&offset=0/.test(url)) return Promise.resolve(ok({ items: [VULN], total: 1 }));
if (/\/secrets/.test(url) || /\/misconfigs/.test(url)) return Promise.resolve(ok({ items: [] }));
if (/\/security\/scans\/1$/.test(url)) return Promise.resolve(ok(SCAN));
return Promise.resolve(ok({ items: [] }));
}) as unknown as typeof apiFetch);
});
async function openSuppressDialog() {
render(<VulnerabilityScanSheet scanId={1} onClose={() => {}} canManageSuppressions />);
await waitFor(() => expect(screen.getByText('CVE-2026-1000')).toBeInTheDocument());
await userEvent.click(screen.getByTitle('Suppress this CVE'));
}
async function pickSelect(label: string, optionName: string) {
await userEvent.click(screen.getByRole('combobox', { name: label }));
await userEvent.click(await screen.findByRole('option', { name: optionName }));
}
describe('VulnerabilityScanSheet suppress dialog', () => {
it('requires an OpenVEX justification for a not-affected decision and clears it when switching away', async () => {
await openSuppressDialog();
await pickSelect('Triage decision', 'Not affected');
expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('Reason'), 'Vendor confirmed unreachable code path.');
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
expect(toast.error).toHaveBeenCalledWith('An OpenVEX justification is required for this triage decision.');
expect(postSuppressionCall()).toBeUndefined();
await pickSelect('Triage decision', 'Accepted risk');
expect(screen.queryByRole('combobox', { name: 'OpenVEX justification' })).not.toBeInTheDocument();
});
it('sends the triage status and justification when suppressing a CVE', async () => {
await openSuppressDialog();
await userEvent.type(screen.getByLabelText('Reason'), 'False positive confirmed by vendor.');
await pickSelect('Triage decision', 'False positive');
await pickSelect('OpenVEX justification', 'Inline mitigations already exist');
await userEvent.click(screen.getByRole('button', { name: 'Suppress' }));
await waitFor(() => expect(postSuppressionCall()).toBeTruthy());
const body = JSON.parse((postSuppressionCall()![1] as { body: string }).body);
expect(body.status).toBe('false_positive');
expect(body.justification).toBe('inline_mitigations_already_exist');
});
});
@@ -6,12 +6,22 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch } from '@/lib/api';
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
import type { CveSuppression } from '@/types/security';
import type { CveSuppression, TriageStatus } from '@/types/security';
import {
TRIAGE_STATUS_OPTIONS,
TRIAGE_JUSTIFICATION_OPTIONS,
TRIAGE_STATUS_HINT,
openVexRequiresJustification,
justificationForStatus,
triageStatusLabel,
triageJustificationLabel,
} from '@/lib/triage';
import { useAuth } from '@/context/AuthContext';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
@@ -23,6 +33,8 @@ interface SuppressionFormState {
imagePattern: string;
reason: string;
expiresInDays: string;
status: TriageStatus;
justification: string;
}
const EMPTY_FORM: SuppressionFormState = {
@@ -31,6 +43,8 @@ const EMPTY_FORM: SuppressionFormState = {
imagePattern: '',
reason: '',
expiresInDays: '',
status: 'accepted',
justification: '',
};
interface SuppressionsPanelProps {
@@ -88,13 +102,19 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
// Recompute the expiry as days from now so the same control edits it; blank
// means "no expiry". The CVE id and package scope are identity, not editable.
expiresInDays: row.expires_at === null ? '' : String(Math.max(1, Math.ceil((row.expires_at - Date.now()) / 86_400_000))),
status: row.status ?? 'accepted',
justification: row.justification ?? '',
});
setDialogOpen(true);
};
const handleStatusChange = (status: TriageStatus) => {
setForm((f) => ({ ...f, status, justification: justificationForStatus(status, f.justification) }));
};
const handleSave = async () => {
// CVE id and package scope identify a suppression, so they are only validated
// and sent on create; the edit endpoint updates reason, image pattern, expiry.
// and sent on create; the edit endpoint updates reason, image pattern, expiry, status, and justification.
const cveId = form.cveId.trim();
if (!editRow && !CVE_ID_RE.test(cveId)) {
toast.error('CVE must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx.');
@@ -105,6 +125,11 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
toast.error('A reason is required.');
return;
}
const justification = form.justification.trim();
if (openVexRequiresJustification(form.status) && !justification) {
toast.error('An OpenVEX justification is required for this triage decision.');
return;
}
let expiresAt: number | null = null;
const days = form.expiresInDays.trim();
if (days) {
@@ -115,17 +140,20 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
}
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
}
const sharedBody = {
image_pattern: form.imagePattern.trim() || null,
reason,
expires_at: expiresAt,
status: form.status,
justification: openVexRequiresJustification(form.status) ? (justification || null) : null,
};
setSaving(true);
try {
const res = editRow
? await apiFetch(`/security/suppressions/${editRow.id}`, {
method: 'PUT',
localOnly: true,
body: JSON.stringify({
image_pattern: form.imagePattern.trim() || null,
reason,
expires_at: expiresAt,
}),
body: JSON.stringify(sharedBody),
})
: await apiFetch('/security/suppressions', {
method: 'POST',
@@ -133,9 +161,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
body: JSON.stringify({
cve_id: cveId,
pkg_name: form.pkgName.trim() || null,
image_pattern: form.imagePattern.trim() || null,
reason,
expires_at: expiresAt,
...sharedBody,
}),
});
if (!res.ok) {
@@ -262,69 +288,80 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
{!loading && rows.length > 0 && (
<ScrollArea className="max-h-[420px] pr-2">
<ul className="divide-y divide-glass-border">
{pageItems.map((row) => (
<li key={row.id} className="py-2.5 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{row.cve_id}</span>
{row.pkg_name && (
<Badge variant="outline" className="text-[10px] font-mono">
{row.pkg_name}
{pageItems.map((row) => {
const justLabel = triageJustificationLabel(row.justification);
return (
<li key={row.id} className="py-2.5 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{row.cve_id}</span>
<Badge variant="outline" className="text-[10px]">
{triageStatusLabel(row.status)}
</Badge>
{row.pkg_name && (
<Badge variant="outline" className="text-[10px] font-mono">
{row.pkg_name}
</Badge>
)}
{row.image_pattern && (
<Badge variant="outline" className="text-[10px] font-mono truncate max-w-[220px]">
{row.image_pattern}
</Badge>
)}
{!row.active && (
<Badge variant="secondary" className="text-[10px]">expired</Badge>
)}
{row.replicated_from_control === 1 && (
<Badge variant="secondary" className="text-[10px]">replicated</Badge>
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-2">{row.reason}</div>
{justLabel && (
<div className="text-[11px] font-mono text-stat-subtitle">
Justification: {justLabel}
</div>
)}
{row.image_pattern && (
<Badge variant="outline" className="text-[10px] font-mono truncate max-w-[220px]">
{row.image_pattern}
</Badge>
)}
{!row.active && (
<Badge variant="secondary" className="text-[10px]">expired</Badge>
)}
{row.replicated_from_control === 1 && (
<Badge variant="secondary" className="text-[10px]">replicated</Badge>
)}
<div className="text-[11px] font-mono text-stat-subtitle">
by {row.created_by} - expires {formatExpiry(row)}
</div>
</div>
<div className="text-xs text-muted-foreground line-clamp-2">{row.reason}</div>
<div className="text-[11px] font-mono text-stat-subtitle">
by {row.created_by} - expires {formatExpiry(row)}
</div>
</div>
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
<div className="flex items-center gap-1 shrink-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stat-subtitle hover:text-stat-value"
onClick={() => openEdit(row)}
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Edit suppression</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteRow(row)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Remove suppression</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</li>
))}
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
<div className="flex items-center gap-1 shrink-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stat-subtitle hover:text-stat-value"
onClick={() => openEdit(row)}
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Edit suppression</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteRow(row)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Remove suppression</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</li>
);
})}
</ul>
</ScrollArea>
)}
@@ -335,8 +372,8 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
kicker={editRow ? 'SUPPRESSIONS · EDIT' : 'SUPPRESSIONS · NEW'}
title={editRow ? 'Edit suppression' : 'New suppression'}
description={editRow
? 'Update the reason, image scope, or expiry. The CVE and package scope are fixed.'
: 'Accept a CVE as known-benign so it stops triggering alerts across the fleet.'}
? 'Update the triage decision, reason, image scope, or expiry. The CVE and package scope are fixed.'
: 'Record a triage decision for a CVE so it stops triggering alerts across the fleet.'}
/>
<ModalBody>
<div className="space-y-2">
@@ -368,6 +405,40 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
onChange={(e) => setForm({ ...form, imagePattern: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="s-status">Triage decision</Label>
<Select value={form.status} onValueChange={(v) => handleStatusChange(v as TriageStatus)}>
<SelectTrigger id="s-status" aria-label="Triage decision" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{TRIAGE_STATUS_HINT}
</p>
</div>
{openVexRequiresJustification(form.status) && (
<div className="space-y-2">
<Label htmlFor="s-justification">OpenVEX justification</Label>
<Select
value={form.justification}
onValueChange={(v) => setForm({ ...form, justification: v })}
>
<SelectTrigger id="s-justification" aria-label="OpenVEX justification" className="w-full">
<SelectValue placeholder="Select a justification" />
</SelectTrigger>
<SelectContent>
{TRIAGE_JUSTIFICATION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="s-reason">Reason</Label>
<textarea
@@ -7,6 +7,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CveSuppression } from '@/types/security';
vi.mock('@/lib/api', () => ({
@@ -35,6 +36,11 @@ 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> };
async function pickSelect(label: string, optionName: string) {
await userEvent.click(screen.getByRole('combobox', { name: label }));
await userEvent.click(await screen.findByRole('option', { name: optionName }));
}
function suppression(overrides: Partial<CveSuppression> = {}): CveSuppression {
return {
id: 1,
@@ -73,4 +79,64 @@ describe('SuppressionsPanel', () => {
await waitFor(() => expect(screen.getByText('CVE-2026-0001')).toBeInTheDocument());
expect(mockedToast.error).not.toHaveBeenCalled();
});
async function openCreateDialog() {
render(<SuppressionsPanel isReplica={false} />);
await waitFor(() => expect(screen.getByText('Add suppression')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add suppression'));
}
function postCall() {
return mockedFetch.mock.calls.find(
([url, opts]) => url === '/security/suppressions' && (opts as { method?: string } | undefined)?.method === 'POST',
);
}
it('sends the triage status and justification when creating a suppression', async () => {
mockedFetch.mockImplementation(async (_url: string, opts?: { method?: string }) =>
opts?.method === 'POST' ? { ok: true, json: async () => ({}) } : { ok: true, json: async () => [] },
);
await openCreateDialog();
await userEvent.type(screen.getByLabelText('CVE or advisory ID'), 'CVE-2026-0002');
await userEvent.type(screen.getByLabelText('Reason'), 'Vendor confirmed the code path is unreachable.');
await pickSelect('Triage decision', 'Not affected');
await pickSelect('OpenVEX justification', 'Vulnerable code not present');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(postCall()).toBeTruthy());
const body = JSON.parse((postCall()![1] as { body: string }).body);
expect(body.status).toBe('not_affected');
expect(body.justification).toBe('vulnerable_code_not_present');
});
it('requires an OpenVEX justification when the decision is not affected', async () => {
mockedFetch.mockImplementation(async (_url: string, opts?: { method?: string }) =>
opts?.method === 'POST' ? { ok: true, json: async () => ({}) } : { ok: true, json: async () => [] },
);
await openCreateDialog();
await userEvent.type(screen.getByLabelText('CVE or advisory ID'), 'CVE-2026-0003');
await userEvent.type(screen.getByLabelText('Reason'), 'Needs a justification before this can save.');
await pickSelect('Triage decision', 'Not affected');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(mockedToast.error).toHaveBeenCalledWith('An OpenVEX justification is required for this triage decision.');
expect(postCall()).toBeUndefined();
});
it('clears the justification when switching to a decision that does not require one', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => [] });
await openCreateDialog();
await pickSelect('Triage decision', 'False positive');
await pickSelect('OpenVEX justification', 'Component not present');
expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toHaveTextContent('Component not present');
await pickSelect('Triage decision', 'Accepted risk');
expect(screen.queryByRole('combobox', { name: 'OpenVEX justification' })).not.toBeInTheDocument();
await pickSelect('Triage decision', 'Not affected');
expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toHaveTextContent(/Select a justification/i);
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { TriageJustification, TriageStatus } from '@/types/security';
export const TRIAGE_STATUS_OPTIONS: ReadonlyArray<{ value: TriageStatus; label: string }> = [
{ value: 'accepted', label: 'Accepted risk' },
{ value: 'not_affected', label: 'Not affected' },
{ value: 'false_positive', label: 'False positive' },
{ value: 'affected', label: 'Affected' },
{ value: 'needs_review', label: 'Needs review' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'ignored', label: 'Ignored until expiry' },
];
export const TRIAGE_JUSTIFICATION_OPTIONS: ReadonlyArray<{ value: TriageJustification; label: string }> = [
{ value: 'vulnerable_code_not_present', label: 'Vulnerable code not present' },
{ value: 'vulnerable_code_not_in_execute_path', label: 'Vulnerable code not in execute path' },
{ value: 'component_not_present', label: 'Component not present' },
{ value: 'inline_mitigations_already_exist', label: 'Inline mitigations already exist' },
];
export const TRIAGE_STATUS_HINT =
'How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review and affected stay counted but actionable.';
/** True when OpenVEX export needs a justification code for this triage status. */
export function openVexRequiresJustification(status: TriageStatus): boolean {
return status === 'not_affected' || status === 'false_positive';
}
/** Keep the current justification only when the new status still requires one. */
export function justificationForStatus(status: TriageStatus, justification: string): string {
return openVexRequiresJustification(status) ? justification : '';
}
export function triageStatusLabel(status: TriageStatus | undefined): string {
if (!status) return 'Accepted risk';
return TRIAGE_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
}
export function triageJustificationLabel(
justification: TriageJustification | null | undefined,
): string | null {
if (!justification) return null;
return TRIAGE_JUSTIFICATION_OPTIONS.find((o) => o.value === justification)?.label ?? justification;
}
+8 -1
View File
@@ -159,6 +159,13 @@ export interface VulnerabilityDetail {
export type TriageStatus =
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
/** OpenVEX-aligned justification codes (mirrors the backend TriageJustification). */
export type TriageJustification =
| 'vulnerable_code_not_in_execute_path'
| 'vulnerable_code_not_present'
| 'component_not_present'
| 'inline_mitigations_already_exist';
export interface CveSuppression {
id: number;
cve_id: string;
@@ -171,7 +178,7 @@ export interface CveSuppression {
replicated_from_control: number;
active: boolean;
status?: TriageStatus;
justification?: string | null;
justification?: TriageJustification | null;
}
export interface ScanSummary {