mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
3b650523c1
* 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.
328 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|