From 59df1a9819fdda64b8f2290b855adcc6bdb923ef Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sat, 5 Sep 2026 23:42:53 -0400 Subject: [PATCH 1/4] Standardize Dependabot auto-merge safeguards --- .github/scripts/dependabot-auto-merge.mjs | 149 ++++++++++++++++++++ .github/workflows/ci.yml | 31 ++++ .github/workflows/dependabot-auto-merge.yml | 76 ++++------ ui/dependabot_auto_merge_test.js | 111 +++++++++++++++ 4 files changed, 319 insertions(+), 48 deletions(-) create mode 100644 .github/scripts/dependabot-auto-merge.mjs create mode 100644 ui/dependabot_auto_merge_test.js diff --git a/.github/scripts/dependabot-auto-merge.mjs b/.github/scripts/dependabot-auto-merge.mjs new file mode 100644 index 0000000..aedf93a --- /dev/null +++ b/.github/scripts/dependabot-auto-merge.mjs @@ -0,0 +1,149 @@ +/** Validate that a dependency pull request remains safe to auto-merge. */ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEPENDABOT = "dependabot[bot]"; +const WEB_FLOW = "web-flow"; + +function refuse(message) { + throw new Error(`Refusing auto-merge; ${message}`); +} + +function dependencyEcosystem(headRef) { + if (headRef.startsWith("dependabot/uv/")) return "uv"; + if (headRef.startsWith("dependabot/npm_and_yarn/")) return "npm"; + if (headRef.startsWith("dependabot/github_actions/")) return "github-actions"; + refuse(`unsupported Dependabot branch: ${headRef}`); +} + +function assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory) { + if (changedFiles.length === 0) + refuse("the pull request has no changed files."); + if (ecosystem === "uv") { + if (changedFiles.length !== 1 || changedFiles[0] !== "uv.lock") + refuse("uv updates must change only uv.lock."); + return; + } + if (ecosystem === "npm") { + const isPackageFile = (path) => + path === "package.json" || path === "package-lock.json"; + if ( + changedFiles.length !== 2 || + !changedFiles.includes("package.json") || + !changedFiles.includes("package-lock.json") || + !changedFiles.every(isPackageFile) + ) + refuse( + "npm updates must change only package.json and package-lock.json.", + ); + return; + } + + const isExistingAllowedFile = (path) => + (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path) || + path === "action.yml" || + path === "action.yaml") && + existsSync(resolve(trustedBaseDirectory, path)); + if (!changedFiles.every(isExistingAllowedFile)) + refuse("the update changes a file outside the trusted dependency scope."); +} + +function assertDirectDependabotHistory(event, commits) { + const [commit] = commits; + if ( + commits.length !== 1 || + commit?.author?.login !== DEPENDABOT || + commit?.commit?.verification?.verified !== true || + commit?.sha !== event.pull_request.head.sha + ) + refuse("the pull request does not have a verified Dependabot head commit."); +} + +function assertUpdateBranchHistory(event, commits) { + if (event.action !== "synchronize") + refuse(`the triggering actor was not ${DEPENDABOT}.`); + if (commits.length < 2) + refuse("the synchronization was not a GitHub Update branch merge."); + if ( + commits[0]?.author?.login !== DEPENDABOT || + commits[0]?.commit?.verification?.verified !== true + ) + refuse("the pull request history does not begin with Dependabot."); + + for (let index = 1; index < commits.length; index += 1) { + const commit = commits[index]; + const previous = commits[index - 1]; + if ( + commit?.committer?.login !== WEB_FLOW || + commit?.commit?.verification?.verified !== true || + commit?.parents?.length !== 2 || + commit.parents[0]?.sha !== previous?.sha + ) + refuse("the pull request contains a non-Dependabot edit."); + } + + const latest = commits.at(-1); + if ( + latest?.sha !== event.pull_request.head.sha || + latest.parents[1]?.sha !== event.pull_request.base.sha + ) + refuse("the latest commit is not an update from the current base branch."); +} + +/** + * Authorize a Dependabot update or a chain containing only GitHub Update branch merges. + * + * @param {object} input Validation inputs from the pull-request event and API. + */ +export function authorizeDependabotUpdate({ + actor, + changedFiles, + commits, + event, + trustedBaseDirectory = process.cwd(), +}) { + const pullRequest = event.pull_request; + if ( + event.repository?.fork !== false || + pullRequest?.user?.login !== DEPENDABOT || + pullRequest?.head?.repo?.full_name !== event.repository?.full_name || + pullRequest?.base?.ref !== event.repository?.default_branch + ) + refuse( + "the pull request does not have the required Dependabot provenance.", + ); + + const ecosystem = dependencyEcosystem(pullRequest.head.ref); + if (actor === DEPENDABOT) assertDirectDependabotHistory(event, commits); + else assertUpdateBranchHistory(event, commits); + assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory); + return ecosystem; +} + +function main() { + const [, , eventPath, changedFilesPath, commitsPath] = process.argv; + if (!eventPath || !changedFilesPath || !commitsPath) + throw new Error( + "Usage: dependabot-auto-merge.mjs EVENT CHANGED_FILES COMMITS", + ); + const event = JSON.parse(readFileSync(eventPath, "utf8")); + const changedFiles = readFileSync(changedFilesPath, "utf8") + .split("\n") + .filter(Boolean); + const commitPages = JSON.parse(readFileSync(commitsPath, "utf8")); + const commits = commitPages.flat(); + authorizeDependabotUpdate({ + actor: process.env.GITHUB_ACTOR, + changedFiles, + commits, + event, + }); + console.log("Authorized dependency update files and commit history."); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === resolve(process.argv[1]) +) + main(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c81c9..0809510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: node-ci-${{ github.workflow }}-${{ github.ref }} @@ -19,6 +20,36 @@ jobs: name: Node CI runs-on: ubuntu-latest steps: + - name: Checkout trusted dependency authorization helper + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'dependabot[bot]' + uses: actions/checkout@v7 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + + - name: Authorize Dependabot update before checking out its head + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'dependabot[bot]' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + changed_files="${RUNNER_TEMP}/dependabot-changed-files" + commits="${RUNNER_TEMP}/dependabot-commits.json" + gh api --paginate \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + --jq '.[].filename' > "${changed_files}" + gh api --paginate --slurp \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ + > "${commits}" + node .github/scripts/dependabot-auto-merge.mjs \ + "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" + - name: Check out repository uses: actions/checkout@v7 with: diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 800f23c..d97de9c 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,4 +1,4 @@ -name: Dependabot dependency auto-merge +name: Dependabot auto-merge on: pull_request: @@ -9,63 +9,45 @@ concurrency: cancel-in-progress: true jobs: - verify-dependabot-metadata: + authorize-dependency-update: if: >- github.event.repository.fork == false && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == - github.event.repository.default_branch + (startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/') || + startsWith(github.event.pull_request.head.ref, 'dependabot/github_actions/')) && + github.event.pull_request.base.ref == github.event.repository.default_branch runs-on: ubuntu-latest permissions: contents: read pull-requests: read steps: - - name: Fetch Dependabot metadata - uses: dependabot/fetch-metadata@v3 + - name: Checkout trusted authorization helper + uses: actions/checkout@v7 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} - verify-changed-files: - if: >- - github.event.repository.fork == false && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == - github.event.repository.default_branch - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - steps: - - name: Verify supported dependency update + - name: Authorize dependency update files env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} run: | - changed_files="$(gh api --paginate \ + set -euo pipefail + changed_files="${RUNNER_TEMP}/dependabot-changed-files" + commits="${RUNNER_TEMP}/dependabot-commits.json" + gh api --paginate \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ - --jq '.[].filename')" - [[ -n "${changed_files}" ]] || { - echo "Refusing auto-merge; no changed files were reported" - exit 1 - } - npm_invalid_files="$(printf '%s\n' "${changed_files}" | grep -Ev '^package(-lock)?\.json$' || true)" - if [[ -z "${npm_invalid_files}" ]] && \ - printf '%s\n' "${changed_files}" | grep -Fxq 'package-lock.json'; then - exit 0 - fi - - actions_invalid_files="$(printf '%s\n' "${changed_files}" | grep -Ev '^(\.github/workflows/[^/]+\.ya?ml|(review/|fix/)?action\.ya?ml)$' || true)" - if [[ -z "${actions_invalid_files}" ]]; then - exit 0 - fi - - echo "Refusing auto-merge; changed files are neither a supported npm update nor a GitHub Actions-only update:" - echo "${changed_files}" - exit 1 + --jq '.[].filename' > "${changed_files}" + gh api --paginate --slurp \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ + > "${commits}" + node .github/scripts/dependabot-auto-merge.mjs \ + "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" enable-auto-merge: - needs: [verify-dependabot-metadata, verify-changed-files] + needs: authorize-dependency-update runs-on: ubuntu-latest permissions: contents: write @@ -76,20 +58,18 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_URL: ${{ github.event.pull_request.html_url }} - run: - gh pr merge --auto --squash --match-head-commit "${HEAD_SHA}" - "${PR_URL}" + run: gh pr merge --auto --squash --match-head-commit "${HEAD_SHA}" "${PR_URL}" disable-auto-merge: if: >- - always() && (needs.verify-dependabot-metadata.result != 'success' || - needs.verify-changed-files.result != 'success') && + failure() && !cancelled() && github.event.repository.fork == false && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == - github.event.repository.default_branch - needs: [verify-dependabot-metadata, verify-changed-files] + (startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/') || + startsWith(github.event.pull_request.head.ref, 'dependabot/github_actions/')) && + github.event.pull_request.base.ref == github.event.repository.default_branch + needs: authorize-dependency-update runs-on: ubuntu-latest permissions: contents: write diff --git a/ui/dependabot_auto_merge_test.js b/ui/dependabot_auto_merge_test.js new file mode 100644 index 0000000..1de5013 --- /dev/null +++ b/ui/dependabot_auto_merge_test.js @@ -0,0 +1,111 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { authorizeDependabotUpdate } from "../.github/scripts/dependabot-auto-merge.mjs"; + +const hashes = ["1", "2", "3"].map((character) => character.repeat(40)); +const temporaryDirectories = []; + +function eventFor(headRef) { + return { + action: "opened", + repository: { + default_branch: "main", + fork: false, + full_name: "Snuffy2/shellport", + }, + pull_request: { + base: { ref: "main", sha: hashes[1] }, + head: { + ref: headRef, + repo: { full_name: "Snuffy2/shellport" }, + sha: hashes[2], + }, + user: { login: "dependabot[bot]" }, + }, + }; +} + +function dependabotCommit(sha = hashes[2]) { + return { + author: { login: "dependabot[bot]" }, + commit: { verification: { verified: true } }, + parents: [], + sha, + }; +} + +function trustedBase(path) { + const directory = mkdtempSync(join(tmpdir(), "dependabot-authorizer-")); + temporaryDirectories.push(directory); + const file = join(directory, path); + mkdirSync(join(file, ".."), { recursive: true }); + writeFileSync(file, "trusted\n"); + return directory; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) + rmSync(directory, { force: true, recursive: true }); +}); + +describe("Dependabot auto-merge authorization", () => { + test("accepts a verified npm manifest and lockfile update", () => { + expect( + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles: ["package.json", "package-lock.json"], + commits: [dependabotCommit()], + event: eventFor("dependabot/npm_and_yarn/example-1.0.0"), + }), + ).toBe("npm"); + }); + + test("rejects npm updates that include generated output or omit the manifest", () => { + const event = eventFor("dependabot/npm_and_yarn/example-1.0.0"); + for (const changedFiles of [ + ["package.json", "package-lock.json", "dist/index.js"], + ["package-lock.json"], + ]) { + expect(() => + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles, + commits: [dependabotCommit()], + event, + }), + ).toThrow(); + } + }); + + test("accepts an Actions update only when its target exists on the trusted base", () => { + const event = eventFor("dependabot/github_actions/actions/checkout-7"); + expect( + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles: [".github/workflows/ci.yml"], + commits: [dependabotCommit()], + event, + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + }), + ).toBe("github-actions"); + }); + + test("rejects Actions files that are absent from the trusted base", () => { + expect(() => + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles: ["action.yml"], + commits: [dependabotCommit()], + event: eventFor("dependabot/github_actions/example/action"), + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + }), + ).toThrow(); + }); +}); From b27a5cc5e10751cfb5654cb90be95a07959f7694 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 6 Sep 2026 00:14:47 -0400 Subject: [PATCH 2/4] Derive Dependabot policy from the trusted base --- .github/scripts/dependabot-auto-merge.mjs | 44 +++++-- .github/workflows/dependabot-auto-merge.yml | 4 - ui/dependabot_auto_merge_test.js | 138 ++++++++++++++++---- 3 files changed, 144 insertions(+), 42 deletions(-) diff --git a/.github/scripts/dependabot-auto-merge.mjs b/.github/scripts/dependabot-auto-merge.mjs index aedf93a..f94fe03 100644 --- a/.github/scripts/dependabot-auto-merge.mjs +++ b/.github/scripts/dependabot-auto-merge.mjs @@ -1,6 +1,6 @@ /** Validate that a dependency pull request remains safe to auto-merge. */ -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const DEPENDABOT = "dependabot[bot]"; @@ -17,23 +17,46 @@ function dependencyEcosystem(headRef) { refuse(`unsupported Dependabot branch: ${headRef}`); } +function isTrustedBaseFile(trustedBaseDirectory, path) { + const base = resolve(trustedBaseDirectory); + const candidate = resolve(base, path); + const pathFromBase = relative(base, candidate); + if ( + pathFromBase === "" || + isAbsolute(pathFromBase) || + pathFromBase === ".." || + pathFromBase.startsWith(`..${sep}`) + ) + return false; + return existsSync(candidate) && statSync(candidate).isFile(); +} + function assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory) { if (changedFiles.length === 0) refuse("the pull request has no changed files."); if (ecosystem === "uv") { + if (!isTrustedBaseFile(trustedBaseDirectory, "uv.lock")) + refuse("the trusted base does not use uv."); if (changedFiles.length !== 1 || changedFiles[0] !== "uv.lock") refuse("uv updates must change only uv.lock."); return; } if (ecosystem === "npm") { - const isPackageFile = (path) => - path === "package.json" || path === "package-lock.json"; if ( - changedFiles.length !== 2 || - !changedFiles.includes("package.json") || - !changedFiles.includes("package-lock.json") || - !changedFiles.every(isPackageFile) + !isTrustedBaseFile(trustedBaseDirectory, "package.json") || + !isTrustedBaseFile(trustedBaseDirectory, "package-lock.json") ) + refuse("the trusted base does not use npm."); + const isPackageFile = (path) => + path === "package.json" || path === "package-lock.json"; + const isLockfileOnly = + changedFiles.length === 1 && changedFiles[0] === "package-lock.json"; + const isManifestAndLockfile = + changedFiles.length === 2 && + changedFiles.includes("package.json") && + changedFiles.includes("package-lock.json") && + changedFiles.every(isPackageFile); + if (!isLockfileOnly && !isManifestAndLockfile) refuse( "npm updates must change only package.json and package-lock.json.", ); @@ -42,9 +65,8 @@ function assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory) { const isExistingAllowedFile = (path) => (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path) || - path === "action.yml" || - path === "action.yaml") && - existsSync(resolve(trustedBaseDirectory, path)); + /(^|\/)action\.ya?ml$/.test(path)) && + isTrustedBaseFile(trustedBaseDirectory, path); if (!changedFiles.every(isExistingAllowedFile)) refuse("the update changes a file outside the trusted dependency scope."); } diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index d97de9c..bbd147c 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -14,8 +14,6 @@ jobs: github.event.repository.fork == false && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository && - (startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/') || - startsWith(github.event.pull_request.head.ref, 'dependabot/github_actions/')) && github.event.pull_request.base.ref == github.event.repository.default_branch runs-on: ubuntu-latest permissions: @@ -66,8 +64,6 @@ jobs: github.event.repository.fork == false && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository && - (startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/') || - startsWith(github.event.pull_request.head.ref, 'dependabot/github_actions/')) && github.event.pull_request.base.ref == github.event.repository.default_branch needs: authorize-dependency-update runs-on: ubuntu-latest diff --git a/ui/dependabot_auto_merge_test.js b/ui/dependabot_auto_merge_test.js index 1de5013..c943e8b 100644 --- a/ui/dependabot_auto_merge_test.js +++ b/ui/dependabot_auto_merge_test.js @@ -3,36 +3,38 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { authorizeDependabotUpdate } from "../.github/scripts/dependabot-auto-merge.mjs"; -const hashes = ["1", "2", "3"].map((character) => character.repeat(40)); +const [dependabotSha, currentBaseSha, headSha] = ["1", "2", "3"].map( + (character) => character.repeat(40), +); const temporaryDirectories = []; -function eventFor(headRef) { +function eventFor(headRef, action = "opened") { return { - action: "opened", + action, repository: { default_branch: "main", fork: false, full_name: "Snuffy2/shellport", }, pull_request: { - base: { ref: "main", sha: hashes[1] }, + base: { ref: "main", sha: currentBaseSha }, head: { ref: headRef, repo: { full_name: "Snuffy2/shellport" }, - sha: hashes[2], + sha: headSha, }, user: { login: "dependabot[bot]" }, }, }; } -function dependabotCommit(sha = hashes[2]) { +function dependabotCommit(sha = headSha) { return { author: { login: "dependabot[bot]" }, commit: { verification: { verified: true } }, @@ -41,12 +43,24 @@ function dependabotCommit(sha = hashes[2]) { }; } -function trustedBase(path) { +function updateCommit(sha, previous, base) { + return { + author: { login: "maintainer" }, + commit: { verification: { verified: true } }, + committer: { login: "web-flow" }, + parents: [{ sha: previous }, { sha: base }], + sha, + }; +} + +function trustedBase(...paths) { const directory = mkdtempSync(join(tmpdir(), "dependabot-authorizer-")); temporaryDirectories.push(directory); - const file = join(directory, path); - mkdirSync(join(file, ".."), { recursive: true }); - writeFileSync(file, "trusted\n"); + for (const path of paths) { + const file = join(directory, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, "trusted\n"); + } return directory; } @@ -56,22 +70,35 @@ afterEach(() => { }); describe("Dependabot auto-merge authorization", () => { - test("accepts a verified npm manifest and lockfile update", () => { + test("accepts a verified npm manifest and lockfile update from an npm base", () => { expect( authorizeDependabotUpdate({ actor: "dependabot[bot]", changedFiles: ["package.json", "package-lock.json"], commits: [dependabotCommit()], event: eventFor("dependabot/npm_and_yarn/example-1.0.0"), + trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), + }), + ).toBe("npm"); + }); + + test("accepts a verified npm lockfile-only update from an npm base", () => { + expect( + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles: ["package-lock.json"], + commits: [dependabotCommit()], + event: eventFor("dependabot/npm_and_yarn/example-1.0.0"), + trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), }), ).toBe("npm"); }); - test("rejects npm updates that include generated output or omit the manifest", () => { + test("rejects npm updates that omit the lockfile or include generated output", () => { const event = eventFor("dependabot/npm_and_yarn/example-1.0.0"); for (const changedFiles of [ ["package.json", "package-lock.json", "dist/index.js"], - ["package-lock.json"], + ["package.json"], ]) { expect(() => authorizeDependabotUpdate({ @@ -79,32 +106,89 @@ describe("Dependabot auto-merge authorization", () => { changedFiles, commits: [dependabotCommit()], event, + trustedBaseDirectory: trustedBase( + "package.json", + "package-lock.json", + ), }), ).toThrow(); } }); - test("accepts an Actions update only when its target exists on the trusted base", () => { - const event = eventFor("dependabot/github_actions/actions/checkout-7"); - expect( + test("rejects an update whose ecosystem is absent from the trusted base", () => { + expect(() => authorizeDependabotUpdate({ actor: "dependabot[bot]", - changedFiles: [".github/workflows/ci.yml"], + changedFiles: ["uv.lock"], commits: [dependabotCommit()], - event, - trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + event: eventFor("dependabot/uv/example-1.0.0"), + trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), }), - ).toBe("github-actions"); + ).toThrow(); }); - test("rejects Actions files that are absent from the trusted base", () => { + test("accepts existing trusted workflow and action manifests", () => { + const event = eventFor("dependabot/github_actions/actions/checkout-7"); + const trustedBaseDirectory = trustedBase( + ".github/workflows/ci.yml", + "actions/release/action.yaml", + ); + for (const changedFiles of [ + [".github/workflows/ci.yml"], + ["actions/release/action.yaml"], + ]) + expect( + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles, + commits: [dependabotCommit()], + event, + trustedBaseDirectory, + }), + ).toBe("github-actions"); + }); + + test("rejects absent and path-escaping Actions files", () => { + const event = eventFor("dependabot/github_actions/example/action"); + for (const changedFiles of [["action.yml"], ["../action.yml"]]) + expect(() => + authorizeDependabotUpdate({ + actor: "dependabot[bot]", + changedFiles, + commits: [dependabotCommit()], + event, + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + }), + ).toThrow(); + }); + + test("accepts a verified web-flow Update branch chain", () => { + const firstUpdateSha = "4".repeat(40); + expect( + authorizeDependabotUpdate({ + actor: "maintainer", + changedFiles: ["package-lock.json"], + commits: [ + dependabotCommit(dependabotSha), + updateCommit(firstUpdateSha, dependabotSha, "5".repeat(40)), + updateCommit(headSha, firstUpdateSha, currentBaseSha), + ], + event: eventFor("dependabot/npm_and_yarn/example-1.0.0", "synchronize"), + trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), + }), + ).toBe("npm"); + }); + + test("rejects a non-web-flow Update branch chain", () => { + const commit = updateCommit(headSha, dependabotSha, currentBaseSha); + commit.committer.login = "maintainer"; expect(() => authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles: ["action.yml"], - commits: [dependabotCommit()], - event: eventFor("dependabot/github_actions/example/action"), - trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + actor: "maintainer", + changedFiles: ["package-lock.json"], + commits: [dependabotCommit(dependabotSha), commit], + event: eventFor("dependabot/npm_and_yarn/example-1.0.0", "synchronize"), + trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), }), ).toThrow(); }); From 26de945a6d7a562695715a7fb89887089d044bd3 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 6 Sep 2026 10:29:25 -0400 Subject: [PATCH 3/4] fix: verify Dependabot merge ancestry --- .github/scripts/dependabot-auto-merge.mjs | 51 ++++- .github/workflows/ci.yml | 16 +- .github/workflows/dependabot-auto-merge.yml | 18 +- ui/dependabot_auto_merge_test.js | 238 ++++++++++++-------- ui/dependabot_workflow_contract_test.js | 192 ++++++++++++++++ 5 files changed, 398 insertions(+), 117 deletions(-) create mode 100644 ui/dependabot_workflow_contract_test.js diff --git a/.github/scripts/dependabot-auto-merge.mjs b/.github/scripts/dependabot-auto-merge.mjs index f94fe03..4736ec5 100644 --- a/.github/scripts/dependabot-auto-merge.mjs +++ b/.github/scripts/dependabot-auto-merge.mjs @@ -82,11 +82,37 @@ function assertDirectDependabotHistory(event, commits) { refuse("the pull request does not have a verified Dependabot head commit."); } -function assertUpdateBranchHistory(event, commits) { - if (event.action !== "synchronize") - refuse(`the triggering actor was not ${DEPENDABOT}.`); +function assertMergeParentAncestry(event, commits, ancestryProofs) { + const mergeCommits = commits.slice(1); + const currentBase = event.pull_request.base.sha; + if ( + !Array.isArray(ancestryProofs) || + ancestryProofs.length !== mergeCommits.length + ) + refuse("the merge-parent ancestry evidence is incomplete."); + + for (const [index, commit] of mergeCommits.entries()) { + const secondParent = commit?.parents?.[1]?.sha; + const proof = ancestryProofs[index]; + if ( + typeof secondParent !== "string" || + proof?.parent_sha !== secondParent || + proof?.base_sha !== currentBase || + proof?.base_commit !== secondParent || + proof?.head_commit !== currentBase || + proof?.merge_base_commit !== secondParent || + !["ahead", "identical"].includes(proof?.status) || + !Number.isInteger(proof?.ahead_by) || + proof.ahead_by < 0 || + proof?.behind_by !== 0 + ) + refuse("a merge second parent is not proven to be on the current base."); + } +} + +function assertUpdateBranchHistory(event, commits, ancestryProofs) { if (commits.length < 2) - refuse("the synchronization was not a GitHub Update branch merge."); + refuse("the pull request is not a GitHub Update branch merge."); if ( commits[0]?.author?.login !== DEPENDABOT || commits[0]?.commit?.verification?.verified !== true @@ -105,6 +131,7 @@ function assertUpdateBranchHistory(event, commits) { refuse("the pull request contains a non-Dependabot edit."); } + assertMergeParentAncestry(event, commits, ancestryProofs); const latest = commits.at(-1); if ( latest?.sha !== event.pull_request.head.sha || @@ -119,7 +146,7 @@ function assertUpdateBranchHistory(event, commits) { * @param {object} input Validation inputs from the pull-request event and API. */ export function authorizeDependabotUpdate({ - actor, + ancestryProofs = [], changedFiles, commits, event, @@ -137,17 +164,18 @@ export function authorizeDependabotUpdate({ ); const ecosystem = dependencyEcosystem(pullRequest.head.ref); - if (actor === DEPENDABOT) assertDirectDependabotHistory(event, commits); - else assertUpdateBranchHistory(event, commits); + if (commits.length === 1) assertDirectDependabotHistory(event, commits); + else assertUpdateBranchHistory(event, commits, ancestryProofs); assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory); return ecosystem; } function main() { - const [, , eventPath, changedFilesPath, commitsPath] = process.argv; - if (!eventPath || !changedFilesPath || !commitsPath) + const [, , eventPath, changedFilesPath, commitsPath, ancestryProofsPath] = + process.argv; + if (!eventPath || !changedFilesPath || !commitsPath || !ancestryProofsPath) throw new Error( - "Usage: dependabot-auto-merge.mjs EVENT CHANGED_FILES COMMITS", + "Usage: dependabot-auto-merge.mjs EVENT CHANGED_FILES COMMITS ANCESTRY_PROOFS", ); const event = JSON.parse(readFileSync(eventPath, "utf8")); const changedFiles = readFileSync(changedFilesPath, "utf8") @@ -155,8 +183,9 @@ function main() { .filter(Boolean); const commitPages = JSON.parse(readFileSync(commitsPath, "utf8")); const commits = commitPages.flat(); + const ancestryProofs = JSON.parse(readFileSync(ancestryProofsPath, "utf8")); authorizeDependabotUpdate({ - actor: process.env.GITHUB_ACTOR, + ancestryProofs, changedFiles, commits, event, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0809510..559fc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: test: name: Node CI runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: - name: Checkout trusted dependency authorization helper if: >- @@ -41,14 +44,25 @@ jobs: set -euo pipefail changed_files="${RUNNER_TEMP}/dependabot-changed-files" commits="${RUNNER_TEMP}/dependabot-commits.json" + ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json" + ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl" + base_sha="${{ github.event.pull_request.base.sha }}" gh api --paginate \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ --jq '.[].filename' > "${changed_files}" gh api --paginate --slurp \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ > "${commits}" + : > "${ancestry_proof_items}" + while IFS= read -r second_parent; do + gh api "repos/${REPOSITORY}/compare/${second_parent}...${base_sha}" | + jq -c --arg parent_sha "${second_parent}" --arg base_sha "${base_sha}" \ + '{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \ + >> "${ancestry_proof_items}" + done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}") + jq -s . "${ancestry_proof_items}" > "${ancestry_proofs}" node .github/scripts/dependabot-auto-merge.mjs \ - "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" + "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" "${ancestry_proofs}" - name: Check out repository uses: actions/checkout@v7 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index bbd147c..20225b7 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -11,10 +11,7 @@ concurrency: jobs: authorize-dependency-update: if: >- - github.event.repository.fork == false && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.base.ref == github.event.repository.default_branch + github.event.pull_request.user.login == 'dependabot[bot]' runs-on: ubuntu-latest permissions: contents: read @@ -35,14 +32,25 @@ jobs: set -euo pipefail changed_files="${RUNNER_TEMP}/dependabot-changed-files" commits="${RUNNER_TEMP}/dependabot-commits.json" + ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json" + ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl" + base_sha="${{ github.event.pull_request.base.sha }}" gh api --paginate \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ --jq '.[].filename' > "${changed_files}" gh api --paginate --slurp \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ > "${commits}" + : > "${ancestry_proof_items}" + while IFS= read -r second_parent; do + gh api "repos/${REPOSITORY}/compare/${second_parent}...${base_sha}" | + jq -c --arg parent_sha "${second_parent}" --arg base_sha "${base_sha}" \ + '{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \ + >> "${ancestry_proof_items}" + done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}") + jq -s . "${ancestry_proof_items}" > "${ancestry_proofs}" node .github/scripts/dependabot-auto-merge.mjs \ - "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" + "${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" "${ancestry_proofs}" enable-auto-merge: needs: authorize-dependency-update diff --git a/ui/dependabot_auto_merge_test.js b/ui/dependabot_auto_merge_test.js index c943e8b..25dfb89 100644 --- a/ui/dependabot_auto_merge_test.js +++ b/ui/dependabot_auto_merge_test.js @@ -9,12 +9,16 @@ import { afterEach, describe, expect, test } from "vitest"; import { authorizeDependabotUpdate } from "../.github/scripts/dependabot-auto-merge.mjs"; -const [dependabotSha, currentBaseSha, headSha] = ["1", "2", "3"].map( - (character) => character.repeat(40), -); +const [dependabotSha, firstBaseSha, firstUpdateSha, currentBaseSha, headSha] = [ + "1", + "2", + "3", + "4", + "5", +].map((character) => character.repeat(40)); const temporaryDirectories = []; -function eventFor(headRef, action = "opened") { +function eventFor(headRef, action = "reopened") { return { action, repository: { @@ -34,10 +38,10 @@ function eventFor(headRef, action = "opened") { }; } -function dependabotCommit(sha = headSha) { +function dependabotCommit(sha = headSha, verified = true) { return { author: { login: "dependabot[bot]" }, - commit: { verification: { verified: true } }, + commit: { verification: { verified } }, parents: [], sha, }; @@ -53,6 +57,34 @@ function updateCommit(sha, previous, base) { }; } +function ancestryProof(parentSha, status = "ahead") { + return { + ahead_by: status === "identical" ? 0 : 1, + base_commit: parentSha, + base_sha: currentBaseSha, + behind_by: 0, + head_commit: currentBaseSha, + merge_base_commit: parentSha, + parent_sha: parentSha, + status, + }; +} + +function updateChain() { + return [ + dependabotCommit(dependabotSha), + updateCommit(firstUpdateSha, dependabotSha, firstBaseSha), + updateCommit(headSha, firstUpdateSha, currentBaseSha), + ]; +} + +function updateChainProofs() { + return [ + ancestryProof(firstBaseSha), + ancestryProof(currentBaseSha, "identical"), + ]; +} + function trustedBase(...paths) { const directory = mkdtempSync(join(tmpdir(), "dependabot-authorizer-")); temporaryDirectories.push(directory); @@ -64,131 +96,137 @@ function trustedBase(...paths) { return directory; } +function authorize({ + actor = "dependabot[bot]", + ancestryProofs = [], + changedFiles = ["package-lock.json"], + commits = [dependabotCommit()], + event = eventFor("dependabot/npm_and_yarn/example-1.0.0"), + trustedBaseDirectory = trustedBase("package.json", "package-lock.json"), +} = {}) { + return authorizeDependabotUpdate({ + actor, + ancestryProofs, + changedFiles, + commits, + event, + trustedBaseDirectory, + }); +} + afterEach(() => { for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { force: true, recursive: true }); }); describe("Dependabot auto-merge authorization", () => { - test("accepts a verified npm manifest and lockfile update from an npm base", () => { + test("authorizes reopened direct updates from verified exact history", () => { + expect(authorize()).toBe("npm"); expect( - authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles: ["package.json", "package-lock.json"], - commits: [dependabotCommit()], - event: eventFor("dependabot/npm_and_yarn/example-1.0.0"), - trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), - }), + authorize({ changedFiles: ["package.json", "package-lock.json"] }), ).toBe("npm"); }); - test("accepts a verified npm lockfile-only update from an npm base", () => { + test("authorizes reopened GitHub Update branch chains", () => { expect( - authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles: ["package-lock.json"], - commits: [dependabotCommit()], - event: eventFor("dependabot/npm_and_yarn/example-1.0.0"), - trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), + authorize({ + ancestryProofs: updateChainProofs(), + commits: updateChain(), }), ).toBe("npm"); }); - test("rejects npm updates that omit the lockfile or include generated output", () => { - const event = eventFor("dependabot/npm_and_yarn/example-1.0.0"); - for (const changedFiles of [ - ["package.json", "package-lock.json", "dist/index.js"], - ["package.json"], - ]) { - expect(() => - authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles, - commits: [dependabotCommit()], - event, - trustedBaseDirectory: trustedBase( - "package.json", - "package-lock.json", - ), - }), - ).toThrow(); - } - }); - - test("rejects an update whose ecosystem is absent from the trusted base", () => { + test("rejects an Update branch merge not committed by GitHub web flow", () => { + const chain = updateChain(); + chain[1].committer.login = "maintainer"; expect(() => - authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles: ["uv.lock"], - commits: [dependabotCommit()], - event: eventFor("dependabot/uv/example-1.0.0"), - trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), - }), + authorize({ ancestryProofs: updateChainProofs(), commits: chain }), ).toThrow(); }); - test("accepts existing trusted workflow and action manifests", () => { - const event = eventFor("dependabot/github_actions/actions/checkout-7"); - const trustedBaseDirectory = trustedBase( - ".github/workflows/ci.yml", - "actions/release/action.yaml", - ); - for (const changedFiles of [ - [".github/workflows/ci.yml"], - ["actions/release/action.yaml"], + test("does not use the triggering actor or action as authorization inputs", () => { + for (const [actor, action] of [ + ["dependabot[bot]", "opened"], + ["maintainer", "synchronize"], + ["any-user", "reopened"], ]) expect( - authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles, - commits: [dependabotCommit()], - event, - trustedBaseDirectory, + authorize({ + actor, + event: eventFor("dependabot/npm_and_yarn/example-1.0.0", action), }), - ).toBe("github-actions"); + ).toBe("npm"); }); - test("rejects absent and path-escaping Actions files", () => { - const event = eventFor("dependabot/github_actions/example/action"); - for (const changedFiles of [["action.yml"], ["../action.yml"]]) + test("accepts an older base ancestor for an intermediate GitHub merge", () => { + expect( + authorize({ + ancestryProofs: updateChainProofs(), + commits: updateChain(), + }), + ).toBe("npm"); + }); + + test("rejects absent, arbitrary, diverged, and mismatched ancestry evidence", () => { + const invalidProofSets = [ + [], + [{}, ancestryProof(currentBaseSha, "identical")], + [ancestryProof(firstBaseSha), ancestryProof("9".repeat(40))], + [ + ancestryProof(firstBaseSha, "diverged"), + ancestryProof(currentBaseSha, "identical"), + ], + [ + { ...ancestryProof(firstBaseSha), head_commit: "8".repeat(40) }, + ancestryProof(currentBaseSha, "identical"), + ], + ]; + for (const ancestryProofs of invalidProofSets) expect(() => - authorizeDependabotUpdate({ - actor: "dependabot[bot]", - changedFiles, - commits: [dependabotCommit()], - event, - trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), - }), + authorize({ ancestryProofs, commits: updateChain() }), ).toThrow(); }); - test("accepts a verified web-flow Update branch chain", () => { - const firstUpdateSha = "4".repeat(40); - expect( - authorizeDependabotUpdate({ - actor: "maintainer", - changedFiles: ["package-lock.json"], - commits: [ - dependabotCommit(dependabotSha), - updateCommit(firstUpdateSha, dependabotSha, "5".repeat(40)), - updateCommit(headSha, firstUpdateSha, currentBaseSha), - ], - event: eventFor("dependabot/npm_and_yarn/example-1.0.0", "synchronize"), - trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), - }), - ).toBe("npm"); + test("requires the latest merge parent and latest commit to match event state", () => { + const staleParentChain = updateChain(); + staleParentChain[2] = updateCommit(headSha, firstUpdateSha, firstBaseSha); + for (const commits of [staleParentChain, [dependabotCommit(dependabotSha)]]) + expect(() => + authorize({ ancestryProofs: updateChainProofs(), commits }), + ).toThrow(); }); - test("rejects a non-web-flow Update branch chain", () => { - const commit = updateCommit(headSha, dependabotSha, currentBaseSha); - commit.committer.login = "maintainer"; + test("rejects unverified history, invalid provenance, and scope changes", () => { + const untrustedEvent = eventFor("dependabot/npm_and_yarn/example-1.0.0"); + untrustedEvent.pull_request.head.repo.full_name = "fork/repository"; + for (const input of [ + { commits: [dependabotCommit(headSha, false)] }, + { event: untrustedEvent }, + { changedFiles: ["README.md"] }, + ]) + expect(() => authorize(input)).toThrow(); + }); + + test("keeps each ecosystem scope rooted in trusted base contents", () => { + expect( + authorize({ + changedFiles: ["uv.lock"], + event: eventFor("dependabot/uv/example-1.0.0"), + trustedBaseDirectory: trustedBase("uv.lock"), + }), + ).toBe("uv"); + expect( + authorize({ + changedFiles: [".github/workflows/ci.yml"], + event: eventFor("dependabot/github_actions/actions/checkout-7"), + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), + }), + ).toBe("github-actions"); expect(() => - authorizeDependabotUpdate({ - actor: "maintainer", - changedFiles: ["package-lock.json"], - commits: [dependabotCommit(dependabotSha), commit], - event: eventFor("dependabot/npm_and_yarn/example-1.0.0", "synchronize"), - trustedBaseDirectory: trustedBase("package.json", "package-lock.json"), + authorize({ + changedFiles: ["action.yml"], + event: eventFor("dependabot/github_actions/example/action"), + trustedBaseDirectory: trustedBase(".github/workflows/ci.yml"), }), ).toThrow(); }); diff --git a/ui/dependabot_workflow_contract_test.js b/ui/dependabot_workflow_contract_test.js new file mode 100644 index 0000000..1c12b13 --- /dev/null +++ b/ui/dependabot_workflow_contract_test.js @@ -0,0 +1,192 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import * as prettier from "prettier"; +import { describe, expect, test } from "vitest"; + +function yamlValue(node) { + if ( + [ + "plain", + "quoteDouble", + "quoteSingle", + "blockFolded", + "blockLiteral", + ].includes(node.type) + ) + return node.value ?? ""; + if (node.type === "mapping") + return Object.fromEntries( + (node.children ?? []).map((item) => { + const [key, value] = item.children ?? []; + return [yamlValue(key).toString(), yamlValue(value)]; + }), + ); + if (node.type === "sequence") + return (node.children ?? []).map((item) => yamlValue(item)); + for (const child of node.children ?? []) { + const value = yamlValue(child); + if (value !== null) return value; + } + return null; +} + +async function workflow(path) { + const source = readFileSync(resolve(path), "utf8"); + const parsed = await prettier.__debug.parse(source, { parser: "yaml" }); + return yamlValue(parsed.ast); +} + +function steps(job) { + return job.steps; +} + +function authorizationStep(job) { + const step = steps(job).find((candidate) => + candidate.run?.includes("dependabot-auto-merge.mjs"), + ); + expect(step).toBeDefined(); + return step; +} + +function trustedCheckoutBefore(job) { + const jobSteps = steps(job); + const authorizationIndex = jobSteps.indexOf(authorizationStep(job)); + const checkout = jobSteps + .slice(0, authorizationIndex) + .find( + (candidate) => + candidate.uses?.startsWith("actions/checkout@") && + candidate.with?.ref === "${{ github.event.pull_request.base.sha }}", + ); + expect(checkout).toBeDefined(); + expect(checkout.with["persist-credentials"]).toBe("false"); +} + +function requiresEligibleDependabot(condition) { + const value = String(condition); + for (const term of [ + "repository.fork == false", + "pull_request.user.login == 'dependabot[bot]'", + "pull_request.head.repo.full_name == github.repository", + "pull_request.base.ref == github.event.repository.default_branch", + ]) + expect(value).toContain(term); +} + +function requiresDependabotPullRequest(condition) { + const value = String(condition); + expect(value).toContain("github.event_name == 'pull_request'"); + requiresDependabotAuthor(condition); +} + +function requiresDependabotAuthor(condition) { + const value = String(condition); + expect(value).toContain("pull_request.user.login == 'dependabot[bot]'"); + for (const excludedRestriction of [ + "repository.fork == false", + "pull_request.head.repo.full_name == github.repository", + "pull_request.base.ref == github.event.repository.default_branch", + ]) + expect(value).not.toContain(excludedRestriction); +} + +function assertsAuthoritativeDataflow(job) { + const run = authorizationStep(job).run; + expect(run).toMatch(/pulls.*files/); + expect(run).toMatch(/pulls.*commits/); + expect(run).toContain("compare/"); + expect(run).toContain("dependabot-auto-merge.mjs"); +} + +function authorizationJob(workflowDefinition) { + const job = Object.values(workflowDefinition.jobs).find((candidate) => + steps(candidate).some((step) => + step.run?.includes("dependabot-auto-merge.mjs"), + ), + ); + expect(job).toBeDefined(); + return job; +} + +describe("Dependabot workflow trust contracts", () => { + test("uses trusted read-only authorization with PR data and ancestry evidence", async () => { + const autoMerge = await workflow( + ".github/workflows/dependabot-auto-merge.yml", + ); + const ci = await workflow(".github/workflows/ci.yml"); + const authorization = authorizationJob(autoMerge); + const ciAuthorization = authorizationJob(ci); + expect(authorization.permissions).toMatchObject({ + contents: "read", + "pull-requests": "read", + }); + expect(ciAuthorization.permissions).toMatchObject({ + contents: "read", + "pull-requests": "read", + }); + requiresDependabotAuthor(authorization.if); + requiresDependabotPullRequest(steps(ciAuthorization)[0].if); + requiresDependabotPullRequest(authorizationStep(ciAuthorization).if); + trustedCheckoutBefore(authorization); + trustedCheckoutBefore(ciAuthorization); + assertsAuthoritativeDataflow(authorization); + assertsAuthoritativeDataflow(ciAuthorization); + }); + + test("keeps write jobs dependent on authorization and checkout-free", async () => { + const autoMerge = await workflow( + ".github/workflows/dependabot-auto-merge.yml", + ); + const authorization = authorizationJob(autoMerge); + const authorizationName = Object.entries(autoMerge.jobs).find( + ([, candidate]) => Object.is(candidate, authorization), + )[0]; + const writeJobs = Object.values(autoMerge.jobs).filter((job) => + [job.permissions?.contents, job.permissions?.["pull-requests"]].includes( + "write", + ), + ); + expect(writeJobs).not.toHaveLength(0); + for (const job of writeJobs) { + expect(String(job.needs)).toContain(authorizationName); + expect( + steps(job).some((step) => step.uses?.startsWith("actions/checkout@")), + ).toBe(false); + } + const enable = writeJobs.find( + (job) => !String(job.if).includes("failure()"), + ); + expect(enable).toBeDefined(); + expect(enable.if).toBeUndefined(); + }); + + test("uses cancellation-safe cleanup under the eligibility guard", async () => { + const autoMerge = await workflow( + ".github/workflows/dependabot-auto-merge.yml", + ); + const cleanup = Object.values(autoMerge.jobs).find((job) => + String(job.if).includes("failure()"), + ); + expect(cleanup).toBeDefined(); + expect(String(cleanup.if)).toContain("!cancelled()"); + requiresEligibleDependabot(cleanup.if); + }); + + test("authorizes eligible Dependabot PRs before normal CI checks out their head", async () => { + const ci = await workflow(".github/workflows/ci.yml"); + const ciAuthorization = authorizationJob(ci); + trustedCheckoutBefore(ciAuthorization); + const jobSteps = steps(ciAuthorization); + const authorizationIndex = jobSteps.indexOf( + authorizationStep(ciAuthorization), + ); + const headCheckoutIndex = jobSteps.findIndex( + (step) => step.uses?.startsWith("actions/checkout@") && !step.with?.ref, + ); + expect(headCheckoutIndex).toBeGreaterThan(authorizationIndex); + }); +}); From c05fba99dfe38ba67ea05c6fe414f95e3751bc51 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 6 Sep 2026 16:42:38 -0400 Subject: [PATCH 4/4] Harden Dependabot commit verification --- .github/scripts/dependabot-auto-merge.mjs | 16 ++++++++------ .github/workflows/ci.yml | 6 +++--- .github/workflows/dependabot-auto-merge.yml | 6 +++--- ui/dependabot_auto_merge_test.js | 24 ++++++++++++++++++++- ui/dependabot_workflow_contract_test.js | 6 +++++- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/.github/scripts/dependabot-auto-merge.mjs b/.github/scripts/dependabot-auto-merge.mjs index 4736ec5..09dcdd5 100644 --- a/.github/scripts/dependabot-auto-merge.mjs +++ b/.github/scripts/dependabot-auto-merge.mjs @@ -71,12 +71,19 @@ function assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory) { refuse("the update changes a file outside the trusted dependency scope."); } +function isVerifiedDependabotCommit(commit) { + return ( + commit?.author?.login === DEPENDABOT && + commit?.committer?.login === WEB_FLOW && + commit?.commit?.verification?.verified === true + ); +} + function assertDirectDependabotHistory(event, commits) { const [commit] = commits; if ( commits.length !== 1 || - commit?.author?.login !== DEPENDABOT || - commit?.commit?.verification?.verified !== true || + !isVerifiedDependabotCommit(commit) || commit?.sha !== event.pull_request.head.sha ) refuse("the pull request does not have a verified Dependabot head commit."); @@ -113,10 +120,7 @@ function assertMergeParentAncestry(event, commits, ancestryProofs) { function assertUpdateBranchHistory(event, commits, ancestryProofs) { if (commits.length < 2) refuse("the pull request is not a GitHub Update branch merge."); - if ( - commits[0]?.author?.login !== DEPENDABOT || - commits[0]?.commit?.verification?.verified !== true - ) + if (!isVerifiedDependabotCommit(commits[0])) refuse("the pull request history does not begin with Dependabot."); for (let index = 1; index < commits.length; index += 1) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 559fc3b..3f761fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} @@ -46,7 +47,6 @@ jobs: commits="${RUNNER_TEMP}/dependabot-commits.json" ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json" ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl" - base_sha="${{ github.event.pull_request.base.sha }}" gh api --paginate \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ --jq '.[].filename' > "${changed_files}" @@ -55,8 +55,8 @@ jobs: > "${commits}" : > "${ancestry_proof_items}" while IFS= read -r second_parent; do - gh api "repos/${REPOSITORY}/compare/${second_parent}...${base_sha}" | - jq -c --arg parent_sha "${second_parent}" --arg base_sha "${base_sha}" \ + gh api "repos/${REPOSITORY}/compare/${second_parent}...${BASE_SHA}" | + jq -c --arg parent_sha "${second_parent}" --arg base_sha "${BASE_SHA}" \ '{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \ >> "${ancestry_proof_items}" done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}") diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 20225b7..ba750b5 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -25,6 +25,7 @@ jobs: - name: Authorize dependency update files env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} @@ -34,7 +35,6 @@ jobs: commits="${RUNNER_TEMP}/dependabot-commits.json" ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json" ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl" - base_sha="${{ github.event.pull_request.base.sha }}" gh api --paginate \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ --jq '.[].filename' > "${changed_files}" @@ -43,8 +43,8 @@ jobs: > "${commits}" : > "${ancestry_proof_items}" while IFS= read -r second_parent; do - gh api "repos/${REPOSITORY}/compare/${second_parent}...${base_sha}" | - jq -c --arg parent_sha "${second_parent}" --arg base_sha "${base_sha}" \ + gh api "repos/${REPOSITORY}/compare/${second_parent}...${BASE_SHA}" | + jq -c --arg parent_sha "${second_parent}" --arg base_sha "${BASE_SHA}" \ '{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \ >> "${ancestry_proof_items}" done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}") diff --git a/ui/dependabot_auto_merge_test.js b/ui/dependabot_auto_merge_test.js index 25dfb89..f246043 100644 --- a/ui/dependabot_auto_merge_test.js +++ b/ui/dependabot_auto_merge_test.js @@ -38,10 +38,15 @@ function eventFor(headRef, action = "reopened") { }; } -function dependabotCommit(sha = headSha, verified = true) { +function dependabotCommit( + sha = headSha, + verified = true, + committer = "web-flow", +) { return { author: { login: "dependabot[bot]" }, commit: { verification: { verified } }, + committer: { login: committer }, parents: [], sha, }; @@ -136,6 +141,23 @@ describe("Dependabot auto-merge authorization", () => { ).toBe("npm"); }); + test("rejects direct updates and chain roots without GitHub web-flow identity", () => { + for (const committer of [undefined, "maintainer"]) + expect(() => { + const direct = dependabotCommit(headSha, true, committer); + if (committer === undefined) delete direct.committer; + authorize({ commits: [direct] }); + }).toThrow(); + + for (const committer of [undefined, "maintainer"]) + expect(() => { + const chain = updateChain(); + if (committer === undefined) delete chain[0].committer; + else chain[0].committer.login = committer; + authorize({ ancestryProofs: updateChainProofs(), commits: chain }); + }).toThrow(); + }); + test("rejects an Update branch merge not committed by GitHub web flow", () => { const chain = updateChain(); chain[1].committer.login = "maintainer"; diff --git a/ui/dependabot_workflow_contract_test.js b/ui/dependabot_workflow_contract_test.js index 1c12b13..7ab30a8 100644 --- a/ui/dependabot_workflow_contract_test.js +++ b/ui/dependabot_workflow_contract_test.js @@ -95,11 +95,15 @@ function requiresDependabotAuthor(condition) { } function assertsAuthoritativeDataflow(job) { - const run = authorizationStep(job).run; + const step = authorizationStep(job); + const run = step.run; expect(run).toMatch(/pulls.*files/); expect(run).toMatch(/pulls.*commits/); expect(run).toContain("compare/"); expect(run).toContain("dependabot-auto-merge.mjs"); + expect(step.env?.BASE_SHA).toBe("${{ github.event.pull_request.base.sha }}"); + expect(run).toContain("BASE_SHA"); + expect(run).not.toContain("github.event.pull_request.base.sha"); } function authorizationJob(workflowDefinition) {