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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@synsoftworks/depgraph-cli",
"version": "0.2.6",
"version": "0.2.7",
"description": "Graph-first dependency risk analysis for npm packages and dependency trees",
"type": "module",
"keywords": [
Expand Down
133 changes: 34 additions & 99 deletions src/interface/console-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ 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 signalTags = deriveSignalTags(result)
const summary = buildScanSummary(result)
const exitCode = result.suspicious_count > 0 ? 1 : 0
const showOverallRisk = shouldRenderOverallRisk(result)
Expand Down Expand Up @@ -106,20 +105,12 @@ function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element {
<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)}`}
{`${formatPresentedRiskLevel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`}
</Text>
</Box>
) : null}
</Box>

{signalTags.length > 0 ? (
<Box marginTop={1} flexWrap="wrap">
{signalTags.map((tag) => (
<StatusChip key={tag} label={tag} color="blueBright" />
))}
</Box>
) : null}

<Box marginTop={1}>
<Text color="gray">Process exited with code </Text>
<Text color={exitCode === 0 ? 'greenBright' : 'redBright'}>{String(exitCode)}</Text>
Expand Down Expand Up @@ -203,15 +194,24 @@ function TreeRow({
prefix: string
finding: ScanFinding | undefined
}): React.JSX.Element {
const emphatic = finding !== undefined || node.risk_level === 'critical'
const isRootNode = node.depth === 0
const isActiveFinding = finding !== undefined
const nameColor = isActiveFinding
? 'white'
: isRootNode
? 'white'
: node.risk_level === 'safe'
? 'gray'
: riskColor(node.risk_level)

return (
<Box alignItems="center">
<Text color={emphatic ? 'red' : 'blue'}>{prefix}</Text>
<Text bold={emphatic} color={emphatic ? 'redBright' : 'white'}>
<Text color={isActiveFinding ? 'red' : 'blue'}>{prefix}</Text>
<Text bold={isRootNode || isActiveFinding} color={nameColor}>
{node.name}
</Text>
<Text color="gray">{`@${node.version}`}</Text>
{isRootNode ? <Text color="gray">{' · scanned package'}</Text> : null}
{node.is_project_root ? <Text color="gray">{' · project root'}</Text> : null}
{node.metadata_status === 'unresolved_registry_lookup' ? (
<Text color="yellow">{' · registry metadata unavailable'}</Text>
Expand Down Expand Up @@ -263,9 +263,9 @@ function FindingPanel({
const reasons = formatFindingReasons(finding)
const findingKind = isSecurityRelatedFinding(finding) ? 'PRIORITY FINDING' : 'ROUTINE FINDING'
const details: Array<[string, string, string]> = [
['age', formatAge(node.age_days), 'redBright'],
['downloads', formatDownloads(node.weekly_downloads, node.is_security_tombstone), 'redBright'],
['versions', formatPublishedVersions(node.total_versions), 'yellowBright'],
['package age', formatAge(node.age_days), 'redBright'],
['weekly downloads', formatDownloads(node.weekly_downloads, node.is_security_tombstone), 'redBright'],
['total versions', formatPublishedVersions(node.total_versions), 'yellowBright'],
['risk score', `${finding.risk_score.toFixed(2)} (threshold: ${threshold.toFixed(2)})`, 'redBright'],
]

Expand Down Expand Up @@ -295,17 +295,20 @@ function FindingPanel({
{details.map(([label, value, color]) => (
<Box key={label}>
<Text color="redBright">{'• '}</Text>
<Text color="gray">{`${label.padEnd(11)}`}</Text>
<Text color="gray">{`${label.padEnd(16)}`}</Text>
<Text color={color}>{value}</Text>
</Box>
))}
{reasons.map((reason) => (
<Box key={reason}>
<Text color="redBright">{'• '}</Text>
<Text color="gray">{'reason'.padEnd(11)}</Text>
<Text color="yellowBright">{reason}</Text>
{reasons.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
{reasons.map((reason) => (
<Box key={reason}>
<Text color="redBright">{'• '}</Text>
<Text color="yellowBright">{reason}</Text>
</Box>
))}
</Box>
))}
) : null}
</Box>
</Box>
)
Expand All @@ -316,9 +319,9 @@ function RiskBadge({ level }: { level: RiskLevel }): React.JSX.Element {

return (
<Box marginLeft={1}>
<Text bold color={color} backgroundColor={riskBadgeBackground(level)}>
{` ${level === 'critical' ? 'suspicious' : riskLabel(level)} `}
</Text>
<Text color="gray">[</Text>
<Text bold color={color}>{formatPresentedRiskLevel(level)}</Text>
<Text color="gray">]</Text>
</Box>
)
}
Expand Down Expand Up @@ -364,20 +367,6 @@ function MetricBlock({
)
}

function StatusChip({
label,
color,
}: {
label: string
color: string
}): React.JSX.Element {
return (
<Box borderStyle="round" borderColor="blue" paddingX={1} marginRight={1}>
<Text color="blueBright">{label}</Text>
</Box>
)
}

function flattenTree(node: PackageNode, ancestors: boolean[] = []): Array<{ node: PackageNode; prefix: string }> {
const rows = [
{
Expand Down Expand Up @@ -420,58 +409,21 @@ 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 {
return formatPresentedRiskLevel(level)
}

function riskLabel(level: RiskLevel): string {
export function formatPresentedRiskLevel(level: RiskLevel): string {
switch (level) {
case 'critical':
return 'suspicious'
return 'critical'
case 'review':
return 'review'
default:
return 'safe'
}
}

function formatOverallRiskLabel(level: RiskLevel): string {
switch (level) {
case 'critical':
return 'HIGH'
case 'review':
return 'MEDIUM'
default:
return 'LOW'
}
}

function formatSignalLabel(type: string): string {
switch (type) {
case 'unresolved_registry_lookup':
return 'registry metadata unavailable'
case 'security_tombstone':
return 'security tombstone'
case 'new_and_unproven':
return 'zero provenance'
case 'new_package_age':
return 'new publisher'
case 'zero_downloads':
return 'zero downloads'
case 'deprecated_package':
return 'deprecated package'
default:
return type.replaceAll('_', ' ')
}
}

function formatAge(days: number | null): string {
if (days === null) {
return 'n/a'
Expand All @@ -493,7 +445,7 @@ function formatPublishedVersions(totalVersions: number | null): string {
return 'n/a'
}

return `${totalVersions} published`
return `${totalVersions}`
}

function formatDownloads(downloads: number | null, isSecurityTombstone: boolean): string {
Expand All @@ -512,23 +464,6 @@ export function shouldRenderOverallRisk(result: Pick<ScanResult, 'suspicious_cou
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')
}

// 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)
}

export async function renderInk(result: ScanResult): Promise<void> {
const app = render(<ScanResultView result={result} />)
await app.waitUntilExit()
Expand Down
10 changes: 8 additions & 2 deletions src/interface/plain-text-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildScanSummary,
formatEdgeFindingReason,
formatFindingReasons,
formatFindingSignalLabels,
partitionFindings,
} from './scan-output-presenter.js'

Expand Down Expand Up @@ -101,6 +102,7 @@ function renderFindings(findings: ScanResult['findings']): string[] {
lines.push(`- ${finding.key} [${finding.risk_level} ${finding.risk_score.toFixed(2)}]`)
lines.push(` Path: ${formatPath(finding.path.packages)}`)
lines.push(` Target: ${finding.review_target.target_id}`)
lines.push(` Signals: ${formatFindingSignalLabels(finding).join(', ')}`)

for (const reason of formatFindingReasons(finding)) {
lines.push(` - ${reason}`)
Expand All @@ -113,7 +115,7 @@ function renderFindings(findings: ScanResult['findings']): string[] {
function renderTree(node: PackageNode, prefix = '', isLast = true): string[] {
const connector = prefix.length === 0 ? '-' : isLast ? '└─' : '├─'
const lines = [
`${prefix}${connector} ${node.key}${formatNodeTags(node)} [${node.risk_level} ${node.risk_score.toFixed(2)}]`,
`${prefix}${connector} ${node.key}${formatNodeTags(node, prefix.length === 0)} [${node.risk_level} ${node.risk_score.toFixed(2)}]`,
]
// Prefix state is derived during traversal so tree rows stay deterministic across environments.
const childPrefix = prefix.length === 0 ? ' ' : `${prefix}${isLast ? ' ' : '│ '}`
Expand All @@ -129,9 +131,13 @@ function formatPath(packages: ScanResult['findings'][number]['path']['packages']
return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join(' > ')
}

function formatNodeTags(node: PackageNode): string {
function formatNodeTags(node: PackageNode, isRootNode: boolean): string {
const tags: string[] = []

if (isRootNode) {
tags.push('scanned package')
}

if (node.is_project_root) {
tags.push('project root')
}
Expand Down
47 changes: 47 additions & 0 deletions src/interface/scan-output-presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ export function formatFindingReasons(finding: Pick<ScanFinding, 'signals' | 'exp
return deduplicate(reasons)
}

/**
* Formats concise, stable signal labels for finding-level presentation.
*
* @param finding Finding with its precomputed signal list.
* @returns Deduplicated human-facing signal labels in original signal order.
*/
export function formatFindingSignalLabels(finding: Pick<ScanFinding, 'signals'>): string[] {
return deduplicate(
finding.signals.map((signal) => formatSignalLabel(signal.type)),
)
}

/**
* Formats a user-facing explanation for an edge finding.
*
Expand Down Expand Up @@ -240,6 +252,41 @@ function isSecurityRelatedSignal(signal: RiskSignal): boolean {
return SECURITY_MESSAGE_PATTERN.test(value)
}

function formatSignalLabel(type: string): string {
switch (type) {
case 'security_tombstone':
return 'security tombstone'
case 'security_deprecation_language':
return 'security warning'
case 'deprecated_package':
return 'deprecated package'
case 'new_and_unproven':
return 'new and unproven'
case 'new_package_age':
return 'new package age'
case 'fresh_release_on_mature_package':
return 'fresh release on mature package'
case 'low_version_history':
return 'low version history'
case 'low_weekly_downloads':
return 'low weekly downloads'
case 'zero_downloads':
return 'zero downloads'
case 'rapid_publish_churn':
return 'rapid publish churn'
case 'large_dependency_surface':
return 'large dependency surface'
case 'unresolved_registry_lookup':
return 'registry metadata unavailable'
case 'new_direct_dependency_edge':
return 'new direct dependency'
case 'new_transitive_dependency_edge':
return 'new transitive dependency'
default:
return type.replaceAll('_', ' ')
}
}

function formatSecurityDeprecationReason(signal: RiskSignal): string {
const message = extractSignalText(signal)
const cve = message.match(CVE_PATTERN)?.[0]?.toUpperCase()
Expand Down
Loading
Loading