// Vulnerability Scanner Service // Supports Grype and Trivy scanners // Uses long-running containers for faster subsequent scans (cached vulnerability databases) import { listImages, pullImage, createVolume, listVolumes, removeVolume, runContainer, runContainerWithStreaming, inspectImage, checkImageUpdateAvailable, getNegotiatedApiVersion } from './docker'; import { getEnvironment, getEnvSetting, getSetting } from './db'; import { sendEventNotification } from './notifications'; import { detectRemoteSocketPath } from './scanner-socket-detect'; import { truncateForLog, classifyUnparseableOutput, pickScanDisplayName } from './scanner-output-core'; import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnExtraHosts, getOwnNetworkMode, getOwnAllNetworks } from './host-path'; import { resolve } from 'node:path'; import { mkdir, chown, rm } from 'node:fs/promises'; import { resolveScannerOverrides } from '$lib/utils/scanner-overrides'; import { PROTECTED_VOLUME_NAMES } from './volume-prune-core'; export type ScannerType = 'none' | 'grype' | 'trivy' | 'both'; /** * Send vulnerability notifications based on scan results. * Sends the most severe notification type based on found vulnerabilities. */ export async function sendVulnerabilityNotifications( imageName: string, summary: VulnerabilitySeverity, envId?: number ): Promise { const totalVulns = summary.critical + summary.high + summary.medium + summary.low + summary.negligible + summary.unknown; if (totalVulns === 0) { // No vulnerabilities found, no notification needed return; } // Send notifications based on severity (most severe first) // Note: Users can subscribe to specific severity levels, so we send all applicable if (summary.critical > 0) { await sendEventNotification('vulnerability_critical', { title: 'Critical vulnerabilities found', message: `Image "${imageName}" has ${summary.critical} critical vulnerabilities (${totalVulns} total)`, type: 'error' }, envId); } if (summary.high > 0) { await sendEventNotification('vulnerability_high', { title: 'High severity vulnerabilities found', message: `Image "${imageName}" has ${summary.high} high severity vulnerabilities (${totalVulns} total)`, type: 'warning' }, envId); } // Only send 'any' notification if there are medium/low/negligible but no critical/high // This prevents notification spam for users who only want to know about lesser severities if (summary.critical === 0 && summary.high === 0 && totalVulns > 0) { await sendEventNotification('vulnerability_any', { title: 'Vulnerabilities found', message: `Image "${imageName}" has ${totalVulns} vulnerabilities (medium: ${summary.medium}, low: ${summary.low})`, type: 'info' }, envId); } } // Volume names for scanner database caching // Names are the single source of truth in volume-prune-core (imported at top) so // the prune logic and the scanner agree on exactly which volumes to protect. const [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME] = PROTECTED_VOLUME_NAMES; // Scanner cache directory for rootless Docker (bind mounts instead of volumes) const DATA_DIR = process.env.DATA_DIR || '/app/data'; const SCANNER_CACHE_DIR = 'scanner-cache'; // Per-type serial lock to prevent concurrent scans of the same scanner type. // This avoids DB lock conflicts AND ensures the second scan uses warm cache // instead of re-downloading the entire vulnerability database (~100MB). const scannerLocks = new Map>(); // key: "grype" or "trivy" async function withScannerLock(scannerType: string, fn: () => Promise): Promise { const existing = scannerLocks.get(scannerType); if (existing) { console.log(`[Scanner] Waiting for previous ${scannerType} scan to complete...`); await existing.catch(() => {}); // Don't fail if previous scan errored } let resolve: () => void; const lockPromise = new Promise(r => { resolve = r; }); scannerLocks.set(scannerType, lockPromise); try { return await fn(); } finally { resolve!(); if (scannerLocks.get(scannerType) === lockPromise) { scannerLocks.delete(scannerType); } } } // Track in-progress scans per image to prevent duplicate scans // Key: "{scannerType}:{imageName}", Value: Promise that resolves to the scan result const inProgressScans = new Map>(); /** Scanner queue depth — for the metrics endpoint. `inProgress` = distinct * image scans running/deduped; `locked` = scanner types holding the serial lock. */ export function getScannerStats(): { inProgress: number; locked: number } { return { inProgress: inProgressScans.size, locked: scannerLocks.size }; } // Default CLI arguments for scanners (image name is substituted for {image}) 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.115.0'; export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.71.2'; export interface VulnerabilitySeverity { critical: number; high: number; medium: number; low: number; negligible: number; unknown: number; } export interface Vulnerability { id: string; severity: string; package: string; version: string; fixedVersion?: string; description?: string; link?: string; scanner: 'grype' | 'trivy'; } export interface ScanResult { imageId: string; imageName: string; scanner: 'grype' | 'trivy'; scannedAt: string; vulnerabilities: Vulnerability[]; summary: VulnerabilitySeverity; scanDuration: number; error?: string; } /** * Convert a ScanResult into the row shape expected by saveVulnerabilityScan(). * Note: `vulnerabilities` is passed as the raw array — saveVulnerabilityScan() * does the JSON.stringify. (The previous inline copy of this helper stringified * here too, which double-encoded the column; readers must tolerate both.) */ export function scanResultToDbFormat(result: ScanResult, envId?: number | null) { 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: result.vulnerabilities, error: result.error ?? null }; } export interface ScanProgress { stage: 'checking' | 'pulling-scanner' | 'scanning' | 'parsing' | 'complete' | 'error'; message: string; scanner?: 'grype' | 'trivy'; progress?: number; result?: ScanResult; results?: ScanResult[]; // All scanner results when using 'both' error?: string; 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, networkMode, dns] = await Promise.all([ getSetting('default_grype_args'), getSetting('default_trivy_args'), getSetting('default_grype_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, networkMode: networkMode ?? '', dns: parseDnsSetting(dns) }; } // 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 and images are always global - no need for per-env settings const [globalDefaults, scanner] = await Promise.all([ getGlobalScannerDefaults(), getEnvSetting('vulnerability_scanner', envId) ]); return { scanner: scanner || 'none', grypeArgs: globalDefaults.grypeArgs, 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 and images are global export async function getScannerSettingsWithDefaults( envId: number | undefined, 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, grypeImage: globalDefaults.grypeImage, trivyImage: globalDefaults.trivyImage }; } // Parse CLI args string into array, substituting {image} placeholder function parseCliArgs(argsString: string, imageName: string): string[] { // Replace {image} placeholder with actual image name const withImage = argsString.replace(/\{image\}/g, imageName); // Split by whitespace, respecting quoted strings const args: string[] = []; let current = ''; let inQuote = false; let quoteChar = ''; for (const char of withImage) { if ((char === '"' || char === "'") && !inQuote) { inQuote = true; quoteChar = char; } else if (char === quoteChar && inQuote) { inQuote = false; quoteChar = ''; } else if (char === ' ' && !inQuote) { if (current) { args.push(current); current = ''; } } else { current += char; } } if (current) { args.push(current); } return args; } // Check if a scanner image is available locally async function isScannerImageAvailable(scannerImage: string, envId?: number): Promise { try { const images = await listImages(envId); const imageWithTag = scannerImage.includes(':') ? scannerImage : `${scannerImage}:latest`; return images.some((img) => img.tags?.some((tag: string) => tag === imageWithTag) ); } catch { return false; } } // Pull scanner image if not available async function ensureScannerImage( scannerImage: string, envId?: number, onProgress?: (progress: ScanProgress) => void ): Promise { const isAvailable = await isScannerImageAvailable(scannerImage, envId); if (isAvailable) { return true; } onProgress?.({ stage: 'pulling-scanner', message: `Pulling scanner image ${scannerImage}...` }); try { await pullImage(scannerImage, undefined, envId); return true; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[Scanner] Failed to pull image ${scannerImage}:`, errorMsg); return false; } } // Extract JSON object from raw scanner output that may contain non-JSON content // (binary Docker stream headers, warning lines, control characters) export function extractJson(output: string): string { const firstBrace = output.indexOf('{'); const lastBrace = output.lastIndexOf('}'); if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) { throw new Error('No JSON object found in scanner output'); } return output.slice(firstBrace, lastBrace + 1); } /** * Sanitize control characters inside JSON string values that would cause parse failures. * Some scanners (Grype) may include raw control chars (newlines, tabs, null bytes) * in vulnerability descriptions that aren't properly JSON-escaped. */ export function sanitizeJsonString(json: string): string { // Replace unescaped control characters (0x00-0x1F) inside JSON string values // by walking through the string and tracking whether we're inside a quoted string let result = ''; let inString = false; let escaped = false; let sanitized = 0; for (let i = 0; i < json.length; i++) { const ch = json.charCodeAt(i); if (escaped) { // Validate JSON escape sequences: only " \ / b f n r t u are valid const ch2 = json[i]; if ('"\\\/bfnrtu'.includes(ch2)) { result += ch2; } else if (ch < 0x20) { // Backslash followed by a raw control character (e.g. \ + 0x0A) // The backslash was already added to result — escape it as \\ // then also escape the control character result += '\\'; if (ch === 0x0A) result += '\\n'; else if (ch === 0x0D) result += '\\r'; else if (ch === 0x09) result += '\\t'; else result += `\\u${ch.toString(16).padStart(4, '0')}`; sanitized++; } else { // Invalid escape like \x, \a, \e — convert backslash to literal \\ result += '\\' + ch2; sanitized++; } escaped = false; continue; } if (inString) { if (ch === 0x5C) { // backslash result += json[i]; escaped = true; } else if (ch === 0x22) { // closing quote result += json[i]; inString = false; } else if (ch < 0x20) { // Control character inside a string - escape it if (ch === 0x0A) result += '\\n'; else if (ch === 0x0D) result += '\\r'; else if (ch === 0x09) result += '\\t'; else result += `\\u${ch.toString(16).padStart(4, '0')}`; sanitized++; } else { result += json[i]; } } else { if (ch === 0x22) { // opening quote inString = true; } result += json[i]; } } if (sanitized > 0) { console.warn(`[Scanner] Sanitized ${sanitized} control/escape characters in JSON output`); } return result; } // Parse Grype JSON output function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; summary: VulnerabilitySeverity } { const vulnerabilities: Vulnerability[] = []; const summary: VulnerabilitySeverity = { critical: 0, high: 0, medium: 0, low: 0, negligible: 0, unknown: 0 }; try { const extracted = extractJson(output); const sanitized = sanitizeJsonString(extracted); const data = JSON.parse(sanitized); if (data.matches) { for (const match of data.matches) { const severity = (match.vulnerability?.severity || 'Unknown').toLowerCase(); const vuln: Vulnerability = { id: match.vulnerability?.id || 'Unknown', severity: severity, package: match.artifact?.name || 'Unknown', version: match.artifact?.version || 'Unknown', fixedVersion: match.vulnerability?.fix?.versions?.[0], description: match.vulnerability?.description, link: match.vulnerability?.dataSource, scanner: 'grype' }; vulnerabilities.push(vuln); // Count by severity if (severity === 'critical') summary.critical++; else if (severity === 'high') summary.high++; else if (severity === 'medium') summary.medium++; else if (severity === 'low') summary.low++; else if (severity === 'negligible') summary.negligible++; else summary.unknown++; } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Grype] Failed to parse output:', errorMsg); console.error('[Grype] Output length:', output.length); console.error('[Grype] First 200 chars:', output.slice(0, 200)); console.error('[Grype] Last 200 chars:', output.slice(-200)); // Check if output looks like an error message from grype const firstLine = output.split('\n')[0].trim(); if (firstLine && !firstLine.startsWith('{')) { throw new Error(`Scanner output error: ${firstLine}`); } throw new Error('Failed to parse scanner output - ensure CLI args include "-o json"'); } return { vulnerabilities, summary }; } // Parse Trivy JSON output function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; summary: VulnerabilitySeverity } { const vulnerabilities: Vulnerability[] = []; const summary: VulnerabilitySeverity = { critical: 0, high: 0, medium: 0, low: 0, negligible: 0, unknown: 0 }; try { const data = JSON.parse(sanitizeJsonString(extractJson(output))); const results = data.Results || []; for (const result of results) { const vulns = result.Vulnerabilities || []; for (const v of vulns) { const severity = (v.Severity || 'Unknown').toLowerCase(); const vuln: Vulnerability = { id: v.VulnerabilityID || 'Unknown', severity: severity, package: v.PkgName || 'Unknown', version: v.InstalledVersion || 'Unknown', fixedVersion: v.FixedVersion, description: v.Description, link: v.PrimaryURL || v.References?.[0], scanner: 'trivy' }; vulnerabilities.push(vuln); // Count by severity if (severity === 'critical') summary.critical++; else if (severity === 'high') summary.high++; else if (severity === 'medium') summary.medium++; else if (severity === 'low') summary.low++; else if (severity === 'negligible') summary.negligible++; else summary.unknown++; } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Trivy] Failed to parse output:', errorMsg); console.error('[Trivy] Output length:', output.length); console.error('[Trivy] First 32 bytes (hex):', Buffer.from(output.slice(0, 32)).toString('hex')); // Never dump the whole report: a large truncated report is ~13 MB and this ran // on every failed scan (#1496). Log only a bounded head + tail for diagnosis. console.error('[Trivy] Output (head/tail):', truncateForLog(output)); const kind = classifyUnparseableOutput(output); if (kind === 'truncated') { // The head of the JSON was lost to container-log rotation the helper now disables (#1496). throw new Error('Scanner output truncated (container log rotation?) - the JSON report was too large to read back in full'); } if (kind === 'cli-error') { throw new Error(`Scanner output error: ${output.trimStart().split('\n', 1)[0].trim()}`); } throw new Error('Failed to parse scanner output - ensure CLI args include "--format json"'); } return { vulnerabilities, summary }; } /** * Resolve the stable image ID AND a human-readable display name from ONE inspect. * The scan is driven by `imageRef`, which the auto-update path passes as a bare digest; that * digest would otherwise be stored and shown in notifications. `displayName` prefers a real * RepoTag (see pickScanDisplayName); `imageId` is the SHA used for cache keying (unchanged). */ async function resolveImageIdentity(imageRef: string, envId?: number): Promise<{ imageId: string; displayName: string }> { try { const info = await inspectImage(imageRef, envId) as any; return { imageId: info.Id || imageRef, displayName: pickScanDisplayName(imageRef, info.RepoTags) }; } catch { return { imageId: imageRef, displayName: imageRef }; } } // Ensure a named volume exists for caching scanner databases async function ensureVolume(volumeName: string, envId?: number): Promise { const volumes = await listVolumes(envId); const exists = volumes.some(v => v.name === volumeName); if (!exists) { console.log(`[Scanner] Creating database volume: ${volumeName}`); await createVolume({ name: volumeName }, envId); } else { console.log(`[Scanner] Using existing database volume: ${volumeName}`); } } /** * Ensure scanner cache directory exists with correct ownership for rootless Docker. * Creates the directory in Dockhand's data volume and chowns it to the target UID. * * This is needed because Docker volumes are always created with root ownership, * but rootless Docker scanners run as a non-root user (e.g., UID 1000). * By using a bind mount from Dockhand's data directory (which Dockhand can chown * since it runs as root), the scanner can write to its cache. * * @param scannerType - 'grype' or 'trivy' * @param uid - Target UID for ownership (e.g., '1000') * @returns The HOST path to the cache directory (for bind mounting into scanner) */ async function ensureScannerCacheDir( scannerType: 'grype' | 'trivy', uid: string ): Promise { const containerPath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType); // Create directory if needed (recursive) await mkdir(containerPath, { recursive: true }); // Chown to the target UID so scanner can write const uidNum = parseInt(uid, 10); await chown(containerPath, uidNum, uidNum); console.log(`[Scanner] Set ownership of ${containerPath} to ${uid}:${uid}`); // Return the HOST path for bind mounting const hostDataDir = getHostDataDir(); if (hostDataDir) { return `${hostDataDir}/${SCANNER_CACHE_DIR}/${scannerType}`; } // Fallback: not running in Docker, use container path as-is return containerPath; } // Run scanner in a fresh container with volume-cached database async function runScannerContainer( scannerImage: string, scannerType: 'grype' | 'trivy', imageName: string, cmd: string[], envId?: number, onOutput?: (line: string) => void ): Promise { // Check if a scan for this exact image is already in progress // This prevents duplicate scans when multiple containers use the same image const scanKey = `${scannerType}:${imageName}:${envId ?? 'local'}`; const existingScan = inProgressScans.get(scanKey); if (existingScan) { console.log(`[Scanner] Reusing in-progress ${scannerType} scan for: ${imageName}`); return existingScan; } // Create the actual scan promise const scanPromise = runScannerContainerImpl(scannerImage, scannerType, imageName, cmd, envId, onOutput); // Register it so concurrent requests can reuse it inProgressScans.set(scanKey, scanPromise); try { return await scanPromise; } finally { // Clean up the tracking entry when done inProgressScans.delete(scanKey); } } // Internal implementation of scanner container run async function runScannerContainerImpl( scannerImage: string, scannerType: 'grype' | 'trivy', imageName: string, cmd: string[], envId?: number, onOutput?: (line: string) => void ): Promise { // Serialize scans of the same type to avoid DB lock conflicts and re-downloads return withScannerLock(scannerType, () => runScannerContainerCore(scannerImage, scannerType, imageName, cmd, envId, onOutput) ); } async function runScannerContainerCore( scannerImage: string, scannerType: 'grype' | 'trivy', imageName: string, cmd: string[], envId?: number, onOutput?: (line: string) => void ): Promise { console.log(`[Scanner] Starting ${scannerType} scan for image: ${imageName}, envId: ${envId ?? 'local'}`); // Always use the base cache path — serial lock prevents concurrent conflicts const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy'; const dbPath = basePath; // Detect how the scanner container should access Docker. // Strategy: mirror Dockhand's own Docker connection when running locally. const env = envId ? await getEnvironment(envId) : undefined; const connectionType = env?.connectionType; const isHawser = connectionType === 'hawser-standard' || connectionType === 'hawser-edge'; let hostSocketPath: string | null = null; let rootlessUid: string | undefined; let scannerNetworkMode: string | undefined; let scannerDockerHost: string | undefined; let scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined; // Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy). // Detected at startup from Dockhand's own container inspect data. // This applies to ALL non-hawser environments since the scanner container // runs on the same Docker daemon and needs the same access method. const ownDockerHost = getOwnDockerHost(); if (!isHawser && ownDockerHost?.startsWith('tcp://')) { // TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand. scannerDockerHost = ownDockerHost; scannerNetworkMode = getOwnNetworkMode() ?? undefined; const allNets = getOwnAllNetworks(); if (allNets.length > 1) { // Multiple custom networks — if socket-proxy lives on a network // other than the one we picked, DNS will fail. Make the choice // visible so users with split-network setups can colocate // socket-proxy with Dockhand on the primary network (#1011). console.warn( `[Scanner] Dockhand is on multiple networks (${allNets.join(', ')}); scanner will only join "${scannerNetworkMode}". If DOCKER_HOST=${scannerDockerHost} fails to resolve, put socket-proxy on this network.` ); } console.log( `[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}` ); if (scannerExtraHosts?.length) { console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`); } } else if (isHawser) { // Hawser: scanner runs on remote host. Detect the actual socket path // because rootless Podman uses /run/user/UID/podman/podman.sock, not // /var/run/docker.sock (#1076). Falls back to the standard path on // detection failure — no regression for stock Docker hosts. hostSocketPath = await detectRemoteSocketPath(envId); console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`); } else { // Local socket — handles rootless Docker and direct-TCP envs // (the latter via the same socket bind; see #1195, #1076). hostSocketPath = getHostDockerSocket(); console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`); // For user-specific Docker sockets (rootless Docker), detect UID for cache ownership const uid = extractUidFromSocketPath(hostSocketPath); if (uid) { rootlessUid = uid; console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`); console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`); } if (scannerExtraHosts?.length) { console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`); } } // Determine cache storage strategy based on environment // For rootless Docker: use bind mount from data directory with correct ownership // For standard Docker: use named volume (root-owned is fine when running as root) let cacheBind: string; const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME; if (rootlessUid) { // Rootless Docker: use bind mount from data directory with correct ownership const hostCachePath = await ensureScannerCacheDir(scannerType, rootlessUid); cacheBind = `${hostCachePath}:${basePath}`; console.log(`[Scanner] Rootless mode - using bind mount: ${cacheBind}`); } else { // Standard Docker: use named volume (root-owned is fine when running as root) await ensureVolume(volumeName, envId); cacheBind = `${volumeName}:${basePath}`; console.log(`[Scanner] Standard mode - using volume: ${volumeName}`); } // Build binds — only include socket mount when using socket mode const binds: string[] = []; if (hostSocketPath) { binds.push(`${hostSocketPath}:/var/run/docker.sock:ro`); } binds.push(cacheBind); console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`); // Environment variables to ensure scanners use the correct cache path const envVars = scannerType === 'grype' ? [`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}`); } // 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 const output = await runContainerWithStreaming({ image: scannerImage, cmd, binds, env: envVars, extraHosts: overrides.extraHosts, name: `dockhand-${scannerType}-${Date.now()}`, envId, networkMode: overrides.networkMode, dns: overrides.dns, timeout: 600_000, // 10 minutes onStderr: (data) => { // Stream stderr lines for real-time progress output const lines = data.split('\n'); for (const line of lines) { if (line.trim()) { onOutput?.(line); } } } }); console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`); if (output.length < 100) { console.log(`[Scanner] ${scannerType} output preview: ${output}`); } return output; } // Scan image with Grype export async function scanWithGrype( imageName: string, envId?: number, onProgress?: (progress: ScanProgress) => void ): Promise { const startTime = Date.now(); const { grypeArgs, grypeImage: scannerImage } = await getScannerSettings(envId); onProgress?.({ stage: 'checking', message: 'Checking Grype scanner availability...', scanner: 'grype' }); // Ensure scanner image is available const available = await ensureScannerImage(scannerImage, envId, onProgress); if (!available) { throw new Error('Failed to get Grype scanner image. Please ensure Docker can pull images.'); } onProgress?.({ stage: 'scanning', message: `Scanning ${imageName} with Grype...`, scanner: 'grype', progress: 30 }); try { // Parse CLI args from settings const cmd = parseCliArgs(grypeArgs, imageName); const output = await runScannerContainer( scannerImage, 'grype', imageName, cmd, envId, (line) => { onProgress?.({ stage: 'scanning', message: `Scanning ${imageName} with Grype...`, scanner: 'grype', progress: 50, output: line }); } ); // Defensive logging for empty output console.log(`[Grype] Scanner container output received, length: ${output.length}`); if (output.length === 0) { console.error('[Grype] WARNING: Empty output from scanner container - possible race condition'); } onProgress?.({ stage: 'parsing', message: 'Parsing scan results...', scanner: 'grype', progress: 80 }); const { vulnerabilities, summary } = parseGrypeOutput(output); // SHA256 id for caching + a readable name for the record/notification. const { imageId, displayName } = await resolveImageIdentity(imageName, envId); const result: ScanResult = { imageId, imageName: displayName, scanner: 'grype', scannedAt: new Date().toISOString(), vulnerabilities, summary, scanDuration: Date.now() - startTime }; onProgress?.({ stage: 'complete', message: `Grype scan complete: ${summary.critical} critical, ${summary.high} high, ${summary.medium} medium, ${summary.low} low`, scanner: 'grype', progress: 100, result }); return result; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); onProgress?.({ stage: 'error', message: `Grype scan failed: ${errorMsg}`, scanner: 'grype', error: errorMsg }); throw error; } } // Scan image with Trivy export async function scanWithTrivy( imageName: string, envId?: number, onProgress?: (progress: ScanProgress) => void ): Promise { const startTime = Date.now(); const { trivyArgs, trivyImage: scannerImage } = await getScannerSettings(envId); onProgress?.({ stage: 'checking', message: 'Checking Trivy scanner availability...', scanner: 'trivy' }); // Ensure scanner image is available const available = await ensureScannerImage(scannerImage, envId, onProgress); if (!available) { throw new Error('Failed to get Trivy scanner image. Please ensure Docker can pull images.'); } onProgress?.({ stage: 'scanning', message: `Scanning ${imageName} with Trivy...`, scanner: 'trivy', progress: 30 }); try { // Parse CLI args from settings const cmd = parseCliArgs(trivyArgs, imageName); const output = await runScannerContainer( scannerImage, 'trivy', imageName, cmd, envId, (line) => { onProgress?.({ stage: 'scanning', message: `Scanning ${imageName} with Trivy...`, scanner: 'trivy', progress: 50, output: line }); } ); // Defensive logging for empty output console.log(`[Trivy] Scanner container output received, length: ${output.length}`); if (output.length === 0) { console.error('[Trivy] WARNING: Empty output from scanner container - possible race condition'); } onProgress?.({ stage: 'parsing', message: 'Parsing scan results...', scanner: 'trivy', progress: 80 }); const { vulnerabilities, summary } = parseTrivyOutput(output); // SHA256 id for caching + a readable name for the record/notification. const { imageId, displayName } = await resolveImageIdentity(imageName, envId); const result: ScanResult = { imageId, imageName: displayName, scanner: 'trivy', scannedAt: new Date().toISOString(), vulnerabilities, summary, scanDuration: Date.now() - startTime }; onProgress?.({ stage: 'complete', message: `Trivy scan complete: ${summary.critical} critical, ${summary.high} high, ${summary.medium} medium, ${summary.low} low`, scanner: 'trivy', progress: 100, result }); return result; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); onProgress?.({ stage: 'error', message: `Trivy scan failed: ${errorMsg}`, scanner: 'trivy', error: errorMsg }); throw error; } } // Scan image with configured scanner(s) export async function scanImage( imageName: string, envId?: number, onProgress?: (progress: ScanProgress) => void, forceScannerType?: ScannerType ): Promise { const { scanner } = await getScannerSettings(envId); const scannerType = forceScannerType || scanner; if (scannerType === 'none') { return []; } const results: ScanResult[] = []; const errors: Error[] = []; if (scannerType === 'grype' || scannerType === 'both') { try { const result = await scanWithGrype(imageName, envId, onProgress); results.push(result); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Grype] Scan failed:', errorMsg); errors.push(error instanceof Error ? error : new Error(String(error))); if (scannerType === 'grype') throw error; } } if (scannerType === 'trivy' || scannerType === 'both') { try { const result = await scanWithTrivy(imageName, envId, onProgress); results.push(result); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Trivy] Scan failed:', errorMsg); errors.push(error instanceof Error ? error : new Error(String(error))); if (scannerType === 'trivy') throw error; } } // If using 'both' and all scanners failed, throw an error if (scannerType === 'both' && results.length === 0 && errors.length > 0) { throw new Error(`All scanners failed: ${errors.map(e => e.message).join('; ')}`); } // Send vulnerability notifications based on combined results // When using 'both' scanners, take the MAX of each severity across all results if (results.length > 0) { const combinedSummary: VulnerabilitySeverity = { critical: Math.max(...results.map(r => r.summary.critical)), high: Math.max(...results.map(r => r.summary.high)), medium: Math.max(...results.map(r => r.summary.medium)), low: Math.max(...results.map(r => r.summary.low)), negligible: Math.max(...results.map(r => r.summary.negligible)), unknown: Math.max(...results.map(r => r.summary.unknown)) }; // Notify with the readable name the scan resolved (a tag when the image has one), // not the bare digest the caller may have passed in. const notifyName = results[0]?.imageName || imageName; sendVulnerabilityNotifications(notifyName, combinedSummary, envId).catch(err => { const errorMsg = err instanceof Error ? err.message : String(err); console.error('[Scanner] Failed to send vulnerability notifications:', errorMsg); }); } return results; } // Check if scanner images are available export async function checkScannerAvailability(envId?: number): Promise<{ grype: boolean; trivy: boolean; }> { const defaults = await getGlobalScannerDefaults(); const [grypeAvailable, trivyAvailable] = await Promise.all([ isScannerImageAvailable(defaults.grypeImage, envId), isScannerImageAvailable(defaults.trivyImage, envId) ]); return { grype: grypeAvailable, trivy: trivyAvailable }; } // Get scanner version by running a temporary container async function getScannerVersion( scannerType: 'grype' | 'trivy', envId?: number ): Promise { try { const defaults = await getGlobalScannerDefaults(); const scannerImage = scannerType === 'grype' ? defaults.grypeImage : defaults.trivyImage; // 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.startsWith(imageRepo + ':')) ); if (!hasImage) return null; // 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, networkMode: overrides.networkMode, dns: overrides.dns }); console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`); const output = stdout || stderr; // Parse version from output // Grype: "grype 0.74.0" or "Application: grype\nVersion: 0.86.1" // Trivy: "Version: 0.48.0" or just "0.48.0" const versionMatch = output.match(/(?:grype|trivy|Version:?\s*)?([\d]+\.[\d]+\.[\d]+)/i); const version = versionMatch ? versionMatch[1] : null; if (!version) { console.error(`Could not parse ${scannerType} version from output:`, output.substring(0, 200)); } return version; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[Scanner] Failed to get ${scannerType} version:`, errorMsg); return null; } } // Get versions of available scanners export async function getScannerVersions(envId?: number): Promise<{ grype: string | null; trivy: string | null; }> { const [grypeVersion, trivyVersion] = await Promise.all([ getScannerVersion('grype', envId), getScannerVersion('trivy', envId) ]); return { grype: grypeVersion, trivy: trivyVersion }; } // Check if scanner images have updates available by comparing local digest with remote export async function checkScannerUpdates(envId?: number): Promise<{ grype: { hasUpdate: boolean; localDigest?: string; remoteDigest?: string }; trivy: { hasUpdate: boolean; localDigest?: string; remoteDigest?: string }; }> { const result = { grype: { hasUpdate: false, localDigest: undefined as string | undefined, remoteDigest: undefined as string | undefined }, trivy: { hasUpdate: false, localDigest: undefined as string | undefined, remoteDigest: undefined as string | undefined } }; try { const defaults = await getGlobalScannerDefaults(); const images = await listImages(envId); // Check both scanners for (const [scanner, imageName] of [['grype', defaults.grypeImage], ['trivy', defaults.trivyImage]] as const) { try { // Find local image const localImage = images.find((img) => img.tags?.includes(imageName) ); if (localImage && localImage.id) { const updateResult = await checkImageUpdateAvailable(imageName, localImage.id, envId); result[scanner].hasUpdate = updateResult.hasUpdate; result[scanner].localDigest = updateResult.currentDigest; result[scanner].remoteDigest = updateResult.registryDigest; } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[Scanner] Failed to check updates for ${scanner}:`, errorMsg); } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Scanner] Failed to check scanner updates:', errorMsg); } return result; } // Clean up scanner database volumes (removes cached vulnerability databases) export async function cleanupScannerVolumes(envId?: number): Promise { try { // Remove scanner database volumes for (const volumeName of [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME]) { try { await removeVolume(volumeName, true, envId); console.log(`[Scanner] Removed volume: ${volumeName}`); } catch { // Volume might not exist, ignore } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Scanner] Failed to cleanup scanner volumes:', errorMsg); } } /** * Clean up all scanner cache storage (volumes + bind mount directories). * Handles both standard Docker (named volumes) and rootless Docker (bind mounts). * Next scan after cleanup will re-download a fresh vulnerability database (~200MB). */ export async function cleanupScannerCache(envId?: number): Promise<{ volumes: string[]; dirs: string[] }> { const removedVolumes: string[] = []; const removedDirs: string[] = []; // 1. Remove named volumes (standard Docker mode) for (const volumeName of [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME]) { try { await removeVolume(volumeName, true, envId); removedVolumes.push(volumeName); const envSuffix = envId ? ` (env ${envId})` : ''; console.log(`[Scanner] Removed volume: ${volumeName}${envSuffix}`); } catch { // Volume might not exist, ignore } } // 2. Remove bind mount cache directories (rootless Docker mode, local only) if (!envId) { for (const scannerType of ['grype', 'trivy'] as const) { const cachePath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType); try { await rm(cachePath, { recursive: true, force: true }); removedDirs.push(cachePath); console.log(`[Scanner] Removed cache directory: ${cachePath}`); } catch { // Directory might not exist, ignore } } } if (removedVolumes.length > 0 || removedDirs.length > 0) { console.log(`[Scanner] Cache cleanup complete: ${removedVolumes.length} volumes, ${removedDirs.length} directories removed`); } return { volumes: removedVolumes, dirs: removedDirs }; }