---
name: harden-cicd-pipeline
description: "Defensive methodology for CI/CD pipeline compromise: expression and workflow injection that turns attacker-controlled PR titles, branch names, and issue bodies into runner command execution, over-broad OIDC federation that hands the wrong repo or branch cloud credentials, and self-hosted runner abuse (MITRE ATT&CK T1195.002, T1059, T1199, T1078.004, T1552.004, T1098.001). Signals live in the GitHub/GitLab/Azure DevOps audit log, runner process and EDR telemetry, and cloud federation logs (AWS CloudTrail AssumeRoleWithWebIdentity, Entra ID service-principal sign-ins, GCP STS). Gives the defender view, what to log and alert on, a hunt, and OIDC, runner-isolation, and workflow-injection hardening. Use when hardening GitHub Actions, GitLab CI, or Azure Pipelines, reviewing an OIDC trust policy, hunting for poisoned pipeline execution, triaging an unexpected AssumeRoleWithWebIdentity, or isolating self-hosted runners."
---

# CI/CD Pipeline Compromise - Defensive Methodology

> **Tier 2 - methodology skill.** No bespoke UMBRASEC detection rule ships with this skill yet. It gives the defender's method, the log sources and signals, and the hardening, and points to public rule sources. Promotion to Tier 1 (a sourced, tuned UMBRASEC rule) is tracked in [COVERAGE.md](https://github.com/umbrasec-labs/umbra-blue/blob/main/COVERAGE.md). Do not treat the content below as citing a UMBRASEC rule; there is none for this threat yet.

## TL;DR

A CI/CD pipeline runs code in a trusted context, holds deployment credentials, and is watched less closely than production. Three paths do most of the damage, and each has a specific defender signal. **Workflow injection:** attacker-controlled text (`${{ github.event.pull_request.title }}`, `github.head_ref`, issue and comment bodies) flows into a `run:` step and executes as the runner; the tell is a `Runner.Worker` child process spawning a shell with network egress on a `pull_request_target` or `workflow_run` job. **OIDC over-trust:** the pipeline exchanges a short-lived OIDC token for cloud credentials, but the cloud trust policy accepts a wildcard subject, so the wrong repo, fork, or branch can assume the role; the tell is an `AssumeRoleWithWebIdentity` in CloudTrail whose `subjectFromWebIdentityToken` does not match your expected `repo:ORG/REPO:ref:` prefix. **Self-hosted runner abuse:** a new or persistent runner on a public or widely forked repo lets a forked-PR job run on your infrastructure; the tell is a `self_hosted_runner` registration event in the SCM audit log. Harden by scoping OIDC trust to an exact repo, ref, and environment, pinning least-privilege tokens, and running untrusted workloads only on ephemeral, isolated runners.

## What it looks like, defender view

These attacks are designed to ride inside normal pipeline activity, so the signal is a mismatch against your own baseline rather than a novel event type. In workflow injection the pipeline looks like it ran the intended job, but one step forked a shell and reached out to the network because untrusted event data was interpolated into a command; you see a legitimate workflow run whose runner process tree contains an unexpected interpreter and egress. In OIDC over-trust the federation works exactly as designed, which is the problem: a valid build token is swapped for valid cloud credentials, so the cloud control plane records a clean `AssumeRoleWithWebIdentity` or workload-identity sign-in whose only anomaly is the federated subject, the role, or the source that requested it. In self-hosted runner abuse a runner comes online and accepts jobs normally, but it is attached to a repo that should never run untrusted code on owned infrastructure. All three present as authorized-looking pipeline or cloud activity whose subject, source, or process behavior does not match how your pipelines are supposed to work. This skill explains each path only to the depth needed to catch and close it and ships no pipeline-exploitation tooling.

## Log sources and signals

Instrument three planes: the SCM audit log, the runner host, and the cloud federation endpoint. The injection path is only fully visible on the runner host, and the OIDC path is only fully visible in the cloud log, so a gap on either is a blind spot.

- **SCM / pipeline audit log.** GitHub Enterprise and organization audit log (stream it to your SIEM), GitLab audit events and CI job logs, Azure DevOps audit streams. The high-value GitHub events: `workflows.prepared_workflow_job` (records the runner labels and whether the job requested an OIDC token), `workflows.created_workflow_run`, `org.register_self_hosted_runner` / `repo.register_self_hosted_runner`, `org.self_hosted_runner_online`, `org.remove_self_hosted_runner` / `repo.remove_self_hosted_runner`, the `org.runner_group_*` events (for example `org.runner_group_created`, `org.runner_group_runners_added`), `protected_branch.policy_override`, `repo.change_merge_setting`, `repo.access` changes, and secret events such as `org.create_actions_secret` and the repository secret equivalents. Lean on the fields `actor`, `repo`, `event` (the triggering event, for example `pull_request_target`), `head_branch`, `workflow_run_id`, and `runner_labels`.
- **Runner host telemetry.** For self-hosted runners, OS process and EDR data: Linux auditd or `execve` records and Sysmon Event ID 1 (process create) and Event ID 3 (network connect) on Windows. The signal is a child of `Runner.Worker` (or `run-worker`) that is an interpreter or network client (`bash`, `sh`, `pwsh`, `curl`, `wget`, `python`) making outbound connections, or a step that writes to `$GITHUB_ENV` / `$GITHUB_PATH` from untrusted input. The hosted-runner equivalent is the workflow step logs and the `_diag/Worker_*.log` diagnostic files.
- **Cloud federation logs.** AWS CloudTrail: `AssumeRoleWithWebIdentity` with `eventSource` `sts.amazonaws.com`. The decisive fields are `responseElements.subjectFromWebIdentityToken` (the OIDC `sub`, for example `repo:ORG/REPO:ref:refs/heads/main` or `repo:ORG/REPO:environment:production`), `requestParameters.roleArn`, `requestParameters.roleSessionName`, and `sourceIPAddress`. Azure: Entra ID service-principal sign-ins in the `AADServicePrincipalSignInLogs` table in Log Analytics / Microsoft Sentinel, plus directory audit events for adding a federated identity credential. GCP: Cloud Audit Logs for `sts.googleapis.com` and `iamcredentials.googleapis.com` `GenerateAccessToken`, keying off `protoPayload.authenticationInfo.principalSubject`.

What normal looks like: your OIDC subjects should be a small, enumerable set of `repo:ORG/REPO:` prefixes bound to specific refs or environments; your self-hosted runners should be a known fleet attached to private repos; and `Runner.Worker` should spawn only the interpreters your build actually uses. Deviation from each of those baselines is the alertable condition.

## Detection approach

Detect across the three planes, in prose.

**OIDC trust-boundary mismatch (highest precision).** Maintain the allowlist of OIDC subjects each cloud role is meant to serve, then alert on any `AssumeRoleWithWebIdentity` whose `subjectFromWebIdentityToken` falls outside it: a `repo:` prefix that is not yours, a `ref:` that is not the protected branch the role expects, a missing `environment:` claim where you require one, or a `roleSessionName` or `sourceIPAddress` that does not fit your build sources. The root cause this catches is a trust policy whose `sub` condition uses a wildcard (`repo:ORG/*` or `repo:ORG/REPO:*`) instead of an exact ref or environment. The Azure and GCP analogues alert on a service-principal or workload-identity sign-in whose federated subject is outside the expected set.

**Workflow injection on the runner.** Two layers. Pre-merge static analysis of workflow YAML catches the injectable sink before it ships: flag `${{ ... }}` interpolation of `github.event.*`, `github.head_ref`, and comment, issue, and title bodies inside `run:` or action inputs, and flag `pull_request_target` / `workflow_run` jobs that check out an untrusted ref and then execute it. Runtime detection alerts on the runner process anomaly above: `Runner.Worker` spawning an unexpected interpreter with egress during a job triggered by an untrusted event.

**Self-hosted runner abuse.** Alert on `org.register_self_hosted_runner` / `repo.register_self_hosted_runner` and `org.runner_group_*` changes, especially for runners attached to public or heavily forked repositories, and on persistent (non-ephemeral) runners accepting jobs from forked pull requests.

There is no tuned UMBRASEC rule for this threat yet. For public, maintained starting points: the SigmaHQ repository ships GitHub audit-log rules under [rules/application/github/audit](https://github.com/SigmaHQ/sigma/tree/master/rules/application/github/audit) (self-hosted runner changes, high-risk setting changes, new secrets, outside collaborator) and AWS STS rules under [rules/cloud/aws/cloudtrail](https://github.com/SigmaHQ/sigma/tree/master/rules/cloud/aws); treat these as scaffolding to tune against your own subject allowlist and runner fleet. For workflow static analysis use the maintained open-source linters [zizmor](https://github.com/zizmorcore/zizmor), [octoscan](https://github.com/synacktiv/octoscan), and [actionlint](https://github.com/rhysd/actionlint), and GitHub's CodeQL Actions queries. For runner runtime policy and egress control see [StepSecurity Harden-Runner](https://github.com/step-security/harden-runner). The authoritative hardening references are CISA and NSA's [Defending Continuous Integration/Continuous Delivery Environments](https://www.cisa.gov/resources-tools/resources/defending-continuous-integration-continuous-delivery-cicd-environments), GitHub's [Security hardening for GitHub Actions](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions) and [OIDC hardening](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments) docs, OWASP's [Top 10 CI/CD Security Risks](https://owasp.org/www-project-top-10-ci-cd-security-risks/), and the [SLSA](https://slsa.dev/) build-integrity framework.

## Threat hunt

For the quiet attacker who assumes one role once, or triggers one injected job and waits, the volume and registration alerts will not fire. Hunt instead:

1. Over a long window, pull every `AssumeRoleWithWebIdentity` event and project `subjectFromWebIdentityToken`, `roleArn`, and `sourceIPAddress`. Build the real distribution of subjects per role and compare it to the trust policy: any `sub` that the policy's wildcard technically permits but that your pipelines never legitimately produce is a finding. Rank by the privilege of the assumed role.
2. Enumerate every cloud OIDC trust policy / federated credential and grade its `sub` condition. A condition that matches `repo:ORG/*`, `repo:ORG/REPO:*`, or a bare `repo:ORG/REPO:` with no `ref:` or `environment:` qualifier is the over-trust that makes step 1 exploitable; treat those as findings in their own right.
3. Across all repositories, grep workflow definitions for untrusted interpolation sinks (`github.event.*`, `github.head_ref`) inside `run:` steps and for `pull_request_target` / `workflow_run` jobs that check out and execute a fork's code. These are the injectable workflows whether or not anyone has fired them.
4. List every self-hosted runner and the repositories it serves, and flag any runner reachable from public or forkable repos, any non-ephemeral runner, and any runner that has executed a job triggered by a forked pull request.
5. Correlate: for any injectable workflow (step 3) running on an exposed runner (step 4) that can request an over-trusted OIDC token (step 2), you have a full path from external contributor to cloud credentials. Close that chain first.

## Hardening

Shrink the trust each path depends on. Mappings below use control codes grounded in the UMBRASEC guide ([identity-and-access.html](https://umbrasec.dev/guide/identity-and-access.html), [governance-and-maturity.html](https://umbrasec.dev/guide/governance-and-maturity.html), [foundation.html](https://umbrasec.dev/guide/foundation.html)); a cell is left blank where no single control maps cleanly.

| Control | What it does | Essential Eight | ISO 27001:2022 | NIST CSF 2.0 |
|---|---|---|---|---|
| Short-lived OIDC federation instead of stored secrets | Replace long-lived cloud keys in CI with per-run OIDC tokens so there is no static credential to steal from the runner or secret store | - | A.5.17 Authentication information | PR.AA-01 Identities and credentials |
| Exact-match OIDC trust policy | Scope each cloud role's trust to an exact `repo:ORG/REPO`, a specific ref or environment, and the correct audience, removing the wildcard subject path | - | A.8.2 Privileged access rights | PR.AA-05 Least privilege |
| Least-privilege pipeline token | Set the default `GITHUB_TOKEN` and equivalent to read-only and grant only the narrow write scopes a job needs, per job not per workflow | Restrict administrative privileges | A.8.2 Privileged access rights | PR.AA-05 Least privilege |
| Ephemeral, isolated runners | Run untrusted or fork-triggered workloads only on single-use, network-restricted runners; keep self-hosted runners off public repos; apply application control on the runner host | All eight strategies at ML2; application control | A.8.19 Software installation | PR.PS Platform security |
| Pin actions and patch the runner | Pin third-party actions to a full commit SHA and keep the runner agent and actions current, closing the mutable-tag and known-vulnerability paths | Patch applications | A.8.8 Technical vulnerabilities | PR.PS Platform security |
| Govern third-party actions and dependencies | Allowlist which external actions may run and review their provenance, treating the marketplace as a supplier | - (Beyond E8) | A.5.19-A.5.22 Supplier relationships | GV.SC Supply chain |
| MFA / SSO and branch protection on the SCM | Require strong auth on the source platform and required reviews plus protected branches so untrusted code cannot reach a privileged pipeline unreviewed | Multi-factor authentication | A.8.5 Secure authentication | PR.AA-01 Identities and credentials |

Keep two injection-specific practices alongside the table: never use `pull_request_target` or `workflow_run` to check out and execute a fork's code with secrets in scope, and pass untrusted event data through an intermediate environment variable (`env:`) rather than interpolating it directly into a `run:` command.

## Honest limitations

The OIDC mismatch detection is only as good as your subject allowlist and your trust policies: if a role already trusts a wildcard subject, the assume event looks legitimate and will only surface through the step 1 hunt, not through a real-time rule. Workflow-injection runtime detection depends on runner host telemetry that hosted runners do not give you, so on GitHub-hosted runners you are mostly limited to pre-merge static analysis and cannot watch the process tree. Static linters flag the injectable pattern but cannot prove exploitability, so they produce false positives on workflows that interpolate untrusted data into contexts that never execute it. None of this replaces fixing the trust itself: a wildcard OIDC policy, a `pull_request_target` job that runs fork code, or a persistent self-hosted runner on a public repo is the actual vulnerability, and detection only buys time until the trust policy is scoped, the workflow is rewritten, and untrusted workloads are moved to ephemeral runners. Baseline every rule in report-only mode before enforcing.

## ATT&CK and D3FEND

- MITRE ATT&CK: [T1195.002 Supply Chain Compromise: Compromise Software Supply Chain](https://attack.mitre.org/techniques/T1195/002/) (the pipeline as the injection point); [T1059 Command and Scripting Interpreter](https://attack.mitre.org/techniques/T1059/) (workflow injection executing on the runner); [T1199 Trusted Relationship](https://attack.mitre.org/techniques/T1199/) and [T1078.004 Valid Accounts: Cloud Accounts](https://attack.mitre.org/techniques/T1078/004/) (OIDC federation abused to reach the cloud); [T1552.004 Unsecured Credentials: Private Keys](https://attack.mitre.org/techniques/T1552/004/) (secret theft from the runner); [T1098.001 Account Manipulation: Additional Cloud Credentials](https://attack.mitre.org/techniques/T1098/001/) (persistence after an assume).
- MITRE D3FEND: [Application Configuration Hardening (D3-ACH)](https://d3fend.mitre.org/technique/d3f:ApplicationConfigurationHardening/) for exact-match trust policies, least-privilege tokens, and pinned actions; [Credential Rotation (D3-CRO)](https://d3fend.mitre.org/technique/d3f:CredentialRotation/) for the short-lived OIDC tokens that replace stored secrets; [Execution Isolation (D3-EI)](https://d3fend.mitre.org/technique/d3f:ExecutionIsolation/) for ephemeral, network-restricted runners.
- Public rule and guidance sources: [SigmaHQ application/github rules](https://github.com/SigmaHQ/sigma/tree/master/rules/application/github/audit), [SigmaHQ cloud/aws rules](https://github.com/SigmaHQ/sigma/tree/master/rules/cloud/aws), [zizmor](https://github.com/zizmorcore/zizmor), [octoscan](https://github.com/synacktiv/octoscan), [actionlint](https://github.com/rhysd/actionlint), [StepSecurity Harden-Runner](https://github.com/step-security/harden-runner), [CISA/NSA Defending CI/CD Environments](https://www.cisa.gov/resources-tools/resources/defending-continuous-integration-continuous-delivery-cicd-environments), [GitHub Actions security hardening](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions), [OWASP Top 10 CI/CD Security Risks](https://owasp.org/www-project-top-10-ci-cd-security-risks/), [SLSA](https://slsa.dev/).

**Scope note.** This is a defensive skill. It explains CI/CD pipeline compromise only to the depth needed to detect and mitigate it and deliberately provides no pipeline-exploitation, injection, or credential-theft tooling. UMBRASEC publishes defense, not offense.
