Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:*",
Expand Down
45 changes: 45 additions & 0 deletions packages/backend/scripts/capture-osv-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<BatchResult> } = 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}`)
83 changes: 83 additions & 0 deletions packages/backend/scripts/depscan-vs-osv-scanner.ts
Original file line number Diff line number Diff line change
@@ -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 <lockfile>...
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<string>
readonly aliases?: ReadonlyArray<string>
}>
}>
}>
}

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<string, string>()
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)
167 changes: 167 additions & 0 deletions packages/backend/src/services/security/PackageAdvisoryCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -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:<n>` or `vuln:<id>`. */
readonly requests: Array<string>
/** 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<Query> } =
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<PackageRef> = 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)))
})
})
Loading
Loading