From 8c53cbd3d2123a2b8924dbcc648ffebff6400114 Mon Sep 17 00:00:00 2001 From: gak Date: Thu, 13 Aug 2026 11:36:19 +1000 Subject: [PATCH 1/3] Add trusted Ops CI policy workflow Signed-off-by: gak --- .github/workflows/ops-trusted-ci.yml | 35 +++++++++ scripts/test-verify-ops-ci-blob.sh | 105 +++++++++++++++++++++++++++ scripts/verify-ops-ci-blob.sh | 88 ++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 .github/workflows/ops-trusted-ci.yml create mode 100755 scripts/test-verify-ops-ci-blob.sh create mode 100755 scripts/verify-ops-ci-blob.sh diff --git a/.github/workflows/ops-trusted-ci.yml b/.github/workflows/ops-trusted-ci.yml new file mode 100644 index 0000000..a57f3cc --- /dev/null +++ b/.github/workflows/ops-trusted-ci.yml @@ -0,0 +1,35 @@ +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: d827e31018faee923db062db9de77995e93c7354 + GH_API_URL: ${{ github.api_url }} + GH_EVENT_NAME: ${{ github.event_name }} + GH_EVENT_PATH: ${{ github.event_path }} + 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 + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Verify immutable Ops CI definition + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: ./scripts/verify-ops-ci-blob.sh diff --git a/scripts/test-verify-ops-ci-blob.sh b/scripts/test-verify-ops-ci-blob.sh new file mode 100755 index 0000000..95223f8 --- /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="d827e31018faee923db062db9de77995e93c7354" +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\":\"d827e31018faee923db062db9de77995e93c7354\"}}" >"$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}" \ + GH_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":"d827e31018faee923db062db9de77995e93c7354"}' +expect_failure "tree in place of blob" WORKFLOW_ENTRY='{"path":"ci.yml","type":"tree","mode":"040000","sha":"d827e31018faee923db062db9de77995e93c7354"}' +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..5e05334 --- /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="${GH_EVENT_PATH:?GH_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' From bd2a0704fb8ec1f7b87a43cb76ab7c6934dfa33c Mon Sep 17 00:00:00 2001 From: gak Date: Thu, 13 Aug 2026 11:38:10 +1000 Subject: [PATCH 2/3] Use the Actions event payload path Signed-off-by: gak --- .github/workflows/ops-trusted-ci.yml | 1 - scripts/test-verify-ops-ci-blob.sh | 2 +- scripts/verify-ops-ci-blob.sh | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ops-trusted-ci.yml b/.github/workflows/ops-trusted-ci.yml index a57f3cc..5767626 100644 --- a/.github/workflows/ops-trusted-ci.yml +++ b/.github/workflows/ops-trusted-ci.yml @@ -17,7 +17,6 @@ jobs: EXPECTED_WORKFLOW_BLOB: d827e31018faee923db062db9de77995e93c7354 GH_API_URL: ${{ github.api_url }} GH_EVENT_NAME: ${{ github.event_name }} - GH_EVENT_PATH: ${{ github.event_path }} GH_REPOSITORY: ${{ github.repository }} steps: - name: Load the trusted verifier diff --git a/scripts/test-verify-ops-ci-blob.sh b/scripts/test-verify-ops-ci-blob.sh index 95223f8..2acfa67 100755 --- a/scripts/test-verify-ops-ci-blob.sh +++ b/scripts/test-verify-ops-ci-blob.sh @@ -51,7 +51,7 @@ run_verifier() { EXPECTED_WORKFLOW_BLOB="${EXPECTED_BLOB_OVERRIDE:-$expected_blob}" \ GH_API_URL="https://api.github.test" \ GH_EVENT_NAME="${EVENT_NAME_OVERRIDE:-pull_request}" \ - GH_EVENT_PATH="${EVENT_PATH_OVERRIDE:-$tmp_dir/event.json}" \ + 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:-}" \ diff --git a/scripts/verify-ops-ci-blob.sh b/scripts/verify-ops-ci-blob.sh index 5e05334..e5a7825 100755 --- a/scripts/verify-ops-ci-blob.sh +++ b/scripts/verify-ops-ci-blob.sh @@ -7,7 +7,7 @@ readonly expected_path="${EXPECTED_WORKFLOW_PATH:?EXPECTED_WORKFLOW_PATH is requ 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="${GH_EVENT_PATH:?GH_EVENT_PATH 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}" From 491ee0b87a667661c8b7369110e0b9bc67cff441 Mon Sep 17 00:00:00 2001 From: gak Date: Thu, 13 Aug 2026 12:58:19 +1000 Subject: [PATCH 3/3] Repin trusted Ops CI after transport merge Signed-off-by: gak --- .github/workflows/ops-trusted-ci.yml | 15 +++++++++++++-- scripts/test-verify-ops-ci-blob.sh | 8 ++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ops-trusted-ci.yml b/.github/workflows/ops-trusted-ci.yml index 5767626..9221898 100644 --- a/.github/workflows/ops-trusted-ci.yml +++ b/.github/workflows/ops-trusted-ci.yml @@ -14,7 +14,7 @@ jobs: EXPECTED_REPOSITORY: slowchop/ops EXPECTED_BASE_REF: main EXPECTED_WORKFLOW_PATH: .github/workflows/ci.yml - EXPECTED_WORKFLOW_BLOB: d827e31018faee923db062db9de77995e93c7354 + EXPECTED_WORKFLOW_BLOB: 5a067cd347b739c0e7f79fd07be18f5f5e56b342 GH_API_URL: ${{ github.api_url }} GH_EVENT_NAME: ${{ github.event_name }} GH_REPOSITORY: ${{ github.repository }} @@ -24,11 +24,22 @@ jobs: with: repository: slowchop/.github ref: ${{ github.workflow_sha }} - sparse-checkout: scripts/verify-ops-ci-blob.sh + 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 index 2acfa67..7441322 100755 --- a/scripts/test-verify-ops-ci-blob.sh +++ b/scripts/test-verify-ops-ci-blob.sh @@ -4,7 +4,7 @@ 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="d827e31018faee923db062db9de77995e93c7354" +readonly expected_blob="5a067cd347b739c0e7f79fd07be18f5f5e56b342" readonly head_sha="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/test-ops-trusted-ci.XXXXXX")" @@ -35,7 +35,7 @@ case "$url" in */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\":\"d827e31018faee923db062db9de77995e93c7354\"}}" >"$destination" ;; + printf '{"truncated":false,"tree":[%s]}\n' "${WORKFLOW_ENTRY:-{\"path\":\"ci.yml\",\"type\":\"blob\",\"mode\":\"100644\",\"sha\":\"5a067cd347b739c0e7f79fd07be18f5f5e56b342\"}}" >"$destination" ;; *) exit 22 ;; esac SH @@ -93,8 +93,8 @@ expect_failure "missing path" ROOT_ENTRY='{"path":"other","type":"tree","mode":" 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":"d827e31018faee923db062db9de77995e93c7354"}' -expect_failure "tree in place of blob" WORKFLOW_ENTRY='{"path":"ci.yml","type":"tree","mode":"040000","sha":"d827e31018faee923db062db9de77995e93c7354"}' +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