bun-scan 1.0.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/LICENSE +22 -0
- package/README.md +338 -0
- package/SECURITY.md +360 -0
- package/package.json +69 -0
- package/src/cli.ts +180 -0
- package/src/client.ts +284 -0
- package/src/config.ts +151 -0
- package/src/constants.ts +121 -0
- package/src/index.ts +58 -0
- package/src/logger.ts +80 -0
- package/src/processor.ts +195 -0
- package/src/retry.ts +86 -0
- package/src/schema.ts +124 -0
- package/src/semver.ts +109 -0
- package/src/severity.ts +103 -0
- package/src/types.ts +28 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OSVQuery, OSVVulnerability } from "./schema.js"
|
|
7
|
+
import { OSVResponseSchema, OSVBatchResponseSchema, OSVVulnerabilitySchema } from "./schema.js"
|
|
8
|
+
import { OSV_API, HTTP, PERFORMANCE, getConfig, ENV } from "./constants.js"
|
|
9
|
+
import { withRetry } from "./retry.js"
|
|
10
|
+
import { logger } from "./logger.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* OSV API Client
|
|
14
|
+
* Handles all communication with OSV.dev API including batch queries and individual lookups
|
|
15
|
+
*/
|
|
16
|
+
export class OSVClient {
|
|
17
|
+
private readonly baseUrl: string
|
|
18
|
+
private readonly timeout: number
|
|
19
|
+
private readonly useBatch: boolean
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
this.baseUrl = getConfig(ENV.API_BASE_URL, OSV_API.BASE_URL)
|
|
23
|
+
this.timeout = getConfig(ENV.TIMEOUT_MS, OSV_API.TIMEOUT_MS)
|
|
24
|
+
this.useBatch = !getConfig(ENV.DISABLE_BATCH, false)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Query vulnerabilities for multiple packages
|
|
29
|
+
* Uses batch API when possible for better performance
|
|
30
|
+
*/
|
|
31
|
+
async queryVulnerabilities(packages: Bun.Security.Package[]): Promise<OSVVulnerability[]> {
|
|
32
|
+
if (packages.length === 0) {
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Deduplicate packages by name@version
|
|
37
|
+
const uniquePackages = this.deduplicatePackages(packages)
|
|
38
|
+
logger.info(`Scanning ${uniquePackages.length} unique packages (${packages.length} total)`)
|
|
39
|
+
|
|
40
|
+
// Create OSV queries
|
|
41
|
+
const queries = uniquePackages.map((pkg) => ({
|
|
42
|
+
package: {
|
|
43
|
+
name: pkg.name,
|
|
44
|
+
ecosystem: OSV_API.DEFAULT_ECOSYSTEM,
|
|
45
|
+
},
|
|
46
|
+
version: pkg.version,
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
if (this.useBatch && queries.length > 1) {
|
|
50
|
+
return await this.queryWithBatch(queries)
|
|
51
|
+
} else {
|
|
52
|
+
return await this.queryIndividually(queries)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Deduplicate packages by name@version to avoid redundant queries
|
|
58
|
+
*/
|
|
59
|
+
private deduplicatePackages(packages: Bun.Security.Package[]): Bun.Security.Package[] {
|
|
60
|
+
const packageMap = new Map<string, Bun.Security.Package>()
|
|
61
|
+
|
|
62
|
+
for (const pkg of packages) {
|
|
63
|
+
const key = `${pkg.name}@${pkg.version}`
|
|
64
|
+
if (!packageMap.has(key)) {
|
|
65
|
+
packageMap.set(key, pkg)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const uniquePackages = Array.from(packageMap.values())
|
|
70
|
+
|
|
71
|
+
if (uniquePackages.length < packages.length) {
|
|
72
|
+
logger.debug(
|
|
73
|
+
`Deduplicated ${packages.length} packages to ${uniquePackages.length} unique packages`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return uniquePackages
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Use batch API for efficient querying
|
|
82
|
+
* Follows OSV.dev recommended pattern: batch query → individual details
|
|
83
|
+
*/
|
|
84
|
+
private async queryWithBatch(queries: OSVQuery[]): Promise<OSVVulnerability[]> {
|
|
85
|
+
const vulnerabilityIds: string[] = []
|
|
86
|
+
|
|
87
|
+
// Process queries in batches
|
|
88
|
+
for (let i = 0; i < queries.length; i += OSV_API.MAX_BATCH_SIZE) {
|
|
89
|
+
const batchQueries = queries.slice(i, i + OSV_API.MAX_BATCH_SIZE)
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const batchIds = await this.executeBatchQuery(batchQueries)
|
|
93
|
+
vulnerabilityIds.push(...batchIds)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.error(`Batch query failed for ${batchQueries.length} packages`, {
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
startIndex: i,
|
|
98
|
+
})
|
|
99
|
+
// Continue with next batch rather than failing completely
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fetch detailed vulnerability information
|
|
104
|
+
return await this.fetchVulnerabilityDetails(vulnerabilityIds)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute a single batch query
|
|
109
|
+
*/
|
|
110
|
+
private async executeBatchQuery(queries: OSVQuery[]): Promise<string[]> {
|
|
111
|
+
const vulnerabilityIds: string[] = []
|
|
112
|
+
|
|
113
|
+
const response = await withRetry(async () => {
|
|
114
|
+
const res = await fetch(`${this.baseUrl}/querybatch`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": HTTP.CONTENT_TYPE,
|
|
118
|
+
"User-Agent": HTTP.USER_AGENT,
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify({ queries }),
|
|
121
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
throw new Error(`OSV API returned ${res.status}: ${res.statusText}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return res
|
|
129
|
+
}, `OSV batch query (${queries.length} packages)`)
|
|
130
|
+
|
|
131
|
+
const data = await response.json()
|
|
132
|
+
const parsed = OSVBatchResponseSchema.parse(data)
|
|
133
|
+
|
|
134
|
+
// Extract vulnerability IDs from batch response
|
|
135
|
+
for (const result of parsed.results) {
|
|
136
|
+
if (result.vulns) {
|
|
137
|
+
vulnerabilityIds.push(...result.vulns.map((v) => v.id))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const vulnCount = parsed.results.reduce((sum, r) => sum + (r.vulns?.length || 0), 0)
|
|
142
|
+
logger.info(`Batch query found ${vulnCount} vulnerabilities across ${queries.length} packages`)
|
|
143
|
+
|
|
144
|
+
return [...new Set(vulnerabilityIds)] // Deduplicate IDs
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Query packages individually (fallback method)
|
|
149
|
+
*/
|
|
150
|
+
private async queryIndividually(queries: OSVQuery[]): Promise<OSVVulnerability[]> {
|
|
151
|
+
const responses = await Promise.allSettled(
|
|
152
|
+
queries.map((query) => this.querySinglePackage(query)),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const vulnerabilities: OSVVulnerability[] = []
|
|
156
|
+
let successCount = 0
|
|
157
|
+
|
|
158
|
+
for (const response of responses) {
|
|
159
|
+
if (response.status === "fulfilled") {
|
|
160
|
+
vulnerabilities.push(...response.value)
|
|
161
|
+
successCount++
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logger.info(`Individual queries completed: ${successCount}/${queries.length} successful`)
|
|
166
|
+
return vulnerabilities
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Query a single package with pagination support
|
|
171
|
+
*/
|
|
172
|
+
private async querySinglePackage(query: OSVQuery): Promise<OSVVulnerability[]> {
|
|
173
|
+
const allVulns: OSVVulnerability[] = []
|
|
174
|
+
let currentQuery = { ...query }
|
|
175
|
+
|
|
176
|
+
while (true) {
|
|
177
|
+
try {
|
|
178
|
+
const response = await withRetry(
|
|
179
|
+
async () => {
|
|
180
|
+
const res = await fetch(`${this.baseUrl}/query`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: {
|
|
183
|
+
"Content-Type": HTTP.CONTENT_TYPE,
|
|
184
|
+
"User-Agent": HTTP.USER_AGENT,
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify(currentQuery),
|
|
187
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return res
|
|
195
|
+
},
|
|
196
|
+
`OSV query for ${query.package?.name || "unknown"}@${query.version || "unknown"}`,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const data = await response.json()
|
|
200
|
+
const parsed = OSVResponseSchema.parse(data)
|
|
201
|
+
|
|
202
|
+
// Add vulnerabilities from this page
|
|
203
|
+
if (parsed.vulns) {
|
|
204
|
+
allVulns.push(...parsed.vulns)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check for pagination
|
|
208
|
+
if (parsed.next_page_token) {
|
|
209
|
+
currentQuery = { ...query, page_token: parsed.next_page_token }
|
|
210
|
+
} else {
|
|
211
|
+
break // No more pages
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
logger.warn(
|
|
215
|
+
`Query failed for ${query.package?.name || "unknown"}@${query.version || "unknown"}`,
|
|
216
|
+
{
|
|
217
|
+
error: error instanceof Error ? error.message : String(error),
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
break // Exit pagination loop on error
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return allVulns
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Fetch detailed vulnerability information by IDs
|
|
229
|
+
*/
|
|
230
|
+
private async fetchVulnerabilityDetails(ids: string[]): Promise<OSVVulnerability[]> {
|
|
231
|
+
if (ids.length === 0) return []
|
|
232
|
+
|
|
233
|
+
const uniqueIds = [...new Set(ids)] // Deduplicate requests
|
|
234
|
+
logger.info(`Fetching details for ${uniqueIds.length} vulnerabilities`)
|
|
235
|
+
|
|
236
|
+
// Process in smaller chunks to avoid overwhelming the API
|
|
237
|
+
const chunkSize = PERFORMANCE.MAX_CONCURRENT_DETAILS
|
|
238
|
+
const vulnerabilities: OSVVulnerability[] = []
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
|
241
|
+
const chunk = uniqueIds.slice(i, i + chunkSize)
|
|
242
|
+
const chunkResults = await Promise.allSettled(
|
|
243
|
+
chunk.map((id) => this.fetchSingleVulnerability(id)),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for (const result of chunkResults) {
|
|
247
|
+
if (result.status === "fulfilled" && result.value) {
|
|
248
|
+
vulnerabilities.push(result.value)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
logger.info(`Retrieved ${vulnerabilities.length}/${uniqueIds.length} vulnerability details`)
|
|
254
|
+
return vulnerabilities
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fetch a single vulnerability by ID
|
|
259
|
+
*/
|
|
260
|
+
private async fetchSingleVulnerability(id: string): Promise<OSVVulnerability | null> {
|
|
261
|
+
try {
|
|
262
|
+
return await withRetry(async () => {
|
|
263
|
+
const response = await fetch(`${this.baseUrl}/vulns/${id}`, {
|
|
264
|
+
headers: {
|
|
265
|
+
"User-Agent": HTTP.USER_AGENT,
|
|
266
|
+
},
|
|
267
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const data = await response.json()
|
|
275
|
+
return OSVVulnerabilitySchema.parse(data)
|
|
276
|
+
}, `Get vulnerability ${id}`)
|
|
277
|
+
} catch (error) {
|
|
278
|
+
logger.warn(`Failed to fetch vulnerability ${id}`, {
|
|
279
|
+
error: error instanceof Error ? error.message : String(error),
|
|
280
|
+
})
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod"
|
|
7
|
+
import { logger } from "./logger.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Schema for package-specific ignore rules
|
|
11
|
+
*/
|
|
12
|
+
const IgnorePackageRuleSchema = z.object({
|
|
13
|
+
/** Vulnerability IDs to ignore for this package (CVE-*, GHSA-*) */
|
|
14
|
+
vulnerabilities: z.array(z.string()).optional(),
|
|
15
|
+
/** Ignore until this date (ISO 8601 format) - for temporary ignores */
|
|
16
|
+
until: z.string().optional(),
|
|
17
|
+
/** Reason for ignoring (for documentation) */
|
|
18
|
+
reason: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Schema for the ignore configuration file
|
|
23
|
+
*/
|
|
24
|
+
export const IgnoreConfigSchema = z.object({
|
|
25
|
+
/** Vulnerability IDs to ignore globally (CVE-*, GHSA-*) */
|
|
26
|
+
ignore: z.array(z.string()).optional(),
|
|
27
|
+
/** Package-specific ignore rules */
|
|
28
|
+
packages: z.record(z.string(), IgnorePackageRuleSchema).optional(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export type IgnoreConfig = z.infer<typeof IgnoreConfigSchema>
|
|
32
|
+
export type IgnorePackageRule = z.infer<typeof IgnorePackageRuleSchema>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default config file names to search for (in order of priority)
|
|
36
|
+
*/
|
|
37
|
+
const CONFIG_FILES = [".bun-scan.json", ".bun-scan.config.json"] as const
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load ignore configuration from the current working directory
|
|
41
|
+
*/
|
|
42
|
+
export async function loadIgnoreConfig(): Promise<IgnoreConfig> {
|
|
43
|
+
for (const filename of CONFIG_FILES) {
|
|
44
|
+
const config = await tryLoadConfigFile(filename)
|
|
45
|
+
if (config) {
|
|
46
|
+
return config
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// No config file found - return empty config
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Try to load and parse a config file
|
|
56
|
+
*/
|
|
57
|
+
async function tryLoadConfigFile(filename: string): Promise<IgnoreConfig | null> {
|
|
58
|
+
try {
|
|
59
|
+
const file = Bun.file(filename)
|
|
60
|
+
const exists = await file.exists()
|
|
61
|
+
|
|
62
|
+
if (!exists) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const content = await file.json()
|
|
67
|
+
const parsed = IgnoreConfigSchema.parse(content)
|
|
68
|
+
|
|
69
|
+
logger.info(`Loaded ignore configuration from ${filename}`)
|
|
70
|
+
logIgnoreStats(parsed)
|
|
71
|
+
|
|
72
|
+
return parsed
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof z.ZodError) {
|
|
75
|
+
logger.warn(`Invalid ignore config in ${filename}`, {
|
|
76
|
+
errors: error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
|
|
77
|
+
})
|
|
78
|
+
} else if (error instanceof SyntaxError) {
|
|
79
|
+
logger.warn(`Failed to parse ${filename} as JSON`, {
|
|
80
|
+
error: error.message,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
// For other errors (file not found, etc.), silently continue
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Log statistics about the loaded ignore configuration
|
|
90
|
+
*/
|
|
91
|
+
function logIgnoreStats(config: IgnoreConfig): void {
|
|
92
|
+
const globalIgnores = config.ignore?.length ?? 0
|
|
93
|
+
const packageRules = Object.keys(config.packages ?? {}).length
|
|
94
|
+
|
|
95
|
+
if (globalIgnores > 0 || packageRules > 0) {
|
|
96
|
+
logger.info(`Ignore rules loaded`, {
|
|
97
|
+
globalIgnores,
|
|
98
|
+
packageRules,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a vulnerability should be ignored based on the config
|
|
105
|
+
*/
|
|
106
|
+
export function shouldIgnoreVulnerability(
|
|
107
|
+
vulnId: string,
|
|
108
|
+
vulnAliases: string[] | undefined,
|
|
109
|
+
packageName: string | undefined,
|
|
110
|
+
config: IgnoreConfig,
|
|
111
|
+
): { ignored: boolean; reason?: string } {
|
|
112
|
+
// Get all IDs to check (primary ID + aliases)
|
|
113
|
+
const idsToCheck = [vulnId, ...(vulnAliases ?? [])]
|
|
114
|
+
|
|
115
|
+
// Check global ignores
|
|
116
|
+
if (config.ignore) {
|
|
117
|
+
for (const id of idsToCheck) {
|
|
118
|
+
if (config.ignore.includes(id)) {
|
|
119
|
+
return { ignored: true, reason: `globally ignored (${id})` }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check package-specific ignores
|
|
125
|
+
if (packageName && config.packages?.[packageName]) {
|
|
126
|
+
const rule = config.packages[packageName]
|
|
127
|
+
|
|
128
|
+
// Check if the ignore has expired
|
|
129
|
+
if (rule.until) {
|
|
130
|
+
const untilDate = new Date(rule.until)
|
|
131
|
+
if (untilDate < new Date()) {
|
|
132
|
+
logger.debug(`Ignore rule for ${packageName} expired on ${rule.until}`)
|
|
133
|
+
return { ignored: false }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check package-specific vulnerability ignores
|
|
138
|
+
if (rule.vulnerabilities) {
|
|
139
|
+
for (const id of idsToCheck) {
|
|
140
|
+
if (rule.vulnerabilities.includes(id)) {
|
|
141
|
+
const reason = rule.reason
|
|
142
|
+
? `package rule: ${rule.reason}`
|
|
143
|
+
: `ignored for ${packageName} (${id})`
|
|
144
|
+
return { ignored: true, reason }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { ignored: false }
|
|
151
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Centralized configuration constants for OSV Scanner
|
|
8
|
+
* All magic numbers and configuration values consolidated here
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FatalSeverity } from "./types.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OSV API Configuration
|
|
15
|
+
*/
|
|
16
|
+
export const OSV_API = {
|
|
17
|
+
/** Base URL for OSV API */
|
|
18
|
+
BASE_URL: "https://api.osv.dev/v1",
|
|
19
|
+
|
|
20
|
+
/** Request timeout in milliseconds */
|
|
21
|
+
TIMEOUT_MS: 30_000,
|
|
22
|
+
|
|
23
|
+
/** Maximum packages per batch query */
|
|
24
|
+
MAX_BATCH_SIZE: 1_000,
|
|
25
|
+
|
|
26
|
+
/** Maximum retry attempts for failed requests */
|
|
27
|
+
MAX_RETRY_ATTEMPTS: 2,
|
|
28
|
+
|
|
29
|
+
/** Delay between retry attempts in milliseconds */
|
|
30
|
+
RETRY_DELAY_MS: 1_000,
|
|
31
|
+
|
|
32
|
+
/** Default ecosystem for npm packages */
|
|
33
|
+
DEFAULT_ECOSYSTEM: "npm",
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* HTTP Configuration
|
|
38
|
+
*/
|
|
39
|
+
export const HTTP = {
|
|
40
|
+
/** Content type for API requests */
|
|
41
|
+
CONTENT_TYPE: "application/json",
|
|
42
|
+
|
|
43
|
+
/** User agent for requests */
|
|
44
|
+
USER_AGENT: "bun-osv-scanner/1.0.0",
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Security Configuration
|
|
49
|
+
*/
|
|
50
|
+
export const SECURITY = {
|
|
51
|
+
/** CVSS score threshold for fatal advisories */
|
|
52
|
+
CVSS_FATAL_THRESHOLD: 7.0,
|
|
53
|
+
|
|
54
|
+
/** Database severities that map to fatal level */
|
|
55
|
+
FATAL_SEVERITIES: ["CRITICAL", "HIGH"] as const satisfies readonly FatalSeverity[],
|
|
56
|
+
|
|
57
|
+
/** Maximum vulnerabilities to process per package */
|
|
58
|
+
MAX_VULNERABILITIES_PER_PACKAGE: 100,
|
|
59
|
+
|
|
60
|
+
/** Maximum length for vulnerability descriptions */
|
|
61
|
+
MAX_DESCRIPTION_LENGTH: 200,
|
|
62
|
+
} as const
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Performance Configuration
|
|
66
|
+
*/
|
|
67
|
+
export const PERFORMANCE = {
|
|
68
|
+
/** Enable batch queries for better performance */
|
|
69
|
+
USE_BATCH_QUERIES: true,
|
|
70
|
+
|
|
71
|
+
/** Maximum concurrent vulnerability detail requests */
|
|
72
|
+
MAX_CONCURRENT_DETAILS: 10,
|
|
73
|
+
|
|
74
|
+
/** Maximum response size in bytes (32MB) */
|
|
75
|
+
MAX_RESPONSE_SIZE: 32 * 1024 * 1024,
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Environment variable configuration
|
|
80
|
+
*/
|
|
81
|
+
export const ENV = {
|
|
82
|
+
/** Log level environment variable */
|
|
83
|
+
LOG_LEVEL: "OSV_LOG_LEVEL",
|
|
84
|
+
|
|
85
|
+
/** Custom API base URL override */
|
|
86
|
+
API_BASE_URL: "OSV_API_BASE_URL",
|
|
87
|
+
|
|
88
|
+
/** Custom timeout override */
|
|
89
|
+
TIMEOUT_MS: "OSV_TIMEOUT_MS",
|
|
90
|
+
|
|
91
|
+
/** Disable batch queries */
|
|
92
|
+
DISABLE_BATCH: "OSV_DISABLE_BATCH",
|
|
93
|
+
} as const
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get configuration value with environment variable override
|
|
97
|
+
*/
|
|
98
|
+
export function getConfig<T>(envVar: string, defaultValue: T, parser?: (value: string) => T): T {
|
|
99
|
+
const envValue = Bun.env[envVar]
|
|
100
|
+
if (!envValue) return defaultValue
|
|
101
|
+
|
|
102
|
+
if (parser) {
|
|
103
|
+
try {
|
|
104
|
+
return parser(envValue)
|
|
105
|
+
} catch {
|
|
106
|
+
return defaultValue
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Type-safe parsing for common types
|
|
111
|
+
if (typeof defaultValue === "number") {
|
|
112
|
+
const parsed = Number(envValue)
|
|
113
|
+
return (Number.isNaN(parsed) ? defaultValue : parsed) as T
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof defaultValue === "boolean") {
|
|
117
|
+
return (envValue.toLowerCase() === "true") as T
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return envValue as T
|
|
121
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/// <reference types="bun-types" />
|
|
7
|
+
import "./types.js"
|
|
8
|
+
import { OSVClient } from "./client.js"
|
|
9
|
+
import { VulnerabilityProcessor } from "./processor.js"
|
|
10
|
+
import { logger } from "./logger.js"
|
|
11
|
+
import { loadIgnoreConfig } from "./config.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bun Security Scanner for OSV.dev vulnerability detection
|
|
15
|
+
* Integrates with Google's OSV database to detect vulnerabilities in npm packages
|
|
16
|
+
*/
|
|
17
|
+
export const scanner: Bun.Security.Scanner = {
|
|
18
|
+
version: "1", // This is the version of Bun security scanner implementation. You should keep this set as '1'
|
|
19
|
+
|
|
20
|
+
async scan({ packages }) {
|
|
21
|
+
try {
|
|
22
|
+
logger.info(`Starting OSV scan for ${packages.length} packages`)
|
|
23
|
+
|
|
24
|
+
// Load ignore configuration
|
|
25
|
+
const ignoreConfig = await loadIgnoreConfig()
|
|
26
|
+
|
|
27
|
+
// Initialize components
|
|
28
|
+
const client = new OSVClient()
|
|
29
|
+
const processor = new VulnerabilityProcessor(ignoreConfig)
|
|
30
|
+
|
|
31
|
+
// Fetch vulnerabilities from OSV.dev
|
|
32
|
+
const vulnerabilities = await client.queryVulnerabilities(packages)
|
|
33
|
+
|
|
34
|
+
// Process vulnerabilities into security advisories
|
|
35
|
+
const advisories = processor.processVulnerabilities(vulnerabilities, packages)
|
|
36
|
+
|
|
37
|
+
logger.info(
|
|
38
|
+
`OSV scan completed: ${advisories.length} advisories found for ${packages.length} packages`,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return advisories
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
44
|
+
logger.error("OSV scanner encountered an unexpected error", {
|
|
45
|
+
error: message,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Fail-safe: allow installation to proceed on scanner errors
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// CLI entry point
|
|
55
|
+
if (import.meta.main) {
|
|
56
|
+
const { runCli } = await import("./cli.js")
|
|
57
|
+
await runCli()
|
|
58
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Structured logging utilities for OSV Scanner
|
|
8
|
+
* Provides consistent, configurable logging with proper levels and context
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ENV } from "./constants.js"
|
|
12
|
+
|
|
13
|
+
export type LogLevel = "debug" | "info" | "warn" | "error"
|
|
14
|
+
export type LogContext = Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
interface Logger {
|
|
17
|
+
debug(message: string, context?: LogContext): void
|
|
18
|
+
info(message: string, context?: LogContext): void
|
|
19
|
+
warn(message: string, context?: LogContext): void
|
|
20
|
+
error(message: string, context?: LogContext): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class OSVLogger implements Logger {
|
|
24
|
+
private readonly levels = { debug: 0, info: 1, warn: 2, error: 3 }
|
|
25
|
+
|
|
26
|
+
private parseLogLevel(level?: string): LogLevel | null {
|
|
27
|
+
if (!level) return null
|
|
28
|
+
const normalized = level.toLowerCase()
|
|
29
|
+
return ["debug", "info", "warn", "error"].includes(normalized) ? (normalized as LogLevel) : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private get minLevel(): LogLevel {
|
|
33
|
+
return this.parseLogLevel(process.env[ENV.LOG_LEVEL]) || "info"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private shouldLog(level: LogLevel): boolean {
|
|
37
|
+
return this.levels[level] >= this.levels[this.minLevel]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private safeStringify(obj: unknown): string {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(obj)
|
|
43
|
+
} catch {
|
|
44
|
+
return "[Circular]"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
|
|
49
|
+
const timestamp = new Date().toISOString()
|
|
50
|
+
const prefix = `[${timestamp}] OSV-${level.toUpperCase()}:`
|
|
51
|
+
const contextStr = context ? ` ${this.safeStringify(context)}` : ""
|
|
52
|
+
return `${prefix} ${message}${contextStr}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
debug(message: string, context?: LogContext): void {
|
|
56
|
+
if (this.shouldLog("debug")) {
|
|
57
|
+
console.debug(this.formatMessage("debug", message, context))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
info(message: string, context?: LogContext): void {
|
|
62
|
+
if (this.shouldLog("info")) {
|
|
63
|
+
console.info(this.formatMessage("info", message, context))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
warn(message: string, context?: LogContext): void {
|
|
68
|
+
if (this.shouldLog("warn")) {
|
|
69
|
+
console.warn(this.formatMessage("warn", message, context))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
error(message: string, context?: LogContext): void {
|
|
74
|
+
if (this.shouldLog("error")) {
|
|
75
|
+
console.error(this.formatMessage("error", message, context))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const logger = new OSVLogger()
|