#!/usr/bin/env node // Asserts that every runtime stage in the root Dockerfile drops to a non-root USER // before its HEALTHCHECK/CMD/ENTRYPOINT directive. Fails the gate if any runtime // stage (web, api, worker) ships as root. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const RUNTIME_STAGES = new Set(["web", "api", "worker"]); function readDockerfile(rootDir) { return fs.readFileSync(path.join(rootDir, "Dockerfile"), "utf8"); } function splitStages(content) { const stages = []; const lines = content.split(/\r?\n/); let current = null; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; const fromMatch = /^FROM\b.*\bAS\s+([A-Za-z0-9_-]+)\s*$/i.exec(line); if (fromMatch) { if (current) { current.endLine = i; stages.push(current); } current = { name: fromMatch[1], startLine: i + 1, endLine: lines.length, body: [] }; continue; } if (current) { current.body.push(line); } } if (current) { stages.push(current); } return stages; } function findRuntimeViolations(stage) { const userIndex = stage.body.findIndex((line) => /^USER\s+(\S+)/i.test(line)); const userValue = userIndex >= 0 ? /^USER\s+(\S+)/i.exec(stage.body[userIndex])?.[1] : null; const cmdIndex = stage.body.findIndex((line) => /^(CMD|ENTRYPOINT)\b/i.test(line)); const violations = []; if (cmdIndex < 0) { return violations; } if (userIndex < 0 || !userValue) { violations.push(`stage "${stage.name}" has CMD/ENTRYPOINT but no USER directive — runs as root`); return violations; } if (userValue === "root" || userValue === "0") { violations.push(`stage "${stage.name}" USER is "${userValue}" — runtime must be non-root`); } if (userIndex > cmdIndex) { violations.push(`stage "${stage.name}" USER must come before CMD/ENTRYPOINT (USER at offset ${userIndex}, CMD at offset ${cmdIndex})`); } return violations; } function main() { const rootDir = path.resolve(fileURLToPath(import.meta.url), "..", ".."); const content = readDockerfile(rootDir); const stages = splitStages(content); const runtimeStages = stages.filter((stage) => RUNTIME_STAGES.has(stage.name)); const failures = []; const passes = []; for (const expected of RUNTIME_STAGES) { if (!runtimeStages.some((stage) => stage.name === expected)) { failures.push(`Dockerfile is missing the "${expected}" runtime stage`); } } for (const stage of runtimeStages) { const violations = findRuntimeViolations(stage); if (violations.length === 0) { passes.push(`Dockerfile stage "${stage.name}" drops to non-root before CMD/ENTRYPOINT`); } else { failures.push(...violations.map((message) => `Dockerfile: ${message}`)); } } for (const line of passes) console.log(`PASS ${line}`); for (const line of failures) console.error(`FAIL ${line}`); console.log(`Summary: ${passes.length} passed, 0 observed, ${failures.length} failed`); if (failures.length > 0) { process.exit(1); } } main();