mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 05:39:04 +00:00
757 lines
28 KiB
Svelte
757 lines
28 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import * as Dialog from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { ShieldCheck, ShieldAlert, ShieldX, AlertTriangle, Info, ExternalLink, Loader2, CheckCircle2, XCircle, Terminal, Download, FileText, FileSpreadsheet, Sun, Moon } from 'lucide-svelte';
|
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
imageName: string;
|
|
envId?: number | null;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
let { open = $bindable(), imageName, envId, onOpenChange }: Props = $props();
|
|
|
|
type ScanStage = 'idle' | 'checking' | 'pulling-scanner' | 'scanning' | 'parsing' | 'complete' | 'error';
|
|
|
|
interface VulnerabilitySummary {
|
|
critical: number;
|
|
high: number;
|
|
medium: number;
|
|
low: number;
|
|
negligible: number;
|
|
unknown: number;
|
|
}
|
|
|
|
interface Vulnerability {
|
|
id: string;
|
|
severity: string;
|
|
package: string;
|
|
version: string;
|
|
fixedVersion?: string;
|
|
description?: string;
|
|
link?: string;
|
|
scanner: 'grype' | 'trivy';
|
|
}
|
|
|
|
interface ScanResult {
|
|
imageId: string;
|
|
imageName: string;
|
|
scanner: 'grype' | 'trivy';
|
|
scannedAt: string;
|
|
vulnerabilities: Vulnerability[];
|
|
summary: VulnerabilitySummary;
|
|
scanDuration: number;
|
|
error?: string;
|
|
}
|
|
|
|
let stage = $state<ScanStage>('idle');
|
|
let message = $state('');
|
|
let progress = $state(0);
|
|
let error = $state('');
|
|
let result = $state<ScanResult | null>(null);
|
|
let results = $state<ScanResult[]>([]);
|
|
let scanner = $state<'grype' | 'trivy' | null>(null);
|
|
let expandedVulns = $state<Set<string>>(new Set());
|
|
let outputLinesByScanner = $state<Record<string, string[]>>({ grype: [], trivy: [], general: [] });
|
|
let outputContainer: HTMLDivElement | undefined;
|
|
let activeTab = $state<'grype' | 'trivy'>('grype');
|
|
let scannerErrors = $state<Record<string, string>>({});
|
|
let logDarkMode = $state(true);
|
|
|
|
// Get output lines for active scanner (or all if scanning)
|
|
const activeOutputLines = $derived(
|
|
stage === 'complete' && results.length > 1
|
|
? outputLinesByScanner[activeTab] || []
|
|
: [...outputLinesByScanner.general, ...outputLinesByScanner.grype, ...outputLinesByScanner.trivy]
|
|
);
|
|
|
|
// Computed total vulnerabilities across all results
|
|
const totalVulnerabilities = $derived(
|
|
results.length > 0
|
|
? results.reduce((sum, r) => sum + r.summary.critical + r.summary.high + r.summary.medium + r.summary.low + r.summary.negligible + r.summary.unknown, 0)
|
|
: (result ? result.summary.critical + result.summary.high + result.summary.medium + result.summary.low + result.summary.negligible + result.summary.unknown : 0)
|
|
);
|
|
|
|
// Get active result for display
|
|
const activeResult = $derived(
|
|
results.length > 1
|
|
? results.find(r => r.scanner === activeTab) || results[0]
|
|
: results[0] || result
|
|
);
|
|
|
|
// Reset state when modal opens
|
|
$effect(() => {
|
|
if (open) {
|
|
stage = 'idle';
|
|
message = '';
|
|
progress = 0;
|
|
error = '';
|
|
result = null;
|
|
results = [];
|
|
expandedVulns = new Set();
|
|
outputLinesByScanner = { grype: [], trivy: [], general: [] };
|
|
activeTab = 'grype';
|
|
scannerErrors = {};
|
|
startScan();
|
|
}
|
|
});
|
|
|
|
async function startScan() {
|
|
stage = 'checking';
|
|
message = 'Starting vulnerability scan...';
|
|
progress = 5;
|
|
error = '';
|
|
result = null;
|
|
|
|
try {
|
|
const url = envId ? `/api/images/scan?env=${envId}` : '/api/images/scan';
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ imageName })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error('No response body');
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const data = JSON.parse(line.slice(6));
|
|
handleProgress(data);
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
stage = 'error';
|
|
message = `Scan failed: ${error}`;
|
|
}
|
|
}
|
|
|
|
async function scrollOutputToBottom() {
|
|
await tick();
|
|
if (outputContainer) {
|
|
outputContainer.scrollTop = outputContainer.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function handleProgress(data: any) {
|
|
// Don't overwrite stage with 'error' for individual scanner failures when using 'both' mode
|
|
// The scanner sends 'error' stage per-scanner, but we want to continue scanning
|
|
if (data.stage === 'error' && data.scanner) {
|
|
// Store individual scanner error, don't change global stage yet
|
|
scannerErrors[data.scanner] = data.error || data.message || 'Unknown error';
|
|
} else {
|
|
stage = data.stage || stage;
|
|
}
|
|
message = data.message || message;
|
|
progress = data.progress ?? progress;
|
|
scanner = data.scanner || scanner;
|
|
|
|
if (data.result) {
|
|
result = data.result;
|
|
}
|
|
|
|
if (data.results && Array.isArray(data.results)) {
|
|
results = data.results;
|
|
if (results.length > 0) {
|
|
activeTab = results[0].scanner;
|
|
}
|
|
}
|
|
|
|
if (data.error && !data.scanner) {
|
|
// Global error (not per-scanner)
|
|
error = data.error;
|
|
}
|
|
|
|
// Store output by scanner with prefix for coloring
|
|
const targetScanner = data.scanner || 'general';
|
|
const prefix = targetScanner === 'grype' ? '[grype] ' : targetScanner === 'trivy' ? '[trivy] ' : '[dockhand] ';
|
|
if (data.output) {
|
|
outputLinesByScanner[targetScanner] = [...(outputLinesByScanner[targetScanner] || []), prefix + data.output];
|
|
scrollOutputToBottom();
|
|
}
|
|
|
|
if (data.message && !data.output) {
|
|
outputLinesByScanner[targetScanner] = [...(outputLinesByScanner[targetScanner] || []), prefix + data.message];
|
|
scrollOutputToBottom();
|
|
}
|
|
}
|
|
|
|
function toggleVulnDetails(vulnId: string) {
|
|
const newSet = new Set(expandedVulns);
|
|
if (newSet.has(vulnId)) {
|
|
newSet.delete(vulnId);
|
|
} else {
|
|
newSet.add(vulnId);
|
|
}
|
|
expandedVulns = newSet;
|
|
}
|
|
|
|
function toggleLogTheme() {
|
|
logDarkMode = !logDarkMode;
|
|
}
|
|
|
|
function getSeverityColor(severity: string): string {
|
|
switch (severity.toLowerCase()) {
|
|
case 'critical': return 'bg-red-500/10 text-red-500 border-red-500/30';
|
|
case 'high': return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
|
|
case 'medium': return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border-yellow-500/30';
|
|
case 'low': return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
|
|
case 'negligible': return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
|
|
default: return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
|
|
}
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
function downloadFile(content: string, filename: string, mimeType: string) {
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function sanitizeFilename(name: string): string {
|
|
return name.replace(/[/:]/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
}
|
|
|
|
function exportToCSV() {
|
|
if (!activeResult) return;
|
|
|
|
const headers = ['CVE ID', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Description', 'Link'];
|
|
const rows = activeResult.vulnerabilities.map(v => [
|
|
v.id,
|
|
v.severity,
|
|
v.package,
|
|
v.version,
|
|
v.fixedVersion || '',
|
|
(v.description || '').replace(/"/g, '""'),
|
|
v.link || ''
|
|
]);
|
|
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
|
].join('\n');
|
|
|
|
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.csv`;
|
|
downloadFile(csvContent, filename, 'text/csv');
|
|
}
|
|
|
|
function exportToMarkdown() {
|
|
if (!activeResult) return;
|
|
|
|
const scanDate = new Date(activeResult.scannedAt).toLocaleString();
|
|
const summaryParts = [];
|
|
if (activeResult.summary.critical > 0) summaryParts.push(`**${activeResult.summary.critical} Critical**`);
|
|
if (activeResult.summary.high > 0) summaryParts.push(`**${activeResult.summary.high} High**`);
|
|
if (activeResult.summary.medium > 0) summaryParts.push(`${activeResult.summary.medium} Medium`);
|
|
if (activeResult.summary.low > 0) summaryParts.push(`${activeResult.summary.low} Low`);
|
|
if (activeResult.summary.negligible > 0) summaryParts.push(`${activeResult.summary.negligible} Negligible`);
|
|
if (activeResult.summary.unknown > 0) summaryParts.push(`${activeResult.summary.unknown} Unknown`);
|
|
|
|
let md = `# Vulnerability Scan Report\n\n`;
|
|
md += `**Image:** \`${imageName}\`\n\n`;
|
|
md += `**Scanner:** ${activeResult.scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'}\n\n`;
|
|
md += `**Scan Date:** ${scanDate}\n\n`;
|
|
md += `**Duration:** ${formatDuration(activeResult.scanDuration)}\n\n`;
|
|
md += `## Summary\n\n`;
|
|
md += summaryParts.length > 0 ? summaryParts.join(' | ') : 'No vulnerabilities found';
|
|
md += `\n\n**Total:** ${activeResult.vulnerabilities.length} vulnerabilities\n\n`;
|
|
|
|
if (activeResult.vulnerabilities.length > 0) {
|
|
// Group by severity for better readability
|
|
const bySeverity: Record<string, Vulnerability[]> = {};
|
|
for (const vuln of activeResult.vulnerabilities) {
|
|
const sev = vuln.severity.toLowerCase();
|
|
if (!bySeverity[sev]) bySeverity[sev] = [];
|
|
bySeverity[sev].push(vuln);
|
|
}
|
|
|
|
const severityOrder = ['critical', 'high', 'medium', 'low', 'negligible', 'unknown'];
|
|
|
|
for (const severity of severityOrder) {
|
|
const vulns = bySeverity[severity];
|
|
if (!vulns || vulns.length === 0) continue;
|
|
|
|
md += `## ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${vulns.length})\n\n`;
|
|
|
|
for (const vuln of vulns) {
|
|
md += `### ${vuln.id}\n\n`;
|
|
md += `- **Package:** \`${vuln.package}\`\n`;
|
|
md += `- **Installed:** \`${vuln.version}\`\n`;
|
|
if (vuln.fixedVersion) {
|
|
md += `- **Fixed in:** \`${vuln.fixedVersion}\`\n`;
|
|
} else {
|
|
md += `- **Fixed in:** *No fix available*\n`;
|
|
}
|
|
if (vuln.link) {
|
|
md += `- **Reference:** [${vuln.id}](${vuln.link})\n`;
|
|
}
|
|
if (vuln.description) {
|
|
md += `\n${vuln.description}\n`;
|
|
}
|
|
md += `\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
md += `---\n\n*Report generated by Dockhand*\n`;
|
|
|
|
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.md`;
|
|
downloadFile(md, filename, 'text/markdown');
|
|
}
|
|
|
|
function exportToJSON() {
|
|
if (!activeResult) return;
|
|
|
|
const report = {
|
|
image: imageName,
|
|
scanner: activeResult.scanner,
|
|
scannedAt: activeResult.scannedAt,
|
|
scanDuration: activeResult.scanDuration,
|
|
summary: activeResult.summary,
|
|
vulnerabilities: activeResult.vulnerabilities
|
|
};
|
|
|
|
const jsonContent = JSON.stringify(report, null, 2);
|
|
const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.json`;
|
|
downloadFile(jsonContent, filename, 'application/json');
|
|
}
|
|
</script>
|
|
|
|
<Dialog.Root bind:open onOpenChange={onOpenChange}>
|
|
<Dialog.Content class="max-w-6xl h-[90vh] flex flex-col">
|
|
<Dialog.Header>
|
|
<Dialog.Title class="flex items-center gap-2">
|
|
{#if stage === 'complete' && activeResult}
|
|
{#if activeResult.summary.critical > 0 || activeResult.summary.high > 0}
|
|
<ShieldX class="w-5 h-5 text-red-500" />
|
|
{:else if activeResult.summary.medium > 0}
|
|
<ShieldAlert class="w-5 h-5 text-yellow-500" />
|
|
{:else}
|
|
<ShieldCheck class="w-5 h-5 text-green-500" />
|
|
{/if}
|
|
{:else if stage === 'error'}
|
|
<XCircle class="w-5 h-5 text-red-500" />
|
|
{:else}
|
|
<ShieldCheck class="w-5 h-5" />
|
|
{/if}
|
|
Vulnerability scan
|
|
</Dialog.Title>
|
|
<Dialog.Description class="space-y-1">
|
|
<div>Scanning <code class="text-xs bg-muted px-1.5 py-0.5 rounded">{imageName}</code></div>
|
|
{#if activeResult?.imageId}
|
|
<div class="text-xs text-muted-foreground font-mono">SHA: {activeResult.imageId.replace('sha256:', '')}</div>
|
|
{/if}
|
|
</Dialog.Description>
|
|
</Dialog.Header>
|
|
|
|
<div class="flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
{#if stage !== 'complete' && stage !== 'error'}
|
|
<!-- Scanning in progress -->
|
|
<div class="flex flex-col flex-1 min-h-0 py-4 gap-4">
|
|
<div class="flex items-center gap-3 shrink-0">
|
|
<Loader2 class="w-5 h-5 animate-spin text-primary" />
|
|
<span class="text-sm">{message}</span>
|
|
</div>
|
|
<div class="h-2 bg-muted rounded-full overflow-hidden shrink-0">
|
|
<div
|
|
class="h-full bg-primary transition-all duration-300"
|
|
style="width: {progress}%"
|
|
></div>
|
|
</div>
|
|
{#if scanner}
|
|
<p class="text-xs text-muted-foreground shrink-0">
|
|
Using {scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'} scanner
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Output Log -->
|
|
<div class="flex flex-col flex-1 min-h-0">
|
|
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<Terminal class="w-3.5 h-3.5" />
|
|
<span>Scanner output</span>
|
|
</div>
|
|
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors" title="Toggle log theme">
|
|
{#if logDarkMode}
|
|
<Sun class="w-3.5 h-3.5" />
|
|
{:else}
|
|
<Moon class="w-3.5 h-3.5" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<div
|
|
bind:this={outputContainer}
|
|
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
|
|
>
|
|
{#each activeOutputLines as line}
|
|
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
|
|
{#if line.startsWith('[grype]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[trivy]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[dockhand]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
|
|
<span>{line.slice(11)}</span>
|
|
{:else}
|
|
<span>{line}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if stage === 'error'}
|
|
<!-- Error state -->
|
|
<div class="flex flex-col flex-1 min-h-0 py-4 gap-4">
|
|
<!-- Show individual scanner errors if available -->
|
|
{#if Object.keys(scannerErrors).length > 0}
|
|
<div class="flex flex-col gap-2 shrink-0">
|
|
{#each Object.entries(scannerErrors) as [scannerName, scannerError]}
|
|
<div class="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
|
<div class="flex items-start gap-3">
|
|
<XCircle class="w-4 h-4 text-destructive mt-0.5 shrink-0" />
|
|
<div class="min-w-0">
|
|
<h4 class="font-medium text-destructive text-sm">{scannerName === 'grype' ? 'Grype' : 'Trivy'} failed</h4>
|
|
<p class="text-xs text-muted-foreground mt-1 break-words">{scannerError}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="p-4 rounded-lg bg-destructive/10 border border-destructive/30 shrink-0">
|
|
<div class="flex items-start gap-3">
|
|
<XCircle class="w-5 h-5 text-destructive mt-0.5" />
|
|
<div>
|
|
<h4 class="font-medium text-destructive">Scan failed</h4>
|
|
<p class="text-sm text-muted-foreground mt-1">{error}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<Button class="w-fit shrink-0" onclick={startScan}>
|
|
Retry scan
|
|
</Button>
|
|
|
|
<!-- Output Log -->
|
|
<div class="flex flex-col flex-1 min-h-0">
|
|
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<Terminal class="w-3.5 h-3.5" />
|
|
<span>Scanner output</span>
|
|
</div>
|
|
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors" title="Toggle log theme">
|
|
{#if logDarkMode}
|
|
<Sun class="w-3.5 h-3.5" />
|
|
{:else}
|
|
<Moon class="w-3.5 h-3.5" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<div
|
|
bind:this={outputContainer}
|
|
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
|
|
>
|
|
{#each activeOutputLines as line}
|
|
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
|
|
{#if line.startsWith('[grype]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[trivy]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[dockhand]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
|
|
<span>{line.slice(11)}</span>
|
|
{:else}
|
|
<span>{line}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if stage === 'complete' && activeResult}
|
|
<!-- Results -->
|
|
<div class="flex flex-col flex-1 min-h-0 py-2 gap-4">
|
|
<!-- Scanner tabs (only if multiple results) -->
|
|
{#if results.length > 1}
|
|
<div class="flex gap-1 border-b shrink-0">
|
|
{#each results as r}
|
|
<button
|
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === r.scanner ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
onclick={() => activeTab = r.scanner}
|
|
>
|
|
{r.scanner === 'grype' ? 'Grype' : 'Trivy'}
|
|
{#if r.summary.critical > 0 || r.summary.high > 0}
|
|
<Badge variant="outline" class="ml-2 bg-red-500/10 text-red-500 border-red-500/30 text-xs">
|
|
{r.summary.critical + r.summary.high}
|
|
</Badge>
|
|
{:else}
|
|
<Badge variant="outline" class="ml-2 bg-green-500/10 text-green-500 border-green-500/30 text-xs">
|
|
{r.vulnerabilities.length}
|
|
</Badge>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Show any scanner errors that occurred (partial success) -->
|
|
{#if Object.keys(scannerErrors).length > 0}
|
|
<div class="flex flex-col gap-2 shrink-0">
|
|
{#each Object.entries(scannerErrors) as [scannerName, scannerError]}
|
|
<div class="p-2 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
|
<div class="flex items-start gap-2">
|
|
<AlertTriangle class="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
|
|
<div class="min-w-0">
|
|
<span class="font-medium text-amber-600 dark:text-amber-500 text-sm">{scannerName === 'grype' ? 'Grype' : 'Trivy'} failed:</span>
|
|
<span class="text-xs text-muted-foreground ml-1">{scannerError}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Summary -->
|
|
<div class="flex flex-wrap gap-2 shrink-0">
|
|
{#if activeResult.summary.critical > 0}
|
|
<Badge variant="outline" class="bg-red-500/10 text-red-500 border-red-500/30">
|
|
{activeResult.summary.critical} Critical
|
|
</Badge>
|
|
{/if}
|
|
{#if activeResult.summary.high > 0}
|
|
<Badge variant="outline" class="bg-orange-500/10 text-orange-500 border-orange-500/30">
|
|
{activeResult.summary.high} High
|
|
</Badge>
|
|
{/if}
|
|
{#if activeResult.summary.medium > 0}
|
|
<Badge variant="outline" class="bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border-yellow-500/30">
|
|
{activeResult.summary.medium} Medium
|
|
</Badge>
|
|
{/if}
|
|
{#if activeResult.summary.low > 0}
|
|
<Badge variant="outline" class="bg-blue-500/10 text-blue-500 border-blue-500/30">
|
|
{activeResult.summary.low} Low
|
|
</Badge>
|
|
{/if}
|
|
{#if activeResult.summary.negligible > 0}
|
|
<Badge variant="outline" class="bg-gray-500/10 text-gray-500 border-gray-500/30">
|
|
{activeResult.summary.negligible} Negligible
|
|
</Badge>
|
|
{/if}
|
|
{#if activeResult.summary.unknown > 0}
|
|
<Badge variant="outline" class="bg-gray-500/10 text-gray-500 border-gray-500/30">
|
|
{activeResult.summary.unknown} Unknown
|
|
</Badge>
|
|
{/if}
|
|
{#if activeResult.vulnerabilities.length === 0}
|
|
<Badge variant="outline" class="bg-green-500/10 text-green-500 border-green-500/30">
|
|
<CheckCircle2 class="w-3 h-3 mr-1" />
|
|
No vulnerabilities found
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Scan info -->
|
|
<div class="text-xs text-muted-foreground flex items-center gap-3 shrink-0">
|
|
<span>Scanner: {activeResult.scanner === 'grype' ? 'Grype' : 'Trivy'}</span>
|
|
<span>Duration: {formatDuration(activeResult.scanDuration)}</span>
|
|
<span>Total: {activeResult.vulnerabilities.length} vulnerabilities</span>
|
|
</div>
|
|
|
|
<!-- Vulnerability list -->
|
|
{#if activeResult.vulnerabilities.length > 0}
|
|
<div class="border rounded-lg flex-1 min-h-0 max-h-[40vh] overflow-y-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-muted sticky top-0">
|
|
<tr>
|
|
<th class="text-left py-2 px-3 font-medium w-[20%]">CVE ID</th>
|
|
<th class="text-left py-2 px-3 font-medium w-[15%]">Severity</th>
|
|
<th class="text-left py-2 px-3 font-medium w-[25%]">Package</th>
|
|
<th class="text-left py-2 px-3 font-medium w-[20%]">Installed</th>
|
|
<th class="text-left py-2 px-3 font-medium w-[20%]">Fixed in</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each activeResult.vulnerabilities.slice(0, 100) as vuln, i}
|
|
<tr
|
|
class="border-t border-muted hover:bg-muted/30 cursor-pointer transition-colors"
|
|
onclick={() => toggleVulnDetails(vuln.id + i)}
|
|
>
|
|
<td class="py-2 px-3">
|
|
<div class="flex items-center gap-1.5">
|
|
<code class="text-xs">{vuln.id}</code>
|
|
{#if vuln.link}
|
|
<a
|
|
href={vuln.link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick={(e) => e.stopPropagation()}
|
|
class="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ExternalLink class="w-3 h-3" />
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
<Badge variant="outline" class={getSeverityColor(vuln.severity)}>
|
|
{vuln.severity}
|
|
</Badge>
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
<code class="text-xs">{vuln.package}</code>
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
<code class="text-xs text-muted-foreground">{vuln.version}</code>
|
|
</td>
|
|
<td class="py-2 px-3">
|
|
{#if vuln.fixedVersion}
|
|
<code class="text-xs text-green-600 dark:text-green-500">{vuln.fixedVersion}</code>
|
|
{:else}
|
|
<span class="text-xs text-muted-foreground italic">No fix available</span>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{#if expandedVulns.has(vuln.id + i) && vuln.description}
|
|
<tr class="bg-muted/20">
|
|
<td colspan="5" class="py-2 px-3">
|
|
<p class="text-xs text-muted-foreground">{vuln.description}</p>
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{#if activeResult.vulnerabilities.length > 100}
|
|
<div class="py-2 px-3 bg-muted/50 text-xs text-muted-foreground text-center">
|
|
Showing 100 of {activeResult.vulnerabilities.length} vulnerabilities
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Output Log -->
|
|
<div class="flex flex-col flex-1 min-h-[120px]">
|
|
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<Terminal class="w-3.5 h-3.5" />
|
|
<span>Scanner output ({activeOutputLines.length} lines)</span>
|
|
</div>
|
|
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors" title="Toggle log theme">
|
|
{#if logDarkMode}
|
|
<Sun class="w-3.5 h-3.5" />
|
|
{:else}
|
|
<Moon class="w-3.5 h-3.5" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<div
|
|
bind:this={outputContainer}
|
|
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
|
|
>
|
|
{#each activeOutputLines as line}
|
|
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
|
|
{#if line.startsWith('[grype]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[trivy]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
|
|
<span>{line.slice(8)}</span>
|
|
{:else if line.startsWith('[dockhand]')}
|
|
<span class="inline-flex items-center px-1 rounded text-2xs font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
|
|
<span>{line.slice(11)}</span>
|
|
{:else}
|
|
<span>{line}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<Dialog.Footer class="flex justify-between">
|
|
{#if stage === 'complete'}
|
|
<div class="flex gap-2">
|
|
<Button variant="outline" onclick={startScan}>
|
|
Rescan
|
|
</Button>
|
|
{#if activeResult && activeResult.vulnerabilities.length > 0}
|
|
<DropdownMenu.Root>
|
|
<DropdownMenu.Trigger>
|
|
{#snippet child({ props })}
|
|
<Button variant="outline" {...props}>
|
|
<Download class="w-4 h-4 mr-2" />
|
|
Report
|
|
</Button>
|
|
{/snippet}
|
|
</DropdownMenu.Trigger>
|
|
<DropdownMenu.Content align="start">
|
|
<DropdownMenu.Item onclick={exportToMarkdown}>
|
|
<FileText class="w-4 h-4 mr-2 text-blue-500" />
|
|
Markdown report (.md)
|
|
</DropdownMenu.Item>
|
|
<DropdownMenu.Item onclick={exportToCSV}>
|
|
<FileSpreadsheet class="w-4 h-4 mr-2 text-green-500" />
|
|
CSV spreadsheet (.csv)
|
|
</DropdownMenu.Item>
|
|
<DropdownMenu.Item onclick={exportToJSON}>
|
|
<FileText class="w-4 h-4 mr-2 text-amber-500" />
|
|
JSON data (.json)
|
|
</DropdownMenu.Item>
|
|
</DropdownMenu.Content>
|
|
</DropdownMenu.Root>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div></div>
|
|
{/if}
|
|
<Button variant="secondary" onclick={() => open = false}>
|
|
Close
|
|
</Button>
|
|
</Dialog.Footer>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|