This commit is contained in:
jarek committed 2026-03-21 14:29:30 +01:00
1 parent aefa5e7925
commit b55e1e5aad
107 files changed
+2193 -443

No files matched your search

+19 -4
View File
@@ -819,6 +819,13 @@ export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
return eventTypes.filter(id => environmentEventIds.has(id));
}
export interface NotificationSettingData {
id: number;
type: 'smtp' | 'apprise';
@@ -982,7 +989,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
return rows.map((row: any) => ({
...row,
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
})) as EnvironmentNotificationData[];
}
@@ -1009,7 +1016,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
if (!rows[0]) return null;
return {
...rows[0],
eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
} as EnvironmentNotificationData;
}
@@ -1019,7 +1026,7 @@ export async function createEnvironmentNotification(data: {
enabled?: boolean;
eventTypes?: NotificationEventType[];
}): Promise<EnvironmentNotificationData> {
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
await db.insert(environmentNotifications).values({
environmentId: data.environmentId,
notificationId: data.notificationId,
@@ -1087,7 +1094,7 @@ export async function getEnabledEnvironmentNotifications(
return rows
.map(row => ({
...row,
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
}))
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
@@ -2019,6 +2026,14 @@ export async function updateGitRepository(id: number, data: Partial<GitRepositor
return getGitRepository(id);
}
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
return db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
}
export async function deleteGitRepository(id: number): Promise<boolean> {
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
+41 -29
View File
@@ -1,4 +1,4 @@
import { setGlobalDispatcher, Agent } from 'undici';
import { setGlobalDispatcher, Agent, EnvHttpProxyAgent } from 'undici';
import dns from 'node:dns';
import net from 'node:net';
@@ -60,32 +60,44 @@ function lookupWithCache(hostname: string): Promise<{ address: string; family: n
return promise;
}
setGlobalDispatcher(
new Agent({
connect: {
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
timeout: 30_000,
lookup(hostname: string, opts: any, cb: any) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
// IP addresses / localhost → no DNS needed
if (net.isIP(hostname) || hostname === 'localhost') {
return origLookup(hostname, opts, cb);
}
lookupWithCache(hostname)
.then(({ address, family }) => {
if (opts.all) {
cb(null, [{ address, family }]);
} else {
cb(null, address, family);
}
})
.catch((err) => cb(err));
}
// Shared connect options for DNS lookup
const connectOptions = {
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
timeout: 30_000,
lookup(hostname: string, opts: any, cb: any) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
})
);
// IP addresses / localhost → no DNS needed
if (net.isIP(hostname) || hostname === 'localhost') {
return origLookup(hostname, opts, cb);
}
lookupWithCache(hostname)
.then(({ address, family }) => {
if (opts.all) {
cb(null, [{ address, family }]);
} else {
cb(null, address, family);
}
})
.catch((err) => cb(err));
}
};
// Use EnvHttpProxyAgent when HTTP(S)_PROXY env vars are set, otherwise plain Agent.
// Node.js fetch/undici does NOT respect proxy env vars by default — EnvHttpProxyAgent
// reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY automatically.
const hasProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY ||
process.env.http_proxy || process.env.https_proxy;
if (hasProxy) {
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
process.env.HTTP_PROXY || process.env.http_proxy;
console.log(`[DNS] HTTP proxy detected (${proxyUrl}), using EnvHttpProxyAgent`);
setGlobalDispatcher(new EnvHttpProxyAgent({ connect: connectOptions }));
} else {
setGlobalDispatcher(new Agent({ connect: connectOptions }));
}
+20
View File
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
/**
* Checks if a value contains path traversal or injection characters.
* Rejects: .., /, \, null bytes, % (catches double-encoding).
*/
function containsPathTraversal(value: string): boolean {
return value.includes('..') || value.includes('/') || value.includes('\\') || value.includes('\0') || value.includes('%');
}
/**
* Validates a Docker resource ID/name from URL params.
* Returns a 400 Response if invalid, null if valid.
*/
export function validateDockerIdParam(id: string, resourceType = 'resource'): Response | null {
if (!id || containsPathTraversal(id)) {
return json({ error: `Invalid ${resourceType} ID` }, { status: 400 });
}
return null;
}
+162 -20
View File
@@ -819,6 +819,11 @@ export async function dockerFetch(
options: DockerFetchOptions = {},
envId?: number | null
): Promise<Response> {
// Guard against path traversal — legitimate Docker API paths never contain '..'
if (path.includes('..')) {
throw new Error('Invalid Docker API path');
}
const startTime = Date.now();
const config = await getDockerConfig(envId);
const { streaming, ...fetchOptions } = options;
@@ -977,7 +982,7 @@ export async function dockerFetch(
/**
* Make a JSON request to Docker API
*/
async function dockerJsonRequest<T>(
export async function dockerJsonRequest<T>(
path: string,
options: RequestInit = {},
envId?: number | null
@@ -1789,6 +1794,40 @@ export async function recreateContainerFromInspect(
HostConfig: hostConfig
};
// 4a. Update image-embedded labels to match the new image.
// Docker's create API uses exactly the labels you pass, ignoring the new image's
// embedded labels. We inspect both old and new images to distinguish image-origin
// labels from user-set labels, then merge accordingly.
try {
const [oldImageInspect, newImageInspect] = await Promise.all([
inspectImage(config.Image, envId),
inspectImage(newImage, envId)
]);
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
const containerLabels: Record<string, string> = createConfig.Labels || {};
const mergedLabels: Record<string, string> = {};
// Keep user-set labels (not present in old image)
for (const [k, v] of Object.entries(containerLabels)) {
if (!(k in oldImageLabels)) {
mergedLabels[k] = v;
}
}
// Add all new image labels (overrides old image labels)
for (const [k, v] of Object.entries(newImageLabels)) {
mergedLabels[k] = v;
}
createConfig.Labels = mergedLabels;
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
} catch (e) {
log?.(`Warning: could not update image labels: ${e}`);
// Fall through with old labels — non-fatal
}
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
// Docker returns -1, Podman returns 0 when unset.
const swappiness = createConfig.HostConfig?.MemorySwappiness;
@@ -2269,6 +2308,14 @@ export function extractContainerOptions(inspectData: any): CreateContainerOption
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
const oldContainerInfo = await inspectContainer(id, envId);
const wasRunning = oldContainerInfo.State.Running;
const name = oldContainerInfo.Name?.replace(/^\//, '') || '';
const oldContainerId = oldContainerInfo.Id;
const networks: Record<string, any> = oldContainerInfo.NetworkSettings?.Networks || {};
const hostConfig = oldContainerInfo.HostConfig || {};
const networkMode = hostConfig.NetworkMode || '';
const isSharedNetwork = networkMode.startsWith('container:') ||
networkMode.startsWith('service:') ||
networkMode === 'host' || networkMode === 'none';
// Extract ALL existing container options
const existingOptions = extractContainerOptions(oldContainerInfo);
@@ -2285,18 +2332,81 @@ export async function updateContainer(id: string, options: Partial<CreateContain
}
};
// 1. Stop old container
if (wasRunning) {
await stopContainer(id, envId);
}
await removeContainer(id, true, envId);
// 2. Rename old container to free the name (instead of removing — allows rollback)
await dockerFetch(
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`,
{ method: 'POST' },
envId
).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); });
const newContainer = await createContainer(mergedOptions, envId);
if (startAfterUpdate || wasRunning) {
await newContainer.start();
// 3. Disconnect networks from old container to free static IPs
if (!isSharedNetwork) {
for (const [, netConfig] of Object.entries(networks)) {
const nc = netConfig as any;
if (nc.NetworkID) {
await disconnectContainerFromNetwork(nc.NetworkID, oldContainerId, true, envId).catch(() => {});
}
}
}
// Rollback helper: restore old container on failure
const rollback = async () => {
try {
// Rename back
await dockerFetch(
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`,
{ method: 'POST' },
envId
).then(r => drainResponse(r)).catch(() => {});
// Reconnect networks
if (!isSharedNetwork) {
for (const [, netConfig] of Object.entries(networks)) {
const nc = netConfig as any;
if (nc.NetworkID) {
await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {});
}
}
}
// Restart if it was running
if (wasRunning) {
await startContainer(oldContainerId, envId).catch(() => {});
}
} catch {
// Rollback is best-effort
}
};
// 4. Create new container
let newContainer;
try {
newContainer = await createContainer(mergedOptions, envId);
} catch (createError) {
await rollback();
throw createError;
}
// 5. Start if needed
if (startAfterUpdate || wasRunning) {
try {
await newContainer.start();
} catch (startError) {
// Remove failed new container and restore old one
await removeContainer(newContainer.id, true, envId).catch(() => {});
await rollback();
throw startError;
}
}
// 6. Remove old container (success path only)
await removeContainer(oldContainerId, true, envId).catch(() => {});
return newContainer;
}
@@ -3134,6 +3244,22 @@ export async function getDockerVersion(envId?: number | null) {
return dockerJsonRequest('/version', {}, envId);
}
/**
* Get the Docker daemon's API version string for a given environment.
* Used to pin DOCKER_API_VERSION when spawning sidecar containers (scanner, updater)
* whose bundled Docker CLI may be newer than the host daemon supports.
* Returns null if the version cannot be determined.
* Requires an envId — local Docker (no environment) must query /version directly.
*/
export async function getNegotiatedApiVersion(envId: number): Promise<string | null> {
try {
const versionInfo = await getDockerVersion(envId);
return versionInfo?.ApiVersion || null;
} catch {
return null;
}
}
/**
* Lightweight ping check for Docker daemon availability.
* Uses /_ping endpoint which returns "OK" as plain text with minimal overhead.
@@ -3317,7 +3443,29 @@ export interface NetworkInfo {
}
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
const networks = await dockerJsonRequest<any[]>('/networks', {}, envId);
const [networks, containers] = await Promise.all([
dockerJsonRequest<any[]>('/networks', {}, envId),
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId)
]);
// Build map of networkId -> container info from container network settings
const networkContainers = new Map<string, Record<string, { name: string; ipv4Address: string }>>();
for (const container of containers) {
const nets = container.NetworkSettings?.Networks;
if (!nets) continue;
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
for (const [, netInfo] of Object.entries(nets as Record<string, any>)) {
const netId = netInfo.NetworkID;
if (!netId) continue;
if (!networkContainers.has(netId)) {
networkContainers.set(netId, {});
}
networkContainers.get(netId)![container.Id] = {
name: containerName,
ipv4Address: netInfo.IPAddress || ''
};
}
}
return networks.map((network: any) => ({
id: network.Id,
@@ -3335,13 +3483,7 @@ export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]
auxAddress: cfg.AuxAddress || cfg.auxAddress
}))
},
containers: Object.entries(network.Containers || {}).reduce((acc: any, [id, data]: [string, any]) => {
acc[id] = {
name: data.Name,
ipv4Address: data.IPv4Address
};
return acc;
}, {})
containers: networkContainers.get(network.Id) || {}
}));
}
@@ -3970,8 +4112,9 @@ export async function runContainerWithStreaming(options: {
// Container has exited. Now fetch stdout reliably (no race condition).
const stdout = await fetchContainerStdout(containerId, config, options.envId);
// If stdout is empty and exit code is non-zero, fetch stderr for debugging
// If stdout is empty and exit code is non-zero, fetch stderr and throw
if (stdout.length === 0 && exitCode !== 0) {
let stderrText = '';
try {
const stderrResponse = await dockerFetch(
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
@@ -3980,13 +4123,12 @@ export async function runContainerWithStreaming(options: {
);
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
const stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
if (stderrText) {
console.error(`[runContainerWithStreaming] Container stderr: ${stderrText.substring(0, 1000)}`);
}
stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
} catch {
// Ignore stderr fetch errors
}
const detail = stderrText ? stderrText.substring(0, 1000) : 'no stderr output';
throw new Error(`Container exited with code ${exitCode}: ${detail}`);
}
return stdout;
@@ -4761,7 +4903,7 @@ function getVolumeCacheKey(volumeName: string, envId?: number | null): string {
/**
* Ensure the volume helper image (busybox) is available, pulling if necessary
*/
async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
export async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
// Check if image exists
const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId);
+36
View File
@@ -0,0 +1,36 @@
import { resolve } from 'path';
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
function getIconsDir(): string {
const dataDir = process.env.DATA_DIR || './data';
const dir = resolve(dataDir, 'icons');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
}
export function saveEnvironmentIcon(envId: number, base64Data: string): void {
const dir = getIconsDir();
// Strip data URL prefix if present
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64, 'base64');
writeFileSync(resolve(dir, `env-${envId}.webp`), buffer);
}
export function deleteEnvironmentIcon(envId: number): void {
const dir = getIconsDir();
const path = resolve(dir, `env-${envId}.webp`);
if (existsSync(path)) {
unlinkSync(path);
}
}
export function getEnvironmentIconBuffer(envId: number): Buffer | null {
const dir = getIconsDir();
const path = resolve(dir, `env-${envId}.webp`);
if (!existsSync(path)) {
return null;
}
return readFileSync(path);
}
+10 -6
View File
@@ -88,7 +88,7 @@ let _nssWrapperNeeded = false;
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
if (_nssWrapperChecked) {
if (_nssWrapperNeeded) {
env.LD_PRELOAD = NSS_WRAPPER_LIB;
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
env.NSS_WRAPPER_GROUP = TMP_GROUP;
}
@@ -136,7 +136,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
}
_nssWrapperNeeded = true;
env.LD_PRELOAD = NSS_WRAPPER_LIB;
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
env.NSS_WRAPPER_GROUP = TMP_GROUP;
console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
@@ -733,7 +733,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Blobless clones fetch all commits (for git diff) but download blobs on-demand
const previousCommit = await getPreviousCommit(repoPath, env);
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
if (existsSync(repoPath)) {
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
rmSync(repoPath, { recursive: true, force: true });
@@ -762,7 +763,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
const commitChanged = previousCommit !== newCommit;
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);
// Check if any files in the compose file's directory have changed
@@ -1101,7 +1103,8 @@ export async function deployGitStackWithProgress(
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
const previousCommit = await getPreviousCommit(repoPath, env);
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
@@ -1130,7 +1133,8 @@ export async function deployGitStackWithProgress(
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
const commitChanged = previousCommit !== newCommit;
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
// Check if any files in the compose file's directory have changed
// (for consistency with syncGitStack, though this function always deploys)
+12 -38
View File
@@ -42,47 +42,21 @@ export interface NotificationResult {
error?: string;
}
// SMTP transporter cache — reuses connections instead of creating a new TLS pool per notification.
const transporterCache = new Map<string, { transporter: ReturnType<typeof nodemailer.createTransport>; lastUsed: number }>();
function getOrCreateTransporter(config: SmtpConfig): ReturnType<typeof nodemailer.createTransport> {
const key = `${config.host}:${config.port}:${config.secure}:${config.username || ''}`;
const cached = transporterCache.get(key);
if (cached) {
cached.lastUsed = Date.now();
return cached.transporter;
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
transporterCache.set(key, { transporter, lastUsed: Date.now() });
return transporter;
}
// Clean up idle transporters every 10 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of transporterCache) {
if (now - entry.lastUsed > 10 * 60 * 1000) {
entry.transporter.close();
transporterCache.delete(key);
}
}
}, 10 * 60 * 1000);
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = getOrCreateTransporter(config);
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
+65 -26
View File
@@ -11,7 +11,8 @@ import {
runContainer,
runContainerWithStreaming,
inspectImage,
checkImageUpdateAvailable
checkImageUpdateAvailable,
getNegotiatedApiVersion
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
@@ -108,6 +109,10 @@ const inProgressScans = new Map<string, Promise<string>>();
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
// Pinned scanner images — avoid :latest after the March 2026 Trivy supply chain attack
export const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.110.0';
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.69.3';
export interface VulnerabilitySeverity {
critical: number;
high: number;
@@ -150,28 +155,36 @@ export interface ScanProgress {
output?: string; // Line of scanner output
}
// Get global default scanner CLI args from general settings (or fallback to hardcoded defaults)
// 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;
}> {
const [grypeArgs, trivyArgs] = await Promise.all([
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
getSetting('default_grype_args'),
getSetting('default_trivy_args')
getSetting('default_trivy_args'),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
]);
return {
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
};
}
// Get scanner settings (scanner type is per-environment, CLI args are global)
// Get scanner settings (scanner type is per-environment, CLI args and images are global)
export async function getScannerSettings(envId?: number): Promise<{
scanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}> {
// CLI args are always global - no need for per-env settings
// CLI args and images are always global - no need for per-env settings
const [globalDefaults, scanner] = await Promise.all([
getGlobalScannerDefaults(),
getEnvSetting('vulnerability_scanner', envId)
@@ -180,25 +193,31 @@ export async function getScannerSettings(envId?: number): Promise<{
return {
scanner: scanner || 'none',
grypeArgs: globalDefaults.grypeArgs,
trivyArgs: globalDefaults.trivyArgs
trivyArgs: globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
};
}
// Optimized version that accepts pre-cached global defaults (avoids redundant DB calls)
// Only looks up scanner type per-environment since CLI args are global
// Only looks up scanner type per-environment since CLI args and images are global
export async function getScannerSettingsWithDefaults(
envId: number | undefined,
globalDefaults: { grypeArgs: string; trivyArgs: string }
globalDefaults: { grypeArgs: string; trivyArgs: string; grypeImage: string; trivyImage: string }
): Promise<{
scanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}> {
const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none';
return {
scanner,
grypeArgs: globalDefaults.grypeArgs,
trivyArgs: globalDefaults.trivyArgs
trivyArgs: globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
};
}
@@ -668,6 +687,28 @@ async function runScannerContainerCore(
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
: [`TRIVY_CACHE_DIR=${dbPath}`];
// Pin Docker API version so scanner's bundled Docker client doesn't request
// a version newer than the host daemon supports (e.g. grype ships client v1.53
// but the host may only support up to v1.43).
const apiVersion = await getNegotiatedApiVersion(envId);
if (apiVersion) {
envVars.push(`DOCKER_API_VERSION=${apiVersion}`);
console.log(`[Scanner] Using negotiated Docker API version: ${apiVersion}`);
}
// Propagate proxy env vars so scanners can reach the internet in proxied environments
const proxyVarNames = [
'HTTP_PROXY', 'http_proxy',
'HTTPS_PROXY', 'https_proxy',
'NO_PROXY', 'no_proxy',
'ALL_PROXY', 'all_proxy',
];
for (const name of proxyVarNames) {
if (process.env[name]) {
envVars.push(`${name}=${process.env[name]}`);
}
}
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
if (scannerDockerHost) {
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
@@ -697,11 +738,7 @@ async function runScannerContainerCore(
});
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
if (output.length === 0) {
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
console.error(`[Scanner] This may indicate the scanner couldn't access Docker`);
console.error(`[Scanner] Docker access: ${scannerDockerHost ? `TCP ${scannerDockerHost}` : `socket ${hostSocketPath}`}`);
} else if (output.length < 100) {
if (output.length < 100) {
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
}
@@ -715,8 +752,7 @@ export async function scanWithGrype(
onProgress?: (progress: ScanProgress) => void
): Promise<ScanResult> {
const startTime = Date.now();
const scannerImage = 'anchore/grype:latest';
const { grypeArgs } = await getScannerSettings(envId);
const { grypeArgs, grypeImage: scannerImage } = await getScannerSettings(envId);
onProgress?.({
stage: 'checking',
@@ -813,8 +849,7 @@ export async function scanWithTrivy(
onProgress?: (progress: ScanProgress) => void
): Promise<ScanResult> {
const startTime = Date.now();
const scannerImage = 'aquasec/trivy:latest';
const { trivyArgs } = await getScannerSettings(envId);
const { trivyArgs, trivyImage: scannerImage } = await getScannerSettings(envId);
onProgress?.({
stage: 'checking',
@@ -978,9 +1013,10 @@ export async function checkScannerAvailability(envId?: number): Promise<{
grype: boolean;
trivy: boolean;
}> {
const defaults = await getGlobalScannerDefaults();
const [grypeAvailable, trivyAvailable] = await Promise.all([
isScannerImageAvailable('anchore/grype', envId),
isScannerImageAvailable('aquasec/trivy', envId)
isScannerImageAvailable(defaults.grypeImage, envId),
isScannerImageAvailable(defaults.trivyImage, envId)
]);
return {
@@ -995,12 +1031,14 @@ async function getScannerVersion(
envId?: number
): Promise<string | null> {
try {
const scannerImage = scannerType === 'grype' ? 'anchore/grype:latest' : 'aquasec/trivy:latest';
const defaults = await getGlobalScannerDefaults();
const scannerImage = scannerType === 'grype' ? defaults.grypeImage : defaults.trivyImage;
// Check if image exists first
// Check if image exists first — match by repo name prefix, not exact tag
const imageRepo = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
const images = await listImages(envId);
const hasImage = images.some((img) =>
img.tags?.some((tag: string) => tag === scannerImage)
img.tags?.some((tag: string) => tag.startsWith(imageRepo + ':'))
);
if (!hasImage) return null;
@@ -1062,10 +1100,11 @@ export async function checkScannerUpdates(envId?: number): Promise<{
};
try {
const defaults = await getGlobalScannerDefaults();
const images = await listImages(envId);
// Check both scanners
for (const [scanner, imageName] of [['grype', 'anchore/grype:latest'], ['trivy', 'aquasec/trivy:latest']] as const) {
for (const [scanner, imageName] of [['grype', defaults.grypeImage], ['trivy', defaults.trivyImage]] as const) {
try {
// Find local image
const localImage = images.find((img) =>