diff --git a/backend/src/__tests__/suppression-routes.test.ts b/backend/src/__tests__/suppression-routes.test.ts
index 87891cfd..1ed76e59 100644
--- a/backend/src/__tests__/suppression-routes.test.ts
+++ b/backend/src/__tests__/suppression-routes.test.ts
@@ -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');
});
diff --git a/docs/api-reference/security.mdx b/docs/api-reference/security.mdx
index 7decba17..97dafc24 100644
--- a/docs/api-reference/security.mdx
+++ b/docs/api-reference/security.mdx
@@ -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 \
diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx
index 4587f9d0..d108690d 100644
--- a/docs/features/cve-suppressions.mdx
+++ b/docs/features/cve-suppressions.mdx
@@ -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. |
-
+
### 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
diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts
index 986f864e..b5a41170 100644
--- a/frontend/src/__tests__/setup.ts
+++ b/frontend/src/__tests__/setup.ts
@@ -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 {
diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx
index f26d5a31..053657ab 100644
--- a/frontend/src/components/VulnerabilityScanSheet.tsx
+++ b/frontend/src/components/VulnerabilityScanSheet.tsx
@@ -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({
- 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}