CD7: CI pipelines with GitHub Actions
Keeping workflows secure
🎯You'll be able to
Give workflows least privilege, pin the actions they use, handle untrusted input safely, and know the risks of forks and self-hosted runners.
🏠The everyday picture
A building's maintenance contractor gets a key to the plant room, not to every flat. The key is cut from a lock you've checked, not a copy someone hands you in the car park. And notes slipped under the door are read, not obeyed.
📘What it really is
-
The
GITHUB_TOKEN: each run gets a token to act on the repository. Set its permissions explicitly at the top:yaml permissions: contents: readand grant more only in the job that needs it (for examplepull-requests: writeto comment, orid-token: writefor OIDC). Don't usepermissions: write-all. -
Pin actions:
uses: actions/checkout@mainruns whatever is on that branch today. Pin to a release tag (@v7) at minimum, or for the strongest protection to a full commit SHA (@<40 hex characters>), with a comment naming the version. Tags can be moved; SHAs can't. Dependabot can keep pinned actions up to date (package-ecosystem: github-actions). -
Untrusted input: pull request titles, branch names and issue comments are written by anyone. Putting them straight into a script lets them run commands:
yaml run: echo "${{ github.event.pull_request.title }}" # unsafe: the title becomes part of the scriptPass them as environment variables instead, so the shell treats them as text:yaml env: TITLE: ${{ github.event.pull_request.title }} run: echo "$TITLE" -
Forks: workflows triggered by
pull_requestfrom a fork get no repository secrets and a read-only token, which is safe. Thepull_request_targetevent runs with the base repository's secrets; checking out and running the fork's code in it hands your secrets to a stranger. -
Self-hosted runners keep running on your machine between jobs. On a public repository, anyone can open a pull request whose workflow runs on your runner. Use them only with private repositories, or with strict approval rules for outside contributors.
-
Secrets in logs: GitHub masks secret values as
***, but only exact matches.echo ${{ secrets.X }} | base64prints a version the mask doesn't catch. Don't print secrets at all.
📝A worked example
Input: the starter workflow has permissions: write-all, uses: actions/checkout@main and a step
run: echo "Deploying with token ${{ secrets.DEPLOY_TOKEN }}".
Expected output: Project 2 test 2 fails with set top-level permissions: contents: read, and test 3 fails with
actions/checkout@main isn't pinned to a version tag or commit SHA, then with a step prints a secret. Changing to contents: read, @v7
tags (or SHAs) and deleting the echo step makes both pass.
🌍Where it's used
-
Supply-chain security: in March 2025, the widely used
tj-actions/changed-filesaction was compromised: its tags were moved to code that printed secrets into build logs. Repositories pinned to a commit SHA weren't affected. -
Open-source maintainers require approval before workflows run for first-time contributors.
- Security reviews of pipelines check permissions, pinning and use of
pull_request_target.
📝Case scenario
A project's workflow labelled pull requests using pull_request_target, so it could write labels. A later change checked out the pull
request's code in the same workflow to run a linter. An attacker opened a pull request that changed the linter's configuration to send the
repository's secrets to their server. The project split the work into two workflows: an unprivileged one for the fork's code and a
privileged one that only read the results.
🖱️Try it
Find every uses: line in your workflow and check each is pinned. Then search the workflow for ${{ github.event. inside a run: line.
Success: every action has @v<number> or a SHA, and no event field appears directly inside a script.
⚠️Common mistakes
write-allor nopermissionsblock. Sign: a token that can push code and change releases. Fix:contents: readat the top.- Actions on
@mainor@master. Sign: behaviour changes with nobody touching your workflow. Fix: version tags or SHAs, updated by Dependabot. - Event text inside
run:. Sign:${{ github.event... }}in a script. Fix: pass it throughenv:.
🛠️For your project
Project 2 tests 2 and 3 check contents: read, no write-all, pinned actions, and no step that prints a secret.
✅Quick check
Why pin an action to a commit SHA?
Show the answer
A SHA can't be moved to different code, unlike a tag or branch.
Why are self-hosted runners risky on public repositories?
Show the answer
Anyone can open a pull request whose workflow runs on your machine.
