Skip to content

Commit b7a606e

Browse files
authored
feat(security-contacts): implement tier D (#4373)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 6d840a9 commit b7a606e

8 files changed

Lines changed: 332 additions & 9 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
2+
3+
const SOURCE = 'go.dev/security-policy'
4+
const POLICY_URL = 'https://go.dev/doc/security/policy#reporting-a-security-bug'
5+
6+
export async function fetchGo(): Promise<ExtractorResult> {
7+
const fetchedAt = new Date().toISOString()
8+
const prov = (): ProvenanceEntry[] => [
9+
{ source: SOURCE, sourceTier: 'D', path: POLICY_URL, fetchedAt },
10+
]
11+
const contacts: RawContact[] = [
12+
{
13+
channel: 'email',
14+
value: 'security@golang.org',
15+
role: 'security-team',
16+
tier: 'D',
17+
provenance: prov(),
18+
},
19+
{
20+
channel: 'web-form',
21+
value: 'https://g.co/vulnz',
22+
role: 'security-team',
23+
tier: 'D',
24+
provenance: prov(),
25+
},
26+
]
27+
return { contacts, policies: {} }
28+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Extractor, ExtractorResult, RawContact, RepoPolicies } from '../../type
22

33
import { fetchCargo } from './cargo'
44
import { fetchComposer } from './composer'
5+
import { fetchGo } from './go'
56
import { fetchMaven } from './maven'
67
import { fetchNpm } from './npm'
78
import { fetchNuget } from './nuget'
@@ -16,7 +17,7 @@ type EcosystemFetcher = (
1617
repoUrl?: string,
1718
) => Promise<ExtractorResult>
1819

19-
// Keyed by the lowercased packages.ecosystem value. go has no package-manifest contacts.
20+
// Keyed by the lowercased packages.ecosystem value.
2021
const FETCHERS: Record<string, EcosystemFetcher> = {
2122
npm: fetchNpm,
2223
pypi: fetchPypi,
@@ -25,6 +26,7 @@ const FETCHERS: Record<string, EcosystemFetcher> = {
2526
nuget: fetchNuget,
2627
rubygems: fetchRubygems,
2728
composer: fetchComposer,
29+
go: fetchGo,
2830
}
2931

3032
export const extractManifest: Extractor = async (target, deps) => {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { getServiceChildLogger } from '@crowd/logging'
2+
3+
import { parseGithubUrl } from '../../enricher/fetchLightRepo'
4+
import { ExtractorDeps, ProvenanceEntry, RawContact, RepoTarget } from '../types'
5+
6+
import { isEmail } from './http'
7+
8+
const log = getServiceChildLogger('security-contacts:repo-owner')
9+
10+
const SOURCE = 'github-repo-owner'
11+
12+
export function buildOwnerHandleContact(owner: string, fetchedAt: string, url: string): RawContact {
13+
return {
14+
channel: 'github-handle',
15+
value: owner,
16+
handle: owner,
17+
role: 'org-owner',
18+
tier: 'D',
19+
provenance: [{ source: SOURCE, sourceTier: 'D', path: url, fetchedAt }],
20+
}
21+
}
22+
23+
export async function fetchRepoOwner(
24+
target: RepoTarget,
25+
deps: Pick<ExtractorDeps, 'githubGet'>,
26+
): Promise<RawContact[]> {
27+
let owner: string
28+
try {
29+
;({ owner } = parseGithubUrl(target.url))
30+
} catch {
31+
return []
32+
}
33+
34+
const fetchedAt = new Date().toISOString()
35+
const contacts: RawContact[] = [buildOwnerHandleContact(owner, fetchedAt, target.url)]
36+
37+
try {
38+
const { text } = await deps.githubGet(`/users/${owner}`)
39+
const email = (text ? (JSON.parse(text) as { email?: unknown }) : null)?.email
40+
if (typeof email === 'string' && isEmail(email)) {
41+
const provenance: ProvenanceEntry[] = [
42+
{
43+
source: SOURCE,
44+
sourceTier: 'D',
45+
path: `https://api.github.com/users/${owner}`,
46+
fetchedAt,
47+
},
48+
]
49+
contacts.push({
50+
channel: 'email',
51+
value: email,
52+
handle: owner,
53+
role: 'org-owner',
54+
tier: 'D',
55+
provenance,
56+
})
57+
}
58+
} catch (err) {
59+
log.warn(
60+
{ repoId: target.repoId, owner, errMsg: (err as Error).message },
61+
'Owner email lookup failed',
62+
)
63+
}
64+
65+
return contacts
66+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { getServiceChildLogger } from '@crowd/logging'
2+
3+
import { parseGithubUrl } from '../../enricher/fetchLightRepo'
4+
import { ExtractorDeps, ProvenanceEntry, RawContact, RepoTarget } from '../types'
5+
6+
import { isEmail } from './http'
7+
8+
const log = getServiceChildLogger('security-contacts:top-committers')
9+
10+
const SOURCE = 'github-commits'
11+
const SINCE_DAYS = 90
12+
const TOP_N = 3
13+
// GitHub computes /stats/contributors asynchronously and returns 202 while it's not ready yet —
14+
// poll a few times with a short backoff before giving up for this pass.
15+
const STATS_POLL_ATTEMPTS = 3
16+
const STATS_POLL_DELAY_MS = 2000
17+
18+
interface WeekStat {
19+
w?: unknown
20+
c?: unknown
21+
}
22+
23+
interface ContributorStat {
24+
author?: { login?: unknown; type?: unknown } | null
25+
weeks?: WeekStat[]
26+
}
27+
28+
interface AggregatedAuthor {
29+
login: string
30+
count: number
31+
}
32+
33+
const BOT_TOKENS = [
34+
'dependabot',
35+
'renovate',
36+
'github-actions',
37+
'claude',
38+
'anthropic',
39+
'copilot',
40+
'chatgpt',
41+
'openai',
42+
]
43+
44+
function matchesBotToken(value: string): boolean {
45+
const lower = value.toLowerCase()
46+
return BOT_TOKENS.some((token) => lower.includes(token))
47+
}
48+
49+
function isBotLogin(login: string): boolean {
50+
const lower = login.toLowerCase()
51+
if (lower.endsWith('[bot]')) return true
52+
return matchesBotToken(lower)
53+
}
54+
55+
export function aggregateTopCommitters(
56+
stats: ContributorStat[],
57+
sinceUnixSeconds: number,
58+
topN: number = TOP_N,
59+
): AggregatedAuthor[] {
60+
const authors: AggregatedAuthor[] = []
61+
62+
for (const stat of stats) {
63+
if (stat.author?.type === 'Bot') continue
64+
const login = typeof stat.author?.login === 'string' ? stat.author.login : undefined
65+
if (!login || isBotLogin(login)) continue
66+
67+
const count = (stat.weeks ?? []).reduce((sum, week) => {
68+
const w = typeof week.w === 'number' ? week.w : 0
69+
const c = typeof week.c === 'number' ? week.c : 0
70+
return w >= sinceUnixSeconds ? sum + c : sum
71+
}, 0)
72+
if (count > 0) authors.push({ login, count })
73+
}
74+
75+
return authors.sort((a, b) => b.count - a.count || a.login.localeCompare(b.login)).slice(0, topN)
76+
}
77+
78+
async function resolvePublicEmail(
79+
login: string,
80+
githubGet: ExtractorDeps['githubGet'],
81+
): Promise<string | null> {
82+
try {
83+
const { text } = await githubGet(`/users/${login}`)
84+
const email = (text ? (JSON.parse(text) as { email?: unknown }) : null)?.email
85+
if (typeof email !== 'string' || !isEmail(email) || matchesBotToken(email)) return null
86+
return email
87+
} catch (err) {
88+
log.warn({ login, errMsg: (err as Error).message }, 'Committer email resolution failed')
89+
return null
90+
}
91+
}
92+
93+
export async function mapCommittersToContacts(
94+
authors: AggregatedAuthor[],
95+
fetchedAt: string,
96+
apiPath: string,
97+
githubGet: ExtractorDeps['githubGet'],
98+
): Promise<RawContact[]> {
99+
const contacts: RawContact[] = []
100+
101+
for (const author of authors) {
102+
const provenance: ProvenanceEntry[] = [
103+
{ source: SOURCE, sourceTier: 'D', path: apiPath, fetchedAt },
104+
]
105+
106+
contacts.push({
107+
channel: 'github-handle',
108+
value: author.login,
109+
handle: author.login,
110+
role: 'committer',
111+
tier: 'D',
112+
provenance,
113+
})
114+
115+
const email = await resolvePublicEmail(author.login, githubGet)
116+
if (email) {
117+
contacts.push({
118+
channel: 'email',
119+
value: email,
120+
handle: author.login,
121+
role: 'committer',
122+
tier: 'D',
123+
provenance,
124+
})
125+
}
126+
}
127+
128+
return contacts
129+
}
130+
131+
async function fetchContributorStats(
132+
path: string,
133+
target: RepoTarget,
134+
owner: string,
135+
name: string,
136+
githubGet: ExtractorDeps['githubGet'],
137+
sleep: (ms: number) => Promise<void>,
138+
): Promise<ContributorStat[] | null> {
139+
for (let attempt = 1; attempt <= STATS_POLL_ATTEMPTS; attempt++) {
140+
let status: number
141+
let text: string | null
142+
try {
143+
;({ status, text } = await githubGet(path, { extraOkStatuses: [202] }))
144+
} catch (err) {
145+
log.warn(
146+
{ repoId: target.repoId, owner, name, errMsg: (err as Error).message },
147+
'Contributor stats lookup failed',
148+
)
149+
return null
150+
}
151+
152+
if (status === 202) {
153+
if (attempt < STATS_POLL_ATTEMPTS) await sleep(STATS_POLL_DELAY_MS)
154+
continue
155+
}
156+
157+
if (!text) return null
158+
try {
159+
const parsed = JSON.parse(text)
160+
return Array.isArray(parsed) ? (parsed as ContributorStat[]) : null
161+
} catch (err) {
162+
log.warn(
163+
{ repoId: target.repoId, owner, name, errMsg: (err as Error).message },
164+
'Contributor stats lookup failed',
165+
)
166+
return null
167+
}
168+
}
169+
170+
log.info(
171+
{ repoId: target.repoId, owner, name },
172+
'Contributor stats still computing after polling — skipping this pass',
173+
)
174+
return null
175+
}
176+
177+
export async function fetchTopCommitters(
178+
target: RepoTarget,
179+
deps: Pick<ExtractorDeps, 'githubGet'>,
180+
now: () => Date = () => new Date(),
181+
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
182+
): Promise<RawContact[]> {
183+
let owner: string
184+
let name: string
185+
try {
186+
;({ owner, name } = parseGithubUrl(target.url))
187+
} catch {
188+
return []
189+
}
190+
191+
const path = `/repos/${owner}/${name}/stats/contributors`
192+
const stats = await fetchContributorStats(path, target, owner, name, deps.githubGet, sleep)
193+
if (!stats) return []
194+
195+
const sinceUnixSeconds = Math.floor(now().getTime() / 1000) - SINCE_DAYS * 24 * 60 * 60
196+
const authors = aggregateTopCommitters(stats, sinceUnixSeconds)
197+
const fetchedAt = new Date().toISOString()
198+
return mapCommittersToContacts(
199+
authors,
200+
fetchedAt,
201+
`https://api.github.com${path}`,
202+
deps.githubGet,
203+
)
204+
}

services/apps/packages_worker/src/security-contacts/githubToken.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,16 +129,19 @@ async function fetchOnce(
129129
export async function githubApiGet(
130130
path: string,
131131
timeoutMs: number,
132-
opts: { raw?: boolean } = {},
132+
opts: { raw?: boolean; extraOkStatuses?: number[] } = {},
133133
): Promise<GithubGetResult> {
134134
const accept = opts.raw ? 'application/vnd.github.raw' : 'application/vnd.github+json'
135+
const okStatuses = opts.extraOkStatuses
136+
? new Set([...ABSENT_STATUSES, ...opts.extraOkStatuses])
137+
: ABSENT_STATUSES
135138
const url = `${GITHUB_API}${path}`
136139
const resolved = await ensurePool()
137140

138141
if (!resolved) {
139142
const res = await fetchOnce(url, timeoutMs, { Accept: accept })
140143
if (res.status === 200) return { status: 200, text: await res.text() }
141-
if (ABSENT_STATUSES.has(res.status)) return { status: res.status, text: null }
144+
if (okStatuses.has(res.status)) return { status: res.status, text: null }
142145
throw new Error(`githubApiGet ${path} failed: HTTP ${res.status}`)
143146
}
144147

@@ -166,7 +169,7 @@ export async function githubApiGet(
166169
Accept: accept,
167170
})
168171

169-
if (res.status === 200 || ABSENT_STATUSES.has(res.status)) {
172+
if (res.status === 200 || okStatuses.has(res.status)) {
170173
pool.parkIfBudgetLow(
171174
installationId,
172175
numOrNull(res.headers.get('x-ratelimit-remaining')),

0 commit comments

Comments
 (0)