fix(security): gate Dependabot approve/merge on check success and commit provenance (#1399, #1400)
Summary
Both Dependabot reusables feed one privileged sink: a GitHub App that approves Dependabot PRs and merges them with ruleset bypass. This PR adds gates in front of that sink:
- Merger (
dependabot-rebase-reusable.yml) merges only when every check on the head commit other than org agent jobs succeeded and every commit on the PR came from Dependabot. The merge call is bound to the head SHA that was checked. - Auto-approver (
dependabot-automerge-reusable.yml) approves only when every commit came from Dependabot and the head is still the commit the event was for. The approval and the auto-merge request are both bound to that SHA.
The decision logic is in new bats-tested libs under .github/scripts/dependabot/lib/:
gates.shholds the pure decisions.gh-io.shholds theghI/O.
Each reusable checks the libs out at its own commit (job.workflow_sha, the same pattern as feature-ideation #839) and keeps only glue in YAML. Caller stubs, inputs and secrets are unchanged.
#1399: merger did not require successful checks
Fixes #1399
Confirmed. At .github/workflows/dependabot-rebase-reusable.yml L163–180 on main, the only check gate counted checks with status != "COMPLETED". A completed FAILURE passed, and so did any commit status, because status contexts have no .name. The step then merged with the App token, which bypasses the rulesets. To reproduce, I ran main's actual run-block against a stubbed gh with a completed failing check: it printed All required checks pass — merging PR #5 and called the merge endpoint (test/workflows/dependabot/rebase-merge-gate.bats, "a completed but FAILED check is not merged").
Fix. Before a direct merge, dependabot_merge_gate now requires, on one snapshot of the head commit (GraphQL headRefOid plus the check rollup of that commit):
- auto-merge requested and
MERGEABLE(as before); - every check run
COMPLETEDwith conclusionsuccess,neutralorskipped, and every commit statussuccess;failure,cancelled,timed_out,action_required,stale,startup_failure, unknown values, pending, no checks at all, or more than one page of checks all block;
- every required status check from the base branch's rulesets (
GET rules/branches/{base}) is present. If the rules can't be read, the PR is skipped; - the commit-provenance gate from #1400 below, checked again at merge time.
The merge PUT now passes sha=<verified head>, so GitHub rejects it if the branch moved after the check.
Regression tests. rebase-merge-gate.bats has 15 end-to-end tests of the real run-block: failed, cancelled, timed-out, stale and pending checks; a failing commit status; a missing required check; unreadable rules; a non-Dependabot commit; sha= binding; token split. gates.bats has 39 unit tests.
#1400: auto-approval from first-commit metadata
Fixes #1400
Confirmed. At .github/workflows/dependabot-automerge-reusable.yml L41–98 on main, eligibility comes only from dependabot/fetch-metadata (with skip-commit-verification: true) plus the PR's original author. The step then ran gh pr review --approve and gh pr merge --auto for whatever the PR's current head was. Nothing checked later commits or pinned the head. Main's run-block approved a PR whose second commit was authored by someone else ("a later non-Dependabot commit is NOT approved" fails on main).
Fix. Before approving, dependabot_provenance_check reads the PR and all of its commits (paginated) and requires:
- the full commit list was read: its length equals the PR's commit count and it contains the head;
- the head equals the event's
pull_request.head.sha. If it doesn't, the run skips without acting, and the newer event's run decides; - every commit is one of:
- authored by
dependabot[bot], committed byweb-flowordependabot[bot], with a GitHub-verified signature; - a merge commit (2+ parents) authored by this App (
<app-slug>[bot], fromcreate-github-app-token'sapp-slugoutput), committed byweb-flowand verified. These are the merge commitsupdate-branchcreates; the merger's chain depends on them.
- authored by
On pass, the review is posted with commit_id=<head> and auto-merge is enabled with --match-head-commit <head>.
On fail, the step does not approve. It also disables auto-merge and dismisses the App's earlier approvals (best effort), so an approval of an earlier revision can't carry a later one. If PR state can't be read, the step fails without approving or revoking.
I checked the rule against real data from the live Dependabot PR #1376 (read-only API calls):
- Dependabot's commit:
author=dependabot[bot],committer=web-flow, verified. - The App's update-branch merges:
author=combsmith-dependabot-automrg[bot],committer=web-flow, 2 parents, verified.
Regression tests. automerge-provenance.bats has 10 end-to-end tests of the real run-block:
- a foreign commit is not approved; auto-merge is disabled and only the App's approval is dismissed;
commit_idand--match-head-commitbinding;- App update-branch merges still pass;
- a stale event does nothing;
- unreadable state fails closed;
- reads use
GITHUB_TOKEN, writes use the App token.
The provenance unit tests are in gates.bats.
Tests run (local, branch fix/sec-dependabot-merge-gating)
bats test/workflows/dependabot/: 64/64 pass.- On origin/main's workflow files, the end-to-end regression tests fail. That includes both headline regressions: a failed check merged, and a foreign commit approved.
bats -r test/workflows test/scripts/standards-templates test/scripts/deploy-standard-workflows test/scripts/cron-timing: 706 tests. The 11 failures are local-environment issues in files this PR doesn't touch (Pythonjsonschemamissing for the feature-ideation schema tests; macOSbase64 -win one deploy dry-run test).shellcheck -xon the new libs and the test helper: clean.actionlinton.github/workflows/*.ymlwith the same ignores asci.yml: clean.gitleakson the new dirs: clean.- New workflow
dependabot-tests.ymlruns shellcheck andbats test/workflows/dependabot/on changes todependabot-*.yml,.github/scripts/dependabot/**, the tests and thestandards/workflows/dependabot-*.ymlstubs.
Behavior changes / release notes
- Reusable change, not yet live in callers. Callers pin
dependabot-rebase/v2-stableanddependabot-automerge/v2-stable; the fix reaches them on the next channel promotion. This repo dogfoods both via local refs, so it gets the fix on merge. No tags were moved. - Stricter merge readiness. The merger used to merge past failing non-required checks; the docs cited a gitleaks false positive as an example. Now any failed, cancelled or pending check holds the Dependabot PR for a human, and
standards/dependabot-policy.mdis updated to match. Org agent jobs (dev-lead / *,pr-auto-review / *,pr-review / review,review / review) are not counted, because they do not test the change: exact names from GitHub Actions only, and never a check the rulesets require. Replaying recent merges, repos with failing audit or gitleaks checks will hold many Dependabot PRs until those checks are fixed. - Non-Dependabot commits take a PR out of automation. That includes a person clicking "Update branch" in the UI, because the merge commit's author is the person, not the App. Recovery is
@dependabot recreate. - The merger job now also requests
contents: readto check out the gate scripts. The stub grantscontents: write, so stubs don't change.
Needs a human decision
- Commit provenance relies on GitHub's author/committer attribution plus signature verification (
web-flow). A committer with write access and API tooling may be able to produce commits that look like Dependabot's. The original finding didn't cover this, so it's out of scope here; if that threat matters, layer a check that the PR's diff only touches dependency manifests.
Accepted residual risk
Native auto-merge stays. It has no form tied to one commit, so a foreign head can stay eligible for it until revocation completes. The required approval of the last push should block a merge in that window, but this has not been tested end to end. The maintainer accepted this on 2026-11-02. The direct merger is not exposed: it re-runs both gates and merges with sha=.
Required before stable (decided 2026-11-03): test the last-push approval backstop live before dependabot-automerge/v2-stable or dependabot-rebase/v2-stable moves to a release with these gates. On a sandbox repo with the org rulesets and the Dependabot App, take a Dependabot PR the App approved with auto-merge on. Push a non-Dependabot commit to its branch twice: once with a normal token, and once with a token whose pushes don't trigger workflows, so no synchronize run can revoke. Confirm neither head can merge, natively or through the merger. If either merges, stop the promotion. This is also in standards/dependabot-policy.md.
Review follow-up (pr-review at b64c2894)
-
Required checks bound to their app: a check the base branch's rules require counts only as a check run from the app the rule's
integration_idnames, or from GitHub Actions when it names none (the maintainer's fallback, and true of every rule in the live org rulesets). A commit status, a run from any other app, or a run whose app can't be read doesn't satisfy it. On this PR's head every required context has an Actions run, so existing PRs aren't held. Both App tokens now request only the permissions they need (see the caller permissions section below). -
Forged Dependabot author, accepted by the maintainer on 2026-11-03: a sandbox test on combsmith/software-factory-canary made two commits with Dependabot's noreply email. One went through the Contents API (author
dependabot[bot], committerdon-petry, unsigned; dbe30ce5). The other went through the Git Data API (author and committerdependabot[bot], unsigned; e8cb9d97). GitHub attributes both todependabot[bot], but both are unsigned, and the gate's verified-signature requirement refuses them. Tests pin both shapes, and removing theverifiedrequirement fails the Git Data API case. A second sandbox test, approved by the user, used a workflowGITHUB_TOKEN(branchsec-test/gha-target-1793755971, run 41754805617):69b67dab, Contents API with the author set to Dependabot's email: authordependabot[bot], committergithub-actions[bot], unsigned.1b677050, Git Data API with author and committer set to Dependabot's email: author and committerdependabot[bot], unsigned.1e425fc7, Contents API with no author override (control): authorgithub-actions[bot], committerweb-flow, verified.
So GitHub signs a workflow-token commit only when the author isn't overridden, and then the commit is attributed to
github-actions[bot], not Dependabot. The gate refuses all three;gates.batsnow also pins thegithub-actions[bot]-committer shape and the signed control.Still untested: commits made with a GitHub App installation token (the App key isn't available to test with). This is the one remaining path that might produce a commit GitHub both attributes to
dependabot[bot]and marks as verified.
Caller permissions and App token (review follow-up, 15804e0a)
- Startup-failure fix:
60cd8bb4had the rebase reusable'supdate-and-mergejob requestchecks: readandstatuses: readfromGITHUB_TOKEN. Neither caller grants those:.github/workflows/dependabot-rebase.ymlandstandards/workflows/dependabot-rebase.ymlgrant onlycontents: writeandpull-requests: write. Once promoted, the job would have failed at startup in every repo. This PR's CI never caught it, because the caller only runs on push to main, on a schedule or by hand. The job now requests onlycontents: readandpull-requests: readagain. - Merge gate reads with the App token: it reads the check rollup (including
checkSuite.app), commit statuses, rules and commits with the App installation token. The token is minted withpermission-checks: readandpermission-statuses: read, plus thecontents: writeandpull-requests: writethe merger needs. Listing PRs and comparing branches stay onGITHUB_TOKEN. The token no longer requestsworkflows: the App doesn't have it, and asking would fail the token. - Fails closed until the App is updated: if the token can't be minted, an "Explain a failed app token" step names the missing
checks:read/statuses:readpermissions and the job fails. The run block also refuses to start without an App token. No PR is merged in either case. - Caller-grant test:
test/workflows/dependabot/caller-permissions.batschecks that every permission each Dependabot reusable requests is granted by this repo's stub and by the standards template (rebase and automerge). It would have failed on60cd8bb4. - No actor filter in the callers: none of the caller stubs (rebase and automerge, in this repo and in
standards/workflows/) filters ongithub.actor; their jobs have noif. The automerge reusable's only job condition is the PR author (github.event.pull_request.user.login == 'dependabot[bot]'), not the triggering actor. - Required before
dependabot-rebaseis promoted: the Dependabot App (combsmith-dependabot-automrg, app id 3167543) must havechecks: readandstatuses: read, and the org installation update must be accepted. Today it haschecks: noneandstatuses: none. The maintainer will grant both. This is also instandards/dependabot-policy.md.
🤖 Generated with Claude Code
Problem
See the linked issue.
Risk
Medium — changes GitHub Actions workflow behavior, which is exercised only post-merge; verify via the affected workflow runs.
Test plan
Tests added/updated: test/workflows/dependabot/automerge-provenance.bats,test/workflows/dependabot/gates.bats test/workflows/dependabot/rebase-merge-gate.bats. Verification: bash scripts/dev-lead-lint.sh (shellcheck --severity=warning) ran pre-commit; the bats suite runs in CI.
Rollback
Revert this PR. No non-revertible side effects (no tags, migrations, or external state).
Monitoring
Watch the affected workflow run(s) in the Actions tab and this PR's Lint check for regressions.