Security¶
Threat model and hardening for workflow YAML. Load for every new or changed workflow. Machine checks (zizmor) cover a subset; the reasoning below is still required.
Trust boundaries¶
The central question: can someone outside the trusted group trigger this workflow, and what does the run get?
| Trigger | Triggered by | Token | Secrets |
|---|---|---|---|
push, same-repo pull_request |
collaborators | write | yes |
pull_request from a fork |
anyone | read-only | no |
pull_request_target |
anyone | write | yes |
workflow_run |
after another run | write | yes |
issue_comment, issues |
anyone | write | yes |
schedule, workflow_dispatch |
maintainers | default | yes |
Fork pull_request is safe by design. Do not switch to pull_request_target to regain
secrets for fork PRs; that hands a write token and secrets to arbitrary contributors.
Script injection¶
${{ }} is expanded into the script text before the shell runs. Any attacker-controllable
value in a run:, a script: body, or an action input that reaches a shell is command
injection.
Untrusted sources include issue, PR, comment, and review bodies and titles; branch names
(github.head_ref, github.event.pull_request.head.ref); commit messages and author fields;
labels; and wiki page names. Treat any github.event.* field a non-collaborator can set as
untrusted.
Safe pattern: bind to env: and reference the quoted shell variable.
# unsafe
- run: |
echo "PR: ${{ github.event.pull_request.title }}"
# safe
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "PR: $PR_TITLE"
The same applies to actions/github-script (read process.env, never interpolate into
script:) and to with: inputs of actions that pass inputs to a shell.
Writing untrusted multiline data to $GITHUB_ENV or $GITHUB_OUTPUT can inject variables or
outputs. Use the heredoc form with a delimiter that cannot appear in the value, and validate
the value shape first.
Permissions¶
- If no
permissions:is set, the workflow inherits the repository default, which may be read/write for most scopes. - Set a restrictive workflow default, then elevate per job.
permissions: {}denies all;contents: readis the usual baseline. - When any permission is listed, all unlisted scopes become
none. - Fork
pull_requestruns downgrade write scopes to read; you cannot elevate them. pull_request_targetruns get the repository's full read/write token.GITHUB_TOKENevents do not trigger new workflow runs, exceptworkflow_dispatchandrepository_dispatch, and approval-gatedpull_requestactivity. Use a GitHub App token or PAT when you need chained runs.
Secrets¶
- Store secrets in repository, organization, or environment settings, never in YAML.
- Environment secrets are readable only after the environment protection rules pass.
- Masking is exact-string and best effort. Transformed or structured data (JSON blobs) can
defeat it. Do not store a structured document as one secret; register generated values with
::add-mask::. - Fork PR runs and Dependabot-triggered runs get no Actions secrets.
- Pass secrets to reusable workflows explicitly or with
secrets: inherit; inherit forwards every accessible secret and is worth avoiding in shared workflows.
Supply chain¶
Every uses: runs third-party code with your token and secrets.
- A full-length commit SHA is the only immutable reference. Tags and branches can be moved.
- Third-party actions require a deliberate decision: SHA-pin per repository policy, and verify the SHA belongs to the action's repository; a SHA from a fork is an impostor commit.
- Resolve a version to a SHA from the upstream repository (
git ls-remote https://github.com/owner/repo refs/tags/vX.Y.Zor the GitHub API). Never guess or invent a SHA. - First-party
actions/*andgithub/*are lower risk but still benefit from pinning. If the repository intentionally allows tags for first-party actions, record that policy instead of pinning everything to satisfy a tool default. - Never use
@mainor@master. - Keep a
# vX.Y.Zcomment next to a SHA so updates are reviewable. - Enable Dependabot for the
github-actionsecosystem to keep pins current. actions/checkoutpersists the token in.git/configby default. Setpersist-credentials: falsefor jobs that later run untrusted code and for jobs that never push.
Privileged triggers and untrusted code¶
pull_request_target and workflow_run run in the base repository with secrets and a write
token. The safe pattern is two workflows: an unprivileged pull_request workflow that runs
the untrusted code and uploads results, and a privileged workflow_run workflow that consumes
only the results as data.
Do not, in a privileged workflow:
- check out
github.event.pull_request.head.sha,refs/pull/N/merge, or a fork repository - run install, build, or test commands on untrusted code
- execute scripts or binaries from downloaded artifacts
- evaluate attacker-controlled configuration
actions/checkout blocks fork head refs in pull_request_target unless
allow-unsafe-pr-checkout: true is set. Treat that option as a red flag.
A privileged trigger used only for metadata operations (labels, comments, statuses) that never checks out or executes contributor code is acceptable. Keep its token scoped to the single write permission it needs, and never add a checkout "because the workflow might need it".
Caches and artifacts¶
Caches and artifacts from low-trust runs are untrusted input to privileged workflows; restoring
or executing them can run attacker-controlled content. See caching-artifacts.md for cache and
artifact trust rules.
Runners¶
GitHub-hosted runners are ephemeral. Self-hosted runners persist and can be compromised by untrusted code, then used against later jobs or the surrounding network. Do not run fork-triggered workflows on self-hosted runners. If self-hosted is required, use ephemeral or JIT runners and isolate the network.
OIDC¶
Prefer OIDC over long-lived cloud keys. id-token: write lets the job request a short-lived
JWT; the cloud provider must still trust it, scoped to this repository and ideally a branch or
environment. See deployments-and-oidc.md for setup.
Reporting findings¶
State the trust boundary, the concrete exploit path, and the fix. Severity reflects who can
trigger the workflow and what the run can access. Do not mark a fork pull_request run
critical merely because it runs untrusted code; it has no secrets and a read-only token.