diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 372620c..3d3404f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22.x - - name: Build + - name: Build and test run: | yarn - yarn tsc + yarn test diff --git a/README.md b/README.md index 41b6fee..d4d6e94 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ metadata:read Repo: members:write +actions:write # only if you use `vouched_ci` +pull_requests:read # only if you use `vouched_ci` ``` Once created, you can generate and download a Private Key for the app, and supply it to Sheriff. @@ -241,8 +243,54 @@ common_rulesets: # Same structure as the object in `repositories[name].rulesets` # Used to deduplicate rulesets that you want to apply to multiple repos - +# Optional, see "Vouched CI" below. Users are pinned by numeric GitHub user id, +# the login is cross-checked so a typo in the id can not vouch for someone else +vouched_ci: + - login: + id: ``` +#### Vouched CI + +GitHub can require a maintainer to approve GitHub Actions runs for pull requests from forks +(Sheriff's `forks_need_actions_approval` repository setting turns that on for all external +contributors). The `vouched_ci` +list lets Sheriff approve those runs automatically for a small set of trusted people who are not +collaborators on the repository, for example release engineers who work out of forks. + +When a fork pull request run is created and gated on "Approve and run", GitHub emits a +`workflow_run` `requested` event for it (reported as `completed` / `action_required`). Sheriff +handles that event for `pull_request` runs from forks: because `workflow_run.pull_requests` is +always empty for fork runs, it looks up the open pull requests for the run's `owner:branch` head +and keeps the one whose head commit and head repository match the run. Sheriff then approves that +single run only when **all** of the following hold: + +* The user whose push created the run (the run's `triggering_actor`) is in `vouched_ci` with a + matching `id` and `login`. +* Exactly one open pull request is pinned to the run's head commit and head repository, it comes + from a fork, and it has at most 250 commits. +* Every commit in the pull request (not just the newest one) was authored **and** committed by that + same user, and GitHub reports its signature as `verified` with reason `valid`, i.e. it was signed + with a GPG/SSH/S-MIME key registered on that user's account. +* The pull request head still is the run's head commit after the commits were listed. + +If any check fails Sheriff does nothing and logs why. Each workflow file gets its own run and its +own event, so every run is verified and approved individually; each new push creates new runs and +gets its own decision. Sheriff never approves "the pull request", only individual runs pinned to +the commit it verified. Every approval is posted to Slack. + +To be vouched, you must sign every commit you push with a key on your GitHub account (commits made +through the GitHub web UI are signed by GitHub, not by you, and are not accepted), push your own +commits yourself (nobody else's, including "apply suggestion" or cherry-picked patches from other +people), and rebase rather than merge branches other than the pull request's base into your branch. +Your numeric user id is available at `https://api.github.com/users/`. + +Vouched users can run arbitrary code in your fork pull request CI, treat the list like write access +to the repository's Actions runners. Using this feature requires the GitHub App to have the +`actions:write` and `pull_requests:read` repository permissions in addition to the ones listed +above; the org-wide webhook already delivers `workflow_run` events when configured to send +everything. + #### Generating your initial configuration You can generate a permissions file for the current state of your org using the `generate` helper script. diff --git a/package.json b/package.json index f83456f..f3c9826 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc", "dev": "yarn start", "lint": "prettier --check \"src/**/*.{ts,tsx}\"", + "test": "tsc && node --test \"lib/**/*.test.js\"", "lint-staged": "lint-staged", "prepare": "husky install", "prettier:write": "prettier --write \"src/**/*.{ts,tsx}\"", diff --git a/src/index.ts b/src/index.ts index d444ed5..9dd3166 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,14 +10,14 @@ import { createNodeMiddleware, EmitterWebhookEvent, } from '@octokit/webhooks'; -import { isMainRepo, isSecurityAdvisoryRepo, hook } from './helpers.js'; +import { isMainRepo, isSecurityAdvisoryRepo, hook, IS_DRY_RUN } from './helpers.js'; import { MessageBuilder, createMessageBlock, createMarkdownBlock, PermissionEnforcementAction, } from './MessageBuilder.js'; -import { getOctokit } from './octokit.js'; +import { getOctokit, getVouchedCIOctokit } from './octokit.js'; import { GITHUB_WEBHOOK_SECRET, PERMISSIONS_FILE_ORG, @@ -35,6 +35,12 @@ import { } from './permissions/level-converters.js'; import { SheriffAccessLevel } from './permissions/types.js'; import { queueDryRun } from './dry-run-q.js'; +import { + evaluateVouchedCI, + findVouchedUser, + isApprovableRun, + matchPullRequestForRun, +} from './vouched-ci.js'; const webhooks = new WebhooksApi({ secret: GITHUB_WEBHOOK_SECRET, @@ -518,6 +524,118 @@ webhooks.on( }), ); +webhooks.on( + 'workflow_run.requested', + hook(async (event, ctx) => { + const repo = event.payload.repository; + const run = event.payload.workflow_run; + + // GitHub emits `requested` for a fork pull request run the moment it is + // created and gated on "Approve and run". This fires for every run in the + // organization, so do the payload-only checks before touching anything. + if (!isApprovableRun(run, repo.id).approvable) return; + // Narrowed above, but the webhook types do not know that + if (!run.head_repository?.owner?.login || !run.triggering_actor) return; + + const allConfigs = await getValidatedConfig(); + const orgConfig = allConfigs.organizations.find((c) => c.organization === repo.owner.login); + const vouched = orgConfig?.vouched_ci; + if (!vouched?.length) return; + + const tag = `vouched_ci ${repo.full_name} run ${run.id} ${run.head_sha}:`; + // The triggering actor is the authenticated user whose push created the run. + // This is only a cheap pre-filter for the vast majority of runs that come from + // users who are not vouched; the real gate is the per-commit verification below. + const vouchedUser = findVouchedUser(vouched, run.triggering_actor); + if (!vouchedUser) { + ctx.log(tag, 'not approving: triggering actor', run.triggering_actor.login, 'is not vouched'); + return; + } + + const octokit = await getVouchedCIOctokit(repo.owner.login); + const coords = { owner: repo.owner.login, repo: repo.name }; + + // `workflow_run.pull_requests` is always empty for runs from forks, so find the + // pull request by its head instead and insist on exactly one match + const candidates = await octokit.paginate(octokit.pulls.list, { + ...coords, + state: 'open', + head: `${run.head_repository.owner.login}:${run.head_branch}`, + per_page: 100, + }); + const match = matchPullRequestForRun(candidates, run); + if (!match.pullRequest) { + ctx.log(tag, 'not approving:', match.reason); + return; + } + const pr = match.pullRequest; + + const commits = await octokit.paginate(octokit.pulls.listCommits, { + ...coords, + pull_number: pr.number, + per_page: 100, + }); + // Re-fetch the pull request *after* listing so a racing push is detected + const freshPr = (await octokit.pulls.get({ ...coords, pull_number: pr.number })).data; + if (freshPr.head.repo?.id !== run.head_repository.id) { + ctx.log(tag, 'not approving: pull request head repository does not match the run'); + return; + } + + const decision = evaluateVouchedCI({ + vouched, + sender: run.triggering_actor, + headSha: run.head_sha, + pullRequest: { + headSha: freshPr.head.sha, + headRepoId: freshPr.head.repo?.id ?? null, + baseRepoId: freshPr.base.repo.id, + commitCount: freshPr.commits, + }, + commits, + }); + if (!decision.approve) { + ctx.log(tag, 'not approving:', decision.reason); + return; + } + + if (IS_DRY_RUN) { + ctx.log(tag, 'would approve run', run.id, `(${run.name})`); + return; + } + try { + await octokit.actions.approveWorkflowRun({ ...coords, run_id: run.id }); + } catch (err) { + // A maintainer may have clicked "Approve and run" in the meantime, or the run + // may have been cancelled; GitHub answers those with a 4xx and there is + // nothing left to do. Anything else (5xx, network) is a real failure. + const status = (err as { status?: unknown })?.status; + if (typeof status === 'number' && status >= 400 && status < 500) { + ctx.log( + tag, + `run could not be approved (HTTP ${status}), probably no longer pending:`, + err, + ); + return; + } + throw err; + } + ctx.log(tag, 'approved run', run.id, `(${run.name})`); + + const text = `Approved fork CI run "${run.name}" on pull request #${pr.number} vouched for by ${decision.vouchedUser.login}`; + await MessageBuilder.create() + .setEventPayload(event) + .setNotificationContent(text) + .addBlock(createMessageBlock(text)) + .addRepositoryAndBlame(repo, run.triggering_actor) + .addSeverity('normal') + .addContext( + `:white_check_mark: <${pr.html_url}|#${pr.number}> at \`${run.head_sha}\`, <${run.html_url}|run ${run.id}>`, + ) + .send(); + }), +); + webhooks.on( 'repository_ruleset.edited', hook(async (event) => { diff --git a/src/octokit.ts b/src/octokit.ts index 397b993..343a8a1 100644 --- a/src/octokit.ts +++ b/src/octokit.ts @@ -50,6 +50,29 @@ export async function getOctokit(org: string, forceReadOnly = false): Promise { + const mapKey = `vouched_ci/${org}`; + if (!octokitMap.has(mapKey)) { + const creds = appCredentialsFromString(SHERIFF_GITHUB_APP_CREDS!); + const authOpts = await getAuthOptionsForOrg(org, creds, { + permissions: { + actions: IS_DRY_RUN ? 'read' : 'write', + contents: 'read', + metadata: 'read', + pull_requests: 'read', + }, + }); + octokitMap.set(mapKey, new Octokit({ ...authOpts })); + } + + return octokitMap.get(mapKey)!; +} + export async function getEnterpriseOctokit( enterprise: string, forceReadOnly = false, diff --git a/src/permissions/run.ts b/src/permissions/run.ts index 85996ed..9d429b0 100644 --- a/src/permissions/run.ts +++ b/src/permissions/run.ts @@ -271,6 +271,14 @@ const validateConfigFast = async (config: EnterpriseConfig): Promise(); + const seenVouchedLogins = new Set(); + for (const user of orgConfig.vouched_ci || []) { + const login = user.login.toLowerCase(); + if (seenVouchedIds.has(user.id) || seenVouchedLogins.has(login)) { + throw new Error( + `User "${user.login}" (${user.id}) appears multiple times in the vouched_ci list for "${orgConfig.organization}", it should only appear once`, + ); + } + seenVouchedIds.add(user.id); + seenVouchedLogins.add(login); + } + if (orgConfig.customProperties?.length) { for (const customProp of orgConfig.customProperties) { if (customProp.allowed_values) { diff --git a/src/permissions/types.ts b/src/permissions/types.ts index 85e5ce2..0cb7fc8 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -98,6 +98,16 @@ export interface TeamConfig { slack?: string | true; } +/** + * A GitHub user pinned by numeric id. The id is the identity (logins can be + * reused after a rename or deletion); the login is cross-checked so a typo in + * the id can not vouch for an unrelated account. + */ +export interface VouchedUser { + login: string; + id: number; +} + export interface OrganizationConfig { organization: string; repository_defaults: RepoSettings; @@ -105,6 +115,11 @@ export interface OrganizationConfig { repositories: RepositoryConfig[]; common_rulesets?: Ruleset[]; customProperties?: CustomProperty[]; + /** + * Users whose own verified-signed commits, pushed by themselves to a fork + * pull request, get their GitHub Actions runs approved automatically + */ + vouched_ci?: VouchedUser[]; } export interface EnterpriseConfig { diff --git a/src/vouched-ci.test.ts b/src/vouched-ci.test.ts new file mode 100644 index 0000000..54451aa --- /dev/null +++ b/src/vouched-ci.test.ts @@ -0,0 +1,348 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + evaluateVouchedCI, + findVouchedUser, + isApprovableRun, + matchPullRequestForRun, + MAX_PULL_REQUEST_COMMITS, + VouchedCICommit, + VouchedCIInput, + VouchedCIWorkflowRun, +} from './vouched-ci.js'; + +const alice = { login: 'alice', id: 1001 }; +const bob = { login: 'bob', id: 2002 }; +const vouched = [alice]; + +const user = (id: number) => ({ login: `user-${id}`, id } as any); + +const commit = ( + sha: string, + { + author = alice.id, + committer = alice.id, + verified = true, + reason = 'valid', + }: { + author?: number | null; + committer?: number | null; + verified?: boolean; + reason?: string | null; + } = {}, +): VouchedCICommit => ({ + sha, + author: author === null ? null : user(author), + committer: committer === null ? null : user(committer), + commit: { verification: reason === null ? null : { verified, reason } }, +}); + +const input = (overrides: Partial = {}): VouchedCIInput => { + const commits = overrides.commits || [commit('aaa'), commit('bbb')]; + return { + vouched, + sender: alice, + headSha: commits[commits.length - 1]?.sha ?? 'bbb', + pullRequest: { + headSha: commits[commits.length - 1]?.sha ?? 'bbb', + headRepoId: 77, + baseRepoId: 55, + commitCount: commits.length, + }, + ...overrides, + commits, + }; +}; + +const expectRefusal = (decision: ReturnType, pattern: RegExp) => { + assert.equal(decision.approve, false); + if (!decision.approve) assert.match(decision.reason, pattern); +}; + +describe('findVouchedUser', () => { + it('matches on id and login, case-insensitively', () => { + assert.equal(findVouchedUser(vouched, { id: 1001, login: 'Alice' }), alice); + }); + + it('does not match a different account that reused the login', () => { + assert.equal(findVouchedUser(vouched, { id: 9999, login: 'alice' }), undefined); + }); + + it('does not match the pinned id under a different login (id typo guard)', () => { + assert.equal(findVouchedUser(vouched, { id: 1001, login: 'mallory' }), undefined); + }); +}); + +describe('evaluateVouchedCI', () => { + it('approves when every commit is authored, committed and verified by the vouched sender', () => { + const decision = evaluateVouchedCI(input()); + assert.deepEqual(decision, { approve: true, vouchedUser: alice }); + }); + + it('approves a single commit pull request', () => { + assert.equal(evaluateVouchedCI(input({ commits: [commit('aaa')] })).approve, true); + }); + + it('refuses when the sender is not vouched, even if the commits are', () => { + expectRefusal(evaluateVouchedCI(input({ sender: bob })), /sender bob \(2002\) is not vouched/); + }); + + it('refuses a sender whose login collides with a vouched login but has a different id', () => { + expectRefusal( + evaluateVouchedCI(input({ sender: { login: 'alice', id: 31337 } })), + /is not vouched/, + ); + }); + + it('refuses when nobody is vouched', () => { + expectRefusal(evaluateVouchedCI(input({ vouched: [] })), /is not vouched/); + }); + + it('refuses pull requests that are not from a fork', () => { + expectRefusal( + evaluateVouchedCI( + input({ pullRequest: { headSha: 'bbb', headRepoId: 55, baseRepoId: 55, commitCount: 2 } }), + ), + /not from a fork/, + ); + }); + + it('refuses when the head repository is gone', () => { + expectRefusal( + evaluateVouchedCI( + input({ + pullRequest: { headSha: 'bbb', headRepoId: null, baseRepoId: 55, commitCount: 2 }, + }), + ), + /no longer exists/, + ); + }); + + it('refuses when the head SHA moved between the event and verification', () => { + expectRefusal( + evaluateVouchedCI( + input({ pullRequest: { headSha: 'ccc', headRepoId: 77, baseRepoId: 55, commitCount: 2 } }), + ), + /head moved from bbb to ccc/, + ); + }); + + it('refuses when the commit listing does not contain the head SHA', () => { + expectRefusal( + evaluateVouchedCI( + input({ + headSha: 'zzz', + pullRequest: { headSha: 'zzz', headRepoId: 77, baseRepoId: 55, commitCount: 2 }, + }), + ), + /does not contain head zzz/, + ); + }); + + it('refuses when the listing is incomplete', () => { + expectRefusal( + evaluateVouchedCI( + input({ pullRequest: { headSha: 'bbb', headRepoId: 77, baseRepoId: 55, commitCount: 3 } }), + ), + /listed 2 commits but the pull request has 3/, + ); + }); + + it('refuses pull requests with more commits than can be listed', () => { + const count = MAX_PULL_REQUEST_COMMITS + 1; + expectRefusal( + evaluateVouchedCI( + input({ + pullRequest: { headSha: 'bbb', headRepoId: 77, baseRepoId: 55, commitCount: count }, + }), + ), + /more than can be listed/, + ); + }); + + it('refuses an empty pull request', () => { + expectRefusal( + evaluateVouchedCI( + input({ + commits: [], + headSha: 'bbb', + pullRequest: { headSha: 'bbb', headRepoId: 77, baseRepoId: 55, commitCount: 0 }, + }), + ), + /no commits/, + ); + }); + + it('refuses when any commit is unsigned', () => { + expectRefusal( + evaluateVouchedCI( + input({ commits: [commit('aaa'), commit('bbb', { verified: false, reason: 'unsigned' })] }), + ), + /commit bbb signature is not verified \(unsigned\)/, + ); + }); + + it('refuses a verified signature whose reason is not "valid"', () => { + expectRefusal( + evaluateVouchedCI( + input({ commits: [commit('aaa', { verified: true, reason: 'unknown_key' })] }), + ), + /not verified \(unknown_key\)/, + ); + }); + + it('refuses when the verification object is missing', () => { + expectRefusal( + evaluateVouchedCI(input({ commits: [commit('aaa', { reason: null })] })), + /not verified \(missing\)/, + ); + }); + + it('refuses when a commit was authored by someone else but committed by the vouched user', () => { + expectRefusal( + evaluateVouchedCI(input({ commits: [commit('aaa', { author: bob.id })] })), + /commit aaa author \(2002\) is not alice/, + ); + }); + + it('refuses when a commit was committed by someone else (e.g. web-flow) but authored by the vouched user', () => { + expectRefusal( + evaluateVouchedCI(input({ commits: [commit('aaa', { committer: 19864447 })] })), + /commit aaa committer \(19864447\) is not alice/, + ); + }); + + it('refuses when GitHub could not resolve the author or committer to an account', () => { + expectRefusal( + evaluateVouchedCI(input({ commits: [commit('aaa', { author: null })] })), + /author \(null\) is not alice/, + ); + expectRefusal( + evaluateVouchedCI(input({ commits: [{ ...commit('aaa'), committer: {} }] })), + /committer \(null\) is not alice/, + ); + }); + + it('refuses when a commit from another vouched user is mixed in', () => { + const decision = evaluateVouchedCI( + input({ + vouched: [alice, bob], + commits: [commit('aaa'), commit('bbb', { author: bob.id, committer: bob.id })], + }), + ); + expectRefusal(decision, /commit bbb author \(2002\) is not alice/); + }); + + it('refuses when an earlier commit in the range is not vouched, even if the head is', () => { + expectRefusal( + evaluateVouchedCI( + input({ + commits: [commit('aaa', { author: bob.id, committer: bob.id }), commit('bbb')], + }), + ), + /commit aaa/, + ); + }); +}); + +const BASE_REPO_ID = 55; +const FORK_REPO_ID = 77; + +const run = (overrides: Partial = {}): VouchedCIWorkflowRun => ({ + id: 1, + head_sha: 'bbb', + head_branch: 'feature', + head_repository: { id: FORK_REPO_ID, owner: { login: 'alice' } }, + event: 'pull_request', + status: 'completed', + conclusion: 'action_required', + ...overrides, +}); + +const expectNotApprovable = (r: VouchedCIWorkflowRun, pattern: RegExp) => { + const check = isApprovableRun(r, BASE_REPO_ID); + assert.equal(check.approvable, false); + if (!check.approvable) assert.match(check.reason, pattern); +}; + +describe('isApprovableRun', () => { + it('accepts a gated pull_request run from a fork', () => { + assert.deepEqual(isApprovableRun(run(), BASE_REPO_ID), { approvable: true }); + }); + + it('also accepts a run whose status (rather than conclusion) is action_required', () => { + assert.deepEqual( + isApprovableRun(run({ status: 'action_required', conclusion: null }), BASE_REPO_ID), + { approvable: true }, + ); + }); + + it('rejects runs for other events, including pull_request_target', () => { + expectNotApprovable(run({ event: 'pull_request_target' }), /pull_request_target, not/); + expectNotApprovable(run({ event: 'push' }), /push, not/); + }); + + it('rejects runs that are not awaiting approval', () => { + expectNotApprovable(run({ status: 'queued', conclusion: null }), /not awaiting approval/); + expectNotApprovable( + run({ status: 'completed', conclusion: 'success' }), + /not awaiting approval/, + ); + expectNotApprovable(run({ status: 'requested', conclusion: null }), /not awaiting approval/); + }); + + it('rejects runs from the repository itself', () => { + expectNotApprovable( + run({ head_repository: { id: BASE_REPO_ID, owner: { login: 'org' } } }), + /not from a fork/, + ); + }); + + it('rejects runs whose head repository is missing', () => { + expectNotApprovable(run({ head_repository: null }), /no head repository/); + }); + + it('rejects runs whose head repository owner is missing', () => { + expectNotApprovable(run({ head_repository: { id: FORK_REPO_ID, owner: null } }), /no owner/); + }); + + it('rejects runs without a head branch, which cannot be resolved to a pull request', () => { + expectNotApprovable(run({ head_branch: null }), /no head branch/); + }); +}); + +describe('matchPullRequestForRun', () => { + const pr = (number: number, sha = 'bbb', repoId: number | null = FORK_REPO_ID) => ({ + number, + head: { sha, repo: repoId === null ? null : { id: repoId } }, + }); + + it('returns the single pull request pinned to the run head and head repository', () => { + const match = matchPullRequestForRun([pr(1, 'aaa'), pr(2), pr(3, 'bbb', 78)], run()); + assert.equal(match.pullRequest?.number, 2); + }); + + it('returns null when there are no candidates', () => { + const match = matchPullRequestForRun([], run()); + assert.equal(match.pullRequest, null); + if (!match.pullRequest) assert.match(match.reason, /no open pull request has head bbb/); + }); + + it('returns null when two pull requests share the head', () => { + const match = matchPullRequestForRun([pr(1), pr(2)], run()); + assert.equal(match.pullRequest, null); + if (!match.pullRequest) assert.match(match.reason, /2 open pull requests \(#1, #2\)/); + }); + + it('ignores pull requests whose head sha differs from the run', () => { + const match = matchPullRequestForRun([pr(1, 'ccc')], run()); + assert.equal(match.pullRequest, null); + }); + + it('ignores pull requests whose head repository differs from the run', () => { + assert.equal(matchPullRequestForRun([pr(1, 'bbb', 78)], run()).pullRequest, null); + assert.equal(matchPullRequestForRun([pr(1, 'bbb', null)], run()).pullRequest, null); + assert.equal(matchPullRequestForRun([pr(1)], run({ head_repository: null })).pullRequest, null); + }); +}); diff --git a/src/vouched-ci.ts b/src/vouched-ci.ts new file mode 100644 index 0000000..553cf3d --- /dev/null +++ b/src/vouched-ci.ts @@ -0,0 +1,215 @@ +import type { components } from '@octokit/openapi-types'; +import type { VouchedUser } from './permissions/types.js'; + +// `GET /repos/{owner}/{repo}/pulls/{pull_number}/commits` never returns more +// than this many commits, so beyond it the listing is silently incomplete. +export const MAX_PULL_REQUEST_COMMITS = 250; + +type SimpleUser = components['schemas']['simple-user']; + +/** + * The subset of a REST commit object (`GET /repos/{owner}/{repo}/pulls/{pull_number}/commits`) + * that the decision depends on. `author` / `committer` are GitHub's server side + * resolution of the commit emails to accounts; the git metadata in + * `commit.author` / `commit.committer` is free-form text and deliberately unused. + */ +export interface VouchedCICommit { + sha: string; + author: SimpleUser | Record | null; + committer: SimpleUser | Record | null; + commit: { + verification?: { + verified: boolean; + reason: string; + } | null; + }; +} + +/** + * The subset of a `workflow_run` webhook payload that the decision depends on. + * `head_repository` is typed as non-null by the webhook schema but the REST + * representation can be `null` once a fork is deleted, so it is guarded anyway. + */ +export interface VouchedCIWorkflowRun { + id: number; + head_sha: string; + head_branch: string | null; + head_repository: { id: number; owner: { login: string } | null } | null; + event: string; + status: string | null; + conclusion: string | null; +} + +export interface VouchedCIInput { + vouched: VouchedUser[]; + /** + * The `triggering_actor` of the `workflow_run` webhook event, i.e. the + * authenticated user whose push to the fork created the run + */ + sender: { id: number; login: string }; + /** + * The run's `head_sha` from the webhook payload, the tree the approved run will execute + */ + headSha: string; + /** + * The pull request as re-fetched *after* listing its commits + */ + pullRequest: { + headSha: string; + headRepoId: number | null; + baseRepoId: number; + commitCount: number; + }; + commits: VouchedCICommit[]; +} + +export type VouchedCIDecision = + | { approve: true; vouchedUser: VouchedUser } + | { approve: false; reason: string }; + +const refuse = (reason: string): VouchedCIDecision => ({ approve: false, reason }); + +const userId = (user: SimpleUser | Record | null): number | null => + user && typeof user.id === 'number' ? user.id : null; + +/** + * Finds the vouched entry for a user. The numeric id is the identity, the login + * is cross-checked (case-insensitively, like GitHub) so that a typo in the id + * can never vouch for an unrelated account. + */ +export const findVouchedUser = ( + vouched: VouchedUser[], + user: { id: number; login: string }, +): VouchedUser | undefined => + vouched.find((v) => v.id === user.id && v.login.toLowerCase() === user.login.toLowerCase()); + +/** + * Decides whether a workflow run for `headSha` may be approved. Approving a + * run executes the whole head tree, so every commit in the pull request range + * must have been authored, committed and verified-signed by the vouched user + * who pushed it. Pure: every input comes from GitHub, nothing is fetched. + */ +export function evaluateVouchedCI(input: VouchedCIInput): VouchedCIDecision { + const { sender, headSha, pullRequest, commits } = input; + + if (pullRequest.headRepoId === null) return refuse('head repository no longer exists'); + if (pullRequest.headRepoId === pullRequest.baseRepoId) { + return refuse('pull request is not from a fork'); + } + + const vouchedUser = findVouchedUser(input.vouched, sender); + if (!vouchedUser) return refuse(`sender ${sender.login} (${sender.id}) is not vouched`); + + if (pullRequest.headSha !== headSha) { + return refuse(`head moved from ${headSha} to ${pullRequest.headSha} during verification`); + } + if (pullRequest.commitCount > MAX_PULL_REQUEST_COMMITS) { + return refuse(`pull request has ${pullRequest.commitCount} commits, more than can be listed`); + } + if (commits.length === 0) return refuse('pull request has no commits'); + if (commits.length !== pullRequest.commitCount) { + return refuse( + `listed ${commits.length} commits but the pull request has ${pullRequest.commitCount}`, + ); + } + if (!commits.some((c) => c.sha === headSha)) { + return refuse(`commit listing does not contain head ${headSha}`); + } + + for (const commit of commits) { + const author = userId(commit.author); + const committer = userId(commit.committer); + if (author !== vouchedUser.id) { + return refuse(`commit ${commit.sha} author (${author}) is not ${vouchedUser.login}`); + } + if (committer !== vouchedUser.id) { + return refuse(`commit ${commit.sha} committer (${committer}) is not ${vouchedUser.login}`); + } + const verification = commit.commit.verification; + if (!verification?.verified || verification.reason !== 'valid') { + return refuse( + `commit ${commit.sha} signature is not verified (${verification?.reason || 'missing'})`, + ); + } + } + + return { approve: true, vouchedUser }; +} + +export type VouchedCIRunCheck = { approvable: true } | { approvable: false; reason: string }; + +/** + * Cheap, payload-only checks for a `workflow_run` `requested` event: is this a + * run that `vouched_ci` could ever approve? Only `pull_request` runs + * (`pull_request_target` and friends already run in the base repository + * context), only runs GitHub gated on approval (reported as `completed` / + * `action_required` in the webhook payload) and only runs from a fork. This + * event fires for every run in the organization, so it runs before any I/O. + */ +export function isApprovableRun(run: VouchedCIWorkflowRun, baseRepoId: number): VouchedCIRunCheck { + if (run.event !== 'pull_request') { + return { approvable: false, reason: `run event is ${run.event}, not pull_request` }; + } + if (run.conclusion !== 'action_required' && run.status !== 'action_required') { + return { + approvable: false, + reason: `run is not awaiting approval (${run.status} / ${run.conclusion})`, + }; + } + if (!run.head_repository) return { approvable: false, reason: 'run has no head repository' }; + if (run.head_repository.id === baseRepoId) { + return { approvable: false, reason: 'run is not from a fork' }; + } + if (!run.head_repository.owner?.login) { + return { approvable: false, reason: 'run head repository has no owner' }; + } + if (!run.head_branch) return { approvable: false, reason: 'run has no head branch' }; + return { approvable: true }; +} + +export interface VouchedCIPullRequest { + number: number; + head: { + sha: string; + repo: { id: number } | null; + }; +} + +export type VouchedCIPullRequestMatch = + | { pullRequest: T } + | { pullRequest: null; reason: string }; + +/** + * Picks the pull request a fork run belongs to. `workflow_run.pull_requests` is + * empty for runs from forks, so the candidates come from listing the open pull + * requests for the run's `owner:branch` head; a candidate only counts if it is + * pinned to the exact tree the run will execute *and* comes from the run's head + * repository (a fork of a fork can reuse the owner login of a deleted fork). + * Exactly one pull request must match: with two the run cannot be attributed. + */ +export function matchPullRequestForRun( + pullRequests: T[], + run: Pick, +): VouchedCIPullRequestMatch { + const matches = pullRequests.filter( + (pr) => + pr.head.sha === run.head_sha && + run.head_repository !== null && + pr.head.repo?.id === run.head_repository.id, + ); + if (matches.length === 1) return { pullRequest: matches[0] }; + if (matches.length === 0) { + return { + pullRequest: null, + reason: `no open pull request has head ${run.head_sha} from repository ${ + run.head_repository?.id ?? 'unknown' + } (${pullRequests.length} candidate(s))`, + }; + } + return { + pullRequest: null, + reason: `${matches.length} open pull requests (${matches + .map((pr) => `#${pr.number}`) + .join(', ')}) share head ${run.head_sha}`, + }; +}