devvami 1.3.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.
@@ -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,23 @@ 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
+ },
98
+ {
99
+ title: 'Dotfiles & Cifratura',
100
+ cmds: [
101
+ { id: 'dotfiles:setup', hint: '[--json]' },
102
+ { id: 'dotfiles:add', hint: '[FILES...] [--encrypt]' },
103
+ { id: 'dotfiles:status', hint: '[--json]' },
104
+ { id: 'dotfiles:sync', hint: '[--push] [--pull] [--dry-run]' },
105
+ ],
106
+ },
90
107
  {
91
108
  title: 'Setup & Ambiente',
92
109
  cmds: [
@@ -119,7 +136,20 @@ export default class CustomHelp extends Help {
119
136
  async showRootHelp() {
120
137
  // Animated logo — identical to `dvmi init` (no-ops in CI/non-TTY)
121
138
  await printBanner()
122
- this.log(this.#buildRootLayout())
139
+
140
+ // Version check: uses cached result (populated by init hook) — 800 ms timeout
141
+ let versionInfo = null
142
+ try {
143
+ const { checkForUpdate } = await import('./services/version-check.js')
144
+ versionInfo = await Promise.race([
145
+ checkForUpdate(),
146
+ new Promise((resolve) => setTimeout(() => resolve(null), 800)),
147
+ ])
148
+ } catch {
149
+ // never block help output
150
+ }
151
+
152
+ this.log(this.#buildRootLayout(versionInfo))
123
153
  }
124
154
 
125
155
  /**
@@ -151,9 +181,10 @@ export default class CustomHelp extends Help {
151
181
 
152
182
  /**
153
183
  * Build the full categorized root help layout.
184
+ * @param {{ hasUpdate: boolean, current: string, latest: string|null }|null} [versionInfo]
154
185
  * @returns {string}
155
186
  */
156
- #buildRootLayout() {
187
+ #buildRootLayout(versionInfo = null) {
157
188
  /** @type {Map<string, import('@oclif/core').Command.Cached>} */
158
189
  const cmdMap = new Map(this.config.commands.map((c) => [c.id, c]))
159
190
 
@@ -181,7 +212,18 @@ export default class CustomHelp extends Help {
181
212
  { cmd: 'dvmi logs --group /aws/lambda/my-fn --filter "ERROR"', note: 'Filtra eventi ERROR su un log group' },
182
213
  { cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
183
214
  { cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
215
+ { cmd: 'dvmi dotfiles setup', note: 'Configura chezmoi con cifratura age' },
216
+ { cmd: 'dvmi dotfiles add ~/.zshrc ~/.gitconfig', note: 'Aggiungi dotfile a chezmoi' },
217
+ { cmd: 'dvmi dotfiles status --json', note: 'Stato dotfile gestiti (JSON)' },
218
+ { cmd: 'dvmi dotfiles sync --push', note: 'Push dotfile al repository remoto' },
184
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à' },
185
227
  ]
186
228
 
187
229
  const lines = []
@@ -248,6 +290,24 @@ export default class CustomHelp extends Help {
248
290
  }
249
291
 
250
292
  lines.push('')
293
+
294
+ // ── Versione + update notice ───────────────────────────────────────────
295
+ const current = versionInfo?.current ?? this.config.version
296
+ const versionStr = isColorEnabled
297
+ ? chalk.dim('version ') + chalk.hex(DIM_BLUE)(current)
298
+ : `version ${current}`
299
+
300
+ if (versionInfo?.hasUpdate && versionInfo.latest) {
301
+ const updateStr = isColorEnabled
302
+ ? chalk.yellow('update disponibile: ') +
303
+ chalk.dim(current) + chalk.yellow(' → ') + chalk.green(versionInfo.latest) +
304
+ chalk.dim(' (esegui ') + chalk.hex(LIGHT_ORANGE)('dvmi upgrade') + chalk.dim(')')
305
+ : `update disponibile: ${current} → ${versionInfo.latest} (esegui dvmi upgrade)`
306
+ lines.push(' ' + versionStr + chalk.dim(' · ') + updateStr)
307
+ } else {
308
+ lines.push(' ' + versionStr)
309
+ }
310
+
251
311
  lines.push(
252
312
  ' ' + chalk.dim('Approfondisci:') + ' ' +
253
313
  chalk.hex(DIM_BLUE)('dvmi <COMANDO> --help') +
@@ -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
+ }