devvami 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1932,7 +1932,193 @@
1932
1932
  "tasks",
1933
1933
  "today.js"
1934
1934
  ]
1935
+ },
1936
+ "vuln:detail": {
1937
+ "aliases": [],
1938
+ "args": {
1939
+ "cveId": {
1940
+ "description": "CVE identifier (e.g. CVE-2021-44228)",
1941
+ "name": "cveId",
1942
+ "required": true
1943
+ }
1944
+ },
1945
+ "description": "View full details for a specific CVE",
1946
+ "examples": [
1947
+ "<%= config.bin %> vuln detail CVE-2021-44228",
1948
+ "<%= config.bin %> vuln detail CVE-2021-44228 --open",
1949
+ "<%= config.bin %> vuln detail CVE-2021-44228 --json"
1950
+ ],
1951
+ "flags": {
1952
+ "json": {
1953
+ "description": "Format output as json.",
1954
+ "helpGroup": "GLOBAL",
1955
+ "name": "json",
1956
+ "allowNo": false,
1957
+ "type": "boolean"
1958
+ },
1959
+ "open": {
1960
+ "char": "o",
1961
+ "description": "Open the first reference URL in the default browser",
1962
+ "name": "open",
1963
+ "allowNo": false,
1964
+ "type": "boolean"
1965
+ }
1966
+ },
1967
+ "hasDynamicHelp": false,
1968
+ "hiddenAliases": [],
1969
+ "id": "vuln:detail",
1970
+ "pluginAlias": "devvami",
1971
+ "pluginName": "devvami",
1972
+ "pluginType": "core",
1973
+ "strict": true,
1974
+ "enableJsonFlag": true,
1975
+ "isESM": true,
1976
+ "relativePath": [
1977
+ "src",
1978
+ "commands",
1979
+ "vuln",
1980
+ "detail.js"
1981
+ ]
1982
+ },
1983
+ "vuln:scan": {
1984
+ "aliases": [],
1985
+ "args": {},
1986
+ "description": "Scan the current directory for known vulnerabilities in dependencies",
1987
+ "examples": [
1988
+ "<%= config.bin %> vuln scan",
1989
+ "<%= config.bin %> vuln scan --severity high",
1990
+ "<%= config.bin %> vuln scan --no-fail",
1991
+ "<%= config.bin %> vuln scan --report vuln-report.md",
1992
+ "<%= config.bin %> vuln scan --json"
1993
+ ],
1994
+ "flags": {
1995
+ "json": {
1996
+ "description": "Format output as json.",
1997
+ "helpGroup": "GLOBAL",
1998
+ "name": "json",
1999
+ "allowNo": false,
2000
+ "type": "boolean"
2001
+ },
2002
+ "severity": {
2003
+ "char": "s",
2004
+ "description": "Minimum severity filter",
2005
+ "name": "severity",
2006
+ "hasDynamicHelp": false,
2007
+ "multiple": false,
2008
+ "options": [
2009
+ "low",
2010
+ "medium",
2011
+ "high",
2012
+ "critical"
2013
+ ],
2014
+ "type": "option"
2015
+ },
2016
+ "no-fail": {
2017
+ "description": "Exit with code 0 even when vulnerabilities are found",
2018
+ "name": "no-fail",
2019
+ "allowNo": false,
2020
+ "type": "boolean"
2021
+ },
2022
+ "report": {
2023
+ "char": "r",
2024
+ "description": "Export vulnerability report to file path (Markdown format)",
2025
+ "name": "report",
2026
+ "hasDynamicHelp": false,
2027
+ "multiple": false,
2028
+ "type": "option"
2029
+ }
2030
+ },
2031
+ "hasDynamicHelp": false,
2032
+ "hiddenAliases": [],
2033
+ "id": "vuln:scan",
2034
+ "pluginAlias": "devvami",
2035
+ "pluginName": "devvami",
2036
+ "pluginType": "core",
2037
+ "strict": true,
2038
+ "enableJsonFlag": true,
2039
+ "isESM": true,
2040
+ "relativePath": [
2041
+ "src",
2042
+ "commands",
2043
+ "vuln",
2044
+ "scan.js"
2045
+ ]
2046
+ },
2047
+ "vuln:search": {
2048
+ "aliases": [],
2049
+ "args": {
2050
+ "keyword": {
2051
+ "description": "Product, library, or keyword to search for (optional — omit to see all recent CVEs)",
2052
+ "name": "keyword",
2053
+ "required": false
2054
+ }
2055
+ },
2056
+ "description": "Search for recent CVEs by keyword (omit keyword to see all recent CVEs)",
2057
+ "examples": [
2058
+ "<%= config.bin %> vuln search openssl",
2059
+ "<%= config.bin %> vuln search openssl --days 30",
2060
+ "<%= config.bin %> vuln search log4j --severity critical",
2061
+ "<%= config.bin %> vuln search nginx --limit 10 --json",
2062
+ "<%= config.bin %> vuln search",
2063
+ "<%= config.bin %> vuln search --days 7 --severity high"
2064
+ ],
2065
+ "flags": {
2066
+ "json": {
2067
+ "description": "Format output as json.",
2068
+ "helpGroup": "GLOBAL",
2069
+ "name": "json",
2070
+ "allowNo": false,
2071
+ "type": "boolean"
2072
+ },
2073
+ "days": {
2074
+ "char": "d",
2075
+ "description": "Time window in days (search CVEs published within last N days)",
2076
+ "name": "days",
2077
+ "default": 14,
2078
+ "hasDynamicHelp": false,
2079
+ "multiple": false,
2080
+ "type": "option"
2081
+ },
2082
+ "severity": {
2083
+ "char": "s",
2084
+ "description": "Minimum severity filter",
2085
+ "name": "severity",
2086
+ "hasDynamicHelp": false,
2087
+ "multiple": false,
2088
+ "options": [
2089
+ "low",
2090
+ "medium",
2091
+ "high",
2092
+ "critical"
2093
+ ],
2094
+ "type": "option"
2095
+ },
2096
+ "limit": {
2097
+ "char": "l",
2098
+ "description": "Maximum number of results to display",
2099
+ "name": "limit",
2100
+ "default": 20,
2101
+ "hasDynamicHelp": false,
2102
+ "multiple": false,
2103
+ "type": "option"
2104
+ }
2105
+ },
2106
+ "hasDynamicHelp": false,
2107
+ "hiddenAliases": [],
2108
+ "id": "vuln:search",
2109
+ "pluginAlias": "devvami",
2110
+ "pluginName": "devvami",
2111
+ "pluginType": "core",
2112
+ "strict": true,
2113
+ "enableJsonFlag": true,
2114
+ "isESM": true,
2115
+ "relativePath": [
2116
+ "src",
2117
+ "commands",
2118
+ "vuln",
2119
+ "search.js"
2120
+ ]
1935
2121
  }
1936
2122
  },
1937
- "version": "1.4.0"
2123
+ "version": "1.4.1"
1938
2124
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "devvami",
3
3
  "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -0,0 +1,65 @@
1
+ import { Command, Args, Flags } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import { getCveDetail } from '../../services/nvd.js'
4
+ import { formatCveDetail } from '../../formatters/vuln.js'
5
+ import { openBrowser } from '../../utils/open-browser.js'
6
+ import { ValidationError } from '../../utils/errors.js'
7
+
8
+ export default class VulnDetail extends Command {
9
+ static description = 'View full details for a specific CVE'
10
+
11
+ static examples = [
12
+ '<%= config.bin %> vuln detail CVE-2021-44228',
13
+ '<%= config.bin %> vuln detail CVE-2021-44228 --open',
14
+ '<%= config.bin %> vuln detail CVE-2021-44228 --json',
15
+ ]
16
+
17
+ static enableJsonFlag = true
18
+
19
+ static args = {
20
+ cveId: Args.string({ description: 'CVE identifier (e.g. CVE-2021-44228)', required: true }),
21
+ }
22
+
23
+ static flags = {
24
+ open: Flags.boolean({
25
+ char: 'o',
26
+ description: 'Open the first reference URL in the default browser',
27
+ default: false,
28
+ }),
29
+ }
30
+
31
+ async run() {
32
+ const { args, flags } = await this.parse(VulnDetail)
33
+ const isJson = flags.json
34
+ const { cveId } = args
35
+
36
+ if (!cveId || !/^CVE-\d{4}-\d{4,}$/i.test(cveId)) {
37
+ throw new ValidationError(
38
+ `Invalid CVE ID: ${cveId}`,
39
+ 'CVE IDs must match the format CVE-YYYY-NNNNN (e.g. CVE-2021-44228)',
40
+ )
41
+ }
42
+
43
+ const spinner = isJson ? null : ora(`Fetching ${cveId.toUpperCase()}...`).start()
44
+
45
+ try {
46
+ const detail = await getCveDetail(cveId)
47
+ spinner?.stop()
48
+
49
+ if (isJson) return detail
50
+
51
+ this.log(formatCveDetail(detail))
52
+
53
+ if (flags.open && detail.references.length > 0) {
54
+ const firstUrl = detail.references[0].url
55
+ this.log(`\nOpening ${firstUrl} ...`)
56
+ await openBrowser(firstUrl)
57
+ }
58
+
59
+ return detail
60
+ } catch (err) {
61
+ spinner?.stop()
62
+ throw err
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,155 @@
1
+ import { Command, Flags } from '@oclif/core'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import ora from 'ora'
4
+ import chalk from 'chalk'
5
+ import { detectEcosystems, supportedEcosystemsMessage } from '../../services/audit-detector.js'
6
+ import { runAudit, summarizeFindings, filterBySeverity } from '../../services/audit-runner.js'
7
+ import { formatFindingsTable, formatScanSummary, formatMarkdownReport } from '../../formatters/vuln.js'
8
+
9
+ export default class VulnScan extends Command {
10
+ static description = 'Scan the current directory for known vulnerabilities in dependencies'
11
+
12
+ static examples = [
13
+ '<%= config.bin %> vuln scan',
14
+ '<%= config.bin %> vuln scan --severity high',
15
+ '<%= config.bin %> vuln scan --no-fail',
16
+ '<%= config.bin %> vuln scan --report vuln-report.md',
17
+ '<%= config.bin %> vuln scan --json',
18
+ ]
19
+
20
+ static enableJsonFlag = true
21
+
22
+ static flags = {
23
+ severity: Flags.string({
24
+ char: 's',
25
+ description: 'Minimum severity filter',
26
+ options: ['low', 'medium', 'high', 'critical'],
27
+ }),
28
+ 'no-fail': Flags.boolean({
29
+ description: 'Exit with code 0 even when vulnerabilities are found',
30
+ default: false,
31
+ }),
32
+ report: Flags.string({
33
+ char: 'r',
34
+ description: 'Export vulnerability report to file path (Markdown format)',
35
+ }),
36
+ }
37
+
38
+ async run() {
39
+ const { flags } = await this.parse(VulnScan)
40
+ const isJson = flags.json
41
+ const { severity, 'no-fail': noFail, report } = flags
42
+
43
+ const projectPath = process.env.DVMI_SCAN_DIR ?? process.cwd()
44
+ const scanDate = new Date().toISOString()
45
+
46
+ // Detect ecosystems
47
+ const ecosystems = detectEcosystems(projectPath)
48
+
49
+ if (ecosystems.length === 0) {
50
+ if (isJson) {
51
+ return {
52
+ projectPath,
53
+ scanDate,
54
+ ecosystems: [],
55
+ findings: [],
56
+ summary: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0, total: 0 },
57
+ errors: [{ ecosystem: 'none', message: 'No supported package manager detected.' }],
58
+ }
59
+ }
60
+
61
+ this.log(chalk.red(' ✘ No supported package manager detected.'))
62
+ this.log('')
63
+ this.log(' Supported ecosystems:')
64
+ this.log(supportedEcosystemsMessage())
65
+ this.log('')
66
+ this.log(' Tip: Make sure you have a lock file in the current directory.')
67
+ this.exit(2)
68
+ return
69
+ }
70
+
71
+ // Display detected ecosystems
72
+ if (!isJson) {
73
+ this.log(chalk.bold('Vulnerability Scan'))
74
+ this.log('')
75
+ this.log(' Detected ecosystems:')
76
+ for (const eco of ecosystems) {
77
+ this.log(` ${chalk.green('●')} ${eco.name} (${eco.lockFile})`)
78
+ }
79
+ this.log('')
80
+ }
81
+
82
+ // Run audits
83
+ const allFindings = []
84
+ const errors = []
85
+
86
+ for (const eco of ecosystems) {
87
+ const spinner = isJson ? null : ora(` Scanning ${eco.name} dependencies...`).start()
88
+
89
+ const { findings, error } = await runAudit(eco)
90
+
91
+ if (error) {
92
+ spinner?.fail(` Scanning ${eco.name} dependencies... failed`)
93
+ errors.push({ ecosystem: eco.name, message: error })
94
+ } else {
95
+ spinner?.succeed(` Scanning ${eco.name} dependencies... done`)
96
+ allFindings.push(...findings)
97
+ }
98
+ }
99
+
100
+ // Apply severity filter
101
+ const filteredFindings = filterBySeverity(allFindings, severity)
102
+
103
+ // Build summary
104
+ const summary = summarizeFindings(filteredFindings)
105
+
106
+ const result = {
107
+ projectPath,
108
+ scanDate,
109
+ ecosystems,
110
+ findings: filteredFindings,
111
+ summary,
112
+ errors,
113
+ }
114
+
115
+ // Write report if requested
116
+ if (report) {
117
+ const markdown = formatMarkdownReport(result)
118
+ await writeFile(report, markdown, 'utf8')
119
+ if (!isJson) this.log(`\n Report saved to: ${report}`)
120
+ }
121
+
122
+ if (isJson) return result
123
+
124
+ this.log('')
125
+
126
+ if (filteredFindings.length === 0 && errors.length === 0) {
127
+ this.log(chalk.green(' ✔ No known vulnerabilities found.'))
128
+ return result
129
+ }
130
+
131
+ if (filteredFindings.length > 0) {
132
+ this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`))
133
+ this.log('')
134
+ this.log(formatFindingsTable(filteredFindings))
135
+ this.log('')
136
+ this.log(chalk.bold(' Summary'))
137
+ this.log(formatScanSummary(summary))
138
+ this.log('')
139
+ this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail <CVE-ID>\` for details.`))
140
+ }
141
+
142
+ if (errors.length > 0) {
143
+ this.log('')
144
+ for (const err of errors) {
145
+ this.log(chalk.red(` ✘ ${err.ecosystem}: ${err.message}`))
146
+ }
147
+ }
148
+
149
+ if (filteredFindings.length > 0 && !noFail) {
150
+ this.exit(1)
151
+ }
152
+
153
+ return result
154
+ }
155
+ }
@@ -0,0 +1,128 @@
1
+ import { Command, Args, Flags } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import chalk from 'chalk'
4
+ import { searchCves, getCveDetail } from '../../services/nvd.js'
5
+ import { formatCveSearchTable, colorSeverity, formatScore, formatDate, truncate } from '../../formatters/vuln.js'
6
+ import { startInteractiveTable } from '../../utils/tui/navigable-table.js'
7
+ import { ValidationError } from '../../utils/errors.js'
8
+
9
+ // Minimum terminal rows required to show the interactive TUI
10
+ const MIN_TTY_ROWS = 6
11
+
12
+ // Column widths for the navigable table
13
+ const COL_WIDTHS = {
14
+ id: 20,
15
+ severity: 10,
16
+ score: 5,
17
+ published: 10,
18
+ reference: 30,
19
+ }
20
+
21
+ export default class VulnSearch extends Command {
22
+ static description = 'Search for recent CVEs by keyword (omit keyword to see all recent CVEs)'
23
+
24
+ static examples = [
25
+ '<%= config.bin %> vuln search openssl',
26
+ '<%= config.bin %> vuln search openssl --days 30',
27
+ '<%= config.bin %> vuln search log4j --severity critical',
28
+ '<%= config.bin %> vuln search nginx --limit 10 --json',
29
+ '<%= config.bin %> vuln search',
30
+ '<%= config.bin %> vuln search --days 7 --severity high',
31
+ ]
32
+
33
+ static enableJsonFlag = true
34
+
35
+ static args = {
36
+ keyword: Args.string({ description: 'Product, library, or keyword to search for (optional — omit to see all recent CVEs)', required: false }),
37
+ }
38
+
39
+ static flags = {
40
+ days: Flags.integer({
41
+ char: 'd',
42
+ description: 'Time window in days (search CVEs published within last N days)',
43
+ default: 14,
44
+ }),
45
+ severity: Flags.string({
46
+ char: 's',
47
+ description: 'Minimum severity filter',
48
+ options: ['low', 'medium', 'high', 'critical'],
49
+ }),
50
+ limit: Flags.integer({
51
+ char: 'l',
52
+ description: 'Maximum number of results to display',
53
+ default: 20,
54
+ }),
55
+ }
56
+
57
+ async run() {
58
+ const { args, flags } = await this.parse(VulnSearch)
59
+ const isJson = flags.json
60
+
61
+ const { keyword } = args
62
+ const { days, severity, limit } = flags
63
+
64
+ if (days < 1 || days > 120) {
65
+ throw new ValidationError(
66
+ `--days must be between 1 and 120, got ${days}`,
67
+ 'The NVD API supports a maximum 120-day date range per request.',
68
+ )
69
+ }
70
+
71
+ if (limit < 1 || limit > 2000) {
72
+ throw new ValidationError(
73
+ `--limit must be between 1 and 2000, got ${limit}`,
74
+ 'The NVD API returns at most 2000 results per page.',
75
+ )
76
+ }
77
+
78
+ const spinner = isJson ? null : ora(keyword ? `Searching NVD for "${keyword}"...` : `Fetching recent CVEs (last ${days} days)...`).start()
79
+
80
+ try {
81
+ const { results, totalResults } = await searchCves({ keyword, days, severity, limit })
82
+ spinner?.stop()
83
+
84
+ const result = { keyword: keyword ?? null, days, severity: severity ?? null, totalResults, results }
85
+
86
+ if (isJson) return result
87
+
88
+ this.log(formatCveSearchTable(results, keyword, days, totalResults))
89
+
90
+ // Interactive navigable table — only in a real TTY with enough rows, skipped in CI / --json / piped output
91
+ const ttyRows = process.stdout.rows ?? 0
92
+ if (process.stdout.isTTY && results.length > 0 && ttyRows >= MIN_TTY_ROWS) {
93
+ const heading = keyword
94
+ ? `CVE Search: "${keyword}" (last ${days} days)`
95
+ : `CVE Search: all recent (last ${days} days)`
96
+
97
+ const termCols = process.stdout.columns || 80
98
+ const descWidth = Math.max(20, Math.min(60, termCols - 84))
99
+
100
+ const rows = results.map((r) => ({
101
+ id: r.id,
102
+ severity: r.severity,
103
+ score: formatScore(r.score),
104
+ published: formatDate(r.publishedDate),
105
+ description: truncate(r.description, descWidth),
106
+ reference: r.firstReference ? truncate(r.firstReference, COL_WIDTHS.reference) : '—',
107
+ }))
108
+
109
+ /** @type {import('../../utils/tui/navigable-table.js').TableColumnDef[]} */
110
+ const columns = [
111
+ { header: 'CVE ID', key: 'id', width: COL_WIDTHS.id, colorize: (v) => chalk.cyan(v) },
112
+ { header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v) },
113
+ { header: 'Score', key: 'score', width: COL_WIDTHS.score },
114
+ { header: 'Published', key: 'published', width: COL_WIDTHS.published },
115
+ { header: 'Description', key: 'description', width: descWidth },
116
+ { header: 'Reference', key: 'reference', width: COL_WIDTHS.reference },
117
+ ]
118
+
119
+ await startInteractiveTable(rows, columns, heading, totalResults, getCveDetail)
120
+ }
121
+
122
+ return result
123
+ } catch (err) {
124
+ spinner?.stop()
125
+ throw err
126
+ }
127
+ }
128
+ }