Audit Your GitHub Actions Workflows for Security and Performance Issues
Most GitHub Actions workflows accumulate problems quietly.
A missing cache: 'npm' in your setup-node step adds 60 seconds to every CI run. Multiply that by 50 developers pushing code 5 times a day — that's 25,000 seconds of wasted CI time per week. Across a year, it's real money in compute costs and real friction in developer experience.
Worse: a pull_request_target misconfiguration can let a malicious contributor exfiltrate your repository secrets. GitHub's own security team published an advisory about this class of vulnerability. It affects popular open source projects and well-funded startups alike.
These issues are detectable. They follow predictable patterns. They should be caught before they ship.
That's what ci-check does.
What ci-check Does
ci-check is a zero-dependency Node.js CLI that scans your .github/workflows/ directory and reports:
- Security vulnerabilities (critical and high severity)
- Performance inefficiencies (missing caches, no concurrency cancellation)
- Maintenance issues (mutable action pins, missing timeouts, missing permissions)
No external dependencies. No API calls. No configuration required. It reads your workflow files and tells you what's wrong.
npx ci-check
That's the entire install-and-run process.
The 10 Checks
SEC-001: pull_request_target with Untrusted Code Checkout (Critical)
This is the highest-severity issue ci-check detects. The pull_request_target trigger runs in the context of the target branch — meaning it has write permissions and access to secrets. If you also check out the PR's code and run it, you've given the PR author execution access to your secrets.
What the vulnerable pattern looks like:
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← dangerous
- run: npm test # ← PR code runs with repo write access
Why it's dangerous: An attacker submits a PR that modifies the test script to print ${{ secrets.NPM_TOKEN }} to stdout. Your CI runs it. Secret gone.
The fix: Never execute PR code in a pull_request_target workflow. If you need to comment on PRs (common use case), use a two-workflow pattern: an unprivileged pull_request workflow that runs code and uploads an artifact, and a separate privileged workflow triggered by workflow_run that downloads the artifact and posts the comment.
SEC-002: Secrets in pull_request_target Without Environment Protection (High)
Even without checking out PR code, using secrets in a pull_request_target workflow without environment protection gates is risky. Someone with write access could modify the workflow.
The fix: Add an environment with required reviewers:
jobs:
deploy:
environment: production # Requires approval before secrets are available
PERF-001: Missing npm Cache (Warning)
Every npm install without caching downloads all your dependencies from scratch. On a warm internet connection, this is 30-90 seconds per run. The fix is one line.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # ← this one line
With this, GitHub caches your node_modules keyed to your package-lock.json. Subsequent runs that don't change dependencies skip the install entirely. First run after a lock file change restores from cache automatically.
PERF-002: Docker Build Without Layer Cache (Warning)
Docker builds in CI rebuild every layer from scratch unless you configure cache backends. For a typical Node.js application, this means re-building the base image, re-running npm install, and re-copying application files every time — even if nothing relevant changed.
- uses: docker/build-push-action@v5
with:
context: .
push: true
cache-from: type=gha # ← restore from GitHub Actions cache
cache-to: type=gha,mode=max # ← save to GitHub Actions cache
The GitHub Actions cache backend (type=gha) stores Docker layer cache in the same cache storage as your regular workflow caches. First run builds everything. Subsequent runs that don't change the Dockerfile or dependencies restore from cache and finish in seconds.
PERF-003: Missing Concurrency Cancellation (Warning)
When a developer pushes 5 commits in a row while refactoring, 5 workflow runs queue up. Without concurrency cancellation, all 5 run to completion. The developer waits for the oldest run to finish before seeing results from the latest commit.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This cancels any in-progress run for the same workflow+branch when a new run starts. Developers always see results from their latest commit. CI cost drops proportionally.
PERF-004: Push Trigger Without Path Filters (Info)
Without path filters, pushing a typo fix in README.md triggers your full test suite and Docker build. This burns CI minutes and adds noise to the PR check list.
on:
push:
paths:
- 'src/**'
- 'package*.json'
- 'Dockerfile'
paths-ignore:
- '**.md'
- 'docs/**'
With path filters, documentation changes don't trigger expensive pipelines. Code changes still do.
MAINT-001: Mutable Action Tags (Info)
Pinning to @v4 or @v3 means the action can change without notice. If an action maintainer's account is compromised, an attacker can push malicious code to the same tag.
# Mutable (what most people write):
- uses: docker/build-push-action@v5
# Immutable (what security-conscious teams use):
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a0f # v5.1.0
Add Dependabot or Renovate to automate SHA updates when new versions release.
MAINT-002: Missing Job Timeout (Warning)
GitHub's default timeout for a job is 6 hours. If your test suite hangs waiting for a database connection that never comes, you'll burn 6 hours of CI minutes before GitHub kills it. Set explicit timeouts:
jobs:
test:
timeout-minutes: 15 # Kill after 15 minutes, not 6 hours
runs-on: ubuntu-latest
The right value is "3-5x your normal run time." If tests normally take 4 minutes, set 15-20 minutes as the timeout.
MAINT-003: Missing Permissions Declaration (Info)
By default, the GITHUB_TOKEN in workflows has broad permissions based on your repository settings. Declaring minimal permissions explicitly is better practice and is required by some compliance policies.
permissions:
contents: read # Read repository code
pull-requests: write # Post PR comments (if needed)
packages: write # Publish to GHCR (if needed)
If your workflow only reads code, contents: read with everything else denied is sufficient.
MAINT-004: continue-on-error on Critical Steps (Warning)
continue-on-error: true allows a step to fail without failing the job. This is occasionally useful — for example, posting a status comment that shouldn't block deployment. But on test steps, it silently allows broken code to pass CI.
# Dangerous — tests can fail and CI still goes green
- run: npm test
continue-on-error: true # ← remove this
If you use continue-on-error, add a comment explaining exactly why the failure is acceptable.
Installation
# Run without installing (good for one-off audits)
npx ci-check
# Install globally
npm install -g ci-check
# Add to your project
npm install --save-dev ci-check
Use It in Your Own CI
The most useful application of ci-check is running it as a step in your pipeline to prevent workflow regressions:
name: Lint Workflows
on:
pull_request:
paths:
- '.github/workflows/**'
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Audit GitHub Actions workflows
run: npx ci-check
# Exits 1 on critical/high findings — blocks the PR
This means anyone adding or modifying workflows gets immediate feedback on security and performance issues before the PR merges.
JSON Output for Integration
ci-check --json
Outputs structured JSON useful for custom tooling, dashboards, or piping into other CLI tools:
[
{
"file": ".github/workflows/ci.yml",
"findings": [
{
"checkId": "PERF-001",
"severity": "warning",
"title": "Missing npm cache configuration",
"line": 12,
"detail": "actions/setup-node found without cache: npm.",
"fix": "Add cache: 'npm' to your setup-node step..."
}
]
}
]
What a Clean Workflow Looks Like
Here's a workflow that passes all ci-check checks:
name: CI
on:
push:
paths:
- 'src/**'
- 'package*.json'
pull_request:
# Cancel in-progress runs when new commits push
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Minimal permissions
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run build
This workflow is fast (npm cache hits on every non-lock-file push), safe (no secrets, minimal permissions), and resilient (5-minute timeout, concurrency cancellation).
Get the Package
npm install -g ci-check
Source, issues, and contributions: github.com/axiom-experiment/ci-check
If ci-check catches something useful in your codebase, consider sponsoring further development.
This package is part of the AXIOM open source toolkit — a collection of zero-dependency Node.js developer tools. Follow the experiment at axiom-experiment.hashnode.dev.
Top comments (0)