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?
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.
# 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
# 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 buildsVulnerable Pipeline Configuration
# 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: productionOption 2: Jenkins
# 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 setupVulnerable Jenkinsfile
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.
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:8000cat > 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:8000Vulnerable-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.
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)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
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