What is CI/CD security?
HuntRule Team · · 12 min read

On this page
- The privilege question first
- Trigger surface: code that runs before review
- Third-party actions pinned to a tag
- Secrets that reach jobs which do not need them
- Self-hosted runners that remember
- The deploy credential itself
- Detection: what to actually alert on
- What this rule catches and misses
- Runner egress as the second signal
- ATT&CK mapping
- Summary
On 14 and 15 March 2025 the tags v1 through v45.0.7 of tj-actions/changed-files were repointed to commit 0e58ed8. The injected updateFeatures code read secrets out of the runner process and printed them into the workflow log. NVD tracks it as CVE-2025-30066. CISA added it to the KEV catalog on 18 March 2025.
Nobody was phished. Nobody exploited a memory bug. Thousands of pipelines fetched a mutable tag, as designed, and executed what was behind it.
CI/CD security is the practice of treating that pipeline as a production system with a production identity. Most estates do not. The runner is a machine that executes code written by people outside the trust boundary, holds credentials that can deploy to production, and is monitored less than the average laptop.
The privilege question first
Start with the question that decides everything else. What can this runner do if the code it runs is hostile?
For a typical deploy pipeline the honest answer is: everything the deploy role can do. A runner holding a Kubernetes service account token can apply a manifest. A runner with an npm publish token can ship a release to every downstream consumer.
That is not a build agent. That is a production identity with no MFA, no session, and no human attached. Rate it in your asset inventory the way you rate a domain controller, and the rest of this post stops being optional.
Trigger surface: code that runs before review
The default pull_request trigger on GitHub is comparatively safe. The workflow runs against the fork, with a read-only GITHUB_TOKEN and no access to secrets.
pull_request_target is the dangerous one. It runs the workflow definition from the default branch of the base repository, with a token that can have write access and with access to repository secrets. That is fine on its own. It becomes a compromise the moment the workflow checks out the pull request head and runs anything from it.
# Vulnerable pattern. Attacker-controlled code runs with base-repo secrets.
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run build # postinstall and build scripts are the fork'sThe second trigger problem is expression injection. GitHub interpolates ${{ }} expressions into the shell script before the shell sees them, so attacker-controlled text becomes shell code.
# Vulnerable. The PR title is substituted into the script body before bash runs.
- run: echo "Reviewing ${{ github.event.pull_request.title }}"
# Fixed. The value arrives through the environment, never through the script text.
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Reviewing $PR_TITLE"Pull request titles and bodies, branch names, commit messages, issue comments and review bodies all carry attacker text. Treat every github.event.* string as untrusted.
Third-party actions pinned to a tag
uses: some-org/some-action@v4 is a request for whatever v4 points at when the job starts. Git tags are mutable. Whoever controls that repository, or steals a maintainer token, controls what your runner executes on the next run of every pipeline that references it.
That is exactly the tj-actions mechanism. No new version was published. Existing tags were moved.
# Mutable. Resolves at run time to whatever the tag points at now.
- uses: actions/checkout@v4
# Immutable. Content-addressed, so a repointed tag changes nothing.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2Pinning by hash is the control. Its limit is that it pins the top level only. If the pinned action's action.yml calls a second action by tag, or runs a Docker image by tag, you have pinned a wrapper around a moving target.
Secrets that reach jobs which do not need them
Repository secrets are readable by anyone with write access to the repository, which usually means every engineer who can merge. Organisation secrets are worse, being visible to every repository in scope. Neither is scoped to the job that needs the credential.
Environments fix this, because an environment carries required reviewers and a branch restriction. A deploy secret placed in a production environment with a required reviewer cannot be reached by a job on a feature branch, whatever the workflow file says. Job-level permissions does the same for the GITHUB_TOKEN.
permissions:
contents: read # workflow default, everything else dropped
jobs:
deploy:
environment: production
permissions:
contents: read
id-token: write # only this job can mint an OIDC tokenSelf-hosted runners that remember
A GitHub-hosted runner is a fresh VM destroyed after the job. A self-hosted runner, by default, is a long-lived process on a machine you own. Between jobs it keeps the filesystem, the Docker layer cache, the npm and pip caches, environment variables written by earlier steps, and any process a previous job left running.
That makes it a persistence host. Job A drops a binary into /opt/actions-runner/_work/_tool or writes a shell function into the runner user's profile, and job B on a different repository, holding a different secret, inherits it. GitHub's guidance is that self-hosted runners should almost never be used for public repositories, because any user can open a pull request and compromise the environment, and that private and internal repositories deserve the same caution, because anyone who can fork one and open a pull request can do the same.
The control is registering the runner as ephemeral, so it accepts exactly one job and then deregisters itself, backed by fresh instances built from an image. Its limit is honest to state: ephemeral removes cross-job persistence, not in-job reach. A single malicious job on an ephemeral runner still sees the network the runner sits on, and CI networks are usually flat and inside the perimeter.

The deploy credential itself
A long-lived cloud access key stored as a CI secret is the worst version of this. It is valid until someone rotates it, it works from anywhere, and it appears in the cloud audit log as a legitimate access key with no linkage back to the pipeline that used it.
OIDC federation replaces it. The workflow requests a short-lived token from token.actions.githubusercontent.com, the cloud provider validates the claims and returns temporary credentials. Its security is entirely in the trust policy conditions, and the common misconfiguration is a sub condition wide enough to be meaningless.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/*"
}
}That trusts every repository in the organisation, including the one an intern created this morning. The correct shape pins the repository and the ref, for example repo:my-org/my-service:ref:refs/heads/main or repo:my-org/my-service:environment:production. Omit the sub condition entirely and any GitHub repository on the internet can assume the role.
Detection: what to actually alert on
The GitHub audit log is the primary source, and several of the useful events do not appear in the web interface at all. They are only available through the REST API, audit log streaming or a JSON/CSV export. If streaming was never enabled, you do not have this data.
workflows.prepared_workflow_job is the one to know about. GitHub's documentation states it records that a workflow job was started and includes the list of secrets that were provided to the job. That answers "which secrets did this run touch", the first question in any CI incident.
title: GitHub Actions guardrail weakened or CI identity expanded
id: 3f7b2c19-8d64-4a5e-9c31-2e8a7b40df16
status: experimental
description: >
Detects audit log events that weaken the controls protecting a CI pipeline or
extend what it can reach. Branch protection removal and fork-PR policy changes
put attacker-controlled code closer to the deploy credential. Runner and runner
group changes add execution capacity that may not be ephemeral. None of these
are malicious on their own, which is why the rule pairs the event with the
actor and expects triage rather than response.
references:
- https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization
- https://nvd.nist.gov/vuln/detail/CVE-2025-30066
author: HuntRule
date: 2026/07/31
tags:
- attack.defense-evasion
- attack.persistence
- attack.privilege-escalation
- attack.t1078.004
- attack.t1195.001
logsource:
product: github
service: audit
definition: >
Requires GitHub audit log streaming or periodic REST API export. Several of
these actions are not visible in the audit log web interface.
detection:
selection_review_controls:
action:
- 'protected_branch.destroy'
- 'protected_branch.policy_override'
- 'protected_branch.dismissal_restricted_users_teams'
selection_actions_policy:
action:
- 'org.set_fork_pr_workflows_policy'
- 'repo.set_fork_pr_workflows_policy'
- 'org.set_actions_fork_pr_approvals_policy'
- 'repo.set_actions_fork_pr_approvals_policy'
- 'org.set_default_workflow_permissions'
- 'repo.set_default_workflow_permissions'
- 'org.set_workflow_permission_can_approve_pr'
- 'repo.set_workflow_permission_can_approve_pr'
- 'repo.update_actions_access_settings'
selection_runners:
action:
- 'org.register_self_hosted_runner'
- 'repo.register_self_hosted_runner'
- 'org.runner_group_created'
- 'org.runner_group_updated'
- 'org.runner_group_runners_added'
- 'org.update_repo_self_hosted_runners_policy'
condition: 1 of selection_*
falsepositives:
- Platform teams rolling out runner fleets or changing org-wide Actions policy
- Repository migration and archival work, which touches branch protection
- Automation using an org-admin token, where the actor is a bot rather than a human
level: mediumWhat this rule catches and misses
It catches the setup, not the payload. Someone removing branch protection on main at 02:00, then registering a new runner, is a sequence worth waking up for. So is protected_branch.policy_override, which fires when a repository administrator pushes past a protection requirement rather than removing it.
It misses the tj-actions case entirely. Nothing in the GitHub audit log records that a workflow resolved a tag to a different commit than it did yesterday. That check belongs in the pipeline, as a .github/workflows diff gate or an allowlist of pinned SHAs, not in the SIEM.
It also does not detect a workflow file modified outside a reviewed pull request, which is the event most people want. The audit log carries no file-level diff. The closest honest approximation is to join protected_branch.policy_override and protected_branch.destroy against push events from your Git webhook stream, filtered to paths under .github/workflows/, and treat any push to that path with no approved review attached as the finding. That needs the webhook feed. The audit log alone will not get you there.
The false positive load lands on selection_runners. A team standing up a new runner fleet will generate dozens of these in an afternoon. Run that selection as a weekly hunt with the actor and runner group name in the output, and keep only selection_review_controls on the alerting path.
Runner egress as the second signal
Everything above is control-plane telemetry. The host-plane question is simpler: is this runner talking to somewhere it has no reason to talk to? CI runners have narrow destination sets. Source control, one or two package registries, a container registry, your cloud API endpoints. Almost nothing else.
-- Hunt: self-hosted runner egress outside the expected destination set.
-- Source: Zeek conn.log, VPC flow logs, or EDR network telemetry.
SELECT
src_ip,
dest_domain,
dest_port,
count(*) AS conns,
min(ts) AS first_seen
FROM network_conn
WHERE ts > now() - INTERVAL '7 days'
AND src_ip IN (SELECT ip FROM asset_group WHERE name = 'ci_runners')
AND dest_domain NOT LIKE '%.github.com'
AND dest_domain NOT LIKE '%.githubusercontent.com'
AND dest_domain NOT LIKE '%.amazonaws.com'
AND dest_domain NOT IN (
'github.com',
'registry.npmjs.org',
'pypi.org',
'files.pythonhosted.org',
'proxy.golang.org',
'registry.terraform.io'
)
GROUP BY src_ip, dest_domain, dest_port
ORDER BY first_seen DESC;Build your own allowlist from 30 days of baseline before you enable this, because ours will not match yours. The blind spot is exfiltration to an allowlisted host. A payload that writes secrets into a public GitHub Gist, or into the workflow log itself, produces no anomalous destination. tj-actions did the second one, which is why it stayed invisible on the network and was caught by someone reading a build log.
ATT&CK mapping
ID | Name | Where it shows up |
|---|---|---|
T1195.001 | Compromise Software Dependencies and Development Tools | Repointed action tags, malicious postinstall scripts |
T1195.002 | Compromise Software Supply Chain | Runner used to publish a trojanized release artifact |
T1078.004 | Valid Accounts: Cloud Accounts | Over-broad OIDC trust policy, stolen deploy key |
T1098.001 | Account Manipulation: Additional Cloud Credentials | New key minted from a compromised deploy role |
T1552.001 | Unsecured Credentials: Credentials In Files | Secrets in workflow logs and runner working directories |
T1213.003 | Data from Information Repositories: Code Repositories | Source and secret harvesting from a compromised job |
T1199 | Trusted Relationship | Downstream consumers of a compromised build |
Summary
A CI runner runs code that arrives from outside your trust boundary and holds credentials that reach production. Those two facts together are the whole problem. The controls that hold are boring and specific: never check out a fork's head under pull_request_target, pass untrusted expressions through the environment rather than the script body, pin third-party actions to full commit SHAs, put deploy secrets behind environments with required reviewers, register self-hosted runners as ephemeral, and pin OIDC trust policies to a repository and a ref rather than an organisation glob. Detection is the part that is usually missing entirely, and the reason is upstream of the SOC. GitHub audit log streaming is off by default, many of the events that matter never appear in the web interface, and self-hosted runners often sit outside the EDR rollout. CI is routinely the least-collected log source in the estate, which is covered more broadly in what is security telemetry.
Collect these before you need them:
GitHub audit log (streaming or REST export, not the web UI):
workflows.prepared_workflow_job job started, with the list of secrets provided
workflows.created_workflow_run run created, with event, head_branch, head_sha
workflows.actions_policy_violation run breached a workflow execution protection policy
protected_branch.destroy branch protection removed
protected_branch.policy_override admin pushed past a protection requirement
repo.register_self_hosted_runner new runner attached to a repository
org.register_self_hosted_runner new runner attached to an organisation
repo.self_hosted_runner_online runner process started (API/stream only)
org.create_actions_secret new org-scoped Actions secret
repo.create_actions_secret new repo-scoped Actions secret
environment.create_actions_secret new environment-scoped Actions secret
personal_access_token.access_granted fine-grained PAT given access to resources
integration_installation.create GitHub App installed
Runner host and network:
EDR process creation on self-hosted runners, with full command line
Outbound connection logs keyed to the runner asset group
Cloud CloudTrail/Activity logs for AssumeRoleWithWebIdentity, with the sub claimPipeline and cloud detection content lives in the catalog under /rules?domain=cloud, and supply-chain specific rules are at /rules?q=supply%20chain.
Related articles

What Is a Build Pipeline Compromise?
SolarWinds.Orion.Core.BusinessLayer.dll shipped with a valid SolarWinds Authenticode signature and a backdoor inside the same assembly. The git history was clean. The certificate was the real one.…
2026-06-149 min read

What Is Typosquatting in Package Repositories?
On 19 July 2017 an npm user named hacktask published a package called crossenv. The popular package is cross-env. The squat read the environment variables of whatever machine installed it and sent…
2026-05-2110 min read

What Is Dependency Confusion?
pip install --extra-index-url https://pypi.internal.example.com/simple internal-auth-lib queries two indexes at once. pip's own documentation says what happens next. There is no priority in the…
2026-05-1111 min read