109 lines
4.1 KiB
JavaScript
109 lines
4.1 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const WORKFLOW_ROOTS = [".gitea/workflows"];
|
|
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
const IMAGE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
const EXACT_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
|
|
function inspectExecutablePackagePin(line, relativePath, lineNumber) {
|
|
const match = line.match(/\b(?:npx(?:\s+--yes|\s+-y)?|pnpm\s+dlx)\s+(@[^\s@]+\/[^\s@]+|[^\s@]+)@([^\s"']+)/);
|
|
if (!match) return null;
|
|
|
|
const [, packageName, reference] = match;
|
|
if (EXACT_SEMVER_PATTERN.test(reference)) {
|
|
return { pass: `${relativePath}:${lineNumber}: executable package ${packageName} pinned to ${reference}` };
|
|
}
|
|
return {
|
|
failure: `${relativePath}:${lineNumber}: executable package must use an exact semantic version (${packageName}@${reference})`,
|
|
};
|
|
}
|
|
|
|
function listWorkflowFiles(rootDir, relativeDirectory) {
|
|
const absoluteDirectory = path.join(rootDir, relativeDirectory);
|
|
if (!fs.existsSync(absoluteDirectory)) return [];
|
|
|
|
return fs.readdirSync(absoluteDirectory, { withFileTypes: true }).flatMap((entry) => {
|
|
const relativePath = path.join(relativeDirectory, entry.name);
|
|
if (entry.isDirectory()) return listWorkflowFiles(rootDir, relativePath);
|
|
return /\.ya?ml$/i.test(entry.name) ? [relativePath] : [];
|
|
});
|
|
}
|
|
|
|
export function inspectWorkflowActionPins(source, relativePath) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
for (const [index, line] of source.split(/\r?\n/).entries()) {
|
|
const executablePin = inspectExecutablePackagePin(line, relativePath, index + 1);
|
|
if (executablePin?.pass) passes.push(executablePin.pass);
|
|
if (executablePin?.failure) failures.push(executablePin.failure);
|
|
|
|
const match = line.match(/^\s*-?\s*uses:\s*["']?([^"'#\s]+)["']?/);
|
|
if (!match) continue;
|
|
|
|
const target = match[1];
|
|
if (target.startsWith("./")) {
|
|
passes.push(`${relativePath}:${index + 1}: local action or reusable workflow`);
|
|
continue;
|
|
}
|
|
|
|
if (target.startsWith("docker://")) {
|
|
const digest = target.slice(target.lastIndexOf("@") + 1);
|
|
if (target.includes("@") && IMAGE_DIGEST_PATTERN.test(digest)) {
|
|
passes.push(`${relativePath}:${index + 1}: container action pinned by digest`);
|
|
} else {
|
|
failures.push(`${relativePath}:${index + 1}: container action must use an immutable sha256 digest (${target})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const separatorIndex = target.lastIndexOf("@");
|
|
const reference = separatorIndex >= 0 ? target.slice(separatorIndex + 1) : "";
|
|
if (separatorIndex > 0 && COMMIT_SHA_PATTERN.test(reference)) {
|
|
passes.push(`${relativePath}:${index + 1}: remote action pinned to ${reference}`);
|
|
} else {
|
|
failures.push(`${relativePath}:${index + 1}: remote action must use an immutable 40-character commit SHA (${target})`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures };
|
|
}
|
|
|
|
export function verifyActionPins(rootDir = process.cwd()) {
|
|
const files = WORKFLOW_ROOTS.flatMap((relativeDirectory) => listWorkflowFiles(rootDir, relativeDirectory));
|
|
const result = { passes: [], failures: [] };
|
|
|
|
for (const relativePath of files) {
|
|
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
const fileResult = inspectWorkflowActionPins(source, relativePath);
|
|
result.passes.push(...fileResult.passes);
|
|
result.failures.push(...fileResult.failures);
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
result.failures.push("No Gitea workflow files were found");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function formatActionPinResult(result) {
|
|
const status = result.failures.length === 0 ? "PASS" : "FAIL";
|
|
return [
|
|
`${status} workflow action pin verification`,
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
`Summary: ${result.passes.length} pinned/local action references, ${result.failures.length} failures`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
if (isCli) {
|
|
const result = verifyActionPins();
|
|
process.stdout.write(formatActionPinResult(result));
|
|
if (result.failures.length > 0) process.exitCode = 1;
|
|
}
|