-
+
+ {#if activeTab === 'images'}
@@ -829,10 +1034,96 @@
{/if}
+ {/if}
+
+ {#if activeTab === 'vulnerabilities'}
+
+
+
+ e.key === 'Escape' && (vulnSearch = '')}
+ class="pl-8 h-8 w-80 text-sm"
+ />
+
+
+
+
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+ exportVulnerabilities('json')}>
+
+ JSON
+
+ exportVulnerabilities('csv')}>
+
+ CSV
+
+ exportVulnerabilities('sarif')}>
+
+ SARIF
+
+
+
+
+ {#if scannerEnabled}
+
+ {/if}
+
+
+ {/if}
-
-
+
+
+ {#if activeTab === 'images'}
+
+
+ {#if selectedImages.size > 0}
+
{#if selectedImages.size > 0}
{selectedInFilter.length} selected
@@ -857,6 +1148,7 @@
{/if}
+ {/if}
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
@@ -1171,8 +1463,33 @@
{/snippet}
{/if}
+ {/if}
+
+
+
+ {#if activeTab === 'vulnerabilities'}
+ { vulnSort = field; vulnDir = dir; }}
+ onRangeChange={handleVulnRange}
+ />
+ {/if}
+
+
+
+
+
diff --git a/src/routes/images/ImageLayersView.svelte b/src/routes/images/ImageLayersView.svelte
index 4025ada..aca4dc9 100644
--- a/src/routes/images/ImageLayersView.svelte
+++ b/src/routes/images/ImageLayersView.svelte
@@ -3,6 +3,7 @@
import { Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-svelte';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings';
+ import { highlightCommand } from '$lib/utils/highlight-command';
interface Props {
imageId: string;
@@ -125,37 +126,6 @@
return parts[0];
}
- function highlightCommand(cmd: string): string {
- if (!cmd) return '';
-
- // Dockerfile instructions
- const dockerInstructions = ['ADD', 'COPY', 'ENV', 'ARG', 'WORKDIR', 'RUN', 'CMD', 'ENTRYPOINT', 'EXPOSE', 'VOLUME', 'USER', 'LABEL', 'HEALTHCHECK', 'SHELL', 'ONBUILD', 'STOPSIGNAL', 'MAINTAINER', 'FROM'];
- const dockerInstructionPattern = new RegExp(`\\b(${dockerInstructions.join('|')})\\b`, 'g');
-
- // Shell keywords
- const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'in', 'select'];
- const shellKeywordPattern = new RegExp(`\\b(${shellKeywords.join('|')})\\b`, 'g');
-
- // Common commands
- const commands = ['apt-get', 'apk', 'yum', 'pip', 'npm', 'yarn', 'git', 'curl', 'wget', 'mkdir', 'cd', 'cp', 'mv', 'rm', 'chmod', 'chown', 'echo', 'cat', 'grep', 'sed', 'awk', 'tar', 'unzip', 'make', 'gcc', 'python', 'node', 'sh', 'bash'];
- const commandPattern = new RegExp(`\\b(${commands.join('|')})\\b`, 'g');
-
- let highlighted = cmd
- // Strings in quotes
- .replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '
$1')
- // Comments
- .replace(/(#[^\n]*)/g, '
$1')
- // Dockerfile instructions (must be before shell keywords to take precedence)
- .replace(dockerInstructionPattern, '
$1')
- // Shell keywords
- .replace(shellKeywordPattern, '
$1')
- // Commands
- .replace(commandPattern, '
$1')
- // Flags (words starting with - or --)
- .replace(/(\s)(--?[a-zA-Z0-9-]+)/g, '$1
$2');
-
- return highlighted;
- }
diff --git a/src/routes/images/ImageScanModal.svelte b/src/routes/images/ImageScanModal.svelte
index 11a9593..29c3be8 100644
--- a/src/routes/images/ImageScanModal.svelte
+++ b/src/routes/images/ImageScanModal.svelte
@@ -3,7 +3,9 @@
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Checkbox } from '$lib/components/ui/checkbox';
- import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileSpreadsheet } from 'lucide-svelte';
+ import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileJson, FileSpreadsheet, ShieldPlus } from 'lucide-svelte';
+ import { findingsToSarif } from '$lib/utils/sarif';
+ import { flattenScansToFindings } from '$lib/utils/vulnerability';
import { currentEnvironment } from '$lib/stores/environment';
import ScanTab from '$lib/components/ScanTab.svelte';
import type { ScanResult } from '$lib/components/ScanTab.svelte';
@@ -224,6 +226,24 @@
downloadFile(jsonContent, filename, 'application/json');
}
+ function exportToSARIF() {
+ if (exportResults.length === 0) return;
+ // Reuse the shared flattener so cross-scanner duplicates (grype + trivy
+ // reporting the same CVE) are deduped, matching the server exports.
+ const findings = flattenScansToFindings(
+ exportResults.map((r) => ({
+ imageId: r.imageId ?? imageName,
+ imageName,
+ scannedAt: new Date().toISOString(),
+ vulnerabilities: r.vulnerabilities
+ }))
+ );
+ const sarif = findingsToSarif(findings, { toolName: 'Dockhand' });
+ const scannerSuffix = exportResults.length > 1 ? 'combined' : exportResults[0].scanner;
+ const filename = `vuln-report-${sanitizeFilename(imageName)}-${scannerSuffix}-${new Date().toISOString().split('T')[0]}.sarif`;
+ downloadFile(JSON.stringify(sarif, null, 2), filename, 'application/sarif+json');
+ }
+
const totalVulnerabilities = $derived(
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
@@ -312,9 +332,13 @@
CSV spreadsheet (.csv)
-
+
JSON data (.json)
+
+
+ SARIF (.sarif)
+
{/if}
diff --git a/src/routes/images/ScanResultsView.svelte b/src/routes/images/ScanResultsView.svelte
index c085729..7368b27 100644
--- a/src/routes/images/ScanResultsView.svelte
+++ b/src/routes/images/ScanResultsView.svelte
@@ -1,6 +1,7 @@
+
+
+
+
+ Images scanned: {summary.imagesScanned}/{summary.totalImages}
+ Total: {summary.total}
+
+
+
+ {#if !scannerEnabled}
+
+
+
No vulnerability scanner is configured for this environment.
+
Configure a scanner in settings
+
+ {:else}
+ {#if !loading && findings.length === 0}
+
+
+
No vulnerabilities found.
+
Click "Scan all images" to populate this view.
+
+ {:else}
+
+
+
onSortChange?.(state.field as SortField, state.direction)}
+ onVisibleRangeChange={(start, end) => onRangeChange?.(start, end)}
+ >
+ {#snippet headerCell(column, sortState)}
+ {#if column.sortable}
+
+ {:else}
+ {column.label}
+ {/if}
+ {/snippet}
+ {#snippet cell(column, finding)}
+ {#if column.id === 'cve'}
+ {#snippet cveLink(extra: Record = {})}
+
+ {finding.cve}
+
+
+ {/snippet}
+ {#if finding.description}
+
+
+ {#snippet child({ props })}
+ {@render cveLink(props)}
+ {/snippet}
+
+
+
+
+
{finding.description}
+
+
+
+ {:else}
+ {@render cveLink()}
+ {/if}
+ {:else if column.id === 'package'}
+ {finding.package}
+ {:else if column.id === 'severity'}
+
+ {finding.severity}
+
+ {:else if column.id === 'installed'}
+ {finding.installedVersion}
+ {:else if column.id === 'fixed'}
+ {#if finding.fixedVersion}
+ {finding.fixedVersion}
+ {:else}
+ No fix
+ {/if}
+ {:else if column.id === 'image'}
+ {finding.imageName}
+ {:else if column.id === 'container'}
+ {#if finding.containers && finding.containers.length}
+
+
+ {#if finding.containers.length > 1}
+ c.name).join(', ')}>
+ +{finding.containers.length - 1}
+
+ {/if}
+
+ {:else}
+ —
+ {/if}
+ {:else if column.id === 'stack'}
+ {#if finding.stacks && finding.stacks.length}
+
+ {:else}
+ —
+ {/if}
+ {:else if column.id === 'scannedAt'}
+ {#if finding.scannedAt}
+ {formatDateTime(finding.scannedAt)}
+ {:else}
+ —
+ {/if}
+ {/if}
+ {/snippet}
+
+
+ {/if}
+ {/if}
+
+
+
diff --git a/src/routes/images/VulnerabilityScanModal.svelte b/src/routes/images/VulnerabilityScanModal.svelte
new file mode 100644
index 0000000..9bda455
--- /dev/null
+++ b/src/routes/images/VulnerabilityScanModal.svelte
@@ -0,0 +1,312 @@
+
+
+
+ {
+ // While scanning, ESC cancels the operation instead of closing the modal.
+ if (phase === 'scanning') { e.preventDefault(); cancel(); }
+ }}
+ onInteractOutside={(e) => {
+ if (phase === 'scanning') { e.preventDefault(); cancel(); }
+ }}
+ >
+
+
+
+ Scanning images{#if envName} on {envName}{/if}
+
+
+ {#if phase === 'scanning'}
+ Running the configured scanner across all images. This can take a while.
+ {:else if phase === 'done'}
+ Scan complete.
+ {:else if phase === 'cancelled'}
+ Scan cancelled.
+ {:else if phase === 'error'}
+ The scan did not finish successfully.
+ {/if}
+
+
+
+
+
+ {#if phase === 'scanning'}
+
+
+ {#if currentImage}
+ Scanning
+ {currentImage}
+ ({current}/{total})
+ {:else}
+ Preparing scan…
+ {/if}
+
+
+
+
+
+ {#if totalFindings === 0 && log.length > 0}
+ No findings yet
+ {/if}
+
+
{percent}%
+
+ {:else}
+
+
+ {#if phase === 'done'}
+
+ {:else if phase === 'cancelled'}
+
+ {:else}
+
+ {/if}
+ {#if phase === 'error'}
+ {errorMessage}
+ {:else}
+
+ Scanned {scanned} image{scanned === 1 ? '' : 's'}{failed > 0 ? `, ${failed} failed` : ''}
+ · {totalFindings} finding{totalFindings === 1 ? '' : 's'}.
+
+ {/if}
+
+ {#if phase !== 'error'}
+
+ {/if}
+ {/if}
+
+
+
+
+ {#if log.length > 0}
+
+ {#each log as entry, i (i)}
+
+ {#if entry.ok}
+
+ {:else}
+
+ {/if}
+
{entry.imageName}
+ {#if entry.ok}
+ {@const hasFindings = entry.scanners.some((s) => s.critical + s.high + s.medium + s.low > 0)}
+ {#if hasFindings}
+
+ {#each SCANNERS as sc}
+ {@const counts = scannerCounts(entry, sc.id)}
+
+ {sc.label}
+ {#each SEVERITY_COLS as col}
+ {@const n = counts ? counts[col.key] : 0}
+ {n}
+ {/each}
+
+ {/each}
+
+ {:else}
+
clean
+ {/if}
+ {:else}
+
failed
+ {/if}
+
+ {/each}
+
+ {:else}
+
+
+ Waiting for first result…
+
+ {/if}
+
+
+
+ {#if phase === 'scanning'}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/metrics/+server.ts b/src/routes/metrics/+server.ts
new file mode 100644
index 0000000..5b1bc81
--- /dev/null
+++ b/src/routes/metrics/+server.ts
@@ -0,0 +1,42 @@
+/**
+ * Prometheus metrics endpoint (#339), served at /metrics (Prometheus convention).
+ *
+ * Gating:
+ * - Disabled unless EXPORT_METRICS=true → 404 (looks like the route doesn't exist).
+ * - When app auth is DISABLED, /metrics is public (mirrors /api/health), so a
+ * trusted-network scrape works out of the box.
+ * - When app auth is ENABLED, a valid session OR API bearer token is required
+ * (authorize() handles both) → 401 otherwise. Prometheus scrapes with a
+ * `authorization: { credentials:
}`.
+ */
+import { authorize } from '$lib/server/authorize';
+import { METRICS_ENABLED, renderMetrics, metricsContentType } from '$lib/server/metrics';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ cookies }) => {
+ if (!METRICS_ENABLED) {
+ return new Response('Not found', { status: 404 });
+ }
+
+ const auth = await authorize(cookies);
+ if (auth.authEnabled && !auth.isAuthenticated) {
+ return new Response('Unauthorized', {
+ status: 401,
+ headers: { 'WWW-Authenticate': 'Bearer' }
+ });
+ }
+
+ try {
+ const body = await renderMetrics();
+ return new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': metricsContentType }
+ });
+ } catch (error) {
+ console.error('[metrics] render failed:', error);
+ return new Response('# metrics collection failed\n', {
+ status: 500,
+ headers: { 'Content-Type': 'text/plain' }
+ });
+ }
+};
diff --git a/src/routes/registry/CopyToRegistryModal.svelte b/src/routes/registry/CopyToRegistryModal.svelte
index 997ca61..c59e35b 100644
--- a/src/routes/registry/CopyToRegistryModal.svelte
+++ b/src/routes/registry/CopyToRegistryModal.svelte
@@ -79,7 +79,12 @@
const fullSourceImageName = $derived(() => {
const tagToUse = sourceTag.trim() || 'latest';
- const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`;
+ // "Already tagged" means a colon AFTER the last slash (or a @digest) — NOT the
+ // colon in a registry host:port like registry.example.com:5000/myimage, which
+ // would otherwise swallow the real tag and default it to :latest (#1243).
+ const lastSlash = imageName.lastIndexOf('/');
+ const alreadyTagged = imageName.lastIndexOf(':') > lastSlash || imageName.includes('@');
+ const imageWithTag = alreadyTagged ? imageName : `${imageName}:${tagToUse}`;
if (sourceRegistry && !isDockerHub(sourceRegistry)) {
const urlObj = new URL(sourceRegistry.url);
// Include both host and path (e.g., registry.example.com/organization)
diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte
index f509b67..71c3781 100644
--- a/src/routes/settings/general/GeneralTab.svelte
+++ b/src/routes/settings/general/GeneralTab.svelte
@@ -26,6 +26,7 @@
let showExposedPorts = $derived($appSettings.showExposedPorts);
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
+ let showWhatsNew = $derived($appSettings.showWhatsNew);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
@@ -351,6 +352,20 @@ services:
Show a release-notes icon next to images with updates available