From 8fd122bb1e4afa551401ad4d65b177b70b3f713c Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 12:56:40 +0200 Subject: [PATCH 1/7] feat: add nuget ecosystem to blast Signed-off-by: Umberto Sgueglia --- .../src/api/public/v1/packages/blastRadius.ts | 2 +- .../__tests__/packageIdentifier.test.ts | 26 ++- .../src/blast-radius/agent/nugetPrompts.ts | 127 +++++++++++++ .../clients/__tests__/nugetSource.test.ts | 108 +++++++++++ .../src/blast-radius/clients/nugetSource.ts | 103 ++++++++++ .../src/blast-radius/clients/osvClient.ts | 7 +- .../src/blast-radius/ecosystemSupport.ts | 2 +- .../src/blast-radius/packageIdentifier.ts | 53 ++++-- .../stages/__tests__/dispatch.test.ts | 40 ++++ .../__tests__/ecosystemVersions.test.ts | 72 +++++++ .../blast-radius/stages/ecosystemVersions.ts | 102 ++++++++++ .../src/blast-radius/stages/ecosystems.ts | 8 + .../src/blast-radius/stages/go/intelGo.ts | 7 +- .../blast-radius/stages/maven/intelMaven.ts | 7 +- .../stages/maven/mavenVersions.ts | 91 +-------- .../src/blast-radius/stages/npm/intelNpm.ts | 5 +- .../nuget/__tests__/nugetConstraint.test.ts | 85 +++++++++ .../stages/nuget/dependentsNuGet.ts | 111 +++++++++++ .../stages/nuget/dependentsScanNuGet.ts | 58 ++++++ .../blast-radius/stages/nuget/intelNuGet.ts | 178 ++++++++++++++++++ .../stages/nuget/nugetConstraint.ts | 124 ++++++++++++ .../stages/nuget/reachabilityConfig.ts | 33 ++++ .../stages/selectAdvisoryEntry.ts | 12 +- .../apps/packages_worker/src/nuget/client.ts | 12 ++ 24 files changed, 1251 insertions(+), 122 deletions(-) create mode 100644 services/apps/packages_worker/src/blast-radius/agent/nugetPrompts.ts create mode 100644 services/apps/packages_worker/src/blast-radius/clients/__tests__/nugetSource.test.ts create mode 100644 services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsNuGet.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsScanNuGet.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/reachabilityConfig.ts diff --git a/backend/src/api/public/v1/packages/blastRadius.ts b/backend/src/api/public/v1/packages/blastRadius.ts index e5d9ac9911..3b77e751df 100644 --- a/backend/src/api/public/v1/packages/blastRadius.ts +++ b/backend/src/api/public/v1/packages/blastRadius.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm', 'go', 'maven', 'cargo'] as const +export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm', 'go', 'maven', 'cargo', 'nuget'] as const // Always exactly one job per request — advisory-wide (package omitted) or narrowed // to a single package. package accepts either a full purl or a bare package name, diff --git a/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts index 9e9b99de01..2351aa4d79 100644 --- a/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts +++ b/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { toBareNpmName, toDbCargoName } from '../packageIdentifier' +import { toBareNpmName, toBareNuGetId, toDbCargoName } from '../packageIdentifier' describe('toBareNpmName', () => { it('returns a bare name unchanged', () => { @@ -45,3 +45,27 @@ describe('toDbCargoName', () => { expect(toDbCargoName('Actix-Web')).toBe('actix_web') }) }) + +describe('toBareNuGetId', () => { + it('returns a bare id unchanged, preserving casing', () => { + expect(toBareNuGetId('Newtonsoft.Json')).toBe('Newtonsoft.Json') + }) + + it('strips the pkg:nuget/ prefix', () => { + expect(toBareNuGetId('pkg:nuget/Newtonsoft.Json')).toBe('Newtonsoft.Json') + }) + + it('strips a trailing version', () => { + expect(toBareNuGetId('pkg:nuget/Newtonsoft.Json@13.0.1')).toBe('Newtonsoft.Json') + }) + + it('strips qualifiers and subpath', () => { + expect(toBareNuGetId('pkg:nuget/Newtonsoft.Json@13.0.1?foo=bar#sub')).toBe('Newtonsoft.Json') + }) + + it('does not lowercase the id — DB lookups are case-sensitive', () => { + expect(toBareNuGetId('pkg:nuget/Microsoft.AspNetCore.Mvc@2.2.0')).toBe( + 'Microsoft.AspNetCore.Mvc', + ) + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/agent/nugetPrompts.ts b/services/apps/packages_worker/src/blast-radius/agent/nugetPrompts.ts new file mode 100644 index 0000000000..cc36e725d1 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/agent/nugetPrompts.ts @@ -0,0 +1,127 @@ +// Parallels mavenPrompts.ts/goPrompts.ts — shared shape lives in promptKit.ts; +// only C#-specific keys/enum and system-prompt prose live here. +import { + buildIntelPrompt, + buildIntelSchema, + buildReachabilitySymbolsBlock, + buildVerdictSchema, +} from './promptKit' +import { SymbolSpec } from './prompts' + +// ---------- STAGE 1: INTEL ---------- + +const IMPORT_SIGNATURE_KEYS = [ + 'using_directive', + 'using_alias', + 'global_using', + 'fully_qualified_reference', +] + +export const NUGET_INTEL_SCHEMA = buildIntelSchema(IMPORT_SIGNATURE_KEYS) + +export const NUGET_INTEL_SYSTEM_PROMPT = `You are a vulnerability analyst. Your working directory contains the source (fetched from the +package's GitHub repository at the matching commit/tag) of the vulnerable version of a NuGet +package (C#/.NET). You are given the security advisory and the patch (diff) that fixed the +vulnerability. + +Your job is to determine, precisely, WHAT is vulnerable — so that downstream analysts can +check whether other packages actually reach the vulnerable code. + +Rules: +- Identify the exact vulnerable type(s)/method(s)/property(ies) from the patch and the source. + Be minimal and precise: do NOT include similar-but-unaffected symbols. If the patch only + touches a private/internal helper, trace which \`public\`/\`protected\` members route through + it and list those as the reachable surface (note the helper in \`notes\`). +- Read the source to verify visibility — only \`public\` (and \`protected\` on a non-sealed + class) members are reachable from outside the assembly; \`internal\` members are only + reachable from an \`InternalsVisibleTo\` friend assembly, which is rare across independent + packages — note the exact namespace-qualified name each symbol lives in. +- Build \`import_signatures\`: concrete code patterns a C# dependent would contain if it uses + the vulnerable symbol. Cover: a plain \`using Some.Namespace;\` directive followed by + \`Foo.Method()\`/\`new Foo()\` usage, a \`using\` alias (\`using F = Some.Namespace.Foo;\`), a + file-scoped/implicit \`global using\`, and a fully-qualified reference used inline without + any using directive (\`Some.Namespace.Foo.Method()\`). These are the patterns analysts will + grep for — make them literal and greppable, not prose. +- \`reachability_notes\` must state what does NOT count (e.g. sibling members that look + similar but are not affected, usage confined to a \`*.Tests\`/\`*.Test\` project or + \`samples/\`) and any conditions required for exploitability. +- Set \`confidence\` for your identification: 0.9+ only if the patch unambiguously + identifies the symbol(s); lower if you had to infer from indirect evidence.` + +export const buildNuGetIntelPrompt = buildIntelPrompt + +// ---------- STAGE 3: REACHABILITY ---------- + +const IMPORT_STYLE_ENUM = [ + 'using-directive', + 'using-alias', + 'global-using', + 'fqcn-reference', + 'reexport', + 'none', +] + +export const NUGET_VERDICT_SCHEMA = buildVerdictSchema(IMPORT_STYLE_ENUM) + +export function buildNuGetReachabilitySystemPrompt(spec: SymbolSpec): string { + const { symbolsText, signatures } = buildReachabilitySymbolsBlock(spec) + + return `You are a security reachability analyst. Your working directory contains the source (fetched +from GitHub) of ONE NuGet package (the "dependent") that declares a dependency on +\`${spec.package}\`, which has a known vulnerability (${spec.vuln_id}). + +## The vulnerability +${spec.summary} + +Vulnerable symbol(s) in \`${spec.package}\`: +${symbolsText} + +Exploit preconditions: ${spec.exploit_preconditions} + +Analyst notes: ${spec.reachability_notes} + +## Import signatures to look for +${signatures} + +## Your task +Decide whether THIS dependent's own code actually reaches the vulnerable symbol(s). + +Scope rules — follow strictly: +1. Only the dependent's OWN shipped code counts. Ignore anything under a \`*.Tests\`/\`*.Test\` + project, \`samples/\`, \`bin/\`, or \`obj/\`. Usage of the vulnerable symbol inside the + dependent's other dependencies is OUT OF SCOPE (that is second-level analysis, done + separately). +2. Merely declaring \`${spec.package}\` as a dependency (a \`\` in a + \`.csproj\`, or a legacy \`packages.config\` entry) is NOT enough — the vulnerable symbol + itself must be reached. Uses of other types/members from the package are irrelevant. +3. Usage only in a \`*.Tests\`/\`*.Test\` project or \`samples/\` that is not part of the shipped + runtime code → \`not_affected\` (explain in reasoning). +4. If the dependent RE-EXPORTS the vulnerable symbol to its own consumers (a thin + wrapper class/method that passes arguments through, or a subclass that doesn't override + the vulnerable member), that DOES count as \`affected\` with \`import_style: "reexport"\` — + it propagates the vulnerable surface. +5. Watch for indirect reachability inside the dependent's own code: fully-qualified + references without a \`using\`, \`using\` aliases, implicit/global usings, interface + implementation, and reflection-based dispatch. +6. \`import_style\` describes how the VULNERABLE SYMBOL is reached, not how the package is + imported: report \`none\` whenever the vulnerable symbol itself is not reached, even if + the package is imported for other functionality. + +Method: grep for the import signatures (and the bare symbol names) across the source, +open every hit, and trace whether the symbol is actually invoked. Check the \`.csproj\` +(or \`packages.config\`) to confirm the declared dependency and its version. Exclude +\`*.Tests\`/\`*.Test\` projects and \`samples/\` from consideration. + +## Confidence calibration +- 0.8–1.0: direct evidence — you found (or ruled out) the using directive AND the call site + explicitly; source was readable. +- 0.4–0.8: symbol is imported but the call path is ambiguous (interface dispatch, + conditional use, generated code). +- <0.4 and/or \`unclear\`: source is generated/absent, or indirection you could not resolve. + +Report evidence as exact file paths, line numbers, and short verbatim snippets.` +} + +export const NUGET_REACHABILITY_PROMPT = + 'Analyze this package per your instructions and produce the structured verdict. ' + + 'Start by listing the project structure and grepping for the import signatures.' diff --git a/services/apps/packages_worker/src/blast-radius/clients/__tests__/nugetSource.test.ts b/services/apps/packages_worker/src/blast-radius/clients/__tests__/nugetSource.test.ts new file mode 100644 index 0000000000..599a088b35 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/__tests__/nugetSource.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { fetchNuspec } from '../../../nuget/client' +import { downloadAndExtractTarball } from '../npmTarball' +import { NuGetSourceNotFoundError, downloadAndExtractNuGetSource } from '../nugetSource' + +vi.mock('../../../nuget/client', () => ({ fetchNuspec: vi.fn() })) +vi.mock('../npmTarball', () => ({ downloadAndExtractTarball: vi.fn() })) + +const mockFetchNuspec = vi.mocked(fetchNuspec) +const mockDownloadAndExtractTarball = vi.mocked(downloadAndExtractTarball) + +function nuspecWithRepository(url?: string, commit?: string): string { + const attrs = [url ? `url="${url}"` : null, commit ? `commit="${commit}"` : null] + .filter(Boolean) + .join(' ') + const repository = attrs ? `` : '' + return `${repository}` +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('downloadAndExtractNuGetSource', () => { + it('downloads the exact commit recorded in the nuspec element', async () => { + mockFetchNuspec.mockResolvedValue( + nuspecWithRepository('https://github.com/owner/repo', 'abc123'), + ) + mockDownloadAndExtractTarball.mockResolvedValue(undefined) + + await downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest') + + expect(mockDownloadAndExtractTarball).toHaveBeenCalledWith( + 'https://codeload.github.com/owner/repo/tar.gz/abc123', + '/tmp/dest', + ) + expect(mockDownloadAndExtractTarball).toHaveBeenCalledTimes(1) + }) + + it('falls back to a version-tag guess when no commit is recorded', async () => { + mockFetchNuspec.mockResolvedValue(nuspecWithRepository('https://github.com/owner/repo')) + mockDownloadAndExtractTarball.mockResolvedValue(undefined) + + await downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest') + + expect(mockDownloadAndExtractTarball).toHaveBeenCalledWith( + 'https://codeload.github.com/owner/repo/tar.gz/v1.0.0', + '/tmp/dest', + ) + }) + + it('tries the next tag guess when the first candidate fails', async () => { + mockFetchNuspec.mockResolvedValue(nuspecWithRepository('https://github.com/owner/repo')) + mockDownloadAndExtractTarball + .mockRejectedValueOnce(new Error('404')) + .mockResolvedValueOnce(undefined) + + await downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest') + + expect(mockDownloadAndExtractTarball).toHaveBeenNthCalledWith( + 1, + 'https://codeload.github.com/owner/repo/tar.gz/v1.0.0', + '/tmp/dest', + ) + expect(mockDownloadAndExtractTarball).toHaveBeenNthCalledWith( + 2, + 'https://codeload.github.com/owner/repo/tar.gz/1.0.0', + '/tmp/dest', + ) + }) + + it('throws NuGetSourceNotFoundError when every candidate fails', async () => { + mockFetchNuspec.mockResolvedValue(nuspecWithRepository('https://github.com/owner/repo')) + mockDownloadAndExtractTarball.mockRejectedValue(new Error('404')) + + await expect( + downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest'), + ).rejects.toThrow(NuGetSourceNotFoundError) + }) + + it('throws NuGetSourceNotFoundError when the nuspec has no repository url', async () => { + mockFetchNuspec.mockResolvedValue(nuspecWithRepository()) + + await expect( + downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest'), + ).rejects.toThrow(NuGetSourceNotFoundError) + expect(mockDownloadAndExtractTarball).not.toHaveBeenCalled() + }) + + it('throws NuGetSourceNotFoundError for a non-GitHub repository host', async () => { + mockFetchNuspec.mockResolvedValue(nuspecWithRepository('https://gitlab.com/owner/repo')) + + await expect( + downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest'), + ).rejects.toThrow(NuGetSourceNotFoundError) + expect(mockDownloadAndExtractTarball).not.toHaveBeenCalled() + }) + + it('throws NuGetSourceNotFoundError when the nuspec fetch itself fails', async () => { + mockFetchNuspec.mockResolvedValue({ kind: 'NOT_FOUND', message: 'not found' }) + + await expect( + downloadAndExtractNuGetSource('Some.Package', '1.0.0', '/tmp/dest'), + ).rejects.toThrow(NuGetSourceNotFoundError) + expect(mockDownloadAndExtractTarball).not.toHaveBeenCalled() + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts b/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts new file mode 100644 index 0000000000..a087a12509 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts @@ -0,0 +1,103 @@ +import { XMLParser } from 'fast-xml-parser' +import * as fs from 'fs' + +import { fetchNuspec } from '../../nuget/client' +import { isNuGetFetchError } from '../../nuget/types' +import { canonicalizeRepoUrl } from '../../utils/canonicalizeRepoUrl' + +import { downloadAndExtractTarball } from './npmTarball' + +// Thrown when no GitHub source could be resolved for a dependent at all — the +// reachability stage turns this into a clean "no source" verdict rather than a retry. +export class NuGetSourceNotFoundError extends Error { + constructor(packageId: string, version: string) { + super(`No resolvable GitHub source for ${packageId}@${version}`) + this.name = 'NuGetSourceNotFoundError' + } +} + +const nuspecParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }) + +interface NuspecRepository { + url: string | null + commit: string | null +} + +// Mirrors nuget/normalize.ts:parseNuspecRepositoryUrl, but also reads @_commit — +// blast-radius needs the exact commit to fetch matching C# source, not just the repo. +function parseNuspecRepository(nuspecXml: string): NuspecRepository { + try { + const doc = nuspecParser.parse(nuspecXml) + const repository = doc?.package?.metadata?.repository + const url = typeof repository?.['@_url'] === 'string' ? repository['@_url'].trim() : null + const commit = + typeof repository?.['@_commit'] === 'string' ? repository['@_commit'].trim() : null + return { url: url || null, commit: commit || null } + } catch { + return { url: null, commit: null } + } +} + +function githubOwnerRepo(canonicalGithubUrl: string): { owner: string; repo: string } | null { + const match = canonicalGithubUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/) + return match ? { owner: match[1], repo: match[2] } : null +} + +function codeloadTarballUrl(owner: string, repo: string, ref: string): string { + return `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref}` +} + +// .nupkg ships compiled DLLs, not C# source (unlike Maven's -sources.jar), so source +// must come from GitHub. Ordered candidates: the exact commit the nuspec +// element records (most precise), then a couple of common version-tag conventions +// against the same repo. Only GitHub repos are supported — GitLab/Bitbucket tarballs +// don't share codeload's single-wrapper-directory layout that downloadAndExtractTarball +// (strip: 1) relies on. +async function candidateSourceTarballUrls(packageId: string, version: string): Promise { + const nuspec = await fetchNuspec(packageId, version) + if (isNuGetFetchError(nuspec)) return [] + + const { url, commit } = parseNuspecRepository(nuspec) + if (!url) return [] + + const canonical = canonicalizeRepoUrl(url) + if (!canonical || canonical.host !== 'github') return [] + + const ownerRepo = githubOwnerRepo(canonical.url) + if (!ownerRepo) return [] + + const candidates: string[] = [] + if (commit) candidates.push(codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, commit)) + candidates.push(codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, `v${version}`)) + candidates.push(codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, version)) + return candidates +} + +export async function downloadAndExtractNuGetSource( + packageId: string, + version: string, + destDir: string, +): Promise { + const candidates = await candidateSourceTarballUrls(packageId, version) + if (candidates.length === 0) { + throw new NuGetSourceNotFoundError(packageId, version) + } + + let lastErr: unknown + for (const url of candidates) { + try { + // Clear between attempts — a prior candidate's partial extraction (e.g. hit an + // extraction limit mid-stream) must not leave stale files a later candidate builds on. + fs.rmSync(destDir, { recursive: true, force: true }) + await downloadAndExtractTarball(url, destDir) + return + } catch (err) { + lastErr = err + } + } + + throw new NuGetSourceNotFoundError( + `${packageId} (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})`, + version, + ) +} diff --git a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts index 5a11a60465..3dddd00623 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts @@ -60,7 +60,8 @@ function mergeDuplicatePackageEntries(entries: OsvAffectedPackage[]): OsvAffecte const merged = new Map() for (const entry of entries) { - const key = `${entry.package.ecosystem}|${entry.package.name}` + // All entries share same ecosystem (filtered upstream), so just use package name as key. + const key = entry.package.name const existing = merged.get(key) if (!existing) { merged.set(key, { @@ -71,8 +72,8 @@ function mergeDuplicatePackageEntries(entries: OsvAffectedPackage[]): OsvAffecte continue } if (entry.ranges) { - const seen = new Set((existing.ranges ?? []).map((r) => JSON.stringify(r))) - const newRanges = entry.ranges.filter((r) => !seen.has(JSON.stringify(r))) + const existingRangeStrs = new Set((existing.ranges ?? []).map((r) => JSON.stringify(r))) + const newRanges = entry.ranges.filter((r) => !existingRangeStrs.has(JSON.stringify(r))) existing.ranges = [...(existing.ranges ?? []), ...newRanges] } if (entry.versions) { diff --git a/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts b/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts index 43691530b8..db2b53c02b 100644 --- a/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts +++ b/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts @@ -2,7 +2,7 @@ import { ApplicationFailure } from '@temporalio/workflow' // Single source of truth for supported ecosystems — kept in this leaf, I/O-free file // (no activities/DAL imports) so the workflow bundle stays deterministic-safe. -export const SUPPORTED_ECOSYSTEMS = ['npm', 'go', 'maven', 'cargo'] as const +export const SUPPORTED_ECOSYSTEMS = ['npm', 'go', 'maven', 'cargo', 'nuget'] as const export type Ecosystem = (typeof SUPPORTED_ECOSYSTEMS)[number] // Pure so it's testable outside the workflow sandbox (Workflow.log/context calls diff --git a/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts b/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts index 86506a64d9..d97b34bffb 100644 --- a/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts +++ b/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts @@ -1,12 +1,20 @@ -// Accepts a bare npm name or a full purl (see blastRadiusJobRequestSchema) and reduces -// it to the bare form OSV/the npm registry compare against. +// Strip query string and fragment from a purl or identifier string. +function stripQueryAndFragment(input: string): string { + const q = input.indexOf('?') + const h = input.indexOf('#') + const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h) + return cut === -1 ? input : input.slice(0, cut) +} + +// The blast-radius submit endpoint accepts either a bare npm package name +// ("lodash", "@babel/core") or a full purl ("pkg:npm/lodash", "pkg:npm/%40babel/core@4.17.21") +// for the `package` field — see blastRadiusJobRequestSchema. OSV affected-package entries and +// the npm registry only ever use bare names, so a purl must be reduced to that form before +// it's compared against them (raw string equality otherwise never matches a purl input). export function toBareNpmName(input: string): string { let name = input.trim() - const q = name.indexOf('?') - const h = name.indexOf('#') - const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h) - if (cut !== -1) name = name.slice(0, cut) + name = stripQueryAndFragment(name) if (name.startsWith('pkg:npm/')) { name = name.slice('pkg:npm/'.length) @@ -25,10 +33,7 @@ export function toBareNpmName(input: string): string { export function toBareGoModule(input: string): string { let name = input.trim() - const q = name.indexOf('?') - const h = name.indexOf('#') - const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h) - if (cut !== -1) name = name.slice(0, cut) + name = stripQueryAndFragment(name) name = decodeURIComponent(name) @@ -46,10 +51,7 @@ export function toBareGoModule(input: string): string { export function toBareCargoName(input: string): string { let name = input.trim() - const q = name.indexOf('?') - const h = name.indexOf('#') - const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h) - if (cut !== -1) name = name.slice(0, cut) + name = stripQueryAndFragment(name) name = decodeURIComponent(name) @@ -62,6 +64,24 @@ export function toBareCargoName(input: string): string { return name } +// Same normalization as toBareGoModule, but for NuGet. Deliberately does NOT lowercase — +// findPackageId/findPackageIdsByName compare case-sensitively against canonical casing. +export function toBareNuGetId(input: string): string { + let name = input.trim() + + name = stripQueryAndFragment(name) + + name = decodeURIComponent(name) + + if (name.startsWith('pkg:nuget/')) { + name = name.slice('pkg:nuget/'.length) + } + + name = name.replace(/@[^/@]+$/, '') + + return name +} + // packages/purl rows store cargo names '_'-normalized (see cargo/loadDump.ts) while // OSV/crates.io use '-'. Apply ONLY at the packages-table lookup boundary. export function toDbCargoName(name: string): string { @@ -73,10 +93,7 @@ export function toDbCargoName(name: string): string { export function toBareMavenCoordinate(input: string): { groupId: string; artifactId: string } { let name = input.trim() - const q = name.indexOf('?') - const h = name.indexOf('#') - const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h) - if (cut !== -1) name = name.slice(0, cut) + name = stripQueryAndFragment(name) name = decodeURIComponent(name) diff --git a/services/apps/packages_worker/src/blast-radius/stages/__tests__/dispatch.test.ts b/services/apps/packages_worker/src/blast-radius/stages/__tests__/dispatch.test.ts index 873214537f..d61291bba6 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/__tests__/dispatch.test.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/__tests__/dispatch.test.ts @@ -16,6 +16,9 @@ import { mavenReachabilityConfig } from '../maven/reachabilityConfig' import { runDependentsStageNpm } from '../npm/dependentsNpm' import { runIntelStageNpm } from '../npm/intelNpm' import { npmReachabilityConfig } from '../npm/reachabilityConfig' +import { runDependentsStageNuGet } from '../nuget/dependentsNuGet' +import { runIntelStageNuGet } from '../nuget/intelNuGet' +import { nugetReachabilityConfig } from '../nuget/reachabilityConfig' import { runReachabilityStage } from '../reachability' import { runReachabilityStage as runReachabilityStageWithConfig } from '../reachabilityStage' @@ -30,6 +33,9 @@ vi.mock('../maven/intelMaven', () => ({ vi.mock('../cargo/intelCargo', () => ({ runIntelStageCargo: vi.fn().mockResolvedValue(undefined), })) +vi.mock('../nuget/intelNuGet', () => ({ + runIntelStageNuGet: vi.fn().mockResolvedValue(undefined), +})) vi.mock('../go/dependentsGo', () => ({ runDependentsStageGo: vi.fn().mockResolvedValue(undefined), })) @@ -42,6 +48,9 @@ vi.mock('../maven/dependentsMaven', () => ({ vi.mock('../cargo/dependentsCargo', () => ({ runDependentsStageCargo: vi.fn().mockResolvedValue(undefined), })) +vi.mock('../nuget/dependentsNuGet', () => ({ + runDependentsStageNuGet: vi.fn().mockResolvedValue(undefined), +})) vi.mock('../reachabilityStage', () => ({ runReachabilityStage: vi.fn().mockResolvedValue(undefined), })) @@ -89,12 +98,22 @@ describe('stage dispatchers (EcosystemConfig registry)', () => { expect(runIntelStageMaven).not.toHaveBeenCalled() }) + it('routes intel to the NuGet body when ecosystem is nuget', async () => { + mockGetAnalysisDetail.mockResolvedValue({ ecosystem: 'nuget' } as never) + await runIntelStage(qx, 'analysis-1', 'GHSA-xxxx', undefined) + expect(runIntelStageNuGet).toHaveBeenCalledWith(qx, 'analysis-1', 'GHSA-xxxx', undefined) + expect(runIntelStageGo).not.toHaveBeenCalled() + expect(runIntelStageNpm).not.toHaveBeenCalled() + expect(runIntelStageMaven).not.toHaveBeenCalled() + }) + it('routes intel to the npm body when ecosystem is missing/unknown', async () => { mockGetAnalysisDetail.mockResolvedValue(null) await runIntelStage(qx, 'analysis-1', 'GHSA-xxxx', undefined) expect(runIntelStageNpm).toHaveBeenCalled() expect(runIntelStageGo).not.toHaveBeenCalled() expect(runIntelStageMaven).not.toHaveBeenCalled() + expect(runIntelStageNuGet).not.toHaveBeenCalled() }) it('routes dependents to the Go body when ecosystem is go', async () => { @@ -122,12 +141,22 @@ describe('stage dispatchers (EcosystemConfig registry)', () => { expect(runDependentsStageMaven).not.toHaveBeenCalled() }) + it('routes dependents to the NuGet body when ecosystem is nuget', async () => { + mockGetAnalysisDetail.mockResolvedValue({ ecosystem: 'nuget' } as never) + await runDependentsStage(qx, 'analysis-1', undefined, undefined) + expect(runDependentsStageNuGet).toHaveBeenCalled() + expect(runDependentsStageGo).not.toHaveBeenCalled() + expect(runDependentsStageNpm).not.toHaveBeenCalled() + expect(runDependentsStageMaven).not.toHaveBeenCalled() + }) + it('routes dependents to the npm body for npm/unknown ecosystems', async () => { mockGetAnalysisDetail.mockResolvedValue({ ecosystem: 'npm' } as never) await runDependentsStage(qx, 'analysis-1', undefined, undefined) expect(runDependentsStageNpm).toHaveBeenCalled() expect(runDependentsStageGo).not.toHaveBeenCalled() expect(runDependentsStageMaven).not.toHaveBeenCalled() + expect(runDependentsStageNuGet).not.toHaveBeenCalled() }) it('routes reachability to the Go config when ecosystem is go', async () => { @@ -163,6 +192,17 @@ describe('stage dispatchers (EcosystemConfig registry)', () => { ) }) + it('routes reachability to the NuGet config when ecosystem is nuget', async () => { + mockGetAnalysisDetail.mockResolvedValue({ ecosystem: 'nuget' } as never) + await runReachabilityStage(qx, 'analysis-1', undefined) + expect(mockRunReachabilityStageWithConfig).toHaveBeenCalledWith( + qx, + 'analysis-1', + nugetReachabilityConfig, + undefined, + ) + }) + it('routes reachability to the npm config for npm/unknown ecosystems', async () => { mockGetAnalysisDetail.mockResolvedValue({ ecosystem: 'npm' } as never) await runReachabilityStage(qx, 'analysis-1', undefined) diff --git a/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts b/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts new file mode 100644 index 0000000000..5f51facb73 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { ecosystemRangeEvents, highestVersion, versionsInRanges } from '../ecosystemVersions' + +describe('ecosystemVersions', () => { + describe('ecosystemRangeEvents', () => { + it('reads ECOSYSTEM-typed ranges with an introduced/fixed pair', () => { + const events = ecosystemRangeEvents({ + package: { ecosystem: 'Maven', name: 'com.example:foo' }, + ranges: [{ type: 'ECOSYSTEM', events: [{ introduced: '1.0' }, { fixed: '2.0' }] }], + }) + expect(events).toEqual([{ introduced: '1.0', fixed: '2.0', lastAffected: null }]) + }) + + it('ignores SEMVER-typed ranges', () => { + const events = ecosystemRangeEvents({ + package: { ecosystem: 'NuGet', name: 'Some.Package' }, + ranges: [{ type: 'SEMVER', events: [{ introduced: '1.0' }, { fixed: '2.0' }] }], + }) + expect(events).toEqual([]) + }) + + it('falls back to the explicit versions list when there are no ranges', () => { + const events = ecosystemRangeEvents({ + package: { ecosystem: 'NuGet', name: 'Some.Package' }, + versions: ['1.0', '1.1'], + }) + expect(events).toEqual([ + { introduced: '1.0', fixed: null, lastAffected: '1.0' }, + { introduced: '1.1', fixed: null, lastAffected: '1.1' }, + ]) + }) + }) + + describe('versionsInRanges', () => { + it('orders Maven versions via Maven comparison, not lexical/semver', () => { + const versions = ['1.0', '1.9', '1.10', '2.0'] + const ranges = [{ introduced: '1.0', fixed: '2.0', lastAffected: null }] + // Lexically "1.10" < "1.9", but Maven orders 1.10 > 1.9 — both must be included, + // and the fixed version 2.0 must be excluded. + expect(versionsInRanges('maven', versions, ranges)).toEqual(['1.0', '1.9', '1.10']) + }) + + it('orders NuGet versions via semver comparison', () => { + const versions = ['1.0.0', '1.9.0', '1.10.0', '2.0.0'] + const ranges = [{ introduced: '1.0.0', fixed: '2.0.0', lastAffected: null }] + // Semver orders 1.9.0 > 1.10.0's minor is 10 > 9, so unlike the Maven case above, + // 1.10.0 must sort after 1.9.0 here too, but via a different comparator entirely. + expect(versionsInRanges('nuget', versions, ranges)).toEqual(['1.0.0', '1.9.0', '1.10.0']) + }) + + it('excludes when an unparseable bound makes a bound comparison ambiguous', () => { + const versions = ['1.0.0'] + const ranges = [{ introduced: '---', fixed: null, lastAffected: null }] + expect(versionsInRanges('nuget', versions, ranges)).toEqual([]) + }) + }) + + describe('highestVersion', () => { + it('returns the Maven-ordered highest version', () => { + expect(highestVersion('maven', ['1.0', '1.10', '1.9'])).toBe('1.10') + }) + + it('returns the semver-ordered highest version for NuGet', () => { + expect(highestVersion('nuget', ['1.0.0', '1.10.0', '1.9.0'])).toBe('1.10.0') + }) + + it('handles an empty list', () => { + expect(highestVersion('nuget', [])).toBeNull() + }) + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts new file mode 100644 index 0000000000..0d5141eb33 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts @@ -0,0 +1,102 @@ +import { compareVersion } from '../../osv/versionCompare' +import { OsvAffectedPackage } from '../clients/osvClient' + +// Shared by any ecosystem whose OSV advisories use ECOSYSTEM-typed ranges (ordered via +// compareVersion(ecosystem, …) rather than node-semver) instead of SEMVER-typed ranges — +// currently Maven and NuGet. Parameterized by ecosystem so the two don't duplicate this +// logic; see mavenVersions.ts for the ecosystem-bound wrappers Maven's stage files import. +export interface EcosystemRange { + introduced: string | null + fixed: string | null + lastAffected: string | null +} + +export function ecosystemRangeEvents(entry: OsvAffectedPackage): EcosystemRange[] { + const events: EcosystemRange[] = [] + + if (entry.ranges) { + for (const range of entry.ranges) { + if (range.type !== 'ECOSYSTEM' || !range.events) continue + + let introduced: string | null = null + + for (const event of range.events) { + if (event.introduced) introduced = event.introduced + + if (event.fixed) { + events.push({ introduced, fixed: event.fixed, lastAffected: null }) + introduced = null + } else if (event.last_affected) { + events.push({ introduced, fixed: null, lastAffected: event.last_affected }) + introduced = null + } + } + + if (introduced !== null) { + events.push({ introduced, fixed: null, lastAffected: null }) + } + } + } + + // Fallback: if no ECOSYSTEM ranges, use the explicit version list as exact + // vulnerable versions. + if (events.length === 0 && (entry.versions ?? []).length > 0) { + for (const v of entry.versions ?? []) { + events.push({ introduced: v, fixed: null, lastAffected: v }) + } + } + + return events +} + +function compareOrNull(ecosystem: string, a: string, b: string): number | null { + return compareVersion(ecosystem, a, b) +} + +// Unparseable bound → treated as NOT in range (unlike the constraint helpers' over-inclusive +// stance — this only decides the vulnerable-version set, not dependent reachability). +function isInRange(ecosystem: string, version: string, range: EcosystemRange): boolean { + if (range.introduced) { + const c = compareOrNull(ecosystem, version, range.introduced) + if (c === null || c < 0) return false + } + if (range.fixed) { + const c = compareOrNull(ecosystem, version, range.fixed) + if (c === null || c >= 0) return false + } + if (range.lastAffected) { + const c = compareOrNull(ecosystem, version, range.lastAffected) + if (c === null || c > 0) return false + } + return true +} + +export function versionsInRanges( + ecosystem: string, + versions: string[], + ranges: EcosystemRange[], +): string[] { + const result: string[] = [] + for (const v of versions) { + for (const range of ranges) { + if (isInRange(ecosystem, v, range)) { + result.push(v) + break + } + } + } + return result +} + +export function highestVersion(ecosystem: string, versions: string[]): string | null { + let best: string | null = null + for (const v of versions) { + if (best === null) { + best = v + continue + } + const c = compareOrNull(ecosystem, v, best) + if (c !== null && c > 0) best = v + } + return best +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/ecosystems.ts b/services/apps/packages_worker/src/blast-radius/stages/ecosystems.ts index 10bebbc42c..e2a5e1dfed 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/ecosystems.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/ecosystems.ts @@ -14,6 +14,9 @@ import { mavenReachabilityConfig } from './maven/reachabilityConfig' import { runDependentsStageNpm } from './npm/dependentsNpm' import { runIntelStageNpm } from './npm/intelNpm' import { npmReachabilityConfig } from './npm/reachabilityConfig' +import { runDependentsStageNuGet } from './nuget/dependentsNuGet' +import { runIntelStageNuGet } from './nuget/intelNuGet' +import { nugetReachabilityConfig } from './nuget/reachabilityConfig' import { ReachabilitySourceConfig } from './reachabilityStage' // Replaces the 3 scattered `if (ecosystem === 'go')` dispatch branches. Record @@ -55,6 +58,11 @@ const ECOSYSTEMS: Record = { runDependents: runDependentsStageCargo, reachability: cargoReachabilityConfig, }, + nuget: { + runIntel: runIntelStageNuGet, + runDependents: runDependentsStageNuGet, + reachability: nugetReachabilityConfig, + }, } export function getEcosystemConfig(ecosystem: string | null | undefined): EcosystemConfig { diff --git a/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts b/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts index b13ee7eca5..db11b2902d 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts @@ -61,9 +61,10 @@ export async function runIntelStageGo( // Pick the Go entry the analysis was requested for; see selectAdvisoryEntry for // rejection rules on non-matching or omitted requests. const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) - const requestedModule = analysisDetail?.package_name - ? toBareGoModule(analysisDetail.package_name) - : null + const requestedModule = + analysisDetail?.package_name !== undefined + ? toBareGoModule(analysisDetail.package_name) + : null const { entry, relatedAffectedPackages } = selectAdvisoryEntry( goEntries, requestedModule, diff --git a/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts b/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts index f76ea65b70..43bbdd388f 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts @@ -65,9 +65,10 @@ export async function runIntelStageMaven( // Pick the Maven entry the analysis was requested for; see selectAdvisoryEntry for // rejection rules on non-matching or omitted requests. const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) - const requested = analysisDetail?.package_name - ? toBareMavenCoordinate(analysisDetail.package_name) - : null + const requested = + analysisDetail?.package_name !== undefined + ? toBareMavenCoordinate(analysisDetail.package_name) + : null const requestedCoordinate = requested ? `${requested.groupId}:${requested.artifactId}` : null const { entry, relatedAffectedPackages } = selectAdvisoryEntry( mavenEntries, diff --git a/services/apps/packages_worker/src/blast-radius/stages/maven/mavenVersions.ts b/services/apps/packages_worker/src/blast-radius/stages/maven/mavenVersions.ts index c997c570c3..dac6873ed2 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/maven/mavenVersions.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/maven/mavenVersions.ts @@ -1,96 +1,19 @@ -import { compareVersion } from '../../../osv/versionCompare' import { OsvAffectedPackage } from '../../clients/osvClient' +import * as ecosystemVersions from '../ecosystemVersions' // Maven-specific counterpart to semverRange.ts — OSV Maven advisories use ECOSYSTEM-typed -// ranges, ordered via compareVersion('maven', …) instead of node-semver. -export interface MavenRange { - introduced: string | null - fixed: string | null - lastAffected: string | null -} +// ranges, ordered via compareVersion('maven', …) instead of node-semver. Thin wrappers +// binding ecosystem='maven' over the shared ecosystemVersions.ts logic (also used by NuGet). +export type MavenRange = ecosystemVersions.EcosystemRange export function mavenRangeEvents(entry: OsvAffectedPackage): MavenRange[] { - const events: MavenRange[] = [] - - if (entry.ranges) { - for (const range of entry.ranges) { - if (range.type !== 'ECOSYSTEM' || !range.events) continue - - let introduced: string | null = null - - for (const event of range.events) { - if (event.introduced) introduced = event.introduced - - if (event.fixed) { - events.push({ introduced, fixed: event.fixed, lastAffected: null }) - introduced = null - } else if (event.last_affected) { - events.push({ introduced, fixed: null, lastAffected: event.last_affected }) - introduced = null - } - } - - if (introduced !== null) { - events.push({ introduced, fixed: null, lastAffected: null }) - } - } - } - - // Fallback: if no ECOSYSTEM ranges, use the explicit version list as exact - // vulnerable versions. - if (events.length === 0 && (entry.versions ?? []).length > 0) { - for (const v of entry.versions ?? []) { - events.push({ introduced: v, fixed: null, lastAffected: v }) - } - } - - return events -} - -function compareOrNull(a: string, b: string): number | null { - return compareVersion('maven', a, b) -} - -// Unparseable bound → treated as NOT in range (unlike mavenConstraint.ts's over-inclusive -// stance — this only decides the vulnerable-version set, not dependent reachability). -function isInRange(version: string, range: MavenRange): boolean { - if (range.introduced) { - const c = compareOrNull(version, range.introduced) - if (c === null || c < 0) return false - } - if (range.fixed) { - const c = compareOrNull(version, range.fixed) - if (c === null || c >= 0) return false - } - if (range.lastAffected) { - const c = compareOrNull(version, range.lastAffected) - if (c === null || c > 0) return false - } - return true + return ecosystemVersions.ecosystemRangeEvents(entry) } export function versionsInRanges(versions: string[], ranges: MavenRange[]): string[] { - const result: string[] = [] - for (const v of versions) { - for (const range of ranges) { - if (isInRange(v, range)) { - result.push(v) - break - } - } - } - return result + return ecosystemVersions.versionsInRanges('maven', versions, ranges) } export function highestVersion(versions: string[]): string | null { - let best: string | null = null - for (const v of versions) { - if (best === null) { - best = v - continue - } - const c = compareOrNull(v, best) - if (c !== null && c > 0) best = v - } - return best + return ecosystemVersions.highestVersion('maven', versions) } diff --git a/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts b/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts index 4f73e2ca13..a9c0d756bf 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts @@ -60,9 +60,8 @@ export async function runIntelStageNpm( // Requested package may be a bare name or full purl; OSV entries are always bare // names, so normalize before comparing (see selectAdvisoryEntry for rejection rules). const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) - const requestedPackage = analysisDetail?.package_name - ? toBareNpmName(analysisDetail.package_name) - : null + const requestedPackage = + analysisDetail?.package_name !== undefined ? toBareNpmName(analysisDetail.package_name) : null const { entry, relatedAffectedPackages } = selectAdvisoryEntry( npmEntries, requestedPackage, diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts new file mode 100644 index 0000000000..29b2030a94 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import { nugetConstraintMayInclude } from '../nugetConstraint' + +describe('nugetConstraintMayInclude', () => { + it('treats a bare version as an inclusive floor, unlike Maven soft requirements', () => { + // NuGet's resolver documents "1.0" as equivalent to "[1.0,)" — a real minimum, + // not Maven's overridable soft hint. + expect(nugetConstraintMayInclude('1.0.0', ['1.5.0'])).toBe('matched') + expect(nugetConstraintMayInclude('1.5.0', ['1.5.0'])).toBe('matched') + expect(nugetConstraintMayInclude('2.0.0', ['1.5.0'])).toBe('excluded') + }) + + it('matches a half-open hard range containing the max vulnerable version', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0)', ['1.5.0'])).toBe('matched') + }) + + it('excludes a half-open hard range that excludes the upper bound', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0)', ['2.0.0'])).toBe('excluded') + }) + + it('matches a closed hard range including its upper bound', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0]', ['2.0.0'])).toBe('matched') + }) + + it('matches an open-ended lower-bound range', () => { + expect(nugetConstraintMayInclude('(,1.0.0]', ['1.0.0'])).toBe('matched') + }) + + it('excludes an open-ended lower-bound range above the max vulnerable version', () => { + expect(nugetConstraintMayInclude('(,1.0.0]', ['1.5.0'])).toBe('excluded') + }) + + it('matches an open-ended upper-bound range', () => { + expect(nugetConstraintMayInclude('[1.5.0,)', ['1.5.0'])).toBe('matched') + }) + + it('matches an exact-version range', () => { + expect(nugetConstraintMayInclude('[1.0.0]', ['1.0.0'])).toBe('matched') + }) + + it('excludes an exact-version range that does not equal the max vulnerable version', () => { + expect(nugetConstraintMayInclude('[1.0.0]', ['1.5.0'])).toBe('excluded') + }) + + it('matches when any interval in a comma-separated union matches', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0),[3.0.0,4.0.0)', ['3.5.0'])).toBe('matched') + }) + + it('excludes when no interval in a comma-separated union matches', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0),[3.0.0,4.0.0)', ['2.5.0'])).toBe('excluded') + }) + + it('matches a bounded range containing an older vulnerable version but not the max', () => { + expect(nugetConstraintMayInclude('[1.0.0,1.2.0]', ['1.1.0', '2.9.0'])).toBe('matched') + }) + + it('conservatively includes an empty constraint', () => { + expect(nugetConstraintMayInclude('', ['1.5.0'])).toBe('unparseable-included') + }) + + it('conservatively includes a malformed bracket expression', () => { + expect(nugetConstraintMayInclude('[1.0.0,', ['1.5.0'])).toBe('unparseable-included') + }) + + it('conservatively includes a mismatched-bracket exact range instead of treating it as exact', () => { + expect(nugetConstraintMayInclude('(1.0.0)', ['1.5.0'])).toBe('unparseable-included') + expect(nugetConstraintMayInclude('[1.0.0)', ['1.5.0'])).toBe('unparseable-included') + }) + + it('conservatively includes a null constraint instead of throwing', () => { + expect(nugetConstraintMayInclude(null, ['1.5.0'])).toBe('unparseable-included') + }) + + it('conservatively includes a bare version containing bracket-like characters', () => { + // Not a valid interval and not a plain version — must not be silently treated as a floor. + expect(nugetConstraintMayInclude('1.0.0,2.0.0', ['1.5.0'])).toBe('unparseable-included') + }) + + it('conservatively includes a range with trailing garbage after the closed interval', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0)garbage', ['3.0.0'])).toBe( + 'unparseable-included', + ) + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsNuGet.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsNuGet.ts new file mode 100644 index 0000000000..5de9423322 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsNuGet.ts @@ -0,0 +1,111 @@ +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { findPackageIdsByName } from '@crowd/data-access-layer/src/packages/osv' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { scanNuGetDependents } from './dependentsScanNuGet' + +export async function runDependentsStageNuGet( + qx: QueryExecutor, + analysisId: string, + onProgress?: () => void, + signal?: AbortSignal, +): Promise { + const startTime = Date.now() + + try { + const existingStatus = await blastRadiusDal.getStageRunStatus(qx, analysisId, 'dependents') + if (existingStatus === 'succeeded') { + return + } + + await blastRadiusDal.startStageRun(qx, { + analysisId, + stage: 'dependents', + status: 'running', + model: null, + }) + + const spec = await blastRadiusDal.getSymbolSpec(qx, analysisId) + if (!spec) { + throw new Error('Symbol spec not found; stage 1 (intel) must run first') + } + + // See dependentsNpm.ts for why this unconditional clear is safe: reachability + // hasn't produced any verdicts yet at this point in the pipeline. + await blastRadiusDal.deleteDependents(qx, analysisId) + + const analysis = await blastRadiusDal.getAnalysis(qx, analysisId) + if (!analysis?.package_id) { + throw new Error('Vulnerable module package_id not resolved; stage 1 (intel) must run first') + } + + const vulnerableVersions = (spec.vulnerable_versions || []) as string[] + + // Heartbeat before the scan starts, not just after it completes — the scan is a + // single DB round trip that could otherwise run past the activity's heartbeat + // timeout with no heartbeat sent in between. + onProgress?.() + + const scanResult = await scanNuGetDependents( + qx, + String(analysis.package_id), + vulnerableVersions, + 25, + ) + + if (signal?.aborted) { + throw new Error('Dependents scan cancelled') + } + onProgress?.() + + const names = scanResult.analyzed.map((d) => d.name) + const packageIdsByName = await findPackageIdsByName(qx, 'nuget', names) + + const dependentInputs = [ + ...scanResult.analyzed.map((d) => ({ + analysisId, + packageId: packageIdsByName.get(d.name) ?? null, + name: d.name, + version: d.version, + downloads: d.downloads, + declaredRange: d.declaredRange, + dependencyKind: d.dependencyKind, + rangeIncludesVuln: d.rangeIncludesVuln, + rangeCheck: d.rangeCheck, + tarballUrl: d.tarballUrl, + excludedByRange: false, + exclusionReason: null, + })), + ...scanResult.excludedByRange.map((d) => ({ + analysisId, + packageId: null, + name: d.name, + version: d.version, + downloads: d.downloads, + declaredRange: d.declaredRange, + dependencyKind: d.dependencyKind, + rangeIncludesVuln: d.rangeIncludesVuln, + rangeCheck: d.rangeCheck, + tarballUrl: d.tarballUrl, + excludedByRange: true, + exclusionReason: `Constraint does not include vulnerable versions (${d.rangeCheck})`, + })), + ] + + await blastRadiusDal.insertDependents(qx, dependentInputs) + await blastRadiusDal.setDependentsMeta( + qx, + analysisId, + scanResult.source, + scanResult.candidatesConsidered, + ) + + const duration = Date.now() - startTime + await blastRadiusDal.completeStageRun(qx, analysisId, 'dependents', duration, 0) + } catch (err) { + const duration = Date.now() - startTime + const errorMsg = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failStageRun(qx, analysisId, 'dependents', duration, errorMsg) + throw err + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsScanNuGet.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsScanNuGet.ts new file mode 100644 index 0000000000..7f0b4c6cd4 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/dependentsScanNuGet.ts @@ -0,0 +1,58 @@ +import { getReverseDependents } from '@crowd/data-access-layer/src/packages/blastRadiusDependents' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { DependentCandidate, ScanDependentsResult } from '../../dependentsScan' +import { highestVersion } from '../ecosystemVersions' + +import { nugetConstraintMayInclude } from './nugetConstraint' + +// NuGet dependents come from package_dependencies (deps.dev BigQuery ingestion), same as +// Maven/Go — no download-count signal, and (unlike Maven) deps.dev never resolves a +// concrete version for NuGet edges, so matching goes purely through version_constraint. +export async function scanNuGetDependents( + qx: QueryExecutor, + vulnerablePackageId: string, + vulnerableVersions: string[], + topN: number, +): Promise { + const maxVulnerableVersion = highestVersion('nuget', vulnerableVersions) + if (!maxVulnerableVersion) { + return { + source: 'package_dependencies', + candidatesConsidered: 0, + analyzed: [], + excludedByRange: [], + excludedByRangeCount: 0, + } + } + + // Cap distinct from topN: gather a wider pool so excludedByRange candidates are + // still visible for diagnostics, same pattern as Maven/Go's scanLimit. + const scanLimit = Math.max(topN * 8, 200) + const rows = await getReverseDependents(qx, vulnerablePackageId, 'nuget', scanLimit) + + const candidates: DependentCandidate[] = rows.map((row) => { + const rangeCheck = nugetConstraintMayInclude(row.versionConstraint, vulnerableVersions) + return { + name: row.name, + version: row.versionNumber, + downloads: row.dependentReposCount ?? row.dependentCount ?? null, + declaredRange: row.versionConstraint, + dependencyKind: row.dependencyKind, + rangeIncludesVuln: rangeCheck !== 'excluded', + rangeCheck, + tarballUrl: null, + } + }) + + const included = candidates.filter((c) => c.rangeIncludesVuln) + const excluded = candidates.filter((c) => !c.rangeIncludesVuln) + + return { + source: 'package_dependencies', + candidatesConsidered: candidates.length, + analyzed: included.slice(0, topN), + excludedByRange: excluded.slice(0, 200), + excludedByRangeCount: excluded.length, + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts new file mode 100644 index 0000000000..3ce3712b6b --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts @@ -0,0 +1,178 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { getVersionNumbers } from '@crowd/data-access-layer/src/packages/blastRadiusDependents' +import { findPackageId } from '@crowd/data-access-layer/src/packages/osv' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { fetchVersionList } from '../../../nuget/client' +import { isNuGetFetchError } from '../../../nuget/types' +import { + NUGET_INTEL_SCHEMA, + NUGET_INTEL_SYSTEM_PROMPT, + buildNuGetIntelPrompt, +} from '../../agent/nugetPrompts' +import { runAnalysisAgent } from '../../agent/runner' +import { fetchPatch } from '../../clients/githubPatch' +import { downloadAndExtractNuGetSource } from '../../clients/nugetSource' +import { + affectedEntriesForEcosystem, + fetchOsvVuln, + fixReferenceUrls, +} from '../../clients/osvClient' +import { toBareNuGetId } from '../../packageIdentifier' +import { ecosystemRangeEvents, highestVersion, versionsInRanges } from '../ecosystemVersions' +import { selectAdvisoryEntry } from '../selectAdvisoryEntry' + +// OSV spells the NuGet ecosystem 'NuGet' (mixed case), unlike our DB's lowercase 'nuget'. +const OSV_NUGET_ECOSYSTEM = 'NuGet' + +export async function runIntelStageNuGet( + qx: QueryExecutor, + analysisId: string, + advisoryOsvId: string, + onProgress?: () => void, +): Promise { + const startTime = Date.now() + + try { + // Check if already done — avoid clobbering a succeeded stage_run's status/started_at + // on a redundant re-invocation (startStageRun's ON CONFLICT always overwrites status). + const existingStatus = await blastRadiusDal.getStageRunStatus(qx, analysisId, 'intel') + if (existingStatus === 'succeeded') { + return + } + + await blastRadiusDal.startStageRun(qx, { + analysisId, + stage: 'intel', + status: 'running', + model: 'claude-opus-4-8', + }) + + const osv = await fetchOsvVuln(advisoryOsvId) + + const nugetEntries = affectedEntriesForEcosystem(osv, OSV_NUGET_ECOSYSTEM) + if (nugetEntries.length === 0) { + throw new Error(`No NuGet entries found in advisory ${advisoryOsvId}`) + } + + // Pick the NuGet entry the analysis was requested for; see selectAdvisoryEntry for + // rejection rules on non-matching or omitted requests. + const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) + const requestedId = + analysisDetail?.package_name !== undefined ? toBareNuGetId(analysisDetail.package_name) : null + const { entry, relatedAffectedPackages } = selectAdvisoryEntry( + nugetEntries, + requestedId, + (e) => e.package.name === requestedId, + advisoryOsvId, + ) + + const nugetId = requestedId ?? entry.package.name + const ecosystem = 'nuget' + + // Resolve vulnerable versions from OSV ranges first (NuGet OSV ranges are + // ECOSYSTEM-typed, not SEMVER — same shape as Maven, see ecosystemVersions.ts). + const ranges = ecosystemRangeEvents(entry) + + const dbPackageId = await findPackageId(qx, { ecosystem, namespace: null, name: nugetId }) + + // The nuget.org registration index is the authoritative version list; fall back to + // our own ingested `versions` rows (deps.dev) if the registry is unreachable/rate-limited + // and the package is already known to us. + const versionListResult = await fetchVersionList(nugetId) + let allVersions: string[] + if (!isNuGetFetchError(versionListResult)) { + allVersions = versionListResult + } else if (dbPackageId) { + allVersions = await getVersionNumbers(qx, String(dbPackageId)) + } else { + throw new Error( + `Failed to fetch NuGet version list for ${nugetId} (${versionListResult.kind}) and package is not in our DB`, + ) + } + + const vulnerableVersions = versionsInRanges('nuget', allVersions, ranges) + + const analyzed = highestVersion('nuget', vulnerableVersions) + if (!analyzed) { + throw new Error(`Could not determine analyzed version for ${nugetId}`) + } + + const pkgsrcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nugetsrc-')) + const patches: Record = {} + + try { + await downloadAndExtractNuGetSource(nugetId, analyzed, pkgsrcDir) + + const patchUrls = fixReferenceUrls(osv) + for (const url of patchUrls.slice(0, 3)) { + try { + const patchText = await fetchPatch(url) + const slug = new URL(url).pathname.split('/').filter(Boolean).join('-') + patches[slug] = patchText + } catch { + // Ignore patch fetch errors + } + } + + const agentPrompt = buildNuGetIntelPrompt( + osv.id || advisoryOsvId, + osv.aliases || [], + osv.details || osv.summary || '', + analyzed, + patches, + ) + + const agentResult = await runAnalysisAgent({ + prompt: agentPrompt, + systemPrompt: NUGET_INTEL_SYSTEM_PROMPT, + cwd: pkgsrcDir, + model: 'claude-opus-4-8', + schema: NUGET_INTEL_SCHEMA, + maxTurns: 15, + timeoutMs: 600_000, + onProgress, + }) + + if (agentResult.isError || !agentResult.structuredOutput) { + throw new Error(`Agent failed: ${agentResult.errorMessage}`) + } + + const output = agentResult.structuredOutput + await blastRadiusDal.upsertSymbolSpec(qx, { + analysisId, + vulnId: osv.id || advisoryOsvId, + aliases: osv.aliases || [], + package: nugetId, + ecosystem, + affectedRanges: ranges as unknown as Record[], + vulnerableVersions, + analyzedVersion: analyzed, + relatedAffectedPackages, + vulnerableSymbols: (output.vulnerable_symbols || []) as Record[], + importSignatures: (output.import_signatures || {}) as Record, + exploitPreconditions: String(output.exploit_preconditions || ''), + reachabilityNotes: String(output.reachability_notes || ''), + confidence: Number(output.confidence ?? 0.5), + sources: [advisoryOsvId], + summary: String(output.summary || ''), + }) + + await blastRadiusDal.resolveAdvisoryAndPackageIds(qx, analysisId, advisoryOsvId, dbPackageId) + + const duration = Date.now() - startTime + await blastRadiusDal.completeStageRun(qx, analysisId, 'intel', duration, agentResult.costUsd) + } finally { + fs.rmSync(pkgsrcDir, { recursive: true, force: true }) + } + } catch (err) { + const duration = Date.now() - startTime + const errorMsg = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failStageRun(qx, analysisId, 'intel', duration, errorMsg) + throw err + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts new file mode 100644 index 0000000000..32d17f15c2 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts @@ -0,0 +1,124 @@ +import { compareVersion } from '../../../osv/versionCompare' + +export type NuGetConstraintMatch = 'matched' | 'excluded' | 'unparseable-included' + +interface NuGetInterval { + lower: string | null + lowerInclusive: boolean + upper: string | null + upperInclusive: boolean +} + +// A top-level comma joins alternative intervals, e.g. "[1.0,2.0),[3.0,4.0)" — track +// bracket depth only to skip the comma inside an interval's own lower/upper bound. +// Returns null (not a partial result) on unmatched brackets or trailing text after +// the last closed interval — accepting a valid-looking prefix there could silently +// drop the tail and wrongly exclude a vulnerable version instead of falling through +// to unparseable-included. +function splitTopLevelCommas(s: string): string[] | null { + const parts: string[] = [] + let depth = 0 + let start = 0 + for (let i = 0; i < s.length; i++) { + if (s[i] === '[' || s[i] === '(') depth++ + else if (s[i] === ']' || s[i] === ')') { + depth-- + if (depth < 0) return null + if (depth === 0) { + parts.push(s.slice(start, i + 1)) + start = i + 1 + if (s[start] === ',') start++ + } + } + } + if (depth !== 0 || start !== s.length) return null + return parts.filter(Boolean) +} + +function parseNuGetInterval(seg: string): NuGetInterval | null { + const s = seg.trim() + if (s.length < 2) return null + if (s[0] !== '[' && s[0] !== '(') return null + if (s[s.length - 1] !== ']' && s[s.length - 1] !== ')') return null + + const lowerInclusive = s[0] === '[' + const upperInclusive = s[s.length - 1] === ']' + const body = s.slice(1, -1) + const comma = body.indexOf(',') + + if (comma === -1) { + // Exact version, e.g. "[1.0.0]" — requires both brackets closed, unlike a mismatched + // "(1.0.0)" or "[1.0.0)", which is malformed and must fall through to unparseable-included. + if (!lowerInclusive || !upperInclusive) return null + const exact = body.trim() + if (!exact) return null + return { lower: exact, lowerInclusive: true, upper: exact, upperInclusive: true } + } + + const lower = body.slice(0, comma).trim() || null + const upper = body.slice(comma + 1).trim() || null + return { lower, lowerInclusive, upper, upperInclusive } +} + +// Unlike Maven's bare requirement (a soft hint mediation can override, so it's treated +// as unparseable), NuGet's bare version IS the enforced minimum — NuGet's own resolver +// documents "1.0" as equivalent to "[1.0,)". Parse it as an open-ended lower-bound interval +// rather than falling through to unparseable-included. +function parseBareVersionAsFloor(trimmed: string): NuGetInterval | null { + if (!trimmed || /[[(\]),]/.test(trimmed)) return null + return { lower: trimmed, lowerInclusive: true, upper: null, upperInclusive: false } +} + +function parseNuGetRange(constraint: string | null): NuGetInterval[] | null { + // package_dependencies.version_constraint is nullable (deps.dev fill path) — treat a + // missing constraint the same as an unparseable one, not a crash on .trim(). + if (constraint == null) return null + const trimmed = constraint.trim() + if (!trimmed) return null + + if (!trimmed.startsWith('[') && !trimmed.startsWith('(')) { + const bare = parseBareVersionAsFloor(trimmed) + return bare ? [bare] : null + } + + const segments = splitTopLevelCommas(trimmed) + if (!segments || segments.length === 0) return null + const intervals: NuGetInterval[] = [] + for (const seg of segments) { + const interval = parseNuGetInterval(seg) + if (!interval) return null + intervals.push(interval) + } + return intervals +} + +function intervalMayInclude(interval: NuGetInterval, version: string): boolean { + if (interval.lower) { + const c = compareVersion('nuget', version, interval.lower) + if (c === null) return true // unparseable bound — over-inclusive + if (interval.lowerInclusive ? c < 0 : c <= 0) return false + } + if (interval.upper) { + const c = compareVersion('nuget', version, interval.upper) + if (c === null) return true + if (interval.upperInclusive ? c > 0 : c >= 0) return false + } + return true +} + +// Over-inclusive by design: the reachability stage (real source analysis) is the actual +// precision filter, so an unparseable constraint is always surfaced, never dropped. +// +// Checks against every vulnerable version, not just the highest one: a bounded interval +// like "[1.0.0,1.2.0]" can include an older vulnerable version without including the max. +export function nugetConstraintMayInclude( + constraint: string | null, + vulnerableVersions: string[], +): NuGetConstraintMatch { + const intervals = parseNuGetRange(constraint) + if (!intervals) return 'unparseable-included' + const matched = intervals.some((interval) => + vulnerableVersions.some((version) => intervalMayInclude(interval, version)), + ) + return matched ? 'matched' : 'excluded' +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/reachabilityConfig.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/reachabilityConfig.ts new file mode 100644 index 0000000000..dd7b7c82fb --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/reachabilityConfig.ts @@ -0,0 +1,33 @@ +import { fetchVersionList } from '../../../nuget/client' +import { isNuGetFetchError } from '../../../nuget/types' +import { + NUGET_REACHABILITY_PROMPT, + NUGET_VERDICT_SCHEMA, + buildNuGetReachabilitySystemPrompt, +} from '../../agent/nugetPrompts' +import { downloadAndExtractNuGetSource } from '../../clients/nugetSource' +import { highestVersion } from '../ecosystemVersions' +import { ReachabilitySourceConfig } from '../reachabilityStage' + +// deps.dev never resolves a concrete version for NuGet edges (see dependentsScanNuGet.ts), +// so dep.version is always null — fall back to the package's current highest listed version. +async function resolveNuGetVersion(packageId: string): Promise { + const versionList = await fetchVersionList(packageId) + if (isNuGetFetchError(versionList)) return null + return highestVersion('nuget', versionList) +} + +export const nugetReachabilityConfig: ReachabilitySourceConfig = { + prompt: NUGET_REACHABILITY_PROMPT, + schema: NUGET_VERDICT_SCHEMA, + buildSystemPrompt: buildNuGetReachabilitySystemPrompt, + prepareSource: async (dep) => { + const version = dep.version ?? (await resolveNuGetVersion(dep.name)) + if (!version) return null + return { + download: (destDir) => downloadAndExtractNuGetSource(dep.name, version, destDir), + } + }, + noSourceMessage: 'Could not resolve a concrete NuGet package version with GitHub source', + downloadErrorPrefix: 'NuGet source download failed', +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts b/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts index 807fd23364..1740a2ff80 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts @@ -13,28 +13,30 @@ export function selectAdvisoryEntry( matchesRequested: (entry: T) => boolean, advisoryOsvId: string, ): SelectedAdvisoryEntry { - const affectedNames = entries.map((e) => e.package.name) - // `!== null`, not truthiness — an empty string is still an explicit (if malformed) // request and must go through matching/rejection, not be treated as "none requested". if (requestedPackageName !== null) { const entry = entries.find(matchesRequested) if (!entry) { + const affectedNames = entries.map((e) => e.package.name).join(', ') throw ApplicationFailure.nonRetryable( `Requested package ${requestedPackageName} not found in advisory ${advisoryOsvId} ` + - `(affected: ${affectedNames.join(', ')})`, + `(affected: ${affectedNames})`, 'ADVISORY_PACKAGE_NOT_FOUND', ) } return { entry, - relatedAffectedPackages: affectedNames.filter((name) => name !== entry.package.name), + relatedAffectedPackages: entries + .map((e) => e.package.name) + .filter((name) => name !== entry.package.name), } } if (entries.length > 1) { + const affectedNames = entries.map((e) => e.package.name).join(', ') throw ApplicationFailure.nonRetryable( - `Advisory ${advisoryOsvId} affects ${entries.length} packages (${affectedNames.join(', ')}); ` + + `Advisory ${advisoryOsvId} affects ${entries.length} packages (${affectedNames}); ` + `advisory-wide analysis is not supported for multi-artifact advisories — specify one via 'package'`, 'ADVISORY_MULTI_ARTIFACT_AMBIGUOUS', ) diff --git a/services/apps/packages_worker/src/nuget/client.ts b/services/apps/packages_worker/src/nuget/client.ts index b7f4013b38..b18eb49db8 100644 --- a/services/apps/packages_worker/src/nuget/client.ts +++ b/services/apps/packages_worker/src/nuget/client.ts @@ -5,6 +5,7 @@ import { NuGetRegistrationIndex, NuGetRegistrationPage, NuGetSearchItem, + isNuGetFetchError, } from './types' const SERVICE_INDEX_URL = 'https://api.nuget.org/v3/index.json' @@ -146,6 +147,17 @@ export async function fetchRegistration( } } +// Thin wrapper for callers (blast-radius intel) that only need the version strings, +// not the full registration payload with per-version metadata. +export async function fetchVersionList(packageId: string): Promise { + const registration = await fetchRegistration(packageId) + if (isNuGetFetchError(registration)) return registration + + return registration.items.flatMap((page) => + (page.items ?? []).map((leaf) => leaf.catalogEntry.version), + ) +} + // The registration API never exposes the nuspec element — it must be read from the // raw nuspec XML, served by the flat-container endpoint. export async function fetchNuspec( From 07a14857d088bf404b803c73c772d8d41f0f5eb4 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 16:03:29 +0200 Subject: [PATCH 2/7] feat: add nuget ecosystem Signed-off-by: Umberto Sgueglia --- .../src/blast-radius/__tests__/ecosystemSupport.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts index b60cd7609a..90b29b90c1 100644 --- a/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts +++ b/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest' import { SUPPORTED_ECOSYSTEMS, buildEcosystemNotSupportedFailure } from '../ecosystemSupport' describe('SUPPORTED_ECOSYSTEMS', () => { - it('includes cargo alongside npm, go, and maven', () => { - expect(SUPPORTED_ECOSYSTEMS).toEqual(['npm', 'go', 'maven', 'cargo']) + it('includes cargo and nuget alongside npm, go, and maven', () => { + expect(SUPPORTED_ECOSYSTEMS).toEqual(['npm', 'go', 'maven', 'cargo', 'nuget']) }) }) From 50a4f63c01954fcb875358f6f840fcb8e43a2ca3 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 16:13:40 +0200 Subject: [PATCH 3/7] fix: lint Signed-off-by: Umberto Sgueglia --- .../stages/__tests__/ecosystemVersions.test.ts | 6 ++++++ .../src/blast-radius/stages/ecosystemVersions.ts | 4 +++- .../src/blast-radius/stages/go/intelGo.ts | 4 +--- .../src/blast-radius/stages/maven/intelMaven.ts | 2 +- .../src/blast-radius/stages/npm/intelNpm.ts | 2 +- .../stages/nuget/__tests__/nugetConstraint.test.ts | 10 ++++++++++ .../src/blast-radius/stages/nuget/intelNuGet.ts | 8 +++++--- .../src/blast-radius/stages/nuget/nugetConstraint.ts | 8 +++++++- 8 files changed, 34 insertions(+), 10 deletions(-) diff --git a/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts b/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts index 5f51facb73..486efdb1dc 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts @@ -54,6 +54,12 @@ describe('ecosystemVersions', () => { const ranges = [{ introduced: '---', fixed: null, lastAffected: null }] expect(versionsInRanges('nuget', versions, ranges)).toEqual([]) }) + + it('treats introduced "0" as "from the beginning" instead of an unparseable bound', () => { + const versions = ['1.0.0', '1.5.0', '2.0.0'] + const ranges = [{ introduced: '0', fixed: '2.0.0', lastAffected: null }] + expect(versionsInRanges('nuget', versions, ranges)).toEqual(['1.0.0', '1.5.0']) + }) }) describe('highestVersion', () => { diff --git a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts index 0d5141eb33..7c68f54ad1 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts @@ -56,7 +56,9 @@ function compareOrNull(ecosystem: string, a: string, b: string): number | null { // Unparseable bound → treated as NOT in range (unlike the constraint helpers' over-inclusive // stance — this only decides the vulnerable-version set, not dependent reachability). function isInRange(ecosystem: string, version: string, range: EcosystemRange): boolean { - if (range.introduced) { + // OSV defines introduced: "0" as "vulnerable from the beginning" — not a real version + // to parse/compare (see osv/deriveCriticalFlag.ts's identical special case). + if (range.introduced && range.introduced !== '0') { const c = compareOrNull(ecosystem, version, range.introduced) if (c === null || c < 0) return false } diff --git a/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts b/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts index db11b2902d..c5de0c9e5e 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/go/intelGo.ts @@ -62,9 +62,7 @@ export async function runIntelStageGo( // rejection rules on non-matching or omitted requests. const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) const requestedModule = - analysisDetail?.package_name !== undefined - ? toBareGoModule(analysisDetail.package_name) - : null + analysisDetail?.package_name != null ? toBareGoModule(analysisDetail.package_name) : null const { entry, relatedAffectedPackages } = selectAdvisoryEntry( goEntries, requestedModule, diff --git a/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts b/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts index 43bbdd388f..60a3450324 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/maven/intelMaven.ts @@ -66,7 +66,7 @@ export async function runIntelStageMaven( // rejection rules on non-matching or omitted requests. const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) const requested = - analysisDetail?.package_name !== undefined + analysisDetail?.package_name != null ? toBareMavenCoordinate(analysisDetail.package_name) : null const requestedCoordinate = requested ? `${requested.groupId}:${requested.artifactId}` : null diff --git a/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts b/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts index a9c0d756bf..53b3e3f054 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/npm/intelNpm.ts @@ -61,7 +61,7 @@ export async function runIntelStageNpm( // names, so normalize before comparing (see selectAdvisoryEntry for rejection rules). const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) const requestedPackage = - analysisDetail?.package_name !== undefined ? toBareNpmName(analysisDetail.package_name) : null + analysisDetail?.package_name != null ? toBareNpmName(analysisDetail.package_name) : null const { entry, relatedAffectedPackages } = selectAdvisoryEntry( npmEntries, requestedPackage, diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts index 29b2030a94..b0be3c2851 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts @@ -82,4 +82,14 @@ describe('nugetConstraintMayInclude', () => { 'unparseable-included', ) }) + + it('conservatively includes adjacent intervals missing their comma separator', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0)[3.0.0,4.0.0)', ['3.5.0'])).toBe( + 'unparseable-included', + ) + }) + + it('conservatively includes a comma-separated union with a trailing comma', () => { + expect(nugetConstraintMayInclude('[1.0.0,2.0.0),', ['3.5.0'])).toBe('unparseable-included') + }) }) diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts index 3ce3712b6b..5a0757feab 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts @@ -63,15 +63,17 @@ export async function runIntelStageNuGet( // rejection rules on non-matching or omitted requests. const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) const requestedId = - analysisDetail?.package_name !== undefined ? toBareNuGetId(analysisDetail.package_name) : null + analysisDetail?.package_name != null ? toBareNuGetId(analysisDetail.package_name) : null + // NuGet package IDs are case-insensitive; match case-insensitively but resolve + // to OSV's own canonical spelling below so the case-sensitive DB lookup succeeds. const { entry, relatedAffectedPackages } = selectAdvisoryEntry( nugetEntries, requestedId, - (e) => e.package.name === requestedId, + (e) => e.package.name.toLowerCase() === requestedId?.toLowerCase(), advisoryOsvId, ) - const nugetId = requestedId ?? entry.package.name + const nugetId = entry.package.name const ecosystem = 'nuget' // Resolve vulnerable versions from OSV ranges first (NuGet OSV ranges are diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts index 32d17f15c2..088e13b6b7 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts @@ -27,7 +27,13 @@ function splitTopLevelCommas(s: string): string[] | null { if (depth === 0) { parts.push(s.slice(start, i + 1)) start = i + 1 - if (s[start] === ',') start++ + if (start < s.length) { + // Exactly one comma must separate top-level intervals — no separator + // (e.g. "[1,2)[3,4)") and a trailing comma are both malformed. + if (s[start] !== ',') return null + start++ + if (start >= s.length) return null + } } } } From a8a5b0f1d6c75006b2dbbc8f9d28baa044d59432 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 16:32:01 +0200 Subject: [PATCH 4/7] fix: version compare Signed-off-by: Umberto Sgueglia --- .../__tests__/ecosystemVersions.test.ts | 10 +++ .../blast-radius/stages/ecosystemVersions.ts | 5 ++ .../nuget/__tests__/nugetConstraint.test.ts | 5 ++ .../__tests__/nugetVersionCompare.test.ts | 45 ++++++++++++ .../stages/nuget/nugetConstraint.ts | 6 +- .../stages/nuget/nugetVersionCompare.ts | 69 +++++++++++++++++++ 6 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetVersionCompare.test.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/nuget/nugetVersionCompare.ts diff --git a/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts b/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts index 486efdb1dc..3634ab3d17 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/__tests__/ecosystemVersions.test.ts @@ -60,6 +60,12 @@ describe('ecosystemVersions', () => { const ranges = [{ introduced: '0', fixed: '2.0.0', lastAffected: null }] expect(versionsInRanges('nuget', versions, ranges)).toEqual(['1.0.0', '1.5.0']) }) + + it('includes four-component NuGet versions, which node-semver cannot parse', () => { + const versions = ['4.5.0.0', '4.5.0.1', '4.6.0.0'] + const ranges = [{ introduced: '4.5.0.0', fixed: '4.6.0.0', lastAffected: null }] + expect(versionsInRanges('nuget', versions, ranges)).toEqual(['4.5.0.0', '4.5.0.1']) + }) }) describe('highestVersion', () => { @@ -74,5 +80,9 @@ describe('ecosystemVersions', () => { it('handles an empty list', () => { expect(highestVersion('nuget', [])).toBeNull() }) + + it('picks the correct highest four-component NuGet version', () => { + expect(highestVersion('nuget', ['4.5.0.0', '4.5.0.10', '4.5.0.2'])).toBe('4.5.0.10') + }) }) }) diff --git a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts index 7c68f54ad1..e3e443697b 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts @@ -1,6 +1,8 @@ import { compareVersion } from '../../osv/versionCompare' import { OsvAffectedPackage } from '../clients/osvClient' +import { compareNuGetVersion } from './nuget/nugetVersionCompare' + // Shared by any ecosystem whose OSV advisories use ECOSYSTEM-typed ranges (ordered via // compareVersion(ecosystem, …) rather than node-semver) instead of SEMVER-typed ranges — // currently Maven and NuGet. Parameterized by ecosystem so the two don't duplicate this @@ -49,7 +51,10 @@ export function ecosystemRangeEvents(entry: OsvAffectedPackage): EcosystemRange[ return events } +// NuGet accepts a 4th numeric component (Major.Minor.Patch.Revision), which node-semver +// rejects outright — see nugetVersionCompare.ts for why 'nuget' can't share compareVersion. function compareOrNull(ecosystem: string, a: string, b: string): number | null { + if (ecosystem === 'nuget') return compareNuGetVersion(a, b) return compareVersion(ecosystem, a, b) } diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts index b0be3c2851..9c917cf14e 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetConstraint.test.ts @@ -92,4 +92,9 @@ describe('nugetConstraintMayInclude', () => { it('conservatively includes a comma-separated union with a trailing comma', () => { expect(nugetConstraintMayInclude('[1.0.0,2.0.0),', ['3.5.0'])).toBe('unparseable-included') }) + + it('matches four-component versions, which node-semver cannot parse', () => { + expect(nugetConstraintMayInclude('[4.5.0.0,4.6.0.0)', ['4.5.0.5'])).toBe('matched') + expect(nugetConstraintMayInclude('[4.5.0.0,4.6.0.0)', ['4.6.0.0'])).toBe('excluded') + }) }) diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetVersionCompare.test.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetVersionCompare.test.ts new file mode 100644 index 0000000000..a27b292af3 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/__tests__/nugetVersionCompare.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { compareNuGetVersion } from '../nugetVersionCompare' + +describe('compareNuGetVersion', () => { + it('compares three-component versions like semver', () => { + expect(compareNuGetVersion('1.2.3', '1.2.4')).toBe(-1) + expect(compareNuGetVersion('1.2.4', '1.2.3')).toBe(1) + expect(compareNuGetVersion('1.2.3', '1.2.3')).toBe(0) + }) + + it('compares four-component versions, which node-semver cannot parse at all', () => { + expect(compareNuGetVersion('1.2.3.4', '1.2.3.5')).toBe(-1) + expect(compareNuGetVersion('1.2.3.10', '1.2.3.9')).toBe(1) + expect(compareNuGetVersion('4.5.0.0', '4.5.0.0')).toBe(0) + }) + + it('treats a missing revision component as 0', () => { + expect(compareNuGetVersion('1.2.3', '1.2.3.0')).toBe(0) + expect(compareNuGetVersion('1.2.3.1', '1.2.3')).toBe(1) + }) + + it('ranks a release above any prerelease of the same numeric version', () => { + expect(compareNuGetVersion('1.0.0', '1.0.0-beta')).toBe(1) + expect(compareNuGetVersion('1.0.0-beta', '1.0.0')).toBe(-1) + }) + + it('orders prerelease identifiers numerically, not lexically', () => { + expect(compareNuGetVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(-1) + }) + + it('ranks a shorter prerelease identifier list below a longer one sharing its prefix', () => { + expect(compareNuGetVersion('1.0.0-beta', '1.0.0-beta.1')).toBe(-1) + }) + + it('ignores build metadata for comparison', () => { + expect(compareNuGetVersion('1.0.0+build1', '1.0.0+build2')).toBe(0) + }) + + it('returns null for an unparseable version', () => { + expect(compareNuGetVersion('not-a-version', '1.0.0')).toBeNull() + expect(compareNuGetVersion('1.0.0', 'not-a-version')).toBeNull() + expect(compareNuGetVersion('1.2.3.4.5', '1.0.0')).toBeNull() + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts index 088e13b6b7..2d5e90a015 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetConstraint.ts @@ -1,4 +1,4 @@ -import { compareVersion } from '../../../osv/versionCompare' +import { compareNuGetVersion } from './nugetVersionCompare' export type NuGetConstraintMatch = 'matched' | 'excluded' | 'unparseable-included' @@ -100,12 +100,12 @@ function parseNuGetRange(constraint: string | null): NuGetInterval[] | null { function intervalMayInclude(interval: NuGetInterval, version: string): boolean { if (interval.lower) { - const c = compareVersion('nuget', version, interval.lower) + const c = compareNuGetVersion(version, interval.lower) if (c === null) return true // unparseable bound — over-inclusive if (interval.lowerInclusive ? c < 0 : c <= 0) return false } if (interval.upper) { - const c = compareVersion('nuget', version, interval.upper) + const c = compareNuGetVersion(version, interval.upper) if (c === null) return true if (interval.upperInclusive ? c > 0 : c >= 0) return false } diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetVersionCompare.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetVersionCompare.ts new file mode 100644 index 0000000000..c61903c41d --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/nugetVersionCompare.ts @@ -0,0 +1,69 @@ +// node-semver rejects any version with a 4th numeric component (e.g. "1.2.3.4"), but +// NuGetVersion accepts Major.Minor.Patch.Revision — common in Microsoft/BCL packages +// (System.*, Microsoft.NETCore.*). Routing NuGet through the shared semver comparator +// silently drops those versions from range checks; this is a NuGetVersion-compatible +// comparator scoped to blast-radius so it doesn't change the shared OSV sync pipeline. +interface ParsedNuGetVersion { + numbers: [number, number, number, number] + prerelease: string | null +} + +function parseNuGetVersion(input: string): ParsedNuGetVersion | null { + // NuGetVersion ignores build metadata (+xxx) for comparison purposes. + const withoutMetadata = input.trim().split('+')[0] + const dashIndex = withoutMetadata.indexOf('-') + const versionPart = dashIndex === -1 ? withoutMetadata : withoutMetadata.slice(0, dashIndex) + const prerelease = dashIndex === -1 ? null : withoutMetadata.slice(dashIndex + 1) + if (prerelease === '') return null + + const segments = versionPart.split('.') + if (segments.length === 0 || segments.length > 4) return null + + const numbers: [number, number, number, number] = [0, 0, 0, 0] + for (let i = 0; i < segments.length; i++) { + if (!/^\d+$/.test(segments[i])) return null + numbers[i] = parseInt(segments[i], 10) + } + return { numbers, prerelease } +} + +// NuGet prerelease identifiers compare dot-segment by dot-segment: numeric segments +// compare numerically, non-numeric compare ordinally, numeric sorts below non-numeric, +// and a shorter identifier list sorts below a longer one that shares its prefix. +function comparePrerelease(a: string, b: string): number { + const aParts = a.split('.') + const bParts = b.split('.') + const len = Math.max(aParts.length, bParts.length) + + for (let i = 0; i < len; i++) { + if (i >= aParts.length) return -1 + if (i >= bParts.length) return 1 + + const aIsNum = /^\d+$/.test(aParts[i]) + const bIsNum = /^\d+$/.test(bParts[i]) + if (aIsNum && bIsNum) { + const an = parseInt(aParts[i], 10) + const bn = parseInt(bParts[i], 10) + if (an !== bn) return an < bn ? -1 : 1 + continue + } + if (aIsNum !== bIsNum) return aIsNum ? -1 : 1 + if (aParts[i] !== bParts[i]) return aParts[i] < bParts[i] ? -1 : 1 + } + return 0 +} + +export function compareNuGetVersion(a: string, b: string): number | null { + const pa = parseNuGetVersion(a) + const pb = parseNuGetVersion(b) + if (!pa || !pb) return null + + for (let i = 0; i < 4; i++) { + if (pa.numbers[i] !== pb.numbers[i]) return pa.numbers[i] < pb.numbers[i] ? -1 : 1 + } + + if (pa.prerelease === null && pb.prerelease === null) return 0 + if (pa.prerelease === null) return 1 // a release outranks any prerelease of the same numbers + if (pb.prerelease === null) return -1 + return comparePrerelease(pa.prerelease, pb.prerelease) +} From 59f0d6d4aa82a77be0725ac7cc35d3077efe7e5f Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 16:56:04 +0200 Subject: [PATCH 5/7] fix: version compare Signed-off-by: Umberto Sgueglia --- .../src/blast-radius/clients/nugetSource.ts | 21 +++++++++---------- .../src/blast-radius/packageIdentifier.ts | 8 ++----- .../blast-radius/stages/ecosystemVersions.ts | 6 ++---- .../stages/selectAdvisoryEntry.ts | 12 +++++------ 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts b/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts index a087a12509..999c0b40bc 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts @@ -47,12 +47,8 @@ function codeloadTarballUrl(owner: string, repo: string, ref: string): string { return `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref}` } -// .nupkg ships compiled DLLs, not C# source (unlike Maven's -sources.jar), so source -// must come from GitHub. Ordered candidates: the exact commit the nuspec -// element records (most precise), then a couple of common version-tag conventions -// against the same repo. Only GitHub repos are supported — GitLab/Bitbucket tarballs -// don't share codeload's single-wrapper-directory layout that downloadAndExtractTarball -// (strip: 1) relies on. +// .nupkg has no source (unlike Maven's -sources.jar), so fetch from GitHub repo. +// Try exact commit first, then common version-tag conventions. async function candidateSourceTarballUrls(packageId: string, version: string): Promise { const nuspec = await fetchNuspec(packageId, version) if (isNuGetFetchError(nuspec)) return [] @@ -66,11 +62,14 @@ async function candidateSourceTarballUrls(packageId: string, version: string): P const ownerRepo = githubOwnerRepo(canonical.url) if (!ownerRepo) return [] - const candidates: string[] = [] - if (commit) candidates.push(codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, commit)) - candidates.push(codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, `v${version}`)) - candidates.push(codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, version)) - return candidates + // An authoritative commit must never fall through to guessed tags, which could + // resolve to a different revision and produce a verdict from mismatched source. + if (commit) return [codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, commit)] + + return [ + codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, `v${version}`), + codeloadTarballUrl(ownerRepo.owner, ownerRepo.repo, version), + ] } export async function downloadAndExtractNuGetSource( diff --git a/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts b/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts index d97b34bffb..d4bf6cf590 100644 --- a/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts +++ b/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts @@ -1,4 +1,3 @@ -// Strip query string and fragment from a purl or identifier string. function stripQueryAndFragment(input: string): string { const q = input.indexOf('?') const h = input.indexOf('#') @@ -6,11 +5,8 @@ function stripQueryAndFragment(input: string): string { return cut === -1 ? input : input.slice(0, cut) } -// The blast-radius submit endpoint accepts either a bare npm package name -// ("lodash", "@babel/core") or a full purl ("pkg:npm/lodash", "pkg:npm/%40babel/core@4.17.21") -// for the `package` field — see blastRadiusJobRequestSchema. OSV affected-package entries and -// the npm registry only ever use bare names, so a purl must be reduced to that form before -// it's compared against them (raw string equality otherwise never matches a purl input). +// OSV/npm registry use bare names only, so purls must be normalized before comparison. +// See blastRadiusJobRequestSchema for accepted formats. export function toBareNpmName(input: string): string { let name = input.trim() diff --git a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts index e3e443697b..2dd1bd4407 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/ecosystemVersions.ts @@ -3,10 +3,8 @@ import { OsvAffectedPackage } from '../clients/osvClient' import { compareNuGetVersion } from './nuget/nugetVersionCompare' -// Shared by any ecosystem whose OSV advisories use ECOSYSTEM-typed ranges (ordered via -// compareVersion(ecosystem, …) rather than node-semver) instead of SEMVER-typed ranges — -// currently Maven and NuGet. Parameterized by ecosystem so the two don't duplicate this -// logic; see mavenVersions.ts for the ecosystem-bound wrappers Maven's stage files import. +// Shared range type for Maven and NuGet (ecosystems with ECOSYSTEM-typed OSV ranges). +// Parameterized by ecosystem to avoid duplication; see mavenVersions.ts for wrappers. export interface EcosystemRange { introduced: string | null fixed: string | null diff --git a/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts b/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts index 1740a2ff80..807fd23364 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/selectAdvisoryEntry.ts @@ -13,30 +13,28 @@ export function selectAdvisoryEntry( matchesRequested: (entry: T) => boolean, advisoryOsvId: string, ): SelectedAdvisoryEntry { + const affectedNames = entries.map((e) => e.package.name) + // `!== null`, not truthiness — an empty string is still an explicit (if malformed) // request and must go through matching/rejection, not be treated as "none requested". if (requestedPackageName !== null) { const entry = entries.find(matchesRequested) if (!entry) { - const affectedNames = entries.map((e) => e.package.name).join(', ') throw ApplicationFailure.nonRetryable( `Requested package ${requestedPackageName} not found in advisory ${advisoryOsvId} ` + - `(affected: ${affectedNames})`, + `(affected: ${affectedNames.join(', ')})`, 'ADVISORY_PACKAGE_NOT_FOUND', ) } return { entry, - relatedAffectedPackages: entries - .map((e) => e.package.name) - .filter((name) => name !== entry.package.name), + relatedAffectedPackages: affectedNames.filter((name) => name !== entry.package.name), } } if (entries.length > 1) { - const affectedNames = entries.map((e) => e.package.name).join(', ') throw ApplicationFailure.nonRetryable( - `Advisory ${advisoryOsvId} affects ${entries.length} packages (${affectedNames}); ` + + `Advisory ${advisoryOsvId} affects ${entries.length} packages (${affectedNames.join(', ')}); ` + `advisory-wide analysis is not supported for multi-artifact advisories — specify one via 'package'`, 'ADVISORY_MULTI_ARTIFACT_AMBIGUOUS', ) From d2f74f9394062df410ef38e8cbeb15bb499673c0 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 17:14:39 +0200 Subject: [PATCH 6/7] fix: add heartbeat Signed-off-by: Umberto Sgueglia --- .../src/blast-radius/clients/nugetSource.ts | 7 +++++++ .../src/blast-radius/stages/nuget/intelNuGet.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts b/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts index 999c0b40bc..87d22fa6f5 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/nugetSource.ts @@ -76,6 +76,7 @@ export async function downloadAndExtractNuGetSource( packageId: string, version: string, destDir: string, + onProgress?: () => void, ): Promise { const candidates = await candidateSourceTarballUrls(packageId, version) if (candidates.length === 0) { @@ -84,6 +85,10 @@ export async function downloadAndExtractNuGetSource( let lastErr: unknown for (const url of candidates) { + // Some GitHub repos backing a NuGet package are monorepos (e.g. dotnet/runtime), + // so the tarball fetched here is the whole repo, not just this package — heartbeat + // periodically or a slow-but-completing download can trip the activity's heartbeat timeout. + const heartbeatInterval = onProgress ? setInterval(onProgress, 60_000) : null try { // Clear between attempts — a prior candidate's partial extraction (e.g. hit an // extraction limit mid-stream) must not leave stale files a later candidate builds on. @@ -92,6 +97,8 @@ export async function downloadAndExtractNuGetSource( return } catch (err) { lastErr = err + } finally { + if (heartbeatInterval) clearInterval(heartbeatInterval) } } diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts index 5a0757feab..054a9aa453 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts @@ -108,7 +108,7 @@ export async function runIntelStageNuGet( const patches: Record = {} try { - await downloadAndExtractNuGetSource(nugetId, analyzed, pkgsrcDir) + await downloadAndExtractNuGetSource(nugetId, analyzed, pkgsrcDir, onProgress) const patchUrls = fixReferenceUrls(osv) for (const url of patchUrls.slice(0, 3)) { From 32b0bcb0ea3a1f3003a29e2373043171fc70f426 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 5 Aug 2026 17:55:34 +0200 Subject: [PATCH 7/7] fix: use lowercase Signed-off-by: Umberto Sgueglia --- .../src/blast-radius/stages/nuget/intelNuGet.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts index 054a9aa453..33b2be5ba4 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/nuget/intelNuGet.ts @@ -80,7 +80,13 @@ export async function runIntelStageNuGet( // ECOSYSTEM-typed, not SEMVER — same shape as Maven, see ecosystemVersions.ts). const ranges = ecosystemRangeEvents(entry) - const dbPackageId = await findPackageId(qx, { ecosystem, namespace: null, name: nugetId }) + // deps.dev/nuget.org store packages.name in lowercase canonical form, but OSV + // publishes the developer-facing PascalCase spelling — lowercase to match. + const dbPackageId = await findPackageId(qx, { + ecosystem, + namespace: null, + name: nugetId.toLowerCase(), + }) // The nuget.org registration index is the authoritative version list; fall back to // our own ingested `versions` rows (deps.dev) if the registry is unreachable/rate-limited