devvami 1.4.0 → 1.4.2
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.
- package/oclif.manifest.json +275 -89
- package/package.json +1 -1
- package/src/commands/vuln/detail.js +65 -0
- package/src/commands/vuln/scan.js +210 -0
- package/src/commands/vuln/search.js +128 -0
- package/src/formatters/vuln.js +317 -0
- package/src/help.js +15 -0
- package/src/services/audit-detector.js +120 -0
- package/src/services/audit-runner.js +365 -0
- package/src/services/nvd.js +245 -0
- package/src/types.js +9 -5
- package/src/utils/errors.js +2 -0
- package/src/utils/tui/modal.js +224 -0
- package/src/utils/tui/navigable-table.js +504 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { renderTable } from './table.js'
|
|
3
|
+
import { NVD_ATTRIBUTION } from '../services/nvd.js'
|
|
4
|
+
|
|
5
|
+
/** @import { CveSearchResult, CveDetail, VulnerabilityFinding, ScanResult } from '../types.js' */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Colorize a severity string for terminal output.
|
|
9
|
+
* @param {string} severity
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function colorSeverity(severity) {
|
|
13
|
+
switch (severity) {
|
|
14
|
+
case 'Critical': return chalk.red.bold(severity)
|
|
15
|
+
case 'High': return chalk.red(severity)
|
|
16
|
+
case 'Medium': return chalk.yellow(severity)
|
|
17
|
+
case 'Low': return chalk.blue(severity)
|
|
18
|
+
default: return chalk.gray(severity)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format a CVE score as a fixed-precision string or "N/A".
|
|
24
|
+
* @param {number|null} score
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function formatScore(score) {
|
|
28
|
+
if (score === null || score === undefined) return 'N/A'
|
|
29
|
+
return score.toFixed(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format an ISO-8601 date string as YYYY-MM-DD.
|
|
34
|
+
* @param {string} iso
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function formatDate(iso) {
|
|
38
|
+
if (!iso) return ''
|
|
39
|
+
return iso.slice(0, 10)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Truncate a string to max length, appending … if needed.
|
|
44
|
+
* @param {string} str
|
|
45
|
+
* @param {number} max
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function truncate(str, max) {
|
|
49
|
+
if (!str) return ''
|
|
50
|
+
if (str.length <= max) return str
|
|
51
|
+
return str.slice(0, max - 1) + '…'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Format a list of CVE search results as a terminal table string.
|
|
56
|
+
* @param {CveSearchResult[]} results
|
|
57
|
+
* @param {string|null|undefined} keyword
|
|
58
|
+
* @param {number} days
|
|
59
|
+
* @param {number} totalResults
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function formatCveSearchTable(results, keyword, days, totalResults) {
|
|
63
|
+
const lines = []
|
|
64
|
+
const heading = keyword
|
|
65
|
+
? `CVE Search: "${keyword}" (last ${days} days)`
|
|
66
|
+
: `CVE Search: all recent (last ${days} days)`
|
|
67
|
+
lines.push(chalk.bold(`${heading}\n`))
|
|
68
|
+
|
|
69
|
+
if (results.length === 0) {
|
|
70
|
+
lines.push(keyword ? ' No CVEs found for this search.' : ' No CVEs published in this time window.')
|
|
71
|
+
lines.push('')
|
|
72
|
+
lines.push(chalk.dim(NVD_ATTRIBUTION))
|
|
73
|
+
return lines.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = results.map((r) => ({
|
|
77
|
+
id: r.id,
|
|
78
|
+
severity: r.severity,
|
|
79
|
+
score: formatScore(r.score),
|
|
80
|
+
published: formatDate(r.publishedDate),
|
|
81
|
+
description: truncate(r.description, 90),
|
|
82
|
+
reference: r.firstReference ? truncate(r.firstReference, 45) : '—',
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
const table = renderTable(rows, [
|
|
86
|
+
{ header: 'CVE ID', key: 'id', colorize: (v) => chalk.cyan(v) },
|
|
87
|
+
{ header: 'Severity', key: 'severity', colorize: (v) => colorSeverity(v) },
|
|
88
|
+
{ header: 'Score', key: 'score', width: 5 },
|
|
89
|
+
{ header: 'Published', key: 'published', width: 10 },
|
|
90
|
+
{ header: 'Description', key: 'description', width: 90 },
|
|
91
|
+
{ header: 'Reference', key: 'reference', width: 45 },
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
// Indent table by 2 spaces
|
|
95
|
+
lines.push(table.split('\n').map((l) => ` ${l}`).join('\n'))
|
|
96
|
+
lines.push('')
|
|
97
|
+
lines.push(`Showing ${results.length} of ${totalResults} results.`)
|
|
98
|
+
lines.push(chalk.dim(NVD_ATTRIBUTION))
|
|
99
|
+
return lines.join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format a full CVE detail record for terminal output.
|
|
104
|
+
* @param {CveDetail} cve
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function formatCveDetail(cve) {
|
|
108
|
+
const lines = []
|
|
109
|
+
|
|
110
|
+
const scoreStr = cve.score !== null ? ` (${formatScore(cve.score)})` : ''
|
|
111
|
+
lines.push(chalk.bold.cyan(`${cve.id}`) + chalk.bold(` — `) + colorSeverity(cve.severity) + chalk.bold(scoreStr))
|
|
112
|
+
lines.push('')
|
|
113
|
+
|
|
114
|
+
// Description
|
|
115
|
+
lines.push(chalk.bold(' Description'))
|
|
116
|
+
// Word-wrap at ~80 chars, indented 2 spaces
|
|
117
|
+
lines.push(wordWrap(cve.description, 78, ' '))
|
|
118
|
+
lines.push('')
|
|
119
|
+
|
|
120
|
+
// Details
|
|
121
|
+
lines.push(chalk.bold(' Details'))
|
|
122
|
+
lines.push(` Status: ${cve.status}`)
|
|
123
|
+
lines.push(` Published: ${formatDate(cve.publishedDate)}`)
|
|
124
|
+
lines.push(` Last Modified: ${formatDate(cve.lastModified)}`)
|
|
125
|
+
if (cve.cvssVector) lines.push(` CVSS Vector: ${cve.cvssVector}`)
|
|
126
|
+
if (cve.weaknesses.length > 0) {
|
|
127
|
+
const cweIds = [...new Set(cve.weaknesses.map((w) => w.id))].join(', ')
|
|
128
|
+
lines.push(` Weaknesses: ${cweIds}`)
|
|
129
|
+
}
|
|
130
|
+
lines.push('')
|
|
131
|
+
|
|
132
|
+
// Affected products
|
|
133
|
+
if (cve.affectedProducts.length > 0) {
|
|
134
|
+
lines.push(chalk.bold(' Affected Products'))
|
|
135
|
+
lines.push(' ' + chalk.dim('─'.repeat(40)))
|
|
136
|
+
for (const p of cve.affectedProducts) {
|
|
137
|
+
lines.push(` ${p.vendor} / ${p.product} / ${p.versions}`)
|
|
138
|
+
}
|
|
139
|
+
lines.push('')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// References
|
|
143
|
+
if (cve.references.length > 0) {
|
|
144
|
+
lines.push(chalk.bold(' References'))
|
|
145
|
+
cve.references.slice(0, 5).forEach((ref, i) => {
|
|
146
|
+
const tagStr = ref.tags.length > 0 ? ` (${ref.tags.join(', ')})` : ''
|
|
147
|
+
lines.push(` [${i + 1}] ${ref.url}${tagStr}`)
|
|
148
|
+
})
|
|
149
|
+
lines.push('')
|
|
150
|
+
lines.push(chalk.dim(' Tip: Use --open to open the first reference in your browser.'))
|
|
151
|
+
lines.push('')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push(chalk.dim(NVD_ATTRIBUTION))
|
|
155
|
+
return lines.join('\n')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Format a full CVE detail record as an array of plain-text lines (no chalk/ANSI).
|
|
160
|
+
* Used by the modal overlay in the navigable table TUI.
|
|
161
|
+
* @param {CveDetail} cve
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
export function formatCveDetailPlain(cve) {
|
|
165
|
+
const lines = []
|
|
166
|
+
|
|
167
|
+
const scoreStr = cve.score !== null ? ` (${formatScore(cve.score)})` : ''
|
|
168
|
+
lines.push(`${cve.id} — ${cve.severity}${scoreStr}`)
|
|
169
|
+
lines.push('')
|
|
170
|
+
|
|
171
|
+
lines.push(' Description')
|
|
172
|
+
const wrappedDesc = wordWrap(cve.description, 78, ' ')
|
|
173
|
+
for (const l of wrappedDesc.split('\n')) lines.push(l)
|
|
174
|
+
lines.push('')
|
|
175
|
+
|
|
176
|
+
lines.push(' Details')
|
|
177
|
+
lines.push(` Status: ${cve.status}`)
|
|
178
|
+
lines.push(` Published: ${formatDate(cve.publishedDate)}`)
|
|
179
|
+
lines.push(` Last Modified: ${formatDate(cve.lastModified)}`)
|
|
180
|
+
if (cve.cvssVector) lines.push(` CVSS Vector: ${cve.cvssVector}`)
|
|
181
|
+
if (cve.weaknesses.length > 0) {
|
|
182
|
+
const cweIds = [...new Set(cve.weaknesses.map((w) => w.id))].join(', ')
|
|
183
|
+
lines.push(` Weaknesses: ${cweIds}`)
|
|
184
|
+
}
|
|
185
|
+
lines.push('')
|
|
186
|
+
|
|
187
|
+
if (cve.affectedProducts.length > 0) {
|
|
188
|
+
lines.push(' Affected Products')
|
|
189
|
+
lines.push(' ' + '─'.repeat(40))
|
|
190
|
+
for (const p of cve.affectedProducts) {
|
|
191
|
+
lines.push(` ${p.vendor} / ${p.product} / ${p.versions}`)
|
|
192
|
+
}
|
|
193
|
+
lines.push('')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (cve.references.length > 0) {
|
|
197
|
+
lines.push(' References')
|
|
198
|
+
cve.references.slice(0, 5).forEach((ref, i) => {
|
|
199
|
+
const tagStr = ref.tags.length > 0 ? ` (${ref.tags.join(', ')})` : ''
|
|
200
|
+
lines.push(` [${i + 1}] ${ref.url}${tagStr}`)
|
|
201
|
+
})
|
|
202
|
+
lines.push('')
|
|
203
|
+
lines.push(' Tip: Press o to open the first reference in your browser.')
|
|
204
|
+
lines.push('')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
lines.push(NVD_ATTRIBUTION)
|
|
208
|
+
return lines
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Word-wrap a string to a max line length with an indent prefix.
|
|
213
|
+
* @param {string} text
|
|
214
|
+
* @param {number} maxLen
|
|
215
|
+
* @param {string} indent
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
function wordWrap(text, maxLen, indent) {
|
|
219
|
+
if (!text) return ''
|
|
220
|
+
const words = text.split(' ')
|
|
221
|
+
const wrappedLines = []
|
|
222
|
+
let current = indent
|
|
223
|
+
|
|
224
|
+
for (const word of words) {
|
|
225
|
+
if (current.length + word.length + 1 > maxLen && current.trim().length > 0) {
|
|
226
|
+
wrappedLines.push(current.trimEnd())
|
|
227
|
+
current = indent + word + ' '
|
|
228
|
+
} else {
|
|
229
|
+
current += word + ' '
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (current.trim()) wrappedLines.push(current.trimEnd())
|
|
233
|
+
return wrappedLines.join('\n')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Format a list of VulnerabilityFindings as a terminal table string.
|
|
238
|
+
* @param {VulnerabilityFinding[]} findings
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
export function formatFindingsTable(findings) {
|
|
242
|
+
if (findings.length === 0) return ''
|
|
243
|
+
|
|
244
|
+
const rows = findings.map((f) => ({
|
|
245
|
+
pkg: f.package,
|
|
246
|
+
version: f.installedVersion,
|
|
247
|
+
severity: f.severity,
|
|
248
|
+
cve: f.cveId ?? '—',
|
|
249
|
+
title: truncate(f.title ?? '—', 40),
|
|
250
|
+
}))
|
|
251
|
+
|
|
252
|
+
const table = renderTable(rows, [
|
|
253
|
+
{ header: 'Package', key: 'pkg', width: 20 },
|
|
254
|
+
{ header: 'Version', key: 'version', width: 12 },
|
|
255
|
+
{ header: 'Severity', key: 'severity', colorize: (v) => colorSeverity(v) },
|
|
256
|
+
{ header: 'CVE', key: 'cve', colorize: (v) => (v !== '—' ? chalk.cyan(v) : chalk.gray(v)) },
|
|
257
|
+
{ header: 'Title', key: 'title', width: 40 },
|
|
258
|
+
])
|
|
259
|
+
|
|
260
|
+
return table.split('\n').map((l) => ` ${l}`).join('\n')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Format a ScanResult summary block for terminal output.
|
|
265
|
+
* @param {import('../types.js').ScanSummary} summary
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
export function formatScanSummary(summary) {
|
|
269
|
+
const lines = []
|
|
270
|
+
lines.push(' ' + chalk.dim('─'.repeat(21)))
|
|
271
|
+
lines.push(` Critical: ${summary.critical}`)
|
|
272
|
+
lines.push(` High: ${summary.high}`)
|
|
273
|
+
lines.push(` Medium: ${summary.medium}`)
|
|
274
|
+
lines.push(` Low: ${summary.low}`)
|
|
275
|
+
lines.push(` Total: ${summary.total}`)
|
|
276
|
+
return lines.join('\n')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Generate a Markdown vulnerability report.
|
|
281
|
+
* @param {ScanResult} result
|
|
282
|
+
* @returns {string}
|
|
283
|
+
*/
|
|
284
|
+
export function formatMarkdownReport(result) {
|
|
285
|
+
const lines = []
|
|
286
|
+
lines.push('# Vulnerability Report')
|
|
287
|
+
lines.push('')
|
|
288
|
+
lines.push(`**Project**: ${result.projectPath} `)
|
|
289
|
+
lines.push(`**Date**: ${result.scanDate} `)
|
|
290
|
+
lines.push(`**Ecosystems scanned**: ${result.ecosystems.map((e) => e.name).join(', ')}`)
|
|
291
|
+
lines.push('')
|
|
292
|
+
lines.push('## Summary')
|
|
293
|
+
lines.push('')
|
|
294
|
+
lines.push('| Severity | Count |')
|
|
295
|
+
lines.push('|----------|-------|')
|
|
296
|
+
lines.push(`| Critical | ${result.summary.critical} |`)
|
|
297
|
+
lines.push(`| High | ${result.summary.high} |`)
|
|
298
|
+
lines.push(`| Medium | ${result.summary.medium} |`)
|
|
299
|
+
lines.push(`| Low | ${result.summary.low} |`)
|
|
300
|
+
lines.push(`| **Total** | **${result.summary.total}** |`)
|
|
301
|
+
lines.push('')
|
|
302
|
+
|
|
303
|
+
if (result.findings.length > 0) {
|
|
304
|
+
lines.push('## Findings')
|
|
305
|
+
lines.push('')
|
|
306
|
+
lines.push('| Package | Version | Severity | CVE | Title |')
|
|
307
|
+
lines.push('|---------|---------|----------|-----|-------|')
|
|
308
|
+
for (const f of result.findings) {
|
|
309
|
+
lines.push(`| ${f.package} | ${f.installedVersion} | ${f.severity} | ${f.cveId ?? '—'} | ${f.title ?? '—'} |`)
|
|
310
|
+
}
|
|
311
|
+
lines.push('')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
lines.push('---')
|
|
315
|
+
lines.push(`*${NVD_ATTRIBUTION}*`)
|
|
316
|
+
return lines.join('\n')
|
|
317
|
+
}
|
package/src/help.js
CHANGED
|
@@ -87,6 +87,14 @@ const CATEGORIES = [
|
|
|
87
87
|
{ id: 'security:setup', hint: '[--json]' },
|
|
88
88
|
],
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
title: 'CVE & Vulnerabilità',
|
|
92
|
+
cmds: [
|
|
93
|
+
{ id: 'vuln:search', hint: '[KEYWORD] [--days] [--severity] [--limit]' },
|
|
94
|
+
{ id: 'vuln:detail', hint: '<CVE-ID> [--open]' },
|
|
95
|
+
{ id: 'vuln:scan', hint: '[--severity] [--no-fail] [--report]' },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
90
98
|
{
|
|
91
99
|
title: 'Dotfiles & Cifratura',
|
|
92
100
|
cmds: [
|
|
@@ -209,6 +217,13 @@ export default class CustomHelp extends Help {
|
|
|
209
217
|
{ cmd: 'dvmi dotfiles status --json', note: 'Stato dotfile gestiti (JSON)' },
|
|
210
218
|
{ cmd: 'dvmi dotfiles sync --push', note: 'Push dotfile al repository remoto' },
|
|
211
219
|
{ cmd: 'dvmi welcome', note: 'Dashboard missione dvmi con intro animata' },
|
|
220
|
+
{ cmd: 'dvmi vuln search openssl', note: 'Cerca CVE recenti per keyword' },
|
|
221
|
+
{ cmd: 'dvmi vuln search log4j --days 30 --severity critical', note: 'CVE critiche Log4j negli ultimi 30 giorni' },
|
|
222
|
+
{ cmd: 'dvmi vuln detail CVE-2021-44228', note: 'Dettaglio completo di una CVE' },
|
|
223
|
+
{ cmd: 'dvmi vuln detail CVE-2021-44228 --open', note: 'Apri la prima referenza nel browser' },
|
|
224
|
+
{ cmd: 'dvmi vuln scan', note: 'Scansiona dipendenze del progetto corrente' },
|
|
225
|
+
{ cmd: 'dvmi vuln scan --severity high --no-fail', note: 'Scansione senza bloccare CI (solo high+)' },
|
|
226
|
+
{ cmd: 'dvmi vuln scan --report ./vuln-report.md', note: 'Esporta report Markdown delle vulnerabilità' },
|
|
212
227
|
]
|
|
213
228
|
|
|
214
229
|
const lines = []
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { resolve, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** @import { PackageEcosystem } from '../types.js' */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lock file detection entries in priority order.
|
|
8
|
+
* For Node.js: pnpm > npm > yarn (only highest priority used).
|
|
9
|
+
*/
|
|
10
|
+
const LOCK_FILE_MAP = [
|
|
11
|
+
{
|
|
12
|
+
lockFile: 'pnpm-lock.yaml',
|
|
13
|
+
name: 'pnpm',
|
|
14
|
+
auditCommand: 'pnpm audit --json',
|
|
15
|
+
builtIn: true,
|
|
16
|
+
nodeGroup: true,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
lockFile: 'package-lock.json',
|
|
20
|
+
name: 'npm',
|
|
21
|
+
auditCommand: 'npm audit --json',
|
|
22
|
+
builtIn: true,
|
|
23
|
+
nodeGroup: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
lockFile: 'yarn.lock',
|
|
27
|
+
name: 'yarn',
|
|
28
|
+
auditCommand: 'yarn audit --json',
|
|
29
|
+
builtIn: true,
|
|
30
|
+
nodeGroup: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
lockFile: 'Pipfile.lock',
|
|
34
|
+
name: 'pip',
|
|
35
|
+
auditCommand: 'pip-audit -f json',
|
|
36
|
+
builtIn: false,
|
|
37
|
+
nodeGroup: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
lockFile: 'poetry.lock',
|
|
41
|
+
name: 'pip',
|
|
42
|
+
auditCommand: 'pip-audit -f json',
|
|
43
|
+
builtIn: false,
|
|
44
|
+
nodeGroup: false,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
lockFile: 'requirements.txt',
|
|
48
|
+
name: 'pip',
|
|
49
|
+
auditCommand: 'pip-audit -r requirements.txt -f json',
|
|
50
|
+
builtIn: false,
|
|
51
|
+
nodeGroup: false,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
lockFile: 'Cargo.lock',
|
|
55
|
+
name: 'cargo',
|
|
56
|
+
auditCommand: 'cargo audit --json',
|
|
57
|
+
builtIn: false,
|
|
58
|
+
nodeGroup: false,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
lockFile: 'Gemfile.lock',
|
|
62
|
+
name: 'bundler',
|
|
63
|
+
auditCommand: 'bundle-audit check --format json',
|
|
64
|
+
builtIn: false,
|
|
65
|
+
nodeGroup: false,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
lockFile: 'composer.lock',
|
|
69
|
+
name: 'composer',
|
|
70
|
+
auditCommand: 'composer audit --format json',
|
|
71
|
+
builtIn: true,
|
|
72
|
+
nodeGroup: false,
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Detect package manager ecosystems present in a directory by scanning for lock files.
|
|
78
|
+
* Node.js lock files are de-duplicated: only the highest-priority one is returned.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} [dir] - Directory to scan (defaults to process.cwd())
|
|
81
|
+
* @returns {PackageEcosystem[]}
|
|
82
|
+
*/
|
|
83
|
+
export function detectEcosystems(dir = process.cwd()) {
|
|
84
|
+
const ecosystems = []
|
|
85
|
+
let nodeDetected = false
|
|
86
|
+
|
|
87
|
+
for (const entry of LOCK_FILE_MAP) {
|
|
88
|
+
// Skip lower-priority Node.js lock files if one already detected
|
|
89
|
+
if (entry.nodeGroup && nodeDetected) continue
|
|
90
|
+
|
|
91
|
+
const lockFilePath = resolve(join(dir, entry.lockFile))
|
|
92
|
+
if (existsSync(lockFilePath)) {
|
|
93
|
+
ecosystems.push({
|
|
94
|
+
name: entry.name,
|
|
95
|
+
lockFile: entry.lockFile,
|
|
96
|
+
lockFilePath,
|
|
97
|
+
auditCommand: entry.auditCommand,
|
|
98
|
+
builtIn: entry.builtIn,
|
|
99
|
+
})
|
|
100
|
+
if (entry.nodeGroup) nodeDetected = true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return ecosystems
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Return the human-readable list of supported ecosystems for display in the "no package manager"
|
|
109
|
+
* error message.
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function supportedEcosystemsMessage() {
|
|
113
|
+
return [
|
|
114
|
+
' • Node.js (npm, pnpm, yarn) — requires lock file',
|
|
115
|
+
' • Python (pip-audit) — requires Pipfile.lock, poetry.lock, or requirements.txt',
|
|
116
|
+
' • Rust (cargo-audit) — requires Cargo.lock',
|
|
117
|
+
' • Ruby (bundler-audit) — requires Gemfile.lock',
|
|
118
|
+
' • PHP (composer) — requires composer.lock',
|
|
119
|
+
].join('\n')
|
|
120
|
+
}
|