diff --git a/bun.lock b/bun.lock index 80d9387c4c..644b498b14 100644 --- a/bun.lock +++ b/bun.lock @@ -602,6 +602,7 @@ "@maple/email": "workspace:*", "@maple/eventing-core": "workspace:*", "@maple/infra": "workspace:*", + "@maple/lockfiles": "workspace:*", "@maple/prometheus-otlp": "workspace:*", "@maple/query-engine": "workspace:*", "@maple/query-engine-integrations": "workspace:*", diff --git a/packages/backend/package.json b/packages/backend/package.json index 3483a54c8e..5c97f5249f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,7 +16,9 @@ "scripts": { "test": "vitest run", "typecheck": "tsrs --noEmit", - "typecheck:test": "tsrs --noEmit -p tsconfig.test.json" + "typecheck:test": "tsrs --noEmit -p tsconfig.test.json", + "osv:capture-fixtures": "bun scripts/capture-osv-fixtures.ts", + "osv:compare": "bun scripts/depscan-vs-osv-scanner.ts" }, "dependencies": { "@alchemy.run/cloudflare": "catalog:alchemy", @@ -34,6 +36,7 @@ "@maple/chat-platform": "workspace:*", "@maple/db": "workspace:*", "@maple/domain": "workspace:*", + "@maple/lockfiles": "workspace:*", "@maple/email": "workspace:*", "@maple/eventing-core": "workspace:*", "@maple/infra": "workspace:*", diff --git a/packages/backend/scripts/capture-osv-fixtures.ts b/packages/backend/scripts/capture-osv-fixtures.ts new file mode 100644 index 0000000000..c04d97924a --- /dev/null +++ b/packages/backend/scripts/capture-osv-fixtures.ts @@ -0,0 +1,45 @@ +// Captures real OSV.dev answers for the dependency scanner's tests: one querybatch plus every +// vulnerability it names, trimmed to the fields the scanner reads. +// Run: bun packages/backend/scripts/capture-osv-fixtures.ts +const OUT = new URL("../src/services/security/__fixtures__/osv.json", import.meta.url) + +const queries = [ + { package: { ecosystem: "npm", name: "lodash" }, version: "4.17.15" }, + { package: { ecosystem: "npm", name: "minimist" }, version: "1.2.5" }, + { package: { ecosystem: "npm", name: "left-pad" }, version: "1.3.0" }, + { package: { ecosystem: "Go", name: "golang.org/x/net" }, version: "v0.7.0" }, + { package: { ecosystem: "crates.io", name: "time" }, version: "0.1.43" }, + { package: { ecosystem: "crates.io", name: "h2" }, version: "0.3.16" }, +] + +interface BatchResult { + readonly vulns?: ReadonlyArray<{ readonly id: string }> +} + +const batch: { results: ReadonlyArray } = await fetch("https://api.osv.dev/v1/querybatch", { + method: "POST", + body: JSON.stringify({ queries }), +}).then((response) => response.json()) + +const ids = [...new Set(batch.results.flatMap((result) => (result.vulns ?? []).map((vuln) => vuln.id)))] +const vulns = await Promise.all( + ids.map((id) => fetch(`https://api.osv.dev/v1/vulns/${id}`).then((response) => response.json())), +) +const trimmed = vulns.map((vuln) => ({ + id: vuln.id, + aliases: vuln.aliases, + summary: vuln.summary, + published: vuln.published, + withdrawn: vuln.withdrawn, + severity: vuln.severity, + database_specific: + vuln.database_specific?.severity === undefined + ? undefined + : { severity: vuln.database_specific.severity }, + affected: (vuln.affected ?? []).map((affected: { package: unknown; ranges?: unknown }) => ({ + package: affected.package, + ranges: affected.ranges, + })), +})) +await Bun.write(OUT, `${JSON.stringify({ queries, batch, vulns: trimmed }, null, "\t")}\n`) +console.log(`${queries.length} queries, ${ids.length} vulnerabilities -> ${OUT.pathname}`) diff --git a/packages/backend/scripts/depscan-vs-osv-scanner.ts b/packages/backend/scripts/depscan-vs-osv-scanner.ts new file mode 100644 index 0000000000..fa6e807658 --- /dev/null +++ b/packages/backend/scripts/depscan-vs-osv-scanner.ts @@ -0,0 +1,83 @@ +// Compares the dependency scanner's answer with Google's osv-scanner on the same lockfiles, against +// live OSV.dev. Prints one line per lockfile and every package where the two disagree. +// Run: bun packages/backend/scripts/depscan-vs-osv-scanner.ts ... +import { EdgeCacheService, MemoryCacheBackendLive } from "@maple/cache" +import { packageKey } from "@maple/domain/package-advisories" +import { parseLockfile } from "@maple/lockfiles" +import { Console, Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/http" +import { basename, dirname } from "node:path" +import { PackageAdvisoryCatalog } from "../src/services/security/PackageAdvisoryCatalog" + +interface OsvScannerOutput { + readonly results?: ReadonlyArray<{ + readonly packages?: ReadonlyArray<{ + readonly package: { readonly name: string; readonly version: string; readonly ecosystem: string } + readonly groups?: ReadonlyArray<{ + readonly ids: ReadonlyArray + readonly aliases?: ReadonlyArray + }> + }> + }> +} + +const osvScanner = (path: string): OsvScannerOutput => { + const run = Bun.spawnSync(["osv-scanner", "scan", "source", "--format", "json", "--lockfile", path]) + return JSON.parse(run.stdout.toString() || "{}") +} + +const layer = PackageAdvisoryCatalog.layer.pipe( + Layer.provide(EdgeCacheService.layer.pipe(Layer.provide(MemoryCacheBackendLive))), + Layer.provide(FetchHttpClient.layer), +) + +const compare = (path: string) => + Effect.gen(function* () { + const manifests = new Map() + const manifest = Bun.file(`${dirname(path)}/package.json`) + if (yield* Effect.promise(() => manifest.exists())) + manifests.set("package.json", yield* Effect.promise(() => manifest.text())) + const content = yield* Effect.promise(() => Bun.file(path).text()) + const parsed = yield* parseLockfile({ path: basename(path), content, manifests }) + const catalog = yield* PackageAdvisoryCatalog + const ours = yield* catalog.advisories(parsed.packages) + + const theirs = new Map( + (osvScanner(path).results ?? []) + .flatMap((result) => result.packages ?? []) + .map((pkg) => [ + `${pkg.package.ecosystem}:${pkg.package.name}@${pkg.package.version}`, + (pkg.groups ?? []).map((group) => new Set([...group.ids, ...(group.aliases ?? [])])), + ]), + ) + const lines = parsed.packages.flatMap((ref) => { + const key = packageKey(ref) + const mine = (ours.get(key) ?? []).map((advisory) => new Set([advisory.id, ...advisory.aliases])) + // osv-scanner reports Go versions without the `v`. + const other = theirs.get(key) ?? theirs.get(key.replace("@v", "@")) ?? [] + const missing = other.filter( + (group) => !mine.some((names) => [...group].some((id) => names.has(id))), + ) + const extra = mine.filter( + (names) => !other.some((group) => [...group].some((id) => names.has(id))), + ) + return missing.length === 0 && extra.length === 0 + ? [] + : [ + ` ${key}: ${mine.length} vs ${other.length}; only osv-scanner ${missing.map((g) => [...g][0]).join(",")}; only ours ${extra.map((n) => [...n][0]).join(",")}`, + ] + }) + const total = [...ours.values()].reduce((sum, list) => sum + list.length, 0) + const theirTotal = [...theirs.values()].reduce((sum, list) => sum + list.length, 0) + yield* Console.log( + `${lines.length === 0 ? "MATCH" : "DIFF "} ${path}: ${parsed.packages.length} packages, ${total} advisories (osv-scanner ${theirTotal})`, + ) + yield* Effect.forEach(lines, (line) => Console.log(line)) + return lines.length === 0 + }) + +const results = await Effect.runPromise( + // oxlint-disable-next-line effecttsgo/strict-effect-provide + Effect.forEach(process.argv.slice(2), compare).pipe(Effect.provide(layer)), +) +process.exit(results.every(Boolean) ? 0 : 1) diff --git a/packages/backend/src/services/security/PackageAdvisoryCatalog.test.ts b/packages/backend/src/services/security/PackageAdvisoryCatalog.test.ts new file mode 100644 index 0000000000..2e39a894f1 --- /dev/null +++ b/packages/backend/src/services/security/PackageAdvisoryCatalog.test.ts @@ -0,0 +1,167 @@ +import { assert, describe, it } from "@effect/vitest" +import { CacheBackendLive } from "@maple/backend/platform/CacheBackendLive" +import { EdgeCacheService } from "@maple/cache" +import { type PackageRef, packageKey } from "@maple/domain/package-advisories" +import { Effect, Layer } from "effect" +import { HttpClient, type HttpClientRequest, HttpClientResponse } from "effect/http" +import fixture from "./__fixtures__/osv.json" +import { MAX_VULNERABILITIES, PackageAdvisoryCatalog } from "./PackageAdvisoryCatalog" + +interface Query { + readonly package: { readonly ecosystem: string; readonly name: string } + readonly version: string + readonly page_token?: string +} + +interface FakeOsv { + /** Requests seen, as `querybatch:` or `vuln:`. */ + readonly requests: Array + /** Overrides the captured answer for a query. */ + readonly answer?: (query: Query) => { + vulns?: ReadonlyArray<{ id: string; modified: string }> + next_page_token?: string + } + readonly failBatch?: boolean +} + +const capturedAnswer = (query: Query) => { + const index = fixture.queries.findIndex( + (known) => + known.package.ecosystem === query.package.ecosystem && + known.package.name === query.package.name && + known.version === query.version, + ) + return fixture.batch.results[index] ?? {} +} + +const json = (request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) => + HttpClientResponse.fromWeb(request, new Response(JSON.stringify(body), { status })) + +const osv = (fake: FakeOsv) => + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const url = new URL(request.url) + if (url.pathname === "/v1/querybatch") { + const body: { queries: ReadonlyArray } = + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : { queries: [] } + fake.requests.push(`querybatch:${body.queries.length}`) + if (fake.failBatch === true) return json(request, { message: "unavailable" }, 503) + return json(request, { results: body.queries.map(fake.answer ?? capturedAnswer) }) + } + const id = decodeURIComponent(url.pathname.replace("/v1/vulns/", "")) + fake.requests.push(`vuln:${id}`) + const vuln = fixture.vulns.find((candidate) => candidate.id === id) + return vuln === undefined ? json(request, { message: "not found" }, 404) : json(request, vuln) + }), + ) + +const catalogWith = (fake: FakeOsv) => + Layer.effect(PackageAdvisoryCatalog, PackageAdvisoryCatalog.make).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, osv(fake)), + EdgeCacheService.layer.pipe(Layer.provide(CacheBackendLive)), + ), + ), + ) + +const refs: ReadonlyArray = fixture.queries.map((query) => ({ + ecosystem: + query.package.ecosystem === "Go" + ? "Go" + : query.package.ecosystem === "crates.io" + ? "crates.io" + : "npm", + name: query.package.name, + version: query.version, +})) + +const keyOf = (name: string) => { + const ref = refs.find((candidate) => candidate.name === name) + return ref === undefined ? "" : packageKey(ref) +} + +describe("PackageAdvisoryCatalog", () => { + it.effect("answers every package in one batch, reads each vulnerability once and caches it", () => { + const fake: FakeOsv = { requests: [] } + return Effect.gen(function* () { + const catalog = yield* PackageAdvisoryCatalog + const first = yield* catalog.advisories([...refs, ...refs]) + assert.deepStrictEqual( + first + .get(keyOf("minimist")) + ?.map((advisory) => [advisory.id, advisory.severity, advisory.fixedIn]), + [["GHSA-xvch-5gv4-984h", "critical", "1.2.6"]], + ) + assert.strictEqual(first.get(keyOf("lodash"))?.length, 6) + assert.isFalse(first.has(keyOf("left-pad"))) + + const batches = fake.requests.filter((request) => request.startsWith("querybatch")) + const reads = fake.requests.filter((request) => request.startsWith("vuln:")) + assert.deepStrictEqual(batches, [`querybatch:${refs.length}`]) + assert.strictEqual(reads.length, new Set(reads).size) + assert.strictEqual(reads.length, fixture.vulns.length) + + fake.requests.length = 0 + const second = yield* catalog.advisories(refs) + assert.deepStrictEqual(fake.requests, [`querybatch:${refs.length}`]) + assert.deepStrictEqual([...second.keys()], [...first.keys()]) + }).pipe(Effect.provide(catalogWith(fake))) + }) + + it.effect("follows a query's next page", () => { + const fake: FakeOsv = { + requests: [], + answer: (query) => + query.package.name !== "lodash" + ? {} + : query.page_token === undefined + ? { vulns: [{ id: "GHSA-p6mc-m468-83gw", modified: "1" }], next_page_token: "page-2" } + : { vulns: [{ id: "GHSA-29mw-wpgm-hmr9", modified: "1" }] }, + } + return Effect.gen(function* () { + const catalog = yield* PackageAdvisoryCatalog + const advisories = yield* catalog.advisories(refs) + assert.deepStrictEqual( + advisories + .get(keyOf("lodash")) + ?.map((advisory) => advisory.id) + .sort(), + ["GHSA-29mw-wpgm-hmr9", "GHSA-p6mc-m468-83gw"], + ) + assert.deepStrictEqual( + fake.requests.filter((request) => request.startsWith("querybatch")), + [`querybatch:${refs.length}`, "querybatch:1"], + ) + }).pipe(Effect.provide(catalogWith(fake))) + }) + + it.live("fails as a whole when OSV is down, after retrying", () => { + const fake: FakeOsv = { requests: [], failBatch: true } + return Effect.gen(function* () { + const catalog = yield* PackageAdvisoryCatalog + const error = yield* Effect.flip(catalog.advisories(refs)) + assert.strictEqual(error._tag, "@maple/security/PackageAdvisoryCatalogError") + assert.strictEqual(fake.requests.length, 3) + }).pipe(Effect.provide(catalogWith(fake))) + }) + + it.effect("refuses more distinct vulnerabilities than a scan reads", () => { + const many = Array.from({ length: MAX_VULNERABILITIES + 1 }, (_, i) => ({ + id: `GHSA-many-${i}`, + modified: "1", + })) + const fake: FakeOsv = { + requests: [], + answer: (query) => (query.package.name === "lodash" ? { vulns: many } : {}), + } + return Effect.gen(function* () { + const catalog = yield* PackageAdvisoryCatalog + const error = yield* Effect.flip(catalog.advisories(refs)) + assert.strictEqual(error._tag, "@maple/security/PackageAdvisoryCatalogError") + assert.isFalse(fake.requests.some((request) => request.startsWith("vuln:"))) + }).pipe(Effect.provide(catalogWith(fake))) + }) +}) diff --git a/packages/backend/src/services/security/PackageAdvisoryCatalog.ts b/packages/backend/src/services/security/PackageAdvisoryCatalog.ts new file mode 100644 index 0000000000..26ba1ea6f1 --- /dev/null +++ b/packages/backend/src/services/security/PackageAdvisoryCatalog.ts @@ -0,0 +1,203 @@ +import { EdgeCacheService } from "@maple/cache" +import { type PackageAdvisory, type PackageRef, packageKey } from "@maple/domain/package-advisories" +import { Array as Arr, Context, Effect, Layer, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" +import { advisoriesOf, OsvRecord, OsvVulnResponse, toOsvRecord } from "./osv-records" + +// Known vulnerabilities per locked package version for the dependency scanner, read from OSV.dev. +// A scan sends every package through `querybatch` (ids only) and reads each vulnerability once, +// cached by id and modification time, which never changes for a given pair. + +const OSV_API = "https://api.osv.dev/v1" +/** OSV's cap on queries per `querybatch` call. */ +const BATCH_SIZE = 1000 +const CACHE_BUCKET = "osv-vulns" +const CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 +const CACHE_READ_TIMEOUT_MS = 150 +const REQUEST_TIMEOUT = "15 seconds" +/** Distinct vulnerabilities read per call; a scan past it fails rather than store a partial answer. */ +export const MAX_VULNERABILITIES = 500 +const CONCURRENCY = 16 +const RETRY = { times: 2, schedule: Schedule.exponential("250 millis") } as const + +const QueryBatchResponse = Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + vulns: Schema.optionalKey( + Schema.Array(Schema.Struct({ id: Schema.String, modified: Schema.String })), + ), + next_page_token: Schema.optionalKey(Schema.String), + }), + ), +}) + +interface VulnRef { + readonly id: string + readonly modified: string +} + +export class PackageAdvisoryCatalogError extends Schema.TaggedError()( + "@maple/security/PackageAdvisoryCatalogError", + { message: Schema.String, cause: Schema.Defect() }, +) {} + +export interface PackageAdvisoryCatalogApi { + /** + * Advisories per `packageKey`; packages without any are absent. Fails as a whole when any read + * fails, so a scan never mistakes an outage for a clean dependency. + */ + readonly advisories: ( + packages: ReadonlyArray, + ) => Effect.Effect>, PackageAdvisoryCatalogError> +} + +interface Pending { + readonly ref: PackageRef + readonly pageToken?: string +} + +interface Page { + readonly ref: PackageRef + readonly vulns: ReadonlyArray + readonly nextPageToken: string | undefined +} + +export class PackageAdvisoryCatalog extends Context.Service< + PackageAdvisoryCatalog, + PackageAdvisoryCatalogApi +>()("@maple/backend/services/security/PackageAdvisoryCatalog", { + make: Effect.gen(function* () { + const edgeCache = yield* EdgeCacheService + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk) + + const catalogError = (message: string) => (cause: unknown) => + new PackageAdvisoryCatalogError({ message, cause }) + + const queryBatch = ( + batch: ReadonlyArray, + ): Effect.Effect, PackageAdvisoryCatalogError> => + HttpClientRequest.post(`${OSV_API}/querybatch`).pipe( + HttpClientRequest.bodyJsonUnsafe({ + queries: batch.map(({ ref, pageToken }) => ({ + package: { ecosystem: ref.ecosystem, name: ref.name }, + version: ref.version, + // JSON leaves out an undefined token. + page_token: pageToken, + })), + }), + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(QueryBatchResponse)), + Effect.timeout(REQUEST_TIMEOUT), + Effect.retry(RETRY), + Effect.flatMap((body) => + body.results.length === batch.length + ? Effect.succeed( + batch.map(({ ref }, i): Page => ({ + ref, + vulns: body.results[i]?.vulns ?? [], + nextPageToken: body.results[i]?.next_page_token, + })), + ) + : Effect.fail( + `querybatch answered ${body.results.length} of ${batch.length} queries`, + ), + ), + Effect.mapError(catalogError("OSV querybatch failed")), + Effect.withSpan("osv.querybatch", { + kind: "client", + attributes: { "peer.service": "osv.dev", "maple.depscan.query_count": batch.length }, + }), + ) + + /** Every page of every query; a package with more than one page of vulnerabilities is rare. */ + const queryAll = ( + pending: ReadonlyArray, + found: ReadonlyMap>, + ): Effect.Effect>, PackageAdvisoryCatalogError> => + pending.length === 0 + ? Effect.succeed(found) + : Effect.forEach(Arr.chunksOf(pending, BATCH_SIZE), queryBatch, { concurrency: 2 }).pipe( + Effect.flatMap((batches) => { + const pages = batches.flat() + const next = new Map(found) + pages.forEach((page) => { + const key = packageKey(page.ref) + next.set(key, [...(next.get(key) ?? []), ...page.vulns]) + }) + return queryAll( + pages.flatMap((page) => + page.nextPageToken === undefined + ? [] + : [{ ref: page.ref, pageToken: page.nextPageToken }], + ), + next, + ) + }), + ) + + const fetchRecord = (vuln: VulnRef) => + edgeCache + .getOrCompute( + { + bucket: CACHE_BUCKET, + key: `${vuln.id}@${vuln.modified}`, + ttlSeconds: CACHE_TTL_SECONDS, + schema: OsvRecord, + readTimeoutMs: CACHE_READ_TIMEOUT_MS, + }, + httpClient.get(`${OSV_API}/vulns/${encodeURIComponent(vuln.id)}`).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(OsvVulnResponse)), + Effect.timeout(REQUEST_TIMEOUT), + Effect.retry(RETRY), + Effect.map(toOsvRecord), + Effect.mapError(catalogError(`OSV read failed for ${vuln.id}`)), + Effect.withSpan("osv.vuln", { + kind: "client", + attributes: { "peer.service": "osv.dev", "maple.depscan.advisory_id": vuln.id }, + }), + ), + ) + .pipe(Effect.map((result) => result.value)) + + const advisories = Effect.fn("PackageAdvisoryCatalog.advisories")(function* ( + packages: ReadonlyArray, + ) { + const distinct = [...new Map(packages.map((ref) => [packageKey(ref), ref])).values()] + const found = yield* queryAll( + distinct.map((ref) => ({ ref })), + new Map(), + ) + const vulns = [...new Map([...found.values()].flat().map((vuln) => [vuln.id, vuln])).values()] + yield* Effect.annotateCurrentSpan({ + "maple.depscan.package_count": distinct.length, + "maple.depscan.vulnerability_count": vulns.length, + }) + if (vulns.length > MAX_VULNERABILITIES) { + return yield* new PackageAdvisoryCatalogError({ + message: `${vulns.length} known vulnerabilities, more than the ${MAX_VULNERABILITIES} a scan reads`, + cause: undefined, + }) + } + const records = new Map( + (yield* Effect.forEach(vulns, fetchRecord, { concurrency: CONCURRENCY })).map((record) => [ + record.id, + record, + ]), + ) + return new Map( + distinct.flatMap((ref) => { + const matched = (found.get(packageKey(ref)) ?? []).flatMap((vuln) => { + const record = records.get(vuln.id) + return record === undefined ? [] : [record] + }) + const list = advisoriesOf(matched, ref) + return list.length === 0 ? [] : [[packageKey(ref), list] as const] + }), + ) + }) + + return { advisories } satisfies PackageAdvisoryCatalogApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/backend/src/services/security/__fixtures__/osv.json b/packages/backend/src/services/security/__fixtures__/osv.json new file mode 100644 index 0000000000..49176400e8 --- /dev/null +++ b/packages/backend/src/services/security/__fixtures__/osv.json @@ -0,0 +1,3183 @@ +{ + "queries": [ + { + "package": { + "ecosystem": "npm", + "name": "lodash" + }, + "version": "4.17.15" + }, + { + "package": { + "ecosystem": "npm", + "name": "minimist" + }, + "version": "1.2.5" + }, + { + "package": { + "ecosystem": "npm", + "name": "left-pad" + }, + "version": "1.3.0" + }, + { + "package": { + "ecosystem": "Go", + "name": "golang.org/x/net" + }, + "version": "v0.7.0" + }, + { + "package": { + "ecosystem": "crates.io", + "name": "time" + }, + "version": "0.1.43" + }, + { + "package": { + "ecosystem": "crates.io", + "name": "h2" + }, + "version": "0.3.16" + } + ], + "batch": { + "results": [ + { + "vulns": [ + { + "id": "GHSA-29mw-wpgm-hmr9", + "modified": "2025-09-29T21:12:31.102523Z" + }, + { + "id": "GHSA-35jh-r3h4-6jhm", + "modified": "2026-10-17T02:55:38.085813Z" + }, + { + "id": "GHSA-f23m-r3pf-42rh", + "modified": "2026-10-17T02:56:13.084362Z" + }, + { + "id": "GHSA-p6mc-m468-83gw", + "modified": "2025-08-12T21:56:17.174859Z" + }, + { + "id": "GHSA-r5fr-rjxr-66jc", + "modified": "2026-10-17T02:56:15.633068Z" + }, + { + "id": "GHSA-xxjr-mmjv-4gpg", + "modified": "2026-10-17T02:55:54.229635Z" + } + ] + }, + { + "vulns": [ + { + "id": "GHSA-xvch-5gv4-984h", + "modified": "2026-03-13T22:11:59.523514Z" + } + ] + }, + {}, + { + "vulns": [ + { + "id": "GHSA-2wrh-6pvc-2jm9", + "modified": "2026-09-10T03:50:08.520271Z" + }, + { + "id": "GHSA-4374-p667-p6c8", + "modified": "2026-09-10T03:49:24.714188Z" + }, + { + "id": "GHSA-4v7x-pqxf-cx7m", + "modified": "2026-09-10T03:50:11.106451Z" + }, + { + "id": "GHSA-5cv4-jp36-h3mw", + "modified": "2026-09-10T03:50:45.494180Z" + }, + { + "id": "GHSA-qppj-fm5r-hxr3", + "modified": "2026-09-10T03:50:04.757558Z" + }, + { + "id": "GHSA-qxp5-gwg8-xv66", + "modified": "2026-09-10T03:50:23.280114Z" + }, + { + "id": "GHSA-vvgc-356p-c3xw", + "modified": "2026-09-10T03:50:24.031676Z" + }, + { + "id": "GO-2023-1988", + "modified": "2026-02-04T04:12:22.127281Z" + }, + { + "id": "GO-2023-2102", + "modified": "2026-09-28T10:56:56.146690Z" + }, + { + "id": "GO-2024-2687", + "modified": "2026-02-04T04:09:31.762399Z" + }, + { + "id": "GO-2024-3333", + "modified": "2026-03-24T23:55:17.997380Z" + }, + { + "id": "GO-2025-3503", + "modified": "2026-04-16T22:45:11.580296Z" + }, + { + "id": "GO-2025-3595", + "modified": "2026-02-05T02:43:52.511643Z" + }, + { + "id": "GO-2026-4440", + "modified": "2026-05-15T10:59:23.883331Z" + }, + { + "id": "GO-2026-4441", + "modified": "2026-05-15T10:59:23.510056Z" + }, + { + "id": "GO-2026-4918", + "modified": "2026-10-16T10:42:05.228443Z" + }, + { + "id": "GO-2026-5025", + "modified": "2026-05-30T05:14:17.563517Z" + }, + { + "id": "GO-2026-5026", + "modified": "2026-10-16T10:42:05.164302Z" + }, + { + "id": "GO-2026-5027", + "modified": "2026-09-22T10:41:48.597564Z" + }, + { + "id": "GO-2026-5028", + "modified": "2026-07-01T18:26:19.971496Z" + }, + { + "id": "GO-2026-5029", + "modified": "2026-09-22T10:41:48.499970Z" + }, + { + "id": "GO-2026-5030", + "modified": "2026-09-22T10:41:47.813482Z" + }, + { + "id": "GO-2026-5942", + "modified": "2026-08-17T12:25:32.471119Z" + }, + { + "id": "GO-2026-6603", + "modified": "2026-10-16T09:55:47.068117Z" + }, + { + "id": "GO-2026-6610", + "modified": "2026-10-16T09:56:04.364467Z" + }, + { + "id": "GO-2026-6611", + "modified": "2026-10-16T09:55:38.952277Z" + }, + { + "id": "GO-2026-6612", + "modified": "2026-10-16T09:55:54.129082Z" + }, + { + "id": "GO-2026-6617", + "modified": "2026-10-16T09:55:57.350751Z" + }, + { + "id": "GO-2026-6860", + "modified": "2026-10-27T21:30:04.890809Z" + }, + { + "id": "GO-2026-6866", + "modified": "2026-10-27T21:45:08.978077Z" + } + ] + }, + { + "vulns": [ + { + "id": "GHSA-wcg3-cvx6-7396", + "modified": "2026-09-10T03:49:08.359875Z" + }, + { + "id": "RUSTSEC-2020-0071", + "modified": "2026-02-04T02:31:56.682937Z" + } + ] + }, + { + "vulns": [ + { + "id": "GHSA-8r5v-vm4m-4g25", + "modified": "2026-09-10T03:50:09.232197Z" + }, + { + "id": "GHSA-f8vr-r385-rh5r", + "modified": "2023-11-08T04:12:03.011700Z" + }, + { + "id": "GHSA-q6cp-qfwq-4gcv", + "modified": "2026-09-10T03:49:18.784343Z" + }, + { + "id": "RUSTSEC-2023-0034", + "modified": "2023-11-08T04:12:03.011700Z" + }, + { + "id": "RUSTSEC-2024-0003", + "modified": "2026-02-04T03:15:37.377206Z" + }, + { + "id": "RUSTSEC-2024-0332", + "modified": "2024-04-11T16:41:43.668809Z" + }, + { + "id": "RUSTSEC-2026-0258", + "modified": "2026-10-12T18:00:10.934581Z" + } + ] + } + ] + }, + "vulns": [ + { + "id": "GHSA-29mw-wpgm-hmr9", + "aliases": ["CVE-2020-28500"], + "summary": "Regular Expression Denial of Service (ReDoS) in lodash", + "published": "2022-01-06T20:30:46Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "lodash", + "ecosystem": "npm", + "purl": "pkg:npm/lodash" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.17.21" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-es", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-es" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.17.21" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.trimend", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.trimend" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "last_affected": "4.5.1" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.trim", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.trim" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "last_affected": "4.5.1" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-rails", + "ecosystem": "RubyGems", + "purl": "pkg:gem/lodash-rails" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.17.21" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-35jh-r3h4-6jhm", + "aliases": ["CVE-2021-23337"], + "summary": "Command Injection in lodash", + "published": "2021-05-06T16:05:51Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H" + } + ], + "database_specific": { + "severity": "HIGH" + }, + "affected": [ + { + "package": { + "name": "lodash", + "ecosystem": "npm", + "purl": "pkg:npm/lodash" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.17.21" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-es", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-es" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.17.21" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.template", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.template" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "last_affected": "4.5.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-template", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-template" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "last_affected": "1.0.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-rails", + "ecosystem": "RubyGems", + "purl": "pkg:gem/lodash-rails" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.17.21" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-f23m-r3pf-42rh", + "aliases": ["CVE-2026-2950"], + "summary": "lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit`", + "published": "2026-04-01T23:50:27Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "lodash", + "ecosystem": "npm", + "purl": "pkg:npm/lodash" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-es", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-es" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-amd", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-amd" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.unset", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.unset" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-p6mc-m468-83gw", + "aliases": ["CVE-2020-8203"], + "summary": "Prototype Pollution in lodash", + "published": "2020-07-15T19:15:48Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H" + } + ], + "database_specific": { + "severity": "HIGH" + }, + "affected": [ + { + "package": { + "name": "lodash", + "ecosystem": "npm", + "purl": "pkg:npm/lodash" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "3.7.0" + }, + { + "fixed": "4.17.19" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-es", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-es" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "3.7.0" + }, + { + "fixed": "4.17.20" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.pick", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.pick" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "last_affected": "4.4.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.set", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.set" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "3.7.0" + }, + { + "last_affected": "4.3.2" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.setwith", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.setwith" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "last_affected": "4.3.2" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.update", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.update" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "last_affected": "4.10.2" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.updatewith", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.updatewith" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "last_affected": "4.10.2" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-rails", + "ecosystem": "RubyGems", + "purl": "pkg:gem/lodash-rails" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "3.7.0" + }, + { + "fixed": "4.17.19" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-r5fr-rjxr-66jc", + "aliases": ["CVE-2026-4800"], + "summary": "lodash vulnerable to Code Injection via `_.template` imports key names", + "published": "2026-04-01T23:51:12Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "database_specific": { + "severity": "HIGH" + }, + "affected": [ + { + "package": { + "name": "lodash", + "ecosystem": "npm", + "purl": "pkg:npm/lodash" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-es", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-es" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-amd", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-amd" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.template", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.template" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.18.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-xxjr-mmjv-4gpg", + "aliases": ["CVE-2025-13465"], + "summary": "Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions", + "published": "2026-01-21T23:01:22Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L" + }, + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:L/SC:H/SI:H/SA:H/E:P" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "lodash", + "ecosystem": "npm", + "purl": "pkg:npm/lodash" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.17.23" + } + ] + } + ] + }, + { + "package": { + "name": "lodash.unset", + "ecosystem": "npm", + "purl": "pkg:npm/lodash.unset" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "last_affected": "4.5.2" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-es", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-es" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.17.23" + } + ] + } + ] + }, + { + "package": { + "name": "lodash-amd", + "ecosystem": "npm", + "purl": "pkg:npm/lodash-amd" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "4.0.0" + }, + { + "fixed": "4.17.23" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-xvch-5gv4-984h", + "aliases": ["CVE-2021-44906"], + "summary": "Prototype Pollution in minimist", + "published": "2022-03-18T00:01:09Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "database_specific": { + "severity": "CRITICAL" + }, + "affected": [ + { + "package": { + "name": "minimist", + "ecosystem": "npm", + "purl": "pkg:npm/minimist" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.0.0" + }, + { + "fixed": "1.2.6" + } + ] + } + ] + }, + { + "package": { + "name": "minimist", + "ecosystem": "npm", + "purl": "pkg:npm/minimist" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.2.4" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-2wrh-6pvc-2jm9", + "aliases": ["CVE-2023-3978", "GO-2023-1988"], + "summary": "Improper rendering of text nodes in golang.org/x/net/html", + "published": "2023-08-02T21:30:20Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.13.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-4374-p667-p6c8", + "aliases": ["BIT-golang-2023-39325", "CVE-2023-39325", "GO-2023-2102"], + "summary": "HTTP/2 rapid reset can cause excessive work in net/http", + "published": "2023-10-11T20:35:43Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "database_specific": { + "severity": "HIGH" + }, + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-4v7x-pqxf-cx7m", + "aliases": ["BIT-golang-2023-45288", "CVE-2023-45288", "GO-2024-2687"], + "summary": "net/http, x/net/http2: close connections when receiving too many headers", + "published": "2024-04-04T21:30:32Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "net/http", + "ecosystem": "Go", + "purl": "pkg:golang/net/http" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.9" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net/http2", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net/http2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.23.0" + } + ] + } + ] + }, + { + "package": { + "name": "net/http", + "ecosystem": "Go", + "purl": "pkg:golang/net/http" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.23.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-5cv4-jp36-h3mw", + "aliases": ["CVE-2026-25680", "GO-2026-5028"], + "summary": "Go Net HTML parser is vulnerable to denial of service", + "published": "2026-05-26T13:30:17Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-qppj-fm5r-hxr3", + "aliases": [ + "BIT-apisix-2023-44487", + "BIT-aspnet-core-2023-44487", + "BIT-contour-2023-44487", + "BIT-dotnet-2023-44487", + "BIT-dotnet-sdk-2023-44487", + "BIT-envoy-2023-44487", + "BIT-golang-2023-44487", + "BIT-jenkins-2023-44487", + "BIT-kong-2023-44487", + "BIT-nginx-2023-44487", + "BIT-nginx-gateway-2023-44487", + "BIT-node-2023-44487", + "BIT-node-min-2023-44487", + "BIT-solr-2023-44487", + "BIT-tomcat-2023-44487", + "BIT-varnish-2023-44487", + "CVE-2023-44487" + ], + "summary": "HTTP/2 Stream Cancellation Attack", + "published": "2023-10-10T21:28:24Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L/E:H" + }, + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:A" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "github.com/apple/swift-nio-http2", + "ecosystem": "SwiftURL", + "purl": "pkg:swift/github.com/apple/swift-nio-http2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.28.0" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat:tomcat-coyote", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat/tomcat-coyote" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "11.0.0-M1" + }, + { + "fixed": "11.0.0-M12" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat:tomcat-coyote", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat/tomcat-coyote" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "10.0.0" + }, + { + "fixed": "10.1.14" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat:tomcat-coyote", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat/tomcat-coyote" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "9.0.0" + }, + { + "fixed": "9.0.81" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat:tomcat-coyote", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat/tomcat-coyote" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "8.5.0" + }, + { + "fixed": "8.5.94" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat.embed:tomcat-embed-core", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat.embed/tomcat-embed-core" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "11.0.0-M1" + }, + { + "fixed": "11.0.0-M12" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat.embed:tomcat-embed-core", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat.embed/tomcat-embed-core" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "10.0.0" + }, + { + "fixed": "10.1.14" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat.embed:tomcat-embed-core", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat.embed/tomcat-embed-core" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "9.0.0" + }, + { + "fixed": "9.0.81" + } + ] + } + ] + }, + { + "package": { + "name": "org.apache.tomcat.embed:tomcat-embed-core", + "ecosystem": "Maven", + "purl": "pkg:maven/org.apache.tomcat.embed/tomcat-embed-core" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "8.5.0" + }, + { + "fixed": "8.5.94" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:http2-common", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/http2-common" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "9.3.0" + }, + { + "fixed": "9.4.53" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:http2-common", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/http2-common" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "10.0.0" + }, + { + "fixed": "10.0.17" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:http2-common", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/http2-common" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "11.0.0" + }, + { + "fixed": "11.0.17" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:http2-server", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/http2-server" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "9.3.0" + }, + { + "fixed": "9.4.53" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:http2-server", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/http2-server" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "10.0.0" + }, + { + "fixed": "10.0.17" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:http2-server", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/http2-server" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "11.0.0" + }, + { + "fixed": "11.0.17" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:jetty-http2-common", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/jetty-http2-common" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "12.0.0" + }, + { + "fixed": "12.0.2" + } + ] + } + ] + }, + { + "package": { + "name": "org.eclipse.jetty.http2:jetty-http2-server", + "ecosystem": "Maven", + "purl": "pkg:maven/org.eclipse.jetty.http2/jetty-http2-server" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "12.0.0" + }, + { + "fixed": "12.0.2" + } + ] + } + ] + }, + { + "package": { + "name": "com.typesafe.akka:akka-http-core", + "ecosystem": "Maven", + "purl": "pkg:maven/com.typesafe.akka/akka-http-core" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "10.5.3" + } + ] + } + ] + }, + { + "package": { + "name": "com.typesafe.akka:akka-http-core_2.13", + "ecosystem": "Maven", + "purl": "pkg:maven/com.typesafe.akka/akka-http-core_2.13" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "10.5.3" + } + ] + } + ] + }, + { + "package": { + "name": "com.typesafe.akka:akka-http-core_2.12", + "ecosystem": "Maven", + "purl": "pkg:maven/com.typesafe.akka/akka-http-core_2.12" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "10.5.3" + } + ] + } + ] + }, + { + "package": { + "name": "com.typesafe.akka:akka-http-core_2.11", + "ecosystem": "Maven", + "purl": "pkg:maven/com.typesafe.akka/akka-http-core_2.11" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "last_affected": "10.1.15" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-qxp5-gwg8-xv66", + "aliases": ["CVE-2025-22870", "GO-2025-3503"], + "summary": "HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net", + "published": "2025-03-12T22:06:40Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:L" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.36.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-vvgc-356p-c3xw", + "aliases": ["CVE-2025-22872", "GO-2025-3595"], + "summary": "golang.org/x/net vulnerable to Cross-site Scripting", + "published": "2025-04-16T19:22:51Z", + "severity": [ + { + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.38.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2023-1988", + "aliases": ["CVE-2023-3978", "GHSA-2wrh-6pvc-2jm9"], + "summary": "Improper rendering of text nodes in golang.org/x/net/html", + "published": "2023-08-02T15:06:27Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.13.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2023-2102", + "aliases": ["BIT-golang-2023-39325", "CVE-2023-39325", "GHSA-4374-p667-p6c8"], + "summary": "HTTP/2 rapid reset can cause excessive work in net/http", + "published": "2023-10-11T16:49:53Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.10" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.3" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2024-2687", + "aliases": ["BIT-golang-2023-45288", "CVE-2023-45288", "GHSA-4v7x-pqxf-cx7m"], + "summary": "HTTP/2 CONTINUATION flood in net/http", + "published": "2024-04-03T21:12:01Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.9" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.23.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2024-3333", + "aliases": ["CVE-2024-45338", "GHSA-w32m-9786-jp63"], + "summary": "Non-linear parsing of case-insensitive content in golang.org/x/net/html", + "published": "2024-12-18T20:22:06Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.33.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2025-3503", + "aliases": ["CVE-2025-22870", "GHSA-qxp5-gwg8-xv66"], + "summary": "HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net", + "published": "2025-03-12T18:17:07Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.7" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.1" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.36.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2025-3595", + "aliases": ["CVE-2025-22872", "GHSA-vvgc-356p-c3xw"], + "summary": "Incorrect Neutralization of Input During Web Page Generation in x/net in golang.org/x/net", + "published": "2025-04-16T16:54:55Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.38.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-4440", + "aliases": ["CVE-2025-47911", "GHSA-w4gw-w5jq-g9jh"], + "summary": "Quadratic parsing complexity in golang.org/x/net/html", + "published": "2026-02-05T17:23:14Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.45.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-4441", + "aliases": ["CVE-2025-58190"], + "summary": "Infinite parsing loop in golang.org/x/net", + "published": "2026-02-05T17:23:17Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.45.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-4918", + "aliases": ["BIT-golang-2026-33814", "CVE-2026-33814"], + "summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net", + "published": "2026-05-07T19:21:40Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.53.0" + } + ] + } + ] + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5025", + "aliases": ["CVE-2026-42506"], + "summary": "Invoking incorrect handling of namespaced elements in foreign content in golang.org/x/net/html", + "published": "2026-05-22T02:46:43Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5026", + "aliases": ["CVE-2026-39821"], + "summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna", + "published": "2026-05-22T02:46:43Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5027", + "aliases": ["CVE-2026-42502"], + "summary": "Invoking incorrect handling of HTML elements in foreign content in golang.org/x/net/html", + "published": "2026-05-22T02:46:43Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5028", + "aliases": ["CVE-2026-25680", "GHSA-5cv4-jp36-h3mw"], + "summary": "Invoking denial of service when parsing arbitrary HTML in golang.org/x/net/html", + "published": "2026-05-22T02:46:43Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5029", + "aliases": ["CVE-2026-25681"], + "summary": "Invoking incorrect handling of character references in DOCTYPE nodes in golang.org/x/net/html", + "published": "2026-05-22T02:46:43Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5030", + "aliases": ["CVE-2026-27136"], + "summary": "Invoking duplicate attributes can cause XSS in golang.org/x/net/html", + "published": "2026-05-22T02:46:43Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-5942", + "aliases": ["BIT-golang-2026-46600", "CVE-2026-46600"], + "summary": "Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage", + "published": "2026-07-14T17:29:16Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.56.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6603", + "aliases": ["BIT-golang-2026-78659", "CVE-2026-78659"], + "summary": "HTTP/2 server memory exhaustion due to Trailer headers in net/http", + "published": "2026-10-08T22:31:09Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.26.9" + } + ] + } + ] + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.60.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6610", + "aliases": ["BIT-golang-2026-78660", "CVE-2026-78660"], + "summary": "HTTP/2 transport accepts malformed framing-related headers in net/http", + "published": "2026-10-08T22:31:09Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.26.9" + } + ] + } + ] + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.60.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6611", + "aliases": ["BIT-golang-2026-78669", "CVE-2026-78669"], + "summary": "Excessive CPU consumption from repeated initial window changes in net/http", + "published": "2026-10-08T22:31:09Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.26.9" + } + ] + } + ] + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.60.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6612", + "aliases": ["BIT-golang-2026-78663", "CVE-2026-78663"], + "summary": "Double flow control refund on HTTP/2 server streams in net/http", + "published": "2026-10-08T22:31:09Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.26.9" + } + ] + } + ] + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.60.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6617", + "aliases": ["BIT-golang-2026-97032", "CVE-2026-97032"], + "summary": "HTTP/2 server crash due to HPACK encoder race in net/http", + "published": "2026-10-08T22:31:09Z", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.26.9" + } + ] + } + ] + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go", + "purl": "pkg:golang/stdlib" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.2" + } + ] + } + ] + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.60.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6860", + "aliases": ["CVE-2026-78661"], + "summary": "Avoid quadratic complexity when coalescing text nodes in html in golang.org/x/net", + "published": "2026-10-27T21:16:18Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.62.0" + } + ] + } + ] + } + ] + }, + { + "id": "GO-2026-6866", + "aliases": ["CVE-2026-97045"], + "summary": "Check all ancestor namespace transitions when rendering raw text in html in golang.org/x/net", + "published": "2026-10-27T21:16:18Z", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go", + "purl": "pkg:golang/golang.org/x/net" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.62.0" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-wcg3-cvx6-7396", + "aliases": ["CVE-2020-26235", "RUSTSEC-2020-0071"], + "summary": "Segmentation fault in time", + "published": "2021-08-25T20:56:46Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "time", + "ecosystem": "crates.io", + "purl": "pkg:cargo/time" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.1.0" + }, + { + "fixed": "0.2.23" + } + ] + } + ] + }, + { + "package": { + "name": "time", + "ecosystem": "crates.io", + "purl": "pkg:cargo/time" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.2.7" + }, + { + "fixed": "0.2.23" + } + ] + } + ] + } + ] + }, + { + "id": "RUSTSEC-2020-0071", + "aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396"], + "summary": "Potential segfault in the time crate", + "published": "2020-11-18T12:00:00Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + } + ], + "affected": [ + { + "package": { + "name": "time", + "ecosystem": "crates.io", + "purl": "pkg:cargo/time" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-0" + }, + { + "fixed": "0.2.0" + }, + { + "introduced": "0.2.1-0" + }, + { + "fixed": "0.2.1" + }, + { + "introduced": "0.2.2-0" + }, + { + "fixed": "0.2.2" + }, + { + "introduced": "0.2.3-0" + }, + { + "fixed": "0.2.3" + }, + { + "introduced": "0.2.4-0" + }, + { + "fixed": "0.2.4" + }, + { + "introduced": "0.2.5-0" + }, + { + "fixed": "0.2.5" + }, + { + "introduced": "0.2.6-0" + }, + { + "fixed": "0.2.6" + }, + { + "introduced": "0.2.7-0" + }, + { + "fixed": "0.2.23" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-8r5v-vm4m-4g25", + "aliases": ["RUSTSEC-2024-0003"], + "summary": "Resource exhaustion vulnerability in h2 may lead to Denial of Service (DoS)", + "published": "2024-01-19T15:24:56Z", + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.24" + } + ] + } + ] + }, + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.4.0" + }, + { + "fixed": "0.4.2" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-f8vr-r385-rh5r", + "aliases": ["CVE-2023-26964", "RUSTSEC-2023-0034"], + "summary": "h2 vulnerable to denial of service", + "published": "2023-04-11T15:30:30Z", + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.17" + } + ] + } + ] + } + ] + }, + { + "id": "GHSA-q6cp-qfwq-4gcv", + "aliases": ["RUSTSEC-2024-0332"], + "summary": "h2 servers vulnerable to degradation of service with CONTINUATION Flood", + "published": "2024-04-05T15:05:32Z", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L" + } + ], + "database_specific": { + "severity": "MODERATE" + }, + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.26" + } + ] + } + ] + }, + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.4.0" + }, + { + "fixed": "0.4.4" + } + ] + } + ] + } + ] + }, + { + "id": "RUSTSEC-2023-0034", + "aliases": ["CVE-2023-26964", "GHSA-f8vr-r385-rh5r"], + "summary": "Resource exhaustion vulnerability in h2 may lead to Denial of Service (DoS)", + "published": "2023-04-14T12:00:00Z", + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-0" + }, + { + "fixed": "0.3.17" + } + ] + } + ] + } + ] + }, + { + "id": "RUSTSEC-2024-0003", + "aliases": ["GHSA-8r5v-vm4m-4g25"], + "summary": "Resource exhaustion vulnerability in h2 may lead to Denial of Service (DoS)", + "published": "2024-01-17T12:00:00Z", + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-0" + }, + { + "fixed": "0.3.24" + }, + { + "introduced": "0.4.0-0" + }, + { + "fixed": "0.4.2" + } + ] + } + ] + } + ] + }, + { + "id": "RUSTSEC-2024-0332", + "aliases": ["GHSA-q6cp-qfwq-4gcv"], + "summary": "Degradation of service in h2 servers with CONTINUATION Flood", + "published": "2024-04-03T12:00:00Z", + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-0" + }, + { + "fixed": "0.3.26" + }, + { + "introduced": "0.4.0-0" + }, + { + "fixed": "0.4.4" + } + ] + } + ] + } + ] + }, + { + "id": "RUSTSEC-2026-0258", + "aliases": ["GHSA-q83h-524g-xf6h"], + "summary": "h2 unbounded empty DATA frames", + "published": "2026-08-17T12:00:00Z", + "affected": [ + { + "package": { + "name": "h2", + "ecosystem": "crates.io", + "purl": "pkg:cargo/h2" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-0" + }, + { + "fixed": "0.4.16" + } + ] + } + ] + } + ] + } + ] +} diff --git a/packages/backend/src/services/security/cvss.test.ts b/packages/backend/src/services/security/cvss.test.ts new file mode 100644 index 0000000000..4db69eb667 --- /dev/null +++ b/packages/backend/src/services/security/cvss.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { cvss3BaseScore, severityOfScore } from "./cvss" + +// Expected scores come from FIRST's CVSS 3.1 calculator. +describe("cvss3BaseScore", () => { + it.each([ + ["CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical"], + ["CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", 6.1, "medium"], + ["CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", 6.2, "medium"], + ["CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H", 9.9, "critical"], + ["CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", 5.9, "medium"], + ["CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", 7.5, "high"], + ["CVSS:3.1/AV:P/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N", 1.6, "low"], + ["CVSS:3.1/AV:P/AC:H/PR:H/UI:R/S:U/C:N/I:N/A:N", 0, "low"], + ] as const)("%s scores %d (%s)", (vector, score, severity) => { + const computed = cvss3BaseScore(vector) + expect(computed).toBe(score) + expect(computed === undefined ? undefined : severityOfScore(computed)).toBe(severity) + }) + + it.each([ + "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N", + "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/C:H/I:H/A:H", + "CVSS:3.1/AV:X/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "AV:N/AC:L/Au:N/C:P/I:P/A:P", + "", + ])("rejects %j", (vector) => { + expect(cvss3BaseScore(vector)).toBeUndefined() + }) +}) diff --git a/packages/backend/src/services/security/cvss.ts b/packages/backend/src/services/security/cvss.ts new file mode 100644 index 0000000000..5b17af01f4 --- /dev/null +++ b/packages/backend/src/services/security/cvss.ts @@ -0,0 +1,72 @@ +import type { AdvisorySeverity } from "@maple/domain/runtime-advisories" + +// CVSS 3.0 / 3.1 base score from a vector string (FIRST's specification, section 7). Advisories +// without a curated severity (most Go ones) only carry the vector. + +const WEIGHTS = { + AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }, + AC: { L: 0.77, H: 0.44 }, + UI: { N: 0.85, R: 0.62 }, + CIA: { H: 0.56, L: 0.22, N: 0 }, +} as const + +/** Privileges Required weighs more when the scope changes. */ +const PR_WEIGHTS = { + U: { N: 0.85, L: 0.62, H: 0.27 }, + C: { N: 0.85, L: 0.68, H: 0.5 }, +} as const + +const pick = >(table: T, key: string | undefined): number | undefined => + key !== undefined && Object.hasOwn(table, key) ? table[key] : undefined + +/** CVSS 3.1 Roundup: the smallest one-decimal number at or above the input, free of float noise. */ +const roundUp = (value: number): number => { + const scaled = Math.round(value * 100000) + return scaled % 10000 === 0 ? scaled / 100000 : (Math.floor(scaled / 10000) + 1) / 10 +} + +/** The base score of a `CVSS:3.x/...` vector, undefined for any other or malformed vector. */ +export function cvss3BaseScore(vector: string): number | undefined { + const [prefix, ...parts] = vector.trim().split("/") + if (prefix !== "CVSS:3.0" && prefix !== "CVSS:3.1") return undefined + const metrics = new Map( + parts.flatMap((part) => { + const [key, value] = part.split(":") + return key === undefined || value === undefined ? [] : [[key, value] as const] + }), + ) + const scope = metrics.get("S") + if (scope !== "U" && scope !== "C") return undefined + const av = pick(WEIGHTS.AV, metrics.get("AV")) + const ac = pick(WEIGHTS.AC, metrics.get("AC")) + const pr = pick(PR_WEIGHTS[scope], metrics.get("PR")) + const ui = pick(WEIGHTS.UI, metrics.get("UI")) + const c = pick(WEIGHTS.CIA, metrics.get("C")) + const i = pick(WEIGHTS.CIA, metrics.get("I")) + const a = pick(WEIGHTS.CIA, metrics.get("A")) + if ( + av === undefined || + ac === undefined || + pr === undefined || + ui === undefined || + c === undefined || + i === undefined || + a === undefined + ) + return undefined + const iss = 1 - (1 - c) * (1 - i) * (1 - a) + const impact = scope === "U" ? 6.42 * iss : 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02) ** 15 + if (impact <= 0) return 0 + const exploitability = 8.22 * av * ac * pr * ui + return scope === "U" + ? roundUp(Math.min(impact + exploitability, 10)) + : roundUp(Math.min(1.08 * (impact + exploitability), 10)) +} + +/** The qualitative rating of a base score (CVSS 3.1 table 14); a score of 0 rates low. */ +export function severityOfScore(score: number): AdvisorySeverity { + if (score >= 9) return "critical" + if (score >= 7) return "high" + if (score >= 4) return "medium" + return "low" +} diff --git a/packages/backend/src/services/security/osv-records.test.ts b/packages/backend/src/services/security/osv-records.test.ts new file mode 100644 index 0000000000..799c380eca --- /dev/null +++ b/packages/backend/src/services/security/osv-records.test.ts @@ -0,0 +1,127 @@ +import type { PackageRef } from "@maple/domain/package-advisories" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import fixture from "./__fixtures__/osv.json" +import { advisoriesOf, type OsvRecord, OsvVulnResponse, toOsvRecord } from "./osv-records" + +// `fixture` is a real OSV answer, captured by scripts/capture-osv-fixtures.ts. +const records = fixture.vulns.map((vuln) => toOsvRecord(Schema.decodeUnknownSync(OsvVulnResponse)(vuln))) + +const recordsFor = (ref: PackageRef): ReadonlyArray => { + const index = fixture.queries.findIndex( + (query) => query.package.name === ref.name && query.version === ref.version, + ) + const ids = new Set((fixture.batch.results[index]?.vulns ?? []).map((vuln) => vuln.id)) + return records.filter((record) => ids.has(record.id)) +} + +const advisoriesFor = (ref: PackageRef) => advisoriesOf(recordsFor(ref), ref) + +const record = (overrides: Partial): OsvRecord => ({ + id: "GO-2026-0001", + aliases: [], + summary: null, + published: null, + withdrawn: false, + curatedSeverity: null, + cvssV3: null, + affected: [ + { ecosystem: "Go", name: "example.com/mod", events: [{ introduced: "0" }, { fixed: "1.4.0" }] }, + ], + ...overrides, +}) + +const GO_REF: PackageRef = { ecosystem: "Go", name: "example.com/mod", version: "v1.2.0" } + +describe("advisoriesOf", () => { + it("merges a Go vulnerability's GO- and GHSA- records into one advisory under the GHSA id", () => { + const advisories = advisoriesFor({ ecosystem: "Go", name: "golang.org/x/net", version: "v0.7.0" }) + const xss = advisories.filter((advisory) => advisory.aliases.includes("CVE-2023-3978")) + expect(xss).toHaveLength(1) + expect(xss[0]).toMatchObject({ + id: "GHSA-2wrh-6pvc-2jm9", + severity: "medium", + fixedIn: "v0.13.0", + }) + expect(xss[0]?.aliases).toContain("GO-2023-1988") + expect(new Set(advisories.map((advisory) => advisory.id)).size).toBe(advisories.length) + }) + + it("reads GitHub's MODERATE as medium and picks the fix of the locked version's range", () => { + const advisories = advisoriesFor({ ecosystem: "npm", name: "lodash", version: "4.17.15" }) + expect(advisories.find((advisory) => advisory.id === "GHSA-p6mc-m468-83gw")).toMatchObject({ + severity: "high", + fixedIn: "4.17.19", + }) + expect(advisories.find((advisory) => advisory.id === "GHSA-29mw-wpgm-hmr9")?.severity).toBe("medium") + }) + + it("merges a RUSTSEC record with its GHSA twin", () => { + const advisories = advisoriesFor({ ecosystem: "crates.io", name: "time", version: "0.1.43" }) + expect(advisories).toHaveLength(1) + expect(advisories[0]?.aliases).toEqual( + expect.arrayContaining(["RUSTSEC-2020-0071", "CVE-2020-26235"]), + ) + }) + + it("rates a record without a curated severity from its CVSS 3 vector", () => { + const [advisory] = advisoriesOf( + [record({ cvssV3: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" })], + GO_REF, + ) + expect(advisory?.severity).toBe("high") + expect(advisory?.fixedIn).toBe("v1.4.0") + }) + + it("takes the worst rating and the highest fix when the records disagree", () => { + const [advisory] = advisoriesOf( + [ + record({ id: "GO-2026-0001", aliases: ["CVE-2026-1"] }), + record({ + id: "GHSA-aaaa-bbbb-cccc", + aliases: ["CVE-2026-1"], + curatedSeverity: "LOW", + cvssV3: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + affected: [ + { + ecosystem: "Go", + name: "example.com/mod", + events: [{ introduced: "0" }, { fixed: "1.5.0" }], + }, + ], + }), + record({ id: "OSV-2026-9", aliases: ["CVE-2026-1"], curatedSeverity: "HIGH" }), + ], + GO_REF, + ) + expect(advisory).toMatchObject({ id: "GHSA-aaaa-bbbb-cccc", severity: "high", fixedIn: "v1.5.0" }) + }) + + it("takes the GHSA id even when OSV names it only as an alias", () => { + const [advisory] = advisoriesOf( + [record({ id: "GO-2024-3333", aliases: ["CVE-2024-45338", "GHSA-w32m-9786-jp63"] })], + GO_REF, + ) + expect(advisory?.id).toBe("GHSA-w32m-9786-jp63") + expect(advisory?.aliases).toEqual(["CVE-2024-45338", "GO-2024-3333"]) + }) + + it("leaves out withdrawn records and ranges of other packages", () => { + expect(advisoriesOf([record({ withdrawn: true })], GO_REF)).toEqual([]) + const [advisory] = advisoriesOf( + [ + record({ + affected: [ + { + ecosystem: "Go", + name: "example.com/other", + events: [{ introduced: "0" }, { fixed: "1.3.0" }], + }, + ], + }), + ], + GO_REF, + ) + expect(advisory?.fixedIn).toBeNull() + }) +}) diff --git a/packages/backend/src/services/security/osv-records.ts b/packages/backend/src/services/security/osv-records.ts new file mode 100644 index 0000000000..6de5b94304 --- /dev/null +++ b/packages/backend/src/services/security/osv-records.ts @@ -0,0 +1,191 @@ +import { compareVersions } from "@maple/lockfiles" +import type { PackageAdvisory, PackageRef } from "@maple/domain/package-advisories" +import { + ADVISORY_SEVERITIES, + type AdvisorySeverity, + fixedVersionFor, + lowestVersion, + severityOf, +} from "@maple/domain/runtime-advisories" +import { Schema } from "effect" +import { cvss3BaseScore, severityOfScore } from "./cvss" + +// OSV vulnerability records, slimmed to what the scanner keeps, and how the records of one +// vulnerability (a GO- and a GHSA- entry sharing a CVE) become one advisory. + +const SUMMARY_MAX_LENGTH = 160 + +const OsvEvent = Schema.Struct({ + introduced: Schema.optionalKey(Schema.String), + fixed: Schema.optionalKey(Schema.String), +}) + +/** The `GET /v1/vulns/{id}` payload, only the fields read here. */ +export const OsvVulnResponse = Schema.Struct({ + id: Schema.String, + aliases: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + summary: Schema.optionalKey(Schema.String), + details: Schema.optionalKey(Schema.String), + published: Schema.optionalKey(Schema.String), + withdrawn: Schema.optionalKey(Schema.String), + severity: Schema.optionalKey( + Schema.NullOr(Schema.Array(Schema.Struct({ type: Schema.String, score: Schema.String }))), + ), + database_specific: Schema.optionalKey( + Schema.NullOr(Schema.Struct({ severity: Schema.optionalKey(Schema.NullOr(Schema.String)) })), + ), + affected: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + package: Schema.optionalKey( + Schema.Struct({ ecosystem: Schema.String, name: Schema.String }), + ), + ranges: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ type: Schema.String, events: Schema.Array(OsvEvent) }), + ), + ), + ), + }), + ), + ), + ), +}) +type OsvVulnResponse = typeof OsvVulnResponse.Type + +/** What is cached per vulnerability id and modification time. */ +export const OsvRecord = Schema.Struct({ + id: Schema.String, + aliases: Schema.Array(Schema.String), + summary: Schema.NullOr(Schema.String), + published: Schema.NullOr(Schema.String), + withdrawn: Schema.Boolean, + /** The database's own rating (GitHub's, for GHSA records). */ + curatedSeverity: Schema.NullOr(Schema.String), + cvssV3: Schema.NullOr(Schema.String), + affected: Schema.Array( + Schema.Struct({ ecosystem: Schema.String, name: Schema.String, events: Schema.Array(OsvEvent) }), + ), +}) +export type OsvRecord = typeof OsvRecord.Type + +/** Many advisories carry no summary, only details: their first sentence stands in, capped. */ +const summaryOf = (vuln: OsvVulnResponse): string | null => { + const text = (vuln.summary ?? vuln.details?.split(/(?<=\.)\s/)[0] ?? "").trim() + if (text === "") return null + return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH - 1).trimEnd()}…` : text +} + +export const toOsvRecord = (vuln: OsvVulnResponse): OsvRecord => ({ + id: vuln.id, + aliases: vuln.aliases ?? [], + summary: summaryOf(vuln), + published: vuln.published ?? null, + withdrawn: vuln.withdrawn !== undefined, + curatedSeverity: vuln.database_specific?.severity ?? null, + cvssV3: (vuln.severity ?? []).find((entry) => entry.type === "CVSS_V3")?.score ?? null, + affected: (vuln.affected ?? []).flatMap(({ package: pkg, ranges }) => + pkg === undefined + ? [] + : (ranges ?? []) + // GIT ranges are commit hashes, not versions. + .filter((range) => range.type === "SEMVER" || range.type === "ECOSYSTEM") + .map((range) => ({ ecosystem: pkg.ecosystem, name: pkg.name, events: range.events })), + ), +}) + +const severityRank = (severity: AdvisorySeverity): number => ADVISORY_SEVERITIES.indexOf(severity) + +/** The curated rating when the database has one, else the CVSS 3 base score's rating. */ +const recordSeverity = (record: OsvRecord): AdvisorySeverity => { + const curated = severityOf(record.curatedSeverity ?? undefined) + if (curated !== "unknown") return curated + const score = record.cvssV3 === null ? undefined : cvss3BaseScore(record.cvssV3) + return score === undefined ? "unknown" : severityOfScore(score) +} + +/** The lowest fix of the locked version across the record's ranges for that package. */ +const recordFix = (record: OsvRecord, ref: PackageRef): string | null => { + const compare = (a: string, b: string) => compareVersions(ref.ecosystem, a, b) + const fixes = record.affected + .filter((affected) => affected.ecosystem === ref.ecosystem && affected.name === ref.name) + .flatMap((affected) => { + const fixed = fixedVersionFor(affected.events, ref.version, compare) + return fixed === null ? [] : [fixed] + }) + return lowestVersion(fixes, compare) +} + +/** Records naming each other (directly or through a shared CVE) are one vulnerability. */ +export const groupByAlias = (records: ReadonlyArray): ReadonlyArray> => + records + .reduce< + ReadonlyArray<{ readonly names: ReadonlySet; readonly records: ReadonlyArray }> + >((groups, record) => { + const names = [record.id, ...record.aliases] + const linked = groups.filter((group) => names.some((name) => group.names.has(name))) + const rest = groups.filter((group) => !linked.includes(group)) + return [ + ...rest, + { + names: new Set([...names, ...linked.flatMap((group) => [...group.names])]), + records: [...linked.flatMap((group) => group.records), record], + }, + ] + }, []) + .map((group) => group.records) + +/** Go fixes are listed without the `v` that go.mod versions carry. */ +const displayVersion = (ref: PackageRef, version: string): string => + ref.ecosystem === "Go" && !version.startsWith("v") ? `v${version}` : version + +/** + * One advisory from the records of one vulnerability: the GHSA id (stable across scans) when there + * is one, the worst rating any record gives, and the highest of their fixes, so every source agrees. + */ +export const mergeAdvisory = (records: ReadonlyArray, ref: PackageRef): PackageAdvisory => { + const ids = [...new Set(records.map((record) => record.id))].sort() + const names = new Set(records.flatMap((record) => [record.id, ...record.aliases])) + // The GHSA id even when OSV only names it as an alias, so it holds once OSV returns that record too. + const id = [...names].sort().find((name) => name.startsWith("GHSA-")) ?? ids[0] ?? "" + const ordered = [...records].sort((a, b) => + a.id === id ? -1 : b.id === id ? 1 : a.id.localeCompare(b.id), + ) + const compare = (a: string, b: string) => compareVersions(ref.ecosystem, a, b) + const fixes = ordered.flatMap((record) => { + const fix = recordFix(record, ref) + return fix === null ? [] : [fix] + }) + const highestFix = fixes.reduce( + (highest, fix) => (highest === null || compare(fix, highest) > 0 ? fix : highest), + null, + ) + const severity = ordered + .map(recordSeverity) + .filter((rated) => rated !== "unknown") + .reduce( + (worst, rated) => + worst === "unknown" || severityRank(rated) < severityRank(worst) ? rated : worst, + "unknown", + ) + const published = ordered + .flatMap((record) => (record.published === null ? [] : [record.published])) + .sort() + return { + id, + aliases: [...names].filter((name) => name !== id).sort(), + summary: ordered.find((record) => record.summary !== null)?.summary ?? null, + severity, + fixedIn: highestFix === null ? null : displayVersion(ref, highestFix), + published: published[0] ?? null, + } +} + +/** A package's advisories from its matching records, withdrawn ones left out. */ +export const advisoriesOf = ( + records: ReadonlyArray, + ref: PackageRef, +): ReadonlyArray => + groupByAlias(records.filter((record) => !record.withdrawn)).map((group) => mergeAdvisory(group, ref)) diff --git a/packages/domain/package.json b/packages/domain/package.json index 2cb215fd3d..e27f022067 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -17,6 +17,7 @@ "./gcp": "./src/gcp.ts", "./gcp-infra": "./src/gcp-infra.ts", "./runtime-advisories": "./src/runtime-advisories.ts", + "./package-advisories": "./src/package-advisories.ts", "./runtime-posture": "./src/runtime-posture.ts", "./security-telemetry": "./src/security-telemetry.ts", "./service-runtimes": "./src/service-runtimes.ts", diff --git a/packages/domain/src/package-advisories.ts b/packages/domain/src/package-advisories.ts new file mode 100644 index 0000000000..d5d5547074 --- /dev/null +++ b/packages/domain/src/package-advisories.ts @@ -0,0 +1,36 @@ +import { Schema } from "effect" +import { ADVISORY_SEVERITIES } from "./runtime-advisories" + +// Known vulnerabilities in a locked package version, read from OSV.dev for the dependency scanner. + +/** OSV ecosystem names of the package managers the scanner reads. */ +export const PACKAGE_ECOSYSTEMS = ["npm", "Go", "crates.io"] as const +export const PackageEcosystem = Schema.Literals(PACKAGE_ECOSYSTEMS) +export type PackageEcosystem = typeof PackageEcosystem.Type + +export const AdvisorySeverity = Schema.Literals(ADVISORY_SEVERITIES) + +export const PackageAdvisory = Schema.Struct({ + /** Stable across scans: the GitHub advisory id when OSV has one, else the first OSV id. */ + id: Schema.String, + /** Every other id the advisory goes by (CVE, GO-, RUSTSEC-, GHSA-). */ + aliases: Schema.Array(Schema.String), + summary: Schema.NullOr(Schema.String), + severity: AdvisorySeverity, + /** The first version that fixes it for the locked version, null when none is published. */ + fixedIn: Schema.NullOr(Schema.String), + published: Schema.NullOr(Schema.String), +}) +export type PackageAdvisory = typeof PackageAdvisory.Type + +export interface PackageRef { + readonly ecosystem: PackageEcosystem + readonly name: string + readonly version: string +} + +export const packageKey = (ref: PackageRef): string => `${ref.ecosystem}:${ref.name}@${ref.version}` + +/** The id shown to people: the CVE when there is one. */ +export const displayAdvisoryId = (advisory: Pick): string => + advisory.aliases.find((alias) => alias.startsWith("CVE-")) ?? advisory.id diff --git a/packages/domain/src/runtime-advisories.test.ts b/packages/domain/src/runtime-advisories.test.ts index 2022df5d4b..87179f70f2 100644 --- a/packages/domain/src/runtime-advisories.test.ts +++ b/packages/domain/src/runtime-advisories.test.ts @@ -86,7 +86,7 @@ describe("fixedVersionFor", () => { }) describe("severityOf", () => { - it("reads OSV's severity labels and calls anything else unknown", () => { + it("reads OSV's severity labels, GitHub's MODERATE as medium, and anything else as unknown", () => { expect(severityOf("Critical")).toBe("critical") expect(severityOf("High")).toBe("high") expect(severityOf("Unknown")).toBe("unknown") diff --git a/packages/domain/src/runtime-advisories.ts b/packages/domain/src/runtime-advisories.ts index 046c2a37a6..0e0722981c 100644 --- a/packages/domain/src/runtime-advisories.ts +++ b/packages/domain/src/runtime-advisories.ts @@ -65,6 +65,7 @@ export function advisoryTargetOf(input: Parameters[0]): A export function severityOf(raw: string | null | undefined): AdvisorySeverity { const severity = (raw ?? "").trim().toLowerCase() + // GitHub's advisory database says MODERATE for medium. if (severity === "moderate") return "medium" return ADVISORY_SEVERITIES.find((known) => known === severity) ?? "unknown" } @@ -155,6 +156,8 @@ export function isPatched(patched: string | null, version: string): boolean { const nearest = below.at(-1) return nearest !== undefined && (nearest === fixes.at(-1) || lineOf(nearest) === lineOf(version)) } +/** Orders two versions: negative, zero or positive. Ecosystems with their own rules pass theirs. */ +export type VersionComparator = (a: string, b: string) => number export interface OsvRangeEvent { readonly introduced?: string @@ -165,13 +168,16 @@ export interface OsvRangeEvent { * The fix for `version` in OSV range events, which pair an `introduced` with the `fixed` after it: * the `fixed` of the pair whose span holds the version. */ -export function fixedVersionFor(events: ReadonlyArray, version: string): string | null { +export function fixedVersionFor( + events: ReadonlyArray, + version: string, + compare: VersionComparator = compareVersions, +): string | null { const spans = events.reduce<{ introduced: string | null; fixes: ReadonlyArray }>( (state, event) => { if (event.introduced !== undefined) return { ...state, introduced: event.introduced } if (event.fixed === undefined || state.introduced === null) return state - const holds = - compareVersions(version, state.introduced) >= 0 && compareVersions(version, event.fixed) < 0 + const holds = compare(version, state.introduced) >= 0 && compare(version, event.fixed) < 0 return { introduced: null, fixes: holds ? [...state.fixes, event.fixed] : state.fixes } }, { introduced: null, fixes: [] }, @@ -196,10 +202,13 @@ export const sortAdvisories = (advisories: ReadonlyArray): Read export const isUrgentSeverity = (severity: AdvisorySeverity): boolean => severity === "critical" || severity === "high" -/** The lowest of dotted versions, null for none. */ -export const lowestVersion = (versions: ReadonlyArray): string | null => +/** The lowest of the versions, null for none. */ +export const lowestVersion = ( + versions: ReadonlyArray, + compare: VersionComparator = compareVersions, +): string | null => versions.reduce( - (lowest, version) => (lowest === null || compareVersions(version, lowest) < 0 ? version : lowest), + (lowest, version) => (lowest === null || compare(version, lowest) < 0 ? version : lowest), null, )