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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npm install -g @synsoftworks/depgraph-cli
Run without installing:

```bash
npx -p @synsoftworks/depgraph-cli depgraph --help
npx @synsoftworks/depgraph-cli scan axios
```

## Quick Start
Expand Down
33 changes: 27 additions & 6 deletions src/adapters/heuristic-risk-scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
riskLevelForScore,
} from '../domain/value-objects.js'

const MATURE_PACKAGE_VERSION_THRESHOLD = 100
const MATURE_PACKAGE_DOWNLOAD_THRESHOLD = 100_000

export class HeuristicRiskScorer implements RiskScorer {
constructor(private readonly now: () => Date = () => new Date()) {}

Expand All @@ -18,12 +21,22 @@ export class HeuristicRiskScorer implements RiskScorer {
const dependencyCount = context.dependency_count

if (ageDays <= 7) {
signals.push({
type: 'new_package_age',
value: ageDays,
weight: 'high',
reason: `package was published ${ageDays} day(s) ago`,
})
if (isFreshReleaseOnMaturePackage(metadata)) {
// Mature packages still get a freshness signal, but strong adoption and long version history dampen the default suspicion.
signals.push({
type: 'fresh_release_on_mature_package',
value: ageDays,
weight: 'low',
reason: `package was published ${ageDays} day(s) ago on a mature, high-traffic package`,
})
} else {
signals.push({
type: 'new_package_age',
value: ageDays,
weight: 'high',
reason: `package was published ${ageDays} day(s) ago`,
})
}
}

if (metadata.total_versions <= 2) {
Expand Down Expand Up @@ -123,3 +136,11 @@ export class HeuristicRiskScorer implements RiskScorer {
}
}
}

function isFreshReleaseOnMaturePackage(metadata: PackageMetadata): boolean {
return (
metadata.total_versions >= MATURE_PACKAGE_VERSION_THRESHOLD &&
metadata.weekly_downloads !== null &&
metadata.weekly_downloads >= MATURE_PACKAGE_DOWNLOAD_THRESHOLD
)
}
83 changes: 44 additions & 39 deletions src/interface/console-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'
import { Box, Text, render, useApp } from 'ink'

import type { EdgeFinding } from '../domain/contracts.js'
import type { PackageNode, RiskLevel, RiskSignal, ScanFinding, ScanResult } from '../domain/entities.js'
import type { PackageNode, RiskLevel, ScanFinding, ScanResult } from '../domain/entities.js'
import {
buildScanSummary,
formatEdgeFindingReason,
Expand All @@ -27,10 +27,10 @@ function AutoExit(): React.JSX.Element | null {

function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element {
const findingsByKey = new Map(result.findings.map((finding) => [finding.key, finding]))
const allSignals = collectSignals(result.root)
const signalTags = deriveSignalTags(result, allSignals)
const signalTags = deriveSignalTags(result)
const summary = buildScanSummary(result)
const exitCode = result.suspicious_count > 0 ? 1 : 0
const showOverallRisk = shouldRenderOverallRisk(result)

return (
<Box flexDirection="column" paddingX={1}>
Expand Down Expand Up @@ -91,7 +91,7 @@ function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element {

<Text color="gray">{DIVIDER}</Text>

<Box marginTop={1} justifyContent="space-between">
<Box marginTop={1} justifyContent={showOverallRisk ? 'space-between' : 'flex-start'}>
<Box>
<MetricBlock value={String(result.total_scanned)} label="PACKAGES SCANNED" color="greenBright" />
<MetricBlock
Expand All @@ -101,13 +101,15 @@ function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element {
/>
<MetricBlock value={String(result.safe_count)} label="SAFE" color="greenBright" />
</Box>
<Box flexDirection="column" width={42}>
<Text color="gray">OVERALL RISK</Text>
<RiskBar score={result.overall_risk_score} level={result.overall_risk_level} />
<Text color={riskColor(result.overall_risk_level)}>
{`${formatOverallRiskLabel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`}
</Text>
</Box>
{showOverallRisk ? (
<Box flexDirection="column" width={42}>
<Text color="gray">OVERALL RISK</Text>
<RiskBar score={result.overall_risk_score} />
<Text color={riskColor(result.overall_risk_level)}>
{`${formatOverallRiskLabel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`}
</Text>
</Box>
) : null}
</Box>

{signalTags.length > 0 ? (
Expand Down Expand Up @@ -204,7 +206,7 @@ function TreeRow({
const emphatic = finding !== undefined || node.risk_level === 'critical'

return (
<Box>
<Box alignItems="center">
<Text color={emphatic ? 'red' : 'blue'}>{prefix}</Text>
<Text bold={emphatic} color={emphatic ? 'redBright' : 'white'}>
{node.name}
Expand Down Expand Up @@ -313,25 +315,18 @@ function RiskBadge({ level }: { level: RiskLevel }): React.JSX.Element {
const color = riskColor(level)

return (
<Box
borderStyle="round"
borderColor={color}
paddingX={1}
marginLeft={1}
>
<Text color={color}>
{level === 'critical' ? ' suspicious ' : ` ${riskLabel(level)} `}
<Box marginLeft={1}>
<Text bold color={color} backgroundColor={riskBadgeBackground(level)}>
{` ${level === 'critical' ? 'suspicious' : riskLabel(level)} `}
</Text>
</Box>
)
}

function RiskBar({
score,
level,
}: {
score: number
level: RiskLevel
}): React.JSX.Element {
const total = 28
const filled = Math.max(0, Math.min(total, Math.round(score * total)))
Expand All @@ -341,12 +336,12 @@ function RiskBar({
const high = Math.max(filled - low - medium, 0)

return (
<Text>
<Text color="greenBright">{'█'.repeat(low)}</Text>
<Text color="yellowBright">{'█'.repeat(medium)}</Text>
<Text color="redBright">{'█'.repeat(high)}</Text>
<Text color="#2c3243">{'█'.repeat(empty)}</Text>
</Text>
<Box>
{low > 0 ? <Text color="greenBright">{'█'.repeat(low)}</Text> : null}
{medium > 0 ? <Text color="yellowBright">{'█'.repeat(medium)}</Text> : null}
{high > 0 ? <Text color="redBright">{'█'.repeat(high)}</Text> : null}
{empty > 0 ? <Text color="#2c3243">{'█'.repeat(empty)}</Text> : null}
</Box>
)
}

Expand Down Expand Up @@ -414,14 +409,6 @@ function buildTreePrefix(ancestors: boolean[]): string {
return `${branchPrefix}${connector}`
}

function collectSignals(node: PackageNode): RiskSignal[] {
return [node.signals, ...node.dependencies.map(collectSignals)].flat()
}

function getMaxDepth(node: PackageNode): number {
return Math.max(node.depth, ...node.dependencies.map(getMaxDepth), 0)
}

function riskColor(level: RiskLevel): string {
switch (level) {
case 'critical':
Expand All @@ -433,6 +420,17 @@ function riskColor(level: RiskLevel): string {
}
}

function riskBadgeBackground(level: RiskLevel): string {
switch (level) {
case 'critical':
return '#3a1717'
case 'review':
return '#3b2c10'
default:
return '#173420'
}
}

function riskLabel(level: RiskLevel): string {
switch (level) {
case 'critical':
Expand Down Expand Up @@ -510,15 +508,22 @@ function formatDownloads(downloads: number | null, isSecurityTombstone: boolean)
return `${downloads.toLocaleString()} / week`
}

function deriveSignalTags(result: ScanResult, signals: RiskSignal[]): string[] {
export function shouldRenderOverallRisk(result: Pick<ScanResult, 'suspicious_count'>): boolean {
return result.suspicious_count > 0
}

export function deriveSignalTags(result: Pick<ScanResult, 'findings'>): string[] {
const tags = new Set<string>()

if (result.findings.some((finding) => finding.depth === 1)) {
tags.add('depth-1 threat')
}

for (const signal of signals) {
tags.add(formatSignalLabel(signal.type))
// Footer tags should only summarize visible findings, not low-weight signals from otherwise safe packages.
for (const finding of result.findings) {
for (const signal of finding.signals) {
tags.add(formatSignalLabel(signal.type))
}
}

return Array.from(tags).slice(0, 6)
Expand Down
4 changes: 3 additions & 1 deletion src/interface/json-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ScanResult } from '../domain/entities.js'

export function renderJson(result: ScanResult): string {
return JSON.stringify(result, null, 2)
const { field_reliability: _fieldReliability, ...publicResult } = result

return JSON.stringify(publicResult, null, 2)
}
35 changes: 35 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { FailureSurfacingSummary } from '../src/domain/failure-surfacing.js
import { createFieldReliabilityReport } from '../src/domain/field-reliability-policy.js'
import { NetworkFailureError } from '../src/domain/errors.js'
import type { ScanResult } from '../src/domain/entities.js'
import { renderJson as renderScanJson } from '../src/interface/json-renderer.js'

class MemoryStream {
buffer = ''
Expand Down Expand Up @@ -357,6 +358,40 @@ test('CLI maps network failures to exit code 3', async () => {
assert.match(stderr.buffer, /registry down/)
})

test('CLI scan --json omits field reliability metadata from public output', async () => {
const stdout = new MemoryStream()
const stderr = new MemoryStream()

const exitCode = await run(['scan', 'root', '--json'], {
scanPackage: async () => createResult(),
resolveProjectScan,
reviewScan: async () => createReviewEvent(),
evaluateFailures: async () => createFailureSurfacingSummary(),
evaluateScans: async () => createEvaluationSummary(),
renderJson: (result) => renderScanJson(result),
renderSummaryText: () => '',
renderPlainText: () => '',
renderReviewJson: () => '',
renderReviewPlainText: () => '',
renderFailureJson: () => '',
renderFailurePlainText: () => '',
renderEvaluationJson: () => '',
renderEvaluationPlainText: () => '',
renderInk: async () => {},
stdout,
stderr,
isTty: true,
})

const parsed = JSON.parse(stdout.buffer)

assert.equal(exitCode, 0)
assert.equal(parsed.field_reliability, undefined)
assert.equal(parsed.record_id, '2026-04-01T00:00:00.000Z:root@1.0.0:depth=3')
assert.equal(parsed.root.key, 'root@1.0.0')
assert.equal(stderr.buffer, '')
})

test('CLI review command forwards explicit target ids deterministically', async () => {
const stdout = new MemoryStream()
const stderr = new MemoryStream()
Expand Down
68 changes: 68 additions & 0 deletions test/console-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import type { ScanFinding } from '../src/domain/entities.js'
import { deriveSignalTags, shouldRenderOverallRisk } from '../src/interface/console-renderer.js'

function createFinding(
overrides: Partial<ScanFinding> = {},
): ScanFinding {
return {
key: 'pkg@1.0.0',
name: 'pkg',
version: '1.0.0',
depth: 2,
review_target: {
kind: 'package_finding',
record_id: 'record-1',
target_id: 'package_finding:pkg@1.0.0',
finding_key: 'package_finding:pkg@1.0.0',
package_key: 'pkg@1.0.0',
},
path: {
packages: [
{ name: 'root', version: '1.0.0' },
{ name: 'pkg', version: '1.0.0' },
],
},
risk_score: 0.48,
risk_level: 'review',
recommendation: 'review',
signals: [],
explanation: 'test finding',
...overrides,
}
}

test('TUI signal tags only summarize surfaced findings', () => {
const tags = deriveSignalTags({
findings: [],
})

assert.deepEqual(tags, [])
})

test('TUI signal tags are derived from finding signals and keep depth-1 context', () => {
const tags = deriveSignalTags({
findings: [
createFinding({
depth: 1,
signals: [
{
type: 'rapid_publish_churn',
value: 8,
weight: 'medium',
reason: '8 version publish events happened in the last 30 days',
},
],
}),
],
})

assert.deepEqual(tags, ['depth-1 threat', 'rapid publish churn'])
})

test('TUI overall risk section is hidden when no findings exceeded threshold', () => {
assert.equal(shouldRenderOverallRisk({ suspicious_count: 0 }), false)
assert.equal(shouldRenderOverallRisk({ suspicious_count: 1 }), true)
})
Loading
Loading