97 lines
3.0 KiB
JavaScript
97 lines
3.0 KiB
JavaScript
#!/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();
|