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.
@@ -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
+ }
@@ -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
+ }