diff --git a/services/apps/packages_worker/src/security-contacts/extractors/registry/index.ts b/services/apps/packages_worker/src/security-contacts/extractors/registry/index.ts index 42b83cf330..147ece6b51 100644 --- a/services/apps/packages_worker/src/security-contacts/extractors/registry/index.ts +++ b/services/apps/packages_worker/src/security-contacts/extractors/registry/index.ts @@ -29,6 +29,7 @@ const FETCHERS: Record = { export const extractManifest: Extractor = async (target, deps) => { const contacts: RawContact[] = [] const policies: Partial = {} + const candidatesByHandle = new Map() const seenPurls = new Set() for (const pkg of target.packages) { @@ -44,6 +45,15 @@ export const extractManifest: Extractor = async (target, deps) => { try { const result = await fetcher(parsed, deps.fetchTimeoutMs, deps.userAgent) contacts.push(...result.contacts) + for (const candidate of result.handleCandidates ?? []) { + const key = candidate.value.toLowerCase() + const existing = candidatesByHandle.get(key) + if (existing) { + existing.provenance.push(...candidate.provenance) + } else { + candidatesByHandle.set(key, { ...candidate, provenance: [...candidate.provenance] }) + } + } for (const [key, value] of Object.entries(result.policies)) { if (!(policies as Record)[key] && value != null) { ;(policies as Record)[key] = value @@ -55,5 +65,5 @@ export const extractManifest: Extractor = async (target, deps) => { } } - return { contacts, policies } + return { contacts, policies, handleCandidates: [...candidatesByHandle.values()] } } 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 f0fa7463fd..0015430b15 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 @@ -67,6 +67,35 @@ export function mapRubygemsOwners( return contacts } +// RubyGems accounts are independent of GitHub, so a handle matching a GitHub login is only a +// guess — emitted as candidates that must pass repo-contributor corroboration before use. +export function mapRubygemsOwnerHandles( + owners: unknown, + sourceUrl: string, + fetchedAt: string, +): 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 +} + export async function fetchRubygems( parsed: ParsedPurl, timeoutMs: number, @@ -91,5 +120,9 @@ export async function fetchRubygems( ...mapRubygemsOwners(ownersResult.json, ownersUrl, fetchedAt), ] - return { contacts, policies: {} } + return { + contacts, + policies: {}, + handleCandidates: mapRubygemsOwnerHandles(ownersResult.json, ownersUrl, fetchedAt), + } } diff --git a/services/apps/packages_worker/src/security-contacts/processBatch.ts b/services/apps/packages_worker/src/security-contacts/processBatch.ts index 1c36b82c4f..1f6e2a019d 100644 --- a/services/apps/packages_worker/src/security-contacts/processBatch.ts +++ b/services/apps/packages_worker/src/security-contacts/processBatch.ts @@ -26,6 +26,7 @@ import { RepoPolicies, RepoTarget, } from './types' +import { verifyHandleCandidates } from './verifyHandleCandidates' import { writeContactsBatch } from './writeContacts' const log = getServiceChildLogger('security-contacts') @@ -150,9 +151,11 @@ export async function processRepo( let contacts: RawContact[] = [] const policies: Partial = {} + const handleCandidates: RawContact[] = [] for (const r of results) { if (r.status !== 'fulfilled') continue contacts.push(...r.value.contacts) + handleCandidates.push(...(r.value.handleCandidates ?? [])) for (const [key, value] of Object.entries(r.value.policies)) { if (!(policies as Record)[key] && value != null) { ;(policies as Record)[key] = value @@ -165,6 +168,8 @@ export async function processRepo( contacts = contacts.filter((c) => c.channel !== 'github-pvr') } + contacts.push(...(await verifyHandleCandidates(target, deps, handleCandidates))) + contacts.push(...deriveGithubHandlesFromNoreplyEmails(contacts)) const handleContacts = contacts.filter((c) => c.channel === 'github-handle') diff --git a/services/apps/packages_worker/src/security-contacts/types.ts b/services/apps/packages_worker/src/security-contacts/types.ts index 734b804c64..af84a0ea6d 100644 --- a/services/apps/packages_worker/src/security-contacts/types.ts +++ b/services/apps/packages_worker/src/security-contacts/types.ts @@ -46,6 +46,9 @@ export interface RepoPolicies { export interface ExtractorResult { contacts: RawContact[] policies: Partial + /** Registry usernames that only *might* be GitHub logins — never written without + * corroboration (see verifyHandleCandidates.ts). */ + handleCandidates?: RawContact[] } export interface RepoPackage { diff --git a/services/apps/packages_worker/src/security-contacts/verifyHandleCandidates.ts b/services/apps/packages_worker/src/security-contacts/verifyHandleCandidates.ts new file mode 100644 index 0000000000..0fbf6919ed --- /dev/null +++ b/services/apps/packages_worker/src/security-contacts/verifyHandleCandidates.ts @@ -0,0 +1,76 @@ +import { getServiceChildLogger } from '@crowd/logging' + +import { parseGithubUrl } from '../enricher/fetchLightRepo' + +import { ExtractorDeps, RawContact, RepoTarget } from './types' + +const log = getServiceChildLogger('security-contacts') + +interface ContributorEntry { + login?: unknown +} + +/** + * Corroborates registry usernames that are only *guessed* to be GitHub logins (e.g. RubyGems + * owners). A candidate is confirmed only when the same login owns the repo or appears in its + * top-100 contributors — existence of a github.com/ account alone is not enough, since + * an unrelated person or bot can hold that name. + */ +export async function verifyHandleCandidates( + target: RepoTarget, + deps: Pick, + candidates: RawContact[], +): Promise { + if (candidates.length === 0) return [] + + let owner: string + let name: string + try { + ;({ owner, name } = parseGithubUrl(target.url)) + } catch { + return [] + } + + const path = `/repos/${owner}/${name}/contributors?per_page=100` + const contributors = new Set() + try { + const { text } = await deps.githubGet(path) + if (text) { + const entries = JSON.parse(text) as ContributorEntry[] + if (Array.isArray(entries)) { + for (const e of entries) { + if (typeof e.login === 'string') contributors.add(e.login.toLowerCase()) + } + } + } + } catch (err) { + log.warn( + { repoId: target.repoId, errMsg: (err as Error).message }, + 'Contributor lookup failed — dropping handle candidates', + ) + return [] + } + + const fetchedAt = new Date().toISOString() + const confirmed: RawContact[] = [] + for (const c of candidates) { + const handle = c.value.toLowerCase() + const viaContributors = contributors.has(handle) + if (!viaContributors && handle !== owner.toLowerCase()) continue + confirmed.push({ + ...c, + provenance: [ + ...c.provenance, + viaContributors + ? { + source: 'github-contributors', + sourceTier: c.tier, + path: `https://api.github.com${path}`, + fetchedAt, + } + : { source: 'github-repo-owner', sourceTier: c.tier, path: target.url, fetchedAt }, + ], + }) + } + return confirmed +}