diff --git a/.github/workflows/ops-trusted-ci.yml b/.github/workflows/ops-trusted-ci.yml new file mode 100644 index 0000000..9221898 --- /dev/null +++ b/.github/workflows/ops-trusted-ci.yml @@ -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 diff --git a/scripts/test-verify-ops-ci-blob.sh b/scripts/test-verify-ops-ci-blob.sh new file mode 100755 index 0000000..7441322 --- /dev/null +++ b/scripts/test-verify-ops-ci-blob.sh @@ -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" <"$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' diff --git a/scripts/verify-ops-ci-blob.sh b/scripts/verify-ops-ci-blob.sh new file mode 100755 index 0000000..e5a7825 --- /dev/null +++ b/scripts/verify-ops-ci-blob.sh @@ -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'