import { spawn, spawnSync } from 'node:child_process'; import { readdir } from 'node:fs/promises'; import { join, relative } from 'node:path'; export type DockerfileSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; export interface DockerfileFinding { id: string; title: string; severity: DockerfileSeverity; file: string; line: number; message: string; confirmed: boolean; category: 'dockerfile'; } export interface DockerfileResult { skipped: boolean; skippedReason: string | null; dockerfilesFound: number; findings: DockerfileFinding[]; critical: number; high: number; medium: number; low: number; total: number; } interface HadolintJsonFinding { code: unknown; message: unknown; file: unknown; line: unknown; column: unknown; level: unknown; } const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist']); export async function scanDockerfiles(projectPath: string): Promise { const dockerfiles = await discoverDockerfiles(projectPath); if (dockerfiles.length === 0) { return buildSkippedResult('No Dockerfiles found, skipping Dockerfile scan'); } if (!hadolintExists()) { return buildSkippedResult( 'hadolint not found. Install it to enable Dockerfile scanning: https://github.com/hadolint/hadolint', dockerfiles.length, ); } const findings: DockerfileFinding[] = []; for (const dockerfilePath of dockerfiles) { try { const hadolintFindings = await runHadolint(dockerfilePath); for (const finding of hadolintFindings) { const normalized = normalizeHadolintFinding(projectPath, dockerfilePath, finding); if (normalized !== null) { findings.push(normalized); } } } catch (error) { console.warn( ` ⚠ Hadolint failed on ${relative(projectPath, dockerfilePath)}: ${error instanceof Error ? error.message : String(error)}`, ); } } const counts = countBySeverity(findings); return { skipped: false, skippedReason: null, dockerfilesFound: dockerfiles.length, findings, critical: counts.CRITICAL, high: counts.HIGH, medium: counts.MEDIUM, low: counts.LOW, total: findings.length, }; } function buildSkippedResult(reason: string, dockerfilesFound = 0): DockerfileResult { return { skipped: true, skippedReason: reason, dockerfilesFound, findings: [], critical: 0, high: 0, medium: 0, low: 0, total: 0, }; } async function discoverDockerfiles(projectPath: string): Promise { const discovered: string[] = []; const queue: string[] = [projectPath]; while (queue.length > 0) { const current = queue.pop(); if (current === undefined) { continue; } let entries; try { entries = await readdir(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const entryPath = join(current, entry.name); if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) { queue.push(entryPath); } continue; } if (entry.isFile() && isDockerfileName(entry.name)) { discovered.push(entryPath); } } } discovered.sort((a, b) => a.localeCompare(b)); return discovered; } function isDockerfileName(fileName: string): boolean { return fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.'); } function hadolintExists(): boolean { const checker = process.platform === 'win32' ? 'where' : 'which'; const result = spawnSync(checker, ['hadolint'], { stdio: 'ignore', windowsHide: true, }); return result.status === 0; } async function runHadolint(dockerfilePath: string): Promise { const output = await new Promise<{ stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null }>( (resolve, reject) => { const child = spawn('hadolint', ['--format', 'json', dockerfilePath], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk: Buffer | string) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk: Buffer | string) => { stderr += chunk.toString(); }); child.on('error', (error) => { reject(new Error(`failed to run hadolint: ${error.message}`)); }); child.on('close', (code, signal) => { resolve({ stdout, stderr, code, signal }); }); }, ); if (output.signal !== null) { throw new Error(`hadolint terminated by signal ${output.signal}`); } const trimmed = output.stdout.trim(); if (trimmed.length === 0) { if (output.code === 0 || output.code === 1) { return []; } throw new Error(output.stderr.trim() || `hadolint exited with code ${String(output.code)}`); } let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch (error) { throw new Error( `hadolint produced invalid JSON: ${error instanceof Error ? error.message : String(error)}`, ); } if (!Array.isArray(parsed)) { throw new Error('hadolint JSON output is not an array'); } return parsed as HadolintJsonFinding[]; } function normalizeHadolintFinding( projectPath: string, dockerfilePath: string, finding: HadolintJsonFinding, ): DockerfileFinding | null { if ( typeof finding.code !== 'string' || typeof finding.message !== 'string' || typeof finding.line !== 'number' || !Number.isFinite(finding.line) || typeof finding.level !== 'string' ) { return null; } const severity = mapHadolintSeverity(finding.level); if (severity === null) { return null; } return { id: finding.code, title: finding.message, severity, file: relative(projectPath, dockerfilePath), line: Math.max(1, Math.trunc(finding.line)), message: finding.message, confirmed: true, category: 'dockerfile', }; } function mapHadolintSeverity(level: string): DockerfileSeverity | null { const normalized = level.toLowerCase(); if (normalized === 'error') { return 'CRITICAL'; } if (normalized === 'warning') { return 'HIGH'; } if (normalized === 'info') { return 'MEDIUM'; } if (normalized === 'style') { return 'LOW'; } return null; } function countBySeverity(findings: DockerfileFinding[]): Record { const counts: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, }; for (const finding of findings) { counts[finding.severity] += 1; } return counts; }