This commit is contained in:
jarek committed 2026-06-27 15:15:54 +02:00
1 parent ce12d7ae9f
commit 685132ff89
63 files changed
+1324 -281

No files matched your search

+1 -2
View File
@@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
dummyHash = await hashPassword('dh_init_seed');
}
return dummyHash;
}
+41
View File
@@ -36,6 +36,7 @@
*/
import type { Cookies } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
@@ -105,6 +106,27 @@ export interface AuthorizationContext {
* - (User is admin OR has audit_logs view permission)
*/
canViewAuditLog: () => Promise<boolean>;
/**
* Gate handler: require a permission. Returns a 403 Response on denial,
* null when allowed. Use at the top of a handler with `??`-chaining for
* one-line auth/env checks:
*
* const denied = (await auth.requirePermission('schedules', 'edit'))
* ?? (await auth.requireEnvAccess(envId));
* if (denied) return denied;
*
* No-op when authEnabled is false.
*/
requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise<Response | null>;
/**
* Gate handler: require access to the given environment. Returns a 403
* Response on denial, null when allowed. Pass null/undefined to no-op
* (e.g. system-scoped operations that don't target an env). No-op when
* authEnabled is false.
*/
requireEnvAccess: (environmentId: number | null | undefined) => Promise<Response | null>;
}
/**
@@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Check for audit_logs permission
return checkPermission(user, 'audit_logs' as keyof Permissions, 'view');
},
async requirePermission(
resource: keyof Permissions,
action: string,
environmentId?: number
): Promise<Response | null> {
if (!authEnabled) return null;
return (await ctx.can(resource, action, environmentId))
? null
: json({ error: 'Permission denied' }, { status: 403 });
},
async requireEnvAccess(environmentId: number | null | undefined): Promise<Response | null> {
if (!authEnabled) return null;
if (environmentId === null || environmentId === undefined) return null;
return (await ctx.canAccessEnvironment(environmentId))
? null
: json({ error: 'Access denied to this environment' }, { status: 403 });
}
};
+17 -4
View File
@@ -389,8 +389,10 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: string;
editorFont: string;
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: string;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
@@ -398,7 +400,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
getUserSetting(userId, 'animate_icons'),
getUserSetting(userId, 'colored_action_buttons'),
getUserSetting(userId, 'action_icon_size')
]);
return {
lightTheme: lightTheme || 'default',
@@ -409,13 +413,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
animateIcons: animateIcons === 'false' ? false : true,
// Default OFF — only true when explicitly stored
coloredActionButtons: coloredActionButtons === 'true',
actionIconSize: actionIconSize || 'normal'
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -442,6 +449,12 @@ export async function setUserThemePreferences(
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
if (prefs.coloredActionButtons !== undefined) {
updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
}
if (prefs.actionIconSize !== undefined) {
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
}
await Promise.all(updates);
}
+18 -3
View File
@@ -6,14 +6,14 @@
*/
import { homedir } from 'node:os';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import * as http from 'node:http';
import * as https from 'node:https';
import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord, getSetting } from './db';
import { getSetting } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
@@ -4501,6 +4501,8 @@ export async function runContainer(options: {
extraHosts?: string[];
name?: string;
envId?: number | null;
networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<{ stdout: string; stderr: string }> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-temp-${Date.now()}`;
@@ -4524,6 +4526,14 @@ export async function runContainer(options: {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
if (options.networkMode) {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
if (options.dns && options.dns.length > 0) {
containerConfig.HostConfig.Dns = options.dns;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
@@ -4591,6 +4601,7 @@ export async function runContainerWithStreaming(options: {
onStderr?: (data: string) => void;
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<string> {
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
@@ -4625,6 +4636,10 @@ export async function runContainerWithStreaming(options: {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
if (options.dns && options.dns.length > 0) {
containerConfig.HostConfig.Dns = options.dns;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
+29 -3
View File
@@ -1,13 +1,40 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything
// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char.
function isHeaderSafe(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xff) return false;
}
return true;
}
// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a
// base64-encoded `Bearer <token>` / `Basic <creds>` string. We try base64 first
// only when the input looks plausibly base64; if decoding produces non-ASCII
// garbage we fall back to treating the value as a raw token.
export function resolveQueryAuth(queryAuth: string): string {
if (queryAuth.startsWith('tk_')) {
return `Bearer ${queryAuth}`;
}
const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth);
if (looksBase64) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) {
return decoded;
}
}
return `Bearer ${queryAuth}`;
}
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer <token>")
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
@@ -53,8 +80,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
authHeader = resolveQueryAuth(queryAuth);
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
+53 -7
View File
@@ -28,6 +28,7 @@ import {
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
import { resolveScannerOverrides } from '$lib/utils/scanner-overrides';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -164,24 +165,47 @@ export interface ScanProgress {
output?: string; // Line of scanner output
}
function parseDnsSetting(raw: string | null | undefined): string[] {
if (!raw) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
// Tolerate either JSON-array storage (preferred) or comma-separated string
// (defensive — UI submits JSON, but legacy / hand-edited values may exist).
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
} catch {
return [];
}
}
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
}
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
export async function getGlobalScannerDefaults(): Promise<{
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
networkMode: string;
dns: string[];
}> {
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([
getSetting('default_grype_args'),
getSetting('default_trivy_args'),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
]);
return {
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE,
networkMode: networkMode ?? '',
dns: parseDnsSetting(dns)
};
}
@@ -747,6 +771,19 @@ async function runScannerContainerCore(
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
}
// Apply user-configured overrides on top of auto-detection (#1219).
// Empty user settings → resolver returns the auto-detected values unchanged.
const userScannerSettings = await getGlobalScannerDefaults();
const overrides = resolveScannerOverrides(
{ networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts },
{ networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns }
);
if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) {
console.log(
`[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}`
);
}
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
@@ -755,10 +792,11 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
extraHosts: scannerExtraHosts,
extraHosts: overrides.extraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
networkMode: scannerNetworkMode,
networkMode: overrides.networkMode,
dns: overrides.dns,
timeout: 600_000, // 10 minutes
onStderr: (data) => {
// Stream stderr lines for real-time progress output
@@ -1076,14 +1114,22 @@ async function getScannerVersion(
);
if (!hasImage) return null;
// Create temporary container to get version
// Create temporary container to get version. Apply user overrides so
// version probe works in environments where default networking fails
// (#1219 — host needs --network host for scanner images to resolve DNS).
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
const overrides = resolveScannerOverrides(
{},
{ networkMode: defaults.networkMode, dns: defaults.dns }
);
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
const { stdout, stderr } = await runContainer({
image: scannerImage,
cmd: versionCmd,
name: `dockhand-${scannerType}-version-${Date.now()}`,
envId
envId,
networkMode: overrides.networkMode,
dns: overrides.dns
});
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
@@ -37,7 +37,7 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
@@ -343,6 +343,19 @@ export async function runContainerUpdate(
const inspectData = await inspectContainer(container.id, envId) as any;
const imageNameFromConfig = inspectData.Config?.Image;
// Podman pod-infra containers may have an empty Config.Image — silently
// skip them before the "Could not determine image name" failure (#1221).
if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) {
log(`Skipping Podman pod-infra container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Podman pod-infra container' }
});
return;
}
if (!imageNameFromConfig) {
log(`Could not determine image name from container config`);
await updateScheduleExecution(execution.id, {
@@ -30,7 +30,7 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
@@ -108,9 +108,12 @@ export async function runEnvUpdateCheckJob(
// Get all containers in this environment, excluding ones hidden via
// dockhand.hidden=true (consistent with manual check-updates, #1083).
const allContainers = await listContainers(true, environmentId);
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
// Skip hidden + Podman pod-infra (#1083, #1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
);
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0;
@@ -127,6 +127,22 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
return null;
}
/**
* Podman creates a hidden "pod-infra" container for every pod it runs (#1221).
* The image (`localhost/podman-pause`) is generated locally and never published
* to any registry, so update checks always fail noisily. The label is the
* authoritative signal — but Podman may set Config.Image empty for these
* containers, so the name fallback catches that too.
*/
export function isPodmanInfraContainer(
imageName: string | undefined,
labels: Record<string, string> | undefined
): boolean {
if (labels?.['io.podman.annotations.infra'] === 'true') return true;
if (imageName && /podman-pause/i.test(imageName)) return true;
return false;
}
/**
* Combine multiple scan summaries by taking the maximum of each severity level.
*/
+1 -14
View File
@@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
import { validateApiToken } from './api-tokens';
import { isEnterprise } from './license';
import { userHasAdminRole, userCanAccessEnvironment } from './db';
import { parseCookieHeader } from '$lib/utils/cookie-parse';
export interface WsUpgradeAuth {
userId: number;
@@ -10,20 +11,6 @@ export interface WsUpgradeAuth {
authDisabled: boolean;
}
function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
type LowercasedHeaders = Record<string, string | string[] | undefined>;
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {