# GitHub Actions - Workflow Security and Rulesets GitHub Actions has two structural security traps: `${{ }}` templates are textually substituted before bash parses anything, and `pull_request_target` runs fork code with base-repository permissions. Rulesets compose cumulatively, so a repo-level rule with no bypass actors can silently block tag and release operations that an org-level grant allows. ## Key points - `pull_request_target` plus an explicit fork checkout (`ref: ${{ github.event.pull_request.head.sha }}`) is a confused-deputy vulnerability. The workflow runs with base-repository write permissions while it executes attacker-controlled targets. The safe pattern is `workflow_run` plus `download-artifact`. - `${{ }}` is substituted before bash parses. Even double-quoted interpolation is injectable: a `"` inside a value closes the string at template-expansion time, before bash quoting applies. `env:` indirection is the only fix that holds, because the quoting boundary must exist at bash-parse time. - `actionlint` and `shellcheck` have no script-injection rule. That class belongs to `zizmor`. A lint pass validates workflow syntax, not vulnerability closure, and it can stay green before and after an injection fix. - `persist-credentials: false` on `actions/checkout` stops the Actions token from being written to the git credential store. Without it, any later process in the job can make authenticated API calls, a common supply-chain attack vector. - Sandbox every step that makes network calls. The capture-SHA pattern prevents supply-chain substitution during network-enabled steps: record the SHA before the step, and re-checkout it after. - Tag-protection rulesets evaluate cumulatively. A repo-level tag-modification rule with zero bypass actors beats an org-level ruleset that grants maintainers bypass. `current_user_can_bypass: never` means even maintainers cannot delete the protected tag. Only an org admin editing the ruleset can. - A push that creates a brand-new protected ref validates the entire reachable history against the rule. Unsigned commits absorbed by merge-commit merges ride along and reject the push. Older tips pass because GitHub signs their squash-merge commits. Org rulesets are invisible to non-admin tokens. - Org tag-protection rulesets can silently deny nested-module release tags to `GITHUB_TOKEN` while the root release otherwise looks fine. The nested tag is what Go consumers resolve. Check nested-module tags directly after every release. - In the GitHub Terraform provider, `bypass_actors` in `github_repository_ruleset` is fully supported. Actor type IDs (team, integration or app, numeric roles) come from the provider docs, not from the GitHub REST API. ## How to apply 1. Do not check out fork refs inside `pull_request_target`. Consume results from untrusted code with `workflow_run` plus `download-artifact`. 2. Never interpolate `${{ }}` values into shell commands. Pass them through `env:` and reference them as `$VAR_NAME` in the script. 3. Add `zizmor` to the workflow lint pass. `actionlint` plus `shellcheck` do not detect script injection. 4. Set `persist-credentials: false` on every `actions/checkout` step that does not need push access. 5. Wrap network-enabled steps in a sandbox action such as `geomys/sandboxed-step`. Before the step, run `git rev-parse HEAD > trusted-sha`. After the step, run `git checkout $(cat trusted-sha)`. 6. Before you protect a new ref pattern, audit the reachable history for unsigned commits. Signed squash-merge tips pass. Histories that absorbed unsigned merge commits can reject the push. 7. After every release that has nested modules, verify the nested tags exist, not only the root tag. 8. When a tag operation fails with a ruleset denial, check the repo-level rulesets first. Zero bypass actors there overrides any org-level bypass. 9. Manage ruleset bypass actors in Terraform with `bypass_actors` in `github_repository_ruleset`. Take actor type IDs from the provider docs. ## Observations - [security] `pull_request_target` with an explicit fork checkout runs attacker-controlled targets with base-repository permissions. #security (source: agent sessions, 2026-03) - [ci] `actionlint` and `shellcheck` validate workflow syntax, not script injection. Script injection belongs to `zizmor`. #ci (source: agent sessions, 2026-07) - [github] Repo-level tag protection with no bypass actors overrides org-level maintainer bypass. Rulesets evaluate cumulatively. #github (source: agent sessions, 2026-07) - [release] A new protected ref validates the whole reachable history. Unsigned merge-commit history can reject the push. #github (source: agent sessions, 2026-09) - [release] Org tag protection can deny nested-module tags to `GITHUB_TOKEN` without failing the root release. #github (source: agent sessions, 2026-09) ## Relations - relates_to [[resources/documentation/GitHub Actions - Semantics and Caching]] (workflow trigger and step semantics on the same platform) - relates_to [[resources/documentation/Git - Commit Signing with SSH Agents and gitsign]] (rulesets that require signed history interact with commit-signing tooling) - relates_to [[resources/documentation/GitHub - Pull Request Workflow]] (fork events and which actor a workflow runs as) - relates_to [[resources/documentation/Renovate and Dependabot - Automation Semantics]] (automation that pushes tags and releases hits tag-protection rulesets) ## Sources Personal agent-session notes, March 2026 to September 2026