mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
feat(security): prefer digest identity in scan history (#1610)
Retain scans and History search by digest when available, keep imageRefLike for compatibility, and surface digests in compare.
This commit is contained in:
@@ -26,10 +26,12 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cveUrl } from '@/lib/cveUrl';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
||||
import { SeverityChip } from './VulnerabilityScanSheet';
|
||||
import type {
|
||||
ScanCompareResult,
|
||||
ScanCompareSide,
|
||||
ScanCompareVulnerability,
|
||||
VulnSeverity,
|
||||
} from '@/types/security';
|
||||
@@ -44,6 +46,24 @@ type DiffFilter = 'added' | 'removed' | 'unchanged';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function trimmedDigest(scan: ScanCompareSide): string | null {
|
||||
const digest = scan.image_digest?.trim();
|
||||
return digest || null;
|
||||
}
|
||||
|
||||
function scanCompareLabel(scan: ScanCompareSide): string {
|
||||
const digest = trimmedDigest(scan);
|
||||
if (digest) return `${formatShortDigest(digest)} · ${scan.image_ref}`;
|
||||
return scan.image_ref;
|
||||
}
|
||||
|
||||
function isCrossIdentity(a: ScanCompareSide, b: ScanCompareSide): boolean {
|
||||
const aDigest = trimmedDigest(a);
|
||||
const bDigest = trimmedDigest(b);
|
||||
if (aDigest && bDigest) return aDigest !== bDigest;
|
||||
return a.image_ref !== b.image_ref;
|
||||
}
|
||||
|
||||
const SEVERITY_ORDER: Record<VulnSeverity, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
@@ -130,7 +150,7 @@ export function ScanComparisonSheet({
|
||||
const addedCounts = useMemo(() => (data ? countBySeverity(data.added) : null), [data]);
|
||||
const removedCounts = useMemo(() => (data ? countBySeverity(data.removed) : null), [data]);
|
||||
|
||||
const crossImage = data != null && data.scanA.image_ref !== data.scanB.image_ref;
|
||||
const crossImage = data != null && isCrossIdentity(data.scanA, data.scanB);
|
||||
|
||||
const rows = useMemo<ScanCompareVulnerability[]>(() => {
|
||||
if (!data) return [];
|
||||
@@ -149,7 +169,7 @@ export function ScanComparisonSheet({
|
||||
: (loading ? 'Loading…' : '');
|
||||
|
||||
const footerContext = data
|
||||
? `${data.scanA.image_ref} → ${data.scanB.image_ref}`
|
||||
? `${scanCompareLabel(data.scanA)} → ${scanCompareLabel(data.scanB)}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -174,13 +194,13 @@ export function ScanComparisonSheet({
|
||||
<div className="flex items-center gap-3 text-xs font-mono tabular-nums mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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-value truncate">{scanCompareLabel(data.scanA)}</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="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-value truncate">{scanCompareLabel(data.scanB)}</div>
|
||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +209,7 @@ export function ScanComparisonSheet({
|
||||
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 mb-3 text-xs text-warning">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
||||
<span>
|
||||
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
|
||||
You are comparing scans from two different image identities. Package-level changes may reflect image differences rather than CVE drift.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('ScanComparisonSheet', () => {
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/different image references/i)).toBeInTheDocument(),
|
||||
expect(screen.getByText(/different image identities/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,7 +104,22 @@ describe('ScanComparisonSheet', () => {
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Baseline/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/different image references/i)).toBeNull();
|
||||
expect(screen.queryByText(/different image identities/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows cross-image warning when digests differ for the same tag', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
scanA: { id: 1, image_ref: 'app:latest', scanned_at: 1, image_digest: 'sha256:aaa' },
|
||||
scanB: { id: 2, image_ref: 'app:latest', scanned_at: 2, image_digest: 'sha256:bbb' },
|
||||
})),
|
||||
);
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/different image identities/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a toast and closes the sheet on fetch error', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
interface ImageInspect {
|
||||
@@ -59,12 +60,6 @@ function formatRelativeAge(timestampSec: number): string {
|
||||
return `${Math.floor(diff / (86400 * 365))}y ago`;
|
||||
}
|
||||
|
||||
function shortDigest(id: string): string {
|
||||
const colon = id.indexOf(':');
|
||||
const hex = colon >= 0 ? id.slice(colon + 1) : id;
|
||||
return hex.substring(0, 12);
|
||||
}
|
||||
|
||||
export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) {
|
||||
const [data, setData] = useState<ImageDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -104,7 +99,7 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps)
|
||||
const history = data?.history ?? [];
|
||||
const totalLayers = history.length;
|
||||
|
||||
const name = inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details');
|
||||
const name = inspect?.RepoTags?.[0] || (inspect ? formatShortDigest(inspect.Id) : 'Image details');
|
||||
const meta = inspect
|
||||
? `${formatBytes(inspect.Size)} · ${inspect.Architecture ?? '?'}/${inspect.Os ?? '?'} · ${totalLayers} layers`
|
||||
: (loading ? 'Loading…' : '');
|
||||
@@ -138,7 +133,7 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps)
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<Field label="ID">
|
||||
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
|
||||
{shortDigest(inspect.Id)}
|
||||
{formatShortDigest(inspect.Id)}
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import { SeverityChip } from '../VulnerabilityScanSheet';
|
||||
import { ScanComparisonSheet } from '../ScanComparisonSheet';
|
||||
@@ -17,6 +18,22 @@ import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/sec
|
||||
const PAGE_SIZE = 100;
|
||||
const SEVERITY_RANK: Record<VulnSeverity, number> = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
||||
|
||||
function scanIdentityLabel(scan: VulnerabilityScan): {
|
||||
primary: string;
|
||||
subtitle: string | null;
|
||||
title: string;
|
||||
} {
|
||||
const digest = scan.image_digest?.trim();
|
||||
if (digest) {
|
||||
return {
|
||||
primary: formatShortDigest(digest),
|
||||
subtitle: scan.image_ref,
|
||||
title: digest,
|
||||
};
|
||||
}
|
||||
return { primary: scan.image_ref, subtitle: null, title: scan.image_ref };
|
||||
}
|
||||
|
||||
type SortKey = 'scanned_at' | 'image_ref' | 'severity' | 'total';
|
||||
|
||||
/** Sortable column header. Module-scoped so it is a stable component. */
|
||||
@@ -72,7 +89,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(pageToLoad * PAGE_SIZE),
|
||||
});
|
||||
if (term.trim()) params.set('imageRefLike', term.trim());
|
||||
if (term.trim()) params.set('imageIdentityLike', term.trim());
|
||||
const res = await apiFetch(`/security/scans?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
setError(true);
|
||||
@@ -166,7 +183,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search by image..."
|
||||
placeholder="Search by image or digest..."
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
className="pl-8"
|
||||
@@ -191,12 +208,22 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
<TableBody>
|
||||
{!loading && !error && sorted.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
const identity = scanIdentityLabel(scan);
|
||||
return (
|
||||
<TableRow key={scan.id} className={cn('hover:bg-muted/30 transition-colors', isSelected && 'bg-accent/30')}>
|
||||
<TableCell>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelect(scan.id)} aria-label="Select scan to compare" />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[280px]">{scan.image_ref}</TableCell>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<div className="font-mono text-xs truncate" title={identity.title}>
|
||||
{identity.primary}
|
||||
</div>
|
||||
{identity.subtitle && (
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate" title={identity.subtitle}>
|
||||
{identity.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap">{new Date(scan.scanned_at).toLocaleString()}</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize text-stat-subtitle">{scan.triggered_by}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -125,17 +125,30 @@ describe('HistoryTab', () => {
|
||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches by image as you type (no Enter), adding imageRefLike to the request', async () => {
|
||||
it('searches by imageIdentityLike as you type (not legacy imageRefLike)', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis');
|
||||
await userEvent.type(screen.getByPlaceholderText('Search by image or digest...'), 'redis');
|
||||
await waitFor(() => {
|
||||
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
|
||||
expect(calls.some((u) => u.includes('imageIdentityLike=redis'))).toBe(true);
|
||||
expect(calls.every((u) => !u.includes('imageRefLike='))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows short digest as primary when image_digest is present', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([
|
||||
scan({
|
||||
image_ref: 'nginx:1.25',
|
||||
image_digest: 'sha256:abcdef0123456789ffff',
|
||||
}),
|
||||
]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('abcdef012345')).toBeInTheDocument());
|
||||
expect(screen.getByText('nginx:1.25')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error state when the load fails', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response);
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
|
||||
@@ -173,8 +173,8 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Scan history per image"
|
||||
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
|
||||
label="Scan history per digest"
|
||||
helper="How many vulnerability scans to keep per image digest (or per image reference when no digest is stored). Older scans beyond the cap are pruned."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
|
||||
Reference in New Issue
Block a user