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
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
<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 is a supply chain security tool that lives in your terminal. It scans npm packages and their dependencies for attack signals and tells you why something looks suspicious.
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.

Run it before every install. Use the JSON output in CI. Agent friendly.
Run it before every install. Use the JSON output in CI. Built for agents.

## Get Started

Expand Down Expand Up @@ -179,9 +182,12 @@ This history powers baseline diffing and the `depgraph eval` dataset readiness r

## Status

DepGraph is pre-v1 and under active development. Core scanning works.
Some dependency types degrade gracefully rather than fully enriching.
See the roadmap for what's coming.
Core scanning is stable. Registry package scanning, lockfile scanning
(npm and pnpm), baseline diffing, and CI integration all work reliably
today. Some dependency types — private packages, workspace references,
local file links — degrade gracefully rather than failing.

Pre-v1. Interfaces may change before 1.0.

## Roadmap

Expand Down
38 changes: 35 additions & 3 deletions src/adapters/heuristic-risk-scorer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PackageMetadata, RiskAssessment } from '../domain/contracts.js'
import type { RiskSignal } from '../domain/entities.js'
import type { RiskSignal, RiskSignalWeight } from '../domain/entities.js'
import type { RiskScorer, RiskScorerContext } from '../domain/ports.js'
import {
calculateAgeDays,
Expand Down Expand Up @@ -125,14 +125,15 @@ export class HeuristicRiskScorer implements RiskScorer {
})
}

const riskScore = riskScoreForSignals(signals)
const calibratedSignals = calibrateFreshnessSignals(signals)
const riskScore = riskScoreForSignals(calibratedSignals)
const riskLevel = riskLevelForScore(riskScore)

return {
risk_score: riskScore,
risk_level: riskLevel,
recommendation: recommendationForRiskLevel(riskLevel),
signals,
signals: calibratedSignals,
}
}
}
Expand All @@ -144,3 +145,34 @@ function isFreshReleaseOnMaturePackage(metadata: PackageMetadata): boolean {
metadata.weekly_downloads >= MATURE_PACKAGE_DOWNLOAD_THRESHOLD
)
}

function calibrateFreshnessSignals(signals: RiskSignal[]): RiskSignal[] {
const hasNewPackageAge = signals.some((signal) => signal.type === 'new_package_age')
const hasRapidPublishChurn = signals.some((signal) => signal.type === 'rapid_publish_churn')

if (!hasNewPackageAge || !hasRapidPublishChurn || hasStrongerThanFreshnessConcern(signals)) {
return signals
}

// Freshness and churn stay visible, but this pair alone should not cross review without stronger corroborating evidence.
return signals.map((signal) =>
signal.type === 'new_package_age'
? {
...signal,
weight: 'medium' satisfies RiskSignalWeight,
}
: signal,
)
}

function hasStrongerThanFreshnessConcern(signals: RiskSignal[]): boolean {
return signals.some(
(signal) =>
![
'new_package_age',
'fresh_release_on_mature_package',
'rapid_publish_churn',
'large_dependency_surface',
].includes(signal.type),
)
}
57 changes: 57 additions & 0 deletions test/heuristic-risk-scorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,35 @@ test('heuristic scorer dampens freshness for mature high-download packages', ()
assert.ok(result.signals.some((signal) => signal.type === 'rapid_publish_churn'))
})

test('heuristic scorer keeps freshness and churn informational for established packages without stronger concerns', () => {
const scorer = new HeuristicRiskScorer(() => NOW)
const metadata = createMetadata({
package: {
name: 'path-to-regexp',
version: '8.4.2',
},
published_at: '2026-03-27T00:00:00.000Z',
first_published_at: '2014-01-01T00:00:00.000Z',
last_published_at: '2026-03-27T00:00:00.000Z',
total_versions: 42,
weekly_downloads: 50_000,
publish_events_last_30_days: 4,
})

const result = scorer.assessPackage(metadata, {
depth: 0,
path: {
packages: [{ name: 'path-to-regexp', version: '8.4.2' }],
},
dependency_count: 0,
})

assert.equal(result.risk_level, 'safe')
assert.equal(result.risk_score, 0.32)
assert.equal(result.signals.find((signal) => signal.type === 'new_package_age')?.weight, 'medium')
assert.ok(result.signals.some((signal) => signal.type === 'rapid_publish_churn'))
})

test('heuristic scorer keeps the existing freshness signal for genuinely new packages', () => {
const scorer = new HeuristicRiskScorer(() => NOW)
const metadata = createMetadata({
Expand All @@ -135,6 +164,33 @@ test('heuristic scorer keeps the existing freshness signal for genuinely new pac
assert.ok(!result.signals.some((signal) => signal.type === 'fresh_release_on_mature_package'))
})

test('heuristic scorer still escalates low-history packages when freshness is supported by stronger concerns', () => {
const scorer = new HeuristicRiskScorer(() => NOW)
const metadata = createMetadata({
published_at: '2026-03-31T00:00:00.000Z',
total_versions: 2,
weekly_downloads: 5_000,
publish_events_last_30_days: 4,
})

const result = scorer.assessPackage(metadata, {
depth: 1,
path: {
packages: [
{ name: 'root', version: '1.0.0' },
{ name: 'risky-package', version: '1.0.0' },
],
},
dependency_count: 0,
})

assert.equal(result.risk_level, 'review')
assert.equal(result.risk_score, 0.64)
assert.equal(result.signals.find((signal) => signal.type === 'new_package_age')?.weight, 'high')
assert.ok(result.signals.some((signal) => signal.type === 'low_version_history'))
assert.ok(result.signals.some((signal) => signal.type === 'rapid_publish_churn'))
})

test('heuristic scorer does not dampen freshness when weekly downloads are unknown', () => {
const scorer = new HeuristicRiskScorer(() => NOW)
const metadata = createMetadata({
Expand Down Expand Up @@ -206,6 +262,7 @@ test('heuristic scorer escalates deprecations with security language to review',
})

assert.equal(result.risk_level, 'review')
assert.equal(result.risk_score, 0.48)
assert.ok(result.risk_score >= 0.4)
assert.ok(result.signals.some((signal) => signal.type === 'deprecated_package'))
assert.ok(result.signals.some((signal) => signal.type === 'security_deprecation_language'))
Expand Down
Loading