#!/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= — total findings from both tools combined // actionlintFindings= — findings from actionlint alone // zizmorFindings= — 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"; 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: * ::: [] * 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 }} */ 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 // --------------------------------------------------------------------------- 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 total = actionlintCount + zizmorCount; process.stdout.write(`workflowFindings=${total}\n`); process.stdout.write(`actionlintFindings=${actionlintCount}\n`); process.stdout.write(`zizmorFindings=${zizmorCount}\n`); 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();