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
@@ -0,0 +1,28 @@
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'

const SOURCE = 'go.dev/security-policy'
const POLICY_URL = 'https://go.dev/doc/security/policy#reporting-a-security-bug'

export async function fetchGo(): Promise<ExtractorResult> {
const fetchedAt = new Date().toISOString()
const prov = (): ProvenanceEntry[] => [
{ source: SOURCE, sourceTier: 'D', path: POLICY_URL, fetchedAt },
]
const contacts: RawContact[] = [
{
channel: 'email',
value: 'security@golang.org',
role: 'security-team',
tier: 'D',
Comment thread
mbani01 marked this conversation as resolved.
provenance: prov(),
},
{
channel: 'web-form',
value: 'https://g.co/vulnz',
role: 'security-team',
tier: 'D',
provenance: prov(),
},
]
Comment thread
mbani01 marked this conversation as resolved.
return { contacts, policies: {} }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Extractor, ExtractorResult, RawContact, RepoPolicies } from '../../type

import { fetchCargo } from './cargo'
import { fetchComposer } from './composer'
import { fetchGo } from './go'
import { fetchMaven } from './maven'
import { fetchNpm } from './npm'
import { fetchNuget } from './nuget'
Expand All @@ -16,7 +17,7 @@ type EcosystemFetcher = (
repoUrl?: string,
) => Promise<ExtractorResult>

// Keyed by the lowercased packages.ecosystem value. go has no package-manifest contacts.
// Keyed by the lowercased packages.ecosystem value.
const FETCHERS: Record<string, EcosystemFetcher> = {
npm: fetchNpm,
pypi: fetchPypi,
Expand All @@ -25,6 +26,7 @@ const FETCHERS: Record<string, EcosystemFetcher> = {
nuget: fetchNuget,
rubygems: fetchRubygems,
composer: fetchComposer,
go: fetchGo,
Comment thread
mbani01 marked this conversation as resolved.
}

export const extractManifest: Extractor = async (target, deps) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getServiceChildLogger } from '@crowd/logging'

import { parseGithubUrl } from '../../enricher/fetchLightRepo'
import { ExtractorDeps, ProvenanceEntry, RawContact, RepoTarget } from '../types'

import { isEmail } from './http'

const log = getServiceChildLogger('security-contacts:repo-owner')

const SOURCE = 'github-repo-owner'

export function buildOwnerHandleContact(owner: string, fetchedAt: string, url: string): RawContact {
return {
channel: 'github-handle',
value: owner,
handle: owner,
role: 'org-owner',
tier: 'D',
provenance: [{ source: SOURCE, sourceTier: 'D', path: url, fetchedAt }],
}
}

export async function fetchRepoOwner(
target: RepoTarget,
deps: Pick<ExtractorDeps, 'githubGet'>,
): Promise<RawContact[]> {
let owner: string
try {
;({ owner } = parseGithubUrl(target.url))
} catch {
return []
}

const fetchedAt = new Date().toISOString()
const contacts: RawContact[] = [buildOwnerHandleContact(owner, fetchedAt, target.url)]

try {
const { text } = await deps.githubGet(`/users/${owner}`)
const email = (text ? (JSON.parse(text) as { email?: unknown }) : null)?.email
if (typeof email === 'string' && isEmail(email)) {
const provenance: ProvenanceEntry[] = [
{
source: SOURCE,
sourceTier: 'D',
path: `https://api.github.com/users/${owner}`,
fetchedAt,
},
]
contacts.push({
channel: 'email',
value: email,
handle: owner,
role: 'org-owner',
tier: 'D',
provenance,
})
}
} catch (err) {
log.warn(
{ repoId: target.repoId, owner, errMsg: (err as Error).message },
'Owner email lookup failed',
)
}

return contacts
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { getServiceChildLogger } from '@crowd/logging'

import { parseGithubUrl } from '../../enricher/fetchLightRepo'
import { ExtractorDeps, ProvenanceEntry, RawContact, RepoTarget } from '../types'

import { isEmail } from './http'

const log = getServiceChildLogger('security-contacts:top-committers')

const SOURCE = 'github-commits'
const SINCE_DAYS = 90
const TOP_N = 3
// GitHub computes /stats/contributors asynchronously and returns 202 while it's not ready yet —
// poll a few times with a short backoff before giving up for this pass.
const STATS_POLL_ATTEMPTS = 3
const STATS_POLL_DELAY_MS = 2000

interface WeekStat {
w?: unknown
c?: unknown
}

interface ContributorStat {
author?: { login?: unknown; type?: unknown } | null
weeks?: WeekStat[]
}

interface AggregatedAuthor {
login: string
count: number
}

const BOT_TOKENS = [
'dependabot',
'renovate',
'github-actions',
'claude',
'anthropic',
'copilot',
'chatgpt',
'openai',
]

function matchesBotToken(value: string): boolean {
const lower = value.toLowerCase()
return BOT_TOKENS.some((token) => lower.includes(token))
}

function isBotLogin(login: string): boolean {
const lower = login.toLowerCase()
if (lower.endsWith('[bot]')) return true
return matchesBotToken(lower)
}

export function aggregateTopCommitters(
stats: ContributorStat[],
sinceUnixSeconds: number,
topN: number = TOP_N,
): AggregatedAuthor[] {
const authors: AggregatedAuthor[] = []

for (const stat of stats) {
if (stat.author?.type === 'Bot') continue
const login = typeof stat.author?.login === 'string' ? stat.author.login : undefined
if (!login || isBotLogin(login)) continue

const count = (stat.weeks ?? []).reduce((sum, week) => {
const w = typeof week.w === 'number' ? week.w : 0
const c = typeof week.c === 'number' ? week.c : 0
return w >= sinceUnixSeconds ? sum + c : sum
Comment on lines +67 to +70
}, 0)
if (count > 0) authors.push({ login, count })
}

return authors.sort((a, b) => b.count - a.count || a.login.localeCompare(b.login)).slice(0, topN)
}

async function resolvePublicEmail(
login: string,
githubGet: ExtractorDeps['githubGet'],
): Promise<string | null> {
try {
const { text } = await githubGet(`/users/${login}`)
const email = (text ? (JSON.parse(text) as { email?: unknown }) : null)?.email
if (typeof email !== 'string' || !isEmail(email) || matchesBotToken(email)) return null
return email
} catch (err) {
log.warn({ login, errMsg: (err as Error).message }, 'Committer email resolution failed')
return null
}
}

export async function mapCommittersToContacts(
authors: AggregatedAuthor[],
fetchedAt: string,
apiPath: string,
githubGet: ExtractorDeps['githubGet'],
): Promise<RawContact[]> {
const contacts: RawContact[] = []

for (const author of authors) {
const provenance: ProvenanceEntry[] = [
{ source: SOURCE, sourceTier: 'D', path: apiPath, fetchedAt },
]

contacts.push({
channel: 'github-handle',
value: author.login,
handle: author.login,
role: 'committer',
tier: 'D',
provenance,
})

const email = await resolvePublicEmail(author.login, githubGet)
if (email) {
contacts.push({
channel: 'email',
value: email,
handle: author.login,
role: 'committer',
tier: 'D',
provenance,
})
}
}

return contacts
}

async function fetchContributorStats(
path: string,
target: RepoTarget,
owner: string,
name: string,
githubGet: ExtractorDeps['githubGet'],
sleep: (ms: number) => Promise<void>,
): Promise<ContributorStat[] | null> {
for (let attempt = 1; attempt <= STATS_POLL_ATTEMPTS; attempt++) {
let status: number
let text: string | null
try {
;({ status, text } = await githubGet(path, { extraOkStatuses: [202] }))
} catch (err) {
log.warn(
{ repoId: target.repoId, owner, name, errMsg: (err as Error).message },
'Contributor stats lookup failed',
)
return null
}

if (status === 202) {
if (attempt < STATS_POLL_ATTEMPTS) await sleep(STATS_POLL_DELAY_MS)
continue
}

if (!text) return null
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed) ? (parsed as ContributorStat[]) : null
} catch (err) {
log.warn(
{ repoId: target.repoId, owner, name, errMsg: (err as Error).message },
'Contributor stats lookup failed',
)
return null
}
}

log.info(
{ repoId: target.repoId, owner, name },
'Contributor stats still computing after polling — skipping this pass',
)
return null
}

export async function fetchTopCommitters(
target: RepoTarget,
deps: Pick<ExtractorDeps, 'githubGet'>,
now: () => Date = () => new Date(),
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
): Promise<RawContact[]> {
let owner: string
let name: string
try {
;({ owner, name } = parseGithubUrl(target.url))
} catch {
return []
}

const path = `/repos/${owner}/${name}/stats/contributors`
const stats = await fetchContributorStats(path, target, owner, name, deps.githubGet, sleep)
Comment on lines +191 to +192
if (!stats) return []

const sinceUnixSeconds = Math.floor(now().getTime() / 1000) - SINCE_DAYS * 24 * 60 * 60
const authors = aggregateTopCommitters(stats, sinceUnixSeconds)
const fetchedAt = new Date().toISOString()
return mapCommittersToContacts(
authors,
fetchedAt,
`https://api.github.com${path}`,
deps.githubGet,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,19 @@ async function fetchOnce(
export async function githubApiGet(
path: string,
timeoutMs: number,
opts: { raw?: boolean } = {},
opts: { raw?: boolean; extraOkStatuses?: number[] } = {},
): Promise<GithubGetResult> {
const accept = opts.raw ? 'application/vnd.github.raw' : 'application/vnd.github+json'
const okStatuses = opts.extraOkStatuses
? new Set([...ABSENT_STATUSES, ...opts.extraOkStatuses])
: ABSENT_STATUSES
const url = `${GITHUB_API}${path}`
const resolved = await ensurePool()

if (!resolved) {
const res = await fetchOnce(url, timeoutMs, { Accept: accept })
if (res.status === 200) return { status: 200, text: await res.text() }
if (ABSENT_STATUSES.has(res.status)) return { status: res.status, text: null }
if (okStatuses.has(res.status)) return { status: res.status, text: null }
throw new Error(`githubApiGet ${path} failed: HTTP ${res.status}`)
}

Expand Down Expand Up @@ -166,7 +169,7 @@ export async function githubApiGet(
Accept: accept,
})

if (res.status === 200 || ABSENT_STATUSES.has(res.status)) {
if (res.status === 200 || okStatuses.has(res.status)) {
pool.parkIfBudgetLow(
installationId,
numOrNull(res.headers.get('x-ratelimit-remaining')),
Expand Down
Loading
Loading