Saikat
← Back to blog

Config Malware in ESLint & PostCSS: How It Spreads, How We Scanned It, and How to Stop Reinfection

August 26, 2026·18 min read
SecurityMalwareGitHubSupply ChainCI/CDNode.js
Share:
Config Malware in ESLint & PostCSS: How It Spreads, How We Scanned It, and How to Stop Reinfection

Colleagues on Ubuntu and macOS reported that after cloning or pulling some GitHub repositories, their machines looked "reset" or corrupted. The instinctive blame lands on Git. That instinct is wrong — and dangerous, because it points you at the wrong layer while the real payload keeps running.

This post is the full picture from a real multi-repo investigation: how the malware works, what we found (without naming any client or organization), what was ruled out, the cleanup workflow, and the permanent controls that make this family of config malware very hard to land in main again. If you remember one line, make it this:

No npm install / lint / dev on a repo until config malware scan is clean, and no merge to main unless the Security Config Scan check is green.

This is a sibling story to DPRK malware hidden in tailwind.config.js — same class of attack (executable config files), different campaign and C2 design.

Table of contents

  1. Executive verdict
  2. How the attack works
  3. Indicators of compromise (IOCs)
  4. What a real scan looks like
  5. What was ruled out
  6. Why Ubuntu / Mac look "reset"
  7. Incident response workflow
  8. Permanent defense layers
  9. CI malware signature gate
  10. Template and org hygiene
  11. Access, secrets, and npm hardening
  12. Team playbook
  13. Rollout plan
  14. What this permanently solves
  15. Quick checklist

Executive verdict

Question Answer
Do git clone / git pull alone reset the OS? No
Is there malware in some repositories? Yes
How does it run? When Node tooling loads infected config files (eslint / postcss / etc.) after clone — e.g. IDE lint, npm install, npm run dev, npm run build
Can the payload reset / wipe a machine? Yes — second-stage remote code can run arbitrary OS commands
Is this limited to one repo? No — worm-like infection across many projects and local mirrors

Root cause: Obfuscated JavaScript malware appended to legitimate frontend/backend config files. It resolves a command-and-control (C2) address via Ethereum, downloads an encrypted second-stage payload, and spawns a detached Node process. That second stage can steal secrets and run destructive OS commands.

Strategy going forward: never let malicious config blobs reach main, and never trust old local clones after an incident.

There is no 100% permanent shield against every future attack. You can permanently block this malware family and most lookalikes with automation plus process.


How the attack works

Colleague clones / pulls repo
        │
        ▼
Runs npm/pnpm install  OR  opens project in IDE  OR  runs lint/dev/build
        │
        ▼
Node loads eslint.config.* / postcss.config.* / prettier.config.* / tailwind.config.*
        │
        ▼
Hidden malware (appended after real config) executes
        │
        ├─► Resolves C2 IP via Ethereum contract / tx data
        ├─► Fetches encrypted payload over HTTP
        ├─► spawn(detached Node process) → eval remote code
        └─► Optional worm behavior: temp_auto_push.bat / temp_interactive_push.bat
                + entries added to .gitignore

Important clarification

  • git clone / git pull only download files. They do not execute repository JavaScript by themselves.
  • Infection starts when Node executes those config files.
  • Almost every developer workflow after clone triggers this (install, lint, Next.js PostCSS, ESLint in VS Code/Cursor).

That is why "my machine reset after I pulled" correlates with Git activity without Git being the executor.


Indicators of compromise (IOCs)

Campaign / payload markers

Indicator Value / pattern
Campaign ID A8-974-3
Ethereum address (C2 resolver) 0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a
Runtime globals global.i=..., global._V, global._H, global._H2
Payload header x-payload-b64
Stage URLs /0x/cls, /0x/ls
Process spawn child_process.spawn("node", ["-e", ...], { detached: true })
Packed footer (common variant) Tgw(2509);return 1358})(); / jFD(LQI,pYd
Alternate packed footer Ejd(9255);return 4658})();

Worm / propagation markers

Files or .gitignore entries:

  • temp_auto_push.bat
  • temp_interactive_push.bat

In one workspace scan, 132 .gitignore files mentioned these worm markers.

Infected file types

Malware is typically appended after the legitimate export, often as one very long line (padding + obfuscated IIFE):

  • eslint.config.mjs / eslint.config.js
  • postcss.config.mjs / postcss.config.js
  • prettier.config.mjs
  • tailwind.config.js (less common)

Detection heuristic

A config file is highly suspicious if:

  1. It contains global.i= or A8-974-3 or /0x/cls or Tgw(2509);return 1358, or
  2. Any single line is > ~2000 characters after a normal export default / config close, ending with })();

Quick detection commands

Run from a repo root (or workspace) to list likely infected configs:

rg -l --hidden -g '!**/node_modules/**' -g '!**/.git/**' \
  -e 'global\.i=' \
  -e 'A8-974-3' \
  -e 'Tgw\(2509\);return 1358' \
  -e 'jFD\(LQI,pYd' \
  -e '/0x/cls' \
  -e 'temp_auto_push\.bat' \
  .

Find configs with extremely long lines:

find . -type f \( \
  -name 'eslint.config.*' -o -name 'postcss.config.*' \
  -o -name 'prettier.config.*' -o -name 'tailwind.config.*' \
\) ! -path '*/node_modules/*' -print0 \
| xargs -0 awk 'length>2000{print FILENAME ":" NR " len=" length; nextfile}'

What a real scan looks like

A combined GitHub + local-mirror scan (API sampling, commit history including pre-purge recovery, and local workspace mirrors) produced numbers like this:

Metric Approximate result
Infected config files (local mirrors) ~70+
Approximate infected project folders ~70
.gitignore worm markers ~130+
Live remotes still infected at scan time Multiple orgs / backup orgs
Remotes already purged Some production repos cleaned days earlier

Patterns that keep repeating

  1. Remote cleaned, local still infected. Pulling is not enough if the working tree already ran malware. Delete the clone; re-clone after the clean push.
  2. Git history still contains the malware. Anyone checking out an old SHA can get reinfected. Document "do not use commits before purge date," or rewrite history where policy allows.
  3. Packed variants dodge naive searches. Searching only for global.i= misses the packed footer family (Tgw(2509)...). Scan for both.
  4. Shared starters amplify blast radius. One infected Nest/Next template can seed dozens of client repos.

Scope limitations (be honest)

Covered Not fully covered
Sampled personal + org remotes via API Exhaustive history of every personal repo
Targeted live remote checks Every packed-variant remote (API rate limits)
Local workspace mirrors Colleague machines (must be checked on their side)
This config-file malware family Arbitrary npm dependency / lockfile supply-chain malware

GitHub Code Search often hits rate limits mid-scan. Pair remote checks with local mirrors and signature scanners.


What was ruled out

Scanned and not found as an OS-wipe vector in the investigated corpus:

Check Result
rm -rf /, rm -rf ~, mkfs, diskutil erase, dd if=/dev/zero in app code No OS-wipe scripts (only normal Docker/apt cleanup / SSH key cleanup in CI)
Dangerous package.json postinstall that wipes disks Not found
Husky post-checkout / post-merge OS wipe hooks Not found (hooks were lint/format / CI helpers only)
Random curl | bash installers for unrelated CLIs Legitimate, scoped install/uninstall of their own binaries

CI deploy scripts that remove ~/.ssh/deploy_key* after deploy are scoped — not a full OS reset.


Why Ubuntu / Mac look "reset"

The malware’s second stage is remote-controlled. Depending on what the C2 sends, victims may see:

  • User profile / shell config corruption
  • Deleted home directories or project files
  • Stolen then revoked credentials causing lockouts
  • Forced logout / broken login sessions
  • Unexpected reboots or recovery-mode behavior after destructive commands

So "my Ubuntu/Mac reset after pull" is consistent with post-clone npm/IDE execution of infected configs, not with Git itself wiping the OS.


Incident response workflow

┌─────────────────────────────────────────────────────────────────┐
│  A. STOP THE BLEEDING                                           │
│  Notify · freeze npm/IDE on old clones · rotate secrets         │
│  Purge live infected remotes · re-scan packed + clear-text IOCs │
└────────────────────────────┬────────────────────────────────────┘
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  B. CLEAN REPOSITORY CONTENTS                                   │
│  Restore short known-good configs · remove worm .bat / ignore   │
│  Push security commit · tell team to DELETE clones + re-clone   │
└────────────────────────────┬────────────────────────────────────┘
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  C. MACHINE RECOVERY                                            │
│  Assume credential theft · rotate from a CLEAN device           │
│  Prefer OS reinstall if destructive behavior observed           │
│  Audit GitHub / cloud / browser sessions                        │
└────────────────────────────┬────────────────────────────────────┘
                             ▼
┌─────────────────────────────────────────────────────────────────┐
│  D. PREVENT REINFECTION                                         │
│  CI signature gate · pre-commit · branch protection             │
│  Golden templates · least privilege · IOC updates               │
└─────────────────────────────────────────────────────────────────┘

A. Stop the bleeding (day zero)

  1. Notify everyone who cloned/pulled affected repos:
    • Do not run npm / pnpm / yarn / IDE lint on old clones
    • Treat those machines as compromised
  2. Rotate secrets for everyone who touched infected repos:
    • GitHub PATs / SSH keys / deploy keys
    • npm tokens
    • Cloud keys (AWS, GCP, Cloudflare, etc.)
    • Database passwords, .env secrets, Docker Hub
    • Any VPS SSH keys used from those machines
  3. Purge live GitHub infections first — prioritize any public client-facing remotes still matching IOCs.
  4. Re-scan remotes for packed signature Tgw(2509);return 1358 and global.i=.

B. Clean repository contents

For each infected repo:

  1. Restore config files to a known-good short version (only legitimate ESLint/PostCSS/etc.).
  2. Remove temp_auto_push.bat / temp_interactive_push.bat if present.
  3. Remove those entries from .gitignore (optional cleanup).
  4. Commit + push a clear message, e.g. security: remove malicious obfuscated code from config files.
  5. Tell teammates to delete local clones and re-clone after the fix — do not just git pull over a working tree that already ran malware.

C. Machine recovery

If a machine already ran install/dev/lint on an infected repo:

  1. Assume credential theft.
  2. Rotate all secrets from a clean device.
  3. Prefer OS reinstall / clean restore if destructive behavior was observed.
  4. Audit browser sessions, GitHub sessions, and cloud consoles for unknown access.

D. Prevent reinfection

Covered in the next sections: CI gate, hooks, branch protection, templates, access review.

Prevention on top of still-infected remotes is useless. Finish cleanup first.


Permanent defense layers

Use all of them:

Developer machine          GitHub repo                 Team process
─────────────────          ───────────                 ────────────
Pre-commit scanner  ──►  CI security workflow  ──►  Branch protection
Secret hygiene           Required status checks     Code review on configs
Clean templates          CODEOWNERS (optional)      Least-privilege access
Layer Purpose Priority
1. Clean + purge infected repos Remove active malware P0 — do first
2. CI config malware scanner Auto-fail bad PRs/pushes P0
3. Pre-commit hook Stop bad commits locally P1
4. Branch protection Force CI + review on main P1
5. Fix shared templates/starters Stop copying infection into new repos P0
6. Secrets rotation + access control Limit blast radius P0
7. Dependabot / lockfile discipline Reduce npm supply-chain risk P2
8. Team playbook Same response every time P1

CI malware signature gate

Add a workflow that fails the job if any of these appear in config files:

  • global.i=
  • A8-974-3
  • Tgw(2509)
  • jFD(LQI,pYd
  • /0x/cls or /0x/ls
  • temp_auto_push.bat / temp_interactive_push.bat
  • Any line longer than 2000 characters in:
    • eslint.config.*
    • postcss.config.*
    • prettier.config.*
    • tailwind.config.*

Suggested files to add to every app repo / template:

scripts/check-malware-configs.mjs
.github/workflows/security-config-scan.yml

Workflow should run on:

  • pull_request
  • push to main / master
  • optionally workflow_dispatch for manual scans

Rule: main must not merge if this check is red.

Scanner script

Save as scripts/check-malware-configs.mjs:

#!/usr/bin/env node
/**
 * Fails if known config-malware signatures or suspicious long lines are found.
 * Exit 0 = clean, Exit 1 = infected/suspicious.
 */
import fs from "node:fs";
import path from "node:path";

const ROOT = process.cwd();
const MAX_LINE = 2000;
const TARGET_NAMES = [
  /^eslint\.config\./i,
  /^postcss\.config\./i,
  /^prettier\.config\./i,
  /^tailwind\.config\./i,
  /^\.eslintrc/i,
];
const SIGNATURES = [
  /global\.i\s*=/,
  /A8-974-3/,
  /Tgw\(2509\);return 1358/,
  /jFD\(LQI,pYd/,
  /Ejd\(9255\);return 4658/,
  /\/0x\/cls/,
  /\/0x\/ls/,
  /temp_auto_push\.bat/,
  /temp_interactive_push\.bat/,
  /0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a/i,
  /x-payload-b64/i,
];

const SKIP_DIRS = new Set([
  "node_modules",
  ".git",
  "dist",
  "build",
  ".next",
  "coverage",
  "vendor",
]);

function walk(dir, out = []) {
  for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
    if (SKIP_DIRS.has(ent.name)) continue;
    const p = path.join(dir, ent.name);
    if (ent.isDirectory()) walk(p, out);
    else out.push(p);
  }
  return out;
}

function isTarget(file) {
  const base = path.basename(file);
  return TARGET_NAMES.some((re) => re.test(base));
}

const findings = [];
for (const file of walk(ROOT)) {
  if (!isTarget(file) && path.basename(file) !== ".gitignore") continue;
  let text;
  try {
    text = fs.readFileSync(file, "utf8");
  } catch {
    continue;
  }
  for (const re of SIGNATURES) {
    if (re.test(text)) {
      findings.push(`${file}: matched ${re}`);
    }
  }
  if (isTarget(file)) {
    const lines = text.split(/\r?\n/);
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].length > MAX_LINE) {
        findings.push(`${file}:${i + 1}: line length ${lines[i].length} > ${MAX_LINE}`);
      }
    }
  }
}

if (findings.length) {
  console.error("SECURITY: suspicious config malware indicators found:\n");
  for (const f of findings) console.error(" -", f);
  process.exit(1);
}

console.log("SECURITY: config malware scan passed.");

Run locally:

node scripts/check-malware-configs.mjs

GitHub Actions workflow

Save as .github/workflows/security-config-scan.yml:

name: Security Config Scan

on:
  pull_request:
  push:
    branches: [main, master]
  workflow_dispatch:

jobs:
  scan-configs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Scan for config malware signatures
        run: node scripts/check-malware-configs.mjs

Pre-commit hook (optional but strong)

With Husky:

# .husky/pre-commit
node ./scripts/check-malware-configs.mjs

Or a shared org hook template so every new repo gets it from day one.

Branch protection

For each important repo (main / master):

  1. Require a pull request before merge
  2. Require status checks to pass → include Security Config Scan
  3. Dismiss stale approvals when new commits are pushed
  4. Restrict who can push to protected branches
  5. Disable force-push to main
  6. (Optional) Require review from CODEOWNERS for *eslint*, *postcss*, *prettier*, *tailwind*

CODEOWNERS (optional)

.github/CODEOWNERS example:

eslint.config.*   @your-org/leads
postcss.config.*  @your-org/leads
prettier.config.* @your-org/leads
tailwind.config.* @your-org/leads
.github/workflows/** @your-org/leads

Template and org hygiene

If you create many client repos from shared starters, an infected template infects every new repo.

Golden templates (use these)

I published two GitHub template repositories you can use as the only approved starters for new client work. Both ship with the malware scanner, CI security gate, Husky pre-commit hook, and a script to lock main to PR-only:

Stack Template What you get
NestJS + Prisma backend nestjs-backend-template JWT auth, Swagger, Postgres, security:scan, branch-protection script
Next.js App Router frontend nextjs-frontend-template Tailwind v4, API health check, same scanner + CI + PR-only main

On GitHub: Use this template → create the new client repo → customize → after the first push run:

./scripts/enable-branch-protection.sh
# solo: APPROVALS=0 (default) · team: APPROVALS=1 ./scripts/enable-branch-protection.sh

That locks main so direct pushes are rejected; changes land only via PR with scan-configs + build green.

Must-fix / must-guard templates

  • Any NestJS / Next.js starter you copy (prefer the golden templates above)
  • Org .github repositories / reusable workflows
  • Internal “new project” scripts that scaffold configs

Permanent template rule

  1. Keep one clean golden template per stack (backend + frontend).
  2. Run node scripts/check-malware-configs.mjs in CI on that template.
  3. New client repos must be created only from those templates (GitHub “Use this template”).
  4. Never copy configs from random old client folders without scanning first.

Suggested reusable org workflow

Put the scanner in your org’s .github repo as a reusable workflow, then call it from each repo:

jobs:
  security-config-scan:
    uses: your-org/.github/.github/workflows/security-config-scan.yml@main

One update to IOCs then protects every repo.


Access, secrets, and npm hardening

Access

  • Give write access only to people who need it
  • Prefer org teams over personal collaborator sprawl
  • Remove inactive collaborators monthly
  • Prefer deploy keys / GitHub Actions OIDC over long-lived SSH keys on laptops when possible

Secrets

  • Use fine-grained GitHub tokens with expiry
  • Never commit .env, tokens, or private keys
  • Store production secrets in GitHub Actions secrets / a vault — not in chat or shared drives
  • After any malware incident: rotate everything once from a clean machine

Developer machines

  • Do not reuse the same machine token across all client repos forever
  • Prefer passphrase-protected SSH keys
  • Keep OS + Node updated

npm / supply-chain hardening

This incident was mostly committed config malware, but also harden package installs:

  1. Commit lockfiles (package-lock.json / pnpm-lock.yaml) and install with --frozen-lockfile in CI
  2. Enable Dependabot or Renovate
  3. Avoid postinstall scripts that download remote code
  4. Prefer ignore-scripts=true for untrusted packages when auditing
  5. Periodically run npm audit / pnpm audit (not sufficient alone, but useful)

Team playbook

Before opening any repo

# after clone, BEFORE npm install / IDE lint if repo is untrusted
node scripts/check-malware-configs.mjs   # if script exists
# or quick check:
rg -n "global\\.i=|Tgw\\(2509\\)|/0x/cls|temp_auto_push" \
  eslint.config.* postcss.config.* prettier.config.* tailwind.config.* .gitignore 2>/dev/null

Never do this on an untrusted clone

  • npm install / pnpm install blindly
  • Open the project in an IDE with ESLint auto-run enabled
  • Run npm run dev / build before a config scan

If something looks wrong

  1. Disconnect network if possible
  2. Do not push “fixes” from the infected machine without scanning
  3. Rotate credentials from a clean device
  4. Re-clone after remote purge
  5. Update the IOC list in check-malware-configs.mjs if a new variant appears

Rollout plan

Phase Action Target
1 Purge live infected remotes All confirmed infected GitHub remotes
2 Use published golden templates nestjs-backend-template, nextjs-frontend-template
3 Add reusable workflow in org .github repo Shared by all repos
4 Enable branch protection requiring the check All production repos (or ./scripts/enable-branch-protection.sh)
5 Batch-clean local mirrors Old clone directories on developer machines
6 Send short notice to colleagues Ubuntu + Mac users
7 Quarterly re-scan All orgs

What this permanently solves

Solves / strongly mitigates

  • Re-adding this obfuscated ESLint/PostCSS malware family
  • Silent one-line mega-payloads in common JS config files
  • Accidental copy-paste of infected configs from old client folders (if CI runs)
  • Merging infected configs to main without failing checks

Does not fully solve alone

  • Brand-new malware with unknown signatures (needs IOC updates)
  • Compromised npm packages with malicious postinstall
  • A trusted collaborator with write access intentionally bypassing process
  • Developers disabling hooks / force-merging without protections

→ Keep CI required + human review + least privilege. That combination is the durable answer.


Quick checklist

  • All known infected remotes purged
  • Teammates re-cloned clean copies
  • Secrets rotated from a clean machine
  • scripts/check-malware-configs.mjs in templates
  • .github/workflows/security-config-scan.yml active
  • Branch protection requires the security check
  • Write access reviewed / reduced
  • New repos created from nestjs-backend-template / nextjs-frontend-template
  • ./scripts/enable-branch-protection.sh run after first push
  • Team knows: scan configs before npm install on unfamiliar repos
  • IOC list updated when new variants are found

Bottom line

  1. Yes — this is real malware that can damage Ubuntu/Mac after normal Node workflows.
  2. git clone is not OS wipeware — the danger is infected ESLint/PostCSS (and similar) configs executing under Node.
  3. Cleaning one repo is not enough if templates, backup orgs, and local mirrors stay infected.
  4. The durable fix is automation: signature scanner in CI + required status checks + golden templates + least privilege.

Start every new Nest or Next client from the public templates — nestjs-backend-template and nextjs-frontend-template — then lock main with the included branch-protection script.

Update IOC signatures whenever a new variant is discovered. The campaign IDs and packed footers will change; the defense pattern — treat config files as executable, gate them in CI — will not.


Written from automated + manual inspection of GitHub API contents, commit history (including pre-purge malware recovery), and local workspace mirrors. Client and organization names intentionally omitted.