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.
@@ -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
+ }