feat(security): polish scan sheets, fix CVE links, surface policy violations (#721)

* feat(security): polish scan sheets, fix CVE links, surface policy violations

Adds cveUrl helper that rewrites Trivy's 404-ing avd.aquasec.com links to
cve.org for CVE-prefixed IDs (GHSA and misconfig URLs pass through unchanged).
Redesigns both scan sheets with shadow-card-bevel chips, tracked-mono kickers,
severity row tinting with a left accent rail, and tabular-nums timestamps.
Surfaces a destructive policy-violation banner on scans whose policy_evaluation
row flags a block, and fixes the compare sheet's delta ribbon so CRITICAL
net-positive deltas render in destructive (not warning) tone. Backend parses
the JSON policy_evaluation column at the API boundary so the UI receives a
structured object.

* chore(security): suppress CVE-2026-32281 and CVE-2026-32283 in Trivy scan

Both CVEs affect Go stdlib crypto/x509 and TLS in Docker CLI 29.4.0
(Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). No upstream static binary
has been released with the patched Go 1.26.2 or 1.25.9 runtimes yet.

Exposure analysis: the Docker CLI and compose plugin connect to the local
Docker socket (Unix socket, no TLS) and to public registries with well-known
CAs. Neither CVE is exploitable in this configuration. Added alongside
sibling entries already in .trivyignore for the same binary versions.

Revisit on next Docker CLI and Compose upstream release.
This commit is contained in:
Anso
2026-04-21 08:51:35 -04:00
committed by GitHub
parent e4fdb1cd6c
commit 12c2b37510
12 changed files with 295 additions and 83 deletions
+49 -36
View File
@@ -26,6 +26,8 @@ import {
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { cveUrl } from '@/lib/cveUrl';
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
import { SeverityChip } from './VulnerabilityScanSheet';
import type {
ScanCompareResult,
@@ -63,9 +65,25 @@ function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record<VulnSe
return counts;
}
function formatDelta(added: number, removed: number): { text: string; tone: 'success' | 'warning' | 'muted' } {
type DeltaTone = 'success' | 'warning' | 'destructive' | 'muted';
const DELTA_TONE_CLASS: Record<DeltaTone, string> = {
destructive: 'text-destructive border-destructive/40 bg-destructive/10',
warning: 'text-warning border-warning/40 bg-warning/10',
success: 'text-success border-success/40 bg-success/10',
muted: 'text-muted-foreground border-border bg-muted/30',
};
function formatDelta(
severity: VulnSeverity,
added: number,
removed: number,
): { text: string; tone: DeltaTone } {
const net = added - removed;
if (net > 0) return { text: `+${net}`, tone: 'warning' };
if (net > 0) {
const tone: DeltaTone = severity === 'CRITICAL' ? 'destructive' : 'warning';
return { text: `+${net}`, tone };
}
if (net < 0) return { text: `${net}`, tone: 'success' };
return { text: '0', tone: 'muted' };
}
@@ -130,12 +148,13 @@ export function ScanComparisonSheet({
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
<SheetHeader className="p-6 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 pr-6">
<GitCompare className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">
Compare scans
</span>
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Scan comparison
</div>
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
<GitCompare className="w-5 h-5 text-muted-foreground not-italic" strokeWidth={1.5} />
Diff
</SheetTitle>
<SheetDescription className="sr-only">
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
@@ -154,15 +173,15 @@ export function ScanComparisonSheet({
<div className="px-6 py-4 border-b space-y-3">
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
<div className="flex-1 min-w-0">
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Baseline</div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
<div className="text-stat-subtitle">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<div className="flex-1 min-w-0">
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Current</div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
<div className="text-stat-subtitle">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
</div>
</div>
@@ -191,23 +210,18 @@ export function ScanComparisonSheet({
{addedCounts && removedCounts && (
<div className="flex flex-wrap gap-2">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
const delta = formatDelta(addedCounts[sev], removedCounts[sev]);
const toneClass =
delta.tone === 'warning'
? 'text-warning border-warning/40 bg-warning/10'
: delta.tone === 'success'
? 'text-success border-success/40 bg-success/10'
: 'text-muted-foreground border-border bg-muted/30';
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
return (
<span
key={sev}
aria-label={`${sev} delta ${delta.text}`}
data-tone={delta.tone}
className={cn(
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums',
toneClass,
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
DELTA_TONE_CLASS[delta.tone],
)}
>
<span className="uppercase tracking-wide text-[10px]">{sev}</span>
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
<span>{delta.text}</span>
</span>
);
@@ -297,24 +311,23 @@ export function ScanComparisonSheet({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">CVE</TableHead>
<TableHead>Package</TableHead>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[110px]">Status</TableHead>
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((v, idx) => {
const baseRowClass =
filter === 'added'
? 'bg-destructive/5'
: filter === 'removed'
? 'bg-success/5'
: 'opacity-70';
const rowClass = cn(baseRowClass, v.suppressed && 'opacity-60');
const href = cveUrl(v.vulnerability_id, v.primary_url);
const rowClass = cn(
SEVERITY_ROW_TINT[v.severity],
filter === 'unchanged' && 'opacity-75',
v.suppressed && 'opacity-60',
);
return (
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
<TableCell className="font-mono text-xs">
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{v.suppressed && (
<ShieldOff
@@ -323,9 +336,9 @@ export function ScanComparisonSheet({
aria-label="Suppressed"
/>
)}
{v.primary_url ? (
{href ? (
<a
href={v.primary_url}
href={href}
target="_blank"
rel="noreferrer noopener"
className="hover:underline"
@@ -46,6 +46,8 @@ import { ScanComparisonSheet } from './ScanComparisonSheet';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { cveUrl } from '@/lib/cveUrl';
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
import type {
VulnerabilityScan,
VulnerabilityDetail,
@@ -88,7 +90,7 @@ function SeverityChip({ severity }: { severity: VulnSeverity }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-wide',
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel',
SEVERITY_CLASSES[severity],
)}
>
@@ -372,10 +374,13 @@ export function VulnerabilityScanSheet({
return (
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
<SheetHeader className="p-6 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 pr-6">
<ShieldCheck className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Vulnerability scan · {scan?.triggered_by ?? '-'}
</div>
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
<ShieldCheck className="w-5 h-5 text-muted-foreground" strokeWidth={1.5} />
<span className="font-mono text-base truncate not-italic">
{scan?.image_ref ?? 'Loading...'}
</span>
</SheetTitle>
@@ -396,29 +401,55 @@ export function VulnerabilityScanSheet({
<div className="flex flex-col flex-1 min-h-0">
{/* Summary stats */}
<div className="px-6 py-4 border-b space-y-3">
{scan.policy_evaluation?.violated && (
<div
role="alert"
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 shadow-card-bevel"
>
<span
aria-hidden="true"
className="absolute left-0 top-0 bottom-0 w-[3px] bg-destructive/80 rounded-l"
/>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-destructive">
Policy violation
</div>
<div className="text-sm text-stat-value mt-0.5">
<span className="font-mono">{scan.policy_evaluation.policyName}</span> blocks
severities at or above{' '}
<span className="font-mono tabular-nums">
{scan.policy_evaluation.maxSeverity}
</span>
. This scan's highest severity is{' '}
<span className="font-mono tabular-nums">
{scan.highest_severity ?? 'UNKNOWN'}
</span>
.
</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{scan.critical_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.CRITICAL)}>
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.CRITICAL)}>
{scan.critical_count} CRITICAL
</span>
)}
{scan.high_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.HIGH)}>
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.HIGH)}>
{scan.high_count} HIGH
</span>
)}
{scan.medium_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.MEDIUM)}>
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.MEDIUM)}>
{scan.medium_count} MEDIUM
</span>
)}
{scan.low_count > 0 && (
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.LOW)}>
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.LOW)}>
{scan.low_count} LOW
</span>
)}
{scan.total_vulnerabilities === 0 && (
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase">
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel">
No vulnerabilities
</span>
)}
@@ -426,20 +457,20 @@ export function VulnerabilityScanSheet({
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Total</div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Total</div>
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Fixable</div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Fixable</div>
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Triggered</div>
<div className="font-mono text-stat-value">{scan.triggered_by}</div>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Triggered</div>
<div className="font-mono tabular-nums text-stat-value">{scan.triggered_by}</div>
</div>
<div>
<div className="text-stat-subtitle uppercase tracking-wide">Scanned</div>
<div className="font-mono text-stat-value">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Scanned</div>
<div className="font-mono tabular-nums text-stat-value">
{new Date(scan.scanned_at).toLocaleString()}
</div>
</div>
@@ -521,7 +552,7 @@ export function VulnerabilityScanSheet({
</div>
) : (
<>
<div className="text-xs text-stat-subtitle uppercase tracking-wide">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Compare against
</div>
<Combobox
@@ -635,18 +666,23 @@ export function VulnerabilityScanSheet({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">CVE</TableHead>
<TableHead>Package</TableHead>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[110px]">Installed</TableHead>
<TableHead className="w-[110px]">Fixed</TableHead>
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Installed</TableHead>
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fixed</TableHead>
{canManageSuppressions && <TableHead className="w-[40px]" />}
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((d) => (
<TableRow key={d.id} className={d.suppressed ? 'opacity-60' : undefined}>
<TableCell className="font-mono text-xs">
{pageItems.map((d) => {
const href = cveUrl(d.vulnerability_id, d.primary_url);
return (
<TableRow
key={d.id}
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
>
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{d.suppressed && (
<ShieldOff
@@ -655,9 +691,9 @@ export function VulnerabilityScanSheet({
aria-label="Suppressed"
/>
)}
{d.primary_url ? (
{href ? (
<a
href={d.primary_url}
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
@@ -706,7 +742,8 @@ export function VulnerabilityScanSheet({
</TableCell>
)}
</TableRow>
))}
);
})}
</TableBody>
</Table>
)}
@@ -757,15 +794,15 @@ export function VulnerabilityScanSheet({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[160px]">Rule</TableHead>
<TableHead>Title</TableHead>
<TableHead className="w-[260px]">Target</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[160px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Rule</TableHead>
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
<TableHead className="w-[260px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{secretsPageItems.map((s) => (
<TableRow key={s.id}>
<TableRow key={s.id} className={SEVERITY_ROW_TINT[s.severity]}>
<TableCell>
<SeverityChip severity={s.severity} />
</TableCell>
@@ -850,16 +887,16 @@ export function VulnerabilityScanSheet({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Severity</TableHead>
<TableHead className="w-[140px]">Check</TableHead>
<TableHead>Title</TableHead>
<TableHead className="w-[200px]">Target</TableHead>
<TableHead className="w-[220px]">Fix</TableHead>
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
<TableHead className="w-[140px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Check</TableHead>
<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>
</TableRow>
</TableHeader>
<TableBody>
{misconfigsPageItems.map((m) => (
<TableRow key={m.id}>
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
<TableCell>
<SeverityChip severity={m.severity} />
</TableCell>
@@ -206,6 +206,51 @@ describe('ScanComparisonSheet', () => {
expect(screen.queryByRole('button', { name: /Shared/ })).toBeNull();
});
it('rewrites CVE primary_url to cve.org for CVE IDs', async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, result({
added: [vuln({
vulnerability_id: 'CVE-2024-1234',
primary_url: 'https://avd.aquasec.com/nvd/CVE-2024-1234',
})],
})),
);
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
const link = await screen.findByRole('link', { name: 'CVE-2024-1234' });
expect(link).toHaveAttribute(
'href',
'https://www.cve.org/CVERecord?id=CVE-2024-1234',
);
});
it('tags CRITICAL net-positive delta chip with destructive tone', async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, result({
added: [vuln({ vulnerability_id: 'CVE-C', severity: 'CRITICAL' })],
})),
);
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
const chip = await screen.findByLabelText('CRITICAL delta +1');
expect(chip).toHaveAttribute('data-tone', 'destructive');
});
it('tags HIGH net-positive delta chip with warning tone (not destructive)', async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, result({
added: [vuln({ vulnerability_id: 'CVE-H', severity: 'HIGH' })],
})),
);
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
const chip = await screen.findByLabelText('HIGH delta +1');
expect(chip).toHaveAttribute('data-tone', 'warning');
});
it('reloads when the scan ids change', async () => {
mockedFetch.mockResolvedValue(jsonResponse(200, result()));