80 lines
2.9 KiB
JavaScript
80 lines
2.9 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const DIGEST_PATTERN = /@sha256:[0-9a-f]{64}(?=[}\s"']|$)/;
|
|
const SKIP_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", "coverage"]);
|
|
|
|
function listFiles(rootDir) {
|
|
const files = [];
|
|
const stack = [rootDir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
if (!SKIP_DIRECTORIES.has(entry.name)) stack.push(path.join(current, entry.name));
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
if (/^(?:docker-)?compose.*\.ya?ml$/i.test(entry.name) || /^Dockerfile(?:\..+)?$/.test(entry.name)) {
|
|
files.push(path.join(current, entry.name));
|
|
}
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export function inspectContainerFile(source, relativePath) {
|
|
const failures = [];
|
|
const passes = [];
|
|
const stages = new Set();
|
|
|
|
for (const [index, line] of source.split(/\r?\n/).entries()) {
|
|
const image = line.match(/^\s*image:\s*(.+?)\s*(?:#.*)?$/i);
|
|
if (image) {
|
|
if (DIGEST_PATTERN.test(image[1])) {
|
|
passes.push(`${relativePath}:${index + 1}: Compose image pinned by digest`);
|
|
} else {
|
|
failures.push(`${relativePath}:${index + 1}: Compose image must use an immutable sha256 digest (${image[1]})`);
|
|
}
|
|
}
|
|
|
|
const from = line.match(/^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?\s*$/i);
|
|
if (!from) continue;
|
|
const [, base, stage] = from;
|
|
if (stages.has(base)) {
|
|
passes.push(`${relativePath}:${index + 1}: Docker stage derives from local stage ${base}`);
|
|
} else if (DIGEST_PATTERN.test(base)) {
|
|
passes.push(`${relativePath}:${index + 1}: Docker base image pinned by digest`);
|
|
} else {
|
|
failures.push(`${relativePath}:${index + 1}: Docker base image must use an immutable sha256 digest (${base})`);
|
|
}
|
|
if (stage) stages.add(stage);
|
|
}
|
|
|
|
return { failures, passes };
|
|
}
|
|
|
|
export function verifyContainerImagePins(rootDir = process.cwd()) {
|
|
const result = { failures: [], passes: [] };
|
|
for (const file of listFiles(rootDir)) {
|
|
const relativePath = path.relative(rootDir, file);
|
|
const inspected = inspectContainerFile(fs.readFileSync(file, "utf8"), relativePath);
|
|
result.failures.push(...inspected.failures);
|
|
result.passes.push(...inspected.passes);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
if (isCli) {
|
|
const result = verifyContainerImagePins();
|
|
for (const failure of result.failures) process.stderr.write(`FAIL ${failure}\n`);
|
|
process.stdout.write(
|
|
`${result.failures.length === 0 ? "PASS" : "FAIL"} container image pin verification\n` +
|
|
`Summary: ${result.passes.length} pinned/local image references, ${result.failures.length} failures\n`,
|
|
);
|
|
if (result.failures.length > 0) process.exitCode = 1;
|
|
}
|