Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const FETCHERS: Record<string, EcosystemFetcher> = {
export const extractManifest: Extractor = async (target, deps) => {
const contacts: RawContact[] = []
const policies: Partial<RepoPolicies> = {}
const candidatesByHandle = new Map<string, RawContact>()
const seenPurls = new Set<string>()

for (const pkg of target.packages) {
Expand All @@ -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<string, unknown>)[key] && value != null) {
;(policies as Record<string, unknown>)[key] = value
Expand All @@ -55,5 +65,5 @@ export const extractManifest: Extractor = async (target, deps) => {
}
}

return { contacts, policies }
return { contacts, policies, handleCandidates: [...candidatesByHandle.values()] }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
for (const owner of owners) {
const o = (owner ?? {}) as Record<string, unknown>
if (typeof o.email === 'string' && isEmail(o.email)) continue
if (typeof o.handle !== 'string' || o.handle.length === 0) continue
Comment thread
mbani01 marked this conversation as resolved.
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,
Expand All @@ -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),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
RepoPolicies,
RepoTarget,
} from './types'
import { verifyHandleCandidates } from './verifyHandleCandidates'
import { writeContactsBatch } from './writeContacts'

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

let contacts: RawContact[] = []
const policies: Partial<RepoPolicies> = {}
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<string, unknown>)[key] && value != null) {
;(policies as Record<string, unknown>)[key] = value
Expand All @@ -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')
Expand Down
3 changes: 3 additions & 0 deletions services/apps/packages_worker/src/security-contacts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export interface RepoPolicies {
export interface ExtractorResult {
contacts: RawContact[]
policies: Partial<RepoPolicies>
/** Registry usernames that only *might* be GitHub logins — never written without
* corroboration (see verifyHandleCandidates.ts). */
handleCandidates?: RawContact[]
}

export interface RepoPackage {
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<handle> account alone is not enough, since
* an unrelated person or bot can hold that name.
*/
export async function verifyHandleCandidates(
target: RepoTarget,
deps: Pick<ExtractorDeps, 'githubGet'>,
candidates: RawContact[],
): Promise<RawContact[]> {
Comment thread
mbani01 marked this conversation as resolved.
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<string>()
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 []
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
mbani01 marked this conversation as resolved.

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
}
Loading