|
| 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. Main may |
| 194 | + # advance while a long build runs, but it must retain the captured base. |
| 195 | + git cat-file -e "$TRUSTED_BASE_SHA^{commit}" 2>/dev/null || \ |
| 196 | + die "trusted release base is not available locally" |
| 197 | + main_ref_output="$(GIT_TERMINAL_PROMPT=0 git ls-remote origin refs/heads/main 2>/dev/null)" || \ |
| 198 | + die "unable to resolve protected main" |
| 199 | + main_sha="$(printf '%s\n' "$main_ref_output" | awk 'NF == 2 && $2 == "refs/heads/main" { print $1; exit }')" |
| 200 | + [[ "$main_sha" =~ $HEX_SHA_RE ]] || die "protected main did not resolve to a commit" |
| 201 | + git merge-base --is-ancestor "$TRUSTED_BASE_SHA" "$main_sha" || \ |
| 202 | + die "trusted release base is not reachable from the current protected main revision" |
| 203 | + release_parent="$(git rev-parse --verify "$event_sha^1" 2>/dev/null)" || \ |
| 204 | + die "release branch tip has no parent" |
| 205 | + [[ "$release_parent" == "$TRUSTED_BASE_SHA" ]] || \ |
| 206 | + die "release branch tip does not match the trusted base SHA" |
| 207 | + parent_count="$(git rev-list --parents -n 1 "$event_sha" | awk '{ print NF - 1 }')" |
| 208 | + [[ "$parent_count" == "1" ]] || \ |
| 209 | + die "release branch tip must be a single-parent commit" |
| 210 | + |
| 211 | + release_version="${event_ref#refs/heads/release/v}" |
| 212 | + release_subject="$(git log -1 --format=%s "$event_sha")" |
| 213 | + [[ "$release_subject" == "chore: release v$release_version" ]] || \ |
| 214 | + die "release branch commit has an unexpected subject" |
| 215 | + [[ "$(git log -1 --format=%an "$event_sha")" == "github-actions[bot]" ]] || \ |
| 216 | + die "release branch commit author is not the Actions bot" |
| 217 | + [[ "$(git log -1 --format=%ae "$event_sha")" == "github-actions[bot]@users.noreply.github.com" ]] || \ |
| 218 | + die "release branch commit author email is not the Actions bot" |
| 219 | + [[ "$(git log -1 --format=%cn "$event_sha")" == "github-actions[bot]" ]] || \ |
| 220 | + die "release branch commit committer is not the Actions bot" |
| 221 | + [[ "$(git log -1 --format=%ce "$event_sha")" == "github-actions[bot]@users.noreply.github.com" ]] || \ |
| 222 | + die "release branch commit committer email is not the Actions bot" |
| 223 | + |
| 224 | + changed_files="$(git diff-tree --no-commit-id --name-only -r "$event_sha")" |
| 225 | + [[ "$changed_files" == "apps/client/package.json" ]] || \ |
| 226 | + die "release branch commit changes files outside the version manifest" |
| 227 | +fi |
| 228 | + |
| 229 | +echo "trusted-ref: verified $GITHUB_REPOSITORY $event_ref at $event_sha" |
0 commit comments