Supply Chain Security

What is CI/CD security?

HuntRule Team · · 12 min read

A ring of keys left hanging on a hook beside a running factory conveyor belt in a darkened plant
On this page

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's

The 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.2

Pinning 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 token

Self-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.

A dark workshop bench still covered in metal shavings and offcuts from the previous job, one work lamp overhead

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: medium

What 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 claim

Pipeline 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