Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
<a href="https://github.com/synsoftworks/depgraph-cli/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/synsoftworks/depgraph-cli?style=flat-square"></a>
</p>

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.

Expand Down Expand Up @@ -72,7 +69,7 @@ Append a review outcome to a stored scan finding:
depgraph review <record_id> --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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
29 changes: 22 additions & 7 deletions src/adapters/npm-package-metadata-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PackageMetadata> {
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]
Expand All @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -221,21 +227,30 @@ export class NpmPackageMetadataSource implements PackageMetadataSource {
)
}

private async fetchWeeklyDownloads(name: string): Promise<number | null> {
private async fetchWeeklyDownloads(name: string): Promise<WeeklyDownloadsResult> {
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,
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions src/application/scan-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function createScanPackageUseCase({
const nodeMap = new Map<string, PackageNode>()
const pendingFindings: PendingScanFinding[] = []
const warnings: ScanWarning[] = []
let hasDownloadsLookupFallback = false
let overallRiskScore = 0

for (const traversedNode of traversedGraph.nodes) {
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/domain/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions test/npm-package-metadata-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
22 changes: 22 additions & 0 deletions test/plain-text-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading