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.
- package/oclif.manifest.json +397 -1
- package/package.json +1 -1
- package/src/commands/dotfiles/add.js +249 -0
- package/src/commands/dotfiles/setup.js +190 -0
- package/src/commands/dotfiles/status.js +103 -0
- package/src/commands/dotfiles/sync.js +375 -0
- package/src/commands/init.js +35 -2
- 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/dotfiles.js +259 -0
- package/src/formatters/vuln.js +317 -0
- package/src/help.js +62 -2
- package/src/services/audit-detector.js +120 -0
- package/src/services/audit-runner.js +365 -0
- package/src/services/dotfiles.js +573 -0
- package/src/services/nvd.js +245 -0
- package/src/types.js +73 -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,365 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** @import { PackageEcosystem, VulnerabilityFinding } from '../types.js' */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a raw severity string from any audit tool to the 4-tier canonical form.
|
|
8
|
+
* @param {string|undefined} raw
|
|
9
|
+
* @returns {'Critical'|'High'|'Medium'|'Low'|'Unknown'}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeSeverity(raw) {
|
|
12
|
+
if (!raw) return 'Unknown'
|
|
13
|
+
const s = raw.toLowerCase()
|
|
14
|
+
if (s === 'critical') return 'Critical'
|
|
15
|
+
if (s === 'high') return 'High'
|
|
16
|
+
if (s === 'medium' || s === 'moderate') return 'Medium'
|
|
17
|
+
if (s === 'low' || s === 'info') return 'Low'
|
|
18
|
+
return 'Unknown'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse npm v7+ audit JSON output.
|
|
23
|
+
* @param {any} data
|
|
24
|
+
* @param {string} ecosystem
|
|
25
|
+
* @returns {VulnerabilityFinding[]}
|
|
26
|
+
*/
|
|
27
|
+
function parseNpmAudit(data, ecosystem) {
|
|
28
|
+
const findings = []
|
|
29
|
+
const vulns = data.vulnerabilities ?? {}
|
|
30
|
+
|
|
31
|
+
for (const [pkgName, vuln] of Object.entries(vulns)) {
|
|
32
|
+
// `via` can contain strings (transitive) or advisory objects
|
|
33
|
+
const advisories = (vuln.via ?? []).filter((v) => typeof v === 'object')
|
|
34
|
+
|
|
35
|
+
if (advisories.length === 0) {
|
|
36
|
+
// Transitive-only entry: no advisory objects, skip (will be reported through direct dep)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const advisory of advisories) {
|
|
41
|
+
findings.push({
|
|
42
|
+
package: pkgName,
|
|
43
|
+
installedVersion: vuln.range ?? 'unknown',
|
|
44
|
+
severity: normalizeSeverity(advisory.severity ?? vuln.severity),
|
|
45
|
+
cveId: null, // npm audit doesn't include CVE IDs directly
|
|
46
|
+
advisoryUrl: advisory.url ?? null,
|
|
47
|
+
title: advisory.title ?? null,
|
|
48
|
+
patchedVersions: advisory.range ? `>=${advisory.range.replace(/^</, '')}` : null,
|
|
49
|
+
ecosystem,
|
|
50
|
+
isDirect: vuln.isDirect ?? null,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return findings
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse pnpm audit JSON output (npm v6-style with `advisories` object).
|
|
60
|
+
* @param {any} data
|
|
61
|
+
* @param {string} ecosystem
|
|
62
|
+
* @returns {VulnerabilityFinding[]}
|
|
63
|
+
*/
|
|
64
|
+
function parsePnpmAudit(data, ecosystem) {
|
|
65
|
+
const findings = []
|
|
66
|
+
const advisories = data.advisories ?? {}
|
|
67
|
+
|
|
68
|
+
for (const advisory of Object.values(advisories)) {
|
|
69
|
+
const findings_ = /** @type {any} */ (advisory).findings ?? []
|
|
70
|
+
const version = findings_[0]?.version ?? 'unknown'
|
|
71
|
+
|
|
72
|
+
findings.push({
|
|
73
|
+
package: advisory.module_name,
|
|
74
|
+
installedVersion: version,
|
|
75
|
+
severity: normalizeSeverity(advisory.severity),
|
|
76
|
+
cveId: Array.isArray(advisory.cves) && advisory.cves.length > 0 ? advisory.cves[0] : null,
|
|
77
|
+
advisoryUrl: advisory.url ?? null,
|
|
78
|
+
title: advisory.title ?? null,
|
|
79
|
+
patchedVersions: advisory.patched_versions ?? null,
|
|
80
|
+
ecosystem,
|
|
81
|
+
isDirect: null,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return findings
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse yarn v1 NDJSON audit output.
|
|
90
|
+
* @param {string} raw
|
|
91
|
+
* @param {string} ecosystem
|
|
92
|
+
* @returns {VulnerabilityFinding[]}
|
|
93
|
+
*/
|
|
94
|
+
function parseYarnAudit(raw, ecosystem) {
|
|
95
|
+
const findings = []
|
|
96
|
+
|
|
97
|
+
for (const line of raw.split('\n')) {
|
|
98
|
+
const trimmed = line.trim()
|
|
99
|
+
if (!trimmed) continue
|
|
100
|
+
let obj
|
|
101
|
+
try {
|
|
102
|
+
obj = JSON.parse(trimmed)
|
|
103
|
+
} catch {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (obj.type !== 'auditAdvisory') continue
|
|
107
|
+
|
|
108
|
+
const advisory = obj.data?.advisory
|
|
109
|
+
if (!advisory) continue
|
|
110
|
+
|
|
111
|
+
const resolution = obj.data?.resolution
|
|
112
|
+
const version = advisory.findings?.[0]?.version ?? 'unknown'
|
|
113
|
+
|
|
114
|
+
findings.push({
|
|
115
|
+
package: advisory.module_name,
|
|
116
|
+
installedVersion: version,
|
|
117
|
+
severity: normalizeSeverity(advisory.severity),
|
|
118
|
+
cveId: Array.isArray(advisory.cves) && advisory.cves.length > 0 ? advisory.cves[0] : null,
|
|
119
|
+
advisoryUrl: advisory.url ?? null,
|
|
120
|
+
title: advisory.title ?? null,
|
|
121
|
+
patchedVersions: advisory.patched_versions ?? null,
|
|
122
|
+
ecosystem,
|
|
123
|
+
isDirect: resolution?.dev === false ? null : null,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return findings
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse pip-audit JSON output.
|
|
132
|
+
* @param {any} data
|
|
133
|
+
* @param {string} ecosystem
|
|
134
|
+
* @returns {VulnerabilityFinding[]}
|
|
135
|
+
*/
|
|
136
|
+
function parsePipAudit(data, ecosystem) {
|
|
137
|
+
const findings = []
|
|
138
|
+
const deps = data.dependencies ?? []
|
|
139
|
+
|
|
140
|
+
for (const dep of deps) {
|
|
141
|
+
if (!Array.isArray(dep.vulns) || dep.vulns.length === 0) continue
|
|
142
|
+
for (const vuln of dep.vulns) {
|
|
143
|
+
// Determine best ID: prefer CVE
|
|
144
|
+
const cveId = vuln.id?.startsWith('CVE-') ? vuln.id
|
|
145
|
+
: (vuln.aliases ?? []).find((a) => a.startsWith('CVE-')) ?? null
|
|
146
|
+
|
|
147
|
+
findings.push({
|
|
148
|
+
package: dep.name,
|
|
149
|
+
installedVersion: dep.version ?? 'unknown',
|
|
150
|
+
severity: 'Unknown', // pip-audit doesn't include severity in its JSON output
|
|
151
|
+
cveId,
|
|
152
|
+
advisoryUrl: null,
|
|
153
|
+
title: vuln.description ?? null,
|
|
154
|
+
patchedVersions: Array.isArray(vuln.fix_versions) && vuln.fix_versions.length > 0
|
|
155
|
+
? `>=${vuln.fix_versions[0]}`
|
|
156
|
+
: null,
|
|
157
|
+
ecosystem,
|
|
158
|
+
isDirect: null,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return findings
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse cargo-audit JSON output.
|
|
168
|
+
* @param {any} data
|
|
169
|
+
* @param {string} ecosystem
|
|
170
|
+
* @returns {VulnerabilityFinding[]}
|
|
171
|
+
*/
|
|
172
|
+
function parseCargoAudit(data, ecosystem) {
|
|
173
|
+
const findings = []
|
|
174
|
+
const list = data.vulnerabilities?.list ?? []
|
|
175
|
+
|
|
176
|
+
for (const item of list) {
|
|
177
|
+
const advisory = item.advisory ?? {}
|
|
178
|
+
const pkg = item.package ?? {}
|
|
179
|
+
|
|
180
|
+
const cveId = Array.isArray(advisory.aliases)
|
|
181
|
+
? (advisory.aliases.find((a) => /^CVE-/i.test(a)) ?? null)
|
|
182
|
+
: null
|
|
183
|
+
|
|
184
|
+
// CVSS vector string — extract base score from it? Too complex; mark Unknown for now
|
|
185
|
+
findings.push({
|
|
186
|
+
package: pkg.name ?? 'unknown',
|
|
187
|
+
installedVersion: pkg.version ?? 'unknown',
|
|
188
|
+
severity: 'Unknown',
|
|
189
|
+
cveId,
|
|
190
|
+
advisoryUrl: advisory.url ?? null,
|
|
191
|
+
title: advisory.title ?? null,
|
|
192
|
+
patchedVersions: Array.isArray(item.versions?.patched) ? item.versions.patched.join(', ') : null,
|
|
193
|
+
ecosystem,
|
|
194
|
+
isDirect: null,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return findings
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse bundler-audit JSON output.
|
|
203
|
+
* @param {any} data
|
|
204
|
+
* @param {string} ecosystem
|
|
205
|
+
* @returns {VulnerabilityFinding[]}
|
|
206
|
+
*/
|
|
207
|
+
function parseBundlerAudit(data, ecosystem) {
|
|
208
|
+
const findings = []
|
|
209
|
+
const results = data.results ?? []
|
|
210
|
+
|
|
211
|
+
for (const result of results) {
|
|
212
|
+
const advisory = result.advisory ?? {}
|
|
213
|
+
const gem = result.gem ?? {}
|
|
214
|
+
|
|
215
|
+
findings.push({
|
|
216
|
+
package: gem.name ?? 'unknown',
|
|
217
|
+
installedVersion: gem.version ?? 'unknown',
|
|
218
|
+
severity: normalizeSeverity(advisory.criticality),
|
|
219
|
+
cveId: advisory.cve ?? null,
|
|
220
|
+
advisoryUrl: advisory.url ?? null,
|
|
221
|
+
title: advisory.title ?? null,
|
|
222
|
+
patchedVersions: Array.isArray(advisory.patched_versions) ? advisory.patched_versions.join(', ') : null,
|
|
223
|
+
ecosystem,
|
|
224
|
+
isDirect: null,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return findings
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parse composer audit JSON output.
|
|
233
|
+
* @param {any} data
|
|
234
|
+
* @param {string} ecosystem
|
|
235
|
+
* @returns {VulnerabilityFinding[]}
|
|
236
|
+
*/
|
|
237
|
+
function parseComposerAudit(data, ecosystem) {
|
|
238
|
+
const findings = []
|
|
239
|
+
const advisories = data.advisories ?? {}
|
|
240
|
+
|
|
241
|
+
for (const [pkgName, pkgAdvisories] of Object.entries(advisories)) {
|
|
242
|
+
if (!Array.isArray(pkgAdvisories)) continue
|
|
243
|
+
for (const advisory of pkgAdvisories) {
|
|
244
|
+
findings.push({
|
|
245
|
+
package: pkgName,
|
|
246
|
+
installedVersion: 'unknown',
|
|
247
|
+
severity: 'Unknown',
|
|
248
|
+
cveId: advisory.cve ?? null,
|
|
249
|
+
advisoryUrl: advisory.link ?? null,
|
|
250
|
+
title: advisory.title ?? null,
|
|
251
|
+
patchedVersions: null,
|
|
252
|
+
ecosystem,
|
|
253
|
+
isDirect: null,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return findings
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Run the audit command for a detected ecosystem and return normalized findings.
|
|
263
|
+
* @param {PackageEcosystem} ecosystem
|
|
264
|
+
* @returns {Promise<{ findings: VulnerabilityFinding[], error: string|null }>}
|
|
265
|
+
*/
|
|
266
|
+
export async function runAudit(ecosystem) {
|
|
267
|
+
const [cmd, ...args] = ecosystem.auditCommand.split(' ')
|
|
268
|
+
|
|
269
|
+
let result
|
|
270
|
+
try {
|
|
271
|
+
result = await execa(cmd, args, {
|
|
272
|
+
cwd: dirname(ecosystem.lockFilePath),
|
|
273
|
+
reject: false,
|
|
274
|
+
all: true,
|
|
275
|
+
})
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// Binary not found — tool not installed
|
|
278
|
+
const errMsg = /** @type {any} */ (err).code === 'ENOENT'
|
|
279
|
+
? `"${cmd}" is not installed. Install it to scan ${ecosystem.name} dependencies.`
|
|
280
|
+
: String(err)
|
|
281
|
+
return { findings: [], error: errMsg }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const output = result.stdout ?? result.all ?? ''
|
|
285
|
+
|
|
286
|
+
if (!output.trim()) {
|
|
287
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
288
|
+
return { findings: [], error: `${cmd} exited with code ${result.exitCode}: ${result.stderr ?? ''}` }
|
|
289
|
+
}
|
|
290
|
+
return { findings: [], error: null }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
switch (ecosystem.name) {
|
|
295
|
+
case 'npm': {
|
|
296
|
+
const data = JSON.parse(output)
|
|
297
|
+
return { findings: parseNpmAudit(data, ecosystem.name), error: null }
|
|
298
|
+
}
|
|
299
|
+
case 'pnpm': {
|
|
300
|
+
const data = JSON.parse(output)
|
|
301
|
+
return { findings: parsePnpmAudit(data, ecosystem.name), error: null }
|
|
302
|
+
}
|
|
303
|
+
case 'yarn': {
|
|
304
|
+
return { findings: parseYarnAudit(output, ecosystem.name), error: null }
|
|
305
|
+
}
|
|
306
|
+
case 'pip': {
|
|
307
|
+
const data = JSON.parse(output)
|
|
308
|
+
return { findings: parsePipAudit(data, ecosystem.name), error: null }
|
|
309
|
+
}
|
|
310
|
+
case 'cargo': {
|
|
311
|
+
const data = JSON.parse(output)
|
|
312
|
+
return { findings: parseCargoAudit(data, ecosystem.name), error: null }
|
|
313
|
+
}
|
|
314
|
+
case 'bundler': {
|
|
315
|
+
const data = JSON.parse(output)
|
|
316
|
+
return { findings: parseBundlerAudit(data, ecosystem.name), error: null }
|
|
317
|
+
}
|
|
318
|
+
case 'composer': {
|
|
319
|
+
const data = JSON.parse(output)
|
|
320
|
+
return { findings: parseComposerAudit(data, ecosystem.name), error: null }
|
|
321
|
+
}
|
|
322
|
+
default:
|
|
323
|
+
return { findings: [], error: `Unknown ecosystem: ${ecosystem.name}` }
|
|
324
|
+
}
|
|
325
|
+
} catch (parseErr) {
|
|
326
|
+
return { findings: [], error: `Failed to parse ${ecosystem.name} audit output: ${parseErr.message}` }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Summarize a list of findings into counts per severity level.
|
|
332
|
+
* @param {VulnerabilityFinding[]} findings
|
|
333
|
+
* @returns {import('../types.js').ScanSummary}
|
|
334
|
+
*/
|
|
335
|
+
export function summarizeFindings(findings) {
|
|
336
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0, total: 0 }
|
|
337
|
+
for (const f of findings) {
|
|
338
|
+
summary.total++
|
|
339
|
+
switch (f.severity) {
|
|
340
|
+
case 'Critical': summary.critical++; break
|
|
341
|
+
case 'High': summary.high++; break
|
|
342
|
+
case 'Medium': summary.medium++; break
|
|
343
|
+
case 'Low': summary.low++; break
|
|
344
|
+
default: summary.unknown++; break
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return summary
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Filter findings by minimum severity level.
|
|
352
|
+
* @param {VulnerabilityFinding[]} findings
|
|
353
|
+
* @param {'low'|'medium'|'high'|'critical'|undefined} minSeverity
|
|
354
|
+
* @returns {VulnerabilityFinding[]}
|
|
355
|
+
*/
|
|
356
|
+
export function filterBySeverity(findings, minSeverity) {
|
|
357
|
+
if (!minSeverity) return findings
|
|
358
|
+
const order = ['Low', 'Medium', 'High', 'Critical']
|
|
359
|
+
const minIdx = order.indexOf(minSeverity[0].toUpperCase() + minSeverity.slice(1).toLowerCase())
|
|
360
|
+
if (minIdx === -1) return findings
|
|
361
|
+
return findings.filter((f) => {
|
|
362
|
+
const idx = order.indexOf(f.severity)
|
|
363
|
+
return idx === -1 ? false : idx >= minIdx
|
|
364
|
+
})
|
|
365
|
+
}
|