1.0.37
This commit is contained in:
101 files changed
+5097
-642
No files matched your search
@@ -1,16 +1,8 @@
|
||||
import { authorize, enterpriseRequired } from '$lib/server/authorize';
|
||||
import { getAuditLogs, type AuditLogFilters, type AuditEntityType, type AuditAction, type AuditLog } from '$lib/server/db';
|
||||
import { rowsToCSV } from '$lib/server/csv';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
function escapeCSV(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function formatToJSON(logs: AuditLog[]): string {
|
||||
return JSON.stringify(logs, null, 2);
|
||||
}
|
||||
@@ -34,19 +26,19 @@ function formatToCSV(logs: AuditLog[]): string {
|
||||
const rows = logs.map((log) => [
|
||||
log.id,
|
||||
log.createdAt,
|
||||
escapeCSV(log.username),
|
||||
escapeCSV(log.action),
|
||||
escapeCSV(log.entityType),
|
||||
escapeCSV(log.entityId),
|
||||
escapeCSV(log.entityName),
|
||||
log.username,
|
||||
log.action,
|
||||
log.entityType,
|
||||
log.entityId,
|
||||
log.entityName,
|
||||
log.environmentId ?? '',
|
||||
escapeCSV(log.description),
|
||||
escapeCSV(log.ipAddress),
|
||||
escapeCSV(log.userAgent),
|
||||
escapeCSV(log.details ? JSON.stringify(log.details) : '')
|
||||
log.description,
|
||||
log.ipAddress,
|
||||
log.userAgent,
|
||||
log.details ? JSON.stringify(log.details) : ''
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||
return rowsToCSV(headers, rows);
|
||||
}
|
||||
|
||||
function formatToMarkdown(logs: AuditLog[]): string {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||
import { attachmentContentDisposition } from '$lib/server/content-disposition';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
@@ -74,7 +75,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}${extension}"`
|
||||
'Content-Disposition': attachmentContentDisposition(`${filename}${extension}`)
|
||||
};
|
||||
|
||||
// Set content length for compressed data
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates } from '$lib/server/db';
|
||||
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
@@ -21,7 +21,43 @@ export interface UpdateCheckResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all containers for available image updates.
|
||||
* GET = READ the cached result of the last update check (no side effects, does NOT
|
||||
* trigger a new check). Returns the containers currently flagged as having a pending
|
||||
* image update. Use POST (below) to actually run a fresh check. (issue #1266)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (!envIdNum) {
|
||||
return json({ error: 'Environment ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingUpdates = await getPendingContainerUpdates(envIdNum);
|
||||
return json({
|
||||
environmentId: envIdNum,
|
||||
pendingUpdates: pendingUpdates.map(u => ({
|
||||
containerId: u.containerId,
|
||||
containerName: u.containerName,
|
||||
currentImage: u.currentImage,
|
||||
checkedAt: u.checkedAt
|
||||
}))
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting pending updates:', error);
|
||||
return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST = TRIGGER a fresh update check across all containers (job-based).
|
||||
* Returns progress events during checking, final result when done.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
@@ -44,9 +80,9 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
// Skip:
|
||||
// - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
|
||||
// - Podman pod-infra containers (localhost/podman-pause, never published) (#1221)
|
||||
// - Podman pod-infra containers (named <pod>-infra, never published) (#1221)
|
||||
const containers = allContainers.filter(
|
||||
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
|
||||
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
|
||||
);
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
@@ -125,9 +125,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
|
||||
const hostInfo: HostInfo = {
|
||||
// For local connections, show local system info; for remote, show Docker host info
|
||||
hostname: isLocalConnection ? os.hostname() : (dockerInfo.Name || env.host || 'unknown'),
|
||||
ipAddress: isLocalConnection ? getLocalIpAddress() : (env.host || 'unknown'),
|
||||
// Hostname/IP describe the Docker DAEMON's host, NOT Dockhand's own
|
||||
// container. `os.hostname()` / getLocalIpAddress() run INSIDE this
|
||||
// container, so on a local socket they returned the container id and the
|
||||
// bridge IP instead of the real host (issue #1265). Docker's /info `Name`
|
||||
// is the daemon host's hostname for every connection type (the entrypoint
|
||||
// also derives it into DOCKHAND_HOSTNAME).
|
||||
//
|
||||
// The host's LAN IP is NOT reliably discoverable from inside a container
|
||||
// over the socket — Docker's API exposes no host-IP field, and every
|
||||
// container-visible address (bridge gateway, own interfaces) is the wrong
|
||||
// 172.x value. So we surface DOCKHAND_HOST_IP if the operator set it, else
|
||||
// the configured env host, else 'localhost' — never a misleading bridge IP.
|
||||
hostname: dockerInfo?.Name || process.env.DOCKHAND_HOSTNAME || env.host || 'unknown',
|
||||
ipAddress: isLocalConnection ? (process.env.DOCKHAND_HOST_IP || env.host || 'localhost') : (env.host || 'unknown'),
|
||||
platform: isLocalConnection ? os.platform() : (dockerInfo.OperatingSystem || 'unknown'),
|
||||
arch: isLocalConnection ? os.arch() : (dockerInfo.Architecture || 'unknown'),
|
||||
cpus: isLocalConnection ? os.cpus().length : (dockerInfo.NCPU || 0),
|
||||
|
||||
@@ -113,6 +113,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
|
||||
sendData({
|
||||
status: 'scan-complete',
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
|
||||
import { scanImage, scanResultToDbFormat, type ScanProgress } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
|
||||
import { inspectImage } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
// Helper to convert ScanResult to database format
|
||||
function scanResultToDbFormat(result: ScanResult, envId?: number) {
|
||||
return {
|
||||
environmentId: envId ?? null,
|
||||
imageId: result.imageId || result.imageName, // Fallback to imageName if imageId is undefined
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: JSON.stringify(result.vulnerabilities),
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
|
||||
// POST - Start a scan (returns { jobId } for progress polling, or synchronous JSON for Accept: application/json)
|
||||
export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Per-image vulnerability export (#415): reformats the cached scan for one image
|
||||
* as json | csv | sarif for CI / DefectDojo / Dependency-Track integration.
|
||||
* Read-only over persisted scans; no new scanning. Auth via cookie or Bearer
|
||||
* token (CI), with RBAC + enterprise environment scoping.
|
||||
*/
|
||||
import { getScansForImage, getEnvironment } from '$lib/server/db';
|
||||
import { flattenScansToFindings } from '$lib/server/vulnerabilities';
|
||||
import { authorizeVulnAccess } from '$lib/server/vuln-access';
|
||||
import { rowsToCSV } from '$lib/server/csv';
|
||||
import { exportResponse, jsonError, slugify } from '$lib/server/export-response';
|
||||
import type { Finding } from '$lib/utils/vulnerability';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
function toCSV(findings: Finding[]): string {
|
||||
const headers = ['CVE', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Image', 'Scanned', 'Description', 'Link'];
|
||||
const rows = findings.map((f) => [
|
||||
f.cve, f.severity, f.package, f.installedVersion, f.fixedVersion, f.imageName, f.scannedAt, f.description, f.link
|
||||
]);
|
||||
return rowsToCSV(headers, rows);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const imageId = url.searchParams.get('imageId') || url.searchParams.get('image');
|
||||
if (!imageId) return jsonError('imageId is required', 400);
|
||||
|
||||
// RBAC (images:view) + enterprise environment scoping, shared with the other
|
||||
// vuln endpoints. Auth via cookie or Bearer token is handled inside authorize().
|
||||
const { envIdNum, denied, authEnabled } = await authorizeVulnAccess(cookies, url);
|
||||
if (denied) return jsonError(denied.message, denied.status);
|
||||
|
||||
// The same image SHA can be scanned in multiple environments. When auth is on,
|
||||
// require an explicit env so the tenant-scoping check in authorizeVulnAccess
|
||||
// actually runs — otherwise a user scoped to env A could read env B's scans by
|
||||
// omitting env (CWE-639). Free/open mode has no tenant boundary to enforce.
|
||||
if (authEnabled && envIdNum === undefined) {
|
||||
return jsonError('env is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Always scope the query by env so cross-environment scans are never returned,
|
||||
// even if an access check were somehow bypassed (defense in depth).
|
||||
const scans = await getScansForImage(imageId, envIdNum);
|
||||
const findings = flattenScansToFindings(scans);
|
||||
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const imageSlug = findings[0]?.imageName
|
||||
? slugify(findings[0].imageName, 'image')
|
||||
: slugify(imageId.replace('sha256:', '').slice(0, 12), 'image');
|
||||
|
||||
let envSlug = '';
|
||||
if (envIdNum) {
|
||||
const env = await getEnvironment(envIdNum);
|
||||
if (env?.name) envSlug = `-${slugify(env.name, 'env')}`;
|
||||
}
|
||||
|
||||
return exportResponse({
|
||||
format: url.searchParams.get('format') || 'json',
|
||||
filenameBase: `vulnerabilities-${imageSlug}${envSlug}-${timestamp}`,
|
||||
findings,
|
||||
jsonBody: { imageId, findings },
|
||||
toCSV
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error exporting image vulnerabilities:', error);
|
||||
return jsonError('Failed to export image vulnerabilities', 500);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/jobs';
|
||||
import { getJob, cancelJob } from '$lib/server/jobs';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
@@ -21,3 +21,13 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
result: job.result ?? null
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/jobs/[id]
|
||||
* Request cancellation of a running job. The job's operation polls the flag
|
||||
* between units of work and stops gracefully.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const cancelled = cancelJob(params.id);
|
||||
return json({ cancelled });
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { getSidebarPreferences, setSidebarPreferences, deleteSidebarPreferences } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { SidebarPreferences } from '$lib/server/db';
|
||||
|
||||
// GET - retrieve sidebar menu preferences
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
try {
|
||||
// userId for per-user storage when auth is enabled
|
||||
const userId = auth.authEnabled ? auth.user?.id : undefined;
|
||||
const preferences = await getSidebarPreferences(userId);
|
||||
|
||||
return json({ preferences });
|
||||
} catch (error) {
|
||||
console.error('Failed to get sidebar preferences:', error);
|
||||
return json({ error: 'Failed to get sidebar preferences' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// POST - update sidebar menu preferences
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { order, hidden } = body;
|
||||
|
||||
if (!Array.isArray(order) || order.some((h) => typeof h !== 'string')) {
|
||||
return json({ error: 'order must be an array of strings' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(hidden) || hidden.some((h) => typeof h !== 'string')) {
|
||||
return json({ error: 'hidden must be an array of strings' }, { status: 400 });
|
||||
}
|
||||
|
||||
const prefs: SidebarPreferences = { order, hidden };
|
||||
|
||||
// userId for per-user storage when auth is enabled
|
||||
const userId = auth.authEnabled ? auth.user?.id : undefined;
|
||||
await setSidebarPreferences(prefs, userId);
|
||||
|
||||
const preferences = await getSidebarPreferences(userId);
|
||||
return json({ preferences });
|
||||
} catch (error) {
|
||||
console.error('Failed to save sidebar preferences:', error);
|
||||
return json({ error: 'Failed to save sidebar preferences' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE - reset sidebar menu preferences to default
|
||||
export const DELETE: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
try {
|
||||
const userId = auth.authEnabled ? auth.user?.id : undefined;
|
||||
await deleteSidebarPreferences(userId);
|
||||
|
||||
const preferences = await getSidebarPreferences(userId);
|
||||
return json({ preferences });
|
||||
} catch (error) {
|
||||
console.error('Failed to reset sidebar preferences:', error);
|
||||
return json({ error: 'Failed to reset sidebar preferences' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -103,6 +103,8 @@ export interface GeneralSettings {
|
||||
// Whether to surface a "view changelog" link next to the update badge.
|
||||
// Resolved client-side from OCI labels / GHCR image names; no server hit.
|
||||
showImageChangelogLinks: boolean;
|
||||
// Show the "What's New" modal after an upgrade (#1235)
|
||||
showWhatsNew: boolean;
|
||||
// Whether spinning icons (animate-spin etc.) are animated (#1169)
|
||||
animateIcons: boolean;
|
||||
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
|
||||
@@ -146,6 +148,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
labelFilterMode: 'any' as const,
|
||||
honorProxyLabels: true,
|
||||
showImageChangelogLinks: true,
|
||||
showWhatsNew: true,
|
||||
animateIcons: true,
|
||||
protectScannerImages: true,
|
||||
defaultScannerNetworkMode: '',
|
||||
@@ -250,6 +253,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
labelFilterMode,
|
||||
honorProxyLabels,
|
||||
showImageChangelogLinks,
|
||||
showWhatsNew,
|
||||
animateIcons,
|
||||
protectScannerImages,
|
||||
defaultScannerNetworkMode,
|
||||
@@ -297,6 +301,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('show_whats_new'),
|
||||
getSetting('animate_icons'),
|
||||
getSetting('protect_scanner_images'),
|
||||
getSetting('default_scanner_network_mode'),
|
||||
@@ -348,6 +353,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
showWhatsNew: showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
|
||||
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
|
||||
@@ -369,7 +375,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -521,6 +527,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (showImageChangelogLinks !== undefined && typeof showImageChangelogLinks === 'boolean') {
|
||||
await setSetting('show_image_changelog_links', showImageChangelogLinks);
|
||||
}
|
||||
if (showWhatsNew !== undefined && typeof showWhatsNew === 'boolean') {
|
||||
await setSetting('show_whats_new', showWhatsNew);
|
||||
}
|
||||
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
|
||||
await setSetting('animate_icons', animateIcons);
|
||||
}
|
||||
@@ -586,6 +595,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
labelFilterModeVal,
|
||||
honorProxyLabelsVal,
|
||||
showImageChangelogLinksVal,
|
||||
showWhatsNewVal,
|
||||
animateIconsVal,
|
||||
protectScannerImagesVal,
|
||||
defaultScannerNetworkModeVal,
|
||||
@@ -633,6 +643,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('honor_proxy_labels'),
|
||||
getSetting('show_image_changelog_links'),
|
||||
getSetting('show_whats_new'),
|
||||
getSetting('animate_icons'),
|
||||
getSetting('protect_scanner_images'),
|
||||
getSetting('default_scanner_network_mode'),
|
||||
@@ -685,6 +696,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
showWhatsNew: showWhatsNewVal ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
|
||||
defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
|
||||
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, basename, isAbsolute } from 'node:path';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { isProtectedPath } from '$lib/server/fs-guard';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
@@ -42,6 +43,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Path must not contain ..' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
return json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (existsSync(path)) {
|
||||
return json({ error: 'Path already exists' }, { status: 409 });
|
||||
}
|
||||
@@ -72,6 +77,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
const path = url.searchParams.get('path') || '/';
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
return json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(path)) {
|
||||
return json({ error: `Path not found: ${path}` }, { status: 404 });
|
||||
@@ -88,6 +97,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
for (const entry of dirEntries) {
|
||||
try {
|
||||
const fullPath = join(path, entry.name);
|
||||
// Hide Dockhand's secrets (db dir, encryption key) from the listing.
|
||||
if (isProtectedPath(fullPath)) continue;
|
||||
const entryStat = statSync(fullPath);
|
||||
|
||||
entries.push({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { isProtectedPath } from '$lib/server/fs-guard';
|
||||
|
||||
/**
|
||||
* GET /api/system/files/content
|
||||
@@ -23,6 +24,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
return json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(path)) {
|
||||
return json({ error: `File not found: ${path}` }, { status: 404 });
|
||||
|
||||
@@ -90,6 +90,14 @@ export const PUT: RequestHandler = async (event) => {
|
||||
// Check if user is currently an admin (via role)
|
||||
const existingUserIsAdmin = await userHasAdminRole(userId);
|
||||
|
||||
// A non-admin may hold `users:edit` (e.g. a help-desk role) but must not be
|
||||
// able to modify an Admin account — resetting an admin's password here would
|
||||
// be a privilege escalation to superadmin. Self-edits and real admins are
|
||||
// unaffected. Mirrors the isAdmin gate on the MFA-disable endpoint.
|
||||
if (existingUserIsAdmin && auth.user && auth.user.id !== userId && !auth.isAdmin) {
|
||||
return json({ error: 'Cannot modify an admin account' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: any = {};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getVolumeArchive, releaseVolumeHelperContainer, statVolumePath } from '
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||
import { attachmentContentDisposition } from '$lib/server/content-disposition';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||
@@ -89,7 +90,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}${extension}"`
|
||||
'Content-Disposition': attachmentContentDisposition(`${filename}${extension}`)
|
||||
};
|
||||
|
||||
// Set content length for compressed data
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getVulnerabilitiesPage, parseVulnerabilitiesQuery } from '$lib/server/vulnerabilities';
|
||||
import { authorizeVulnAccess } from '$lib/server/vuln-access';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* A page of aggregated vulnerability findings for an environment, filtered and
|
||||
* sorted server-side. Query: limit, offset, q, severity, image, container,
|
||||
* stack, sort, dir. Returns { findings, total } where `total` is the filtered
|
||||
* count (for the "X-Y of N" counter and infinite scroll).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const { envIdNum, denied } = await authorizeVulnAccess(cookies, url);
|
||||
if (denied) return json({ error: denied.message }, { status: denied.status });
|
||||
if (!envIdNum) return json({ findings: [], total: 0 });
|
||||
|
||||
const result = await getVulnerabilitiesPage(envIdNum, parseVulnerabilitiesQuery(url));
|
||||
return json(result);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Vulnerability dashboard metadata for an environment: the total finding count,
|
||||
* the severity summary, and the distinct filter-dropdown values (image /
|
||||
* container / stack) across the full set. Lets the header badge and the filter
|
||||
* dropdowns stay complete without the page loading the full findings array.
|
||||
*/
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getVulnerabilitiesMeta } from '$lib/server/vulnerabilities';
|
||||
import { EMPTY_META } from '$lib/server/vulnerabilities-cache';
|
||||
import { authorizeVulnAccess } from '$lib/server/vuln-access';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const { envIdNum, denied } = await authorizeVulnAccess(cookies, url);
|
||||
if (denied) return json({ error: denied.message }, { status: denied.status });
|
||||
if (!envIdNum) return json(EMPTY_META);
|
||||
|
||||
const meta = await getVulnerabilitiesMeta(envIdNum);
|
||||
return json(meta);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getFilteredSortedFindings, getVulnerabilitiesMeta, parseVulnerabilitiesQuery, type Finding } from '$lib/server/vulnerabilities';
|
||||
import { authorizeVulnAccess } from '$lib/server/vuln-access';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { EMPTY_SUMMARY } from '$lib/utils/vulnerability';
|
||||
import { rowsToCSV } from '$lib/server/csv';
|
||||
import { exportResponse, jsonError, slugify } from '$lib/server/export-response';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
function toCSV(findings: Finding[]): string {
|
||||
const headers = [
|
||||
'CVE', 'Severity', 'Package', 'Installed Version', 'Fixed Version',
|
||||
'Image', 'Containers', 'Stacks', 'Scanned', 'Description', 'Link'
|
||||
];
|
||||
const rows = findings.map((f) => [
|
||||
f.cve,
|
||||
f.severity,
|
||||
f.package,
|
||||
f.installedVersion,
|
||||
f.fixedVersion,
|
||||
f.imageName,
|
||||
(f.containers ?? []).map((c) => c.name).join('; '),
|
||||
(f.stacks ?? []).join('; '),
|
||||
f.scannedAt,
|
||||
f.description,
|
||||
f.link
|
||||
]);
|
||||
return rowsToCSV(headers, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the aggregated vulnerability findings as json | csv.
|
||||
* Server-side rendering so the download matches the grid data exactly.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const { envIdNum, denied } = await authorizeVulnAccess(cookies, url);
|
||||
if (denied) return jsonError(denied.message, denied.status);
|
||||
|
||||
try {
|
||||
// Apply the same filter + sort the grid uses so the export matches the
|
||||
// on-screen view (and reuse the cached flatten).
|
||||
const query = parseVulnerabilitiesQuery(url);
|
||||
const findings = envIdNum ? await getFilteredSortedFindings(envIdNum, query) : [];
|
||||
const summary = envIdNum ? (await getVulnerabilitiesMeta(envIdNum)).summary : EMPTY_SUMMARY;
|
||||
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Include the environment name in the filename (e.g. vulnerabilities-local-2026-07-01.json).
|
||||
let envSlug = 'env';
|
||||
if (envIdNum) {
|
||||
const env = await getEnvironment(envIdNum);
|
||||
if (env?.name) envSlug = slugify(env.name, 'env');
|
||||
}
|
||||
|
||||
return exportResponse({
|
||||
format: url.searchParams.get('format') || 'json',
|
||||
filenameBase: `vulnerabilities-${envSlug}-${timestamp}`,
|
||||
findings,
|
||||
jsonBody: { summary, findings },
|
||||
toCSV
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error exporting vulnerabilities:', error);
|
||||
return jsonError('Failed to export vulnerabilities', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { scanImage, scanResultToDbFormat } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, deleteScansForImageScanner } from '$lib/server/db';
|
||||
import { listImages } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
/**
|
||||
* Scan every (tagged) image in the environment for vulnerabilities.
|
||||
* Reuses the per-image scan flow, sequentially, reporting N/total progress.
|
||||
* A single image failing does not abort the batch.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
if (auth.authEnabled && !(await auth.can('images', 'inspect', envId))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (envId && auth.isEnterprise && !(await auth.canAccessEnvironment(envId))) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
return createJobResponse(async (send, isCancelled) => {
|
||||
const images = await listImages(envId);
|
||||
|
||||
// One scan target per image: use the first usable tag, skip dangling/<none>.
|
||||
const targets = images
|
||||
.map((img) => (img.tags || []).find((t) => t && !t.includes('<none>')))
|
||||
.filter((t): t is string => !!t);
|
||||
// Dedupe (a repo can appear once per tag; scan each unique tag once)
|
||||
const uniqueTargets = Array.from(new Set(targets));
|
||||
|
||||
const total = uniqueTargets.length;
|
||||
send('progress', { current: 0, total, imageName: '' });
|
||||
|
||||
let scanned = 0;
|
||||
let failed = 0;
|
||||
let cancelled = false;
|
||||
|
||||
for (let i = 0; i < uniqueTargets.length; i++) {
|
||||
if (isCancelled()) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
const imageName = uniqueTargets[i];
|
||||
// Announce which image is about to be scanned.
|
||||
send('progress', { current: i, total, imageName });
|
||||
try {
|
||||
const results = await scanImage(imageName, envId);
|
||||
for (const result of results) {
|
||||
// Replace (not accumulate): drop this image+scanner's prior rows first,
|
||||
// so the DB holds only the latest scan per image. Nothing is lost on
|
||||
// cancel/failure since we only delete right before re-inserting.
|
||||
await deleteScansForImageScanner(result.imageId, result.scanner, envId ?? null);
|
||||
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
|
||||
}
|
||||
// Per-image findings: one entry per scanner that ran, with severity counts.
|
||||
const scanners = results.map((r) => ({
|
||||
scanner: r.scanner,
|
||||
critical: r.summary.critical,
|
||||
high: r.summary.high,
|
||||
medium: r.summary.medium,
|
||||
low: r.summary.low,
|
||||
durationMs: r.scanDuration
|
||||
}));
|
||||
send('scanned', { current: i + 1, total, imageName, scanners, ok: true });
|
||||
scanned++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[scan-all] Failed to scan ${imageName}: ${msg}`);
|
||||
send('scanned', { current: i + 1, total, imageName, scanners: [], ok: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
send('progress', { current: total, total, imageName: '' });
|
||||
send('result', { scanned, failed, total, cancelled });
|
||||
}, request);
|
||||
};
|
||||
Reference in new issue
Block a user