Translating CI/CD Identity into Terraform: Killing Standing Cloud Credentials Before the Next Credential-Harvesting Worm
The worms got an upgrade. They stopped mining crypto and started collecting your cloud.
When the Miasma worm tore through 32 @redhat-cloud-services npm packages on June 1, 2026 (96 poisoned versions, pulled ~117,000 times a week before anyone noticed), the interesting part wasn't the compromise. Supply-chain attacks are weather now. The interesting part was what the payload reached for once it landed. Earlier Shai-Hulud variants grabbed secrets: tokens in files, keys in env vars. Miasma added something new. Wiz's teardown notes that this variant bolted on "collectors for GCP and Azure identities" that "collect all identities the infected machine has access to." Not the secrets sitting on the box, but the cloud itself: everything that machine could already reach.
Three weeks earlier, the Megalodon campaign did the same thing at scale: 5,500+ GitHub repositories backdoored by 5,700+ malicious commits in a single six-hour window on May 18, all targeting repos with weak branch protection, all designed to exfiltrate "AWS credentials, GCP access tokens, Azure credentials, SSH private keys, Docker and Kubernetes configurations" from every subsequent pipeline run.
Here's the uncomfortable read for anyone who owns a cardholder data environment: these worms are not exploiting a vulnerability. They're cashing in a design decision. The decision to put long-lived, broadly-scoped cloud credentials on a build host and trust that nothing bad would ever execute there. The payload doesn't break in to your CDE. It logs in, with keys you minted, stored, and forgot.
You can't patch your way out of that. But you can engineer your way out, and the fix lives where Arbure spends most of its time: in Terraform.
Why the harvest works: what's actually on your runner
Walk a CI runner the way an attacker does. The Aikido breakdown of Miasma's 4.2 MB obfuscated index.js payload is essentially a target list for what's sitting in a typical build context:
- CI/CD tokens:
GITHUB_TOKEN,ACTIONS_RUNTIME_TOKEN, GitLab CI tokens - Cloud keys: AWS access keys, GCP service-account JSON files, Azure service-principal secrets
- Cluster access: Kubernetes service-account tokens,
kubeconfigfiles, Vault tokens - Publishing identity: npm / PyPI publish tokens, Docker registry creds, GPG keys, SSH private keys
- The catch-all:
.envfiles scattered across the filesystem
Every item on that list shares one property: it's a standing credential. It existed before the build ran and it'll still be valid after, usually for 90 days, often for "until someone rotates it," which is to say forever. That's the harvest. A worm that runs for nine seconds inside a preinstall script walks away with credentials that stay live for months, used from anywhere, by anyone.
The traditional answer (vault the secret, encrypt it at rest, rotate it on a schedule) manages the credential. It does not remove it. And a credential that exists on the runner is a credential the next worm collects. The only credential that survives a compromised build host is the one that was never there to begin with and is worthless ten minutes later.
That's the whole game: replace standing credentials with short-lived, federated workload identity. No static keys in CI. Ever.
The OIDC paradox (say it out loud, because the attacker used it too)
The mechanism that delivers that short-lived, federated identity is OIDC. GitHub mints a temporary identity token for the running workflow, and your cloud exchanges it for scoped, expiring access. Nothing static is stored on the runner, which is exactly why it starves the harvest.
Here is the honest caveat worth naming before you build it: OIDC is also exactly the mechanism Miasma abused. The injected ci.yaml requested an OIDC token via id-token: write, then used it to authenticate to npm's trusted-publishing endpoint and ship backdoored packages, with valid SLSA provenance attestations on the malware.
So OIDC is not magic, and anyone selling it as magic is selling you something. OIDC moves the security boundary from "where is the secret stored" to "what is the trust policy willing to mint a token for." That trust policy (the sub claim conditions, the audience pin, the repository and ref bindings) is the control. Get it broad and you've built a keyless door that opens for anybody who can trigger a workflow. Get it tight and a worm running in the wrong repo, on the wrong branch, gets a token exchange that returns nothing.
The rest of this piece is about getting it tight, in code.
AWS: federate the role, pin the subject
Stand up the GitHub OIDC provider once, then bind a role to a specific repo and a specific environment, not the whole org, not every branch.
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# No thumbprint_list needed: AWS validates GitHub's OIDC endpoint against its own trusted CAs.
}
# One role per pipeline. This is the blast-radius boundary.
data "aws_iam_policy_document" "ci_deploy_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
effect = "Allow"
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
# Pin the audience.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# Pin the EXACT subject: this repo, this protected environment.
# One line of slop here is the difference between a scoped role
# and a keyless skeleton key.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:acme/payments-cde:environment:prod"]
}
}
}
resource "aws_iam_role" "ci_deploy" {
name = "ci-deploy-payments-cde"
assume_role_policy = data.aws_iam_policy_document.ci_deploy_trust.json
max_session_duration = 3600 # one hour
}Two things earn their keep here. The sub condition uses StringEquals against environment:prod, which means the token only mints for jobs running in a GitHub Environment you can gate with required reviewers and branch restrictions. And max_session_duration caps the credential's life. The worm's nine-second window now yields, at most, a one-hour token scoped to one pipeline's permissions, and only if it can run inside the protected environment, which it can't.
GCP: workload identity federation, conditioned on the repo
Miasma's GCP collector authenticated with a google-api-nodejs-client user agent and enumerated every identity it could reach. Workload Identity Federation removes the service-account JSON key (the exact artifact it was hunting) and replaces it with an attribute-conditioned exchange.
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-actions"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.ref" = "assertion.ref"
}
# Reject the token at the door unless it's the right repo on main.
attribute_condition = "assertion.repository == \"acme/payments-cde\" && assertion.ref == \"refs/heads/main\""
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
# Bind ONLY this repo's principalSet to a least-privilege SA.
resource "google_service_account_iam_member" "ci_impersonation" {
service_account_id = google_service_account.ci_deploy.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/acme/payments-cde"
}No key file lands on the runner. There is nothing for the collector to collect. And the attribute_condition is a hard gate: a token from any other repository never reaches the impersonation step.
Azure: federated credentials, no client secret
Same shape, no service-principal password to harvest:
resource "azuread_application_federated_identity_credential" "github" {
application_id = azuread_application.ci_deploy.id
display_name = "github-payments-cde-prod"
issuer = "https://token.actions.githubusercontent.com"
audiences = ["api://AzureADTokenExchange"]
# Subject pinned to the protected environment, same discipline as AWS.
subject = "repo:acme/payments-cde:environment:prod"
}Across all three clouds the pattern is identical and the discipline is the whole point: one identity per pipeline, trust conditioned to an exact repo + ref/environment, zero stored secret, short TTL. Write it once as a module, instantiate per pipeline, and "standing cloud credential on a build host" stops being a category of risk that exists in your estate.
Mapping the IaC to PCI DSS 4.0.1 (so the QSA agrees with your engineers)
This isn't just good hygiene; it's how you evidence the access-control requirements that PCI 4.0.1 sharpened specifically around application and system accounts. The control language and the Terraform line up cleanly:
| PCI DSS 4.0.1 | What it asks for | How federated workload identity satisfies it |
|---|---|---|
| Req 7 (least privilege, need-to-know) | Access to CDE components limited to least privilege required | One role/SA per pipeline, scoped to that pipeline's deploy actions, not a shared "CI admin" identity with standing CDE reach |
| 8.6.1 | Interactive use of application/system accounts managed and limited | OIDC-federated identities can't be used interactively or off-pipeline; the token only mints for the bound workflow context |
| 8.6.2 | Passwords/passphrases for system accounts not hardcoded in scripts/config/files | There is no secret to hardcode; the static key the requirement is worried about no longer exists |
| 8.6.3 | System-account credentials protected and changed periodically / on compromise, on a risk basis | TTL-bound tokens (one hour or less) rotate every run automatically; rotation becomes a property of the architecture, not a calendar reminder |
Note the framing on 8.6.3. PCI 4.0.1 moved service-account rotation toward a risk-based cadence rather than a fixed clock. Short-lived federated tokens are the strongest possible answer to that targeted-risk-analysis question: you're not rotating on a schedule, you're issuing a fresh, scoped credential per execution and letting it die. That's a far easier story to defend in an assessment than "we rotate the build key every 90 days and store it in Vault," because the assessor's next question is always "and what reaches that key in the meantime?"
When prevention slips: the detection backstop (Req 10/11)
No control is total. Branch protection gets misconfigured, a sub claim gets written with a wildcard, someone leaves one legacy access key live "just for the migration." So instrument for the failure mode these worms actually produce: a federated identity, or a leftover static key, used from a context it should never appear in.
- Alert on any AWS access-key authentication from a principal you migrated to OIDC; its continued use is the signal that a key you thought was dead is being harvested.
- Alert on cloud API calls bearing CI-runner user agents (Miasma's GCP collector announced itself as
google-api-nodejs-client/7.0.0) originating outside your known runner egress IPs. - Alert on role assumptions or workload-identity exchanges from unexpected repositories or refs; your
attribute_conditionshould block these, so an attempt is a high-signal event worth a page.
One detail makes detection non-optional rather than nice-to-have: Miasma "generates a uniquely encrypted payload for each infection," which means hash-based IOCs are useful only for one specific package version. You will not catch the next one by file hash. You catch it by watching identity behave wrong, which is squarely the evidence Req 10 (logging) and Req 11 (detecting and responding) want you to be producing anyway.
The takeaway
The 2026 worms rewrote the threat model in one move: the prize stopped being your code and became your cloud identity. Miasma and Megalodon are not sophisticated; they're opportunistic, and the opportunity you're handing them is a build host carrying credentials that outlive the build by ninety days.
The compliance-as-engineering answer is to delete the prize. Translate Req 7 and Req 8.6 into a Terraform module that issues one short-lived, repo-pinned, environment-gated identity per pipeline and stores no secret anywhere. Then the next credential-harvesting worm runs its collector against your runner, finds a token that expired before it could phone home, and moves on to a softer target.
That's the difference between a PCI program that documents its build-host risk and one that engineered it out of existence. The second one is also a lot less to write up at assessment time.
Arbure is a QSA firm led by practitioners who live in the modern stack. If your CI runners are still holding standing cloud keys, that's a finding waiting to happen, and we'll show you exactly where the trust policy needs to tighten so it isn't.
Sources
- Wiz — Miasma: Supply Chain Attack Targeting Red Hat npm Packages
- Aikido — Red Hat npm Packages Compromised by Credential-Stealing Worm
- CM-Alliance — 5 of the Biggest Supply Chain Attacks of 2026 So Far
- SecurityWeek — Over 5,500 GitHub Repositories Infected in 'Megalodon' Supply Chain Attack
- StepSecurity — Megalodon: Mass GitHub Actions Secret Exfiltration Across 5,500+ Public Repositories
- CISA — Supply Chain Compromises Impact Nx Console and GitHub Repositories (Alert, 2026-05-28)
- PCI DSS v4.0.1 — Requirements 7 and 8.6.1–8.6.3 (application and system accounts)
Christopher Callas
Christopher is the Principal at Arbure Inc., leading strategic and technical initiatives that shape the firm's cybersecurity consulting services. With over a decade of experience, he has built a reputation for delivering tailored security solutions that align with business objectives while addressing modern threats. His expertise spans cloud security, compliance, and risk management, guiding organizations through complex regulatory landscapes and securing multi-cloud environments.