mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user