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,7 +1,7 @@
{
"name": "devvami",
"description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
"version": "1.4.1",
"version": "1.4.2",
"author": "",
"type": "module",
"bin": {
Expand Down
77 changes: 66 additions & 11 deletions src/commands/vuln/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,20 @@ import ora from 'ora'
import chalk from 'chalk'
import { detectEcosystems, supportedEcosystemsMessage } from '../../services/audit-detector.js'
import { runAudit, summarizeFindings, filterBySeverity } from '../../services/audit-runner.js'
import { formatFindingsTable, formatScanSummary, formatMarkdownReport } from '../../formatters/vuln.js'
import { formatFindingsTable, formatScanSummary, formatMarkdownReport, truncate, colorSeverity } from '../../formatters/vuln.js'
import { getCveDetail } from '../../services/nvd.js'
import { startInteractiveTable } from '../../utils/tui/navigable-table.js'

// Minimum terminal rows required to show the interactive TUI (same threshold as vuln search)
const MIN_TTY_ROWS = 6

// Column widths for the navigable table (match the static findings table)
const COL_WIDTHS = {
pkg: 20,
version: 12,
severity: 10,
cve: 20,
}

export default class VulnScan extends Command {
static description = 'Scan the current directory for known vulnerabilities in dependencies'
Expand Down Expand Up @@ -112,7 +125,7 @@ export default class VulnScan extends Command {
errors,
}

// Write report if requested
// Write report if requested (always, regardless of TTY mode)
if (report) {
const markdown = formatMarkdownReport(result)
await writeFile(report, markdown, 'utf8')
Expand All @@ -128,24 +141,66 @@ export default class VulnScan extends Command {
return result
}

if (filteredFindings.length > 0) {
this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`))
this.log('')
this.log(formatFindingsTable(filteredFindings))
this.log('')
this.log(chalk.bold(' Summary'))
this.log(formatScanSummary(summary))
this.log('')
this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail <CVE-ID>\` for details.`))
// ── TTY interactive table ──────────────────────────────────────────────────
// In a real TTY with enough rows and at least one finding, replace the static
// table with the navigable TUI (same experience as `dvmi vuln search`).
const ttyRows = process.stdout.rows ?? 0
const useTUI = process.stdout.isTTY && filteredFindings.length > 0 && ttyRows >= MIN_TTY_ROWS

if (useTUI) {
const count = filteredFindings.length
const label = count === 1 ? 'finding' : 'findings'
const heading = `Vulnerability Scan: ${count} ${label}`

const termCols = process.stdout.columns || 80
// Title width: whatever is left after Package + Version + Severity + CVE + separators
const fixedCols = COL_WIDTHS.pkg + COL_WIDTHS.version + COL_WIDTHS.severity + COL_WIDTHS.cve
const separators = 5 * 2 // 5 gaps between 5 columns
const titleWidth = Math.max(15, Math.min(50, termCols - fixedCols - separators))

const rows = filteredFindings.map((f) => ({
id: f.cveId ?? null,
pkg: f.package,
version: f.installedVersion ?? '—',
severity: f.severity,
cve: f.cveId ?? '—',
title: truncate(f.title ?? '—', titleWidth),
advisoryUrl: f.advisoryUrl ?? null,
}))

/** @type {import('../../utils/tui/navigable-table.js').TableColumnDef[]} */
const columns = [
{ header: 'Package', key: 'pkg', width: COL_WIDTHS.pkg },
{ header: 'Version', key: 'version', width: COL_WIDTHS.version },
{ header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v) },
{ header: 'CVE', key: 'cve', width: COL_WIDTHS.cve, colorize: (v) => (v !== '—' ? chalk.cyan(v) : chalk.gray(v)) },
{ header: 'Title', key: 'title', width: titleWidth },
]

await startInteractiveTable(rows, columns, heading, filteredFindings.length, getCveDetail)
} else {
// Non-TTY fallback: static table + summary (unchanged from pre-TUI behaviour)
if (filteredFindings.length > 0) {
this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`))
this.log('')
this.log(formatFindingsTable(filteredFindings))
this.log('')
this.log(chalk.bold(' Summary'))
this.log(formatScanSummary(summary))
this.log('')
this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail <CVE-ID>\` for details.`))
}
}

// Always print audit errors (e.g. tool not installed) after findings/TUI
if (errors.length > 0) {
this.log('')
for (const err of errors) {
this.log(chalk.red(` ✘ ${err.ecosystem}: ${err.message}`))
}
}

// Preserve exit code semantics: exit 1 when vulns found (unless --no-fail)
if (filteredFindings.length > 0 && !noFail) {
this.exit(1)
}
Expand Down
10 changes: 9 additions & 1 deletion src/utils/tui/navigable-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,16 @@ export async function startInteractiveTable(rows, columns, heading, totalResults
* @returns {Promise<void>}
*/
async function openDetail() {
const cveId = state.rows[state.selectedIndex]?.id
const row = state.rows[state.selectedIndex]
const cveId = row?.id

if (!cveId) {
// No CVE ID — check for an advisory URL (e.g. npm/pnpm advisory findings).
// Open it in the browser and stay in table view rather than showing a modal.
const advisoryUrl = row?.advisoryUrl
if (advisoryUrl) {
await openBrowser(String(advisoryUrl))
}
state = { ...state, currentView: 'table' }
process.stdout.write(buildTableScreen(state))
return
Expand Down
24 changes: 24 additions & 0 deletions tests/integration/vuln-scan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ describe('dvmi vuln scan', () => {
expect(stderr).toMatch(/Expected.*severity|severity.*expected/i)
})

it('non-TTY output contains static findings table without TUI escape codes', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'dvmi-scan-'))
try {
await writeFile(join(tmpDir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n', 'utf8')

// runCli always uses isTTY=false (spawned subprocess with non-TTY stdio)
// so static table output should appear, not the interactive alt-screen TUI
const { stdout, stderr, exitCode } = await runCli(['vuln', 'scan', '--no-fail'], {
DVMI_SCAN_DIR: tmpDir,
})
const combined = stdout + stderr
// Must not contain the ANSI alt-screen sequence used by the TUI
expect(combined).not.toContain('\x1b[?1049h')
// If findings were reported, the static table header should be present
if (combined.includes('Findings')) {
expect(combined).toMatch(/Package|Version|Severity/i)
}
// Exit code must be 0 (--no-fail) or 0 (no vulns found)
expect(exitCode).toBe(0)
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
})

it('outputs valid JSON structure with --json flag when vulns exist', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'dvmi-scan-'))
try {
Expand Down
Loading