From 50352daa35b2b3c0936f87a52d78724a4019e4f1 Mon Sep 17 00:00:00 2001 From: saraeloop Date: Tue, 7 Apr 2026 15:16:47 -0700 Subject: [PATCH] feat(scorer): calibrate freshness and churn interaction - adjust scoring so `new_package_age` combined with `rapid_publish_churn` does not cross review on its own - downgrade `new_package_age` weight when it is the only meaningful evidence alongside churn - preserve original high-weight freshness behavior when additional signals are present - keep mature-package freshness handling unchanged - add regression tests for: - freshness + churn-only cases - mature-package freshness cases - genuinely new low-history packages - security-related cases remaining unchanged verification: - pnpm test - pnpm run build --- README.md | 16 +++++--- src/adapters/heuristic-risk-scorer.ts | 38 ++++++++++++++++-- test/heuristic-risk-scorer.test.ts | 57 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8dd63c9..22b6a13 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,12 @@ License

-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 @@ -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 diff --git a/src/adapters/heuristic-risk-scorer.ts b/src/adapters/heuristic-risk-scorer.ts index 60c13fe..242bd0f 100644 --- a/src/adapters/heuristic-risk-scorer.ts +++ b/src/adapters/heuristic-risk-scorer.ts @@ -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, @@ -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, } } } @@ -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), + ) +} diff --git a/test/heuristic-risk-scorer.test.ts b/test/heuristic-risk-scorer.test.ts index 8766a65..ea364a4 100644 --- a/test/heuristic-risk-scorer.test.ts +++ b/test/heuristic-risk-scorer.test.ts @@ -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({ @@ -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({ @@ -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'))