A workflow runs with your repository secrets, a token that can write to your code, and often credentials for your cloud account. It is the most privileged thing in the repository, and it is usually the least reviewed.
The asymmetry is worth stating plainly. A pull request that changes application code gets read by another developer. A pull request that changes a workflow file frequently does not, because workflow YAML looks like configuration rather than code. It is code, it runs with more privilege than your application, and it is a direct route to your deployment path.
Unpinned actions: you are running someone else's latest commit
Almost every workflow uses third-party actions. The question is what a reference actually resolves to.
Referencing an action by branch — @main — means your pipeline runs whatever is on that branch the next time it fires. The author can change it at any moment, without a release, and you will not be asked. If their account is compromised, your pipeline runs the attacker's code on the next push, with your secrets in the environment.
A tag such as @v3 feels safer and is only slightly so. Git tags are mutable: the author can move v3 to point at a different commit, and most people would never notice.
The only reference that cannot change under you is a full commit SHA:
uses: some-org/some-action@8f4b7c2e1d9a3f5b6c8e0d2a4f6b8c0e2d4f6a8b
Ugly, and correct. Pin third-party actions this way, with a comment naming the version so a human can read it, and use a tool to keep the pins updated deliberately rather than automatically.
A caveat on grading. Pinning first-party actions such as actions/checkout@v4 by SHA is defensible, and far less urgent. A scanner that treats it as equally severe flags essentially every repository on GitHub — for following GitHub's own documented examples. That is how a check gets ignored. Grade by how mutable the reference is and who controls it: a third-party branch is serious, a third-party tag is moderate, a first-party tag is minor.
pull_request_target: the one that is genuinely dangerous
This trigger exists for a reasonable purpose and is regularly misused in a way that hands the repository to anyone who can open a pull request.
The ordinary pull_request trigger runs a workflow against a fork's code without access to your secrets — deliberately, because the code is untrusted. That is inconvenient when a workflow legitimately needs a secret, so pull_request_target exists: it runs in the context of the base repository, with your secrets available and — depending on the repository's workflow-permissions setting — a token that can write. The secrets alone are enough to make the pattern dangerous.
The critical detail is that it is designed to run your workflow file, from your default branch, not the contributor's code. It becomes a vulnerability the moment the workflow explicitly checks out the pull request's head:
on: pull_request_target
steps:
- uses: actions/checkout@v4
with: { ref: '${{ github.event.pull_request.head.sha }}' }
- run: npm install && npm test
Now anyone in the world opens a pull request containing a malicious post-install script, and it executes with your secrets in the environment. They do not need the pull request to be merged, or even reviewed — only opened.
If you need this pattern, do not run untrusted code in the privileged job. Split it into two workflows: an ordinary pull_request workflow builds and tests the contributor's code without secrets and uploads the result as an artifact, and a separate workflow_run workflow picks the artifact up and does the privileged part. That is GitHub's own recommended pattern.
Script injection through workflow expressions
A more subtle one. Workflow expressions are substituted into the shell script before it runs — textually, not as arguments. So this:
- run: echo "Title: ${{ github.event.pull_request.title }}"
becomes a shell command containing whatever someone typed as their pull request title. A title containing a command substitution executes on your runner. The same applies to branch names, issue bodies and commit messages — anything an outsider controls.
The fix is to pass the value through an environment variable, so the shell receives it as data:
- env: { TITLE: '${{ github.event.pull_request.title }}' }
run: echo "Title: $TITLE"
Token permissions: default to read
The token available to a workflow can have broad write access to the repository. Most workflows need to read code and nothing else.
Set permissions: { contents: read } at the top of the workflow and grant more only to the specific jobs that need it. A blanket write-all means any compromised step — an unpinned action, an injected command, a malicious dependency in your own build — can push commits, alter releases or open pull requests.
Stop storing long-lived cloud credentials at all
Everything above limits what a compromised workflow can reach. The deeper fix is to make the thing it reaches worthless on its own. GitHub Actions can request a short-lived OpenID Connect token that your cloud provider exchanges for temporary credentials scoped to a specific repository and branch. There is then no permanent AWS or Azure key sitting in repository secrets for an injected command to read, and a stolen token expires in minutes.
One related trap: a self-hosted runner attached to a public repository will execute code from any fork's pull request on your own hardware. GitHub documents this as unsupported for a reason. Public repositories should use hosted runners.
A YAML detail that quietly breaks checks
Worth knowing if you write your own tooling: in YAML 1.1 — which most parsers, including SnakeYAML and PyYAML, still implement by default — the unquoted key on parses as the boolean true. A parser that looks for a key literally named "on" finds nothing and concludes the workflow has no triggers — which silently turns "I could not analyse this" into "nothing to report". Any check that reads workflow files needs to handle it, and if you are evaluating a scanner, it is a reasonable thing to ask about.
Why should GitHub Actions be pinned to a commit SHA?
Because branch and tag references can change under you. Referencing an action by branch means your pipeline runs whatever is on that branch the next time it fires, and git tags are mutable, so an author can move v3 to a different commit without anyone noticing. A full commit SHA is the only reference that cannot change. If the action author's account is compromised, an unpinned reference means your pipeline runs the attacker's code with your secrets in the environment.
What makes pull_request_target dangerous?
The pull_request_target trigger runs with your repository secrets available — and, depending on the repository's workflow-permissions setting, a token that can write — unlike the ordinary pull_request trigger, which deliberately withholds them from fork code. It is designed to run your workflow file rather than the contributor's code, so it becomes a serious vulnerability when the workflow explicitly checks out the pull request's head and then builds or tests it — at that point anyone who can open a pull request can execute code with your secrets. They do not need it merged or even reviewed, only opened.
What is script injection in a GitHub Actions workflow?
Workflow expressions are substituted into the shell script textually before it runs, so a value an outsider controls — a pull request title, a branch name, an issue body — becomes part of the command. A title containing a command substitution will execute on your runner. The fix is to pass the value through an environment variable so the shell receives it as data rather than as part of the script text.
What permissions should a GitHub Actions workflow have?
Start with contents: read at the workflow level and grant more only to the specific jobs that genuinely need it. Most workflows only need to read code. A blanket write-all means any compromised step — an unpinned action, an injected command, a malicious dependency pulled in during the build — can push commits, alter releases or open pull requests using the workflow's own token.
Where CodeControl fits
CodeControl audits the workflow files in your connected repositories for exactly these issues. Unpinned actions are graded by how mutable the reference is and who controls it, so following GitHub's own examples does not light up your report; pull_request_target combined with a checkout of the pull request head is treated as critical; script-injection patterns and overly broad token permissions are reported with the specific line. It runs on the same scan as the dependency inventory and secret detection.
If an AI agent is what holds that token, the permission question moves upstream: what the agent is allowed to reach, and who decided it.