Skip to content
Draft
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
45 changes: 45 additions & 0 deletions .github/workflows/ops-trusted-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Ops trusted CI definition

on: pull_request

permissions:
contents: read

jobs:
verify:
name: ops-trusted-ci
runs-on: ubuntu-24.04
timeout-minutes: 2
env:
EXPECTED_REPOSITORY: slowchop/ops
EXPECTED_BASE_REF: main
EXPECTED_WORKFLOW_PATH: .github/workflows/ci.yml
EXPECTED_WORKFLOW_BLOB: 5a067cd347b739c0e7f79fd07be18f5f5e56b342
GH_API_URL: ${{ github.api_url }}
GH_EVENT_NAME: ${{ github.event_name }}
GH_REPOSITORY: ${{ github.repository }}
steps:
- name: Load the trusted verifier
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: slowchop/.github
ref: ${{ github.workflow_sha }}
sparse-checkout: |
scripts/verify-ops-ci-blob.sh
scripts/test-verify-ops-ci-blob.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Verify immutable Ops CI definition
if: github.repository == 'slowchop/ops'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: ./scripts/verify-ops-ci-blob.sh
- name: Self-test the public verifier
if: github.repository == 'slowchop/.github'
shell: bash
run: ./scripts/test-verify-ops-ci-blob.sh
- name: Reject an unexpected repository
if: github.repository != 'slowchop/ops' && github.repository != 'slowchop/.github'
shell: bash
run: exit 1
105 changes: 105 additions & 0 deletions scripts/test-verify-ops-ci-blob.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly script_dir
readonly verifier="$script_dir/verify-ops-ci-blob.sh"
readonly expected_blob="5a067cd347b739c0e7f79fd07be18f5f5e56b342"
readonly head_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/test-ops-trusted-ci.XXXXXX")"
trap 'rm -rf -- "$tmp_dir"' EXIT

cat >"$tmp_dir/event.json" <<JSON
{"pull_request":{"base":{"ref":"main","repo":{"full_name":"slowchop/ops"}},"head":{"sha":"$head_sha","repo":{"full_name":"slowchop/ops"}}}}
JSON

cat >"$tmp_dir/fake-curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
destination=""
url=""
while (($#)); do
case "$1" in
--output) destination="$2"; shift 2 ;;
http*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s\n' "$url" >>"$CURL_LOG"
case "$url" in
*/git/commits/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
printf '{"sha":"%s","tree":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}\n' "${RETURNED_COMMIT_SHA:-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}" >"$destination" ;;
*/git/trees/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb)
printf '{"truncated":%s,"tree":[%s]}\n' "${ROOT_TRUNCATED:-false}" "${ROOT_ENTRY:-{\"path\":\".github\",\"type\":\"tree\",\"mode\":\"040000\",\"sha\":\"cccccccccccccccccccccccccccccccccccccccc\"}}" >"$destination" ;;
*/git/trees/cccccccccccccccccccccccccccccccccccccccc)
printf '{"truncated":false,"tree":[%s]}\n' "${GITHUB_ENTRY:-{\"path\":\"workflows\",\"type\":\"tree\",\"mode\":\"040000\",\"sha\":\"dddddddddddddddddddddddddddddddddddddddd\"}}" >"$destination" ;;
*/git/trees/dddddddddddddddddddddddddddddddddddddddd)
printf '{"truncated":false,"tree":[%s]}\n' "${WORKFLOW_ENTRY:-{\"path\":\"ci.yml\",\"type\":\"blob\",\"mode\":\"100644\",\"sha\":\"5a067cd347b739c0e7f79fd07be18f5f5e56b342\"}}" >"$destination" ;;
*) exit 22 ;;
esac
SH
chmod +x "$tmp_dir/fake-curl"

run_verifier() {
env \
CURL_BIN="$tmp_dir/fake-curl" \
CURL_LOG="$tmp_dir/curl.log" \
EXPECTED_REPOSITORY="${EXPECTED_REPOSITORY_OVERRIDE:-slowchop/ops}" \
EXPECTED_BASE_REF="main" \
EXPECTED_WORKFLOW_PATH=".github/workflows/ci.yml" \
EXPECTED_WORKFLOW_BLOB="${EXPECTED_BLOB_OVERRIDE:-$expected_blob}" \
GH_API_URL="https://api.github.test" \
GH_EVENT_NAME="${EVENT_NAME_OVERRIDE:-pull_request}" \
GITHUB_EVENT_PATH="${EVENT_PATH_OVERRIDE:-$tmp_dir/event.json}" \
GH_REPOSITORY="${REPOSITORY_OVERRIDE:-slowchop/ops}" \
GH_TOKEN="test_token" \
RETURNED_COMMIT_SHA="${RETURNED_COMMIT_SHA:-}" \
ROOT_ENTRY="${ROOT_ENTRY:-}" \
ROOT_TRUNCATED="${ROOT_TRUNCATED:-}" \
GITHUB_ENTRY="${GITHUB_ENTRY:-}" \
WORKFLOW_ENTRY="${WORKFLOW_ENTRY:-}" \
"$verifier"
}

expect_failure() {
local label="$1"
shift
if (
local assignment
for assignment in "$@"; do
export "${assignment?}"
done
run_verifier
) >"$tmp_dir/output" 2>&1; then
printf 'expected failure: %s\n' "$label" >&2
exit 1
fi
}

run_verifier >/dev/null
expect_failure "wrong repository" REPOSITORY_OVERRIDE=slowchop/other
expect_failure "wrong event" EVENT_NAME_OVERRIDE=push

jq '.pull_request.base.ref = "develop"' "$tmp_dir/event.json" >"$tmp_dir/wrong-base.json"
expect_failure "wrong base" EVENT_PATH_OVERRIDE="$tmp_dir/wrong-base.json"
jq '.pull_request.head.sha = "not-a-sha"' "$tmp_dir/event.json" >"$tmp_dir/wrong-head.json"
expect_failure "invalid head" EVENT_PATH_OVERRIDE="$tmp_dir/wrong-head.json"
jq '.pull_request.head.repo.full_name = "someone/fork"' "$tmp_dir/event.json" >"$tmp_dir/fork.json"
expect_failure "fork head" EVENT_PATH_OVERRIDE="$tmp_dir/fork.json"
expect_failure "wrong returned commit" RETURNED_COMMIT_SHA=ffffffffffffffffffffffffffffffffffffffff

expect_failure "missing path" ROOT_ENTRY='{"path":"other","type":"tree","mode":"040000","sha":"cccccccccccccccccccccccccccccccccccccccc"}'
expect_failure "duplicate path" ROOT_ENTRY='{"path":".github","type":"tree","mode":"040000","sha":"cccccccccccccccccccccccccccccccccccccccc"},{"path":".github","type":"tree","mode":"040000","sha":"cccccccccccccccccccccccccccccccccccccccc"}'
expect_failure "malformed tree metadata" ROOT_ENTRY='not-json'
expect_failure "truncated tree metadata" ROOT_TRUNCATED=true
expect_failure "symlink" WORKFLOW_ENTRY='{"path":"ci.yml","type":"blob","mode":"120000","sha":"5a067cd347b739c0e7f79fd07be18f5f5e56b342"}'
expect_failure "tree in place of blob" WORKFLOW_ENTRY='{"path":"ci.yml","type":"tree","mode":"040000","sha":"5a067cd347b739c0e7f79fd07be18f5f5e56b342"}'
expect_failure "wrong blob" WORKFLOW_ENTRY='{"path":"ci.yml","type":"blob","mode":"100644","sha":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}'

if grep -q '/git/blobs/' "$tmp_dir/curl.log"; then
printf 'verifier downloaded private blob contents\n' >&2
exit 1
fi

printf 'all trusted CI verifier tests passed\n'
88 changes: 88 additions & 0 deletions scripts/verify-ops-ci-blob.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail

readonly expected_repository="${EXPECTED_REPOSITORY:?EXPECTED_REPOSITORY is required}"
readonly expected_base_ref="${EXPECTED_BASE_REF:?EXPECTED_BASE_REF is required}"
readonly expected_path="${EXPECTED_WORKFLOW_PATH:?EXPECTED_WORKFLOW_PATH is required}"
readonly expected_blob="${EXPECTED_WORKFLOW_BLOB:?EXPECTED_WORKFLOW_BLOB is required}"
readonly api_url="${GH_API_URL:?GH_API_URL is required}"
readonly event_name="${GH_EVENT_NAME:?GH_EVENT_NAME is required}"
readonly event_path="${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required}"
readonly repository="${GH_REPOSITORY:?GH_REPOSITORY is required}"
readonly token="${GH_TOKEN:?GH_TOKEN is required}"
readonly curl_bin="${CURL_BIN:-curl}"

fail() {
printf 'trusted CI verification failed: %s\n' "$1" >&2
exit 1
}

[[ "$event_name" == "pull_request" ]] || fail "unsupported event"
[[ "$repository" == "$expected_repository" ]] || fail "unexpected repository"
[[ "$expected_path" == ".github/workflows/ci.yml" ]] || fail "unexpected protected path"
[[ "$expected_blob" =~ ^[0-9a-f]{40}$ ]] || fail "invalid expected blob ID"
[[ "$token" =~ ^[A-Za-z0-9_]+$ ]] || fail "invalid GitHub token"
[[ -f "$event_path" && ! -L "$event_path" ]] || fail "invalid event payload"

base_repository="$(jq -er '.pull_request.base.repo.full_name | select(type == "string")' "$event_path")" || fail "missing base repository"
base_ref="$(jq -er '.pull_request.base.ref | select(type == "string")' "$event_path")" || fail "missing base ref"
head_repository="$(jq -er '.pull_request.head.repo.full_name | select(type == "string")' "$event_path")" || fail "missing head repository"
head_sha="$(jq -er '.pull_request.head.sha | select(type == "string")' "$event_path")" || fail "missing head SHA"

[[ "$base_repository" == "$expected_repository" ]] || fail "unexpected base repository"
[[ "$base_ref" == "$expected_base_ref" ]] || fail "unexpected base ref"
[[ "$head_repository" == "$expected_repository" ]] || fail "fork pull requests are not accepted"
[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || fail "invalid head SHA"

tmp_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/ops-trusted-ci.XXXXXX")"
chmod 700 "$tmp_dir"
trap 'rm -rf -- "$tmp_dir"' EXIT

api_get() {
local endpoint="$1"
local destination="$2"
{
printf 'header = "Accept: application/vnd.github+json"\n'
printf 'header = "Authorization: Bearer %s"\n' "$token"
printf 'header = "X-GitHub-Api-Version: 2022-11-28"\n'
} | "$curl_bin" --disable --config - --silent --show-error --fail-with-body \
--output "$destination" \
"${api_url}${endpoint}" || fail "GitHub metadata request failed"
}

require_entry() {
local tree_file="$1"
local path="$2"
local type="$3"
local mode="$4"
local count
local sha

jq -e '.truncated == false and (.tree | type == "array")' "$tree_file" >/dev/null || fail "invalid or truncated tree metadata"
count="$(jq -er --arg path "$path" '[.tree[] | select(.path == $path)] | length' "$tree_file")" || fail "invalid tree metadata"
[[ "$count" == "1" ]] || fail "protected path component is missing or ambiguous"
jq -e --arg path "$path" --arg type "$type" --arg mode "$mode" \
'.tree[] | select(.path == $path and .type == $type and .mode == $mode)' \
"$tree_file" >/dev/null || fail "protected path component has an unsafe type or mode"
sha="$(jq -er --arg path "$path" '.tree[] | select(.path == $path) | .sha | select(type == "string")' "$tree_file")" || fail "missing tree object ID"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || fail "invalid tree object ID"
printf '%s\n' "$sha"
}

commit_file="$tmp_dir/commit.json"
tree_file="$tmp_dir/tree.json"
api_get "/repos/${expected_repository}/git/commits/${head_sha}" "$commit_file"
commit_sha="$(jq -er '.sha | select(type == "string")' "$commit_file")" || fail "missing commit ID"
[[ "$commit_sha" == "$head_sha" ]] || fail "GitHub returned the wrong head commit"
tree_sha="$(jq -er '.tree.sha | select(type == "string")' "$commit_file")" || fail "missing root tree ID"
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]] || fail "invalid root tree ID"

api_get "/repos/${expected_repository}/git/trees/${tree_sha}" "$tree_file"
github_tree="$(require_entry "$tree_file" ".github" "tree" "040000")"
api_get "/repos/${expected_repository}/git/trees/${github_tree}" "$tree_file"
workflows_tree="$(require_entry "$tree_file" "workflows" "tree" "040000")"
api_get "/repos/${expected_repository}/git/trees/${workflows_tree}" "$tree_file"
workflow_blob="$(require_entry "$tree_file" "ci.yml" "blob" "100644")"

[[ "$workflow_blob" == "$expected_blob" ]] || fail "CI definition is not the trusted version"
printf 'Trusted Ops CI definition verified for the pull request head.\n'