Skip to content

Commit f8db58f

Browse files
authored
feat: resolve nuget owner handles via github contributor verification [CM-1341] (#4364)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 7ab4d91 commit f8db58f

4 files changed

Lines changed: 86 additions & 23 deletions

File tree

services/apps/packages_worker/src/nuget/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface ResolvedEndpoints {
2626

2727
let cachedEndpoints: ResolvedEndpoints | null = null
2828

29-
async function resolveEndpoints(): Promise<ResolvedEndpoints> {
29+
export async function resolveEndpoints(): Promise<ResolvedEndpoints> {
3030
if (cachedEndpoints) return cachedEndpoints
3131

3232
const resp = await axios.get<ServiceIndex>(SERVICE_INDEX_URL, {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ProvenanceEntry, RawContact } from '../../types'
2+
3+
// Registry usernames are only *guessed* to be GitHub logins — callers emit them as candidates
4+
// that must pass verifyHandleCandidates corroboration before becoming contacts.
5+
export function toHandleCandidates(
6+
handles: unknown[],
7+
source: string,
8+
sourceUrl: string,
9+
fetchedAt: string,
10+
): RawContact[] {
11+
const prov = (): ProvenanceEntry[] => [{ source, sourceTier: 'B', path: sourceUrl, fetchedAt }]
12+
const candidates: RawContact[] = []
13+
const seen = new Set<string>()
14+
for (const handle of handles) {
15+
if (typeof handle !== 'string' || handle.length === 0) continue
16+
const key = handle.toLowerCase()
17+
if (seen.has(key)) continue
18+
seen.add(key)
19+
candidates.push({
20+
channel: 'github-handle',
21+
value: handle,
22+
role: 'maintainer',
23+
tier: 'B',
24+
provenance: prov(),
25+
})
26+
}
27+
return candidates
28+
}

services/apps/packages_worker/src/security-contacts/extractors/registry/nuget.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { XMLParser } from 'fast-xml-parser'
22

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

7+
import { toHandleCandidates } from './handles'
68
import { ParsedPurl } from './purl'
79

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

@@ -45,6 +48,44 @@ export function mapNuspec(xml: string, sourceUrl: string, fetchedAt: string): Ra
4548
return contacts
4649
}
4750

51+
// NuGet.org accounts are independent of GitHub, so an owner username matching a GitHub login is
52+
// only a guess — emitted as candidates that must pass repo-contributor corroboration before use.
53+
export function mapNugetOwnerHandles(
54+
doc: unknown,
55+
packageId: string,
56+
sourceUrl: string,
57+
fetchedAt: string,
58+
): RawContact[] {
59+
const data = (doc as any)?.data
60+
if (!Array.isArray(data)) return []
61+
// The exact package is not guaranteed to be the first result — scan for the id match,
62+
// same as fetchSearch in src/nuget/client.ts.
63+
const lowerId = packageId.toLowerCase()
64+
const entry = data.find((e) => typeof e?.id === 'string' && e.id.toLowerCase() === lowerId) as
65+
| Record<string, unknown>
66+
| undefined
67+
if (!entry || !Array.isArray(entry.owners)) return []
68+
return toHandleCandidates(entry.owners, SEARCH_SOURCE, sourceUrl, fetchedAt)
69+
}
70+
71+
// Owner usernames are only exposed by the search service, not the flatcontainer API; the
72+
// search host is resolved from the V3 service index (it is regional and can rotate).
73+
async function fetchOwnerCandidates(
74+
id: string,
75+
timeoutMs: number,
76+
userAgent: string,
77+
fetchedAt: string,
78+
): Promise<RawContact[]> {
79+
try {
80+
const { searchBaseUrl } = await resolveEndpoints()
81+
const searchUrl = `${searchBaseUrl}?q=packageid:${encodeURIComponent(id)}&prerelease=true&semVerLevel=2.0.0`
82+
const { json } = await fetchJson(searchUrl, timeoutMs, registryHeaders(userAgent))
83+
return mapNugetOwnerHandles(json, id, searchUrl, fetchedAt)
84+
} catch {
85+
return []
86+
}
87+
}
88+
4889
async function latestStableVersion(
4990
id: string,
5091
timeoutMs: number,
@@ -67,11 +108,17 @@ export async function fetchNuget(
67108
userAgent: string,
68109
): Promise<ExtractorResult> {
69110
const id = parsed.name.toLowerCase()
70-
const version = await latestStableVersion(id, timeoutMs, userAgent)
71-
if (!version) return { contacts: [], policies: {} }
111+
const fetchedAt = new Date().toISOString()
112+
113+
const [version, handleCandidates] = await Promise.all([
114+
latestStableVersion(id, timeoutMs, userAgent),
115+
fetchOwnerCandidates(id, timeoutMs, userAgent, fetchedAt),
116+
])
117+
118+
if (!version) return { contacts: [], policies: {}, handleCandidates }
72119

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

services/apps/packages_worker/src/security-contacts/extractors/registry/rubygems.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
22
import { extractEmails, fetchJson, isEmail, registryHeaders } from '../http'
33

4+
import { toHandleCandidates } from './handles'
45
import { ParsedPurl } from './purl'
56

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

79-
const candidates: RawContact[] = []
80-
const seen = new Set<string>()
81-
for (const owner of owners) {
82-
const o = (owner ?? {}) as Record<string, unknown>
83-
if (typeof o.email === 'string' && isEmail(o.email)) continue
84-
if (typeof o.handle !== 'string' || o.handle.length === 0) continue
85-
const key = o.handle.toLowerCase()
86-
if (seen.has(key)) continue
87-
seen.add(key)
88-
candidates.push({
89-
channel: 'github-handle',
90-
value: o.handle,
91-
role: 'maintainer',
92-
tier: 'B',
93-
provenance: [{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt }],
94-
})
95-
}
96-
return candidates
80+
const handles = owners
81+
.map((owner) => (owner ?? {}) as Record<string, unknown>)
82+
.filter((o) => !(typeof o.email === 'string' && isEmail(o.email)))
83+
.map((o) => o.handle)
84+
return toHandleCandidates(handles, SOURCE, sourceUrl, fetchedAt)
9785
}
9886

9987
export async function fetchRubygems(

0 commit comments

Comments
 (0)