diff --git a/services/apps/packages_worker/src/nuget/client.ts b/services/apps/packages_worker/src/nuget/client.ts index c1ad96da78..b7f4013b38 100644 --- a/services/apps/packages_worker/src/nuget/client.ts +++ b/services/apps/packages_worker/src/nuget/client.ts @@ -26,7 +26,7 @@ interface ResolvedEndpoints { let cachedEndpoints: ResolvedEndpoints | null = null -async function resolveEndpoints(): Promise { +export async function resolveEndpoints(): Promise { if (cachedEndpoints) return cachedEndpoints const resp = await axios.get(SERVICE_INDEX_URL, { diff --git a/services/apps/packages_worker/src/security-contacts/extractors/registry/handles.ts b/services/apps/packages_worker/src/security-contacts/extractors/registry/handles.ts new file mode 100644 index 0000000000..9e39d01df8 --- /dev/null +++ b/services/apps/packages_worker/src/security-contacts/extractors/registry/handles.ts @@ -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() + 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 +} diff --git a/services/apps/packages_worker/src/security-contacts/extractors/registry/nuget.ts b/services/apps/packages_worker/src/security-contacts/extractors/registry/nuget.ts index 908dfe461a..690c3b20c8 100644 --- a/services/apps/packages_worker/src/security-contacts/extractors/registry/nuget.ts +++ b/services/apps/packages_worker/src/security-contacts/extractors/registry/nuget.ts @@ -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 }) @@ -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 + | undefined + if (!entry || !Array.isArray(entry.owners)) return [] + return toHandleCandidates(entry.owners, SEARCH_SOURCE, sourceUrl, fetchedAt) +} + +// 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 { + 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, @@ -67,11 +108,17 @@ export async function fetchNuget( userAgent: string, ): Promise { 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 } } diff --git a/services/apps/packages_worker/src/security-contacts/extractors/registry/rubygems.ts b/services/apps/packages_worker/src/security-contacts/extractors/registry/rubygems.ts index 0015430b15..b8de1357a5 100644 --- a/services/apps/packages_worker/src/security-contacts/extractors/registry/rubygems.ts +++ b/services/apps/packages_worker/src/security-contacts/extractors/registry/rubygems.ts @@ -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' @@ -76,24 +77,11 @@ export function mapRubygemsOwnerHandles( ): RawContact[] { if (!Array.isArray(owners)) return [] - const candidates: RawContact[] = [] - const seen = new Set() - for (const owner of owners) { - const o = (owner ?? {}) as Record - 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) + .filter((o) => !(typeof o.email === 'string' && isEmail(o.email))) + .map((o) => o.handle) + return toHandleCandidates(handles, SOURCE, sourceUrl, fetchedAt) } export async function fetchRubygems(