From 3e1f1368bc640b51a5d0cb6bd711610409791cc6 Mon Sep 17 00:00:00 2001 From: saraeloop Date: Tue, 7 Apr 2026 15:29:15 -0700 Subject: [PATCH] feat(scan): add warning for weekly downloads lookup fallback - track weekly downloads lookup fallback in npm package metadata fetching - surface a single aggregated scan warning when one or more package lookups fall back to unknown - reuse the existing warnings channel for rendering and JSON output - avoid warning on synthetic project roots where weekly_downloads is expected to be null - keep scoring and scan result behavior unchanged - add coverage for: - downloads lookup fallback - successful downloads lookup - synthetic project-root null handling - stable warning rendering verification: - pnpm test - pnpm run build --- README.md | 19 ++- src/adapters/npm-package-metadata-source.ts | 29 +++-- src/application/scan-package.ts | 15 +++ src/domain/contracts.ts | 3 +- test/npm-package-metadata-source.test.ts | 40 ++++++ test/plain-text-renderer.test.ts | 22 ++++ test/scan-package.test.ts | 129 ++++++++++++++++++++ 7 files changed, 238 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 22b6a13..30a165b 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,7 @@ License

-DepGraph scores npm packages and their transitive dependencies against -behavioral signals, publish age, version velocity, registry deprecation -and tells you exactly why something looks suspicious. Signature-based -scanners miss what DepGraph catches by design. +DepGraph scores npm packages and their transitive dependencies against behavioral signals, publish age, version velocity, and registry deprecation. It tells you exactly why something looks suspicious. Signature-based scanners miss what DepGraph catches by design. Run it before every install. Use the JSON output in CI. Built for agents. @@ -72,7 +69,7 @@ Append a review outcome to a stored scan finding: depgraph review --target package_finding:axios@1.14.0 --outcome benign --notes "reviewed by analyst" ``` -Inspect local dataset coverage and readiness: +Check how many of your scanned packages have full metadata enrichment versus degraded coverage: ```bash depgraph eval @@ -111,9 +108,9 @@ next@15.1.7 review (0.64) -- packages requiring review: 2 +- packages requiring review: 1 - findings with security-related signals: 1 -- packages that appear safe: 12 +- packages that appear safe: 13 ``` ## JSON Example @@ -191,7 +188,7 @@ Pre-v1. Interfaces may change before 1.0. ## Roadmap -### Shipped +### v0.2 — Shipped - [x] npm package scanning with traversal - [x] rich Ink terminal UI and plain text mode @@ -205,15 +202,15 @@ Pre-v1. Interfaces may change before 1.0. - [x] local dataset evaluation - [x] depgraph.sh -### Coming Soon +### v0.3 — In Progress - [ ] yarn lockfile support -- [ ] sensitive import analysis - [ ] explain command - [ ] CI/CD GitHub Action -### Future +### Later +- [ ] sensitive import analysis - [ ] maintainer history signals - [ ] organization-level scan aggregation diff --git a/src/adapters/npm-package-metadata-source.ts b/src/adapters/npm-package-metadata-source.ts index 97ae60f..4325788 100644 --- a/src/adapters/npm-package-metadata-source.ts +++ b/src/adapters/npm-package-metadata-source.ts @@ -27,13 +27,18 @@ interface DownloadResponse { downloads?: number } +interface WeeklyDownloadsResult { + downloads: number | null + lookup_failed: boolean +} + export class NpmPackageMetadataSource implements PackageMetadataSource { constructor(private readonly fetcher: typeof fetch = fetch) {} async resolvePackage(spec: PackageSpec): Promise { - const [packument, weeklyDownloadsCandidate] = await Promise.all([ + const [packument, weeklyDownloadsResult] = await Promise.all([ this.fetchPackument(spec.name), - this.fetchWeeklyDownloads(spec.name).catch(() => null), + this.fetchWeeklyDownloads(spec.name), ]) const version = this.resolveVersion(packument, spec) const manifest = packument.versions?.[version] @@ -47,7 +52,7 @@ export class NpmPackageMetadataSource implements PackageMetadataSource { const publishEventsLast30Days = this.countRecentPublishes(versionTimes, 30) const deprecatedMessage = manifest.deprecated ?? null const isSecurityTombstone = this.isSecurityTombstone(spec.name, version, deprecatedMessage) - const weeklyDownloads = isSecurityTombstone ? null : weeklyDownloadsCandidate + const weeklyDownloads = isSecurityTombstone ? null : weeklyDownloadsResult.downloads return { package: { @@ -61,6 +66,7 @@ export class NpmPackageMetadataSource implements PackageMetadataSource { total_versions: Object.keys(packument.versions ?? {}).length, publish_events_last_30_days: publishEventsLast30Days, weekly_downloads: weeklyDownloads, + downloads_lookup_failed: isSecurityTombstone ? false : weeklyDownloadsResult.lookup_failed, deprecated_message: deprecatedMessage, is_security_tombstone: isSecurityTombstone, has_advisories: false, @@ -221,21 +227,30 @@ export class NpmPackageMetadataSource implements PackageMetadataSource { ) } - private async fetchWeeklyDownloads(name: string): Promise { + private async fetchWeeklyDownloads(name: string): Promise { const url = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}` try { const response = await this.fetcher(url) if (!response.ok) { - return null + return { + downloads: null, + lookup_failed: true, + } } const payload = (await response.json()) as DownloadResponse - return typeof payload.downloads === 'number' ? payload.downloads : null + return { + downloads: typeof payload.downloads === 'number' ? payload.downloads : null, + lookup_failed: typeof payload.downloads !== 'number', + } } catch { - return null + return { + downloads: null, + lookup_failed: true, + } } } diff --git a/src/application/scan-package.ts b/src/application/scan-package.ts index af988f4..6be5bf4 100644 --- a/src/application/scan-package.ts +++ b/src/application/scan-package.ts @@ -97,6 +97,7 @@ export function createScanPackageUseCase({ const nodeMap = new Map() const pendingFindings: PendingScanFinding[] = [] const warnings: ScanWarning[] = [] + let hasDownloadsLookupFallback = false let overallRiskScore = 0 for (const traversedNode of traversedGraph.nodes) { @@ -120,6 +121,9 @@ export function createScanPackageUseCase({ lockfile_integrity: traversedNode.lockfile_integrity ?? null, }) } + if (traversedNode.metadata?.downloads_lookup_failed === true) { + hasDownloadsLookupFallback = true + } nodeMap.set(traversedNode.key, packageNode) overallRiskScore = Math.max(overallRiskScore, assessment.risk_score) @@ -155,6 +159,17 @@ export function createScanPackageUseCase({ const completedAt = now() const rootNode = nodeMap.get(traversedGraph.root_key)! + if (hasDownloadsLookupFallback) { + warnings.push({ + kind: 'weekly_downloads_unavailable', + package_key: rootNode.key, + package_name: rootNode.name, + package_version: rootNode.version, + message: 'weekly downloads unavailable for one or more packages', + lockfile_resolved_url: null, + lockfile_integrity: null, + }) + } const recordId = `${completedAt.toISOString()}:${packageKey({ name: rootNode.name, version: rootNode.version, diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts index 462c870..620668a 100644 --- a/src/domain/contracts.ts +++ b/src/domain/contracts.ts @@ -77,6 +77,7 @@ export interface PackageMetadata { total_versions: number publish_events_last_30_days: number weekly_downloads: number | null + downloads_lookup_failed?: boolean deprecated_message: string | null is_security_tombstone: boolean has_advisories: boolean @@ -121,7 +122,7 @@ export interface EdgeFinding { } export interface ScanWarning { - kind: 'unresolved_registry_lookup' + kind: 'unresolved_registry_lookup' | 'weekly_downloads_unavailable' package_key: string package_name: string package_version: string diff --git a/test/npm-package-metadata-source.test.ts b/test/npm-package-metadata-source.test.ts index 5eb64a0..22c2413 100644 --- a/test/npm-package-metadata-source.test.ts +++ b/test/npm-package-metadata-source.test.ts @@ -94,6 +94,46 @@ test('metadata source marks npm security tombstones and ignores inherited downlo assert.equal(metadata.is_security_tombstone, true) assert.match(metadata.deprecated_message ?? '', /security placeholder/i) assert.equal(metadata.weekly_downloads, null) + assert.equal(metadata.downloads_lookup_failed, false) +}) + +test('metadata source marks weekly downloads as unavailable when the downloads endpoint fails', async () => { + const source = new NpmPackageMetadataSource(async (input) => { + const url = String(input) + + if (url.startsWith('https://api.npmjs.org/downloads/')) { + return new Response('upstream unavailable', { status: 503 }) + } + + return new Response( + JSON.stringify({ + name: 'demo-package', + 'dist-tags': { + latest: '1.2.3', + }, + versions: { + '1.2.3': { + version: '1.2.3', + dependencies: {}, + }, + }, + time: { + created: '2014-09-02T01:28:28.167Z', + modified: '2024-01-15T10:22:33.000Z', + '1.2.3': '2024-01-15T10:22:33.000Z', + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ) + }) + + const metadata = await source.resolvePackage({ name: 'demo-package' }) + + assert.equal(metadata.weekly_downloads, null) + assert.equal(metadata.downloads_lookup_failed, true) }) test('metadata source throws when publish timestamps are unavailable', async () => { diff --git a/test/plain-text-renderer.test.ts b/test/plain-text-renderer.test.ts index 5b75020..9301c2c 100644 --- a/test/plain-text-renderer.test.ts +++ b/test/plain-text-renderer.test.ts @@ -75,6 +75,28 @@ test('plain text renderer omits the warnings section when there are no warnings' assert.doesNotMatch(output, /\nWarnings:\n- none/) }) +test('plain text renderer keeps aggregated download warnings in the existing warnings section', () => { + const result = createResult() + result.warnings = [ + { + kind: 'weekly_downloads_unavailable', + package_key: 'root@1.0.0', + package_name: 'root', + package_version: '1.0.0', + message: 'weekly downloads unavailable for one or more packages', + lockfile_resolved_url: null, + lockfile_integrity: null, + }, + ] + + const output = renderPlainText(result) + + assert.match( + output, + /- root@1\.0\.0 \[weekly_downloads_unavailable\] weekly downloads unavailable for one or more packages/, + ) +}) + test('plain text renderer does not mutate finding explanations or signals', () => { const result = createResult() const before = JSON.parse(JSON.stringify(result)) diff --git a/test/scan-package.test.ts b/test/scan-package.test.ts index d87f2c5..a7b2f25 100644 --- a/test/scan-package.test.ts +++ b/test/scan-package.test.ts @@ -172,6 +172,7 @@ function createMetadata(name: string, version: string): PackageMetadata { total_versions: 3, publish_events_last_30_days: 1, weekly_downloads: 1000, + downloads_lookup_failed: false, deprecated_message: null, is_security_tombstone: false, has_advisories: false, @@ -1124,6 +1125,134 @@ test('package_lock scans add a low-weight unresolved_registry_lookup signal with assert.equal(result.root.dependencies[0]?.signals[0]?.weight, 'low') }) +test('registry scans surface an aggregated warning when weekly downloads lookup falls back to unknown', async () => { + const reviewStore = new InMemoryReviewStore() + const scanPackage = createScanPackageUseCase({ + registryTraverser: new StubRegistryTraverser({ + root_key: 'root@1.0.0', + nodes: [ + { + key: 'root@1.0.0', + package: { name: 'root', version: '1.0.0' }, + metadata: createMetadata('root', '1.0.0'), + depth: 0, + parent_key: null, + path: { + packages: [{ name: 'root', version: '1.0.0' }], + }, + }, + { + key: 'child@1.0.0', + package: { name: 'child', version: '1.0.0' }, + metadata: { + ...createMetadata('child', '1.0.0'), + weekly_downloads: null, + downloads_lookup_failed: true, + }, + depth: 1, + parent_key: 'root@1.0.0', + path: { + packages: [ + { name: 'root', version: '1.0.0' }, + { name: 'child', version: '1.0.0' }, + ], + }, + }, + ], + }), + packageLockTraverser: new StubPackageLockTraverser(createLinearGraph()), + pnpmLockTraverser: new StubPnpmLockTraverser(createLinearGraph()), + scorer: new StubScorer({}), + reviewStore, + now: () => new Date('2026-04-01T00:00:00.000Z'), + }) + + const result = await scanPackage({ + scan_mode: 'registry_package', + package_spec: 'root', + max_depth: 3, + threshold: 0.4, + verbose: false, + workspace_identity: 'local', + }) + + assert.deepEqual(result.warnings, [ + { + kind: 'weekly_downloads_unavailable', + package_key: 'root@1.0.0', + package_name: 'root', + package_version: '1.0.0', + message: 'weekly downloads unavailable for one or more packages', + lockfile_resolved_url: null, + lockfile_integrity: null, + }, + ]) +}) + +test('successful metadata enrichment does not emit a weekly downloads warning', async () => { + const reviewStore = new InMemoryReviewStore() + const scanPackage = createScanPackageUseCase({ + registryTraverser: new StubRegistryTraverser(createLinearGraph()), + packageLockTraverser: new StubPackageLockTraverser(createLinearGraph()), + pnpmLockTraverser: new StubPnpmLockTraverser(createLinearGraph()), + scorer: new StubScorer({}), + reviewStore, + now: () => new Date('2026-04-01T00:00:00.000Z'), + }) + + const result = await scanPackage({ + scan_mode: 'registry_package', + package_spec: 'root', + max_depth: 3, + threshold: 0.4, + verbose: false, + workspace_identity: 'local', + }) + + assert.deepEqual(result.warnings, []) +}) + +test('synthetic project roots do not emit weekly downloads warnings for their expected null root metadata', async () => { + const reviewStore = new InMemoryReviewStore() + const scanPackage = createScanPackageUseCase({ + registryTraverser: new StubRegistryTraverser(createLinearGraph()), + packageLockTraverser: new StubPackageLockTraverser({ + root_key: 'project@1.0.0', + nodes: [ + { + key: 'project@1.0.0', + package: { name: 'project', version: '1.0.0' }, + metadata: createMetadata('project', '1.0.0'), + depth: 0, + parent_key: null, + path: { + packages: [{ name: 'project', version: '1.0.0' }], + }, + is_virtual_root: true, + }, + ], + }), + pnpmLockTraverser: new StubPnpmLockTraverser(createLinearGraph()), + scorer: new StubScorer({}), + reviewStore, + now: () => new Date('2026-04-01T00:00:00.000Z'), + }) + + const result = await scanPackage({ + scan_mode: 'package_lock', + package_lock_path: '/tmp/project/package-lock.json', + project_root: '/tmp/project', + max_depth: 3, + threshold: 0.4, + verbose: false, + workspace_identity: 'local', + }) + + assert.equal(result.root.is_project_root, true) + assert.equal(result.root.weekly_downloads, null) + assert.deepEqual(result.warnings, []) +}) + function createLinearGraph(): TraversedDependencyGraph { return { root_key: 'root@1.0.0',