Your deploy box is the target: supply chain defenses after this week's attacks

I read the OpenSourceMalware report on Tuesday and then did something simple. I opened my own CI config and listed every secret a single build job could see. The attack it describes starts with a PR from a brand new account labeled "CI diagnostics" that sends environment variables to a remote URL.

The answer was uncomfortable. A build job on my side holds a registry token, a deploy key and a cloud credential, all in one shell. That is exactly the shopping list attackers wrote down this week.

TL;DR

What happened Date What it means for you
A stolen developer key pushed a dropper into @ethersphere/bee-js 13.3.0, and the publish workflow shipped it to npm and GitHub Nov 9 Your release pipeline will publish whatever a valid key commits
A brand new GitHub account sent CI exfiltration PRs, a prompt injection for reviewers and a Rust crate with a build.rs stealer Nov 10 Build scripts and PR checks run with your secrets in reach
Socket's hardening guide says most 2025 and 2026 CI compromises came through npm install, not workflow bugs Nov 3 Install time is the weak spot, so lock it down first

1. What the two attacks actually did

OX Security reported on November 9 that attackers likely used a stolen developer access key to commit malicious code to the official bee-js and swarm-desktop repositories. The commit triggered the normal publish workflow, which created the npm release and the GitHub release. No one broke into the registry. The pipeline did its job with a bad commit.

The dropper then asked a command server for a payload matched to the victim's operating system. It uploaded the local Swarm node's encrypted key and config, then installed a backdoor. On Linux and macOS the backdoor looks up its server address from an Ethereum contract, so the attacker can move it whenever they like. OX counts about 16,800 monthly downloads of exposure. That is small. The pattern is not.

The second attack, written up by OpenSourceMalware on November 10, came from a GitHub account created about seven hours before the report. It ran four moves in a row. Two PRs tried to send environment variables and signing material out of CI. A third PR added a paragraph to AGENTS.md telling reviewers, including automated ones, not to flag those uploads. Another PR added a Rust crate, lwd-uri-util, whose build.rs gathers .git-credentials, .aws/credentials and .npmrc. Finally, a fake security disclosure sent two maintainers to a cloned sign-in page.

Read that list again if you deploy code. The target is not your app. The target is the box where your app gets built.

2. Why the build environment is the prize

A typical CI job carries a registry push token, a cloud credential and a deploy SSH key. It also runs arbitrary install scripts from hundreds of transitive packages. One malicious postinstall or build.rs inherits all of it.

The OpenSourceMalware report lists what the Rust stealer wants: GitHub, AWS, Cargo, npm, PyPI, Android signing keys and CI secrets. Its advice for anyone who built the affected branches is to revoke and reissue every secret present in that build environment. That is a long afternoon. The cheaper move is to make sure the build never holds most of them.

Here is the rule I now apply. A job that installs dependencies gets no deploy credentials. A job that deploys installs nothing.

Split install from deploy

name: release on: push: tags: ["v*"] permissions: {} jobs: build: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@<full-40-char-sha> with: persist-credentials: false - run: npm ci --ignore-scripts - run: npm run build - uses: actions/upload-artifact@<full-40-char-sha> with: name: dist path: dist/ deploy: needs: build runs-on: ubuntu-latest environment: production # holds the deploy secret, needs approval  steps: - uses: actions/download-artifact@<full-40-char-sha> with: name: dist - run: ./scripts/push-release.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} 

The build job sees no secret at all. The deploy job installs nothing, so a poisoned dependency never runs next to the token. The production environment can require a human approval before the second job starts.

3. Pins: tags move, digests do not

Socket's guide makes a point I keep repeating to people. Version tags are movable pointers. In the tj-actions/changed-files attack, pinning by commit SHA kept every repository safe. In the March 2026 trivy-action incident, Socket reports 75 of 76 version tags were force-pushed.

A SHA proves the content cannot change. It does not prove where the commit came from, because impostor commits from forks can still resolve. Pin, and read the diff when you bump.

Images work the same way. A tag like node:22 is a pointer. A digest is content.

# resolve once, on a machine you trust docker buildx imagetools inspect node:22-slim | grep Digest # Digest: sha256:<64 hex chars> # then pin it in the Dockerfile # FROM node:22-slim@sha256:<64 hex chars> 

Do the same for the images you run, not only the ones you build. In a compose file:

services: web: image: registry.example.com/team/web@sha256:<64 hex chars> 

I also keep to upstream-only namespaces. Pull library/postgres or the vendor's own namespace, not a lookalike from a stranger. Even a real namespace can be hijacked, so digests matter more than names. Names only remove the cheapest attacks.

Add a cooldown

Socket cites a Wiz estimate that a 7 to 14 day cooldown catches 80 to 90 percent of supply chain attacks, and notes Dependabot applies a three day cooldown by default on github.com. Ethersphere reverted the bad npm version only after it shipped. If your lockfile refuses anything published in the last week, you skip most of that window.

# .npmrc ignore-scripts=true min-release-age=7 

Scripts then run only where you allow them. Check your npm version supports the release-age key before you rely on it, since Socket describes it as a setting for .npmrc and your update bot.

4. Cut what the token can do

Socket calls the write-scoped GITHUB_TOKEN the usual escalation step from a foothold to a real incident. Set permissions: {} at workflow level, as in the example above, and grant per job. Avoid pull_request_target with untrusted code. Pass PR titles through env: instead of inlining them in run: blocks. Run zizmor in CI to catch the rest.

Then treat registry tokens the same way. A publish token that only works for one package, with a short life, turns a stolen key into a small problem. The Ethersphere case shows the other side: a long-lived developer key plus an automatic publish workflow equals a release anyone with the key can ship.

5. Egress limits, with honest limits

The last layer is the one most people skip. When the stealer cannot reach its server, it collects secrets and has nowhere to put them. The Rust crate uploads to a public webhook endpoint. The Ethersphere dropper called a fresh .xyz domain registered two days earlier. A deny-by-default outbound list blocks both.

Socket's advice is to run an egress agent in audit mode first, build the allowlist from real traffic, then enforce. Socket also admits the limit: allowlisting cannot stop exfiltration to hosts you must allow, such as github.com. Egress is a way to cut damage, not a wall.

On the server that runs your apps, the same idea applies at runtime. If the web container only talks to your payment API, say so.

6. What we do about this

We built outbound allowlists and image pinning into Levelrail for the deploy side. Per the docs, Levelrail pins an image pulled from a registry to its digest at deploy time, APP_IMAGE_VERIFY_MODE=enforce refuses unsigned or unpinned images on promotion, and a service can declare an egress allowlist of host and port pairs. Two honest limits from the same docs: without the egress block the outbound traffic is open, and signature checks support cosign key pairs, not keyless signatures. Build-time secrets are opt in per secret for that reason.

What I changed this week

I did five things, in order of effort.

  1. Moved deploy tokens out of any job that runs npm ci.
  2. Pinned every action to a full SHA and every base image to a digest.
  3. Turned on a seven day release-age floor and ignore-scripts.
  4. Set permissions: {} as the workflow default.
  5. Put the deploy job behind an approval.

None of it takes more than a day. The attacks this week were small: about 16,800 monthly downloads and one wallet-focused crate. The same techniques against a package with millions of installs would look different.

The Bottom Line

Attackers are skipping your code and going for the machine that builds and ships it. Split install from deploy, pin by digest, add a cooldown, shrink token scopes and limit outbound traffic. How many secrets does your slowest, most neglected CI job hold right now?

Sources checked