CI/CD for Beginners: Docker, GitLab CI and the first safe pypaline

Depov

Activist
ULTIMATE
SUPREME
PREMIUM
MEMBER
Joined
Feb 18, 2025
Messages
128
Reaction score
116
Deposit
0$
CI/CD-pipeline as an attack surface
CI/CD (Continuous Integration / Continuous Delivery) - a conveyor that automatically collects, tests and delivers code from repository to production. For the developer, this is the acceleration of releases. For a pentester, a chain of servers, tokens and configuration files, where each link can become an input point.

Why attacking pypeline? Three scenarios with direct financial impact: credentials from CI/CD give access to the production and customer data; the index code turns into a supply chain attack on all users of the product; the capture of Runner servers opens the way to cryptomining or lateral movement over the network. According to Verizon DBIR 2025, 38% of data breaches are related to account data theft. The IBM X-Force recorded an increase in attacks using valid credentials by 71% year-on-year (2024 vs 2023). The numbers speak for themselves.

Here's how the paipeline falls on the kill chain with an internal pentest:
1. Initial Access - compromising the developer account or supply chain attack on addictions (T1195.001). The attacker gets access to the repository with .gitlab-ci.yml.
2. Credential Access - Removing Secrets from Configuration Files, Environment Variables, Docker Confess (Credentials In Files, T1552.001).
3. Execution - the introduction of malicious code into pypline (Poisoned Pipeline Execution, T1677, OWASP CICD-SEC-4). The attacker modifies .gitlab-ci.yml, adding the efficiency step, and on the next Runner cannon, it will perform this code automatically. A separate vector is an infected Docker image (Malcious Image, T1204.003): pulling a malicious image from the public registry.
4. Privelge Escalation - Escape to Host (T1611). Runner with a privileged Docker socket is a straight path to root on the host.
5. Lateral Movement - through deck tokens and service account of the attacker moves into the production environment, the Kubernetes cluster or cloud infrastructure.
According to the OWASPD DSOMM (DevSecopes Maturity Model), most teams sit at the first level of maturity - security is added by post factum. Security Misconfuration (OWASP A05:2021) is more common in pypelines than in the applications themselves. The reason is banal: the pyplay is set up by the jung, and no one is renewing.

Pipeline is the point where credentials converge, source code and production access. Ability to read .gitlab-ci.yml and find holes in it is the basic skill of a pentester on a par with the analysis of configs nginx.
Docker Security: From Configuration Errors to Host Escape
Docker is the basis of most CI/CD-pipelines. GitLab Runner performs tasks in Docker containers by default. The illusion of isolation is created, but reality is more complicated.

[Applicable: internal pentest, any infrastructure with Docker. Restriction: Docker rootless mode and Podman reduces the risks]

Container from root. If Dockerfile does not contain instructions USER, the container is powered by root. When accessing a Docker socket (a typical configuration for assembling images in CI), the attacker escalates the privileges on the host. GTFOBins documents two scenarios, and both use one command: docker run -v /:/mnt --rm -it alpine chroot /mnt sh. (a) User in the group docker on the host - privesc to root through the launch of the container with root FS. (b) Container with fitted /var/run/docker.sock - escape to host (T1611) through the same command made inside the container against the host daemon. Both vectors give root on the host, but the preconditions are different. It does not work if the Docker is running in rootless mode or the environment uses Podman.

Infected basic images (Supply Chain Compromise, T1195.001). June Writing FROM python:latest or pull images from public registers without verification. The attacking, controlling the popular Docker image, gets the code execution on each build of each project using this image - this supply chain attack (T1195.001) Direct intersection with OWASP A06:2021 (Vulnerable and Outdated Components) and A08:2021 (Software and Data Integrity Failures) I saw on the same project how the image with 50 stars on the Docker Hub pulled without a single check. Lucky he was clean.

Escape from the container (Escape to Host, T1611). Privileged regime (--privileged) access to /var/run/docker.sock,mounting of the site sensitive pathways - standard configurations "so CI work". Each of them is a vector of escape. On pentests regularly come across Runners with --privileged "for compatibility with Docker-in-Docker". It's like disable the firewall for convenience.

Minimal safe Dockerffile for pypaline:
Code:
FROM python:3.11-slim AS builder
WORKDIR /app
RUN adduser --disabled-password --gecos '' appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "main.py"]
Let's see what's here: slim-image instead of latest - less packets, less attack surface. Dedicated appuser Instead of root. --no-cache-dir remove cached data from the image. Multi-stage assembly via AS builder separates build-dependences from runtime: in the final form there is no gcc, make and other things that the attacker uses for post-exploitation.

When Docker insulation does not save. Containers share the core with the host. Vulnerability in the container or runc gives root on the host, regardless of the container settings. For critical environments - gVisor or Kata Containers. In the cloud - GKE Shielded Nodes, AKS with Pod Security Standards. Container and Resource Discovery (T1613) allows the attacker, trapped in the container environment, to list the rest of the containers and services - so the network segmentation between the containers is mandatory.
Customize CI/CD from the Zero: GitLab CI piplay with security gates
Adjustments to the environment
• Account: GitLab.com (free parity, shared runners included) or self-hosted GitLab CE 16+
• Docker: Docker Engine 24+ (GNU/Linux) or Docker Desktop (macOS/Windows)
• RAM: at least 4 GB for local Runner with Docker executor; 8 GB at parallel launch Trivy and Semgrep
• OS: Ubuntu 22.04+, macOS 13+, Windows 10+ with WSL2
• Network: online - access to registry.gitlab.com and Docker Hub to download scanner images
• Local Runner (optional): installation through gitlab-runner install, registration with Docker executor
On GitLab.com shared runners go for free - for the first page to put a local Runner is not necessary.
Pipeline Structure
File .gitlab-ci.yml at the root repository determines the stages and tasks. The minimum secure gitlab cipipeline is four stages: build, test, security, deploy. Each stage is a security gate: the failure on the previous one blocks the following. The principle of shift-left security: catch the problem before getting into the production.

Stage build collects the Docker image. Use image: docker:24 with service docker:dind. Commands: docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . and docker push. Variable $CI_REGISTRY_IMAGE automatically points to the built-in Container Registry GitLab. $CI_COMMIT_SHA pint image to a specific comet instead of a floating tag latestwhich can be re-recorded.

Stage test Starts the unit tests. The only safety rule: do not throw production secrets into the test stage. Fixture or mok-data - and enough.

Stage security - what is rare in Russian-speaking CI/CD. Security gate, which blocks the deck when vulnerabilities are detected:
YAML:
security-scan:
stage: security
image:
name: aquasec/trivy:latest
entrypoint: [""]
before_script:
- export TRIVY_USERNAME=$CI_REGISTRY_USER
- export TRIVY_PASSWORD=$CI_REGISTRY_PASSWORD
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- trivy fs --exit-code 1 --severity HIGH,CRITICAL .
allow_failure: false
Flag --exit-code 1 Trivy causes the non-zero code to find HIGH or CRITICAL vulnerabilities. GitLab interprets this as a failure of the task - stage deploy won't start. allow_failure: false - clear indication that the gate cannot be ignored. trivy image scans the Docker image on the known CVE in system packs and dependencies. trivy fs checks the file system of the project, including IaC-spongi.

To check the secrets, add the task with gitleaks detect --source . --exit-code 1 in the same stage. GitLeaks scans files and git history on API key patterns, passwords and tokens. I recommend running it and as pre-commit hook: pre-commit install with config in .pre-commit-config.yaml. So the secrets are caught before the comet, not after the gunpowder.

Stage deploy Protect the rule rules: - if: $CI_COMMIT_BRANCH == "main" - deck only from the main branch. Add environment: production to track decks in the GitLab interface.

GitLab Ultimate vs Free: Built-in SAST, DAST, Dependency Scanning and Secret Detection are only available in GitLab Ultimate. According to the GitLab documentation, these tools are automatically integrated through include templates with full integration in UI. On a free tariff, the same functionality is closed by open-source tools - Trivy, Semgrep, GitLeaks. The difference in integration with Merge Request comments, but detecting is comparable.
Scanning Tools: Selection and Limits
Bandit is referred to as a highly specialized Semgrep alternative for Python: by par with lv.to, it catch hardcoded passwords (B105) and shell injection (B605) with minimal adjustment.

None of these tools replace manual pentest. They catch low-hangging fruit: known CVE, leaked secrets, typical patches of vulnerable code. Difficult business logic, IDOR, race conditions - do not see. SAST without a DAST is a blind zone. Security gates raise the entry threshold for the attacker, but does not close it completely.
DevOps for the Jun: Where the Secrets of the Pipline Are Lost
Credentials In Files (T1552.001) - one of the most exploited techniques in CI/CD. Three patterns that occur on pentests repeatedly:

Secrets in EV-instruction Dockerffile. Line ENV DATABASE_URL=postgres://admin:password@prod-db:5432/main gets into each layer of the Docker image and is visible through docker history. Anyone who gets access to Container Registry will also receive a password from the base. Solution: Transmit Secrets through --build-arg and don’t keep them in the final form, or use Docker Secrets for runtime.

Variables in .gitlab-ci.yml open text. GitLab CI/CD Variables (Settings -> CI/CD -> Variables) allows you to store secrets outside the code. Mark the variable as Masked (not displayed in the logs) and Protected (available only in protected-fours). Without masking, the secret will leak into the log. I saw a case when echo $DATABASE_URL in the debug script sent a password from production base to the public GitLab log. The developer forgot to remove debug, and no one did the configure.

Secrets in Git History. The developer has met .env with credentials, then deleted the file in the next comet. The file is not in HEAD, but he lives in guitar history. Team git log --all --full-history -- .env will show the committee, git show <hash>:.env - contents. GitLeaks detects such artifacts, but only if you start it.

For mature projects, it is worth screwing HashiCorp Vault: secrets are requested by Runner at the time of execution through JWT authentication GitLab, are not stored in variables and do not fall into images. But for the first pypaline GitLab CI/CD Variables with flags Masked + Protected Minimum level required.
 
Top Bottom