From 9ec57626f2f1f88005bf48ea2a04b3251b0dd0ff Mon Sep 17 00:00:00 2001 From: 0thernet Date: Tue, 18 Aug 2026 19:09:43 -0400 Subject: [PATCH] ci: pin workflow actions to commits --- .github/workflows/ci.yml | 15 ++- .github/workflows/nix.yml | 9 +- AGENTS.md | 2 +- CHANGELOG.md | 7 ++ kb/index.md | 7 +- test/workflow-actions.test.ts | 230 ++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 test/workflow-actions.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a9204d8..331176b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test-node: name: Node ${{ matrix.node-version }} (${{ matrix.os }}) @@ -17,9 +20,11 @@ jobs: node-version: ["22", "23"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} @@ -47,9 +52,11 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 0cd5fa87..1afe9858 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -3,6 +3,9 @@ name: Nix on: workflow_call: +permissions: + contents: read + jobs: build-flake: name: Build flake (${{ matrix.os }}) @@ -13,9 +16,11 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - - uses: cachix/install-nix-action@v31 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 with: extra_nix_config: | experimental-features = nix-command flakes diff --git a/AGENTS.md b/AGENTS.md index dad54f40..0b3ff1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,5 +21,5 @@ - Extract a shared package only after two concrete consumers need the same stable interface. Keep shared packages product-neutral and keep indexing, embeddings, ranking, and CLI behavior here. - Freeze shared interfaces before parallel lanes begin. Give manifests, lockfiles, generated files, native compatibility pins, and other convergence surfaces one owner while lanes edit disjoint paths. - Keep QMD-specific skills under `skills/`; `.agents/skills/` contains the portable repository baseline. -- Do not change package manifests or locks for KB tooling. Run `bunx --bun github:hraness/kb#v0.15.1 refresh --root kb`, `bunx --bun github:hraness/kb#v0.15.1 check --root kb`, and `bunx --bun github:hraness/kb#v0.15.1 agents check --root kb --repo .` directly. +- Do not change package manifests or locks for KB tooling. Run `bunx --bun github:hraness/kb#v0.15.2 refresh --root kb`, `bunx --bun github:hraness/kb#v0.15.2 check --root kb`, and `bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .` directly. - Run the documented Node and Bun test paths and package smoke before handing off source changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac90a701..d6134f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Security + +- GitHub Actions workflows now pin every remote action to a reviewed full + commit, run with explicit read-only permissions without retained checkout + credentials, and reject mutable or dynamic action references. Repository KB + checks use the immutable 0.15.2 tool release. + ### Fixed - Embedding generation and legacy fingerprint adoption now tokenize documents diff --git a/kb/index.md b/kb/index.md index 1f3c5559..95f1369f 100644 --- a/kb/index.md +++ b/kb/index.md @@ -28,8 +28,7 @@ Git history is the maintenance log. Do not add generated backlink sections or a Run the pinned KB tools directly without changing this upstream-derived repository's package manifests: ```sh -bunx --bun github:hraness/kb#v0.15.1 refresh --root kb -bunx --bun github:hraness/kb#v0.15.1 check --root kb -bunx --bun github:hraness/kb#v0.15.1 agents check --root kb --repo . +bunx --bun github:hraness/kb#v0.15.2 refresh --root kb +bunx --bun github:hraness/kb#v0.15.2 check --root kb +bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo . ``` - diff --git a/test/workflow-actions.test.ts b/test/workflow-actions.test.ts new file mode 100644 index 00000000..a7f4a61b --- /dev/null +++ b/test/workflow-actions.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from "vitest"; +import { lstatSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseDocument } from "yaml"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const workflowsRoot = join(repoRoot, ".github", "workflows"); +const workflowPathPattern = /^\.\/\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/u; +const commitPattern = /^[0-9a-f]{40}$/u; +const remoteSegmentPattern = /^[A-Za-z0-9_.-]+$/u; + +type JsonObject = Record; + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${label} must be an object.`); + } + return value as JsonObject; +} + +function parseWorkflow(source: string, label: string): JsonObject { + const document = parseDocument(source, { prettyErrors: true, uniqueKeys: true }); + if (document.errors.length > 0) { + throw new TypeError(`${label} is not valid unambiguous YAML: ${document.errors[0]?.message ?? "unknown error"}`); + } + return object(document.toJS(), label); +} + +function assertPinnedRemoteAction(value: string, label: string): void { + const separator = value.lastIndexOf("@"); + if (separator <= 0 || !commitPattern.test(value.slice(separator + 1))) { + throw new TypeError(`${label} must use a full lowercase 40-character commit.`); + } + const segments = value.slice(0, separator).split("/"); + if ( + segments.length < 2 + || segments.some((segment) => + segment === "." || segment === ".." || !remoteSegmentPattern.test(segment)) + ) { + throw new TypeError(`${label} has an invalid remote action path.`); + } +} + +function semanticActionReferences( + workflows: ReadonlyMap, +): readonly string[] { + const references: string[] = []; + for (const [path, source] of workflows) { + const root = parseWorkflow(source, path); + const jobs = object(root.jobs, `${path} jobs`); + for (const [jobName, jobValue] of Object.entries(jobs)) { + const job = object(jobValue, `${path} job ${jobName}`); + const reusable = job.uses; + if (reusable !== undefined) { + if (typeof reusable !== "string") { + throw new TypeError(`${path} job ${jobName} uses must be a string.`); + } + if (reusable.startsWith("./")) { + if (!workflowPathPattern.test(reusable) || !workflows.has(reusable)) { + throw new TypeError(`${path} job ${jobName} references an unknown local workflow.`); + } + } else { + assertPinnedRemoteAction(reusable, `${path} job ${jobName}`); + references.push(reusable); + } + } + + const steps = job.steps; + if (steps === undefined) continue; + if (!Array.isArray(steps)) { + throw new TypeError(`${path} job ${jobName} steps must be an array.`); + } + for (const [stepIndex, stepValue] of steps.entries()) { + const step = object(stepValue, `${path} job ${jobName} step ${String(stepIndex)}`); + const action = step.uses; + if (action === undefined) continue; + if (typeof action !== "string") { + throw new TypeError(`${path} job ${jobName} step ${String(stepIndex)} uses must be a string.`); + } + if (action.startsWith("./")) { + throw new TypeError(`${path} job ${jobName} step ${String(stepIndex)} uses an unreviewed local action.`); + } + assertPinnedRemoteAction(action, `${path} job ${jobName} step ${String(stepIndex)}`); + references.push(action); + } + } + } + if (references.length === 0) { + throw new TypeError("At least one pinned remote action must be present."); + } + return references.toSorted(); +} + +function assertWorkflowCredentialBoundary( + workflows: ReadonlyMap, +): void { + for (const [path, source] of workflows) { + const root = parseWorkflow(source, path); + const permissions = object(root.permissions, `${path} permissions`); + if ( + Object.keys(permissions).length !== 1 + || permissions.contents !== "read" + ) { + throw new TypeError(`${path} must declare only contents: read permissions.`); + } + const jobs = object(root.jobs, `${path} jobs`); + for (const [jobName, jobValue] of Object.entries(jobs)) { + const job = object(jobValue, `${path} job ${jobName}`); + if (job.permissions !== undefined) { + throw new TypeError(`${path} job ${jobName} must not override workflow permissions.`); + } + if (!Array.isArray(job.steps)) continue; + for (const [stepIndex, stepValue] of job.steps.entries()) { + const step = object(stepValue, `${path} job ${jobName} step ${String(stepIndex)}`); + if ( + typeof step.uses !== "string" + || !step.uses.toLowerCase().startsWith("actions/checkout@") + ) { + continue; + } + const inputs = object(step.with, `${path} checkout inputs`); + if (inputs["persist-credentials"] !== false) { + throw new TypeError(`${path} checkout must disable persisted credentials.`); + } + } + } + } +} + +function repositoryWorkflows(): ReadonlyMap { + const workflows = new Map(); + for (const entry of readdirSync(workflowsRoot, { withFileTypes: true })) { + if (!/\.ya?ml$/u.test(entry.name)) continue; + const absolutePath = join(workflowsRoot, entry.name); + const stats = lstatSync(absolutePath); + if ( + !entry.isFile() + || !stats.isFile() + || stats.isSymbolicLink() + || stats.size > 1_048_576 + ) { + throw new TypeError(`${entry.name} must be a bounded regular workflow file.`); + } + workflows.set(`./.github/workflows/${entry.name}`, readFileSync(absolutePath, "utf8")); + } + return workflows; +} + +describe("GitHub workflow action supply chain", () => { + test("pins every semantic remote action reference to a full commit", () => { + const workflows = repositoryWorkflows(); + expect(semanticActionReferences(workflows)).toEqual([ + "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", + "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", + "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", + "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", + "cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24", + "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + ]); + expect(() => assertWorkflowCredentialBoundary(workflows)).not.toThrow(); + }); + + test("rejects mutable, dynamic, and non-string semantic action references", () => { + for (const uses of ["actions/checkout@v4", "actions/checkout@${{ github.ref }}"] as const) { + expect(() => semanticActionReferences(new Map([ + ["./.github/workflows/ci.yml", `jobs:\n test:\n steps:\n - uses: ${uses}\n`], + ]))).toThrow(/full lowercase 40-character commit/u); + } + expect(() => semanticActionReferences(new Map([ + ["./.github/workflows/ci.yml", "jobs:\n test:\n steps:\n - uses: 42\n"], + ]))).toThrow(/uses must be a string/u); + }); + + test("requires every case-equivalent checkout step to discard persisted credentials", () => { + const commit = "1".repeat(40); + const workflow = (uses: string, inputs: string) => new Map([ + [ + "./.github/workflows/ci.yml", + `permissions:\n contents: read\njobs:\n test:\n steps:\n - uses: ${uses}\n${inputs}`, + ], + ]); + + for (const [uses, inputs] of [ + [`actions/checkout@${commit}`, ""], + [`Actions/Checkout@${commit}`, " with:\n persist-credentials: \"false\"\n"], + [`ACTIONS/CHECKOUT@${commit}`, " with:\n fetch-depth: 1\n"], + ] as const) { + expect(() => assertWorkflowCredentialBoundary(workflow(uses, inputs))).toThrow( + /checkout inputs must be an object|disable persisted credentials/u, + ); + } + + expect(() => assertWorkflowCredentialBoundary(workflow( + `Actions/Checkout@${commit}`, + " with:\n persist-credentials: false\n", + ))).not.toThrow(); + }); + + test("checks local reusable workflows but ignores unrelated uses data", () => { + const commit = "1".repeat(40); + const workflows = new Map([ + [ + "./.github/workflows/ci.yml", + [ + "env:", + " uses: unrelated-data", + "jobs:", + " nix:", + " uses: ./.github/workflows/nix.yml", + "", + ].join("\n"), + ], + [ + "./.github/workflows/nix.yml", + `jobs:\n build:\n steps:\n - uses: actions/checkout@${commit}\n`, + ], + ]); + expect(semanticActionReferences(workflows)).toEqual([`actions/checkout@${commit}`]); + + workflows.delete("./.github/workflows/nix.yml"); + expect(() => semanticActionReferences(workflows)).toThrow(/unknown local workflow/u); + }); + + test("rejects a vacuous workflow set even when unrelated data contains uses", () => { + expect(() => semanticActionReferences(new Map([ + ["./.github/workflows/ci.yml", "env:\n uses: actions/checkout@v4\njobs:\n test:\n steps:\n - run: true\n"], + ]))).toThrow(/At least one pinned remote action/u); + }); +});