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,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
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { loadConfig } from './config.js'
|
|
2
|
+
import { DvmiError } from '../utils/errors.js'
|
|
3
|
+
|
|
4
|
+
/** @import { CveSearchResult, CveDetail } from '../types.js' */
|
|
5
|
+
|
|
6
|
+
const NVD_BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0'
|
|
7
|
+
|
|
8
|
+
/** NVD attribution required in all interactive output. */
|
|
9
|
+
export const NVD_ATTRIBUTION =
|
|
10
|
+
'This product uses data from the NVD API but is not endorsed or certified by the NVD.'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize a raw NVD severity string to the 4-tier canonical form.
|
|
14
|
+
* @param {string|undefined} raw
|
|
15
|
+
* @returns {'Critical'|'High'|'Medium'|'Low'|'Unknown'}
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeSeverity(raw) {
|
|
18
|
+
if (!raw) return 'Unknown'
|
|
19
|
+
const s = raw.toUpperCase()
|
|
20
|
+
if (s === 'CRITICAL') return 'Critical'
|
|
21
|
+
if (s === 'HIGH') return 'High'
|
|
22
|
+
if (s === 'MEDIUM') return 'Medium'
|
|
23
|
+
if (s === 'LOW') return 'Low'
|
|
24
|
+
return 'Unknown'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract the best available CVSS metrics from a CVE record.
|
|
29
|
+
* Priority: cvssMetricV31 > cvssMetricV40 > cvssMetricV2
|
|
30
|
+
* @param {Record<string, unknown>} metrics
|
|
31
|
+
* @returns {{ score: number|null, severity: string, vector: string|null }}
|
|
32
|
+
*/
|
|
33
|
+
function extractCvss(metrics) {
|
|
34
|
+
const sources = [
|
|
35
|
+
(metrics?.cvssMetricV31 ?? []),
|
|
36
|
+
(metrics?.cvssMetricV40 ?? []),
|
|
37
|
+
(metrics?.cvssMetricV2 ?? []),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for (const list of sources) {
|
|
41
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
42
|
+
const data = /** @type {any} */ (list[0]).cvssData
|
|
43
|
+
if (data) {
|
|
44
|
+
return {
|
|
45
|
+
score: data.baseScore ?? null,
|
|
46
|
+
severity: normalizeSeverity(data.baseSeverity),
|
|
47
|
+
vector: data.vectorString ?? null,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { score: null, severity: 'Unknown', vector: null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the English description from the NVD descriptions array.
|
|
58
|
+
* @param {Array<{lang: string, value: string}>} descriptions
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function getEnDescription(descriptions) {
|
|
62
|
+
if (!Array.isArray(descriptions)) return ''
|
|
63
|
+
const en = descriptions.find((d) => d.lang === 'en')
|
|
64
|
+
return en?.value ?? ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build query parameters for NVD API request.
|
|
69
|
+
* @param {Record<string, string|number|undefined>} params
|
|
70
|
+
* @returns {URLSearchParams}
|
|
71
|
+
*/
|
|
72
|
+
function buildParams(params) {
|
|
73
|
+
const sp = new URLSearchParams()
|
|
74
|
+
for (const [key, val] of Object.entries(params)) {
|
|
75
|
+
if (val !== undefined && val !== null && val !== '') {
|
|
76
|
+
sp.set(key, String(val))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return sp
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Make an authenticated fetch to the NVD API.
|
|
84
|
+
* @param {URLSearchParams} params
|
|
85
|
+
* @param {string|undefined} apiKey
|
|
86
|
+
* @returns {Promise<unknown>}
|
|
87
|
+
*/
|
|
88
|
+
async function nvdFetch(params, apiKey) {
|
|
89
|
+
const url = `${NVD_BASE_URL}?${params.toString()}`
|
|
90
|
+
/** @type {Record<string, string>} */
|
|
91
|
+
const headers = { Accept: 'application/json' }
|
|
92
|
+
if (apiKey) headers['apiKey'] = apiKey
|
|
93
|
+
|
|
94
|
+
const res = await fetch(url, { headers })
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new DvmiError(
|
|
97
|
+
`NVD API returned HTTP ${res.status}`,
|
|
98
|
+
'Check your network connection or try again later.',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return res.json()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a raw NVD vulnerability object into a CveSearchResult.
|
|
106
|
+
* @param {any} raw
|
|
107
|
+
* @returns {CveSearchResult}
|
|
108
|
+
*/
|
|
109
|
+
function parseCveSearchResult(raw) {
|
|
110
|
+
const cve = raw.cve
|
|
111
|
+
const { score, severity } = extractCvss(cve.metrics ?? {})
|
|
112
|
+
return {
|
|
113
|
+
id: cve.id,
|
|
114
|
+
description: getEnDescription(cve.descriptions),
|
|
115
|
+
severity,
|
|
116
|
+
score,
|
|
117
|
+
publishedDate: cve.published,
|
|
118
|
+
lastModified: cve.lastModified,
|
|
119
|
+
firstReference: (cve.references ?? [])[0]?.url ?? null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse a raw NVD vulnerability object into a CveDetail.
|
|
125
|
+
* @param {any} raw
|
|
126
|
+
* @returns {CveDetail}
|
|
127
|
+
*/
|
|
128
|
+
function parseCveDetail(raw) {
|
|
129
|
+
const cve = raw.cve
|
|
130
|
+
const { score, severity, vector } = extractCvss(cve.metrics ?? {})
|
|
131
|
+
|
|
132
|
+
// Weaknesses: flatten all CWE descriptions
|
|
133
|
+
const weaknesses = (cve.weaknesses ?? []).flatMap((w) =>
|
|
134
|
+
(w.description ?? []).map((d) => ({
|
|
135
|
+
id: d.value,
|
|
136
|
+
description: d.value,
|
|
137
|
+
})),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// Affected products: parse CPE data from configurations
|
|
141
|
+
const affectedProducts = (cve.configurations ?? []).flatMap((cfg) =>
|
|
142
|
+
(cfg.nodes ?? []).flatMap((node) =>
|
|
143
|
+
(node.cpeMatch ?? [])
|
|
144
|
+
.filter((m) => m.vulnerable)
|
|
145
|
+
.map((m) => {
|
|
146
|
+
// cpe:2.3:a:vendor:product:version:...
|
|
147
|
+
const parts = (m.criteria ?? '').split(':')
|
|
148
|
+
const vendor = parts[3] ?? 'unknown'
|
|
149
|
+
const product = parts[4] ?? 'unknown'
|
|
150
|
+
const versionStart = m.versionStartIncluding ?? m.versionStartExcluding ?? ''
|
|
151
|
+
const versionEnd = m.versionEndExcluding ?? m.versionEndIncluding ?? ''
|
|
152
|
+
const versions = versionStart && versionEnd
|
|
153
|
+
? `${versionStart} to ${versionEnd}`
|
|
154
|
+
: versionStart || versionEnd || (parts[5] ?? '*')
|
|
155
|
+
return { vendor, product, versions }
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// References
|
|
161
|
+
const references = (cve.references ?? []).map((r) => ({
|
|
162
|
+
url: r.url ?? '',
|
|
163
|
+
source: r.source ?? '',
|
|
164
|
+
tags: Array.isArray(r.tags) ? r.tags : [],
|
|
165
|
+
}))
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
id: cve.id,
|
|
169
|
+
description: getEnDescription(cve.descriptions),
|
|
170
|
+
severity,
|
|
171
|
+
score,
|
|
172
|
+
cvssVector: vector,
|
|
173
|
+
publishedDate: cve.published,
|
|
174
|
+
lastModified: cve.lastModified,
|
|
175
|
+
status: cve.vulnStatus ?? '',
|
|
176
|
+
weaknesses,
|
|
177
|
+
affectedProducts,
|
|
178
|
+
references,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Search CVEs by keyword within a date window.
|
|
184
|
+
* @param {Object} options
|
|
185
|
+
* @param {string} [options.keyword] - Search keyword (optional — omit to return all recent CVEs)
|
|
186
|
+
* @param {number} [options.days=14] - Look-back window in days
|
|
187
|
+
* @param {string} [options.severity] - Optional minimum severity filter (low|medium|high|critical)
|
|
188
|
+
* @param {number} [options.limit=20] - Maximum results to return
|
|
189
|
+
* @returns {Promise<{ results: CveSearchResult[], totalResults: number }>}
|
|
190
|
+
*/
|
|
191
|
+
export async function searchCves({ keyword, days = 14, severity, limit = 20 }) {
|
|
192
|
+
const config = await loadConfig()
|
|
193
|
+
const apiKey = config.nvd?.apiKey
|
|
194
|
+
|
|
195
|
+
const now = new Date()
|
|
196
|
+
const past = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
|
197
|
+
|
|
198
|
+
// NVD requires ISO-8601 with time component, no trailing Z
|
|
199
|
+
const pubStartDate = past.toISOString().replace('Z', '')
|
|
200
|
+
const pubEndDate = now.toISOString().replace('Z', '')
|
|
201
|
+
|
|
202
|
+
const trimmedKeyword = keyword?.trim()
|
|
203
|
+
|
|
204
|
+
const params = buildParams({
|
|
205
|
+
...(trimmedKeyword ? { keywordSearch: trimmedKeyword } : {}),
|
|
206
|
+
pubStartDate,
|
|
207
|
+
pubEndDate,
|
|
208
|
+
resultsPerPage: limit,
|
|
209
|
+
...(severity ? { cvssV3Severity: severity.toUpperCase() } : {}),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const data = /** @type {any} */ (await nvdFetch(params, apiKey))
|
|
213
|
+
|
|
214
|
+
const results = (data.vulnerabilities ?? []).map(parseCveSearchResult)
|
|
215
|
+
return { results, totalResults: data.totalResults ?? results.length }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fetch full details for a single CVE by ID.
|
|
220
|
+
* @param {string} cveId - CVE identifier (e.g. "CVE-2021-44228")
|
|
221
|
+
* @returns {Promise<CveDetail>}
|
|
222
|
+
*/
|
|
223
|
+
export async function getCveDetail(cveId) {
|
|
224
|
+
if (!cveId || !/^CVE-\d{4}-\d{4,}$/i.test(cveId)) {
|
|
225
|
+
throw new DvmiError(
|
|
226
|
+
`Invalid CVE ID: ${cveId}`,
|
|
227
|
+
'CVE IDs must match the format CVE-YYYY-NNNNN (e.g. CVE-2021-44228)',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const config = await loadConfig()
|
|
232
|
+
const apiKey = config.nvd?.apiKey
|
|
233
|
+
|
|
234
|
+
const params = buildParams({ cveId: cveId.toUpperCase() })
|
|
235
|
+
const data = /** @type {any} */ (await nvdFetch(params, apiKey))
|
|
236
|
+
|
|
237
|
+
if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
|
|
238
|
+
throw new DvmiError(
|
|
239
|
+
`CVE not found: ${cveId}`,
|
|
240
|
+
'Verify the CVE ID is correct and exists in the NVD database.',
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return parseCveDetail(data.vulnerabilities[0])
|
|
245
|
+
}
|
package/src/types.js
CHANGED
|
@@ -358,11 +358,15 @@
|
|
|
358
358
|
*/
|
|
359
359
|
|
|
360
360
|
/**
|
|
361
|
-
* @typedef {Object}
|
|
362
|
-
*
|
|
363
|
-
* @property {
|
|
364
|
-
* @property {string}
|
|
365
|
-
* @property {
|
|
361
|
+
* @typedef {Object} CveSearchResult
|
|
362
|
+
* Represents a single CVE returned from a search query. Used by `dvmi vuln search`.
|
|
363
|
+
* @property {string} id
|
|
364
|
+
* @property {string} description
|
|
365
|
+
* @property {'Critical'|'High'|'Medium'|'Low'|'Unknown'} severity
|
|
366
|
+
* @property {number|null} score
|
|
367
|
+
* @property {string} publishedDate
|
|
368
|
+
* @property {string} lastModified
|
|
369
|
+
* @property {string|null} firstReference - First reference URL from the CVE record, or null
|
|
366
370
|
*/
|
|
367
371
|
|
|
368
372
|
/**
|
package/src/utils/errors.js
CHANGED