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:
Anso
2026-04-17 07:57:08 -04:00
committed by GitHub
parent 732fc95415
commit a95bf1ff33
10 changed files with 1513 additions and 188 deletions
+58 -1
View File
@@ -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>
);
}
+38 -17
View File
@@ -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}
+414 -136
View File
@@ -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>