I built a security scanner for GitHub Actions workflows. Then I pointed it at keephq/keep — a YC W23 startup with 5,000+ GitHub stars — to see what would happen.
The results were not subtle.
This isn't a hit piece. Keep is a well-maintained project with active contributors. The problems I found are industry-normal. That's the point. If a funded startup with a real engineering team has 96 workflow security issues, yours probably does too.
actions-audit is a Python CLI that parses your .github/workflows/ directory and checks for five categories of misconfigurations:
actions/checkout@v4 instead of a SHAgithub.* values in run: blocksNearly every uses: line referenced a tag, not a SHA:
# What they had:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: aws-actions/configure-aws-credentials@v4
# What it should be:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: actions/setup-python@8d9ed9ac5c534f38775b415c983e8fb3e9e7b0a1
v4 gets repointed to malicious code, your CI runs it. Pinning to a SHA means the content is immutable. GitHub's own security hardening guide recommends SHA pinning.
The default behavior of actions/checkout persists the GitHub token in .git/config after the step completes. Any later step — including third-party actions — can read it and push to your repo.
# Fix: one line per workflow
- uses: actions/checkout@v4
with:
persist-credentials: false
Two workflows used pull_request_target, which runs in the context of the base repository — meaning it has access to repo secrets. Combined with an explicit checkout of the PR head, this gives forked PR code access to your secrets.
# Dangerous pattern:
on:
pull_request_target:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Deploy
env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh
A malicious PR can exfiltrate AWS_SECRET_ACCESS_KEY by modifying deploy.sh to echo it to an external endpoint.
Several workflows interpolated GitHub context directly into shell commands:
# Dangerous:
run: echo "Building ${{ github.head_ref }}"
# Safe:
env:
HEAD_REF: ${{ github.head_ref }}
run: echo "Building ${{ env.HEAD_REF }}"
The first version is vulnerable to injection if an attacker crafts a branch name containing shell commands. The second uses GitHub's environment variable mediation, which sanitizes values.
| Category | Count | Severity |
|---|---|---|
| Unpinned actions | 60+ | Medium |
| persist-credentials: true (default) | 14 | Medium |
| pull_request_target + secrets | 2 | High |
| Context injection | 12+ | Low-Medium |
| Secrets in PR context | 3 | High |
actions/checkout's pin-action script or just look up SHAs manually.persist-credentials: false to every actions/checkout step. There is almost no reason to leave this on.pull_request_target unless you absolutely need it, and never combine it with secrets.* access.run: blocks instead of direct interpolation.The scanner is open-source and runs locally — no API keys, no network access needed:
pip install actions-audit
actions-audit scan /path/to/repo/.github/workflows/
It outputs JSON for CI integration and color-coded terminal output for manual review. Exit code is non-zero if it finds issues, so you can gate PRs on it.
Source code: git.sr.ht/~alexreed/portfolio
KeepHQ is not special here. I picked it because it's a real project with real users and real engineers. I could have scanned almost any repo with 15+ workflows and found similar numbers.
The CI/CD supply chain is the soft underbelly of most engineering organizations. We spend enormous effort securing production infrastructure and almost none securing the pipelines that deploy to it. A compromised GitHub Action doesn't need to hack your servers — it already runs with your credentials.
Scan your workflows. Fix the easy stuff. Pin your actions. Turn off credential persistence. It's 30 minutes of work that eliminates most of the attack surface.