Back to Blog
Securing CI/CD Pipelines from Attack
🤖 AI Generated · Auto-published via GitHub Actions
🔐 Cybersecurity Weekly

Securing CI/CD Pipelines from Attack

17 August 2026 7 min read Aswin Mathew

Hook — Why CI/CD Security Matters Right Now

Every modern software team ships code through an automated pipeline. When that pipeline is compromised, the attacker gains a trusted conduit to inject malicious artifacts into production without ever touching a source repository. In the last twelve months I’ve seen three separate engagements where a single mis‑configured GitHub Actions workflow allowed an adversary to exfiltrate signing keys and publish a back‑doored container image to a public registry. The impact is immediate, widespread, and often invisible until a downstream consumer reports anomalous behavior.

Technical Explanation of the Threat

CI/CD pipelines are essentially privileged execution environments. They have access to source code, secret stores, artifact registries, and sometimes production credentials. The attack surface consists of three pillars:

Supply‑Chain Attack Surface

Modern pipelines fetch third‑party actions, Docker images, and language packages at build time. If any of those artifacts are tampered with (e.g., a compromised actions/checkout@v4 release), the pipeline will execute attacker‑controlled code with the same privileges as the legitimate job. This is the exact technique used in the 2021 Codecov breach, where a modified Bash uploader script harvested CI environment variables.

Real‑World Incidents

Step‑by‑Step Technical Breakdown

1. Harden GitHub Actions Workflows

Start by applying the principle of least privilege to every workflow. Use the permissions key to restrict the default GITHUB_TOKEN and avoid actions/checkout with persist-credentials: false when you don’t need push access.

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read          # no write access to repo
  packages: read          # read-only registry access
  id-token: write         # needed for OIDC token exchange

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout (no credentials persisted)
        uses: actions/checkout@v4
        with:
          persist-credentials: false
      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
      - name: Build Docker image
        uses: docker/build-push-action@v5
        with:
          push: false
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          load: true

2. Secure GitLab CI Pipelines

GitLab’s variables section should never contain raw secrets. Use CI/CD variables marked as protected and masked. Enforce GIT_STRATEGY: fetch to avoid cloning the entire history on every job, reducing exposure.

# .gitlab-ci.yml
stages:
  - test
  - docker
  - deploy

variables:
  # Do NOT put secrets here – use protected CI/CD variables in UI
  DOCKER_TLS_CERTDIR: "/certs"
  GIT_STRATEGY: fetch
  IMAGE_TAG: "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"

test:
  stage: test
  image: node:20-alpine
  cache:
    key: "${CI_COMMIT_REF_SLUG}"
    paths:
      - .npm/
  script:
    - npm ci --cache .npm --prefer-offline
    - npm test
  artifacts:
    reports:
      junit: test-results.xml
    expire_in: 1 week

docker_build:
  stage: docker
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
  script:
    - docker build -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
  only:
    - main
    - tags

deploy:
  stage: deploy
  image: alpine:3.19
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
  script:
    - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /app && docker compose pull && docker compose up -d"
  environment:
    name: production
    url: https://app.example.com
  only:
    - tags
  when: manual

3. Jenkins Pipeline Hardening

Declarative pipelines let you enforce timeouts, disable concurrent builds, and bind credentials to environment variables instead of writing them to the workspace. Use the credentials() helper and the withCredentials block for any secret manipulation.

pipeline {
  agent any
  options {
    disableConcurrentBuilds()
    timeout(time: 30, unit: 'MINUTES')
    timestamps()
  }
  environment {
    // Pull Docker Hub credentials from Jenkins credential store
    DOCKER_CREDS = credentials('docker-hub')
    // OIDC token for cloud provider (e.g., AWS, GCP)
    OIDC_TOKEN = credentials('oidc-token')
  }
  stages {
    stage('Checkout') {
      steps {
        checkout scm
      }
    }
    stage('Build') {
      steps {
        sh '''
          docker build -t myapp:${BUILD_NUMBER} .
        '''
      }
    }
    stage('Test') {
      steps {
        sh '''
          docker run --rm myapp:${BUILD_NUMBER} npm test
        '''
      }
    }
    stage('Push Image') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'docker-hub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
          sh '''
            echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin
            docker tag myapp:${BUILD_NUMBER} $DOCKER_USER/myapp:${BUILD_NUMBER}
            docker push $DOCKER_USER/myapp:${BUILD_NUMBER}
          '''
        }
      }
    }
    stage('Deploy') {
      when {
        branch 'main'
      }
      steps {
        // Example: use a dedicated deploy script that reads OIDC_TOKEN
        sh './deploy.sh ${OIDC_TOKEN}'
      }
    }
  }
  post {
    always {
      cleanWs()
    }
    failure {
      mail to: 'secops@example.com',
           subject: "Pipeline ${JOB_NAME} #${BUILD_NUMBER} failed",
           body: "Check console output at ${BUILD_URL}"
    }
  }
}

Defence and Mitigation Strategies

Never store secrets in plain text in pipeline files. Use secret managers (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault) or the native CI secret store with masking and protection flags.

Pro tip: Enable required reviewers and signed commits on protected branches. This prevents an attacker who compromises a contributor account from silently altering the pipeline definition.

Recommended Tools and Further Reading

Key Takeaways

All Articles