A workflow can pass actionlint, zizmor, poutine and a posture gate with zero findings, and still be dangerous. Scanners detect known patterns; they know nothing of what your secrets open, where your runners are plugged in, or who consumes your artifacts. This page gives a seven-question method to establish what a workflow really exposes, and to decide what to fix first.
What you will learn
- Place what a scanner sees and what it cannot see
- Walk through the seven questions on any workflow
- Spot the trust boundary, the place where untrusted data enters
- Assess the blast radius rather than probability alone
- Choose which workflows to model first, and when to redo the exercise
What a scanner cannot know
Scanners reason about the file. They read a permissions:, an action
reference, a dangerous interpolation. That is valuable, and it is bounded: three
categories of information escape them by construction.
| What the scanner sees | What it does not know |
|---|---|
secrets.DEPLOY_TOKEN travels through env: | That this token opens the whole company's production |
runs-on: [self-hosted, prod] | That this machine sits on the administration network |
| An image is pushed to a registry | That forty repositories consume it as a base |
permissions: contents: write | That this repository is the source of an action used elsewhere |
None of those right-hand columns is written in the YAML. They depend on your context, and that is exactly what a threat model formalises. The scanner answers "does this workflow contain a known dangerous pattern". The model answers "what happens if this one is compromised".
The seven questions
The sequence follows the real path of an attack: get in, execute, obtain, get out, spread. Each question is answered in one line, and it is moving from one question to the next that reveals the dangerous chains.
1. Who can trigger this workflow? | v2. Which code runs, and where does it come from? | v3. On which machine, in which network segment? | v4. With which token, and which permissions? | v5. Which secrets are present in the environment? | v6. Where can the machine send traffic? | v7. Which artifact is produced, and who consumes it afterwards?1. Who can trigger it?
This question determines every other one, because it fixes the population of possible attackers. A workflow only three maintainers can trigger and a workflow any stranger can trigger through a pull request do not call for the same level of rigour.
| Trigger | Who can fire it |
|---|---|
push on a protected branch | People with write access, after review |
pull_request | Anyone, on a public repository |
pull_request_target | Anyone, with the target repository's secrets |
issue_comment, issues | Anyone |
workflow_dispatch | People with access to the repository |
schedule | Nobody, but it runs unattended |
The two middle rows concentrate most public incidents. The schedule row
deserves a special mention: a scheduled workflow runs with no witness, often
at night, which lengthens the time to detection.
2. Which code runs?
A safe trigger is not enough: you also need to know which code runs. Four sources stack up, and the third is the most often forgotten.
- The repository code, at the checked-out reference. Which one exactly? A
ref:pointing at the HEAD of a pull request changes everything. - The third-party actions, which are code executed with the same rights as your own steps.
- The dependencies installed during the job. An
npm ciruns the install scripts of the packages, transitive ones included. - The tools downloaded by an action, whose version is not always frozen.
3. On which machine?
A GitHub-hosted runner is a disposable machine, on the public internet, destroyed after the job. A self-hosted runner is your infrastructure, with your network access, and it persists unless configured otherwise.
The question to ask is not "is this runner hardened" but "what can this machine reach": internal databases, registries, administration APIs. Segmentation through runner groups covers that point in detail.
4. With which token?
The job's GITHUB_TOKEN, with the declared permissions. The useful exercise is
translating each permission into an offensive capability: contents: write
lets you modify the code, therefore add a workflow, therefore persist.
packages: write lets you publish a boobytrapped version under your name.
id-token: write lets you obtain a cloud token, if a trust policy accepts it.
5. Which secrets?
List what is actually present in the job environment, not what the workflow is supposed to use. An environment secret is only delivered after the protection rules are satisfied; a repository secret is there from the moment the job starts, whether you use it or not.
For each secret, the question that matters is not "is it masked" but "what does it open, and for how long". A static production token is worth infinitely more than a ten-minute OIDC token scoped to one repository.
6. Where can it send traffic?
Every exfiltration needs an exit channel. By default, a runner reaches any address. Restricting the allowed destinations does not prevent the compromise, but it makes it sterile: the stolen secret goes nowhere, and the attempt turns into a job failure, therefore into a signal.
7. Which artifact, for whom?
This is the question of propagation, and the one most often skipped. A compromised workflow that produces nothing harms one repository. A compromised workflow that publishes an image consumed by forty services, or an action reused across the whole organisation, turns a local incident into a supply chain incident.
Spotting the trust boundary
The central notion fits in one sentence: the trust boundary is the place where data you do not control enters a context you do control. A dangerous workflow is almost always one where that boundary is crossed without anyone noticing.
Untrusted inputs are few and recognisable:
- the content of a fork's pull request, code and configuration files alike;
- the free-text fields filled in by a third party: issue or pull request title and body, a comment, a branch name, a commit message;
- the
workflow_dispatchinputs, typed by a human; - the artifacts of another run, in a workflow triggered by
workflow_run; - the responses of external APIs consumed during the job.
The test goes like this: can this value be chosen by someone with no write access to the repository? If yes, it is untrusted, and everything it reaches is suspect.
Blast radius, more useful than probability
Ranking risks by probability leads to postponing rare scenarios indefinitely. Yet in supply chain security, the landmark incidents are precisely the events everyone judged improbable. Ranking by blast radius works better: it asks what the incident costs, not how often it happens.
| Radius | What is reached | Example |
|---|---|---|
| The job | One run, nothing persists | A hosted runner with no secret |
| The repository | Code, releases, packages of that repository | A hijacked contents: write |
| The organisation | Several repositories, shared secrets | An organisation secret exfiltrated |
| The consumers | The users of your artifacts | A boobytrapped image or action published |
| The infrastructure | Internal network, production | A compromised self-hosted runner |
The last two rows justify the exercise on their own: they are the ones where remediation reaches far beyond your repository, and where third parties must be warned.
A worked example
Take a realistic publishing workflow: triggered when a release is published, it builds an image, pushes it to a registry, attests it and signs it.
| Question | Answer | Verdict |
|---|---|---|
| Who triggers it | A release publication, so the maintainers | A restricted population |
| Which code | The repository at the tag, plus seven third-party actions pinned by SHA | A real third-party surface |
| Which machine | A hosted, disposable runner | Low |
| Which token | packages: write, id-token: write, attestations: write, contents: write | High, but justified |
| Which secrets | The job's GITHUB_TOKEN, no static secret | Good |
| Network egress | Unrestricted | The weak point |
| Artifact | A public image consumed by third parties | Maximum radius |
Reading across gives the priority immediately, and it is not the one you would expect. The risk is neither the trigger nor the token: it is the combination of the last row and the one before it. One compromised third-party action among the seven has a free exit channel and a widely distributed artifact.
The two priority fixes follow: restrict the outbound traffic of the publishing job, and maintain the pinnings to shrink the window during which a compromised action stays in place. Hardening the permissions, which would have been the reflex, comes after.
Where to start, and when to start again
Modelling every workflow of an organisation makes no sense. Three criteria designate the ones that deserve it, and a workflow matching two of them goes to the front of the queue.
-
It can be triggered by a third party:
pull_requeston a public repository,pull_request_target,issue_comment. -
It holds secrets or write permissions: publishing, deploying, signing.
-
It produces something others consume: an image, a package, a reusable action, a reusable workflow.
The exercise is redone on events, not on a calendar. Four changes invalidate an existing model: adding a trigger, adding a secret or a permission, moving to a self-hosted runner, and an artifact gaining consumers. The first three show up in the diff, the fourth does not, and it is the one that silently turns a local risk into a supply chain risk.
Key points
- A workflow can pass every scanner and stay dangerous: they see known patterns, not what your secrets open nor where your runners are plugged in.
- The method fits in seven questions following the path of an attack: trigger, code, machine, token, secrets, network egress, artifact.
- The trust boundary is where data chosen by someone without write access enters a privileged context.
- Rank by blast radius rather than probability: the landmark incidents were all judged improbable.
- A workflow with no exit channel makes a compromise sterile: the stolen secret goes nowhere and the attempt becomes a signal.
- The most forgotten question is the seventh: who consumes the artifact produced, and therefore how far the incident spreads.
- Model first the workflows triggerable by a third party, carrying secrets or producing distributed artifacts, and redo the exercise on events, not on a fixed date.
Next steps
- Securing pull_request_target: the trigger that crosses the trust boundary by construction, and the guards that restore it.
- OIDC: authentication without secrets: lowering the value of the fifth question by replacing static tokens with tokens that live minutes.
- Security checklist: the systematic pass that turns the conclusions of this model into checkpoints before merge.