Files
dockhand-upstream/src/lib/server/scanner.ts
T

1320 lines
44 KiB
TypeScript

// 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<void> {
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<string, Promise<void>>(); // key: "grype" or "trivy"
async function withScannerLock<T>(scannerType: string, fn: () => Promise<T>): Promise<T> {
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<void>(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<string, Promise<string>>();
/** 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<boolean> {
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<boolean> {
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<void> {
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<string> {
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<string> {
// 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<string> {
// 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<string> {
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<ScanResult> {
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<ScanResult> {
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<ScanResult[]> {
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<string | null> {
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<void> {
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 };
}