supply chain security

Detecting Malicious npm Packages

Triage npm packages for install-script malware, exfiltration, and worming behavior.

exfiltrationguarddoginstall-scriptsmalware-analysisnpmstatic-analysissupply-chain-securitythreat-detection
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Legal Notice: Analyze packages in an isolated, disposable environment. Some malicious packages execute on install (npm install runs lifecycle scripts automatically) or on import. Never analyze a suspect package on a workstation with credentials, SSH keys, cloud tokens, or network access to production. This skill is for defensive analysis and authorized incident response only.

Overview

The npm registry is the largest software package ecosystem in the world and the most heavily targeted by supply-chain attackers. Malicious packages reach victims through typosquatting (expresss, crossenv), dependency confusion, account/maintainer takeover (the 2025 Shai-Hulud worm and the event-stream compromise are canonical examples), and starjacking. The defining danger of npm is that npm install automatically runs preinstall, install, and postinstall lifecycle scripts with the developer's full privileges before any application code is invoked — so simply installing a package is enough to be compromised. Roughly 2% of npm packages use install scripts, which makes them both common and a powerful malware delivery vehicle.

Typical malicious behaviors are: exfiltrating environment variables, ~/.npmrc tokens, SSH keys, and cloud credentials to an attacker-controlled URL; opening reverse shells; dropping cryptominers; reading and posting process.env; obfuscating payloads with base64/eval; and self-propagating (worming) by stealing the maintainer's npm token and republishing trojanized versions of other packages they own.

This skill provides a repeatable triage workflow centered on GuardDog (Datadog's open-source heuristic scanner built on Semgrep + metadata rules), supplemented by manual tarball inspection, lockfile-based compromise checks against known-bad version lists, and dynamic detonation with network and filesystem monitoring. The goal is to decide, quickly and safely, whether a given package or a project's dependency tree contains malicious code.

When to Use

  • Triaging a specific npm package before adding it as a dependency.
  • Vetting a full package.json / package-lock.json during code review or onboarding a third-party library.
  • Responding to a supply-chain advisory (e.g., a worm campaign) and needing to check whether your lockfiles pulled a known-bad version.
  • Investigating an endpoint or CI runner suspected of having installed a trojanized package.
  • Building a pre-install gate in CI/CD that blocks packages exhibiting malicious indicators.

Prerequisites

  • An isolated VM or disposable container with no production credentials and snapshot/rollback capability.
  • GuardDog:
    pip install guarddog
    # or run via Docker without local install:
    docker pull ghcr.io/datadog/guarddog
    alias guarddog='docker run --rm ghcr.io/datadog/guarddog'
  • Node.js + npm (use --ignore-scripts when downloading for analysis).
  • jq, tar, and optionally OSV-Scanner for known-vulnerability/known-malicious cross-checks:
    go install github.com/google/osv-scanner/cmd/osv-scanner@v1
  • For dynamic analysis: a sandbox with egress logging (e.g., tcpdump, a DNS sink, or a network namespace).

Objectives

  • Statically scan an npm package (or a whole dependency tree) for malicious heuristics without executing it.
  • Identify install-script abuse, environment/credential exfiltration, obfuscation, and silent process execution.
  • Cross-check lockfile-pinned versions against known-malicious version lists / OSV.
  • Safely detonate a suspect package and observe network and filesystem behavior.
  • Extract indicators of compromise (URLs, IPs, hashes) for blocking and threat intel.
  • Produce a defensible verdict (benign / suspicious / malicious) with evidence.

MITRE ATT&CK Mapping

Technique ID Technique Name Relevance
T1195.002 Supply Chain Compromise: Compromise Software Supply Chain Core technique — trojanized npm package delivered through the registry.
T1059.007 Command and Scripting Interpreter: JavaScript Malicious install scripts / module code execute attacker JavaScript.
T1552.001 Unsecured Credentials: Credentials In Files Packages steal ~/.npmrc, .env, SSH keys, and cloud credential files.
T1041 Exfiltration Over C2 Channel Stolen secrets posted to attacker HTTP(S) endpoints.
T1027 Obfuscated Files or Information base64/eval/hex obfuscation hides the payload from review.

Workflow

1. Download the package without executing it

Fetch the tarball with scripts disabled so nothing runs during acquisition.

mkdir triage && cd triage
# Resolve the tarball URL and download it (no install, no scripts)
npm pack express@4.18.2            # produces express-4.18.2.tgz
# or for an arbitrary version:
npm view some-pkg@1.2.3 dist.tarball
curl -sL "$(npm view some-pkg@1.2.3 dist.tarball)" -o some-pkg.tgz
tar -xzf some-pkg.tgz              # extracts into ./package

2. Scan a single package with GuardDog

GuardDog applies metadata + source heuristics and prints which rules matched.

# Scan the latest published version from the registry
guarddog npm scan express
 
# Scan a specific version
guarddog npm scan some-pkg --version 1.2.3
 
# Scan the local tarball / extracted directory you downloaded above
guarddog npm scan ./some-pkg.tgz
guarddog npm scan ./package/

3. Verify an entire dependency tree

verify scans every dependency declared in a manifest — ideal for code review.

guarddog npm verify /path/to/repo/package.json

4. Focus on the highest-signal heuristics

Filter to the npm rules most indicative of malware to cut noise during triage.

guarddog npm scan some-pkg \
  --rules npm-install-script \
  --rules npm-serialize-environment \
  --rules npm-exec-base64 \
  --rules npm-silent-process-execution \
  --rules npm-obfuscation \
  --rules shady-links \
  --rules typosquatting

5. Emit machine-readable output for pipelines

JSON for tooling, SARIF for GitHub code scanning.

guarddog npm scan some-pkg --output-format=json   > guarddog.json
guarddog npm verify package.json --output-format=sarif > guarddog.sarif

6. Manually inspect lifecycle scripts and source

Lifecycle scripts are the first thing to read; obfuscation and outbound URLs are red flags.

# Show all lifecycle hooks
jq '.scripts' package/package.json
 
# Hunt for exfiltration / execution primitives in the source
grep -rEn "child_process|exec\(|spawn|eval\(|Buffer\.from\(.*base64|process\.env|https?://" package/ \
  --include='*.js' --include='*.ts' | head -50

7. Cross-check lockfiles against known-malicious versions

During an active campaign, compare pinned versions to the advisory's bad-version list, and run OSV.

# Extract resolved name@version pairs from a v3 lockfile
jq -r '.packages | to_entries[] | select(.key|startswith("node_modules/")) | "\(.key|ltrimstr("node_modules/"))@\(.value.version)"' package-lock.json
 
# OSV-Scanner flags known-vulnerable AND known-malicious (MAL-) advisories
osv-scanner --lockfile=package-lock.json

8. Detonate safely with monitoring (only if static is inconclusive)

Run the install inside a disposable, network-monitored sandbox.

# In a throwaway container / VM with egress capture running (tcpdump -w capture.pcap):
npm install ./some-pkg.tgz            # scripts WILL run — sandbox only
# Baseline-diff the filesystem afterwards for writes outside node_modules,
# and inspect capture.pcap for unexpected DNS / HTTP beacons.

9. Extract and operationalize IOCs

Pull URLs, IPs, and hashes for blocking and intel sharing.

grep -rhoE "https?://[a-zA-Z0-9./?=_%:-]+" package/ | sort -u > urls.txt
sha256sum some-pkg.tgz package/*.js > hashes.txt

10. Run the bundled triage helper

agent.py orchestrates GuardDog, lifecycle-script inspection, and IOC extraction into one report.

python scripts/agent.py --package some-pkg --version 1.2.3 --output verdict.json
# or against a local tarball:
python scripts/agent.py --tarball ./some-pkg.tgz --output verdict.json

Tools and Resources

Tool Purpose Source
GuardDog Heuristic npm/PyPI/Go malware scanner https://github.com/DataDog/guarddog
OSV-Scanner Known-vulnerable & known-malicious (MAL-) advisory matching https://github.com/google/osv-scanner
OSV malicious DB Open-source malicious package advisories https://github.com/ossf/malicious-packages
npm lifecycle docs preinstall/install/postinstall semantics https://docs.npmjs.com/cli/v10/using-npm/scripts
Datadog Security Labs npm campaign writeups & rules https://securitylabs.datadoghq.com/
Semgrep Rule engine GuardDog uses for source heuristics https://semgrep.dev/

Validation Criteria

  • Package acquired with scripts disabled in an isolated environment.
  • GuardDog scan run on the target version with results captured.
  • Full dependency tree run through GuardDog verify where applicable.
  • Lifecycle scripts (preinstall/install/postinstall) read and assessed.
  • Lockfile versions cross-checked against OSV / known-bad lists.
  • Dynamic detonation performed in a sandbox if static analysis was inconclusive.
  • IOCs (URLs, IPs, hashes) extracted and recorded.
  • Documented verdict (benign / suspicious / malicious) with supporting evidence.
  • Malicious findings reported to the registry and shared as threat intel.
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 2

api-reference.md2.2 KB

API and Command Reference

GuardDog (DataDog/guarddog)

Install: pip install guarddog | Docker: ghcr.io/datadog/guarddog

Subcommands

Command Description
guarddog npm scan <pkg> Scan latest version from registry.
guarddog npm scan <pkg> --version X.Y.Z Scan a specific version.
guarddog npm scan <path.tgz | dir> Scan a local tarball or extracted directory.
guarddog npm verify <package.json> Scan every dependency in a manifest.
guarddog pypi scan <pkg> Same for PyPI.
guarddog go scan <module> / guarddog go verify go.mod Go modules.
guarddog rubygems scan <gem> RubyGems.

Common flags

Flag Description
--output-format=json Machine-readable JSON.
--output-format=sarif SARIF for GitHub code scanning.
--rules <rule> (repeatable) Run only the named rule(s).
--exclude-rules <rule> Exclude the named rule(s).
--log-level debug Verbose diagnostics.

Key npm heuristics

Rule Detects
npm-install-script preinstall/install/postinstall lifecycle scripts.
npm-serialize-environment Exfiltration of environment variables.
npm-exec-base64 eval of base64-decoded payloads.
npm-silent-process-execution Silent child-process execution.
npm-obfuscation Common obfuscation patterns.
shady-links Suspicious URLs in code.
typosquatting Name similar to a popular package.
potentially_compromised_email_domain Maintainer email on a lapsed domain.

OSV-Scanner

Command Description
osv-scanner --lockfile=package-lock.json Match pinned versions to OSV advisories incl. MAL- malicious entries.
osv-scanner -r <dir> Recursively scan a directory.
osv-scanner --format json JSON output.

npm acquisition (no execution)

Command Description
npm pack <pkg>@<ver> Download tarball without installing.
npm view <pkg>@<ver> dist.tarball Print the tarball URL.
npm install --ignore-scripts Install while skipping lifecycle scripts.
jq '.scripts' package/package.json List lifecycle hooks.
standards.md1.5 KB

Standards and Framework Mapping

MITRE ATT&CK

ID Name Rationale
T1195.002 Supply Chain Compromise: Compromise Software Supply Chain Core technique — trojanized package shipped through the npm registry.
T1059.007 Command and Scripting Interpreter: JavaScript Install scripts and module code run attacker JavaScript on the victim.
T1552.001 Unsecured Credentials: Credentials In Files Packages harvest .npmrc, .env, SSH keys, and cloud credential files.
T1041 Exfiltration Over C2 Channel Stolen data is POSTed to attacker HTTP(S) endpoints.
T1027 Obfuscated Files or Information base64/eval/hex obfuscation conceals the payload.

NIST Cybersecurity Framework 2.0

ID Name Rationale
DE.CM-09 Computing hardware and software, runtime environments, and their data are monitored to find potentially adverse events Static + dynamic triage of npm packages and lockfiles is the monitoring control that surfaces malicious dependencies.

Supporting Standards

  • OWASP Top 10 CI/CD Security Risks — CICD-SEC-03: Dependency Chain Abuse. Malicious package ingestion is a primary dependency-chain abuse vector.
  • NIST SP 800-218 (SSDF) — PW.4 / PS.3. Reuse and verify the integrity of acquired software components; triaging packages satisfies the verification practice.
  • SLSA provenance. Verifying build provenance reduces the chance of consuming a tampered or republished package.

Scripts 1

agent.py6.2 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""
Malicious npm package triage helper.

Orchestrates GuardDog static scanning, lifecycle-script inspection, and IOC
extraction for a single npm package (by name@version) or a local tarball, and
emits a structured verdict report.

WARNING: download with scripts disabled and run only in an isolated, disposable
environment with no production credentials. Defensive / authorized use only.
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
from pathlib import Path

URL_RE = re.compile(r"https?://[A-Za-z0-9./?=_%:&#-]+")
SUSPECT_RE = re.compile(
    r"child_process|\bexec\s*\(|\bspawn\b|\beval\s*\(|Buffer\.from\([^)]*base64|process\.env",
    re.IGNORECASE,
)
HIGH_SIGNAL_RULES = [
    "npm-install-script",
    "npm-serialize-environment",
    "npm-exec-base64",
    "npm-silent-process-execution",
    "npm-obfuscation",
    "shady-links",
    "typosquatting",
]


def run(cmd: list[str], timeout: int = 600) -> tuple[int, str, str]:
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return p.returncode, p.stdout, p.stderr
    except FileNotFoundError:
        return 127, "", f"command not found: {cmd[0]}"
    except subprocess.SubprocessError as exc:
        return 1, "", f"subprocess error: {exc}"


def guarddog_scan(target: str, version: str | None) -> dict:
    if not shutil.which("guarddog"):
        return {"error": "guarddog not installed (pip install guarddog)"}
    cmd = ["guarddog", "npm", "scan", target, "--output-format=json"]
    if version:
        cmd += ["--version", version]
    for rule in HIGH_SIGNAL_RULES:
        cmd += ["--rules", rule]
    rc, out, err = run(cmd)
    try:
        return {"returncode": rc, "result": json.loads(out)} if out.strip() else {"returncode": rc, "stderr": err}
    except json.JSONDecodeError:
        return {"returncode": rc, "raw": out, "stderr": err}


def download_tarball(name: str, version: str | None, dest: Path) -> Path | None:
    spec = f"{name}@{version}" if version else name
    rc, out, err = run(["npm", "view", spec, "dist.tarball"])
    if rc != 0 or not out.strip():
        print(f"[warn] could not resolve tarball for {spec}: {err}", file=sys.stderr)
        return None
    url = out.strip().splitlines()[-1]
    tgz = dest / "package.tgz"
    try:
        urllib.request.urlretrieve(url, tgz)  # noqa: S310 - registry URL from npm
        return tgz
    except Exception as exc:  # noqa: BLE001
        print(f"[warn] download failed: {exc}", file=sys.stderr)
        return None


def inspect_tarball(tgz: Path, workdir: Path) -> dict:
    findings = {"lifecycle_scripts": {}, "suspicious_lines": [], "urls": []}
    extract = workdir / "package"
    try:
        with tarfile.open(tgz, "r:gz") as tf:
            members = [m for m in tf.getmembers() if not m.name.startswith(("/", ".."))]
            tf.extractall(extract, members=members)  # noqa: S202 - path-checked members
    except (tarfile.TarError, OSError) as exc:
        findings["error"] = f"extract failed: {exc}"
        return findings

    pkg_json = next(extract.rglob("package.json"), None)
    if pkg_json:
        try:
            data = json.loads(pkg_json.read_text(encoding="utf-8"))
            findings["lifecycle_scripts"] = {
                k: v for k, v in (data.get("scripts") or {}).items()
                if k in ("preinstall", "install", "postinstall")
            }
        except (json.JSONDecodeError, OSError):
            pass

    for src in extract.rglob("*.js"):
        try:
            text = src.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue
        for m in SUSPECT_RE.finditer(text):
            findings["suspicious_lines"].append({"file": str(src.relative_to(extract)), "match": m.group(0)})
        findings["urls"].extend(URL_RE.findall(text))
    findings["urls"] = sorted(set(findings["urls"]))[:50]
    findings["suspicious_lines"] = findings["suspicious_lines"][:50]
    return findings


def verdict(gd: dict, insp: dict) -> str:
    score = 0
    res = gd.get("result")
    if isinstance(res, dict):
        for v in res.get("results", res).values() if isinstance(res.get("results", res), dict) else []:
            if v:
                score += 2
    if insp.get("lifecycle_scripts"):
        score += 2
    score += min(len(insp.get("suspicious_lines", [])), 5)
    if score >= 6:
        return "MALICIOUS (high confidence)"
    if score >= 2:
        return "SUSPICIOUS (manual review required)"
    return "benign (no strong indicators)"


def main() -> int:
    ap = argparse.ArgumentParser(description="npm malicious package triage")
    ap.add_argument("--package", help="npm package name")
    ap.add_argument("--version", help="specific version")
    ap.add_argument("--tarball", help="path to a local .tgz instead of downloading")
    ap.add_argument("--output", help="write JSON report")
    args = ap.parse_args()

    if not args.package and not args.tarball:
        ap.error("provide --package or --tarball")

    report: dict = {"package": args.package, "version": args.version}
    with tempfile.TemporaryDirectory() as td:
        work = Path(td)
        if args.tarball:
            tgz = Path(args.tarball)
            report["guarddog"] = (
                guarddog_scan(str(tgz), None) if shutil.which("guarddog") else {"error": "no guarddog"}
            )
        else:
            report["guarddog"] = guarddog_scan(args.package, args.version)
            tgz = download_tarball(args.package, args.version, work)

        report["inspection"] = inspect_tarball(tgz, work) if tgz and tgz.exists() else {"error": "no tarball"}

    report["verdict"] = verdict(report["guarddog"], report["inspection"])
    print(f"[+] verdict: {report['verdict']}")
    if report["inspection"].get("lifecycle_scripts"):
        print(f"[!] install scripts present: {report['inspection']['lifecycle_scripts']}")
    if report["inspection"].get("urls"):
        print(f"[i] {len(report['inspection']['urls'])} URL(s) found in source")

    if args.output:
        Path(args.output).write_text(json.dumps(report, indent=2), encoding="utf-8")
        print(f"[+] report written to {args.output}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Keep exploring