fix: differentiate security action links and add suppression editing (#1500)

Security page UX fixes:

- Stop the CVSS x EPSS scatter chart from painting a full-plot "white
  rectangle" cursor on click (cursor disabled), and prevent click-drag
  selection on charts.
- Differentiate the overview action links: "fixable" links (masthead primary
  action, review-queue blocker, and the Fixable signal tile) now open the
  Images tab pre-filtered to fixable findings; the Stale and Failed signal
  tiles link to the History tab where those scans are listed; Secrets and
  Misconfigs tiles link to their tabs. The Images tab accepts an initialFilter
  and exposes a Fixable option in the severity dropdown.
- Fix the "Secrets / misconfigs" option wrapping and misaligning in the
  severity dropdown (single-line option labels, wider trigger).
- Add Edit for CVE suppressions and misconfig acknowledgements (reason, scope
  pattern, expiry), reusing the existing dialog and the existing PUT endpoints;
  the CVE/rule identity stays fixed.
This commit is contained in:
Anso
2026-06-28 06:17:47 -04:00
committed by GitHub
parent ba57c67048
commit 083442d5ea
15 changed files with 316 additions and 115 deletions
+15 -5
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
@@ -52,8 +52,9 @@ function SortHead({ label, k, sortKey, sortDir, onSort, className }: {
);
}
const FILTER_OPTIONS: Array<{ value: 'all' | SeverityKey; label: string }> = [
const FILTER_OPTIONS: Array<{ value: ImageFilterValue; label: string }> = [
{ value: 'all', label: 'All severities' },
{ value: 'FIXABLE', label: 'Fixable' },
{ value: 'CRITICAL', label: 'Critical' },
{ value: 'HIGH', label: 'High' },
{ value: 'MEDIUM', label: 'Medium' },
@@ -75,17 +76,26 @@ interface ImagesTabProps {
/** image_ref of the scan currently in flight, for the per-row spinner. */
scanningRef: string | null;
onScan: (imageRef: string, scanners: ScannerKind[]) => void;
/** Preselects the severity/fixable filter, e.g. when arriving from an
* overview "fixable findings" link. Applied whenever the value changes. */
initialFilter?: ImageFilterValue;
}
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan }: ImagesTabProps) {
export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan, initialFilter }: ImagesTabProps) {
const isMobile = useIsMobile();
const [search, setSearch] = useState('');
const [severity, setSeverity] = useState<ImageFilterValue>('all');
const [severity, setSeverity] = useState<ImageFilterValue>(initialFilter ?? 'all');
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
// Apply an externally-driven filter (e.g. an overview "fixable" deep link).
// Keyed on the incoming value so re-navigating to the same filter re-applies.
useEffect(() => {
if (initialFilter) { setSeverity(initialFilter); setPage(0); }
}, [initialFilter]);
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return Object.values(summaries)
@@ -198,7 +208,7 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
options={FILTER_OPTIONS}
value={severity}
onValueChange={(v) => { setSeverity((v || 'all') as ImageFilterValue); setPage(0); }}
className="w-[180px]"
className="w-[200px]"
/>
</div>
@@ -7,6 +7,8 @@ import { useIsMobile } from '@/hooks/use-is-mobile';
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding, PostureReason } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import type { ImageFilterValue } from '@/lib/severityStyles';
import { reasonImageFilter } from './postureNavigation';
import {
RiskTrendChart,
ActionPostureChart,
@@ -15,6 +17,9 @@ import {
} from './SecurityCharts';
import { ScanNodeLauncher } from './ScanNodeLauncher';
/** Navigate to a security tab, optionally preselecting an Images filter. */
type NavigateFn = (tab: SecurityTab, filter?: ImageFilterValue) => void;
interface OverviewTabProps {
overview: SecurityOverview | null;
/** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */
@@ -24,7 +29,7 @@ interface OverviewTabProps {
exploitIntel: ExploitIntelFinding[];
/** True when the exploit-intel set hit its row cap (highest-risk shown, not all). */
exploitTruncated: boolean;
onNavigate: (tab: SecurityTab) => void;
onNavigate: NavigateFn;
onInspect: (scanId: number) => void;
/** Admin on a node with a ready scanner; enables the node-scan launcher. */
canScan: boolean;
@@ -76,7 +81,7 @@ function ReviewQueueCard({
onNavigate,
}: {
reasons: PostureReason[];
onNavigate: (tab: SecurityTab) => void;
onNavigate: NavigateFn;
}) {
const blockers = reasons.filter((r) => r.severity === 'blocker');
const nonBlockers = reasons.filter((r) => r.severity !== 'blocker');
@@ -96,7 +101,7 @@ function ReviewQueueCard({
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
<button
type="button"
onClick={() => onNavigate(r.targetTab)}
onClick={() => onNavigate(r.targetTab, reasonImageFilter(r.kind))}
className="text-xs font-medium text-brand hover:underline whitespace-nowrap ml-auto"
>
Open {r.targetTab === 'compose' ? 'Compose risks' : r.targetTab === 'suppressions' ? 'Suppressions' : r.targetTab === 'secrets' ? 'Secrets' : r.targetTab === 'history' ? 'History' : r.targetTab === 'scanner' ? 'Scanner setup' : 'Images'}
@@ -164,11 +169,26 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT
const tiles: SignalTile[] = [
{ kicker: 'Scanned images', value: String(overview.scannedImages) },
{ kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' },
{ kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value' },
{ kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value' },
{ kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value' },
{ kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value' },
{
kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value',
onClick: overview.fixable > 0 ? () => onNavigate('images', 'FIXABLE') : undefined,
},
{
kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value',
onClick: overview.secrets > 0 ? () => onNavigate('secrets') : undefined,
},
{
kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value',
onClick: overview.misconfigs > 0 ? () => onNavigate('compose') : undefined,
},
{
kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value',
onClick: overview.staleScans > 0 ? () => onNavigate('history') : undefined,
},
{
kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value',
onClick: overview.failedScans > 0 ? () => onNavigate('history') : undefined,
},
];
const scannerValue = overview.scanner.available
@@ -348,7 +348,9 @@ export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[]
<ZAxis range={[40, 40]} />
<ReferenceLine x={10} stroke="var(--border)" strokeDasharray="4 4" />
<ReferenceLine y={7} stroke="var(--border)" strokeDasharray="4 4" />
<Tooltip cursor={{ strokeDasharray: '3 3' }} content={<QuadrantTooltip />} />
{/* cursor=false: the default scatter cursor is a full-plot rectangle
that reads as selecting the whole chart. Points still hover/tooltip. */}
<Tooltip cursor={false} content={<QuadrantTooltip />} />
<Scatter data={otherPoints} fill="var(--sev-high)" fillOpacity={0.7} />
<Scatter data={kevPoints} fill="var(--sev-critical)" fillOpacity={0.9} />
</ScatterChart>
@@ -113,6 +113,21 @@ it('narrows the list with the severity filter', async () => {
expect(screen.queryByText('low:1')).not.toBeInTheDocument();
});
it('applies initialFilter to show only the matching images on arrival', () => {
render(
<ImagesTab
{...base}
initialFilter="FIXABLE"
summaries={asMap(
summary({ image_ref: 'fix:1', scan_id: 1, highest_severity: 'HIGH', total: 2, high: 2, fixable: 2 }),
summary({ image_ref: 'nofix:1', scan_id: 2, highest_severity: 'HIGH', total: 1, high: 1, fixable: 0 }),
)}
/>,
);
expect(screen.getByText('fix:1')).toBeInTheDocument();
expect(screen.queryByText('nofix:1')).not.toBeInTheDocument();
});
it('shows the scan action only when scanning is allowed', () => {
const data = asMap(summary({ image_ref: 'nginx:1', scan_id: 1 }));
const { rerender } = render(<ImagesTab {...base} canScan={false} summaries={data} />);
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { reasonImageFilter } from '../postureNavigation';
import type { PostureReasonKind } from '@/types/security';
describe('reasonImageFilter', () => {
it('maps fixable findings to the FIXABLE image filter', () => {
expect(reasonImageFilter('fixable_cve')).toBe('FIXABLE');
});
it('returns undefined for kinds with no per-image flag (opens Images unfiltered)', () => {
const others: PostureReasonKind[] = [
'known_exploited',
'secret',
'dangerous_compose',
'public_exposure',
'stale_scan',
'failed_scan',
'needs_review',
];
for (const kind of others) {
expect(reasonImageFilter(kind)).toBeUndefined();
}
});
});
@@ -0,0 +1,10 @@
import type { PostureReasonKind } from '@/types/security';
import type { ImageFilterValue } from '@/lib/severityStyles';
/** The Images filter that best isolates the affected images for a posture reason.
* Only fixable findings map to a data-backed filter; known-exploited and
* public-exposure have no per-image flag in the summaries, so they open Images
* unfiltered rather than mis-hiding the affected images. */
export function reasonImageFilter(kind: PostureReasonKind): ImageFilterValue | undefined {
return kind === 'fixable_cve' ? 'FIXABLE' : undefined;
}