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,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} SearchMatch
362
- * @property {string} file - File path (e.g. "docs/deploy.md")
363
- * @property {number} line - Line number (1-based)
364
- * @property {string} context - Line text containing the match
365
- * @property {number} occurrences - Total number of occurrences in the file
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
  /**
@@ -28,6 +28,8 @@ export class ValidationError extends DvmiError {
28
28
  constructor(message, hint) {
29
29
  super(message, hint, 2)
30
30
  this.name = 'ValidationError'
31
+ // oclif reads this.oclif.exit to determine the process exit code
32
+ this.oclif = { exit: 2 }
31
33
  }
32
34
  }
33
35