Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
- <object>
# 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: <gh_username>
id: <gh_user_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/<login>`.

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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"",
Expand Down
122 changes: 120 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
23 changes: 23 additions & 0 deletions src/octokit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ export async function getOctokit(org: string, forceReadOnly = false): Promise<Oc
return octokitMap.get(mapKey)!;
}

// Approving fork pull request workflow runs needs "actions: write" (and listing
// the PR's commits needs "pull_requests: read"), neither of which the rest of
// Sheriff uses. GitHub refuses to mint a token that requests a permission the
// App has not been granted, so this lives on its own token to ensure a missing
// App permission only breaks vouched_ci rather than every Sheriff API call.
export async function getVouchedCIOctokit(org: string): Promise<Octokit> {
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,
Expand Down
21 changes: 21 additions & 0 deletions src/permissions/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ const validateConfigFast = async (config: EnterpriseConfig): Promise<EnterpriseC
})
.required(),
common_rulesets: Joi.array().items(rulesetValidator).min(1).optional(),
vouched_ci: Joi.array()
.items(
Joi.object({
login: Joi.string().min(1).required(),
id: Joi.number().integer().min(1).required(),
}),
)
.optional(),
customProperties: Joi.array()
.items(
Joi.object({
Expand Down Expand Up @@ -370,6 +378,19 @@ const validateConfigFast = async (config: EnterpriseConfig): Promise<EnterpriseC
seenRepos.add(repo.name);
}

const seenVouchedIds = new Set<number>();
const seenVouchedLogins = new Set<string>();
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) {
Expand Down
15 changes: 15 additions & 0 deletions src/permissions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,28 @@ 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;
teams: TeamConfig[];
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 {
Expand Down
Loading