Skip to content
Security10 min read·

Build-time malware hidden in your Git repo: how the whitespace loader works, and how to clean it up

A stolen Git login, a forged commit that copies a real one, a loader pushed off-screen by hundreds of spaces, and a build script that runs it before your framework starts. Your deployment pipeline then runs it for the attacker. How this campaign works, how to find it in every branch, and the order to clean it up in.

SecuritySupply chainGitCI/CDIncident responseNode.js

I recently spent a day cleaning up an infection that had spread across close to twenty repositories and the applications deployed from them. Nothing about it was exotic. No zero-day, no compromised package on npm. Someone had a developer's GitHub login, pushed commits that looked exactly like that developer's own work, and let the build pipeline do the rest. Security researchers have been tracking the same pattern across many developers in recent weeks, and the reports name it PolinRider, with the command channel described as EtherHiding.

This post explains how it works at the level you need to defend against it, how to find it everywhere it can hide, and the order I would clean it up in again. Nothing here is specific to one framework: I saw it in Next.js, Vite, Laravel front-ends and Astro projects alike.

How the attack works

From one stolen login to code running in production
  1. A Git login is stolen

    Usually from a developer machine that ran an earlier infected build

  2. A forged commit is pushed

    Same author, message and date as a real commit, sometimes force-pushed over it

  3. The loader hides in plain sight

    Hundreds of spaces push obfuscated code off-screen in a config file or a new init file

  4. Build scripts are rewired

    node init && next build, node init && vite build

  5. Auto-deploy builds it

    The pipeline runs the loader on the build server within minutes

    ↳ Start command is npm run start: it also runs in production on every restart

  6. Instructions come from a blockchain

    No domain or server to block; the attacker can change the payload at any time

  7. Credentials and secrets are taken

    Git logins, editor tokens, .env files, then the next repository is infected

Every step uses something the team already trusts: a known author, a normal-looking commit and the team's own build command.

1. The forged commit

The attacker does not invent a commit message. They copy one. In the cases I saw, the malicious commit had the same author, the same message and the same author date as a genuine commit made minutes earlier, and the genuine one had been replaced. Anyone scrolling the history sees nothing unusual. The tells are small: the committer timezone differs from the author's usual one, and the file list is longer than the real change needed. A content commit that touches package.json, .gitignore and a build config is worth a second look.

2. The hidden loader

The payload is a few kilobytes of obfuscated JavaScript placed at the end of a line that already exists, after several hundred spaces or tabs. Open the file in an editor without word wrap and the line ends at export default config;. Review the diff in a browser and the change looks like trailing whitespace. Some variants also add a new file called init with no extension, which starts with thirty lines of harmless-looking setup code before the padding begins. Config files that normally need nothing gain an import of createRequire so the loader can use Node's require from an ES module.

3. The trigger

The attacker changes the dev, build and start scripts in package.json to run node init before the real command. They also plant a copy in a config file that the build loads anyway, such as vite.config.js, postcss.config.mjs, tailwind.config.js or eslint.config.mjs. That way, even if someone removes the init file, the next build that loads the config runs the loader. This is why ignoring install scripts does not help: nothing here is an install script.

4. The command channel and the payload

Instead of calling a server the attacker owns, the loader reads its next instructions from data stored on a public blockchain. Public analyses describe the payload as a background process that collects Git and GitHub credentials, editor sign-in data and other secrets, then uses them to infect more repositories. Because the instructions live on a blockchain, there is no domain to block and nothing for a host to take down, and the attacker can change what the loader does at any time.

5. Weakening the next commit

The same forged commit removes the .env lines from .gitignore. Nothing leaks immediately, but the next routine git add . from a developer could commit database and mail passwords. In my case no secrets had been committed yet, but that was luck, not design.

Why your deployment pipeline makes it worse

Most self-hosted platforms and CI services deploy on push by default. That is convenient, and in this attack it is the delivery mechanism. When the forged commits landed, every application set to auto-deploy rebuilt from them within the same few minutes. Wherever the Dockerfile ran npm run build, the loader executed on the build server with access to whatever the build could see, which usually includes the application's environment variables. Two applications started their containers with npm run start, so the loader ran in production on every container start until they were rebuilt.

How the app is built and startedDid the loader run?What to assume
Dockerfile runs npm run build, serves static files or next startYes, during the buildBuild-time secrets and anything passed to the build are exposed
Container starts with npm run start or npm run devYes, on every startRuntime secrets are exposed; treat it as live in production
PHP or other backend, no npm step in the imageNo, but the code is in the imageLower risk; rebuild anyway so the next build is not infected
Deployed before the forged commit, history since rewrittenUnknownTreat as suspect and rebuild from a known clean commit

How to find it: every branch, every deployment

The mistake to avoid is checking only the branch you have checked out. The forged commits went to main, to production branches, to staging branches and to feature branches nobody had touched in months. A stale remote-tracking branch on a laptop can still hold the infected version after the branch has been deleted on GitHub. These checks are read-only and safe to run in any repository:

bash
# Never run npm in a repository you suspect. These commands only read files.

# 1. Code hidden after 100+ spaces or tabs, in build config and loader files
grep -rnE '[[:blank:]]{100,}[^[:blank:]]' . \
  --include='*.config.js' --include='*.config.mjs' --include='*.config.ts' \
  --include='*.config.cjs' --include='init' --exclude-dir=node_modules

# 2. Build scripts that run something before the real command
grep -nE '"(dev|build|start)"[^,]*node [^"]*&&' package.json

# 3. The .gitignore marker
grep -n '^config\.bat$' .gitignore

# 4. The same checks on every branch, including ones you never check out
git fetch --all --prune
git grep -lE '[[:blank:]]{100,}[^[:blank:]]|node init &&' \
  $(git for-each-ref --format='%(refname)' refs/remotes) \
  -- '*.config.*' 'init' 'package.json'

# 5. Forged commits: look for repeated messages and odd committer timezones
git log --all --format='%h %ad | %cd | %an | %s' --date=iso -40

For an estate with many repositories, script the same checks against the GitHub API so you can cover every branch of every repository without cloning anything. Then do the same for deployments: for each application, take the commit it is actually running, not the one its branch points to now, and scan that. A branch can be clean while the container serving traffic was built from the infected commit three days ago.

How to clean it up, in order

Order matters more than speed. If you clean the code while the attacker still holds a working login, they push it back. If you rebuild before cleaning every branch, auto-deploy can pick up an infected branch again. This is the sequence I followed:

  1. 1Cut off access. Change the Git account password, turn on two-factor authentication, revoke every personal access token and SSH key, and revoke the OAuth apps that hold a login, including the credential manager on each machine. A password change alone does not cancel existing tokens.
  2. 2Stop anything running the loader live. If a container starts with an npm script, stop it or rebuild it first, before anything else.
  3. 3Fix every infected branch with a normal commit. Delete the init files, remove the padded payload and the injected createRequire lines from config files, restore the package.json scripts and put the .env rules back in .gitignore. Keep the legitimate changes the forged commit copied, and do not force-push. The cleaner in build-malware-hunter does exactly this, one normal commit per branch, after a dry run.
  4. 4Rescan every branch and confirm nothing matches before you deploy anything.
  5. 5Rebuild every affected deployment from the clean commits, with a forced rebuild so no cached layer survives, and confirm each one is running the expected commit.
  6. 6Rotate every secret that a build or a running container could read: database passwords, application keys, mail, payment and API keys. Removing the code does not take back what was already read.
  7. 7Check the build server itself for processes, scheduled tasks and keys you do not recognise, since that is where the loader ran.
  8. 8Sign every machine back in with fresh credentials, and have anyone with an old clone pull the cleaned branches or re-clone before building.
Removing the code stops the next build from running it. It does not take back anything the last build already sent.

How to make the next one fail

  • Two-factor authentication on every account with write access, and fine-grained tokens with short expiry instead of long-lived classic ones.
  • Branch protection on main and production branches: no force-pushes, pull requests required. A forged commit then has to survive a review instead of landing directly.
  • No auto-deploy to production from a push. Deploy from a reviewed merge or a manual approval. Keep auto-deploy for preview environments if you like it.
  • Give builds only the secrets they need. Most front-end builds need a handful of public variables, not the database password.
  • A pre-commit or CI check that rejects long whitespace runs followed by code, node init && in scripts, a file named init at a project root and .gitignore changes that remove .env rules. It takes ten minutes to write and would have stopped every commit in this campaign.
  • Turn on word wrap and whitespace rendering in your editor and diff viewer, so trailing padding is visible.
  • A weekly access review: security log, active sessions, authorised apps, tokens and deploy keys.

None of this needs a security team. It needs the defaults that make a stolen login less useful, and a pipeline that does not turn every push into code running on your servers. If you want a second pair of eyes on a repository, a pipeline or a cleanup like this one, get in touch. I can check what is exposed and give you a fixed-scope plan to close it.

Questions people ask

How do I know if my repository has the whitespace loader?

Search every branch, not just the one you have checked out, for lines with a hundred or more spaces or tabs followed by code, for a file called init with no extension at the root of a project, for node init && in package.json scripts and for a config.bat line in .gitignore. Any one of those in a config or build file is a strong signal.

Does `npm ci --ignore-scripts` protect against this?

No. The loader does not use an install script in a dependency. It sits in your own package.json scripts and config files, so it runs when you run npm run dev, npm run build or npm run start, which are commands every developer and every pipeline runs anyway.

Is deleting the malicious code enough?

No. Removing it stops the next build from running it, but it does not undo anything it already took. You also have to revoke the Git login that pushed it, rebuild every deployment from clean commits, and rotate every secret that was available to a build or to the running app.

Should I rewrite Git history to remove the forged commits?

Usually not as a first step. A normal commit that removes the injection fixes every branch without breaking anyone's clone, and the forged commit becomes harmless history. Rewrite history later, if at all, once access is secured and every developer can re-clone.

Building something like this?

I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading