CI/CD Security for GitHub Actions: Protecting Your Pipeline
GitHub Actions is a powerful CI/CD platform, but securing your workflows requires careful attention to secrets management, permissions, and supply chain security. This guide covers essential security practices for GitHub Actions.
Secrets Management
Using GitHub Secrets Securely
# .github/workflows/secure-deployment.yml
name: Secure Deployment
on:
push:
branches: [main]
# Restrict workflow permissions
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires approval and protects secrets
steps:
- uses: actions/checkout@v4
# Use secrets from environment
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
# Use OIDC instead of access keys
- name: Deploy
env:
# Never echo secrets
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
# Mask any accidental output
echo "::add-mask::$DATABASE_URL"
./deploy.shSecrets Scanning Prevention
# .github/workflows/secrets-scan.yml
name: Secrets Scanning
on:
pull_request:
branches: [main]
jobs:
scan-secrets:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for scanning
- name: TruffleHog Secrets Scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.pull_request.base.sha }}
head: HEAD
extra_args: --only-verified
- name: Gitleaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
- name: Check for hardcoded secrets
run: |
# Check for common secret patterns
if grep -rE "(password|secret|api_key|apikey|token)\s*=\s*['\"][^'\"]+['\"]" \
--include="*.py" --include="*.js" --include="*.ts" \
--include="*.yaml" --include="*.yml" --include="*.json" .; then
echo "::error::Potential hardcoded secrets detected"
exit 1
fiOIDC Authentication
AWS OIDC Configuration
# .github/workflows/aws-oidc.yml
name: AWS Deployment with OIDC
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
role-session-name: GitHubActionsSession
aws-region: us-east-1
# No access keys needed!
- name: Verify identity
run: aws sts get-caller-identityAWS IAM Role for GitHub Actions
# terraform/github-oidc.tf
# Configure OIDC provider
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# IAM role for GitHub Actions
resource "aws_iam_role" "github_actions" {
name = "GitHubActionsRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Restrict to specific repo and branches
"token.actions.githubusercontent.com:sub" = [
"repo:myorg/myrepo:ref:refs/heads/main",
"repo:myorg/myrepo:environment:production"
]
}
}
}
]
})
}
# Attach policies based on least privilege
resource "aws_iam_role_policy" "github_actions" {
name = "GitHubActionsPolicy"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecs:UpdateService",
"ecs:DescribeServices"
]
Resource = "arn:aws:ecs:*:*:service/production/*"
}
]
})
}Workflow Permissions
Least Privilege Permissions
# .github/workflows/secure-workflow.yml
name: Secure Workflow
on:
pull_request:
branches: [main]
# Global permissions - most restrictive by default
permissions: {}
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read # Only read access
steps:
- uses: actions/checkout@v4
- run: npm run lint
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # For uploading SARIF
steps:
- uses: actions/checkout@v4
- name: CodeQL Analysis
uses: github/codeql-action/analyze@v3
comment-pr:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # Only for commenting
steps:
- uses: actions/checkout@v4
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '✅ All checks passed!'
})Protected Environments
# .github/workflows/protected-deploy.yml
name: Production Deployment
on:
push:
tags:
- 'v*'
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: ./deploy.sh staging
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://production.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./deploy.sh productionSupply Chain Security
Dependency Pinning
# .github/workflows/pinned-deps.yml
name: Build with Pinned Dependencies
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Pin action versions by SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: '20'
cache: 'npm'
# Pin Docker image versions
- name: Build Docker image
run: |
docker build \
--build-arg BASE_IMAGE=node:20.10.0-alpine3.18@sha256:abc123... \
-t myapp:${{ github.sha }} \
.SBOM Generation
# .github/workflows/sbom.yml
name: Generate SBOM
on:
push:
branches: [main]
release:
types: [published]
permissions:
contents: write
packages: read
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate SBOM with Syft
uses: anchore/sbom-action@v0
with:
format: spdx-json
output-file: sbom.spdx.json
upload-artifact: true
- name: Scan SBOM for vulnerabilities
uses: anchore/scan-action@v3
with:
sbom: sbom.spdx.json
fail-build: true
severity-cutoff: high
- name: Attach SBOM to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
with:
files: sbom.spdx.jsonArtifact Signing
# .github/workflows/signed-artifacts.yml
name: Build and Sign Artifacts
on:
push:
tags:
- 'v*'
permissions:
contents: read
id-token: write # For Sigstore
jobs:
build-and-sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Build binary
run: |
go build -o myapp ./...
sha256sum myapp > myapp.sha256
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Sign with Cosign (keyless)
env:
COSIGN_EXPERIMENTAL: 1
run: |
cosign sign-blob --yes \
--oidc-issuer https://token.actions.githubusercontent.com \
--output-signature myapp.sig \
--output-certificate myapp.crt \
myapp
- name: Verify signature
run: |
cosign verify-blob \
--certificate myapp.crt \
--signature myapp.sig \
--certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/signed-artifacts.yml@${{ github.ref }}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
myapp
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: signed-release
path: |
myapp
myapp.sha256
myapp.sig
myapp.crtSecurity Scanning Integration
Comprehensive Security Pipeline
# .github/workflows/security-pipeline.yml
name: Security Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
pull-requests: write
jobs:
# Static Analysis
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript, python
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
upload: always
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >-
p/security-audit
p/secrets
p/owasp-top-ten
# Dependency Scanning
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Dependency Review
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@v3
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
# Container Scanning
container-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:test .
- name: Run Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:test'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
# IaC Scanning
iac-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
additional_args: --minimum-severity HIGH
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/
framework: terraform
output_format: sarif
output_file_path: checkov.sarif
- name: Upload Checkov results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov.sarif
# Final gate
security-gate:
runs-on: ubuntu-latest
needs: [sast, dependency-scan, container-scan, iac-scan]
steps:
- name: Security checks passed
run: echo "All security checks passed!"Workflow Protection
Branch Protection Rules
# Example branch protection via GitHub API
# Apply using GitHub CLI or API
# Required status checks
checks:
- context: "sast"
required: true
- context: "dependency-scan"
required: true
- context: "security-gate"
required: true
# Required reviews
reviews:
required_approving_review_count: 2
dismiss_stale_reviews: true
require_code_owner_reviews: true
require_last_push_approval: true
# Additional protections
restrictions:
enforce_admins: true
allow_force_pushes: false
allow_deletions: false
required_linear_history: true
required_conversation_resolution: trueReusable Workflow Security
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
image-tag:
required: true
type: string
secrets:
AWS_ROLE_ARN:
required: true
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy
run: |
# Validate input to prevent injection
if [[ ! "${{ inputs.image-tag }}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Invalid image tag format"
exit 1
fi
./deploy.sh ${{ inputs.environment }} ${{ inputs.image-tag }}Audit and Compliance
Workflow Audit Logging
# .github/workflows/audit-trail.yml
name: Audit Trail
on:
workflow_run:
workflows: ["*"]
types: [completed]
permissions:
actions: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Log workflow execution
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run;
const auditLog = {
timestamp: new Date().toISOString(),
workflow: run.name,
conclusion: run.conclusion,
actor: run.actor.login,
sha: run.head_sha,
branch: run.head_branch,
event: run.event,
run_id: run.id,
run_attempt: run.run_attempt,
repository: context.repo.repo
};
console.log(JSON.stringify(auditLog));
// Send to SIEM or logging service
// await fetch(process.env.AUDIT_ENDPOINT, {
// method: 'POST',
// body: JSON.stringify(auditLog)
// });Best Practices Summary
- Use OIDC - Eliminate long-lived credentials with OIDC federation
- Minimize permissions - Apply least privilege to all workflows
- Pin dependencies - Use SHA-pinned action versions
- Scan everything - SAST, DAST, dependency, container, and IaC scanning
- Sign artifacts - Use Sigstore for keyless artifact signing
- Protect branches - Require reviews and status checks
- Audit workflows - Log all workflow executions for compliance
- Use environments - Protect secrets with deployment environments
Following these practices ensures your CI/CD pipeline is as secure as your application code.
Related Resources
- ML CI/CD: Continuous Integration and Deployment for Machine Learning — Extend GitHub Actions security to ML workflows
- MLOps Security: Securing Your ML Pipeline — End-to-end ML pipeline security
- MLOps Best Practices: Building Production-Ready ML Pipelines — Production pipeline patterns
- AI Supply Chain Security: Protecting ML Pipelines — Supply chain attacks in ML
- What is MLOps? — Complete MLOps overview