#!/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 (0 findings each) // 1 — one or more findings (gate failure; advisory mode = always 0; see --advisory) // // 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 --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 STRICT = process.argv.includes("--strict"); 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: [] }; } } // --------------------------------------------------------------------------- // 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" + " This gate is advisory — the build is not blocked." ); 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); } if (total > 0 && !QUIET) { console.log( `[check-workflows] ADVISORY — ${total} finding(s) detected. ` + "Pass --strict to block the gate." ); } process.exit(0); } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();