CI/CD Pipeline Security Hardening

Philip Rehberger Feb 6, 2026 14 min read

Secure your build and deployment pipelines. Protect secrets, scan dependencies, and implement signed artifacts.

CI/CD Pipeline Security Hardening

CI/CD pipelines are high-value targets for attackers. A compromised pipeline can inject malicious code into every deployment. Here's how to secure your build and deployment infrastructure.

Pipeline Security Threats

Attack Vectors

Understanding the threat landscape helps you prioritize security investments. Each vector represents a different way attackers can compromise your software delivery process, from dependency confusion attacks to direct pipeline manipulation.

This diagram outlines the major categories of pipeline threats. You should consider each one when designing your security controls.

Pipeline threats:
├── Compromised dependencies
│   └── Malicious packages in npm, PyPI, etc.
├── Secrets exposure
│   └── Credentials in logs, artifacts, code
├── Code injection
│   └── Malicious PRs, branch protection bypass
├── Supply chain attacks
│   └── Compromised build tools, base images
├── Privilege escalation
│   └── Pipeline with excessive permissions
└── Runner compromise
    └── Shared runners, untrusted code execution

Supply chain attacks are particularly insidious because they compromise trusted components. A single malicious dependency update can affect thousands of downstream projects that trust that package.

Secrets Management

Never Hardcode Secrets

Hardcoded secrets in code or configuration files persist in version control history forever, even after deletion. Using environment variables and secret management systems prevents this exposure and allows for credential rotation.

The following examples show the wrong way and right way to handle secrets in pipeline configurations. Always reference secrets from secure storage rather than embedding them directly.

# Bad: Secrets in code
- name: Deploy
  run: |
    aws configure set aws_access_key_id AKIA1234567890
    aws configure set aws_secret_access_key abcdefghijklmnop

# Good: Use secret variables
- name: Deploy
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: |
    aws s3 sync ./dist s3://my-bucket

Audit your repository history for accidentally committed secrets using tools like git-secrets or truffleHog. If secrets were ever committed, rotate them immediately since the history is permanent.

Mask Secrets in Logs

GitHub Actions automatically masks secret values in logs, but derived values, substrings, or encoded versions of secrets may still leak. Be explicit about masking any value that could reveal secret information.

When you derive values from secrets or use portions of them, you need to explicitly add masking. The automatic masking only catches exact matches of the original secret value.

# GitHub Actions automatically masks secrets
# But be careful with derived values

- name: Configure
  run: |
    # This might leak the secret
    echo "Config: ${{ secrets.API_KEY }}"

    # Use masking for derived values
    API_PREFIX="${{ secrets.API_KEY }}"
    echo "::add-mask::${API_PREFIX:0:10}"

Avoid logging environment variable dumps, configuration objects, or request details that might contain secrets. Even partial secret exposure can aid attackers in guessing or brute-forcing the complete value.

Short-Lived Credentials

Static credentials are a liability since they can be stolen, leaked, or forgotten. OIDC (OpenID Connect) integration lets your pipeline authenticate to cloud providers using short-lived tokens without storing any long-term secrets.

This configuration shows GitHub Actions authenticating to AWS without any stored credentials. The OIDC token exchange provides temporary credentials that expire automatically.

# Use OIDC for cloud authentication (no long-lived keys)
- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
    aws-region: us-east-1
    # No secrets needed - uses OIDC token exchange

# AWS IAM trust policy for GitHub OIDC
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
        }
      }
    }
  ]
}

The IAM trust policy conditions are critical for security. The sub claim restriction ensures only your specific repository can assume the role, preventing other repositories from accessing your AWS resources.

Branch Protection

Required Checks

Branch protection rules prevent unauthorized or untested code from reaching production branches. Required status checks ensure that security scans, tests, and code review have all passed before merging.

This workflow defines the checks that must pass before any code can be merged to main or develop. Configure these as required status checks in your branch protection settings.

# .github/workflows/required-checks.yml
name: Required Checks

on:
  pull_request:
    branches: [main, develop]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run security scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

      - name: Run SAST
        uses: github/codeql-action/analyze@v2

  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

# Branch protection rules (via GitHub UI or API):
# - Require pull request reviews
# - Require status checks to pass
# - Require signed commits
# - Restrict who can push
# - Require linear history

Mark these check jobs as required in your branch protection settings. Anyone attempting to merge a PR will have to wait for all required checks to pass, preventing bypass of security gates.

Signed Commits

Commit signing cryptographically verifies that commits came from who they claim to be from. This prevents attackers from impersonating legitimate developers, even if they gain write access to the repository.

Setting up commit signing requires generating a GPG key and configuring Git to use it. Once configured, all your commits will be signed automatically.

# Generate GPG key
gpg --full-generate-key

# Get key ID
gpg --list-secret-keys --keyid-format=long

# Configure Git
git config --global user.signingkey <KEY_ID>
git config --global commit.gpgsign true

# Sign commits
git commit -S -m "Signed commit message"

# Verify signature
git log --show-signature

Upload your public GPG key to GitHub so it can display verified badges on your commits. For teams, require signed commits through branch protection to ensure all merged code is traceable to verified identities.

Dependency Security

Automated Scanning

Dependencies are a major attack vector since you're running code written by others. Automated scanning catches known vulnerabilities before they reach production, and scheduled scans detect newly disclosed vulnerabilities in existing code.

This workflow runs dependency scans on every push, pull request, and on a daily schedule. Multiple scanning tools provide overlapping coverage for maximum detection.

# .github/workflows/dependency-scan.yml
name: Dependency Scan

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 0 * * *'  # Daily

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # GitHub native scanning
      - name: Dependency Review
        uses: actions/dependency-review-action@v3
        if: github.event_name == 'pull_request'

      # npm audit
      - name: NPM Audit
        run: npm audit --audit-level=high

      # Trivy for comprehensive scanning
      - name: Trivy Scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

The Dependency Review action specifically checks for new vulnerabilities introduced by a PR, making it ideal for catching problems before they're merged. The scheduled scan catches vulnerabilities disclosed after the code was written.

Lock File Integrity

Lock files ensure reproducible builds by pinning exact versions of all dependencies. Verifying lock file integrity prevents dependency confusion attacks where an attacker publishes a malicious package with a higher version number.

Using npm ci instead of npm install ensures the exact versions from the lock file are installed. The diff check catches any unexpected modifications.

# Ensure lock files are used
- name: Install dependencies
  run: |
    # npm ci uses package-lock.json exactly
    npm ci

    # Verify no modifications
    git diff --exit-code package-lock.json

# .npmrc - disable postinstall scripts from dependencies
ignore-scripts=true

The ignore-scripts option prevents dependencies from running arbitrary code during installation, which is a common attack vector. You may need to explicitly run scripts for legitimate packages that require them.

Software Bill of Materials (SBOM)

An SBOM provides a complete inventory of all software components in your application. This enables rapid response when new vulnerabilities are disclosed by immediately identifying affected systems.

Generating an SBOM as part of your build pipeline creates an artifact you can scan now and reference later when new vulnerabilities emerge.

- name: Generate SBOM
  uses: anchore/sbom-action@v0
  with:
    path: .
    format: spdx-json
    output-file: sbom.spdx.json

- name: Upload SBOM
  uses: actions/upload-artifact@v3
  with:
    name: sbom
    path: sbom.spdx.json

- name: Scan SBOM for vulnerabilities
  uses: anchore/scan-action@v3
  with:
    sbom: sbom.spdx.json
    fail-build: true
    severity-cutoff: high

Archive SBOMs for each release so you can retroactively check if a newly discovered vulnerability affects deployed versions. This becomes critical during incident response when you need to quickly assess exposure.

Container Security

Base Image Security

Container base images often contain vulnerabilities and unnecessary components. Using minimal images, pinning versions with digests, and employing multi-stage builds reduces attack surface and ensures reproducible builds.

This Dockerfile progression shows the improvement from using latest tags to pinned digests to distroless images. Each step reduces risk and improves reproducibility.

# Bad: Using latest tag
FROM node:latest

# Good: Pinned version with digest
FROM node:20.10.0-alpine3.18@sha256:abc123...

# Better: Distroless for minimal attack surface
FROM gcr.io/distroless/nodejs20-debian12

# Multi-stage to minimize final image
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app/dist /app
WORKDIR /app
CMD ["server.js"]

Distroless images contain only your application and its runtime dependencies, no shell, package manager, or other utilities that attackers could exploit. This dramatically reduces the potential attack surface.

Container Scanning

Scan container images before pushing to registries and before deploying. Integration with GitHub Security tab provides a centralized view of vulnerabilities across your organization.

This pipeline builds the image, scans it for vulnerabilities, and uploads results to GitHub's security dashboard. The exit code ensures the build fails if critical vulnerabilities are found.

- name: Build image
  run: docker build -t myapp:${{ github.sha }} .

- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    format: 'sarif'
    output: 'trivy-results.sarif'
    severity: 'CRITICAL,HIGH'
    exit-code: '1'

- name: Upload scan results
  uses: github/codeql-action/upload-sarif@v2
  with:
    sarif_file: 'trivy-results.sarif'

The SARIF format integrates with GitHub's security features, creating alerts that can be tracked and dismissed. This provides an audit trail of vulnerability handling decisions.

Image Signing

Image signing ensures that only images you've built and approved can be deployed. Cosign provides keyless signing using OIDC, similar to the credential-less authentication pattern we saw earlier.

Sign images after building and verify signatures before deploying. This creates a chain of trust from your CI/CD pipeline to your production environment.

# Sign with Cosign
- name: Install Cosign
  uses: sigstore/cosign-installer@main

- name: Sign image
  run: |
    cosign sign --yes \
      --key env://COSIGN_PRIVATE_KEY \
      myregistry.io/myapp:${{ github.sha }}
  env:
    COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}

# Verify before deploy
- name: Verify image signature
  run: |
    cosign verify \
      --key env://COSIGN_PUBLIC_KEY \
      myregistry.io/myapp:${{ github.sha }}

Configure your Kubernetes clusters to require valid signatures before running images. This prevents attackers from deploying malicious images even if they compromise your registry credentials.

Pipeline Isolation

Least Privilege

Start with no permissions and grant only what's needed for each job. Separating build and deploy jobs with different permission sets limits the blast radius if any single job is compromised.

This workflow starts with no permissions and explicitly grants only what each job needs. The build job only needs to read code, while the deploy job needs OIDC tokens for authentication.

# Minimal permissions by default
permissions: {}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read  # Only read access to code

  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      contents: read
      id-token: write  # For OIDC
    environment: production  # Requires approval

The id-token: write permission is required for OIDC authentication. Avoid granting write permissions to contents, packages, or other resources unless absolutely necessary for that specific job.

Environment Protection

Environment protection rules add human gates and additional security controls for sensitive deployments. Production deployments should require approval from designated reviewers and be restricted to specific branches.

Configure environment protection in GitHub's repository settings to require approvals, add wait timers, and restrict which branches can deploy.

# Production environment with protections
jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - name: Deploy
        run: ./deploy.sh

# Configure in GitHub:
# Settings → Environments → production
# - Required reviewers
# - Wait timer
# - Deployment branches (only main)

The wait timer provides a cool-down period where deployment can be cancelled if problems are discovered. Combined with required reviewers, this creates a multi-layer approval process for production changes.

Self-Hosted Runner Security

Self-hosted runners require extra security measures since they run on your infrastructure. Ephemeral runners that are destroyed after each job prevent state leakage between workflow runs.

Use container isolation and ephemeral instances to minimize the risk of persistent compromise. The following configuration options help secure self-hosted runners.

# Use ephemeral runners
runs-on: [self-hosted, ephemeral]

# Or use container isolation
container:
  image: node:20-alpine
  options: --user 1001 --read-only

# Runner security measures:
# - Run in isolated VMs/containers
# - Destroy after each job
# - Network isolation
# - No persistent storage
# - Minimal installed tools

Never use self-hosted runners for public repositories since anyone can submit a pull request that runs arbitrary code on your infrastructure. For private repositories, implement network isolation and monitoring.

Code Scanning

SAST Integration

Static Application Security Testing (SAST) analyzes source code for security vulnerabilities without running it. Integrating multiple tools provides comprehensive coverage since different tools excel at finding different vulnerability types.

Running both CodeQL and Semgrep provides complementary coverage. CodeQL performs deep semantic analysis, while Semgrep offers fast pattern matching.

name: SAST

on:
  push:
    branches: [main]
  pull_request:

jobs:
  codeql:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    steps:
      - uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v2
        with:
          languages: javascript, typescript

      - name: Build
        run: npm run build

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v2

  semgrep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/security-audit
            p/secrets
            p/owasp-top-ten

CodeQL provides deep semantic analysis while Semgrep offers fast pattern matching. Running both gives you the best of both approaches. The security-events permission allows writing findings to GitHub's Security tab.

Secret Scanning

Secrets accidentally committed to repositories are a leading cause of breaches. Scanning both pull requests and repository history catches secrets before and after they're committed.

TruffleHog scans for secrets in the diff between base and head commits, catching any secrets introduced by the pull request.

- name: Scan for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: ${{ github.event.pull_request.base.sha }}
    head: ${{ github.event.pull_request.head.sha }}

# Also enable GitHub secret scanning
# Settings → Code security and analysis → Secret scanning

Enable GitHub's push protection feature to block pushes containing detected secrets before they even reach the repository. This is more effective than scanning after the fact since the secret never enters version control.

Audit and Compliance

Pipeline Audit Logging

Comprehensive audit logs enable incident investigation and compliance verification. Capture who triggered deployments, what was deployed, and when, then ship these logs to a tamper-proof storage system.

This step generates a structured audit log entry with all relevant context and ships it to a centralized logging system for retention and analysis.

- name: Audit log
  run: |
    cat >> audit.json << EOF
    {
      "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
      "action": "deploy",
      "actor": "${{ github.actor }}",
      "repository": "${{ github.repository }}",
      "ref": "${{ github.ref }}",
      "sha": "${{ github.sha }}",
      "workflow": "${{ github.workflow }}",
      "run_id": "${{ github.run_id }}"
    }
    EOF

- name: Send to audit system
  run: |
    aws kinesis put-record \
      --stream-name audit-logs \
      --data file://audit.json \
      --partition-key ${{ github.repository }}

The run_id allows you to correlate audit events with the full workflow execution history in GitHub. Include enough context to reconstruct what happened during an incident without storing sensitive data.

Compliance Gates

Automated compliance gates ensure that security and quality standards are met before deployment. These checks can enforce organizational policies around code coverage, vulnerability thresholds, and approval requirements.

This job enforces multiple compliance requirements: minimum code coverage, zero critical vulnerabilities, and multiple approvals. All must pass before the workflow can proceed.

jobs:
  compliance:
    runs-on: ubuntu-latest
    steps:
      - name: Check code coverage
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage $COVERAGE% is below 80% threshold"
            exit 1
          fi

      - name: Check vulnerability count
        run: |
          CRITICAL=$(trivy image --severity CRITICAL --format json myapp | jq '.Results[].Vulnerabilities | length')
          if [ "$CRITICAL" -gt 0 ]; then
            echo "Found $CRITICAL critical vulnerabilities"
            exit 1
          fi

      - name: Verify approvals
        uses: actions/github-script@v6
        with:
          script: |
            const reviews = await github.rest.pulls.listReviews({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number
            });

            const approvals = reviews.data.filter(r => r.state === 'APPROVED');
            if (approvals.length < 2) {
              core.setFailed('Requires at least 2 approvals');
            }

These gates should fail the pipeline, not just warn. Make exceptions explicit and auditable rather than allowing bypasses through configuration changes.

Security Checklist

Use this checklist to audit your pipeline security posture. Each item represents a control that reduces your attack surface or limits the impact of a compromise.

## CI/CD Security Checklist

### Secrets
- [ ] No secrets in code or environment files
- [ ] Secrets stored in secure vault
- [ ] Short-lived credentials via OIDC
- [ ] Secrets masked in logs

### Access Control
- [ ] Branch protection enabled
- [ ] Required reviews for PRs
- [ ] Signed commits required
- [ ] Minimal pipeline permissions

### Dependencies
- [ ] Automated vulnerability scanning
- [ ] Lock files committed and verified
- [ ] SBOM generated and stored

### Containers
- [ ] Base images pinned with digest
- [ ] Container scanning in pipeline
- [ ] Image signing enabled
- [ ] Minimal base images (distroless)

### Pipeline
- [ ] Self-hosted runners isolated
- [ ] Production deploys require approval
- [ ] Audit logging enabled
- [ ] Compliance gates enforced

Conclusion

CI/CD security requires defense in depth. Protect secrets using vaults and OIDC instead of long-lived credentials. Implement branch protection and require signed commits. Scan dependencies and containers for vulnerabilities automatically. Use minimal permissions and isolate runners. Sign images and verify before deployment. Enable audit logging for compliance. A compromised pipeline can affect all your deployments, so security here is critical.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.