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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion services/apps/packages_worker/src/nuget/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface ResolvedEndpoints {

let cachedEndpoints: ResolvedEndpoints | null = null

async function resolveEndpoints(): Promise<ResolvedEndpoints> {
export async function resolveEndpoints(): Promise<ResolvedEndpoints> {
if (cachedEndpoints) return cachedEndpoints

const resp = await axios.get<ServiceIndex>(SERVICE_INDEX_URL, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ProvenanceEntry, RawContact } from '../../types'

// Registry usernames are only *guessed* to be GitHub logins — callers emit them as candidates
// that must pass verifyHandleCandidates corroboration before becoming contacts.
export function toHandleCandidates(
handles: unknown[],
source: string,
sourceUrl: string,
fetchedAt: string,
): RawContact[] {
const prov = (): ProvenanceEntry[] => [{ source, sourceTier: 'B', path: sourceUrl, fetchedAt }]
const candidates: RawContact[] = []
const seen = new Set<string>()
for (const handle of handles) {
if (typeof handle !== 'string' || handle.length === 0) continue
const key = handle.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
candidates.push({
channel: 'github-handle',
value: handle,
role: 'maintainer',
tier: 'B',
provenance: prov(),
})
}
return candidates
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { XMLParser } from 'fast-xml-parser'

import { resolveEndpoints } from '../../../nuget/client'
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
import { extractEmails, fetchJson, fetchText, registryHeaders } from '../http'

import { toHandleCandidates } from './handles'
import { ParsedPurl } from './purl'

const SOURCE = 'nuget-nuspec'
const SEARCH_SOURCE = 'nuget-search'
const BASE = 'https://api.nuget.org/v3-flatcontainer'
const parser = new XMLParser({ ignoreAttributes: true })

Expand Down Expand Up @@ -45,6 +48,44 @@ export function mapNuspec(xml: string, sourceUrl: string, fetchedAt: string): Ra
return contacts
}

// NuGet.org accounts are independent of GitHub, so an owner username matching a GitHub login is
// only a guess — emitted as candidates that must pass repo-contributor corroboration before use.
export function mapNugetOwnerHandles(
doc: unknown,
packageId: string,
sourceUrl: string,
fetchedAt: string,
): RawContact[] {
const data = (doc as any)?.data
if (!Array.isArray(data)) return []
// The exact package is not guaranteed to be the first result — scan for the id match,
// same as fetchSearch in src/nuget/client.ts.
const lowerId = packageId.toLowerCase()
const entry = data.find((e) => typeof e?.id === 'string' && e.id.toLowerCase() === lowerId) as
| Record<string, unknown>
| undefined
if (!entry || !Array.isArray(entry.owners)) return []
return toHandleCandidates(entry.owners, SEARCH_SOURCE, sourceUrl, fetchedAt)
Comment thread
cursor[bot] marked this conversation as resolved.
}

// Owner usernames are only exposed by the search service, not the flatcontainer API; the
// search host is resolved from the V3 service index (it is regional and can rotate).
async function fetchOwnerCandidates(
id: string,
timeoutMs: number,
userAgent: string,
fetchedAt: string,
): Promise<RawContact[]> {
try {
const { searchBaseUrl } = await resolveEndpoints()
const searchUrl = `${searchBaseUrl}?q=packageid:${encodeURIComponent(id)}&prerelease=true&semVerLevel=2.0.0`
const { json } = await fetchJson(searchUrl, timeoutMs, registryHeaders(userAgent))
return mapNugetOwnerHandles(json, id, searchUrl, fetchedAt)
} catch {
return []
}
}

async function latestStableVersion(
id: string,
timeoutMs: number,
Expand All @@ -67,11 +108,17 @@ export async function fetchNuget(
userAgent: string,
): Promise<ExtractorResult> {
const id = parsed.name.toLowerCase()
const version = await latestStableVersion(id, timeoutMs, userAgent)
if (!version) return { contacts: [], policies: {} }
const fetchedAt = new Date().toISOString()

const [version, handleCandidates] = await Promise.all([
latestStableVersion(id, timeoutMs, userAgent),
fetchOwnerCandidates(id, timeoutMs, userAgent, fetchedAt),
])

if (!version) return { contacts: [], policies: {}, handleCandidates }

const url = `${BASE}/${id}/${version}/${id}.nuspec`
const { text } = await fetchText(url, timeoutMs, registryHeaders(userAgent))
if (!text) return { contacts: [], policies: {} }
return { contacts: mapNuspec(text, url, new Date().toISOString()), policies: {} }
if (!text) return { contacts: [], policies: {}, handleCandidates }
return { contacts: mapNuspec(text, url, fetchedAt), policies: {}, handleCandidates }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Candidates lost on fetch errors

Medium Severity

fetchNuget loads owner handleCandidates in parallel with the version index, but a thrown error from latestStableVersion (via Promise.all) or from the later nuspec fetchText rejects the whole extractor. extractManifest then skips the package, discarding handle candidates that were already fetched successfully.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bf5172c. Configure here.

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
import { extractEmails, fetchJson, isEmail, registryHeaders } from '../http'

import { toHandleCandidates } from './handles'
import { ParsedPurl } from './purl'

const SOURCE = 'rubygems'
Expand Down Expand Up @@ -76,24 +77,11 @@ export function mapRubygemsOwnerHandles(
): RawContact[] {
if (!Array.isArray(owners)) return []

const candidates: RawContact[] = []
const seen = new Set<string>()
for (const owner of owners) {
const o = (owner ?? {}) as Record<string, unknown>
if (typeof o.email === 'string' && isEmail(o.email)) continue
if (typeof o.handle !== 'string' || o.handle.length === 0) continue
const key = o.handle.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
candidates.push({
channel: 'github-handle',
value: o.handle,
role: 'maintainer',
tier: 'B',
provenance: [{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt }],
})
}
return candidates
const handles = owners
.map((owner) => (owner ?? {}) as Record<string, unknown>)
.filter((o) => !(typeof o.email === 'string' && isEmail(o.email)))
.map((o) => o.handle)
return toHandleCandidates(handles, SOURCE, sourceUrl, fetchedAt)
}

export async function fetchRubygems(
Expand Down
Loading