diff --git a/services/apps/packages_worker/src/rubygems/workflows.ts b/services/apps/packages_worker/src/rubygems/workflows.ts index 6983562983..a6015f2c2a 100644 --- a/services/apps/packages_worker/src/rubygems/workflows.ts +++ b/services/apps/packages_worker/src/rubygems/workflows.ts @@ -3,7 +3,7 @@ import { continueAsNew, log, proxyActivities } from '@temporalio/workflow' import type * as activities from './activities' const acts = proxyActivities({ - startToCloseTimeout: '30 minutes', + startToCloseTimeout: '60 minutes', retry: { initialInterval: '30 seconds', backoffCoefficient: 2, maximumAttempts: 5 }, }) 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 63375d36e0..f0fa7463fd 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,5 +1,5 @@ import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types' -import { extractEmails, fetchJson, registryHeaders } from '../http' +import { extractEmails, fetchJson, isEmail, registryHeaders } from '../http' import { ParsedPurl } from './purl' @@ -7,7 +7,6 @@ const SOURCE = 'rubygems' /* eslint-disable @typescript-eslint/no-explicit-any */ -// RubyGems hides author emails; we can only surface emails when an author string embeds one. // bug_tracker_uri is an issue tracker, not a security contact, so it is intentionally skipped. export function mapRubygems(doc: unknown, sourceUrl: string, fetchedAt: string): RawContact[] { if (!doc || typeof doc !== 'object') return [] @@ -34,13 +33,63 @@ export function mapRubygems(doc: unknown, sourceUrl: string, fetchedAt: string): return contacts } +export function mapRubygemsOwners( + owners: unknown, + sourceUrl: string, + fetchedAt: string, +): RawContact[] { + if (!Array.isArray(owners)) return [] + + const prov = (): ProvenanceEntry[] => [ + { source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt }, + ] + const contacts: RawContact[] = [] + const seen = new Set() + for (const owner of owners) { + const o = (owner ?? {}) as Record + const email = o.email + if (typeof email !== 'string' || !isEmail(email)) continue + const key = email.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + // RubyGems handles are not GitHub logins — stored as `name`, not `handle`, so + // identityLinkMerge (reconcile.ts) never cross-links them to a github-handle contact. + const name = typeof o.handle === 'string' ? o.handle : undefined + contacts.push({ + channel: 'email', + value: email, + name, + role: 'maintainer', + tier: 'B', + provenance: prov(), + }) + } + return contacts +} + export async function fetchRubygems( parsed: ParsedPurl, timeoutMs: number, userAgent: string, ): Promise { - const url = `https://rubygems.org/api/v1/gems/${encodeURIComponent(parsed.name)}.json` - const { json } = await fetchJson(url, timeoutMs, registryHeaders(userAgent)) - if (!json) return { contacts: [], policies: {} } - return { contacts: mapRubygems(json, url, new Date().toISOString()), policies: {} } + const gemUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(parsed.name)}.json` + const ownersUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(parsed.name)}/owners.json` + const fetchedAt = new Date().toISOString() + + const [gemResult, ownersResult] = await Promise.all([ + fetchJson(gemUrl, timeoutMs, registryHeaders(userAgent)), + fetchJson(ownersUrl, timeoutMs, registryHeaders(userAgent)).catch(() => ({ + status: 0, + json: null, + })), + ]) + + if (!gemResult.json) return { contacts: [], policies: {} } + + const contacts = [ + ...mapRubygems(gemResult.json, gemUrl, fetchedAt), + ...mapRubygemsOwners(ownersResult.json, ownersUrl, fetchedAt), + ] + + return { contacts, policies: {} } }