Skip to content

Commit 68a00e4

Browse files
committed
feat: get rubygems owners as security-contacts
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 97d617b commit 68a00e4

1 file changed

Lines changed: 56 additions & 6 deletions

File tree

  • services/apps/packages_worker/src/security-contacts/extractors/registry

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

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

44
import { ParsedPurl } from './purl'
55

66
const SOURCE = 'rubygems'
77

88
/* eslint-disable @typescript-eslint/no-explicit-any */
99

10-
// RubyGems hides author emails; we can only surface emails when an author string embeds one.
1110
// bug_tracker_uri is an issue tracker, not a security contact, so it is intentionally skipped.
1211
export function mapRubygems(doc: unknown, sourceUrl: string, fetchedAt: string): RawContact[] {
1312
if (!doc || typeof doc !== 'object') return []
@@ -34,13 +33,64 @@ export function mapRubygems(doc: unknown, sourceUrl: string, fetchedAt: string):
3433
return contacts
3534
}
3635

36+
export function mapRubygemsOwners(
37+
owners: unknown,
38+
sourceUrl: string,
39+
fetchedAt: string,
40+
): RawContact[] {
41+
if (!Array.isArray(owners)) return []
42+
43+
const prov = (): ProvenanceEntry[] => [
44+
{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt },
45+
]
46+
const contacts: RawContact[] = []
47+
const seen = new Set<string>()
48+
for (const owner of owners) {
49+
const email = owner && typeof owner === 'object' ? (owner as any).email : undefined
50+
if (typeof email !== 'string' || !isEmail(email)) continue
51+
const key = email.toLowerCase()
52+
if (seen.has(key)) continue
53+
seen.add(key)
54+
const handle = typeof (owner as any).handle === 'string' ? (owner as any).handle : undefined
55+
contacts.push({
56+
channel: 'email',
57+
value: email,
58+
handle,
59+
role: 'maintainer',
60+
tier: 'B',
61+
provenance: prov(),
62+
})
63+
}
64+
return contacts
65+
}
66+
3767
export async function fetchRubygems(
3868
parsed: ParsedPurl,
3969
timeoutMs: number,
4070
userAgent: string,
4171
): Promise<ExtractorResult> {
42-
const url = `https://rubygems.org/api/v1/gems/${encodeURIComponent(parsed.name)}.json`
43-
const { json } = await fetchJson(url, timeoutMs, registryHeaders(userAgent))
44-
if (!json) return { contacts: [], policies: {} }
45-
return { contacts: mapRubygems(json, url, new Date().toISOString()), policies: {} }
72+
const gemUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(parsed.name)}.json`
73+
const ownersUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(parsed.name)}/owners.json`
74+
const fetchedAt = new Date().toISOString()
75+
76+
const [gemResult, ownersResult] = await Promise.all([
77+
fetchJson(gemUrl, timeoutMs, registryHeaders(userAgent)),
78+
fetchJson(ownersUrl, timeoutMs, registryHeaders(userAgent)).catch(() => ({
79+
status: 0,
80+
json: null,
81+
})),
82+
])
83+
84+
if (!gemResult.json) return { contacts: [], policies: {} }
85+
86+
const contacts = mapRubygems(gemResult.json, gemUrl, fetchedAt)
87+
const seen = new Set(contacts.map((c) => c.value.toLowerCase()))
88+
for (const ownerContact of mapRubygemsOwners(ownersResult.json, ownersUrl, fetchedAt)) {
89+
const key = ownerContact.value.toLowerCase()
90+
if (seen.has(key)) continue
91+
seen.add(key)
92+
contacts.push(ownerContact)
93+
}
94+
95+
return { contacts, policies: {} }
4696
}

0 commit comments

Comments
 (0)