action-shield
Paste any.github/workflows/*.ymland get an instant security audit — Pwn Request, cache poisoning, script injection, unpinned actions, brokenpermissions, and more — backed by a live CVE feed for the GitHub Actions ecosystem and a safety scoreboard for the most popular actions.
Built in response to the TanStack May 11 2026 compromise (CVE-2026-45321: pull_request_target
"Pwn Request" + cache poisoning + OIDC token theft) and the Mini Shai-Hulud May 19 wave that
together compromised hundreds of npm packages via GitHub Actions misconfigurations.
CLI scanners exist (zizmor, poutine, actionlint). action-shield is the public web auditor your
team Slack actually needs the morning after the next post-mortem drops.
Features
- Auditor — paste a workflow YAML, get line-numbered findings with severity badges, a
- letter-grade-style score, fix hints, and a "Copy as Markdown" button to drop into a Slack thread
- or PR comment.
- CVE Feed — live mirror of GitHub Actions ecosystem advisories from GHSA, cross-checked
- against OSV.dev, filterable by severity and recency.
- Top Actions — popularity table for ~50 canonical actions plus auto-discovered popular repos
- (live stars, latest release age, archived flag, open CVE count).
- Rules — every detection rule is browsable with side-by-side bad/good examples.
- No signup, no cookies, no analytics. Paste bodies are not stored; only a SHA-256 hash plus
- counts are kept for stats. IP addresses are hashed with a daily-rotating salt for rate-limiting
- only.
Detection rules (13)
| ID | Severity | What it catches |
|---|---|---|
| PWN_REQUEST | high | pull_request_target workflow that checks out PR code |
| PR_TARGET_CACHE | high | pull_request_target + actions/cache write (TanStack pattern) |
| SCRIPT_INJECTION | high | Untrusted GitHub context interpolated in run: |
| WORKFLOW_DISPATCH_INPUT_INJECTION | high | ${{ inputs.foo }} in run: without env quoting |
| UNPINNED_THIRD_PARTY | medium | Non-actions/ action pinned by tag instead of full SHA |
| SECRETS_INHERIT | medium | Reusable workflow called with secrets: inherit |
| BROAD_PERMISSIONS | medium | Top-level permissions: write-all or : write |
| CURL_PIPE_SHELL | medium | curl ... \| bash (or wget / iex) in a run: step |
| SELF_HOSTED_RUNNER_PUBLIC | medium | Self-hosted runner on a pull_request trigger w/o guard |
| UNPINNED_FIRST_PARTY | low | actions/ action pinned by tag instead of SHA |
| MISSING_PERMISSIONS | low | No top-level permissions: block declared |
| OIDC_NO_AUDIENCE | low | id-token: write granted but no audience: set |
| CACHE_KEY_NO_HASH | low | actions/cache key uses github.ref/sha but no hashFiles() |
Stack
- Node.js 18+
- Express
- better-sqlite3 (WAL mode)
- node-cron
- js-yaml
- helmet, compression, morgan
- Vanilla JS SPA (no build step), dark theme
No authentication anywhere. All endpoints are public — Arda wants instant inspection without
typing a password. /api/audit is rate-limited by hashed-IP (60/hour) for abuse protection only.
Real data sources (no mocks, no seeds, no fake "preset" data)
| Source | URL | Auth | Refresh |
|---|---|---|---|
| GitHub Security Advisories (Actions) | https://api.github.com/advisories?ecosystem=actions | optional GITHUB_TOKEN bearer (raises rate from 60 to 5000 req/h) | every 15 minutes |
| OSV.dev query | https://api.osv.dev/v1/query (POST) | none | every 60 minutes, round-robin over tracked actions |
| GitHub repo + releases | https://api.github.com/repos/{owner}/{repo} and /releases?per_page=1 | optional bearer | every 6 hours per tracked action |
| GitHub search (discovery) | https://api.github.com/search/repositories?q=topic:github-actions+stars:>500 | optional bearer | daily |
The only hardcoded data is a list of names of ~50 popular GitHub Actions infetchers/seed_actions.js. Every numeric field (stars, latest version, open CVE count, etc.) is
fetched live at runtime and stored with a refreshed_at timestamp. If a fetch fails the row
shows —; the app never fabricates.
Endpoints
All paths are under BASE_PATH (default /action-shield).
| Method | Path | Description |
|---|---|---|
| GET | / | Main SPA |
| GET | /health | {status:"ok"} — auth-free for orchestrator smoke check |
| GET | /api/cves | Paginated CVE list — filter by ?severity=, ?since=7d, ?package= |
| GET | /api/cves/:ghsa_id | CVE detail (description, references, affected) |
| GET | /api/actions/top | Top tracked actions, sortable by stars / cves / released |
| GET | /api/actions/:owner/:repo | Single action with CVE history |
| POST | /api/audit | Body: {"yaml": "..."} — returns findings + score |
| GET | /api/audits/recent | Last 50 audits, anonymized counts only |
| GET | /api/rules | All detection rules with examples |
| GET | /api/rules/:id | Single rule detail |
| GET | /api/stats | Aggregate dashboard stats (totals, top rules, last-fetch timestamps) |
Cron schedule
| Cron | Job |
|---|---|
| /15 | refresh GHSA Actions advisories |
| 7 | OSV.dev cross-check (round-robin) |
| 0 /6 | refresh repo metadata / releases for tracked actions |
| 0 3 | discover new popular actions via GitHub search |
| 30 4 | recompute open_cve_count per action |
| 0 5 | prune audits + fetch_log older than 30 days |
| 0 0 * | rotate daily IP-hash salt |
An initial fetch runs ~5 seconds after boot so the dashboard isn't empty on first load.
Run locally
cp .env.example .env
npm install
npm start
# open http://localhost:4827/action-shield/
Configuration
| Env var | Default | Purpose |
|---|---|---|
| PORT | 4827 | HTTP port |
| BASE_PATH | /action-shield | URL prefix for everything (lets you mount under nginx) |
| NODE_ENV | production | flips morgan format and log verbosity |
| GITHUB_TOKEN | (none) | optional PAT with no scopes — raises GHSA / search rate from 60 to 5000 req/h |
| DB_PATH | ./action-shield.db | SQLite file path |
License
MIT