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/processor.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OSVVulnerability } from "./schema.js"
|
|
7
|
+
import { isPackageAffected } from "./semver.js"
|
|
8
|
+
import { mapSeverityToLevel } from "./severity.js"
|
|
9
|
+
import { SECURITY } from "./constants.js"
|
|
10
|
+
import { logger } from "./logger.js"
|
|
11
|
+
import { type IgnoreConfig, shouldIgnoreVulnerability } from "./config.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Process OSV vulnerabilities into Bun security advisories
|
|
15
|
+
* Handles vulnerability-to-package matching and advisory generation
|
|
16
|
+
*/
|
|
17
|
+
export class VulnerabilityProcessor {
|
|
18
|
+
private ignoreConfig: IgnoreConfig
|
|
19
|
+
private ignoredCount = 0
|
|
20
|
+
|
|
21
|
+
constructor(ignoreConfig: IgnoreConfig = {}) {
|
|
22
|
+
this.ignoreConfig = ignoreConfig
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert OSV vulnerabilities to Bun security advisories
|
|
27
|
+
* Matches vulnerabilities against input packages and generates appropriate advisories
|
|
28
|
+
*/
|
|
29
|
+
processVulnerabilities(
|
|
30
|
+
vulnerabilities: OSVVulnerability[],
|
|
31
|
+
packages: Bun.Security.Package[],
|
|
32
|
+
): Bun.Security.Advisory[] {
|
|
33
|
+
if (vulnerabilities.length === 0 || packages.length === 0) {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.info(
|
|
38
|
+
`Processing ${vulnerabilities.length} vulnerabilities against ${packages.length} packages`,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const advisories: Bun.Security.Advisory[] = []
|
|
42
|
+
const processedPairs = new Set<string>() // Track processed vuln+package pairs
|
|
43
|
+
this.ignoredCount = 0
|
|
44
|
+
|
|
45
|
+
for (const vuln of vulnerabilities) {
|
|
46
|
+
const vulnAdvisories = this.processVulnerability(vuln, packages, processedPairs)
|
|
47
|
+
advisories.push(...vulnAdvisories)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (this.ignoredCount > 0) {
|
|
51
|
+
logger.info(`Ignored ${this.ignoredCount} vulnerabilities based on config`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.info(`Generated ${advisories.length} security advisories`)
|
|
55
|
+
return advisories
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Process a single vulnerability against all packages
|
|
60
|
+
*/
|
|
61
|
+
private processVulnerability(
|
|
62
|
+
vuln: OSVVulnerability,
|
|
63
|
+
packages: Bun.Security.Package[],
|
|
64
|
+
processedPairs: Set<string>,
|
|
65
|
+
): Bun.Security.Advisory[] {
|
|
66
|
+
const advisories: Bun.Security.Advisory[] = []
|
|
67
|
+
|
|
68
|
+
if (!vuln.affected) {
|
|
69
|
+
logger.debug(`Vulnerability ${vuln.id} has no affected packages`)
|
|
70
|
+
return advisories
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const affected of vuln.affected) {
|
|
74
|
+
for (const pkg of packages) {
|
|
75
|
+
const pairKey = `${vuln.id}:${pkg.name}@${pkg.version}`
|
|
76
|
+
|
|
77
|
+
// Avoid duplicate advisories for same vulnerability+package
|
|
78
|
+
if (processedPairs.has(pairKey)) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isPackageAffected(pkg, affected)) {
|
|
83
|
+
// Check if this vulnerability should be ignored
|
|
84
|
+
const ignoreResult = shouldIgnoreVulnerability(
|
|
85
|
+
vuln.id,
|
|
86
|
+
vuln.aliases,
|
|
87
|
+
pkg.name,
|
|
88
|
+
this.ignoreConfig,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (ignoreResult.ignored) {
|
|
92
|
+
logger.debug(`Ignoring ${vuln.id} for ${pkg.name}: ${ignoreResult.reason}`)
|
|
93
|
+
this.ignoredCount++
|
|
94
|
+
processedPairs.add(pairKey)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const advisory = this.createAdvisory(vuln, pkg)
|
|
99
|
+
advisories.push(advisory)
|
|
100
|
+
processedPairs.add(pairKey)
|
|
101
|
+
|
|
102
|
+
logger.debug(`Created advisory for ${pkg.name}@${pkg.version}`, {
|
|
103
|
+
vulnerability: vuln.id,
|
|
104
|
+
level: advisory.level,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Only create one advisory per package per vulnerability
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return advisories
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a Bun security advisory from an OSV vulnerability and affected package
|
|
118
|
+
*/
|
|
119
|
+
private createAdvisory(vuln: OSVVulnerability, pkg: Bun.Security.Package): Bun.Security.Advisory {
|
|
120
|
+
const level = mapSeverityToLevel(vuln)
|
|
121
|
+
const url = this.getVulnerabilityUrl(vuln)
|
|
122
|
+
const description = this.getVulnerabilityDescription(vuln)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: vuln.id,
|
|
126
|
+
message: vuln.summary || vuln.details || vuln.id,
|
|
127
|
+
level,
|
|
128
|
+
package: pkg.name,
|
|
129
|
+
url,
|
|
130
|
+
description,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the best URL to reference for this vulnerability
|
|
136
|
+
* Prioritizes official references and known vulnerability databases
|
|
137
|
+
*/
|
|
138
|
+
private getVulnerabilityUrl(vuln: OSVVulnerability): string | null {
|
|
139
|
+
if (!vuln.references || vuln.references.length === 0) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Prioritize official advisory URLs
|
|
144
|
+
const advisoryRef = vuln.references.find(
|
|
145
|
+
(ref) => ref.type === "ADVISORY" || ref.url.includes("github.com/advisories"),
|
|
146
|
+
)
|
|
147
|
+
if (advisoryRef) {
|
|
148
|
+
return advisoryRef.url
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Then CVE URLs
|
|
152
|
+
const cveRef = vuln.references.find((ref) => {
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(ref.url)
|
|
155
|
+
return url.hostname === "cve.mitre.org" || url.hostname === "nvd.nist.gov"
|
|
156
|
+
} catch {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
if (cveRef) {
|
|
161
|
+
return cveRef.url
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fall back to first reference
|
|
165
|
+
return vuln.references[0]?.url || null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get a descriptive summary of the vulnerability
|
|
170
|
+
* Uses summary, details, or fallback description
|
|
171
|
+
*/
|
|
172
|
+
private getVulnerabilityDescription(vuln: OSVVulnerability): string | null {
|
|
173
|
+
// Prefer concise summary
|
|
174
|
+
if (vuln.summary?.trim()) {
|
|
175
|
+
return vuln.summary.trim()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fall back to details (truncated if too long)
|
|
179
|
+
if (vuln.details?.trim()) {
|
|
180
|
+
const details = vuln.details.trim()
|
|
181
|
+
if (details.length <= SECURITY.MAX_DESCRIPTION_LENGTH) {
|
|
182
|
+
return details
|
|
183
|
+
}
|
|
184
|
+
// Truncate long details to first sentence or max length
|
|
185
|
+
const firstSentence = details.match(/^[^.!?]*[.!?]/)?.[0]
|
|
186
|
+
if (firstSentence && firstSentence.length <= SECURITY.MAX_DESCRIPTION_LENGTH) {
|
|
187
|
+
return firstSentence
|
|
188
|
+
}
|
|
189
|
+
return `${details.substring(0, SECURITY.MAX_DESCRIPTION_LENGTH - 3)}...`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// No description available
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { OSV_API } from "./constants.js"
|
|
7
|
+
import { logger } from "./logger.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Retry configuration for network operations
|
|
11
|
+
*/
|
|
12
|
+
export interface RetryConfig {
|
|
13
|
+
maxAttempts: number
|
|
14
|
+
delayMs: number
|
|
15
|
+
shouldRetry?: (error: Error) => boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default retry configuration
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
22
|
+
maxAttempts: OSV_API.MAX_RETRY_ATTEMPTS + 1,
|
|
23
|
+
delayMs: OSV_API.RETRY_DELAY_MS,
|
|
24
|
+
shouldRetry: (error: Error) => {
|
|
25
|
+
// Don't retry on 4xx client errors (except rate limiting)
|
|
26
|
+
if (
|
|
27
|
+
error.message.includes("400") ||
|
|
28
|
+
error.message.includes("401") ||
|
|
29
|
+
error.message.includes("403")
|
|
30
|
+
) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
if (error.message.includes("404")) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
// Retry on 5xx server errors and network issues
|
|
37
|
+
return true
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Execute an operation with retry logic
|
|
43
|
+
* Provides exponential backoff and configurable retry conditions
|
|
44
|
+
*/
|
|
45
|
+
export async function withRetry<T>(
|
|
46
|
+
operation: () => Promise<T>,
|
|
47
|
+
operationName: string,
|
|
48
|
+
config: RetryConfig = DEFAULT_RETRY_CONFIG,
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
let lastError: Error = new Error("Unknown error")
|
|
51
|
+
|
|
52
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
|
53
|
+
try {
|
|
54
|
+
const result = await operation()
|
|
55
|
+
|
|
56
|
+
if (attempt > 1) {
|
|
57
|
+
logger.info(`${operationName} succeeded on attempt ${attempt}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result
|
|
61
|
+
} catch (error) {
|
|
62
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
63
|
+
|
|
64
|
+
const isLastAttempt = attempt === config.maxAttempts
|
|
65
|
+
const shouldRetry = config.shouldRetry?.(lastError) ?? true
|
|
66
|
+
|
|
67
|
+
if (isLastAttempt || !shouldRetry) {
|
|
68
|
+
logger.error(`${operationName} failed after ${attempt} attempts`, {
|
|
69
|
+
error: lastError.message,
|
|
70
|
+
attempts: attempt,
|
|
71
|
+
})
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const delay = config.delayMs * 1.5 ** (attempt - 1) // Exponential backoff
|
|
76
|
+
logger.warn(`${operationName} attempt ${attempt} failed, retrying in ${delay}ms`, {
|
|
77
|
+
error: lastError.message,
|
|
78
|
+
nextDelay: delay,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw lastError
|
|
86
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod"
|
|
7
|
+
|
|
8
|
+
// OSV API request schema
|
|
9
|
+
export const OSVQuerySchema = z.object({
|
|
10
|
+
commit: z.string().optional(),
|
|
11
|
+
version: z.string().optional(),
|
|
12
|
+
package: z
|
|
13
|
+
.object({
|
|
14
|
+
name: z.string(),
|
|
15
|
+
ecosystem: z.string(),
|
|
16
|
+
purl: z.string().optional(),
|
|
17
|
+
})
|
|
18
|
+
.optional(),
|
|
19
|
+
page_token: z.string().optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// OSV affected package schema
|
|
23
|
+
export const OSVAffectedSchema = z.object({
|
|
24
|
+
package: z.object({
|
|
25
|
+
name: z.string(),
|
|
26
|
+
ecosystem: z.string(),
|
|
27
|
+
purl: z.string().optional(),
|
|
28
|
+
}),
|
|
29
|
+
ranges: z
|
|
30
|
+
.array(
|
|
31
|
+
z.object({
|
|
32
|
+
type: z.string(),
|
|
33
|
+
repo: z.string().optional(),
|
|
34
|
+
events: z.array(
|
|
35
|
+
z.object({
|
|
36
|
+
introduced: z.string().optional(),
|
|
37
|
+
fixed: z.string().optional(),
|
|
38
|
+
last_affected: z.string().optional(),
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
.optional(),
|
|
44
|
+
versions: z.array(z.string()).optional(),
|
|
45
|
+
ecosystem_specific: z.record(z.string(), z.any()).optional(),
|
|
46
|
+
database_specific: z.record(z.string(), z.any()).optional(),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// OSV vulnerability schema
|
|
50
|
+
export const OSVVulnerabilitySchema = z.object({
|
|
51
|
+
id: z.string(),
|
|
52
|
+
summary: z.string().optional(),
|
|
53
|
+
details: z.string().optional(),
|
|
54
|
+
modified: z.string().optional(),
|
|
55
|
+
published: z.string().optional(),
|
|
56
|
+
withdrawn: z.string().optional(),
|
|
57
|
+
aliases: z.array(z.string()).optional(),
|
|
58
|
+
related: z.array(z.string()).optional(),
|
|
59
|
+
schema_version: z.string().optional(),
|
|
60
|
+
affected: z.array(OSVAffectedSchema).optional(),
|
|
61
|
+
references: z
|
|
62
|
+
.array(
|
|
63
|
+
z.object({
|
|
64
|
+
type: z.string().optional(),
|
|
65
|
+
url: z.string(),
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
.optional(),
|
|
69
|
+
database_specific: z.record(z.string(), z.any()).optional(),
|
|
70
|
+
severity: z
|
|
71
|
+
.array(
|
|
72
|
+
z.object({
|
|
73
|
+
type: z.string(),
|
|
74
|
+
score: z.string(),
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
.optional(),
|
|
78
|
+
ecosystem_specific: z.record(z.string(), z.any()).optional(),
|
|
79
|
+
credits: z
|
|
80
|
+
.array(
|
|
81
|
+
z.object({
|
|
82
|
+
name: z.string(),
|
|
83
|
+
contact: z.array(z.string()).optional(),
|
|
84
|
+
type: z.string().optional(),
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
.optional(),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// OSV API response schema
|
|
91
|
+
export const OSVResponseSchema = z.object({
|
|
92
|
+
vulns: z.array(OSVVulnerabilitySchema).optional(),
|
|
93
|
+
next_page_token: z.string().optional(),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// OSV batch query schemas
|
|
97
|
+
export const OSVBatchQuerySchema = z.object({
|
|
98
|
+
queries: z.array(OSVQuerySchema),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
export const OSVBatchResponseSchema = z.object({
|
|
102
|
+
results: z.array(
|
|
103
|
+
z.object({
|
|
104
|
+
vulns: z
|
|
105
|
+
.array(
|
|
106
|
+
z.object({
|
|
107
|
+
id: z.string(),
|
|
108
|
+
modified: z.string(),
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
.optional(),
|
|
112
|
+
next_page_token: z.string().optional(),
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Exported types
|
|
118
|
+
export type OSVQuery = z.infer<typeof OSVQuerySchema>
|
|
119
|
+
export type OSVAffected = z.infer<typeof OSVAffectedSchema>
|
|
120
|
+
export type OSVVulnerability = z.infer<typeof OSVVulnerabilitySchema>
|
|
121
|
+
export type OSVResponse = z.infer<typeof OSVResponseSchema>
|
|
122
|
+
export type OSVBatchQuery = z.infer<typeof OSVBatchQuerySchema>
|
|
123
|
+
export type OSVBatchResponse = z.infer<typeof OSVBatchResponseSchema>
|
|
124
|
+
export type OSVSeverity = NonNullable<OSVVulnerability["severity"]>[0]
|
package/src/semver.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OSVAffected } from "./schema.js"
|
|
7
|
+
import { logger } from "./logger.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a package version is affected by an OSV vulnerability
|
|
11
|
+
* Handles complex OSV range formats including introduced/fixed/last_affected events
|
|
12
|
+
*/
|
|
13
|
+
export function isPackageAffected(pkg: Bun.Security.Package, affected: OSVAffected): boolean {
|
|
14
|
+
// Package name must match
|
|
15
|
+
if (affected.package.name !== pkg.name) {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check explicit versions list first (fastest path)
|
|
20
|
+
if (affected.versions?.includes(pkg.version)) {
|
|
21
|
+
logger.debug(`Package ${pkg.name}@${pkg.version} found in explicit versions list`)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check version ranges
|
|
26
|
+
if (affected.ranges) {
|
|
27
|
+
for (const range of affected.ranges) {
|
|
28
|
+
if (isVersionInRange(pkg.version, range)) {
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a version falls within an OSV range
|
|
39
|
+
* Supports SEMVER and other range types
|
|
40
|
+
*/
|
|
41
|
+
function isVersionInRange(version: string, range: NonNullable<OSVAffected["ranges"]>[0]): boolean {
|
|
42
|
+
if (!range.events || range.events.length === 0) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (range.type === "SEMVER") {
|
|
47
|
+
return isVersionInSemverRange(version, range)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// For non-SEMVER ranges (GIT, ECOSYSTEM), we can't reliably compare
|
|
51
|
+
logger.debug(`Unsupported range type: ${range.type}`, { range })
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if version satisfies SEMVER range events
|
|
57
|
+
* Handles OSV's introduced/fixed/last_affected event model
|
|
58
|
+
*/
|
|
59
|
+
function isVersionInSemverRange(
|
|
60
|
+
version: string,
|
|
61
|
+
range: {
|
|
62
|
+
events: Array<{
|
|
63
|
+
introduced?: string
|
|
64
|
+
fixed?: string
|
|
65
|
+
last_affected?: string
|
|
66
|
+
}>
|
|
67
|
+
},
|
|
68
|
+
): boolean {
|
|
69
|
+
try {
|
|
70
|
+
// Build semver range string from OSV events
|
|
71
|
+
const rangeExpressions: string[] = []
|
|
72
|
+
|
|
73
|
+
for (const event of range.events) {
|
|
74
|
+
if (event.introduced) {
|
|
75
|
+
if (event.introduced === "0") {
|
|
76
|
+
rangeExpressions.push("*")
|
|
77
|
+
} else {
|
|
78
|
+
rangeExpressions.push(`>=${event.introduced}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (event.fixed) {
|
|
83
|
+
rangeExpressions.push(`<${event.fixed}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (event.last_affected) {
|
|
87
|
+
rangeExpressions.push(`<=${event.last_affected}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (rangeExpressions.length === 0) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Combine range expressions with AND logic
|
|
96
|
+
const combinedRange = rangeExpressions.join(" ")
|
|
97
|
+
|
|
98
|
+
logger.debug(`Checking ${version} against range: ${combinedRange}`)
|
|
99
|
+
|
|
100
|
+
return Bun.semver.satisfies(version, combinedRange)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logger.warn(`Failed to parse semver range`, {
|
|
103
|
+
version,
|
|
104
|
+
range: range.events,
|
|
105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
106
|
+
})
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/severity.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OSVVulnerability } from "./schema.js"
|
|
7
|
+
import type { FatalSeverity } from "./types.js"
|
|
8
|
+
import { SECURITY } from "./constants.js"
|
|
9
|
+
import { logger } from "./logger.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map OSV vulnerability data to Bun security advisory level
|
|
13
|
+
* Uses multiple data sources: database_specific.severity, CVSS scores, etc.
|
|
14
|
+
*/
|
|
15
|
+
export function mapSeverityToLevel(vuln: OSVVulnerability): "fatal" | "warn" {
|
|
16
|
+
// Check database_specific.severity first (most authoritative)
|
|
17
|
+
const dbSeverity = vuln.database_specific?.severity
|
|
18
|
+
if (dbSeverity && isFatalSeverity(dbSeverity)) {
|
|
19
|
+
logger.debug(`Vulnerability ${vuln.id} marked fatal due to database severity: ${dbSeverity}`)
|
|
20
|
+
return "fatal"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Check CVSS scores if available
|
|
24
|
+
if (vuln.severity) {
|
|
25
|
+
const cvssScore = extractHighestCVSSScore(vuln.severity)
|
|
26
|
+
if (cvssScore !== null && cvssScore >= SECURITY.CVSS_FATAL_THRESHOLD) {
|
|
27
|
+
logger.debug(`Vulnerability ${vuln.id} marked fatal due to CVSS score: ${cvssScore}`)
|
|
28
|
+
return "fatal"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Default to warning level for all other cases
|
|
33
|
+
logger.debug(`Vulnerability ${vuln.id} marked as warning (default)`)
|
|
34
|
+
return "warn"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a severity string represents a fatal level
|
|
39
|
+
*/
|
|
40
|
+
function isFatalSeverity(severity: unknown): severity is FatalSeverity {
|
|
41
|
+
return (
|
|
42
|
+
typeof severity === "string" && SECURITY.FATAL_SEVERITIES.includes(severity as FatalSeverity)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract the highest CVSS score from severity array
|
|
48
|
+
* Supports CVSS v2, v3.0, and v3.1 formats
|
|
49
|
+
*/
|
|
50
|
+
function extractHighestCVSSScore(severities: OSVVulnerability["severity"]): number | null {
|
|
51
|
+
if (!Array.isArray(severities)) return null
|
|
52
|
+
|
|
53
|
+
let highestScore: number | null = null
|
|
54
|
+
|
|
55
|
+
for (const severity of severities) {
|
|
56
|
+
if (!severity.type.startsWith("CVSS")) continue
|
|
57
|
+
|
|
58
|
+
const score = parseCVSSScore(severity.score, severity.type)
|
|
59
|
+
if (score !== null && (highestScore === null || score > highestScore)) {
|
|
60
|
+
highestScore = score
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return highestScore
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse CVSS score from various formats
|
|
69
|
+
* Handles CVSS:3.1/..., numeric scores, and other formats
|
|
70
|
+
*/
|
|
71
|
+
function parseCVSSScore(scoreString: string, type: string): number | null {
|
|
72
|
+
try {
|
|
73
|
+
// Handle CVSS vector strings like "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/10.0"
|
|
74
|
+
if (scoreString.includes("CVSS:")) {
|
|
75
|
+
const vectorMatch = scoreString.match(/CVSS:[\d.]+\/.*?(\d+\.\d+|\d+)$/)
|
|
76
|
+
if (vectorMatch?.[1]) {
|
|
77
|
+
return parseFloat(vectorMatch[1])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Some CVSS strings have the score embedded differently
|
|
81
|
+
const scoreMatch = scoreString.match(/(\d+\.\d+|\d+)$/)
|
|
82
|
+
if (scoreMatch?.[1]) {
|
|
83
|
+
return parseFloat(scoreMatch[1])
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Handle plain numeric scores
|
|
88
|
+
const numericScore = parseFloat(scoreString)
|
|
89
|
+
if (!Number.isNaN(numericScore) && numericScore >= 0 && numericScore <= 10) {
|
|
90
|
+
return numericScore
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
logger.debug(`Failed to parse CVSS score`, { type, scoreString })
|
|
94
|
+
return null
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.warn(`Error parsing CVSS score`, {
|
|
97
|
+
type,
|
|
98
|
+
scoreString,
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
})
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 maloma7. All rights reserved.
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Bun Security Scanner API types
|
|
7
|
+
// These will be moved to @types/bun when officially released
|
|
8
|
+
|
|
9
|
+
// OSV API related types
|
|
10
|
+
export type FatalSeverity = "CRITICAL" | "HIGH"
|
|
11
|
+
|
|
12
|
+
// Extend global Bun namespace with missing types
|
|
13
|
+
declare global {
|
|
14
|
+
namespace Bun {
|
|
15
|
+
// Bun.semver types (missing from current bun-types)
|
|
16
|
+
namespace semver {
|
|
17
|
+
function satisfies(version: string, range: string): boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Augment Bun.Security.Advisory with missing properties
|
|
21
|
+
namespace Security {
|
|
22
|
+
interface Advisory {
|
|
23
|
+
id: string
|
|
24
|
+
message: string
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|