bcr-pr-reviewer: check review freshness by commit_id, not commit timestamp (#2841)
`getPrApprovers()` decides whether a maintainer's existing approval
still applies to the PR's current content by comparing each review's
`submitted_at` against the head commit's date
(`latestCommit.commit.author.date`). A commit's author date is fully
controlled by whoever creates the commit (`GIT_AUTHOR_DATE` / `git
commit --date`) and GitHub preserves it on push, so the freshness cutoff
is attacker-controlled. By backdating a newly pushed head commit to a
time before an earlier approval, a stale approval of an earlier
innocuous commit keeps counting as valid for the new head the reviewer
never saw. `reviewPR()` feeds this `approvers` set directly into the
auto-approve + squash-merge decision, and since `CODEOWNERS` delegates
the whole `modules/` tree to this bot, the freshness check is the guard
that decides what merges into the registry.
This is the same freshness check hardened in #2764 (which stopped merge
commits from being excluded from the cutoff) and complements #2653
(which pinned the merge to the analyzed head SHA); both kept the
timestamp comparison, which is what this change removes.
The fix compares each review's `commit_id` against the current PR head
SHA instead of comparing timestamps. A review only vouches for the exact
commit it was submitted against, so it is fresh iff its `commit_id`
equals the head. This is timestamp-free and also handles the
merge-commit case without special-casing parent counts.
`index.test.js` is updated to `commit_id` fixtures and adds a regression
test for the backdated-head-commit case; it keeps the checks that a
genuinely head-matching approval still counts and that a later
non-approving review overrides an earlier approval. `node --test`
passes.
diff --git a/actions/bcr-pr-reviewer/index.js b/actions/bcr-pr-reviewer/index.js
index 776b2ee..543a38f 100644
--- a/actions/bcr-pr-reviewer/index.js
+++ b/actions/bcr-pr-reviewer/index.js
@@ -262,17 +262,19 @@
pull_number: prNumber,
});
- // Use the PR's actual current head commit as the review-freshness cutoff. This must
- // NOT exclude merge commits: a merge commit can itself introduce diff content (e.g.
- // conflict resolution, or content brought in from the merged branch), so treating it
- // as if it doesn't exist lets a stale approval of an earlier commit keep counting as
- // approving a HEAD the reviewer never actually saw -- a pusher could get an innocuous
- // early commit approved, then push a merge commit that changes the content, and the
- // stale approval would still be considered valid for the new HEAD.
+ // Determine review freshness by the commit a review was actually submitted
+ // against (its `commit_id`), not by comparing timestamps against the head
+ // commit's date. A commit's author date is fully attacker-controlled
+ // (GIT_AUTHOR_DATE / `git commit --date`) and GitHub preserves it on push,
+ // so a timestamp cutoff can be defeated by backdating a newly pushed head
+ // commit to before an earlier approval: the stale approval then looks "fresh"
+ // for a HEAD the reviewer never saw. A review only vouches for the exact
+ // commit it was submitted against, so it counts iff its `commit_id` equals the
+ // PR's current head SHA. This is timestamp-free and also covers the
+ // merge-commit case (the head is the head regardless of parent count).
const latestCommit = commits[commits.length - 1];
- const latestCommitTime = new Date(latestCommit.commit.author.date);
- console.log(`Latest commit: ${latestCommit.sha}`);
- console.log(`Latest commit time: ${latestCommitTime}`);
+ const headSha = latestCommit.sha;
+ console.log(`Latest commit (PR head): ${headSha}`);
// Get all review events for the PR
const reviewEvents = await octokit.paginate(octokit.rest.pulls.listReviews, {
@@ -281,11 +283,12 @@
pull_number: prNumber,
});
- // For each reviewer, collect their latest review that are newer than the latest commit
+ // For each reviewer, collect their latest review that was submitted against the
+ // current PR head commit.
// Key: reviewer, Value: review
const latestReviews = new Map();
reviewEvents.forEach(review => {
- if (new Date(review.submitted_at) < latestCommitTime) {
+ if (review.commit_id !== headSha) {
return;
}
diff --git a/actions/bcr-pr-reviewer/index.test.js b/actions/bcr-pr-reviewer/index.test.js
index d6e6e47..61870e9 100644
--- a/actions/bcr-pr-reviewer/index.test.js
+++ b/actions/bcr-pr-reviewer/index.test.js
@@ -20,43 +20,77 @@
fakeOctokit.pullsListCommitsMarker = Symbol('listCommits');
fakeOctokit.pullsListReviewsMarker = Symbol('listReviews');
-test('getPrApprovers: a stale approval does not count once a merge commit changes the PR head', async () => {
+test('getPrApprovers: an approval submitted against an earlier commit does not count once a merge commit changes the PR head', async () => {
// C1 gets approved by a maintainer, then C2 (a merge commit -- 2 parents) is
// pushed afterwards and becomes the PR's real HEAD. The maintainer's review
- // predates C2 and never saw its content.
+ // was submitted against C1 and never saw C2's content.
const commits = [
{ sha: 'C1', parents: [{ sha: 'base' }], commit: { author: { date: '2026-08-06T00:00:00Z' } } },
{ sha: 'C2-merge', parents: [{ sha: 'C1' }, { sha: 'other' }], commit: { author: { date: '2026-08-06T00:20:00Z' } } },
];
const reviews = [
- { user: { login: 'maintainer' }, state: 'APPROVED', submitted_at: '2026-08-06T00:10:00Z' },
+ { user: { login: 'maintainer' }, state: 'APPROVED', commit_id: 'C1', submitted_at: '2026-08-06T00:10:00Z' },
];
const approvers = await getPrApprovers(fakeOctokit({ commits, reviews }), 'owner', 'repo', 1);
- assert.equal(approvers.has('maintainer'), false, 'a review submitted before the real HEAD must not count as approving it');
+ assert.equal(approvers.has('maintainer'), false, 'a review submitted against a commit that is no longer the head must not count');
});
-test('getPrApprovers: an approval submitted after a merge commit still counts', async () => {
+test('getPrApprovers: a stale approval stays rejected even when the new head commit is backdated (author.date is attacker-controlled)', async () => {
+ // The attacker gets C1 approved, then pushes C2-evil as the new head but forges
+ // its author.date to BEFORE the approval (GIT_AUTHOR_DATE). A timestamp cutoff
+ // (review.submitted_at >= head author.date) would wrongly treat the stale C1
+ // approval as fresh for C2-evil. Matching on the review's commit_id is immune
+ // to the forged date.
+ const commits = [
+ { sha: 'C1', parents: [{ sha: 'base' }], commit: { author: { date: '2026-08-06T10:00:00Z' } } },
+ // Pushed after the approval, but its author date is forged into the past.
+ { sha: 'C2-evil', parents: [{ sha: 'C1' }], commit: { author: { date: '2020-01-01T00:00:00Z' } } },
+ ];
+ const reviews = [
+ { user: { login: 'maintainer' }, state: 'APPROVED', commit_id: 'C1', submitted_at: '2026-08-06T10:05:00Z' },
+ ];
+
+ const approvers = await getPrApprovers(fakeOctokit({ commits, reviews }), 'owner', 'repo', 1);
+
+ assert.equal(approvers.has('maintainer'), false, 'a backdated head commit must not let a stale approval count as fresh');
+});
+
+test('getPrApprovers: an approval submitted against the current head merge commit still counts', async () => {
const commits = [
{ sha: 'C1', parents: [{ sha: 'base' }], commit: { author: { date: '2026-08-06T00:00:00Z' } } },
{ sha: 'C2-merge', parents: [{ sha: 'C1' }, { sha: 'other' }], commit: { author: { date: '2026-08-06T00:20:00Z' } } },
];
const reviews = [
- { user: { login: 'maintainer' }, state: 'APPROVED', submitted_at: '2026-08-06T00:30:00Z' },
+ { user: { login: 'maintainer' }, state: 'APPROVED', commit_id: 'C2-merge', submitted_at: '2026-08-06T00:30:00Z' },
];
const approvers = await getPrApprovers(fakeOctokit({ commits, reviews }), 'owner', 'repo', 1);
- assert.equal(approvers.has('maintainer'), true, 'a review submitted after the real HEAD should still count');
+ assert.equal(approvers.has('maintainer'), true, 'a review submitted against the current head should count');
});
-test('getPrApprovers: works normally when the PR has no merge commits at all', async () => {
+test('getPrApprovers: the latest review state against the head wins (a later request-changes overrides an earlier approval)', async () => {
const commits = [
{ sha: 'C1', parents: [{ sha: 'base' }], commit: { author: { date: '2026-08-06T00:00:00Z' } } },
];
const reviews = [
- { user: { login: 'maintainer' }, state: 'APPROVED', submitted_at: '2026-08-06T00:05:00Z' },
+ { user: { login: 'maintainer' }, state: 'APPROVED', commit_id: 'C1', submitted_at: '2026-08-06T00:05:00Z' },
+ { user: { login: 'maintainer' }, state: 'CHANGES_REQUESTED', commit_id: 'C1', submitted_at: '2026-08-06T00:09:00Z' },
+ ];
+
+ const approvers = await getPrApprovers(fakeOctokit({ commits, reviews }), 'owner', 'repo', 1);
+
+ assert.equal(approvers.has('maintainer'), false, 'a newer non-approving review against the same head must override the earlier approval');
+});
+
+test('getPrApprovers: works normally when a single-commit PR is approved at head', async () => {
+ const commits = [
+ { sha: 'C1', parents: [{ sha: 'base' }], commit: { author: { date: '2026-08-06T00:00:00Z' } } },
+ ];
+ const reviews = [
+ { user: { login: 'maintainer' }, state: 'APPROVED', commit_id: 'C1', submitted_at: '2026-08-06T00:05:00Z' },
];
const approvers = await getPrApprovers(fakeOctokit({ commits, reviews }), 'owner', 'repo', 1);