The Backstory Link to heading
When I first started working with GitHub Actions, I was honestly overwhelmed by how many knobs there were to turn. It’s not just about getting the workflow to run but also about designing it in a way that doesn’t accidentally blow a hole in your security.
Even for a simple workflow, the number of things you have to think about is wild. You’re juggling environments, OIDC authentication, permissions, and suddenly it feels like you’re writing a security policy instead of a CI pipeline.
You have to sanitize any user input. You need to double check that every third-party Action you’re using isn’t doing something sketchy behind the scenes. And don’t even get me started on how easy it is to let unauthorized users trigger workflows with OIDC enabled if you’re not careful.
It’s not that GitHub Actions itself is insecure, it’s just that the surface area for mistakes is bigger than you might expect when you’re starting out.
The OWASP Top 10 CI/CD Risks list the following risks:
- Insufficient Flow Control Mechanisms
- Inadequate Identity and Access Management
- Dependency Chain Abuse
- Poisoned Pipeline Execution (PPE)
- Insufficient PBAC (Pipeline-Based Access Controls)
- Insufficient Credential Hygiene
- Insecure System Configuration
- Ungoverned Usage of 3rd Party Services
- Improper Artifact Integrity Validation
- Insufficient Logging and Visibility
From the list above, we are interested in looking at “Insufficient PBAC (Pipeline-Based Access Controls)”, “Insufficient Credential Hygiene”, and “Insecure System Configuration”.
Insufficient PBAC (Pipeline-Based Access Controls) Link to heading
The OWASP page hits the nail on the head with this one:
A piece of malicious code that is able to run in the context of the pipeline execution node has the full permissions of the pipeline stage it runs in. It can access secrets, access the underlying host and connect to any of the systems the pipeline in question has access to. This can lead to exposure of confidential data, lateral movement within the CI environment - potentially accessing servers and systems outside the CI environment, and deployment of malicious artifacts down the pipeline, including to production.
In plain terms: if something sketchy makes it into your pipeline, it’s game over. That code runs with the same privileges as your pipeline stage which often means access to secrets, the host machine, other services, and even production deployment paths. It can leak sensitive data, move laterally across your CI infrastructure, or quietly slip a backdoor into prod.
This is why locking down your workflows is critical. You need to make sure attackers can’t sneak in code that ends up executing with access to secrets or internal systems. Recent supply chain attacks through GitHub Actions have made this painfully clear — some attackers have gone as far as crafting super-specific payloads to slip past template logic or even publishing poisoned Actions to the marketplace.
When workflows have that kind of power, you have to treat them like production code — maybe even more cautiously.
Insufficient Credential Hygiene Link to heading
According to OWASP:
Credentials are the most sought-after object by adversaries, seeking to use them for accessing high-value resources or for deploying malicious code and artifacts. In this context, engineering environments provide attackers with multiple avenues to obtain credentials. The large potential for human error, paired with knowledge gaps around secure credentials management and the concern of breaking processes due to credential rotation, put the high-value resources of many organizations at the risk of compromise due the exposure of their credentials.
And yeah, they’re not wrong. Credentials are basically gold to attackers. Engineering environments, especially CI tend to have a bunch of them lying around, often with more access than you’d expect. Combine that with human error, shaky credential management practices, and the fear of breaking stuff with key rotation, and you’ve got a perfect storm.
Case in point: I recently found out that actions/checkout will by default persist the GITHUB_TOKEN in the .git folder it checks out. That token can then be used by anything else in the workflow — including anything malicious that might’ve snuck in.
Here’s how you stop that from happening:
- uses: actions/checkout@v4
with:
persist-credentials: false # this ensures the token is not copied
This was totally off my radar until recently and when I asked around, most devs hadn’t heard of it either. It definitely made me take a step back and rethink how secure my GitHub Actions setups actually were.
Insecure System Configuration Link to heading
A security flaw in one of the CI/CD systems may be leveraged by an adversary to obtain unauthorized access to the system or worse - compromise the system and access the underlying OS. These flaws may be abused by an attacker to manipulate legitimate CI/CD flows, obtain sensitive tokens and potentially access production environments. In some scenarios, these flaws may allow an attacker to move laterally within the environment and outside the context of CI/CD systems.
One of my bad habits: slapping a permissions
block right at the top of the workflow and calling it a day.
But this point from OWASP was a bit of a wake-up call. It highlights how insecure system configurations especially around permissions, can be a huge attack vector. An attacker doesn’t always need an obvious vulnerability. Sometimes they just need overly generous permissions and a creative way to escalate access.
If you’re giving out id-token
or write
permissions to steps that run third-party Actions or unverified code, you’re basically handing them the keys to the castle. Those permissions should be scoped very tightly and ideally, to just the steps that absolutely need them. And if it’s not first-party or fully audited code, avoid giving it any sensitive access at all.
Lesson learned: least privilege isn’t just a nice-to-have but a survival strategy when dealing with CI workflows.
The Problem Link to heading
The risks I’ve talked about so far? Just the tip of the iceberg. Once you start pulling at the threads, you’ll quickly uncover all sorts of edge cases and weird scenarios that can open up serious vulnerabilities.
As someone who regularly reviews a lot of repositories, it’s just not realistic to manually track every workflow file or scrutinize every single change that gets pushed. It doesn’t scale. And more importantly, it ends up blocking developers who are just trying to ship their changes.
What makes it worse is that GitHub still hasn’t nailed a good way to test workflows locally. Sure, tools like act
exist but they fall short when you’re trying to simulate more advanced setups, like OIDC authentication or secrets from GitHub environments. So devs are stuck in a cycle of “commit, push, wait, debug, repeat,” sometimes burning hours just trying to get a workflow into a working state.
It’s one of those areas where the tooling still has a long way to go, especially for teams trying to strike the balance between speed and security.
The Solution Link to heading
As any normal developer does, I was aimlessly scrolling GitHub’s homepage. Under the Explore repositories
section where GitHub tries to guess what you’re into, I stumbled on an absolute gem: zizmor
.
The author also wrote a great blog post about why they built it. Highly recommend giving it a read — it’s short, insightful, and hits all the right notes if you’re trying to clean up your workflows.
The documentation is refreshingly simple yet sufficient to follow. As a uv
fan, I only had to run:
uvx zizmor
And it scanned all the Github Action files in my repository with no extra setup required.
If you’re still living in 2016 (or just don’t use uv
), you can use pip
:
pip install zimor
You can run it as a one-off command, set it up as a GitHub Action (ironic, but valid), or add it as a pre-commit hook. I went with the pre-commit route mostly to make sure no one (myself included) commits unsafe workflows that haven’t been cleaned up. Once it’s in main, it’s basically set in stone, and cleaning it up after the fact is a nightmare.
Here’s the hook config I’m using:
- repo: https://github.com/woodruffw/zizmor-pre-commit
rev: v1.5.2
hooks:
- id: zizmor
Four lines of code and my pre-commit
hook was ready to use. The results on the scan are quite well formatted:
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> ./.github/workflows/deploy.yaml:37:9
|
37 | - uses: actions/checkout@v4
| ------------------------- does not set persist-credentials: false
|
= note: audit confidence → Low
error[excessive-permissions]: overly broad permissions
--> ./.github/workflows/deploy.yaml:11:3
|
11 | id-token: write
| ^^^^^^^^^^^^^^^ id-token: write is overly broad at the workflow level
|
= note: audit confidence → High
info[use-trusted-publishing]: prefer trusted publishing for authentication
--> ./.github/workflows/deploy.yaml:128:9
|
128 | uses: pypa/gh-action-pypi-publish@release/v1
| -------------------------------------------- info: this step
129 | with:
130 | user: __token__
131 | password: ${{ secrets.PYPI_API_TOKEN }}
| --------------------------------------- info: uses a manually-configured credential instead of Trusted Publishing
|
= note: audit confidence → High
warning[excessive-permissions]: overly broad permissions
--> ./.github/workflows/security_checks.yaml:1:1
|
1 | / name: Security Checks
2 | |
... |
52 | | inputs: requirements-dev.txt
53 | | summary: true
| |________________________- default permissions used due to no permissions: block
|
= note: audit confidence → Medium
warning[excessive-permissions]: overly broad permissions
--> ./.github/workflows/security_checks.yaml:36:3
|
36 | / pip-audit:
37 | | runs-on: ubuntu-latest
... |
52 | | inputs: requirements-dev.txt
53 | | summary: true
| | -
| |________________________|
| this job
| default permissions used due to no permissions: block
|
= note: audit confidence → Medium
info[template-injection]: code injection via template expansion
--> ./.github/workflows/test.yaml:111:9
|
111 | - name: Fail if unit tests are not passing
| ---------------------------------------- info: this step
112 | if: ${{ steps.runtests.outputs.rc != 0}}
113 | uses: actions/github-script@v6
114 | with:
115 | / script: |
116 | | core.setFailed('Unittests failed with rc = ${{ steps.runtests.outputs.rc }}')
| |_________________________________________________________________________________________- info: steps.runtests.outputs.rc may expand into attacker-controllable code
Summary Link to heading
Honestly, this tool is a breath of fresh air if you’re tired of manually reviewing workflows for the same issues over and over again. Using automated static analysis tools like zizmor has seriously made my life easier. When the results are clear and actionable, it’s a no-brainer and I know exactly what I need to fix before pushing anything risky.
It’s also a great example of the whole shift-left mindset in action. Instead of waiting for a security review after the fact (or worse, after something breaks), the tool flags issues early, right in the developer workflow.
And honestly, it helps build better habits. You start learning what “safe” actually looks like, instead of just blindly copying whatever workflow you found on Stack Overflow or in some random repo (which… yeah, I’ve definitely done before 😅).