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.
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
A Git login is stolen
Usually from a developer machine that ran an earlier infected build
A forged commit is pushed
Same author, message and date as a real commit, sometimes force-pushed over it
The loader hides in plain sight
Hundreds of spaces push obfuscated code off-screen in a config file or a new
initfileBuild scripts are rewired
node init && next build,node init && vite buildAuto-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 restartInstructions come from a blockchain
No domain or server to block; the attacker can change the payload at any time
Credentials and secrets are taken
Git logins, editor tokens,
.envfiles, 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 started | Did the loader run? | What to assume |
|---|---|---|
Dockerfile runs npm run build, serves static files or next start | Yes, during the build | Build-time secrets and anything passed to the build are exposed |
Container starts with npm run start or npm run dev | Yes, on every start | Runtime secrets are exposed; treat it as live in production |
| PHP or other backend, no npm step in the image | No, but the code is in the image | Lower risk; rebuild anyway so the next build is not infected |
| Deployed before the forged commit, history since rewritten | Unknown | Treat 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:
# 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 -40For 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:
- 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.
- 2Stop anything running the loader live. If a container starts with an npm script, stop it or rebuild it first, before anything else.
- 3Fix every infected branch with a normal commit. Delete the
initfiles, remove the padded payload and the injectedcreateRequirelines from config files, restore thepackage.jsonscripts and put the.envrules 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. - 4Rescan every branch and confirm nothing matches before you deploy anything.
- 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.
- 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.
- 7Check the build server itself for processes, scheduled tasks and keys you do not recognise, since that is where the loader ran.
- 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 namedinitat a project root and.gitignorechanges that remove.envrules. 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.