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'))