Skip to content

Commit 7ab4d91

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

5 files changed

Lines changed: 129 additions & 2 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const FETCHERS: Record<string, EcosystemFetcher> = {
2929
export const extractManifest: Extractor = async (target, deps) => {
3030
const contacts: RawContact[] = []
3131
const policies: Partial<RepoPolicies> = {}
32+
const candidatesByHandle = new Map<string, RawContact>()
3233
const seenPurls = new Set<string>()
3334

3435
for (const pkg of target.packages) {
@@ -44,6 +45,15 @@ export const extractManifest: Extractor = async (target, deps) => {
4445
try {
4546
const result = await fetcher(parsed, deps.fetchTimeoutMs, deps.userAgent)
4647
contacts.push(...result.contacts)
48+
for (const candidate of result.handleCandidates ?? []) {
49+
const key = candidate.value.toLowerCase()
50+
const existing = candidatesByHandle.get(key)
51+
if (existing) {
52+
existing.provenance.push(...candidate.provenance)
53+
} else {
54+
candidatesByHandle.set(key, { ...candidate, provenance: [...candidate.provenance] })
55+
}
56+
}
4757
for (const [key, value] of Object.entries(result.policies)) {
4858
if (!(policies as Record<string, unknown>)[key] && value != null) {
4959
;(policies as Record<string, unknown>)[key] = value
@@ -55,5 +65,5 @@ export const extractManifest: Extractor = async (target, deps) => {
5565
}
5666
}
5767

58-
return { contacts, policies }
68+
return { contacts, policies, handleCandidates: [...candidatesByHandle.values()] }
5969
}

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ export function mapRubygemsOwners(
6767
return contacts
6868
}
6969

70+
// RubyGems accounts are independent of GitHub, so a handle matching a GitHub login is only a
71+
// guess — emitted as candidates that must pass repo-contributor corroboration before use.
72+
export function mapRubygemsOwnerHandles(
73+
owners: unknown,
74+
sourceUrl: string,
75+
fetchedAt: string,
76+
): RawContact[] {
77+
if (!Array.isArray(owners)) return []
78+
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
97+
}
98+
7099
export async function fetchRubygems(
71100
parsed: ParsedPurl,
72101
timeoutMs: number,
@@ -91,5 +120,9 @@ export async function fetchRubygems(
91120
...mapRubygemsOwners(ownersResult.json, ownersUrl, fetchedAt),
92121
]
93122

94-
return { contacts, policies: {} }
123+
return {
124+
contacts,
125+
policies: {},
126+
handleCandidates: mapRubygemsOwnerHandles(ownersResult.json, ownersUrl, fetchedAt),
127+
}
95128
}

services/apps/packages_worker/src/security-contacts/processBatch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
RepoPolicies,
2727
RepoTarget,
2828
} from './types'
29+
import { verifyHandleCandidates } from './verifyHandleCandidates'
2930
import { writeContactsBatch } from './writeContacts'
3031

3132
const log = getServiceChildLogger('security-contacts')
@@ -150,9 +151,11 @@ export async function processRepo(
150151

151152
let contacts: RawContact[] = []
152153
const policies: Partial<RepoPolicies> = {}
154+
const handleCandidates: RawContact[] = []
153155
for (const r of results) {
154156
if (r.status !== 'fulfilled') continue
155157
contacts.push(...r.value.contacts)
158+
handleCandidates.push(...(r.value.handleCandidates ?? []))
156159
for (const [key, value] of Object.entries(r.value.policies)) {
157160
if (!(policies as Record<string, unknown>)[key] && value != null) {
158161
;(policies as Record<string, unknown>)[key] = value
@@ -165,6 +168,8 @@ export async function processRepo(
165168
contacts = contacts.filter((c) => c.channel !== 'github-pvr')
166169
}
167170

171+
contacts.push(...(await verifyHandleCandidates(target, deps, handleCandidates)))
172+
168173
contacts.push(...deriveGithubHandlesFromNoreplyEmails(contacts))
169174

170175
const handleContacts = contacts.filter((c) => c.channel === 'github-handle')

services/apps/packages_worker/src/security-contacts/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export interface RepoPolicies {
4646
export interface ExtractorResult {
4747
contacts: RawContact[]
4848
policies: Partial<RepoPolicies>
49+
/** Registry usernames that only *might* be GitHub logins — never written without
50+
* corroboration (see verifyHandleCandidates.ts). */
51+
handleCandidates?: RawContact[]
4952
}
5053

5154
export interface RepoPackage {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { getServiceChildLogger } from '@crowd/logging'
2+
3+
import { parseGithubUrl } from '../enricher/fetchLightRepo'
4+
5+
import { ExtractorDeps, RawContact, RepoTarget } from './types'
6+
7+
const log = getServiceChildLogger('security-contacts')
8+
9+
interface ContributorEntry {
10+
login?: unknown
11+
}
12+
13+
/**
14+
* Corroborates registry usernames that are only *guessed* to be GitHub logins (e.g. RubyGems
15+
* owners). A candidate is confirmed only when the same login owns the repo or appears in its
16+
* top-100 contributors — existence of a github.com/<handle> account alone is not enough, since
17+
* an unrelated person or bot can hold that name.
18+
*/
19+
export async function verifyHandleCandidates(
20+
target: RepoTarget,
21+
deps: Pick<ExtractorDeps, 'githubGet'>,
22+
candidates: RawContact[],
23+
): Promise<RawContact[]> {
24+
if (candidates.length === 0) return []
25+
26+
let owner: string
27+
let name: string
28+
try {
29+
;({ owner, name } = parseGithubUrl(target.url))
30+
} catch {
31+
return []
32+
}
33+
34+
const path = `/repos/${owner}/${name}/contributors?per_page=100`
35+
const contributors = new Set<string>()
36+
try {
37+
const { text } = await deps.githubGet(path)
38+
if (text) {
39+
const entries = JSON.parse(text) as ContributorEntry[]
40+
if (Array.isArray(entries)) {
41+
for (const e of entries) {
42+
if (typeof e.login === 'string') contributors.add(e.login.toLowerCase())
43+
}
44+
}
45+
}
46+
} catch (err) {
47+
log.warn(
48+
{ repoId: target.repoId, errMsg: (err as Error).message },
49+
'Contributor lookup failed — dropping handle candidates',
50+
)
51+
return []
52+
}
53+
54+
const fetchedAt = new Date().toISOString()
55+
const confirmed: RawContact[] = []
56+
for (const c of candidates) {
57+
const handle = c.value.toLowerCase()
58+
const viaContributors = contributors.has(handle)
59+
if (!viaContributors && handle !== owner.toLowerCase()) continue
60+
confirmed.push({
61+
...c,
62+
provenance: [
63+
...c.provenance,
64+
viaContributors
65+
? {
66+
source: 'github-contributors',
67+
sourceTier: c.tier,
68+
path: `https://api.github.com${path}`,
69+
fetchedAt,
70+
}
71+
: { source: 'github-repo-owner', sourceTier: c.tier, path: target.url, fetchedAt },
72+
],
73+
})
74+
}
75+
return confirmed
76+
}

0 commit comments

Comments
 (0)