npm audit ships yesterday's risk. Here's how to measure tomorrow's.
Mapping Transitive Dependency Concentration in Modern JavaScript Ecosystems
Current Situation Analysis
Modern JavaScript projects rely on deeply nested dependency trees that pull in hundreds of transitive packages. Traditional security tooling treats this ecosystem as a flat list of known vulnerabilities. Tools like npm audit or SCA scanners cross-reference installed packages against CVE databases. If a package has no filed vulnerability, it passes. This model assumes risk is synonymous with known exploits.
The assumption breaks down when examining recent supply chain incidents. Attacks targeting high-traffic packages like LiteLLM, axios, and ua-parser-js bypassed CVE scanners entirely. At the time of exploitation, no vulnerability existed in public databases. The common denominator across these incidents was not a software flaw, but a structural pattern: a single publisher account controlled publish access to packages with millions of weekly downloads, often with release cycles dormant for over a year.
This gap exists because npm's architecture deliberately separates source control from publish credentials. A repository can have dozens of contributors, pull request reviews, and CI checks, yet the actual published artifact ships from a single personal access token. Compromising that token bypasses all source-level safeguards. The registry accepts the upload without requiring additional verification. Go's module system avoids this class of risk entirely by anchoring modules to VCS commits and go.sum checksums, eliminating separate publish credentials. PyPI and Cargo share npm's structural exposure, though at lower download volumes.
Teams overlook concentration risk because it requires traversing the full transitive graph and correlating publisher metadata with download telemetry. CVE scanners don't do this. They flag what has already broken. Concentration analysis flags what could break if a single credential is phished, making it a proactive structural assessment rather than a reactive patch tracker.
WOW Moment: Key Findings
Shifting from vulnerability tracking to concentration mapping reveals a stark difference in risk visibility. The table below contrasts how traditional scanning and concentration analysis evaluate the same dependency ecosystem.
| Approach | Metric 1 | Metric 2 | Metric 3 |
|---|---|---|---|
| CVE-Based Scanning | Reactive (Post-Exploit) | Low Structural Visibility | Patch-Only Remediation |
| Concentration Analysis | Proactive (Pre-Incident) | High Blast-Radius Visibility | Architectural Risk Mitigation |
This finding matters because it transforms dependency security from a compliance checklist into an architectural decision matrix. When you can quantify the download volume sitting behind a single publisher, you can calculate blast radius before an incident occurs. You stop asking "Is this package vulnerable?" and start asking "What happens if this publisher's credentials are compromised?"
The data becomes actionable when mapped to your direct dependencies. A package like express appears benign at depth 1. At depth 2, it pulls in depd, escape-html, once, and wrappy. Four packages, two npm identities. Combined weekly downloads exceed 400 million. Compromising either identity weaponizes millions of downstream applications instantly. The same pattern repeats across axios, vite, next, and webpack. A standard modern stack carrying these tools accumulates roughly 2 billion weekly downloads of transitive surface area behind single-person tokens. Most will never receive a CVE. They are load-bearing infrastructure, not broken software.
Core Solution
Measuring transitive concentration requires a systematic pipeline that parses your lockfile, queries registry metadata, applies threshold rules, and maps critical paths. The implementation should run in CI or as a pre-merge gate, not as a manual audit.
Step 1: Parse the Lockfile Graph
Extract the full dependency tree from package-lock.json or pnpm-lock.yaml. Flatten the structure to identify every transitive package, deduplicating by package name to avoid counting the same module multiple times across different node_modules branches.
Step 2: Query Registry Metadata
For each unique transitive package, fetch:
- Weekly download statistics from the npm registry API
- Publisher identity (the npm account with publish access)
- Last release timestamp
- Trusted publishing/OIDC flags
Step 3: Apply Concentration Thresholds
Flag packages that meet both conditions:
- Single publisher controls all publish access
- Weekly downloads exceed 10,000,000
Sum the weekly downloads across all flagged packages to calculate critical_concentration. This represents the ecosystem blast radius if that publisher's credentials are compromised.
Step 4: Map Critical Paths
Trace the import chain from your direct dependencies to each flagged transitive package. This produces critical_paths, showing which of your explicitly declared dependencies pull in high-concentration risk.
Step 5: Generate Risk Report
Output a structured report listing flagged packages, publisher identities, download volumes, staleness windows, and the exact dependency chain responsible.
Implementation Example (TypeScript)
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
interface RegistryMetadata {
name: string;
weeklyDownloads: number;
publisher: string;
lastRelease: string;
hasTrustedPublishing: boolean;
}
interface DependencyNode {
name: string;
version: string;
dependencies: Record<string, string>;
}
interface ConcentrationReport {
criticalConcentration: number;
criticalPaths: Map<string, string[]>;
flaggedPackages: RegistryMetadata[];
}
class TransitiveRiskAnalyzer {
private readonly DOWNLOAD_THRESHOLD = 10_000_000;
private readonly STALENESS_MONTHS = 12;
constructor(private lockfilePath: string) {}
async analyze(): Promise<ConcentrationReport> {
const graph = this.parseLockfile();
const uniquePackages = this.deduplicatePackages(graph);
const metadata = await this.fetchRegistryData(uniquePackages);
const flagged = metadata.filter(pkg => this.isCritical(pkg));
const concentration = flagged.reduce((sum, pkg) => sum + pkg.weeklyDownloads, 0);
const pat
hs = this.mapCriticalPaths(graph, flagged);
return {
criticalConcentration: concentration,
criticalPaths: paths,
flaggedPackages: flagged
};
}
private parseLockfile(): Record<string, DependencyNode> { const raw = readFileSync(join(process.cwd(), this.lockfilePath), 'utf-8'); const lockfile = JSON.parse(raw); return lockfile.packages || {}; }
private deduplicatePackages(graph: Record<string, DependencyNode>): string[] { const names = new Set<string>(); for (const key of Object.keys(graph)) { if (key.startsWith('node_modules/')) { const parts = key.split('node_modules/'); const packageName = parts[parts.length - 1]; names.add(packageName); } } return Array.from(names); }
private async fetchRegistryData(packages: string[]): Promise<RegistryMetadata[]> {
const results: RegistryMetadata[] = [];
for (const pkg of packages) {
try {
const res = await fetch(https://registry.npmjs.org/${pkg});
if (!res.ok) continue;
const data = await res.json();
const latest = data['dist-tags']?.latest || Object.keys(data.versions).pop();
const versionData = data.versions[latest];
results.push({
name: pkg,
weeklyDownloads: await this.getWeeklyDownloads(pkg),
publisher: versionData._npmUser?.name || 'unknown',
lastRelease: versionData.time || new Date().toISOString(),
hasTrustedPublishing: versionData.publishConfig?.access === 'public' &&
versionData._npmVersion?.includes('trusted') || false
});
} catch {
continue;
}
}
return results;
}
private async getWeeklyDownloads(pkg: string): Promise<number> {
const res = await fetch(https://api.npmjs.org/downloads/point/last-week/${pkg});
const data = await res.json();
return data.downloads || 0;
}
private isCritical(meta: RegistryMetadata): boolean { const meetsVolume = meta.weeklyDownloads > this.DOWNLOAD_THRESHOLD; const isSolePublisher = true; // Simplified for example; real impl checks maintainer array const notMitigated = !meta.hasTrustedPublishing; return meetsVolume && isSolePublisher && notMitigated; }
private mapCriticalPaths( graph: Record<string, DependencyNode>, flagged: RegistryMetadata[] ): Map<string, string[]> { const paths = new Map<string, string[]>(); const flaggedNames = new Set(flagged.map(f => f.name));
for (const [key, node] of Object.entries(graph)) {
if (key.startsWith('node_modules/')) {
const directDeps = Object.keys(node.dependencies || {});
const matches = directDeps.filter(dep => flaggedNames.has(dep));
if (matches.length > 0) {
const directName = key.split('node_modules/').pop() || 'root';
paths.set(directName, matches);
}
}
}
return paths;
} }
export { TransitiveRiskAnalyzer };
### Architecture Decisions & Rationale
- **Lockfile over `node_modules`**: Parsing `package-lock.json` guarantees deterministic results and avoids scanning symlinked or hoisted duplicates. It reflects exactly what will be installed in production.
- **Registry API over local cache**: Publisher metadata and download telemetry are not stored locally. Querying the registry ensures you evaluate current ecosystem state, not stale local snapshots.
- **Threshold at 10M downloads**: This balances signal-to-noise. Packages below this threshold rarely represent systemic blast radius. The number is configurable per organizational risk tolerance.
- **OIDC/Trusted Publishing exclusion**: Packages using npm Trusted Publishing or OIDC workflows decouple publish access from personal tokens. Excluding them from critical flags prevents false positives and rewards secure publishing practices.
- **Path mapping over flat listing**: Knowing which direct dependency pulls in a critical transitive package enables targeted remediation. You cannot drop `express`, but you can evaluate whether `axios`'s transitive footprint justifies switching to native `fetch` or an alternative HTTP client.
## Pitfall Guide
1. **Treating Staleness as Safety**
Packages with no releases in 12+ months are often assumed stable. In concentration analysis, staleness combined with high download volume increases risk. A dormant package with millions of weekly downloads is a high-value target for credential compromise because maintainers are less likely to notice anomalous publish activity.
*Fix:* Flag packages with >12 months since last release as WARN, regardless of CVE status.
2. **Ignoring Lockfile Deduplication**
npm and pnpm hoist dependencies, creating multiple `node_modules` paths for the same package. Counting each path separately inflates concentration metrics.
*Fix:* Extract unique package names before querying registry data. Aggregate by canonical name, not by filesystem path.
3. **Overlooking Trusted Publishing Mitigations**
Packages that migrated to npm Trusted Publishing or OIDC workflows no longer rely on personal access tokens. Flagging them as critical misrepresents actual risk.
*Fix:* Check `publishConfig` and registry metadata for trusted publishing flags. Exclude OIDC-enabled packages from critical concentration sums.
4. **Focusing Only on Depth-1 Dependencies**
Direct dependencies rarely carry the highest concentration. The risk compounds in transitive layers where utility packages aggregate massive download volumes under single publishers.
*Fix:* Traverse to depth 2 and beyond. Most critical concentration hides in the second or third transitive layer.
5. **Assuming Download Volume Equals Quality**
High weekly downloads indicate popularity, not security posture. In concentration analysis, volume measures blast radius, not trustworthiness.
*Fix:* Treat download metrics as exposure indicators, not quality signals. Pair concentration data with publisher activity and release cadence.
6. **Hardcoding Thresholds Without Context**
A 10M download threshold works for enterprise stacks but may be too aggressive for internal tooling or low-traffic services.
*Fix:* Make thresholds configurable. Allow teams to adjust based on deployment scale, compliance requirements, and risk appetite.
7. **Running Scans Infrequently**
Publisher metadata changes. Packages migrate to trusted publishing. New transitive dependencies enter the tree. A one-time scan provides a snapshot, not continuous assurance.
*Fix:* Integrate into CI pipelines. Schedule weekly or per-PR scans. Cache registry responses to avoid rate limits.
## Production Bundle
### Action Checklist
- [ ] Integrate concentration analyzer into CI pipeline as a pre-merge gate
- [ ] Configure download threshold and staleness window per project risk profile
- [ ] Enable OIDC/Trusted Publishing exclusion to reduce false positives
- [ ] Map critical paths to identify which direct dependencies pull high-risk transitive packages
- [ ] Establish remediation playbook: vendor, pin, replace, or formally accept risk
- [ ] Cache registry API responses to prevent rate limiting during large scans
- [ ] Schedule periodic re-scans to capture publisher metadata changes
- [ ] Document concentration baselines for compliance and audit trails
### Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|----------|---------------------|-----|-------------|
| High-concentration transitive dep in public API | Replace or vendor the dependency | Blast radius affects external users; credential compromise could cause widespread outage | Medium (refactor time) |
| Low-concentration internal tooling | Accept risk with explicit documentation | Internal exposure limited; remediation cost outweighs potential impact | Low (documentation only) |
| Package migrated to Trusted Publishing | Exclude from critical flags | OIDC decouples publish access from personal tokens; structural risk reduced | None |
| Stale package with >50M weekly downloads | Pin to known-good version + monitor publisher | High volume + inactivity = elevated compromise risk; pinning prevents automatic updates | Low-Medium (monitoring overhead) |
| Monorepo with shared transitive deps | Run deduplicated scan at workspace root | Prevents double-counting; provides accurate ecosystem-wide concentration | Low (CI config adjustment) |
### Configuration Template
```yaml
# concentration-scan.config.yaml
thresholds:
weekly_downloads: 10000000
staleness_months: 12
mitigations:
exclude_trusted_publishing: true
exclude_oidc_workflows: true
output:
format: json
path: ./reports/concentration-report.json
include_critical_paths: true
cache:
enabled: true
ttl_hours: 24
storage_path: ./.cache/registry-metadata
ci:
fail_on_critical: true
warn_on_stale: true
max_scan_duration_seconds: 300
Quick Start Guide
- Install the analyzer package or clone the TypeScript implementation into your repository.
- Place the configuration template at the root of your project and adjust thresholds to match your risk tolerance.
- Run
npx concentration-scan --lockfile package-lock.jsonto generate the initial report. - Review the
critical_pathsoutput to identify which direct dependencies pull high-concentration transitive packages. - Integrate the scan into your CI pipeline using the provided configuration template. Set
fail_on_critical: trueto block merges that introduce unmitigated concentration risk.
