mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans
Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.
Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.
* feat(security): acknowledge misconfig findings
Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.
Schema and behavior parity with cve_suppressions:
- UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
collide as expected
- blockIfReplica on every write
- Audit-log entries name the scope (rule_id, stack_pattern) but
never the reason text
- replicated_from_control flag controls UI delete affordance and
drives clearReplicatedRows on demote/reanchor
- Validators reused: validateStackPatternForRedos for glob safety,
sanitizeForLog for log fragments
SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.
Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.
Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.
* fix(security): reap orphaned trivy tmp dirs at startup
When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.
* perf(security): emit per-batch summary for scanAllNodeImages
Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.
* perf(security): cap SARIF export at 5000 findings per type
Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.
A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.
* docs(env): document TRIVY_BIN host-binary override
The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).
* test(security): cover scanComposeStack failure modes
Two new cases drive the existing try/catch through real failure
paths:
- Malformed Trivy stdout: row flips to status='failed' with the
parser error preserved on `error`.
- execFile rejection: row flips to status='failed' with a string
error message.
Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.
* test(e2e): security scanner + misconfig acknowledgement flow
Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
- Trivy availability gate (skips suite when binary absent so CI
without Trivy can opt out via E2E_SKIP_TRIVY=1)
- Stack config scan completes and records misconfig findings
- Concurrent stack scan returns 409 from the dedup gate
- Misconfig ack POST creates and lists on Settings
- Duplicate (rule_id, stack_pattern) returns 409
- Malformed rule_id (shell metacharacters) returns 400
- Misconfigs tab renders against a real stack scan
Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.
* docs(features): add misconfig acknowledgement workflow and SARIF cap
Refreshes vulnerability-scanning.mdx with:
- Misconfig acknowledgements section covering the per-row dialog,
Settings panel, scope/matching rules, and SARIF emission
- Tier table row for the new feature
- SARIF section note on the 5000 row-per-type cap and the
properties.truncated marker for partial exports
- Troubleshooting entries: SARIF cap, hidden Acknowledge button,
findings resurfacing after delete, Trivy DB phone-home, and
409 on concurrent compose-stack scans
* fix(ci): clear backend lint and CodeQL alerts
- Remove the dead fetchAllPages helper in routes/security.ts. It lost
its callers when the SARIF endpoint switched to direct paged reads
for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
paths under os.tmpdir() with predictable names tripped CodeQL's
js/insecure-temporary-file rule (high severity), which warns about
symlink-pre-creation attacks even in test code. mkdtempSync appends
a process-random suffix and creates the dir atomically; the
sencho-trivy- prefix is preserved so the production sweep still
matches the test fixtures.
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ShieldOff,
|
||||
ShieldCheck,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -70,6 +71,13 @@ interface SuppressDialogState {
|
||||
expiresInDays: string;
|
||||
}
|
||||
|
||||
interface AckDialogState {
|
||||
ruleId: string;
|
||||
stackPattern: string;
|
||||
reason: string;
|
||||
expiresInDays: string;
|
||||
}
|
||||
|
||||
type SeverityFilter = 'ALL' | VulnSeverity;
|
||||
type FindingTab = 'vulns' | 'secrets' | 'misconfigs';
|
||||
|
||||
@@ -122,6 +130,8 @@ export function VulnerabilityScanSheet({
|
||||
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(null);
|
||||
const [suppressForm, setSuppressForm] = useState<SuppressDialogState | null>(null);
|
||||
const [savingSuppression, setSavingSuppression] = useState(false);
|
||||
const [ackForm, setAckForm] = useState<AckDialogState | null>(null);
|
||||
const [savingAck, setSavingAck] = useState(false);
|
||||
|
||||
const DETAIL_FETCH_LIMIT = 500;
|
||||
|
||||
@@ -317,6 +327,61 @@ export function VulnerabilityScanSheet({
|
||||
}
|
||||
}, [suppressForm, load]);
|
||||
|
||||
const openAckDialog = useCallback((m: MisconfigFinding) => {
|
||||
// Prefill stack_pattern with the exact stack name when this scan is a
|
||||
// stack-scoped config scan. Operators can broaden in the dialog.
|
||||
const stackPattern = scan?.stack_context ?? '';
|
||||
setAckForm({
|
||||
ruleId: m.rule_id,
|
||||
stackPattern,
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
});
|
||||
}, [scan]);
|
||||
|
||||
const submitAcknowledgement = useCallback(async () => {
|
||||
if (!ackForm) return;
|
||||
const reason = ackForm.reason.trim();
|
||||
if (!reason) {
|
||||
toast.error('A reason is required.');
|
||||
return;
|
||||
}
|
||||
const days = ackForm.expiresInDays.trim();
|
||||
let expiresAt: number | null = null;
|
||||
if (days) {
|
||||
const n = Number(days);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast.error('Expiry must be a positive number of days or blank.');
|
||||
return;
|
||||
}
|
||||
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
setSavingAck(true);
|
||||
try {
|
||||
const res = await apiFetch('/security/misconfig-acks', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({
|
||||
rule_id: ackForm.ruleId,
|
||||
stack_pattern: ackForm.stackPattern.trim() || null,
|
||||
reason,
|
||||
expires_at: expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to create acknowledgement');
|
||||
}
|
||||
toast.success('Acknowledgement created');
|
||||
setAckForm(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to create acknowledgement');
|
||||
} finally {
|
||||
setSavingAck(false);
|
||||
}
|
||||
}, [ackForm, load]);
|
||||
|
||||
const exportCsv = useCallback(() => {
|
||||
if (!scan || details.length === 0) return;
|
||||
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
|
||||
@@ -837,11 +902,23 @@ export function VulnerabilityScanSheet({
|
||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
||||
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
||||
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
|
||||
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{misconfigsPageItems.map((m) => (
|
||||
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
|
||||
<TableRow
|
||||
key={m.id}
|
||||
className={cn(
|
||||
SEVERITY_ROW_TINT[m.severity],
|
||||
m.acknowledged && 'opacity-60',
|
||||
)}
|
||||
title={
|
||||
m.acknowledged
|
||||
? `Acknowledged: ${m.acknowledgement_reason ?? '(no reason)'}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<SeverityChip severity={m.severity} />
|
||||
</TableCell>
|
||||
@@ -851,7 +928,7 @@ export function VulnerabilityScanSheet({
|
||||
>
|
||||
{m.check_id || m.rule_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<TableCell className={cn('text-xs', m.acknowledged && 'line-through')}>
|
||||
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
|
||||
{m.primary_url ? (
|
||||
<a
|
||||
@@ -882,6 +959,21 @@ export function VulnerabilityScanSheet({
|
||||
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
|
||||
{m.resolution || <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
{canManageSuppressions && (
|
||||
<TableCell>
|
||||
{!m.acknowledged && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => openAckDialog(m)}
|
||||
title="Acknowledge this misconfiguration"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -976,6 +1068,75 @@ export function VulnerabilityScanSheet({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={ackForm !== null}
|
||||
onOpenChange={(open) => !open && setAckForm(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Acknowledge misconfiguration</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Accept this misconfiguration as known-benign so it stops triggering alerts across the fleet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{ackForm && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Rule</Label>
|
||||
<div className="font-mono text-sm">{ackForm.ruleId}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-pattern">Stack pattern (optional)</Label>
|
||||
<Input
|
||||
id="ack-pattern"
|
||||
placeholder="e.g. traefik or web-* (leave blank for all stacks)"
|
||||
value={ackForm.stackPattern}
|
||||
onChange={(e) =>
|
||||
setAckForm((f) => (f ? { ...f, stackPattern: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob pattern matched against the stack name. Prefilled with the current stack so this ack stays narrowest by default.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-reason">Reason</Label>
|
||||
<textarea
|
||||
id="ack-reason"
|
||||
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Why is this misconfiguration safe to accept?"
|
||||
value={ackForm.reason}
|
||||
onChange={(e) =>
|
||||
setAckForm((f) => (f ? { ...f, reason: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-expiry">Expires in (days, optional)</Label>
|
||||
<Input
|
||||
id="ack-expiry"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for no expiry"
|
||||
value={ackForm.expiresInDays}
|
||||
onChange={(e) =>
|
||||
setAckForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAckForm(null)} disabled={savingAck}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitAcknowledgement} disabled={savingAck}>
|
||||
{savingAck ? 'Saving...' : 'Acknowledge'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useCallback, 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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { ChevronLeft, ChevronRight, Plus, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { MisconfigAcknowledgement } from '@/types/security';
|
||||
|
||||
const RULE_RE = /^[A-Z0-9][A-Z0-9_-]{0,199}$/i;
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
interface AckFormState {
|
||||
ruleId: string;
|
||||
stackPattern: string;
|
||||
reason: string;
|
||||
expiresInDays: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: AckFormState = {
|
||||
ruleId: '',
|
||||
stackPattern: '',
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
};
|
||||
|
||||
interface MisconfigAckPanelProps {
|
||||
isReplica: boolean;
|
||||
}
|
||||
|
||||
export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
|
||||
const [rows, setRows] = useState<MisconfigAcknowledgement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<AckFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<MisconfigAcknowledgement | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
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 : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load misconfig acknowledgements:', err);
|
||||
toast.error('Failed to load acknowledgements');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
||||
const needsPagination = rows.length > PAGE_SIZE;
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const ruleId = form.ruleId.trim();
|
||||
if (!RULE_RE.test(ruleId)) {
|
||||
toast.error('Rule id must be alpha-numeric (e.g. "DS002" or "AVD-DS-0002").');
|
||||
return;
|
||||
}
|
||||
const reason = form.reason.trim();
|
||||
if (!reason) {
|
||||
toast.error('A reason is required.');
|
||||
return;
|
||||
}
|
||||
let expiresAt: number | null = null;
|
||||
const days = form.expiresInDays.trim();
|
||||
if (days) {
|
||||
const n = Number(days);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast.error('Expiry must be a positive number of days or blank.');
|
||||
return;
|
||||
}
|
||||
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/security/misconfig-acks', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({
|
||||
rule_id: ruleId,
|
||||
stack_pattern: form.stackPattern.trim() || null,
|
||||
reason,
|
||||
expires_at: expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to create acknowledgement');
|
||||
}
|
||||
toast.success('Acknowledgement created');
|
||||
setDialogOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to create acknowledgement');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteRow) return;
|
||||
try {
|
||||
const res = await apiFetch(`/security/misconfig-acks/${deleteRow.id}`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to delete acknowledgement');
|
||||
}
|
||||
toast.success('Acknowledgement removed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to delete acknowledgement');
|
||||
} finally {
|
||||
setDeleteRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatExpiry = (row: MisconfigAcknowledgement): string => {
|
||||
if (row.expires_at === null) return 'Never';
|
||||
const d = new Date(row.expires_at);
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<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">Misconfig Acknowledgements</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0 font-mono tabular-nums">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{needsPagination && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isReplica && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add Acknowledgement
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time
|
||||
across every instance in the fleet and never modify stored scan data.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full rounded" />
|
||||
<Skeleton className="h-10 w-full rounded" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground">
|
||||
No acknowledgements yet. Acknowledge a misconfig from any scan result to silence it fleet-wide.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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.rule_id}</span>
|
||||
{row.stack_pattern && (
|
||||
<Badge variant="outline" className="text-[10px] font-mono truncate max-w-[220px]">
|
||||
{row.stack_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>
|
||||
<div className="text-[11px] font-mono text-stat-subtitle">
|
||||
by {row.created_by} · expires {formatExpiry(row)}
|
||||
</div>
|
||||
</div>
|
||||
{!isReplica && row.replicated_from_control === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0"
|
||||
onClick={() => setDeleteRow(row)}
|
||||
title="Remove acknowledgement"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
||||
<ModalHeader
|
||||
kicker="ACKNOWLEDGEMENTS · NEW"
|
||||
title="New misconfig acknowledgement"
|
||||
description="Accept a known-benign misconfiguration so it stops triggering alerts across the fleet."
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-rule">Rule id</Label>
|
||||
<Input
|
||||
id="ack-rule"
|
||||
placeholder="DS002 or AVD-DS-0002"
|
||||
value={form.ruleId}
|
||||
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-stack">Stack pattern (optional)</Label>
|
||||
<Input
|
||||
id="ack-stack"
|
||||
placeholder="e.g. traefik or web-* (leave blank to match every stack)"
|
||||
value={form.stackPattern}
|
||||
onChange={(e) => setForm({ ...form, stackPattern: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-reason">Reason</Label>
|
||||
<textarea
|
||||
id="ack-reason"
|
||||
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Why is this misconfiguration safe to accept?"
|
||||
value={form.reason}
|
||||
onChange={(e) => setForm({ ...form, reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ack-expiry">Expires in (days, optional)</Label>
|
||||
<Input
|
||||
id="ack-expiry"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for no expiry"
|
||||
value={form.expiresInDays}
|
||||
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Create'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={deleteRow !== null}
|
||||
onOpenChange={(open) => !open && setDeleteRow(null)}
|
||||
variant="destructive"
|
||||
kicker="ACKNOWLEDGEMENTS · REMOVE · IRREVERSIBLE"
|
||||
title="Remove acknowledgement"
|
||||
confirmLabel="Remove"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Future scan results will surface <span className="font-mono font-medium text-stat-value">{deleteRow?.rule_id}</span> again wherever it applies.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { SuppressionsPanel } from './SuppressionsPanel';
|
||||
import { MisconfigAckPanel } from './MisconfigAckPanel';
|
||||
|
||||
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
@@ -503,6 +504,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
|
||||
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
||||
|
||||
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
|
||||
|
||||
{isPaid && (
|
||||
<>
|
||||
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
||||
|
||||
Reference in New Issue
Block a user