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.
- package/oclif.manifest.json +187 -1
- package/package.json +1 -1
- package/src/commands/vuln/detail.js +65 -0
- package/src/commands/vuln/scan.js +155 -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 +496 -0
|
@@ -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
|
+
}
|