diff --git a/.github/actions-allowlist.json b/.github/actions-allowlist.json new file mode 100644 index 0000000..7c849ad --- /dev/null +++ b/.github/actions-allowlist.json @@ -0,0 +1,20 @@ +{ + "description": "Reviewed GitHub Actions allowlist. Every external action used by .github/workflows must appear here and must be referenced by the full-length commit SHA listed for it. Update a SHA only after reviewing the upstream diff. See docs/CI.md.", + "actions": { + "actions/checkout": { + "11d5960a326750d5838078e36cf38b85af677262": "v4.4.0" + }, + "actions/setup-node": { + "49933ea5288caeca8642d1e84afbd3f7d6820020": "v4.4.0" + }, + "pnpm/action-setup": { + "b906affcce14559ad1aafd4ab0e942779e9f58b1": "v4.3.0" + }, + "dtolnay/rust-toolchain": { + "b725eda525362e7cf34af9a7243cac1e3f01a7ec": "stable" + }, + "Swatinem/rust-cache": { + "d1527c8f67ef9af357290475d4af4b0fab8f12e0": "v2.9.3" + } + } +} diff --git a/.github/workflows/actions-policy.yml b/.github/workflows/actions-policy.yml new file mode 100644 index 0000000..d2008df --- /dev/null +++ b/.github/workflows/actions-policy.yml @@ -0,0 +1,43 @@ +name: Actions policy + +on: + push: + branches: [main] + paths: + - '.github/workflows/**' + - '.github/actions-allowlist.json' + - 'scripts/check-actions-policy.mjs' + - 'scripts/check-actions-policy.test.mjs' + - 'package.json' + pull_request: + paths: + - '.github/workflows/**' + - '.github/actions-allowlist.json' + - 'scripts/check-actions-policy.mjs' + - 'scripts/check-actions-policy.test.mjs' + - 'package.json' + +permissions: + # This workflow only reads the repository: it checks the pinning policy. + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + policy: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Test the Actions policy checker + run: node --test scripts/check-actions-policy.test.mjs + + - name: Verify SHA pins and the reviewed allowlist + run: node scripts/check-actions-policy.mjs diff --git a/.github/workflows/bindings-check.yml b/.github/workflows/bindings-check.yml index 3136666..c2df382 100644 --- a/.github/workflows/bindings-check.yml +++ b/.github/workflows/bindings-check.yml @@ -19,17 +19,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Assert pinned Stellar CLI workflow run: node --test scripts/check-bindings-cli.test.mjs - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: + toolchain: stable targets: wasm32v1-none - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: "contracts/round" @@ -44,11 +45,11 @@ jobs: - name: Print Stellar CLI version run: stellar --version - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 10.13.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: pnpm diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 7fc7475..bd15cfc 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -19,16 +19,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: "contracts/round" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1ea4a3d..4edd094 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 10.13.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: pnpm diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml index 66f27f4..d79fe91 100644 --- a/.github/workflows/docs-links.yml +++ b/.github/workflows/docs-links.yml @@ -22,8 +22,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' - name: Check local markdown links diff --git a/.github/workflows/env-examples.yml b/.github/workflows/env-examples.yml index 16d63a7..c382f39 100644 --- a/.github/workflows/env-examples.yml +++ b/.github/workflows/env-examples.yml @@ -26,8 +26,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '22' - name: Test environment template validation diff --git a/.github/workflows/line-lengths.yml b/.github/workflows/line-lengths.yml index 5aa61bb..32b7859 100644 --- a/.github/workflows/line-lengths.yml +++ b/.github/workflows/line-lengths.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 10.13.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: pnpm diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 2f6e752..23c9243 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 10.13.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: pnpm diff --git a/docs/CI.md b/docs/CI.md index 4a3af75..8c4eec1 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,3 +83,75 @@ print order are asserted by: ```bash node --test scripts/check-bindings-cli.test.mjs ``` + +## GitHub Actions supply-chain policy + +Workflow changes are reviewed as code, and the repository enforces three rules +on every workflow under `.github/workflows/`: + +1. **Full-length SHA pins.** Every external `uses:` must reference a + 40-character lowercase commit SHA. Tags and branches (`@v4`, `@stable`) are + rejected because they are mutable. +2. **Reviewed allowlist.** A pinned action must be listed, with that exact SHA, + in `.github/actions-allowlist.json`. A pin the allowlist does not know about + fails even when the action itself is trusted, and an allowlist entry no + workflow uses fails as well, so the allowlist cannot silently widen. +3. **Read-only tokens.** Only `read` (or `none`) entries are accepted in a + `permissions:` block. Adding `write` scopes requires a reviewed change to the + checker itself. + +Local composite actions (`uses: ./...`) are part of this repository and are +reviewed directly. `docker://` actions are rejected because they cannot be +pinned by commit SHA. + +### Run locally + +```bash +pnpm actions:test # tests for the policy checker +pnpm actions:check # verify every workflow pin and the allowlist +``` + +`Actions policy` (`.github/workflows/actions-policy.yml`) runs both commands on +pull requests and pushes that touch workflows, the allowlist, or the checker. + +### Reviewed allowlist + +| Action | Pinned SHA | Upstream ref | +| --- | --- | --- | +| `actions/checkout` | `11d5960a326750d5838078e36cf38b85af677262` | `v4.4.0` | +| `actions/setup-node` | `49933ea5288caeca8642d1e84afbd3f7d6820020` | `v4.4.0` | +| `pnpm/action-setup` | `b906affcce14559ad1aafd4ab0e942779e9f58b1` | `v4.3.0` | +| `dtolnay/rust-toolchain` | `b725eda525362e7cf34af9a7243cac1e3f01a7ec` | `stable` | +| `Swatinem/rust-cache` | `d1527c8f67ef9af357290475d4af4b0fab8f12e0` | `v2.9.3` | + +`dtolnay/rust-toolchain` derives the toolchain from the ref it is invoked with. +Once pinned to a SHA that no longer holds a toolchain name, pass it explicitly: + +```yaml +- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: stable +``` + +### Updating a pin + +1. Review the upstream diff between the pinned SHA and the new release. +2. Update the `uses:` line and keep the human-readable version in the trailing + comment (`# v4.4.0`). +3. Update the matching SHA in `.github/actions-allowlist.json`. +4. Run `pnpm actions:check` and open the PR with the upstream diff link. + +### Repository settings + +The checker backs the same guarantees at review time, but the authoritative +control is a repository setting that only maintainers can change. In +**Settings → Actions → General**: + +- **Actions permissions:** *Allow select actions and reusable workflows*. +- Allow **GitHub-owned actions**, **verified Marketplace actions**, and the + third-party actions in the table above (`pnpm/action-setup`, + `dtolnay/rust-toolchain`, `Swatinem/rust-cache`). Nothing else is needed by + the current workflows. +- **Require actions to be pinned to a full-length commit SHA:** enabled. + +Settings are not stored in git, so record any change here when it happens. diff --git a/package.json b/package.json index 66e5b7c..7200523 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,8 @@ "errors:normalize:check": "node scripts/check-error-normalization.mjs", "errors:normalize:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-error-normalization.test.mjs", "line-lengths:check": "node scripts/check-line-lengths.mjs", - "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs" + "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs", + "actions:check": "node scripts/check-actions-policy.mjs", + "actions:test": "node --test scripts/check-actions-policy.test.mjs" } } diff --git a/scripts/check-actions-policy.mjs b/scripts/check-actions-policy.mjs new file mode 100644 index 0000000..1222259 --- /dev/null +++ b/scripts/check-actions-policy.mjs @@ -0,0 +1,253 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const WORKFLOW_EXTENSIONS = new Set(['.yml', '.yaml']); +const FULL_SHA = /^[0-9a-f]{40}$/; +const ACTION_ID = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const USES_LINE = /^\s*(?:-\s+)?uses\s*:\s*(\S+)\s*(?:#\s*(.*))?$/; +const PERMISSIONS_LINE = /^(\s*)permissions\s*:\s*(.*)$/; +const PERMISSION_ENTRY = /^\s*([A-Za-z0-9_-]+)\s*:\s*(\S+)/; + +/** + * Load and validate .github/actions-allowlist.json. + * + * The file maps each reviewed action to the full-length commit SHAs this + * repository is allowed to run, labeled with the upstream release they come + * from so reviewers can see what a pin means. + */ +function loadAllowlist(file) { + const errors = []; + const allowed = new Map(); + + if (!existsSync(file)) { + return { allowed, errors: [`${file}: allowlist file is missing`] }; + } + + let data; + try { + data = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + return { allowed, errors: [`${file}: invalid JSON (${error.message})`] }; + } + + const actions = data?.actions; + if (!actions || typeof actions !== 'object' || Array.isArray(actions)) { + return { allowed, errors: [`${file}: expected an "actions" object`] }; + } + + for (const [action, pins] of Object.entries(actions)) { + if (!ACTION_ID.test(action)) { + errors.push(`${file}: "${action}" is not an owner/repo action id`); + continue; + } + + const key = action.toLowerCase(); + if (allowed.has(key)) { + errors.push(`${file}: "${action}" is listed more than once`); + continue; + } + + if (!pins || typeof pins !== 'object' || Array.isArray(pins) || Object.keys(pins).length === 0) { + errors.push(`${file}: "${action}" must list at least one pinned commit SHA`); + continue; + } + + const bySha = new Map(); + for (const [sha, version] of Object.entries(pins)) { + if (!FULL_SHA.test(sha)) { + errors.push(`${file}: "${action}" pin "${sha}" is not a full-length lowercase commit SHA`); + continue; + } + if (typeof version !== 'string' || version.trim() === '') { + errors.push(`${file}: "${action}@${sha}" needs a reviewed version label (for example "v4.4.0")`); + } + bySha.set(sha, version); + } + + allowed.set(key, { action, pins: bySha }); + } + + return { allowed, errors }; +} + +/** Return the workflow files under .github/workflows, sorted by name. */ +function listWorkflowFiles(root) { + const dir = join(root, '.github', 'workflows'); + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + return { files: [], error: `${dir}: workflows directory is missing` }; + } + + const files = readdirSync(dir) + .filter((name) => WORKFLOW_EXTENSIONS.has(extname(name).toLowerCase())) + .filter((name) => statSync(join(dir, name)).isFile()) + .sort(); + + return { files: files.map((name) => join(dir, name)), error: null }; +} + +/** Flag any workflow permission that is not read-only. */ +function checkPermissions(lines, file, errors) { + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const match = line.match(PERMISSIONS_LINE); + if (!match) continue; + + const indent = match[1].length; + const inline = match[2].trim(); + const isBlock = inline === '' || inline.startsWith('#'); + + if (!isBlock) { + if (inline.includes('write')) { + errors.push(`${file}:${index + 1}: permissions must stay read-only (found "${inline}")`); + } + continue; + } + + // Walk the block children: anything indented deeper than `permissions:`. + for (let child = index + 1; child < lines.length; child += 1) { + const text = lines[child]; + if (text.trim() === '' || text.trim().startsWith('#')) continue; + + const childIndent = text.match(/^\s*/)[0].length; + if (childIndent <= indent) break; + + const entry = text.match(PERMISSION_ENTRY); + if (entry && entry[2] === 'write') { + errors.push(`${file}:${child + 1}: "${entry[1]}: write" broadens workflow token permissions`); + } + } + } +} + +/** Verify every `uses:` in a workflow is an allowlisted, SHA-pinned action. */ +function checkWorkflow(file, root, allowed, used, errors) { + const relPath = relative(root, file).split('\\').join('/'); + const lines = readFileSync(file, 'utf8').split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(USES_LINE); + if (!match) continue; + + const reference = match[1]; + const location = `${relPath}:${index + 1}`; + + // Local composite actions live in this repository and are reviewed here. + if (reference.startsWith('./')) continue; + + if (reference.startsWith('docker://')) { + errors.push(`${location}: "${reference}" cannot be pinned by commit SHA; use a reviewed action instead`); + continue; + } + + const separator = reference.lastIndexOf('@'); + if (separator === -1) { + errors.push(`${location}: "${reference}" has no ref; pin it as owner/repo@<40-character SHA>`); + continue; + } + + const action = reference.slice(0, separator); + const ref = reference.slice(separator + 1); + + if (!ACTION_ID.test(action)) { + errors.push(`${location}: "${action}" is not an owner/repo action id`); + continue; + } + + if (!FULL_SHA.test(ref)) { + const hint = /^v?\d/.test(ref) ? ` (tag "${ref}" is mutable)` : ''; + errors.push(`${location}: "${reference}" is not pinned to a full-length commit SHA${hint}`); + continue; + } + + const entry = allowed.get(action.toLowerCase()); + if (!entry) { + errors.push(`${location}: action "${action}" is not in .github/actions-allowlist.json`); + continue; + } + + if (!entry.pins.has(ref)) { + const known = [...entry.pins.keys()].join(', '); + errors.push(`${location}: "${action}@${ref}" is not an allowlisted pin (reviewed: ${known})`); + continue; + } + + used.add(`${action.toLowerCase()}@${ref}`); + } + + checkPermissions(lines, relPath, errors); +} + +/** Run every check against a repository root and return the violations found. */ +export function checkRepository(root) { + const errors = []; + const { allowed, errors: allowlistErrors } = loadAllowlist( + join(root, '.github', 'actions-allowlist.json'), + ); + errors.push(...allowlistErrors); + + const { files, error } = listWorkflowFiles(root); + if (error) errors.push(error); + if (files.length === 0 && !error) { + errors.push(`${join(root, '.github', 'workflows')}: no workflow files found`); + } + + const used = new Set(); + for (const file of files) { + checkWorkflow(file, root, allowed, used, errors); + } + + // An unused entry silently widens the allowlist beyond what is reviewed. + for (const [key, entry] of allowed) { + for (const [sha, version] of entry.pins) { + if (!used.has(`${key}@${sha}`)) { + errors.push( + `.github/actions-allowlist.json: unused entry "${entry.action}@${sha}" (${version}); remove it or reference it`, + ); + } + } + } + + return { errors, workflowCount: files.length, actionCount: used.size }; +} + +function parseArgs(argv) { + let root = DEFAULT_ROOT; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--root' && index + 1 < argv.length) { + root = resolve(argv[index + 1]); + index += 1; + } else { + console.error(`Unknown argument: ${argv[index]}`); + process.exitCode = 2; + return null; + } + } + return { root }; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args) return; + + const { errors, workflowCount, actionCount } = checkRepository(args.root); + + if (errors.length > 0) { + for (const error of errors) console.error(`❌ ${error}`); + console.error( + `❌ ${errors.length} GitHub Actions policy violation(s). Pin every action to an allowlisted full-length SHA in .github/actions-allowlist.json.`, + ); + process.exitCode = 1; + return; + } + + console.log( + `✅ ${actionCount} allowlisted action pin(s) across ${workflowCount} workflow(s) verified.`, + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/check-actions-policy.test.mjs b/scripts/check-actions-policy.test.mjs new file mode 100644 index 0000000..1af4093 --- /dev/null +++ b/scripts/check-actions-policy.test.mjs @@ -0,0 +1,263 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const CHECKER = fileURLToPath(new URL('check-actions-policy.mjs', import.meta.url)); +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const CHECKOUT_SHA = '11d5960a326750d5838078e36cf38b85af677262'; + +/** Run the checker against a root and report output without throwing on failure. */ +function runChecker(root) { + try { + const stdout = execFileSync(process.execPath, [CHECKER, '--root', root], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { code: error.status, stdout: error.stdout ?? '', stderr: error.stderr ?? '' }; + } +} + +/** Write a throwaway repository with workflow files and an allowlist. */ +function createProject({ workflows, allowlist }) { + const root = mkdtempSync(join(tmpdir(), 'actions-policy-')); + for (const [path, content] of Object.entries(workflows)) { + const fullPath = join(root, '.github', 'workflows', path); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, content); + } + if (allowlist !== null) { + mkdirSync(join(root, '.github'), { recursive: true }); + writeFileSync( + join(root, '.github', 'actions-allowlist.json'), + typeof allowlist === 'string' ? allowlist : JSON.stringify(allowlist, null, 2), + ); + } + return root; +} + +function defaultAllowlist(pins = { [CHECKOUT_SHA]: 'v4.4.0' }) { + return { actions: { 'actions/checkout': pins } }; +} + +function workflowStep(reference, extra = '') { + return `name: Test\non: push\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: ${reference}\n${extra}`; +} + +function withProject(options, assertions) { + const root = createProject(options); + try { + assertions(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe('check-actions-policy', () => { + it('passes on the real repository', () => { + const { code, stdout } = runChecker(REPO_ROOT); + assert.equal(code, 0, 'repository workflows must satisfy the policy'); + assert.match(stdout, /allowlisted action pin\(s\) across \d+ workflow\(s\) verified/); + }); + + it('accepts an allowlisted action pinned to a full-length SHA with a version comment', () => { + withProject( + { + workflows: { 'ci.yml': workflowStep(`actions/checkout@${CHECKOUT_SHA} # v4.4.0`) }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stdout } = runChecker(root); + assert.equal(code, 0); + assert.match(stdout, /1 allowlisted action pin\(s\) across 1 workflow\(s\)/); + }, + ); + }); + + it('rejects a mutable major-version tag', () => { + withProject( + { workflows: { 'ci.yml': workflowStep('actions/checkout@v4') }, allowlist: defaultAllowlist() }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /ci\.yml:9:.*not pinned to a full-length commit SHA/); + }, + ); + }); + + it('rejects an abbreviated commit SHA', () => { + withProject( + { + workflows: { 'ci.yml': workflowStep('actions/checkout@11d5960') }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /not pinned to a full-length commit SHA/); + }, + ); + }); + + it('rejects an action missing from the allowlist', () => { + withProject( + { + workflows: { + 'ci.yml': workflowStep('untrusted/action@0123456789abcdef0123456789abcdef01234567'), + }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /"untrusted\/action" is not in \.github\/actions-allowlist\.json/); + }, + ); + }); + + it('rejects a SHA that is pinned but not reviewed for an allowlisted action', () => { + withProject( + { + workflows: { + 'ci.yml': workflowStep('actions/checkout@0123456789abcdef0123456789abcdef01234567'), + }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /is not an allowlisted pin \(reviewed: 11d5960/); + }, + ); + }); + + it('rejects allowlist entries that no workflow uses', () => { + withProject( + { + workflows: { 'ci.yml': workflowStep(`actions/checkout@${CHECKOUT_SHA}`) }, + allowlist: { + actions: { + 'actions/checkout': { [CHECKOUT_SHA]: 'v4.4.0' }, + 'actions/setup-node': { '49933ea5288caeca8642d1e84afbd3f7d6820020': 'v4.4.0' }, + }, + }, + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /unused entry "actions\/setup-node@49933ea/); + }, + ); + }); + + it('allows local composite actions and ignores commented-out entries', () => { + withProject( + { + workflows: { + 'ci.yml': `${workflowStep(`actions/checkout@${CHECKOUT_SHA}`)}\n # - uses: actions/checkout@v4\n - uses: ./.github/actions/local\n`, + }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.equal(code, 0, stderr); + }, + ); + }); + + it('rejects docker:// actions that cannot be SHA pinned', () => { + withProject( + { + workflows: { 'ci.yml': workflowStep('docker://alpine:3.20') }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /cannot be pinned by commit SHA/); + }, + ); + }); + + it('rejects write permissions at workflow and job level', () => { + withProject( + { + workflows: { + 'ci.yml': `name: Test\non: push\npermissions:\n contents: write\njobs:\n test:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@${CHECKOUT_SHA}\n`, + }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /ci\.yml:4: "contents: write" broadens workflow token permissions/); + assert.match( + stderr, + /ci\.yml:9: "pull-requests: write" broadens workflow token permissions/, + ); + }, + ); + }); + + it('scans .yaml workflow files as well as .yml', () => { + withProject( + { + workflows: { 'ci.yaml': workflowStep(`actions/checkout@${CHECKOUT_SHA}`) }, + allowlist: defaultAllowlist(), + }, + (root) => { + const { code, stdout } = runChecker(root); + assert.equal(code, 0); + assert.match(stdout, /across 1 workflow\(s\)/); + }, + ); + }); + + it('keeps the documented allowlist in sync with the reviewed allowlist', () => { + const allowlist = JSON.parse( + readFileSync(join(REPO_ROOT, '.github', 'actions-allowlist.json'), 'utf8'), + ); + const docs = readFileSync(join(REPO_ROOT, 'docs', 'CI.md'), 'utf8'); + + for (const [action, pins] of Object.entries(allowlist.actions)) { + for (const [sha, version] of Object.entries(pins)) { + assert.ok(docs.includes(action), `docs/CI.md must list ${action}`); + assert.ok(docs.includes(sha), `docs/CI.md must list the ${action} pin ${sha}`); + assert.ok(docs.includes(version), `docs/CI.md must list the ${action} version ${version}`); + } + } + }); + + it('fails when the allowlist is missing or malformed', () => { + withProject( + { + workflows: { 'ci.yml': workflowStep(`actions/checkout@${CHECKOUT_SHA}`) }, + allowlist: null, + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /allowlist file is missing/); + }, + ); + + withProject( + { + workflows: { 'ci.yml': workflowStep(`actions/checkout@${CHECKOUT_SHA}`) }, + allowlist: '{ "actions": ', + }, + (root) => { + const { code, stderr } = runChecker(root); + assert.notEqual(code, 0); + assert.match(stderr, /invalid JSON/); + }, + ); + }); +});