DevSecOps

CI/CD Security for GitHub Actions: Protecting Your Pipeline

DeviDevs Team
8 min read
#GitHub Actions#CI/CD#security#DevSecOps#automation

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.sh

Secrets 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
          fi

OIDC 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-identity

AWS 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 production

Supply 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.json

Artifact 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.crt

Security 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: true

Reusable 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

  1. Use OIDC - Eliminate long-lived credentials with OIDC federation
  2. Minimize permissions - Apply least privilege to all workflows
  3. Pin dependencies - Use SHA-pinned action versions
  4. Scan everything - SAST, DAST, dependency, container, and IaC scanning
  5. Sign artifacts - Use Sigstore for keyless artifact signing
  6. Protect branches - Require reviews and status checks
  7. Audit workflows - Log all workflow executions for compliance
  8. Use environments - Protect secrets with deployment environments

Following these practices ensures your CI/CD pipeline is as secure as your application code.

Weekly AI Security & Automation Digest

Get the latest on AI Security, workflow automation, secure integrations, and custom platform development delivered weekly.

No spam. Unsubscribe anytime.