ci-lint — GitHub Actions Security Linting
Lints GitHub Actions workflow files under .github/workflows/ for security misconfigurations that can lead to CI pipeline poisoning, secret exfiltration, and privilege escalation.
CI pipeline attacks have become a primary supply chain vector — the tj-actions/changed-files compromise, the Codecov bash uploader incident, and the reviewdog action compromise all exploited misconfigurations that ci-lint would flag.
Usage
greengate ci-lint [OPTIONS]
Options:
-h, --help Print helpExamples
# Lint all .github/workflows/*.yml files
greengate ci-lint
# Output SARIF for GitHub Code Scanning
greengate ci-lint --format sarif > ci-lint.sarifRules
CILINT/UnpinnedAction — Unpinned action ref (HIGH)
Using a mutable tag like @v4 or @main means anyone who can push to that repo can silently change what code runs in your CI.
# BAD — mutable tag, can be changed by the action author at any time
- uses: actions/checkout@v4
# GOOD — pinned to an immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2Fix: pin every uses: to a full commit SHA and add a comment showing the version for human readability.
CILINT/PullRequestTargetCheckout — Dangerous pull_request_target + checkout (CRITICAL)
pull_request_target runs with write permissions and access to repository secrets. If you check out PR code in this context, an attacker can send a pull request with a malicious workflow modification and steal your secrets.
# BAD — checks out untrusted PR code with write permissions
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}Fix: never check out PR code in a pull_request_target workflow. If you need to run tests on PR code, use a separate pull_request workflow that has no write permissions or secret access.
CILINT/ExpressionInjection — Expression injection in run step (HIGH)
GitHub Actions expressions (${{ ... }}) are expanded before the shell interprets the run: script. Injecting attacker-controlled values like github.event.pull_request.title or github.event.issue.body directly into a run: step allows arbitrary command execution.
# BAD — attacker controls github.event.pull_request.title
- run: echo "Processing PR: ${{ github.event.pull_request.title }}"
# GOOD — pass through an intermediate env var (shell safely quotes it)
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Processing PR: $PR_TITLE"Flagged expression sources: github.event.pull_request.title, github.event.pull_request.body, github.event.issue.title, github.event.issue.body, github.head_ref, github.event.head_commit.message.
CILINT/SecretsInEnv — Secrets leaked via job-level env (MEDIUM)
Defining secrets at the jobs.<job>.env level makes them visible to every step in that job — including third-party actions and any commands that print environment variables.
# BAD — secret visible to all steps including third-party actions
jobs:
deploy:
env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- uses: some-third-party/action@v1 # can read AWS_SECRET_ACCESS_KEY
# GOOD — scope the secret to the specific step that needs it
jobs:
deploy:
steps:
- run: aws s3 sync ...
env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Sample output
ℹ️ CI lint: scanning 3 workflow file(s) in .github/workflows/...
⚠️ Found 4 potential issue(s):
- [HIGH] [CILINT/UnpinnedAction] .github/workflows/ci.yml:12
- [HIGH] [CILINT/UnpinnedAction] .github/workflows/ci.yml:18
- [CRITICAL] [CILINT/PullRequestTargetCheckout] .github/workflows/release.yml:31
- [HIGH] [CILINT/ExpressionInjection] .github/workflows/pr-check.yml:44
Error: CI lint failed: 4 issue(s) found.CI usage
- name: Lint CI workflows
run: greengate ci-lint
# Upload findings to GitHub Code Scanning
- name: CI lint (SARIF)
run: greengate ci-lint --format sarif > ci-lint.sarif || true
- name: Upload to Code Scanning
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ci-lint.sarifSuppressing findings
Add # greengate: ignore on the line above the flagged line to suppress a specific finding:
# greengate: ignore
- uses: actions/checkout@v4 # suppressed: internal repo, we control this actionOr disable a rule entirely in .greengate.toml:
[sast]
disabled_rules = ["CILINT/UnpinnedAction"]