File size: 14,886 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
#!/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";

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 }}
 */
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();