Skip to content

Commit a65fb08

Browse files
committed
security: gate privileged workflows on trusted refs
1 parent f7cce53 commit a65fb08

11 files changed

Lines changed: 1046 additions & 83 deletions
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env bash
2+
3+
# Fail-closed guard for workflows that can publish artifacts, write repository
4+
# state, or read environment secrets. The workflow must still use a job-level
5+
# `if` with github.ref_protected so an untrusted ref never gets a runner.
6+
7+
set -euo pipefail
8+
9+
readonly HEX_SHA_RE='^[0-9a-fA-F]{40}$'
10+
readonly REF_NAME_RE='^refs/(heads/main|tags/v[0-9]+\.[0-9]+\.[0-9]+)$'
11+
12+
die() {
13+
echo "trusted-ref: $*" >&2
14+
exit 1
15+
}
16+
17+
require_value() {
18+
local name="$1"
19+
local value="${!name:-}"
20+
[[ -n "$value" ]] || die "$name is required"
21+
}
22+
23+
require_sha_value() {
24+
local name="$1"
25+
local value="$2"
26+
[[ "$value" =~ $HEX_SHA_RE ]] || die "$name must be a 40-character commit SHA"
27+
}
28+
29+
require_sha() {
30+
local name="$1"
31+
require_sha_value "$name" "${!name:-}"
32+
}
33+
34+
require_safe_path() {
35+
local path="$1"
36+
[[ "$path" != /* && "$path" != *..* && "$path" != *$'\n'* ]] || die "unsafe workflow path"
37+
}
38+
39+
require_value GITHUB_REPOSITORY
40+
require_value GITHUB_REF
41+
require_value GITHUB_REF_TYPE
42+
require_value GITHUB_SHA
43+
require_value GITHUB_WORKFLOW_REF
44+
require_value GITHUB_WORKFLOW_SHA
45+
require_value GITHUB_EVENT_NAME
46+
require_value GITHUB_REF_PROTECTED
47+
require_value TRUSTED_WORKFLOW_PATH
48+
require_value TRUSTED_REF_KIND
49+
require_value TRUSTED_BASE_SHA
50+
require_value TRUSTED_SOURCE_SHA
51+
52+
[[ "$GITHUB_REPOSITORY" == "${TRUSTED_REPOSITORY:-manaflow-ai/manaflow}" ]] || \
53+
die "unexpected repository: $GITHUB_REPOSITORY"
54+
[[ "$GITHUB_REF_PROTECTED" == "true" ]] || \
55+
die "ref is not protected; refusing privileged workflow"
56+
[[ "$GITHUB_EVENT_NAME" == "push" || "$GITHUB_EVENT_NAME" == "schedule" || \
57+
"$GITHUB_EVENT_NAME" == "workflow_call" ]] || \
58+
die "event $GITHUB_EVENT_NAME is not allowed"
59+
60+
require_safe_path "$TRUSTED_WORKFLOW_PATH"
61+
require_sha GITHUB_SHA
62+
require_sha GITHUB_WORKFLOW_SHA
63+
require_sha TRUSTED_BASE_SHA
64+
require_sha TRUSTED_SOURCE_SHA
65+
66+
# A reusable workflow inherits the caller's github context. The caller's ref
67+
# and SHA are therefore separate from the release ref and SHA checked out by
68+
# the called workflow. Normal workflows leave these overrides unset.
69+
event_ref="${TRUSTED_EVENT_REF:-$GITHUB_REF}"
70+
event_ref_type="${TRUSTED_EVENT_REF_TYPE:-$GITHUB_REF_TYPE}"
71+
event_sha="${TRUSTED_EVENT_SHA:-$GITHUB_SHA}"
72+
caller_ref="${TRUSTED_CALLER_REF:-$GITHUB_REF}"
73+
workflow_ref="${TRUSTED_WORKFLOW_REF:-$GITHUB_WORKFLOW_REF}"
74+
workflow_sha="${TRUSTED_WORKFLOW_SHA:-$GITHUB_WORKFLOW_SHA}"
75+
ref_protected="${TRUSTED_REF_PROTECTED:-$GITHUB_REF_PROTECTED}"
76+
called_workflow_path="${TRUSTED_CALLED_WORKFLOW_PATH:-$TRUSTED_WORKFLOW_PATH}"
77+
78+
[[ -n "$event_ref" && -n "$event_ref_type" && -n "$event_sha" &&
79+
-n "$caller_ref" && -n "$workflow_ref" && -n "$workflow_sha" &&
80+
-n "$ref_protected" ]] || die "trusted event context is incomplete"
81+
require_sha_value TRUSTED_EVENT_SHA "$event_sha"
82+
require_sha_value TRUSTED_WORKFLOW_SHA "$workflow_sha"
83+
[[ "$ref_protected" == "true" ]] || \
84+
die "ref is not protected; refusing privileged workflow"
85+
[[ "$TRUSTED_SOURCE_SHA" == "$event_sha" ]] || \
86+
die "trusted source SHA does not match the triggering revision"
87+
require_safe_path "$called_workflow_path"
88+
89+
workflow_ref_prefix="${GITHUB_REPOSITORY}/${TRUSTED_WORKFLOW_PATH}@"
90+
[[ "$workflow_ref" == "$workflow_ref_prefix"* ]] || \
91+
die "workflow ref does not identify the expected workflow"
92+
workflow_ref_suffix="${workflow_ref#"$workflow_ref_prefix"}"
93+
[[ "$workflow_ref_suffix" == "$caller_ref" ]] || \
94+
die "workflow ref does not identify the caller ref"
95+
96+
case "$TRUSTED_REF_KIND" in
97+
main)
98+
[[ "$event_ref" == "refs/heads/main" && "$event_ref_type" == "branch" ]] || \
99+
die "main policy requires refs/heads/main"
100+
[[ "$TRUSTED_BASE_SHA" == "$event_sha" ]] || \
101+
die "main policy requires the source SHA as its base"
102+
;;
103+
tag)
104+
[[ "$event_ref_type" == "tag" && "$event_ref" =~ $REF_NAME_RE &&
105+
"$event_ref" == refs/tags/* ]] || \
106+
die "tag policy requires a protected vX.Y.Z tag"
107+
[[ "$TRUSTED_BASE_SHA" == "$event_sha" ]] || \
108+
die "tag policy requires the source SHA as its base"
109+
;;
110+
release-branch)
111+
[[ "$event_ref_type" == "branch" && "$event_ref" =~ ^refs/heads/release/v[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
112+
die "release policy requires a release/vX.Y.Z branch"
113+
[[ "$TRUSTED_WORKFLOW_PATH" == ".github/workflows/release-pr.yml" &&
114+
"$caller_ref" == "refs/heads/main" ]] || \
115+
die "release policy requires the protected release-pr caller"
116+
;;
117+
*)
118+
die "unknown ref policy: $TRUSTED_REF_KIND"
119+
;;
120+
esac
121+
122+
# A checkout step must run before this script. It is deliberately checked
123+
# against the event SHA, rather than trusting the branch name or a mutable
124+
# checkout ref.
125+
checked_out_sha="$(git rev-parse --verify 'HEAD^{commit}' 2>/dev/null)" || \
126+
die "the checkout is not a commit"
127+
[[ "$checked_out_sha" == "$event_sha" ]] || \
128+
die "checked out $checked_out_sha, expected $event_sha"
129+
git cat-file -e "$TRUSTED_BASE_SHA^{commit}" 2>/dev/null || \
130+
die "trusted base is not available locally"
131+
git merge-base --is-ancestor "$TRUSTED_BASE_SHA" "$event_sha" || \
132+
die "checked out revision is not based on the trusted base SHA"
133+
134+
git cat-file -e "$workflow_sha:$TRUSTED_WORKFLOW_PATH" 2>/dev/null || \
135+
die "workflow file is missing from workflow SHA"
136+
git cat-file -e "HEAD:$TRUSTED_WORKFLOW_PATH" 2>/dev/null || \
137+
die "workflow file is missing from checked-out revision"
138+
139+
workflow_blob="$(git rev-parse "$workflow_sha:$TRUSTED_WORKFLOW_PATH")" || \
140+
die "cannot resolve workflow blob at workflow SHA"
141+
head_blob="$(git rev-parse "HEAD:$TRUSTED_WORKFLOW_PATH")" || \
142+
die "cannot resolve workflow blob at checked-out revision"
143+
[[ "$workflow_blob" == "$head_blob" ]] || \
144+
die "workflow file changed after the trusted workflow revision"
145+
146+
if [[ "$called_workflow_path" != "$TRUSTED_WORKFLOW_PATH" ]]; then
147+
git cat-file -e "$workflow_sha:$called_workflow_path" 2>/dev/null || \
148+
die "called workflow file is missing from workflow SHA"
149+
git cat-file -e "HEAD:$called_workflow_path" 2>/dev/null || \
150+
die "called workflow file is missing from checked-out revision"
151+
called_workflow_blob="$(git rev-parse "$workflow_sha:$called_workflow_path")" || \
152+
die "cannot resolve called workflow blob at workflow SHA"
153+
head_called_workflow_blob="$(git rev-parse "HEAD:$called_workflow_path")" || \
154+
die "cannot resolve called workflow blob at checked-out revision"
155+
[[ "$called_workflow_blob" == "$head_called_workflow_blob" ]] || \
156+
die "called workflow file changed after the trusted workflow revision"
157+
fi
158+
159+
git merge-base --is-ancestor "$workflow_sha" "$event_sha" || \
160+
die "workflow SHA is not an ancestor of the checked-out revision"
161+
162+
# Confirm that the remote ref still resolves to this exact commit. This closes
163+
# the dispatch race where a mutable branch or tag moves after GitHub creates a
164+
# run but before the privileged job starts. For annotated tags, accept the
165+
# peeled commit object as well as the direct ref object.
166+
remote_ref_output="$(GIT_TERMINAL_PROMPT=0 git ls-remote origin "$event_ref" "$event_ref^{}" 2>/dev/null)" || \
167+
die "unable to resolve remote ref"
168+
remote_ref_lines="$(printf '%s\n' "$remote_ref_output" | awk 'NF == 2 { count += 1 } END { print count + 0 }')"
169+
(( remote_ref_lines > 0 && remote_ref_lines <= 2 )) || \
170+
die "remote ref response is missing or unexpectedly large"
171+
if ! printf '%s\n' "$remote_ref_output" | awk -v expected="$event_sha" '
172+
NF == 2 && $1 == expected { found = 1 }
173+
END { exit(found ? 0 : 1) }
174+
'; then
175+
die "remote ref does not resolve to $event_sha"
176+
fi
177+
178+
if [[ "$TRUSTED_REF_KIND" == "tag" ]]; then
179+
# A protected release tag must point at a commit already reachable from
180+
# protected main. This rejects a newly-created tag carrying an unrelated
181+
# history, even when the tag itself is covered by a ruleset.
182+
main_ref_output="$(GIT_TERMINAL_PROMPT=0 git ls-remote origin refs/heads/main 2>/dev/null)" || \
183+
die "unable to resolve protected main"
184+
main_sha="$(printf '%s\n' "$main_ref_output" | awk 'NF == 2 && $2 == "refs/heads/main" { print $1; exit }')"
185+
[[ "$main_sha" =~ $HEX_SHA_RE ]] || die "protected main did not resolve to a commit"
186+
git merge-base --is-ancestor "$event_sha" "$main_sha" || \
187+
die "tag revision is not reachable from protected main"
188+
fi
189+
190+
if [[ "$TRUSTED_REF_KIND" == "release-branch" ]]; then
191+
# Generated release branches must carry the base SHA captured by the
192+
# trusted release-pr workflow. The value is passed only through the
193+
# reusable-workflow call, so a user cannot select an old branch.
194+
git cat-file -e "$TRUSTED_BASE_SHA^{commit}" 2>/dev/null || \
195+
die "trusted release base is not available locally"
196+
main_ref_output="$(GIT_TERMINAL_PROMPT=0 git ls-remote origin refs/heads/main 2>/dev/null)" || \
197+
die "unable to resolve protected main"
198+
main_sha="$(printf '%s\n' "$main_ref_output" | awk 'NF == 2 && $2 == "refs/heads/main" { print $1; exit }')"
199+
[[ "$main_sha" =~ $HEX_SHA_RE ]] || die "protected main did not resolve to a commit"
200+
[[ "$TRUSTED_BASE_SHA" == "$main_sha" ]] || \
201+
die "trusted release base is not the current protected main revision"
202+
release_parent="$(git rev-parse --verify "$event_sha^1" 2>/dev/null)" || \
203+
die "release branch tip has no parent"
204+
[[ "$release_parent" == "$TRUSTED_BASE_SHA" ]] || \
205+
die "release branch tip does not match the trusted base SHA"
206+
parent_count="$(git rev-list --parents -n 1 "$event_sha" | awk '{ print NF - 1 }')"
207+
[[ "$parent_count" == "1" ]] || \
208+
die "release branch tip must be a single-parent commit"
209+
210+
release_version="${event_ref#refs/heads/release/v}"
211+
release_subject="$(git log -1 --format=%s "$event_sha")"
212+
[[ "$release_subject" == "chore: release v$release_version" ]] || \
213+
die "release branch commit has an unexpected subject"
214+
[[ "$(git log -1 --format=%an "$event_sha")" == "github-actions[bot]" ]] || \
215+
die "release branch commit author is not the Actions bot"
216+
[[ "$(git log -1 --format=%ae "$event_sha")" == "github-actions[bot]@users.noreply.github.com" ]] || \
217+
die "release branch commit author email is not the Actions bot"
218+
[[ "$(git log -1 --format=%cn "$event_sha")" == "github-actions[bot]" ]] || \
219+
die "release branch commit committer is not the Actions bot"
220+
[[ "$(git log -1 --format=%ce "$event_sha")" == "github-actions[bot]@users.noreply.github.com" ]] || \
221+
die "release branch commit committer email is not the Actions bot"
222+
223+
changed_files="$(git diff-tree --no-commit-id --name-only -r "$event_sha")"
224+
[[ "$changed_files" == "apps/client/package.json" ]] || \
225+
die "release branch commit changes files outside the version manifest"
226+
fi
227+
228+
echo "trusted-ref: verified $GITHUB_REPOSITORY $event_ref at $event_sha"

.github/workflows/docker.yml

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ on:
2929
- crates/**
3030
# Shell scripts
3131
- prompt-wrapper.sh
32-
workflow_dispatch:
32+
33+
concurrency:
34+
group: docker-${{ github.ref }}
35+
cancel-in-progress: false
3336

3437
permissions:
3538
contents: read
@@ -41,11 +44,9 @@ env:
4144
jobs:
4245
docker-build:
4346
name: Build Docker image (${{ matrix.platform }})
44-
# The build pushes to Docker Hub with repository credentials. A manual
45-
# dispatch must use the protected main ref, never arbitrary branch code.
46-
if: >-
47-
github.ref == 'refs/heads/main' &&
48-
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
47+
# The build pushes to Docker Hub with repository credentials. It runs only
48+
# from a protected main push, never from a user-selected ref.
49+
if: github.ref == 'refs/heads/main' && github.ref_protected == true
4950
runs-on: ${{ matrix.runner }}
5051
permissions:
5152
contents: read
@@ -59,6 +60,23 @@ jobs:
5960
runner: ubuntu-24.04-arm
6061

6162
steps:
63+
- name: Checkout
64+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.0
65+
with:
66+
ref: ${{ github.sha }}
67+
fetch-depth: 0
68+
persist-credentials: false
69+
70+
- name: Verify trusted publication ref
71+
env:
72+
TRUSTED_BASE_SHA: ${{ github.sha }}
73+
TRUSTED_REF_KIND: main
74+
TRUSTED_SOURCE_SHA: ${{ github.sha }}
75+
TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }}
76+
TRUSTED_WORKFLOW_PATH: .github/workflows/docker.yml
77+
shell: bash
78+
run: .github/scripts/verify-privileged-ref.sh
79+
6280
- name: Free disk space
6381
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
6482
with:
@@ -70,12 +88,6 @@ jobs:
7088
docker-images: true
7189
swap-storage: true
7290

73-
- name: Checkout
74-
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.0
75-
with:
76-
ref: ${{ github.sha }}
77-
persist-credentials: false
78-
7991
- name: Set up QEMU
8092
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.6.0
8193

@@ -129,14 +141,29 @@ jobs:
129141

130142
docker-merge:
131143
name: Create multi-arch manifest
132-
if: >-
133-
github.ref == 'refs/heads/main' &&
134-
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
144+
if: github.ref == 'refs/heads/main' && github.ref_protected == true
135145
runs-on: ubuntu-24.04
136146
needs: docker-build
137147
permissions:
138148
contents: read
139149
steps:
150+
- name: Checkout
151+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.0
152+
with:
153+
ref: ${{ github.sha }}
154+
fetch-depth: 0
155+
persist-credentials: false
156+
157+
- name: Verify trusted publication ref
158+
env:
159+
TRUSTED_BASE_SHA: ${{ github.sha }}
160+
TRUSTED_REF_KIND: main
161+
TRUSTED_SOURCE_SHA: ${{ github.sha }}
162+
TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }}
163+
TRUSTED_WORKFLOW_PATH: .github/workflows/docker.yml
164+
shell: bash
165+
run: .github/scripts/verify-privileged-ref.sh
166+
140167
- name: Download digests
141168
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
142169
with:

.github/workflows/host-screenshot-collector.yml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ on:
66
paths:
77
- packages/host-screenshot-collector/**
88
- .github/workflows/host-screenshot-collector.yml
9-
workflow_dispatch:
109

1110
permissions:
1211
contents: read
@@ -21,18 +20,29 @@ env:
2120
jobs:
2221
build-and-sync:
2322
name: Build and Sync to Convex
24-
# This job publishes to Convex with the electron environment secret. A
25-
# manual run must use the protected main revision, never branch code.
26-
if: github.ref == 'refs/heads/main'
23+
# This job publishes to Convex with the electron environment secret. It
24+
# runs only from a protected main push, never from a user-selected ref.
25+
if: github.ref == 'refs/heads/main' && github.ref_protected == true
2726
runs-on: ubuntu-latest
2827
environment: electron
2928
steps:
3029
- name: Checkout
3130
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.0
3231
with:
3332
ref: ${{ github.sha }}
33+
fetch-depth: 0
3434
persist-credentials: false
3535

36+
- name: Verify trusted publication ref
37+
env:
38+
TRUSTED_BASE_SHA: ${{ github.sha }}
39+
TRUSTED_REF_KIND: main
40+
TRUSTED_SOURCE_SHA: ${{ github.sha }}
41+
TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }}
42+
TRUSTED_WORKFLOW_PATH: .github/workflows/host-screenshot-collector.yml
43+
shell: bash
44+
run: .github/scripts/verify-privileged-ref.sh
45+
3646
- name: Setup Bun
3747
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
3848
with:

0 commit comments

Comments
 (0)