Skip to content

Commit 5268362

Browse files
committed
feat: add tier D last-resort security contacts (committers + owner)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 5a5bcd8 commit 5268362

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
const PER_PAGE = 100
14+
const MAX_PAGES = 3
15+
16+
interface CommitAuthor {
17+
email?: unknown
18+
name?: unknown
19+
date?: unknown
20+
}
21+
22+
interface CommitEntry {
23+
commit?: { author?: CommitAuthor }
24+
author?: { login?: unknown; type?: unknown } | null
25+
}
26+
27+
interface AggregatedAuthor {
28+
email: string
29+
name?: string
30+
login?: string
31+
count: number
32+
latestDate: string
33+
}
34+
35+
const BOT_TOKENS = ['dependabot', 'renovate', 'github-actions']
36+
37+
export function isBotCommit(c: CommitEntry): boolean {
38+
if (c.author?.type === 'Bot') return true
39+
const email = typeof c.commit?.author?.email === 'string' ? c.commit.author.email : ''
40+
const name = typeof c.commit?.author?.name === 'string' ? c.commit.author.name : ''
41+
const login = typeof c.author?.login === 'string' ? c.author.login : ''
42+
if (/\[bot\]@/i.test(email)) return true
43+
const haystack = `${email} ${name} ${login}`.toLowerCase()
44+
return BOT_TOKENS.some((token) => haystack.includes(token))
45+
}
46+
47+
export function aggregateTopCommitters(
48+
commits: CommitEntry[],
49+
topN: number = TOP_N,
50+
): AggregatedAuthor[] {
51+
const byEmail = new Map<string, AggregatedAuthor>()
52+
53+
for (const c of commits) {
54+
if (isBotCommit(c)) continue
55+
56+
const rawEmail = c.commit?.author?.email
57+
if (typeof rawEmail !== 'string' || !isEmail(rawEmail)) continue
58+
const email = rawEmail.toLowerCase()
59+
60+
const rawName = c.commit?.author?.name
61+
const name = typeof rawName === 'string' ? rawName : undefined
62+
const rawDate = c.commit?.author?.date
63+
const date = typeof rawDate === 'string' ? rawDate : undefined
64+
const rawLogin = c.author?.login
65+
const login = typeof rawLogin === 'string' ? rawLogin : undefined
66+
67+
const existing = byEmail.get(email)
68+
if (existing) {
69+
existing.count += 1
70+
if (!existing.login && login) existing.login = login
71+
if (date && date > existing.latestDate) existing.latestDate = date
72+
} else {
73+
byEmail.set(email, {
74+
email,
75+
name,
76+
login,
77+
count: 1,
78+
latestDate: date ?? new Date(0).toISOString(),
79+
})
80+
}
81+
}
82+
83+
return [...byEmail.values()]
84+
.sort((a, b) => b.count - a.count || a.email.localeCompare(b.email))
85+
.slice(0, topN)
86+
}
87+
88+
export function mapCommittersToContacts(
89+
authors: AggregatedAuthor[],
90+
fetchedAt: string,
91+
apiPath: string,
92+
): RawContact[] {
93+
const contacts: RawContact[] = []
94+
95+
for (const author of authors) {
96+
const provenance: ProvenanceEntry[] = [
97+
{
98+
source: SOURCE,
99+
sourceTier: 'D',
100+
path: apiPath,
101+
fetchedAt,
102+
declaredAt: author.latestDate,
103+
},
104+
]
105+
106+
contacts.push({
107+
channel: 'email',
108+
value: author.email,
109+
name: author.name,
110+
handle: author.login,
111+
role: 'committer',
112+
tier: 'D',
113+
provenance,
114+
})
115+
116+
if (author.login) {
117+
contacts.push({
118+
channel: 'github-handle',
119+
value: author.login,
120+
handle: author.login,
121+
role: 'committer',
122+
tier: 'D',
123+
provenance,
124+
})
125+
}
126+
}
127+
128+
return contacts
129+
}
130+
131+
async function fetchCommitPages(
132+
owner: string,
133+
name: string,
134+
since: string,
135+
deps: Pick<ExtractorDeps, 'githubGet'>,
136+
): Promise<CommitEntry[]> {
137+
const commits: CommitEntry[] = []
138+
139+
for (let page = 1; page <= MAX_PAGES; page++) {
140+
const path = `/repos/${owner}/${name}/commits?since=${since}&per_page=${PER_PAGE}&page=${page}`
141+
const { text } = await deps.githubGet(path)
142+
if (!text) break
143+
144+
const entries = JSON.parse(text) as CommitEntry[]
145+
if (!Array.isArray(entries) || entries.length === 0) break
146+
147+
commits.push(...entries)
148+
if (entries.length < PER_PAGE) break
149+
}
150+
151+
return commits
152+
}
153+
154+
export async function fetchTopCommitters(
155+
target: RepoTarget,
156+
deps: Pick<ExtractorDeps, 'githubGet'>,
157+
now: () => Date = () => new Date(),
158+
): Promise<RawContact[]> {
159+
let owner: string
160+
let name: string
161+
try {
162+
;({ owner, name } = parseGithubUrl(target.url))
163+
} catch {
164+
return []
165+
}
166+
167+
const since = new Date(now().getTime() - SINCE_DAYS * 24 * 60 * 60 * 1000).toISOString()
168+
const apiPath = `https://api.github.com/repos/${owner}/${name}/commits?since=${since}`
169+
170+
let commits: CommitEntry[]
171+
try {
172+
commits = await fetchCommitPages(owner, name, since, deps)
173+
} catch (err) {
174+
log.warn(
175+
{ repoId: target.repoId, owner, name, errMsg: (err as Error).message },
176+
'Commit lookup failed',
177+
)
178+
return []
179+
}
180+
181+
const authors = aggregateTopCommitters(commits)
182+
const fetchedAt = new Date().toISOString()
183+
return mapCommittersToContacts(authors, fetchedAt, apiPath)
184+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { cancellationSignal, heartbeat } from '@temporalio/activity'
22

3+
import { classifyEmailReachability } from '@crowd/common'
34
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
45
import { getServiceChildLogger } from '@crowd/logging'
56

@@ -10,10 +11,12 @@ import { mapWithConcurrency } from '../utils/concurrency'
1011
import { fetchRepoTree } from './extractors/gitTree'
1112
import { extractPvr } from './extractors/pvr'
1213
import { extractManifest } from './extractors/registry'
14+
import { fetchRepoOwner } from './extractors/repoOwner'
1315
import { extractSecurityContactsFile } from './extractors/securityContactsFile'
1416
import { extractSecurityInsights } from './extractors/securityInsights'
1517
import { extractSecurityMd } from './extractors/securityMd'
1618
import { extractSecurityTxt } from './extractors/securityTxt'
19+
import { fetchTopCommitters } from './extractors/topCommitters'
1720
import { githubApiGet } from './githubToken'
1821
import { reconcile } from './reconcile'
1922
import { deriveGithubHandlesFromNoreplyEmails, resolveCdpEmails } from './resolveCdpEmails'
@@ -170,6 +173,17 @@ export async function processRepo(
170173

171174
contacts.push(...(await verifyHandleCandidates(target, deps, handleCandidates)))
172175

176+
const isReachableRaw = (c: RawContact): boolean =>
177+
c.channel !== 'email' || classifyEmailReachability(c.value).reachable
178+
const hasUsableHigherTierContact = contacts.some((c) => c.tier !== 'D' && isReachableRaw(c))
179+
if (!hasUsableHigherTierContact) {
180+
const [committers, ownerContacts] = await Promise.all([
181+
fetchTopCommitters(target, deps),
182+
fetchRepoOwner(target, deps),
183+
])
184+
contacts.push(...committers, ...ownerContacts)
185+
}
186+
173187
contacts.push(...deriveGithubHandlesFromNoreplyEmails(contacts))
174188

175189
const handleContacts = contacts.filter((c) => c.channel === 'github-handle')

0 commit comments

Comments
 (0)