🔥 Advanced

CI/CD Pipeline Lab Setup

Deploy vulnerable CI/CD pipelines locally using GitLab CE, Jenkins, and Gitea to practice pipeline poisoning, credential theft, secret extraction, and supply chain attacks in a safe environment.

Why CI/CD Labs?

CI/CD pipelines are increasingly targeted in real-world attacks. Codecov, SolarWinds, and 3CX all involved supply chain compromises. Understanding pipeline security is essential for modern red team operations.

Lab Architecture

┌──────────────────────────────────────────────────────────────────────┐
│                    CI/CD LAB (Docker Network)                       │
│                                                                      │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────────┐   │
│  │   GitLab CE   │    │   Jenkins    │    │   Gitea + Drone CI  │   │
│  │   :8929       │    │   :8080      │    │   :3000 / :8000     │   │
│  │               │    │              │    │                      │   │
│  │  • Repos      │    │  • Pipelines │    │  • Lightweight Git   │   │
│  │  • CI/CD      │    │  • Groovy    │    │  • Container-native  │   │
│  │  • Registry   │    │  • Plugins   │    │  • YAML pipelines    │   │
│  └──────┬───────┘    └──────┬───────┘    └──────────┬───────────┘   │
│         │                    │                        │              │
│         └────────────┬───────┴────────────────────────┘              │
│                      │                                               │
│              ┌───────┴────────┐                                     │
│              │  Docker-in-     │                                     │
│              │  Docker Runner  │                                     │
│              │  (Build Agent)  │                                     │
│              └────────────────┘                                     │
└──────────────────────────────────────────────────────────────────────┘

Option 1: GitLab CE (Recommended)

GitLab CE includes built-in CI/CD, container registry, and code review — making it the most realistic self-hosted lab environment.

bash
# Create docker-compose.yml for GitLab
cat > docker-compose.yml << 'EOF'
services:
  gitlab:
    image: gitlab/gitlab-ce:latest
    container_name: gitlab
    hostname: gitlab.lab.local
    ports:
      - "8929:80"        # Web UI
      - "2224:22"        # Git SSH
      - "5050:5050"      # Container Registry
    volumes:
      - gitlab_config:/etc/gitlab
      - gitlab_logs:/var/log/gitlab
      - gitlab_data:/var/opt/gitlab
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://gitlab.lab.local:8929'
        gitlab_rails['initial_root_password'] = 'LabPassword123!'
        registry_external_url 'http://gitlab.lab.local:5050'
    shm_size: '256m'

  gitlab-runner:
    image: gitlab/gitlab-runner:latest
    container_name: gitlab-runner
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - runner_config:/etc/gitlab-runner
    depends_on:
      - gitlab

volumes:
  gitlab_config:
  gitlab_logs:
  gitlab_data:
  runner_config:
EOF

docker compose up -d
# Wait 2-3 minutes for GitLab to initialize
# Access at http://localhost:8929 — root / LabPassword123!
# Create docker-compose.yml for GitLab
cat > docker-compose.yml << 'EOF'
services:
  gitlab:
    image: gitlab/gitlab-ce:latest
    container_name: gitlab
    hostname: gitlab.lab.local
    ports:
      - "8929:80"        # Web UI
      - "2224:22"        # Git SSH
      - "5050:5050"      # Container Registry
    volumes:
      - gitlab_config:/etc/gitlab
      - gitlab_logs:/var/log/gitlab
      - gitlab_data:/var/opt/gitlab
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://gitlab.lab.local:8929'
        gitlab_rails['initial_root_password'] = 'LabPassword123!'
        registry_external_url 'http://gitlab.lab.local:5050'
    shm_size: '256m'

  gitlab-runner:
    image: gitlab/gitlab-runner:latest
    container_name: gitlab-runner
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - runner_config:/etc/gitlab-runner
    depends_on:
      - gitlab

volumes:
  gitlab_config:
  gitlab_logs:
  gitlab_data:
  runner_config:
EOF

docker compose up -d
# Wait 2-3 minutes for GitLab to initialize
# Access at http://localhost:8929 — root / LabPassword123!

Register GitLab Runner

bash
# Get registration token from GitLab:
# Admin Area > CI/CD > Runners > Register runner

# Register the runner
docker exec -it gitlab-runner gitlab-runner register \
  --non-interactive \
  --url "http://gitlab:80" \
  --token "YOUR_REGISTRATION_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "lab-runner" \
  --docker-privileged  # Needed for Docker-in-Docker builds
# Get registration token from GitLab:
# Admin Area > CI/CD > Runners > Register runner

# Register the runner
docker exec -it gitlab-runner gitlab-runner register \
  --non-interactive \
  --url "http://gitlab:80" \
  --token "YOUR_REGISTRATION_TOKEN" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "lab-runner" \
  --docker-privileged  # Needed for Docker-in-Docker builds

Vulnerable Pipeline Configuration

yaml
# This pipeline has multiple security issues for practice
stages:
  - build
  - test
  - deploy

variables:
  # BAD: Hardcoded secrets in pipeline variables
  AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE"
  AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
  DB_PASSWORD: "production_password_123"

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    # BAD: Using untrusted base image
    - docker build -t myapp:latest .
    # BAD: Pushing to registry without scanning
    - docker push registry.lab.local:5050/myapp:latest

test:
  stage: test
  script:
    # BAD: Executing arbitrary code from dependencies
    - npm install  # No lockfile verification
    - npm test
    # BAD: Printing environment (may leak secrets)
    - printenv

deploy:
  stage: deploy
  script:
    # BAD: SSH key in pipeline
    - echo "$SSH_PRIVATE_KEY" > /tmp/deploy_key
    - chmod 600 /tmp/deploy_key
    - ssh -i /tmp/deploy_key root@prod-server "docker pull && docker restart app"
  environment:
    name: production
# This pipeline has multiple security issues for practice
stages:
  - build
  - test
  - deploy

variables:
  # BAD: Hardcoded secrets in pipeline variables
  AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE"
  AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
  DB_PASSWORD: "production_password_123"

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    # BAD: Using untrusted base image
    - docker build -t myapp:latest .
    # BAD: Pushing to registry without scanning
    - docker push registry.lab.local:5050/myapp:latest

test:
  stage: test
  script:
    # BAD: Executing arbitrary code from dependencies
    - npm install  # No lockfile verification
    - npm test
    # BAD: Printing environment (may leak secrets)
    - printenv

deploy:
  stage: deploy
  script:
    # BAD: SSH key in pipeline
    - echo "$SSH_PRIVATE_KEY" > /tmp/deploy_key
    - chmod 600 /tmp/deploy_key
    - ssh -i /tmp/deploy_key root@prod-server "docker pull && docker restart app"
  environment:
    name: production

Option 2: Jenkins

bash
# Run Jenkins with Docker socket access
docker run -d \
  --name jenkins \
  -p 8080:8080 \
  -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  jenkins/jenkins:lts

# Get initial admin password
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

# Access at http://localhost:8080
# Install suggested plugins during setup
# Run Jenkins with Docker socket access
docker run -d \
  --name jenkins \
  -p 8080:8080 \
  -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  jenkins/jenkins:lts

# Get initial admin password
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

# Access at http://localhost:8080
# Install suggested plugins during setup

Vulnerable Jenkinsfile

groovy
pipeline {
    agent any

    // BAD: Credentials in environment block
    environment {
        DB_CREDS = 'admin:SuperSecret123'
        API_KEY  = 'sk-live-abc123def456'
    }

    stages {
        stage('Build') {
            steps {
                // BAD: Arbitrary Groovy execution enabled
                script {
                    def output = sh(script: 'whoami && id', returnStdout: true)
                    echo "Running as: ${output}"
                }
                sh 'docker build -t app:latest .'
            }
        }

        stage('Test') {
            steps {
                // BAD: No dependency verification
                sh 'pip install -r requirements.txt'
                sh 'python -m pytest'

                // BAD: Dumping credentials for debugging
                sh 'echo $DB_CREDS'
            }
        }

        stage('Deploy') {
            steps {
                // BAD: Remote code execution via SSH with embedded key
                sh """
                    sshpass -p 'root123' ssh root@production \
                    'cd /opt/app && git pull && docker compose up -d'
                """
            }
        }
    }
}
pipeline {
    agent any

    // BAD: Credentials in environment block
    environment {
        DB_CREDS = 'admin:SuperSecret123'
        API_KEY  = 'sk-live-abc123def456'
    }

    stages {
        stage('Build') {
            steps {
                // BAD: Arbitrary Groovy execution enabled
                script {
                    def output = sh(script: 'whoami && id', returnStdout: true)
                    echo "Running as: ${output}"
                }
                sh 'docker build -t app:latest .'
            }
        }

        stage('Test') {
            steps {
                // BAD: No dependency verification
                sh 'pip install -r requirements.txt'
                sh 'python -m pytest'

                // BAD: Dumping credentials for debugging
                sh 'echo $DB_CREDS'
            }
        }

        stage('Deploy') {
            steps {
                // BAD: Remote code execution via SSH with embedded key
                sh """
                    sshpass -p 'root123' ssh root@production \
                    'cd /opt/app && git pull && docker compose up -d'
                """
            }
        }
    }
}

Option 3: Gitea (Lightweight)

Gitea is a lightweight self-hosted Git service. Pair with Woodpecker CI (Drone fork) for a minimal pipeline lab that uses fewer resources.

bash
cat > docker-compose.yml << 'EOF'
services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    ports:
      - "3000:3000"    # Web UI
      - "2222:22"      # Git SSH
    volumes:
      - gitea_data:/data
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__server__ROOT_URL=http://localhost:3000

  woodpecker:
    image: woodpeckerci/woodpecker-server:latest
    container_name: woodpecker
    ports:
      - "8000:8000"
    volumes:
      - woodpecker_data:/var/lib/woodpecker
    environment:
      - WOODPECKER_OPEN=true
      - WOODPECKER_GITEA=true
      - WOODPECKER_GITEA_URL=http://gitea:3000
      - WOODPECKER_GITEA_CLIENT=your-client-id
      - WOODPECKER_GITEA_SECRET=your-client-secret
      - WOODPECKER_HOST=http://localhost:8000
      - WOODPECKER_SECRET=woodpecker-secret-key

volumes:
  gitea_data:
  woodpecker_data:
EOF

docker compose up -d
# Access Gitea at http://localhost:3000
# Access Woodpecker CI at http://localhost:8000
cat > docker-compose.yml << 'EOF'
services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    ports:
      - "3000:3000"    # Web UI
      - "2222:22"      # Git SSH
    volumes:
      - gitea_data:/data
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__server__ROOT_URL=http://localhost:3000

  woodpecker:
    image: woodpeckerci/woodpecker-server:latest
    container_name: woodpecker
    ports:
      - "8000:8000"
    volumes:
      - woodpecker_data:/var/lib/woodpecker
    environment:
      - WOODPECKER_OPEN=true
      - WOODPECKER_GITEA=true
      - WOODPECKER_GITEA_URL=http://gitea:3000
      - WOODPECKER_GITEA_CLIENT=your-client-id
      - WOODPECKER_GITEA_SECRET=your-client-secret
      - WOODPECKER_HOST=http://localhost:8000
      - WOODPECKER_SECRET=woodpecker-secret-key

volumes:
  gitea_data:
  woodpecker_data:
EOF

docker compose up -d
# Access Gitea at http://localhost:3000
# Access Woodpecker CI at http://localhost:8000

Vulnerable-by-Design CI/CD Labs

CI/CDGoat

Intentionally vulnerable CI/CD environment by Cider Security. Includes GitLab, Jenkins, and Gitea with 11 challenges covering pipeline poisoning, credential theft, and more.

bash
git clone https://github.com/cider-security-research/cicd-goat.git
cd cicd-goat
docker compose up -d
# Access at http://localhost:8080 (Jenkins)
# and http://localhost:3000 (Gitea)
git clone https://github.com/cider-security-research/cicd-goat.git
cd cicd-goat
docker compose up -d
# Access at http://localhost:8080 (Jenkins)
# and http://localhost:3000 (Gitea)
GitHub: cicd-goat →

Top 10 CI/CD Risks

OWASP Top 10 CI/CD Security Risks framework. Each risk maps to real attack techniques you can practice in this lab.

  • • CICD-SEC-1: Insufficient Flow Control
  • • CICD-SEC-3: Dependency Chain Abuse
  • • CICD-SEC-4: Poisoned Pipeline Execution
  • • CICD-SEC-6: Insufficient Credential Hygiene
  • • CICD-SEC-9: Improper Artifact Integrity
OWASP CI/CD Top 10 →

Practice Exercises

Exercise 1: Secret Extraction

Find and extract hardcoded secrets from pipeline configurations. Use tools like trufflehog and gitleaks to scan repositories.

Exercise 2: Pipeline Poisoning (PPE)

Modify a CI config file in a feature branch to inject commands. Fork a repo and submit a PR that runs malicious build steps.

Exercise 3: Jenkins Script Console RCE

Access the Jenkins Script Console (/script) and execute Groovy code to read files, spawn reverse shells, or access credentials.

Exercise 4: Container Escape from Runner

Exploit Docker socket access in a CI runner to escape the container and access the host system.

Exercise 5: Dependency Confusion

Set up a private registry and demonstrate dependency confusion by creating a public package with a higher version number.

Troubleshooting FAQ

GitLab taking too long to start
  • GitLab CE needs 4-8GB RAM and takes 2-5 minutes to fully initialize
  • Check progress: docker logs -f gitlab
  • Increase Docker memory limit in Docker Desktop settings
  • Use Gitea instead if resources are limited — it starts in seconds
GitLab runner not picking up jobs
  • Verify runner registration: check Admin > CI/CD > Runners in GitLab UI
  • Runner must be able to reach GitLab: docker exec gitlab-runner ping gitlab
  • Check runner logs: docker logs gitlab-runner
  • Ensure project has the runner assigned (Settings > CI/CD > Runners)
Jenkins plugins failing to install
  • Check internet connectivity from the container: docker exec jenkins curl -I https://updates.jenkins.io
  • Update Jenkins to latest LTS before installing plugins
  • Some plugins have Java version requirements — check compatibility
  • Try installing from the CLI: docker exec jenkins jenkins-plugin-cli --plugins plugin-name