---
title: "The malware that hid in a lint config for six months"
url: https://automaark.com/insights/the-malware-that-hid-in-a-lint-config
canonical: https://automaark.com/insights/the-malware-that-hid-in-a-lint-config
description: "A first-hand account of a supply-chain compromise inside a client platform we engineer: a remote-code loader in a build config, a 34KB command-and-control payload appended to a lint file, and a commit-forgery tool. How it got past code review, GitHub search and our own scanner, what we found when we finally looked properly, and what we changed for good."
organization: Automaark
legal_name: Nexus Automaark Systems Ltd
language: en
llms: https://automaark.com/llms.txt
author: Olamide Dada
author_url: https://automaark.com/authors/olamide-dada
date_published: 2026-09-28
date_modified: 2026-09-28
tags: Cybersecurity, Supply chain, Incident response, Engineering practice
related_services: https://automaark.com/services/cybersecurity, https://automaark.com/services/cloud-infrastructure, https://automaark.com/services/software-platform-engineering
---

# The malware that hid in a lint config for six months

A first-hand account of a supply-chain compromise inside a client platform we engineer: a remote-code loader in a build config, a 34KB command-and-control payload appended to a lint file, and a commit-forgery tool. How it got past code review, GitHub search and our own scanner, what we found when we finally looked properly, and what we changed for good.

## The short version

- Two malware families sat in four repositories, one of which production built from. Both lived in configuration files, not application code, and one was appended to an existing line so the diff read +1 −1.
- Three layers of review missed it for the same reason: a 34,000-character line makes GitHub collapse the diff as 'generated', code search does not index the file, and our scanner's scheduled run had failed upstream and silently skipped.
- 'I searched and found nothing' is not evidence against this technique. File size against a baseline and maximum line length are. A 30-line shell script catches both families in seconds.
- An earlier cleanup was real and done well; its gap was scope. It cleaned the code people were working in and left the repositories they had stopped working in, one of which was still the production source.
- The permanent fixes are boring and non-negotiable: signed commits, protected main, a second reviewer on config files, a pre-commit gate, and handing work to clients as a re-originated tree with no inherited history.

We found malware in four repositories of a platform we engineer. It had been there since March. One of the four was the repository production built from.

This is not a hypothetical and it is not a war story about someone else. It happened inside work we are responsible for, it got past our review and our tooling, and an earlier cleanup we ran had left it in place. We are writing it up because the technique is simple, it will be used against other teams, and almost none of the advice we could find covers the part that actually failed.

Names, hosts and identifiers are withheld. The mechanics are exact.

## What we found

There were two malware families and one tool.

**A remote-code loader in a build config.** About ten lines, wrapped in an async function, dropped into a Vite config in one project and a Next.js config in another. It base64-decodes a URL hidden in an environment variable that is named like an API key, fetches whatever that URL serves, and passes the response to `eval`. Failures print "Auth Error", so a missing variable looks like a configuration mistake. `node-fetch` had been added to `package.json` in both projects so the import would resolve. That detail is what told us it was deliberate.

Two things about it matter. It runs at build time, so it runs on developer laptops, not only on servers. And the payload is whatever the remote host serves at the moment of the build. Whoever controls that host can change what runs without touching the repository again.

**A 34KB obfuscated command-and-control payload in a lint config.** Appended to `eslint.config.js` on the same physical line as the file's closing bracket, after a run of tab characters. It imports `http`, `https`, `zlib` and `URL`, uses a blockchain-based dead-drop for its instructions, XOR-decrypts its body and spawns a detached Node process. We did not de-obfuscate it. That happens in a sealed environment, by a specialist, and it is still on the open list.

**A commit-forgery script.** Not malware in the sense of calling out anywhere. It reads the previous commit's author, email and timestamp, sets the machine clock back to match, rewrites the git identity, amends the commit with hooks disabled, restores the clock and force-pushes. It attacks the record of who did what. It was never committed, but its filename appears in `.gitignore` in six repositories, which means it has sat in a working tree on at least one machine.

## Why nobody caught it

One property defeated code review, GitHub search and our scanner at the same time: the payload was appended to an existing line rather than added as new lines.

**Code review.** The diff read `+1 −1`. The line was roughly 34,000 characters, which makes GitHub classify the file as generated and collapse the diff behind a "Load diff" placeholder. The reviewer saw three files changed, a plausible one-line TypeScript fix, and a collapsed config file. They approved a correct fix. The correct fix was the cover.

**Code search.** GitHub does not index files like this. We proved it three ways: the payload's own marker string returned zero results despite being on the default branch; a lint rule present in both the clean and infected copies of the same file returned only the clean one; and an organisation-wide search for `atob` returned ten files and omitted one we then opened and read with `atob` on line 43.

**Our own scanner.** The rule was right. It never ran. The scheduled workflow had failed at an upstream step two weeks earlier and skipped both scanning jobs, and nobody triaged a failed security run because failed runs were normal.

The lesson is uncomfortable and it applies to us first: stop trusting search. "I searched and found nothing" is not a result against this technique. Two things work. File size against a known-good baseline: a lint config that should be 4KB and is 38KB has a problem. And maximum line length: no honest source or configuration file has a 2,000-character line.

## What the earlier cleanup got right, and the one thing it missed

Months before this, the team had already found and removed malicious code in this codebase. That remediation was real and it was done properly. We can prove it: the same fix commit exists in two versions with the same author, subject and timestamp to the second. The honest version is 890 bytes. The infected version is 36KB. Somebody rebuilt that commit correctly, and the clean repository it produced is clean on every branch, including its history.

What it did not do was retire the repositories the malware was still sitting in. The cleanup happened in the code people were working in. Three repositories nobody was actively touching were left as they were, and one of them was still what production built from. The infected config in one of them has two commits in its entire history, the last in March. No cleanup ever reached it because no commit of any kind did.

This is the finding we most want other teams to take away: **cleaning the code you are working in does not clean the repositories you have stopped working in.** Dormant repositories are where this survives, and "archive the originals" has to be a named step in the sequence, not an afterthought.

## How it got in

Three insertion events over four months, all inside genuine feature commits, all during ordinary working hours.

The most telling pair is seven minutes and twenty-six seconds apart on the same branch under the same author identity. The first commit removes the forgery tool's filenames from `.gitignore` and carries a valid cryptographic signature. The second puts them back, makes one real one-line fix as cover, appends the 34KB payload, and is unsigned. A developer does not lose the ability to sign between two commits seven minutes apart on the same machine.

Two readings fit the evidence. A compromised workstation or toolchain: a malicious install hook, editor extension or local automation rewriting config files while someone works, so the change rides into a real commit unnoticed. Or deliberate insertion under forged identities, which the forgery tool makes possible. We have not chosen between them, and the commit metadata cannot decide it, because that tool exists precisely to make metadata lie.

Which is why the internal record for this incident says, in its first paragraph, that nobody is in trouble for finding something. We are looking for a machine, not a culprit. The only thing anyone would be in trouble for is quietly deleting evidence.

## The remediation, in order

Nine phases, each ending in a gate that has to pass before the next begins. The rules that govern all of it: preserve before you change; archive, never delete; a revert removes nothing because the object stays reachable by hash; do not trust search; never build inside an affected repository.

1. **Preserve.** Mirror every repository, hash the archives, store them off the platform and off developer machines with a custody note. `git clone --mirror` copies objects and executes nothing, which is why it is safe and why nothing may be built afterwards.
2. **Contain.** Cut every deploy path from code we do not control. Turn off auto-deploy on services sourced from affected repositories. Freeze the four repositories. Revoke and re-issue every token, key and app on both organisations. Export the audit logs before retention rolls.
3. **Credentials.** Rotate on assumed exposure, highest blast radius first: payments and banking, then identity and sessions, then data and infrastructure, then everything with "key" in its name. Invalidate at the provider, not just replace.
4. **Workstations.** Every engineer runs the same four checks and reports, including a clean result. A clean machine and a silent one look identical from outside, so "nothing found" is mandatory.
5. **Eradicate.** Archive and rename the non-production infected repositories. Rename matters: one infected repository sat one character away from its clean replacement, and autocomplete would have cloned the wrong one within a week.
6. **Rebuild clean.** Re-originate: a verified tree with no ancestry. Sever the git history, run the gate on the bare files, one signed initial commit into a fresh repository. A push transfers every object ever committed, which is why earlier "clean copies" were not clean.
7. **Production cutover.** Stand up the clean source in a non-production environment first, verify it builds and serves, then repoint production, soak, and only then archive the old source. Archiving production's source before a replacement exists removes the ability to redeploy in an emergency.
8. **Harden.** The permanent controls below.
9. **Keep it closed.** Weekly automated size and line-length sweeps. Failed security workflows get triaged. Monthly re-audit of tokens, deploy keys and deploy sources.

## The gate

The single most useful artefact from this incident is a short shell script that runs against a bare working tree before any commit exists, against any local clone, and as a pre-commit hook. It checks five things:

1. Any source or config file with a line over 2,000 characters.
2. A base64 decode and an evaluator (`eval`, `new Function`, `vm.run`) in the same config file.
3. A network fetch imported inside a build config.
4. Install hooks in `package.json`, flagged for review.
5. Known toolkit filenames, on disk or in `.gitignore`.

It runs in seconds, it has no dependencies, and several people running it independently against fresh clones gave better coverage than any remote scan. We will publish it once the incident is fully closed.

## What changes permanently

| Control | Why this one |
|---|---|
| Required signed commits on both organisations | The unsigned June commit could not have landed. It also ends the forgery script, which cannot produce a valid signature for someone else. |
| Protected main: PR required, review required, no force-push | If a human can push to main, nothing else matters. |
| A collapsed or "generated" diff blocks the merge until a reviewer opens it | The one rule that catches this exact technique at review time. |
| Second reviewer on config changes | Config files are where both families hid. |
| The gate as a pre-commit hook | By the time CI runs, the objects are already on the platform. |
| A failed security step fails the workflow | The scanner run that would have caught this failed upstream and skipped silently. |
| "Wait for CI" on every deploy | Off everywhere before this; a push deployed before any check ran. |
| Secret scanning, push protection, 2FA, read-only base permission | None were on. |

Most of that table requires a paid GitHub plan. The organisations were on the free tier. That upgrade is the prerequisite, and it is cheaper than one afternoon of this.

## How we hand work to clients now

Export the working tree. Sever the history. Run the gate on the bare files before a single commit exists. One signed initial commit into a fresh, empty repository in the client's organisation. Send a verification report: every config file's size, the tree-wide maximum line length, the gate result, the commit signature status. The client reproduces all four in minutes.

Never a push or a fork from anything upstream. That mechanism is how the client received two infected copies in the first place. Removing it beats detecting it.

## The honest position

We are an engineering firm that sells architecture and operations discipline, and this got past us. The reasons are specific and fixable, and we have fixed them, but we would rather you learn them from this page than from your own incident. If your build or lint configs have not been measured against a baseline recently, measure them today. It takes seconds, and search will not tell you.

## Questions this raises

### Was client data exposed?

We treated every credential as exposed and rotated all of them in order of blast radius: payments and banking first, then identity and sessions, then data and infrastructure, then everything else. The loader is inert without its environment variable, which was absent from production, and the second payload has not been de-obfuscated outside a sealed environment. We do not claim to know what was or was not contacted, which is why egress monitoring is now on the permanent list.

### How do you hand code to a client now?

Export the working tree, sever the git history, run the gate on the bare files, make one signed initial commit into a fresh empty repository, and send a verification report the client can reproduce in minutes: every config file's size, the tree-wide maximum line length, the gate result and the commit signature status. Never a push or a fork from anything upstream.

### Can Automaark audit our repositories for this?

Yes. The audit is read-only: nothing is cloned into a build, nothing is executed. It checks every build and lint configuration on every branch against baseline size and line length, checks for decode-plus-evaluate patterns in config files, reviews install hooks and deploy sources, and produces a written report with a remediation sequence.

## Sources

- [GitHub Docs: Why some diffs are collapsed or marked generated](https://docs.github.com/en/repositories/working-with-files/managing-files/customizing-how-changed-files-appear-on-github)
- [OpenSSF: Best practices for securing the software supply chain](https://openssf.org/)

---
Written by Olamide Dada, Founder & CEO, Automaark. More: https://automaark.com/insights
