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,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
+ }