diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7aa2e98..265f51e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,9 @@ jobs: echo "All dependencies are up to date" fi + - name: Check for outdated GitHub Actions + run: deno run --allow-read --allow-net=api.github.com --allow-env=GITHUB_STEP_SUMMARY --allow-write scripts/audit_actions.ts + skill-review: name: Skill Review needs: [changes] diff --git a/deno.json b/deno.json index 817f1ebc..4527c969 100644 --- a/deno.json +++ b/deno.json @@ -12,6 +12,7 @@ "compile": "deno run -A scripts/compile.ts", "license-headers": "deno run --allow-read --allow-write scripts/add_license_headers.ts", "audit": "deno run --allow-read --allow-net=api.osv.dev scripts/audit_deps.ts && deno outdated", + "audit-actions": "deno run --allow-read --allow-net=api.github.com --allow-env=GITHUB_STEP_SUMMARY --allow-write scripts/audit_actions.ts", "review-skills": "deno run --allow-read --allow-run --allow-env=GITHUB_STEP_SUMMARY --allow-write scripts/review_skills.ts", "eval-skill-triggers": "deno run --allow-read --allow-run --allow-env --allow-write scripts/eval_skill_triggers.ts" }, diff --git a/scripts/audit_actions.ts b/scripts/audit_actions.ts new file mode 100644 index 00000000..bc939b5f --- /dev/null +++ b/scripts/audit_actions.ts @@ -0,0 +1,365 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +/** + * GitHub Actions audit script that checks workflow files for outdated actions + * and unpinned references using the GitHub API. + * + * Reports outdated actions as warnings and unpinned third-party actions + * (not using a commit SHA) as errors. + * + * Usage: deno run audit-actions + * + * Exit codes: + * 0 - Always (this is a warning-only check, never fails the build) + */ + +import { parse as parseYaml } from "@std/yaml"; + +interface ActionRef { + file: string; + action: string; + owner: string; + repo: string; + ref: string; + isShaPin: boolean; + isTrustedPublisher: boolean; +} + +interface TagInfo { + latest: string; + latestSha: string; +} + +interface AuditFinding { + actionRef: ActionRef; + kind: "unpinned" | "outdated"; + detail: string; +} + +/** Publishers whose actions are acceptable with tag-only pins. */ +const TRUSTED_PUBLISHERS = new Set([ + "actions", + "anthropics", + "denoland", + "github", +]); + +const SHA_PATTERN = /^[0-9a-f]{40}$/; + +function extractActionRefs( + filePath: string, + content: string, +): ActionRef[] { + const refs: ActionRef[] = []; + + // deno-lint-ignore no-explicit-any + let workflow: any; + try { + workflow = parseYaml(content); + } catch { + console.warn(`Warning: could not parse ${filePath}, skipping`); + return refs; + } + + if (!workflow || typeof workflow !== "object" || !workflow.jobs) return refs; + + for (const job of Object.values(workflow.jobs)) { + // deno-lint-ignore no-explicit-any + const steps = (job as any)?.steps; + if (!Array.isArray(steps)) continue; + + for (const step of steps) { + const uses = step?.uses; + if (typeof uses !== "string") continue; + + // Skip Docker and local actions + if (uses.startsWith("docker://") || uses.startsWith("./")) continue; + + // Parse owner/repo@ref + const atIndex = uses.indexOf("@"); + if (atIndex === -1) continue; + + const actionPath = uses.substring(0, atIndex); + const ref = uses.substring(atIndex + 1); + const slashIndex = actionPath.indexOf("/"); + if (slashIndex === -1) continue; + + const owner = actionPath.substring(0, slashIndex); + const repo = actionPath.substring(slashIndex + 1); + + refs.push({ + file: filePath, + action: `${owner}/${repo}`, + owner, + repo, + ref, + isShaPin: SHA_PATTERN.test(ref), + isTrustedPublisher: TRUSTED_PUBLISHERS.has(owner), + }); + } + } + + return refs; +} + +async function getLatestTag( + owner: string, + repo: string, +): Promise { + // Try releases first, fall back to tags + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases/latest`, + { + headers: { + "Accept": "application/vnd.github+json", + "User-Agent": "swamp-audit-actions", + }, + }, + ); + + if (response.ok) { + const release = await response.json(); + const tagName = release.tag_name; + + // Get the SHA for this tag + const tagResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/ref/tags/${tagName}`, + { + headers: { + "Accept": "application/vnd.github+json", + "User-Agent": "swamp-audit-actions", + }, + }, + ); + + if (tagResponse.ok) { + const tagData = await tagResponse.json(); + return { latest: tagName, latestSha: tagData.object.sha }; + } + + return { latest: tagName, latestSha: "" }; + } + } catch { + // Fall through to tags API + } + + // Fallback: list tags sorted by creation + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/tags?per_page=1`, + { + headers: { + "Accept": "application/vnd.github+json", + "User-Agent": "swamp-audit-actions", + }, + }, + ); + + if (response.ok) { + const tags = await response.json(); + if (tags.length > 0) { + return { latest: tags[0].name, latestSha: tags[0].commit.sha }; + } + } + } catch { + // Couldn't reach API + } + + return null; +} + +/** Check if a tag ref (e.g., "v3") is a major version prefix of the latest tag. */ +function isSameMajor(currentRef: string, latestTag: string): boolean { + // Strip 'v' prefix for comparison + const current = currentRef.replace(/^v/, ""); + const latest = latestTag.replace(/^v/, ""); + + const currentMajor = current.split(".")[0]; + const latestMajor = latest.split(".")[0]; + + return currentMajor === latestMajor; +} + +async function writeGitHubSummary(findings: AuditFinding[]): Promise { + const summaryFile = Deno.env.get("GITHUB_STEP_SUMMARY"); + if (!summaryFile) return; + + const lines: string[] = ["## GitHub Actions Audit Results\n"]; + + const unpinned = findings.filter((f) => f.kind === "unpinned"); + const outdated = findings.filter((f) => f.kind === "outdated"); + + if (unpinned.length > 0) { + lines.push("### Unpinned Third-Party Actions\n"); + lines.push( + "These actions are not pinned to a commit SHA and could be modified without your knowledge.\n", + ); + for (const { actionRef, detail } of unpinned) { + lines.push( + `- \`${actionRef.action}@${actionRef.ref}\` in \`${actionRef.file}\`: ${detail}`, + ); + } + lines.push(""); + } + + if (outdated.length > 0) { + lines.push("### Outdated Actions\n"); + lines.push( + "> **Warning**: These actions have newer versions available.\n", + ); + for (const { actionRef, detail } of outdated) { + lines.push( + `- \`${actionRef.action}@${actionRef.ref}\` in \`${actionRef.file}\`: ${detail}`, + ); + } + lines.push(""); + } + + if (findings.length === 0) { + lines.push("All GitHub Actions references are up to date and pinned."); + } + + await Deno.writeTextFile(summaryFile, lines.join("\n")); +} + +async function main(): Promise { + const workflowDir = ".github/workflows"; + const refs: ActionRef[] = []; + + // Collect all action references from workflow files + try { + for await (const entry of Deno.readDir(workflowDir)) { + if ( + !entry.isFile || + (!entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml")) + ) { + continue; + } + + const filePath = `${workflowDir}/${entry.name}`; + const content = await Deno.readTextFile(filePath); + refs.push(...extractActionRefs(filePath, content)); + } + } catch { + console.error(`Error: could not read ${workflowDir}`); + Deno.exit(1); + } + + if (refs.length === 0) { + console.log("No action references found in workflow files."); + Deno.exit(0); + } + + // Deduplicate by action+ref for API calls + const uniqueActions = new Map(); + for (const ref of refs) { + const key = `${ref.action}@${ref.ref}`; + if (!uniqueActions.has(key)) { + uniqueActions.set(key, ref); + } + } + + console.log( + `Checking ${uniqueActions.size} unique action references across ${refs.length} usages…`, + ); + + const findings: AuditFinding[] = []; + + // Check each unique action + for (const [, actionRef] of uniqueActions) { + // Check for unpinned third-party actions + if (!actionRef.isShaPin && !actionRef.isTrustedPublisher) { + findings.push({ + actionRef, + kind: "unpinned", + detail: + `Third-party action not pinned to a commit SHA. Pin to a full SHA for supply chain security.`, + }); + } + + // Check for outdated versions + const tagInfo = await getLatestTag(actionRef.owner, actionRef.repo); + if (tagInfo) { + if (actionRef.isShaPin) { + // SHA-pinned: check if the SHA matches the latest tag's SHA + if ( + tagInfo.latestSha && actionRef.ref !== tagInfo.latestSha + ) { + findings.push({ + actionRef, + kind: "outdated", + detail: `Pinned SHA does not match latest release ${tagInfo.latest}`, + }); + } + } else { + // Tag-pinned: check if using an older major version + if (!isSameMajor(actionRef.ref, tagInfo.latest)) { + findings.push({ + actionRef, + kind: "outdated", + detail: + `Using ${actionRef.ref}, latest is ${tagInfo.latest}`, + }); + } + } + } + } + + // Write GitHub Actions job summary + await writeGitHubSummary(findings); + + const unpinned = findings.filter((f) => f.kind === "unpinned"); + const outdated = findings.filter((f) => f.kind === "outdated"); + + if (findings.length === 0) { + console.log("All action references are up to date and properly pinned."); + Deno.exit(0); + } + + if (unpinned.length > 0) { + console.warn( + `\nUnpinned third-party actions (${unpinned.length}):\n`, + ); + for (const { actionRef, detail } of unpinned) { + console.warn( + ` ${actionRef.action}@${actionRef.ref} (${actionRef.file})`, + ); + console.warn(` ${detail}`); + } + } + + if (outdated.length > 0) { + const label = outdated.length === 1 ? "action" : "actions"; + console.warn( + `\nOutdated ${label} (${outdated.length}):\n`, + ); + for (const { actionRef, detail } of outdated) { + console.warn( + ` ${actionRef.action}@${actionRef.ref} (${actionRef.file})`, + ); + console.warn(` ${detail}`); + } + } + + Deno.exit(0); +} + +main();