mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
feat(security): secret and misconfiguration scanning (#651)
Extends Trivy scans with secret detection in image filesystems and misconfiguration scanning for Compose stacks. Adds tabs to the scan drawer for vulnerabilities, secrets, and misconfigs. Secret matches are redacted server-side (first 8 chars + ellipsis) before storage. - TrivyService: --scanners vuln,secret for images; trivy config for stacks - DB: scanners_used/secret_count/misconfig_count cols; secret_findings, misconfig_findings tables; cache key scoped by scanners - Routes: POST /security/scan accepts scanners array (requirePaid when secret requested); POST /security/scan/stack; GET .../secrets and .../misconfigs (paid-tier reads) - UI: tabs in VulnerabilityScanSheet; scan-options dropdown on images; Scan config button on stack header
This commit is contained in:
@@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli
|
||||
import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2 } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { LabelPill, LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel } from './label-types';
|
||||
@@ -63,6 +63,8 @@ import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -101,6 +103,9 @@ const formatBytes = (bytes: number) => {
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { status: trivy } = useTrivyStatus();
|
||||
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
|
||||
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
|
||||
const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes();
|
||||
// Stable ref so notification callbacks always read the latest nodes list
|
||||
// without needing nodes in their dependency arrays (which would cause loops).
|
||||
@@ -1037,6 +1042,34 @@ export default function EditorLayout() {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const scanStackConfig = async () => {
|
||||
if (!selectedFile || stackMisconfigScanning) return;
|
||||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
setStackMisconfigScanning(true);
|
||||
const loadingId = toast.loading(`Scanning ${stackName} configuration...`);
|
||||
try {
|
||||
const res = await apiFetch('/security/scan/stack', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stackName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to start scan');
|
||||
if (data.status === 'failed') {
|
||||
throw new Error(data.error || 'Scan failed');
|
||||
}
|
||||
toast.success(
|
||||
`Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`,
|
||||
);
|
||||
setStackMisconfigScanId(data.id as number);
|
||||
} catch (error) {
|
||||
const err = error as { message?: string; error?: string; data?: { error?: string } };
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Config scan failed');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setStackMisconfigScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deployStack = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -2415,6 +2448,24 @@ export default function EditorLayout() {
|
||||
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
{trivy.available && isAdmin && isPaid && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-lg"
|
||||
onClick={scanStackConfig}
|
||||
disabled={loadingAction !== null || stackMisconfigScanning}
|
||||
title="Scan compose configuration for misconfigurations"
|
||||
>
|
||||
{stackMisconfigScanning ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
)}
|
||||
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
|
||||
</Button>
|
||||
)}
|
||||
{isPaid && backupInfo.exists && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -2883,6 +2934,12 @@ export default function EditorLayout() {
|
||||
onSourceChanged={refreshGitSourcePending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stack config misconfig scan results */}
|
||||
<VulnerabilityScanSheet
|
||||
scanId={stackMisconfigScanId}
|
||||
onClose={() => setStackMisconfigScanId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -590,13 +590,17 @@ export default function ResourcesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanImage = async (imageRef: string, force = false) => {
|
||||
const handleScanImage = async (
|
||||
imageRef: string,
|
||||
options: { force?: boolean; scanners?: ('vuln' | 'secret')[] } = {},
|
||||
) => {
|
||||
const { force = false, scanners } = options;
|
||||
setScanningImageRef(imageRef);
|
||||
const loadingId = toast.loading(`Scanning ${imageRef}...`);
|
||||
try {
|
||||
const res = await apiFetch('/security/scan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageRef, force }),
|
||||
body: JSON.stringify({ imageRef, force, scanners }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || 'Failed to start scan');
|
||||
@@ -885,20 +889,37 @@ export default function ResourcesView() {
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningImageRef === img.RepoTags[0]}
|
||||
onClick={() => handleScanImage(img.RepoTags![0])}
|
||||
title="Scan for vulnerabilities"
|
||||
>
|
||||
{scanningImageRef === img.RepoTags[0] ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={scanningImageRef === img.RepoTags[0]}
|
||||
title="Scan for vulnerabilities"
|
||||
>
|
||||
{scanningImageRef === img.RepoTags[0] ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })}
|
||||
>
|
||||
Scan (vulnerabilities)
|
||||
</DropdownMenuItem>
|
||||
{isPaid && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleScanImage(img.RepoTags![0], { scanners: ['vuln', 'secret'] })}
|
||||
>
|
||||
Full scan (vulnerabilities + secrets)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
@@ -1488,7 +1509,7 @@ export default function ResourcesView() {
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }}
|
||||
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); }}
|
||||
canGenerateSbom={isPaid}
|
||||
canCompare={isPaid}
|
||||
canManageSuppressions={isPaid && isAdmin}
|
||||
|
||||
@@ -27,8 +27,11 @@ import {
|
||||
Loader2,
|
||||
Check,
|
||||
GitCompare,
|
||||
KeyRound,
|
||||
FileWarning,
|
||||
} from 'lucide-react';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -47,6 +50,8 @@ import type {
|
||||
VulnerabilityScan,
|
||||
VulnerabilityDetail,
|
||||
VulnSeverity,
|
||||
SecretFinding,
|
||||
MisconfigFinding,
|
||||
} from '@/types/security';
|
||||
|
||||
interface VulnerabilityScanSheetProps {
|
||||
@@ -67,6 +72,7 @@ interface SuppressDialogState {
|
||||
}
|
||||
|
||||
type SeverityFilter = 'ALL' | VulnSeverity;
|
||||
type FindingTab = 'vulns' | 'secrets' | 'misconfigs';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -102,9 +108,14 @@ export function VulnerabilityScanSheet({
|
||||
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
||||
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
|
||||
const [totalDetails, setTotalDetails] = useState(0);
|
||||
const [secrets, setSecrets] = useState<SecretFinding[]>([]);
|
||||
const [misconfigs, setMisconfigs] = useState<MisconfigFinding[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
|
||||
const [page, setPage] = useState(0);
|
||||
const [secretsPage, setSecretsPage] = useState(0);
|
||||
const [misconfigsPage, setMisconfigsPage] = useState(0);
|
||||
const [tab, setTab] = useState<FindingTab>('vulns');
|
||||
const [downloadingSbom, setDownloadingSbom] = useState(false);
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
const [compareOptions, setCompareOptions] = useState<VulnerabilityScan[]>([]);
|
||||
@@ -119,18 +130,33 @@ export function VulnerabilityScanSheet({
|
||||
if (scanId == null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [scanRes, detailsRes] = await Promise.all([
|
||||
const [scanRes, detailsRes, secretsRes, misconfigsRes] = await Promise.all([
|
||||
apiFetch(`/security/scans/${scanId}`),
|
||||
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`),
|
||||
apiFetch(`/security/scans/${scanId}/secrets?limit=${DETAIL_FETCH_LIMIT}`),
|
||||
apiFetch(`/security/scans/${scanId}/misconfigs?limit=${DETAIL_FETCH_LIMIT}`),
|
||||
]);
|
||||
if (!scanRes.ok) throw new Error('Failed to fetch scan');
|
||||
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
|
||||
const scanData = await scanRes.json();
|
||||
const scanData = (await scanRes.json()) as VulnerabilityScan;
|
||||
const detailsData = await detailsRes.json();
|
||||
const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] };
|
||||
const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] };
|
||||
setScan(scanData);
|
||||
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
|
||||
setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0);
|
||||
setSecrets(Array.isArray(secretsData.items) ? secretsData.items : []);
|
||||
setMisconfigs(Array.isArray(misconfigsData.items) ? misconfigsData.items : []);
|
||||
setPage(0);
|
||||
setSecretsPage(0);
|
||||
setMisconfigsPage(0);
|
||||
if ((scanData.total_vulnerabilities ?? 0) === 0) {
|
||||
if ((scanData.misconfig_count ?? 0) > 0) setTab('misconfigs');
|
||||
else if ((scanData.secret_count ?? 0) > 0) setTab('secrets');
|
||||
else setTab('vulns');
|
||||
} else {
|
||||
setTab('vulns');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to load scan');
|
||||
} finally {
|
||||
@@ -148,8 +174,13 @@ export function VulnerabilityScanSheet({
|
||||
setScan(null);
|
||||
setDetails([]);
|
||||
setTotalDetails(0);
|
||||
setSecrets([]);
|
||||
setMisconfigs([]);
|
||||
setSeverityFilter('ALL');
|
||||
setPage(0);
|
||||
setSecretsPage(0);
|
||||
setMisconfigsPage(0);
|
||||
setTab('vulns');
|
||||
}
|
||||
}, [scanId, load]);
|
||||
|
||||
@@ -163,6 +194,22 @@ export function VulnerabilityScanSheet({
|
||||
const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
||||
const needsPagination = filtered.length > PAGE_SIZE;
|
||||
|
||||
const secretsTotalPages = Math.max(1, Math.ceil(secrets.length / PAGE_SIZE));
|
||||
const secretsSafePage = Math.min(secretsPage, secretsTotalPages - 1);
|
||||
const secretsPageItems = secrets.slice(
|
||||
secretsSafePage * PAGE_SIZE,
|
||||
(secretsSafePage + 1) * PAGE_SIZE,
|
||||
);
|
||||
const secretsNeedsPagination = secrets.length > PAGE_SIZE;
|
||||
|
||||
const misconfigsTotalPages = Math.max(1, Math.ceil(misconfigs.length / PAGE_SIZE));
|
||||
const misconfigsSafePage = Math.min(misconfigsPage, misconfigsTotalPages - 1);
|
||||
const misconfigsPageItems = misconfigs.slice(
|
||||
misconfigsSafePage * PAGE_SIZE,
|
||||
(misconfigsSafePage + 1) * PAGE_SIZE,
|
||||
);
|
||||
const misconfigsNeedsPagination = misconfigs.length > PAGE_SIZE;
|
||||
|
||||
const downloadSbom = useCallback(
|
||||
async (format: 'spdx-json' | 'cyclonedx') => {
|
||||
if (!scan) return;
|
||||
@@ -457,144 +504,375 @@ export function VulnerabilityScanSheet({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Severity filter tabs */}
|
||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
|
||||
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={severityFilter === s ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => {
|
||||
setSeverityFilter(s);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
{needsPagination && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<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>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as FindingTab)}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
<div className="px-6 pt-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="vulns" className="gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Vulnerabilities
|
||||
<span className="font-mono tabular-nums text-stat-subtitle">
|
||||
({totalDetails})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="secrets" className="gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Secrets
|
||||
<span className="font-mono tabular-nums text-stat-subtitle">
|
||||
({scan.secret_count ?? secrets.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="misconfigs" className="gap-1.5">
|
||||
<FileWarning className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Misconfigs
|
||||
<span className="font-mono tabular-nums text-stat-subtitle">
|
||||
({scan.misconfig_count ?? misconfigs.length})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="vulns"
|
||||
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
|
||||
{(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={severityFilter === s ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => {
|
||||
setSeverityFilter(s);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
{needsPagination && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalDetails > details.length && (
|
||||
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
|
||||
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{pageItems.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
{details.length === 0
|
||||
? 'No vulnerabilities found.'
|
||||
: 'No vulnerabilities match the selected filter.'}
|
||||
{totalDetails > details.length && (
|
||||
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
|
||||
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
{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">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{d.suppressed && (
|
||||
<ShieldOff
|
||||
className="w-3 h-3 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{d.primary_url ? (
|
||||
<a
|
||||
href={d.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-xs truncate max-w-[180px]"
|
||||
title={d.suppression_reason || d.pkg_name}
|
||||
>
|
||||
{d.pkg_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityChip severity={d.severity} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{d.installed_version}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{d.fixed_version ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Check className="w-3 h-3" strokeWidth={1.5} />
|
||||
{d.fixed_version}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{canManageSuppressions && (
|
||||
<TableCell>
|
||||
{!d.suppressed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="Suppress this CVE"
|
||||
onClick={() => openSuppressDialog(d)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{pageItems.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
{details.length === 0
|
||||
? 'No vulnerabilities found.'
|
||||
: 'No vulnerabilities match the selected filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
{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">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{d.suppressed && (
|
||||
<ShieldOff
|
||||
className="w-3 h-3 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{d.primary_url ? (
|
||||
<a
|
||||
href={d.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-xs truncate max-w-[180px]"
|
||||
title={d.suppression_reason || d.pkg_name}
|
||||
>
|
||||
{d.pkg_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityChip severity={d.severity} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{d.installed_version}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{d.fixed_version ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Check className="w-3 h-3" strokeWidth={1.5} />
|
||||
{d.fixed_version}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{canManageSuppressions && (
|
||||
<TableCell>
|
||||
{!d.suppressed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="Suppress this CVE"
|
||||
onClick={() => openSuppressDialog(d)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="secrets"
|
||||
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
{secretsNeedsPagination && (
|
||||
<div className="px-6 pt-3 flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setSecretsPage(Math.max(0, secretsSafePage - 1))}
|
||||
disabled={secretsSafePage === 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">
|
||||
{secretsSafePage + 1} / {secretsTotalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
setSecretsPage(Math.min(secretsTotalPages - 1, secretsSafePage + 1))
|
||||
}
|
||||
disabled={secretsSafePage >= secretsTotalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{secrets.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
No secrets detected.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[160px]">Rule</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="w-[260px]">Target</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{secretsPageItems.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>
|
||||
<SeverityChip severity={s.severity} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[160px]" title={s.rule_id}>
|
||||
{s.rule_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="truncate max-w-[320px]" title={s.title ?? undefined}>
|
||||
{s.title || <span className="text-muted-foreground">-</span>}
|
||||
</div>
|
||||
{s.match_excerpt && (
|
||||
<div
|
||||
className="font-mono text-[11px] text-muted-foreground truncate max-w-[320px]"
|
||||
title={s.match_excerpt}
|
||||
>
|
||||
{s.match_excerpt}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[260px]" title={s.target}>
|
||||
{s.target}
|
||||
{s.start_line != null && (
|
||||
<span className="text-muted-foreground">
|
||||
:{s.start_line}
|
||||
{s.end_line != null && s.end_line !== s.start_line
|
||||
? `-${s.end_line}`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="misconfigs"
|
||||
className="flex flex-col flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
{misconfigsNeedsPagination && (
|
||||
<div className="px-6 pt-3 flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setMisconfigsPage(Math.max(0, misconfigsSafePage - 1))}
|
||||
disabled={misconfigsSafePage === 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">
|
||||
{misconfigsSafePage + 1} / {misconfigsTotalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
setMisconfigsPage(
|
||||
Math.min(misconfigsTotalPages - 1, misconfigsSafePage + 1),
|
||||
)
|
||||
}
|
||||
disabled={misconfigsSafePage >= misconfigsTotalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{misconfigs.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
No misconfigurations detected.
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{misconfigsPageItems.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell>
|
||||
<SeverityChip severity={m.severity} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-xs truncate max-w-[140px]"
|
||||
title={m.check_id ?? m.rule_id}
|
||||
>
|
||||
{m.check_id || m.rule_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
|
||||
{m.primary_url ? (
|
||||
<a
|
||||
href={m.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{m.title || m.rule_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
m.title || m.rule_id
|
||||
)}
|
||||
</div>
|
||||
{m.message && (
|
||||
<div
|
||||
className="text-[11px] text-muted-foreground truncate max-w-[320px]"
|
||||
title={m.message}
|
||||
>
|
||||
{m.message}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[200px]" title={m.target}>
|
||||
{m.target}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
|
||||
{m.resolution || <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
||||
Reference in New Issue
Block a user