mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-01 12:22:24 +03:00
npm rejects provenance-signed uploads from self-hosted runners: 422 Unprocessable Entity - Error verifying sigstore provenance bundle: Unsupported GitHub Actions runner environment: "self-hosted". Only "github-hosted" runners are supported when publishing with provenance. v3.8.50 learned that at minute 76 of its 10th publish attempt, after the tag, the GitHub Release and the Docker images were already out. USE_VPS_RUNNER had routed the job to the .113 pool on 2026-08-02; no release ran between 07-30 and 08-28, so the pairing sat latent for four weeks. It is pure text — a job whose runs-on resolves to self-hosted and a step whose run contains --provenance — so the workflow lint now checks it as a hard rule: reported in plain mode, blocking under --strict and --ratchet (the CI mode), emitted as provenanceRunnerFindings=<n> next to the other counters. Against origin/main the rule finds the two real offenders (the staged upload AND the DIRECT emergency fallback in npm-publish.yml); against the #11877 split it finds none. --provenance-file is deliberately not matched (different flag, pre-built bundle) and an opaque runs-on expression with no literal self-hosted is classified unknown and skipped — the check never guesses. The unit suite's last case walks the real .github/workflows and asserts zero findings, so it is red on main until #11877 lands and green after; that is the regression guard working, not a flake.
456 lines
17 KiB
JavaScript
456 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
// scripts/check/check-workflows.mjs
|
||
// Lint + security audit of GitHub Actions workflow files.
|
||
// PLANO-QUALITY-GATES-FASE7.md, Task 19.
|
||
//
|
||
// Tools:
|
||
// actionlint — syntax / correctness / shellcheck of workflow YAML
|
||
// zizmor — 24+ security audits (unpinned actions, script injection,
|
||
// pull_request_target misuse, cache poisoning, …)
|
||
//
|
||
// Graceful-SKIP contract:
|
||
// If EITHER binary is absent from PATH, the script prints a SKIP notice and
|
||
// exits 0. This allows the gate to run in environments that have the tools
|
||
// installed (CI with setup steps, developer machines with actionlint/zizmor)
|
||
// while being inert elsewhere.
|
||
//
|
||
// Output (stdout, one line each):
|
||
// workflowFindings=<n> — total findings from both tools combined
|
||
// actionlintFindings=<n> — findings from actionlint alone
|
||
// zizmorFindings=<n> — findings from zizmor alone
|
||
//
|
||
// Exit codes:
|
||
// 0 — SKIP (binary absent) or all tools passed / no ratchet regression
|
||
// 1 — gate failure: --strict + any finding, OR --ratchet + zizmorFindings
|
||
// regression (measured > baseline)
|
||
//
|
||
// Ratchet mode (--ratchet): reads metrics.zizmorFindings.value from
|
||
// config/quality/quality-baseline.json and exits 1 IF — AND ONLY IF — the MEASURED
|
||
// zizmor count is GREATER than the baseline (real regression, direction:down).
|
||
// ONLY zizmorFindings is ratcheted; actionlint findings are REPORTED but NOT
|
||
// ratcheted (use the separate --strict all-or-nothing flag for those). Any graceful
|
||
// SKIP (binary absent, no workflows) exits 0 even with --ratchet — missing infra
|
||
// never blocks, only a measured regression does.
|
||
//
|
||
// Usage:
|
||
// node scripts/check/check-workflows.mjs # advisory (exit 0 always)
|
||
// node scripts/check/check-workflows.mjs --strict # fail on any finding
|
||
// node scripts/check/check-workflows.mjs --ratchet # fail on zizmor regression
|
||
// node scripts/check/check-workflows.mjs --quiet # suppress progress logs
|
||
|
||
import { execFileSync, spawnSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
import { findProvenanceOnSelfHosted, formatProvenanceFinding } from "./lib/provenanceRunner.mjs";
|
||
|
||
const ROOT = process.cwd();
|
||
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
|
||
const ZIZMOR_CONFIG = path.join(ROOT, ".zizmor.yml");
|
||
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
|
||
|
||
const STRICT = process.argv.includes("--strict");
|
||
const RATCHET = process.argv.includes("--ratchet");
|
||
const QUIET = process.argv.includes("--quiet");
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utility: resolve binary from PATH (cross-platform)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Checks whether a binary exists in PATH by running `which`/`where`.
|
||
* Returns true if found, false otherwise.
|
||
*
|
||
* @param {string} name - Binary name (e.g. "actionlint")
|
||
* @returns {boolean}
|
||
*/
|
||
export function isBinaryAvailable(name) {
|
||
// Use `command -v` on Unix; `where` on Windows (via cmd).
|
||
// We shell through `sh -c` because execFileSync needs the actual path
|
||
// and we want cross-platform behaviour.
|
||
const result = spawnSync("sh", ["-c", `command -v ${name}`], {
|
||
encoding: "utf8",
|
||
timeout: 5_000,
|
||
windowsHide: true,
|
||
});
|
||
return result.status === 0 && result.stdout.trim().length > 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// actionlint result parsing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Parses actionlint output (line-based, one finding per line) and counts
|
||
* findings. Each non-empty line = one finding.
|
||
*
|
||
* actionlint emits lines in the format:
|
||
* <file>:<line>:<col>: <message> [<rule>]
|
||
* or a summary line when all is well (zero findings = empty stdout).
|
||
*
|
||
* @param {string} stdout - Raw stdout from actionlint
|
||
* @returns {{ count: number, lines: string[] }}
|
||
*/
|
||
export function parseActionlintOutput(stdout) {
|
||
const lines = stdout
|
||
.split("\n")
|
||
.map((l) => l.trim())
|
||
.filter(Boolean);
|
||
return { count: lines.length, lines };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// zizmor result parsing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Parses zizmor JSON output and counts findings.
|
||
*
|
||
* zizmor --format json emits a JSON object:
|
||
* { diagnostics: Array<{ ...finding fields }> }
|
||
* or an array directly in older versions.
|
||
*
|
||
* If JSON parsing fails, falls back to line counting (graceful degradation).
|
||
*
|
||
* @param {string} stdout - Raw stdout from zizmor --format json (or text)
|
||
* @returns {{ count: number, diagnostics: unknown[] }}
|
||
*/
|
||
export function parseZizmorOutput(stdout) {
|
||
const trimmed = stdout.trim();
|
||
if (!trimmed) {
|
||
return { count: 0, diagnostics: [] };
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
// zizmor ≥0.8 emits { diagnostics: [...] }
|
||
if (parsed && typeof parsed === "object" && Array.isArray(parsed.diagnostics)) {
|
||
return { count: parsed.diagnostics.length, diagnostics: parsed.diagnostics };
|
||
}
|
||
// Older versions may emit a bare array
|
||
if (Array.isArray(parsed)) {
|
||
return { count: parsed.length, diagnostics: parsed };
|
||
}
|
||
// Unexpected JSON shape — treat whole object as 0 findings if it has no
|
||
// obvious error marker; this is a best-effort parse.
|
||
return { count: 0, diagnostics: [] };
|
||
} catch {
|
||
// Not JSON (e.g. text output or error message) — count non-empty lines as
|
||
// a conservative fallback.
|
||
const lines = trimmed.split("\n").filter(Boolean);
|
||
return { count: lines.length, diagnostics: [] };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Ratchet (direction:down, zizmorFindings only) — exported for tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Evaluates the MEASURED zizmor finding count against the baseline.
|
||
* Direction: down (the count may only DROP — more findings = regression).
|
||
*
|
||
* @param {number} current - Measured zizmor finding count.
|
||
* @param {number} baseline - Frozen count in quality-baseline.json.
|
||
* @returns {{ regressed: boolean, improved: boolean }}
|
||
*/
|
||
export function evaluateZizmorRatchet(current, baseline) {
|
||
return {
|
||
regressed: current > baseline,
|
||
improved: current < baseline,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Reads metrics.zizmorFindings.value from quality-baseline.json.
|
||
* Returns null when the file or metric is missing (no baseline → no ratchet
|
||
* possible; the caller treats this as a graceful SKIP, exit 0).
|
||
*
|
||
* @param {string} baselinePath
|
||
* @returns {number|null}
|
||
*/
|
||
export function readBaselineZizmorValue(baselinePath = BASELINE_PATH) {
|
||
if (!fs.existsSync(baselinePath)) return null;
|
||
let baselineJson;
|
||
try {
|
||
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
||
} catch {
|
||
return null;
|
||
}
|
||
const metric = baselineJson?.metrics?.zizmorFindings;
|
||
if (!metric || typeof metric.value !== "number") return null;
|
||
return metric.value;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Runner helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Collects all *.yml files from the workflows directory.
|
||
*
|
||
* @param {string} workflowsDir
|
||
* @returns {string[]} Absolute paths
|
||
*/
|
||
export function collectWorkflowFiles(workflowsDir) {
|
||
if (!fs.existsSync(workflowsDir)) {
|
||
return [];
|
||
}
|
||
return fs
|
||
.readdirSync(workflowsDir)
|
||
.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
|
||
.map((f) => path.join(workflowsDir, f));
|
||
}
|
||
|
||
/**
|
||
* Runs actionlint over the given workflow files.
|
||
* Returns parsed result. Never throws — returns count=0 on any exec error so
|
||
* that one broken binary does not abort the whole check.
|
||
*
|
||
* @param {string[]} files - Absolute paths to workflow YAMLs
|
||
* @returns {{ count: number, lines: string[], skipped: boolean }}
|
||
*/
|
||
export function runActionlint(files) {
|
||
if (files.length === 0) {
|
||
return { count: 0, lines: [], skipped: false };
|
||
}
|
||
try {
|
||
const stdout = execFileSync("actionlint", files, {
|
||
encoding: "utf8",
|
||
// actionlint exits non-zero when it finds issues; capture output anyway
|
||
// by catching the thrown error.
|
||
});
|
||
return { ...parseActionlintOutput(stdout), skipped: false };
|
||
} catch (err) {
|
||
// execFileSync throws when exit code != 0.
|
||
// stdout still contains the finding lines.
|
||
const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || "";
|
||
return { ...parseActionlintOutput(String(stdout)), skipped: false };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Runs zizmor over the workflows directory.
|
||
* Returns parsed result. Never throws.
|
||
*
|
||
* @param {string} workflowsDir - Path to .github/workflows
|
||
* @returns {{ count: number, diagnostics: unknown[], skipped: boolean }}
|
||
*/
|
||
/**
|
||
* The zizmor version actually doing the auditing, or "unknown".
|
||
*
|
||
* Emitted next to the count because the two must be read together. The GitHub runner measured
|
||
* 1 finding MORE than the devbox on the identical commit (190 vs 189) during the v3.8.49 cycle,
|
||
* which cost a second rebaseline push: CI installed whatever PyPI served that day while the
|
||
* devbox had an older build. A count without the version that produced it is not a
|
||
* reproducible number, and rebaselining against it just moves the disagreement.
|
||
*/
|
||
export function zizmorVersion() {
|
||
try {
|
||
return execFileSync("zizmor", ["--version"], { encoding: "utf8" }).trim() || "unknown";
|
||
} catch {
|
||
return "unknown";
|
||
}
|
||
}
|
||
|
||
export function runZizmor(workflowsDir) {
|
||
const args = ["--format", "json"];
|
||
if (fs.existsSync(ZIZMOR_CONFIG)) {
|
||
args.push("--config", ZIZMOR_CONFIG);
|
||
}
|
||
args.push(workflowsDir);
|
||
|
||
try {
|
||
const stdout = execFileSync("zizmor", args, {
|
||
encoding: "utf8",
|
||
maxBuffer: 4 * 1024 * 1024,
|
||
});
|
||
return { ...parseZizmorOutput(stdout), skipped: false };
|
||
} catch (err) {
|
||
const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || "";
|
||
return { ...parseZizmorOutput(String(stdout)), skipped: false };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Hard rule (not a lint count): `--provenance` inside a job that runs on a
|
||
* self-hosted runner. npm answers 422 at the registry, and in v3.8.50 that
|
||
* answer only came after the tag, the GitHub Release and the Docker images were
|
||
* already out. Blocks under --strict AND --ratchet (the CI mode); plain mode
|
||
* reports it like everything else.
|
||
* @param {string[]} files absolute workflow paths
|
||
*/
|
||
export function runProvenanceRunnerCheck(files) {
|
||
const findings = [];
|
||
for (const file of files) {
|
||
const text = fs.readFileSync(file, "utf8");
|
||
findings.push(...findProvenanceOnSelfHosted(text, path.relative(ROOT, file)));
|
||
}
|
||
return findings;
|
||
}
|
||
|
||
function main() {
|
||
const hasActionlint = isBinaryAvailable("actionlint");
|
||
const hasZizmor = isBinaryAvailable("zizmor");
|
||
|
||
if (!hasActionlint && !hasZizmor) {
|
||
console.log(
|
||
"[check-workflows] SKIP — actionlint and zizmor not found in PATH.\n" +
|
||
" Install them to enable workflow linting and security audit:\n" +
|
||
" • actionlint: https://github.com/rhysd/actionlint\n" +
|
||
" • zizmor: https://github.com/woodruffw/zizmor\n" +
|
||
" Graceful SKIP — exits 0 even with --ratchet (missing binaries never block)."
|
||
);
|
||
process.stdout.write("workflowFindings=SKIP\n");
|
||
process.exit(0);
|
||
}
|
||
|
||
const workflowFiles = collectWorkflowFiles(WORKFLOWS_DIR);
|
||
|
||
if (workflowFiles.length === 0) {
|
||
if (!QUIET) {
|
||
console.log(
|
||
`[check-workflows] No workflow files found in ${WORKFLOWS_DIR} — nothing to check.`
|
||
);
|
||
}
|
||
process.stdout.write("workflowFindings=0\nactionlintFindings=0\nzizmorFindings=0\n");
|
||
process.exit(0);
|
||
}
|
||
|
||
if (!QUIET) {
|
||
console.log(`[check-workflows] Found ${workflowFiles.length} workflow file(s) to check.`);
|
||
}
|
||
|
||
let actionlintCount = 0;
|
||
let zizmorCount = 0;
|
||
|
||
// ── actionlint ────────────────────────────────────────────────────────────
|
||
if (hasActionlint) {
|
||
if (!QUIET) {
|
||
process.stderr.write("[check-workflows] Running actionlint …\n");
|
||
}
|
||
const result = runActionlint(workflowFiles);
|
||
actionlintCount = result.count;
|
||
|
||
if (result.count > 0 && !QUIET) {
|
||
console.error(`[check-workflows] actionlint: ${result.count} finding(s):`);
|
||
result.lines.forEach((l) => console.error(` ${l}`));
|
||
} else if (!QUIET) {
|
||
console.log(`[check-workflows] actionlint: OK (0 findings)`);
|
||
}
|
||
} else {
|
||
if (!QUIET) {
|
||
console.log("[check-workflows] actionlint: SKIP (not in PATH)");
|
||
}
|
||
}
|
||
|
||
// ── zizmor ────────────────────────────────────────────────────────────────
|
||
if (hasZizmor) {
|
||
if (!QUIET) {
|
||
process.stderr.write("[check-workflows] Running zizmor …\n");
|
||
}
|
||
const result = runZizmor(WORKFLOWS_DIR);
|
||
zizmorCount = result.count;
|
||
|
||
if (result.count > 0 && !QUIET) {
|
||
console.error(`[check-workflows] zizmor: ${result.count} finding(s).`);
|
||
console.error(" Run: zizmor --format text .github/workflows/ for human-readable details.");
|
||
} else if (!QUIET) {
|
||
console.log(`[check-workflows] zizmor: OK (0 findings)`);
|
||
}
|
||
} else {
|
||
if (!QUIET) {
|
||
console.log("[check-workflows] zizmor: SKIP (not in PATH)");
|
||
}
|
||
}
|
||
|
||
const provenanceFindings = runProvenanceRunnerCheck(workflowFiles);
|
||
if (provenanceFindings.length > 0) {
|
||
console.error(
|
||
`[check-workflows] provenance×self-hosted: ${provenanceFindings.length} finding(s) — HARD RULE:`
|
||
);
|
||
provenanceFindings.forEach((f) => console.error(` ${formatProvenanceFinding(f)}`));
|
||
} else if (!QUIET) {
|
||
console.log("[check-workflows] provenance×self-hosted: OK (0 findings)");
|
||
}
|
||
|
||
const total = actionlintCount + zizmorCount;
|
||
process.stdout.write(`workflowFindings=${total}\n`);
|
||
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
|
||
process.stdout.write(`zizmorFindings=${zizmorCount}\n`);
|
||
// Read this line with the count above: a finding total is only reproducible against the
|
||
// version that produced it. See zizmorVersion().
|
||
process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`);
|
||
process.stdout.write(`provenanceRunnerFindings=${provenanceFindings.length}\n`);
|
||
if ((STRICT || RATCHET) && provenanceFindings.length > 0) {
|
||
console.error(
|
||
`\n[check-workflows] FAIL — ${provenanceFindings.length} job(s) publish with --provenance from a self-hosted runner.\n` +
|
||
" npm rejects that with 422 at the registry. Move the upload step to a github-hosted job\n" +
|
||
" (see .github/workflows/npm-publish.yml `stage-npm` for the pattern)."
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
if (STRICT && total > 0) {
|
||
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// ── ratchet (zizmorFindings only, direction:down) ──────────────────────────
|
||
// We can only ratchet zizmor when zizmor actually RAN (binary present). If
|
||
// zizmor is absent we have no comparable measurement → graceful SKIP (exit 0):
|
||
// a missing binary must never block, only a measured regression does.
|
||
if (RATCHET) {
|
||
if (!hasZizmor) {
|
||
if (!QUIET) {
|
||
process.stderr.write(
|
||
"[check-workflows] --ratchet: zizmor absent — SKIP (no measurement, never blocks).\n"
|
||
);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
const baselineValue = readBaselineZizmorValue(BASELINE_PATH);
|
||
if (baselineValue === null) {
|
||
if (!QUIET) {
|
||
process.stderr.write(
|
||
"[check-workflows] --ratchet: baseline absent (metrics.zizmorFindings) — SKIP, exit 0.\n"
|
||
);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
const { regressed } = evaluateZizmorRatchet(zizmorCount, baselineValue);
|
||
if (regressed) {
|
||
console.error(
|
||
`\n[check-workflows] REGRESSION — ${zizmorCount} zizmor finding(s) > baseline ${baselineValue}.\n` +
|
||
" → Fix the new workflow finding(s), or re-baseline metrics.zizmorFindings in\n" +
|
||
" config/quality/quality-baseline.json if the rise is a legitimate, justified drift.\n" +
|
||
" (actionlint findings are reported, not ratcheted — use --strict for those.)"
|
||
);
|
||
process.exit(1);
|
||
}
|
||
if (!QUIET) {
|
||
process.stderr.write(
|
||
`[check-workflows] --ratchet OK — ${zizmorCount} zizmor finding(s), baseline ${baselineValue} (no regression).\n`
|
||
);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
if (total > 0 && !QUIET) {
|
||
console.log(
|
||
`[check-workflows] ADVISORY — ${total} finding(s) detected. ` +
|
||
"Pass --strict to block on any finding, or --ratchet to block on a zizmor regression."
|
||
);
|
||
}
|
||
|
||
process.exit(0);
|
||
}
|
||
|
||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|