Files
sencho/frontend/src/components/settings/MisconfigAckPanel.tsx
T
Anso 3b650523c1 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.
2026-05-07 19:23:11 -04:00

328 lines
12 KiB
TypeScript

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>
);
}