Skip to content

Commit 70a96d7

Browse files
authored
feat(security-contacts): emails reachability & noise filtering [CM-1324] (#4353)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 91aa11e commit 70a96d7

13 files changed

Lines changed: 8407 additions & 39 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE security_contacts ADD COLUMN IF NOT EXISTS reachable BOOLEAN NOT NULL DEFAULT TRUE;
2+
ALTER TABLE security_contacts ADD COLUMN IF NOT EXISTS reachability_reason TEXT;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Supports fetchBatch's eligibility WHERE clause (contacts_last_refreshed IS NULL,
2+
-- and the daily/weekly age comparisons), which previously had no supporting index.
3+
CREATE INDEX IF NOT EXISTS repos_contacts_last_refreshed_idx
4+
ON repos (contacts_last_refreshed);

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getSecurityContactsConfig } from '../config'
55

66
import { buildBaseDeps, processRepo } from './processBatch'
77
import { RepoPackage, RepoTarget } from './types'
8-
import { markRepoAttempted } from './writeContacts'
8+
import { markRepoAttempted, writeContacts } from './writeContacts'
99

1010
const log = getServiceChildLogger('security-contacts-ondemand')
1111

@@ -93,7 +93,12 @@ export async function ingestSecurityContactsForPurl(
9393
const target = toTarget(row)
9494
const baseDeps = buildBaseDeps(config)
9595
try {
96-
await processRepo(target, baseDeps, qx, cdpQx)
96+
const result = await processRepo(target, baseDeps, cdpQx)
97+
if (result.status === 'ok') {
98+
await writeContacts(qx, result.repoId, result.contacts, result.policies)
99+
} else {
100+
await markRepoAttempted(qx, result.repoId)
101+
}
97102
} catch (err) {
98103
log.error({ repoId: target.repoId, errMsg: (err as Error).message }, 'Repo processing failed')
99104
await markRepoAttempted(qx, target.repoId).catch(() => undefined)

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

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,17 @@ import { extractSecurityMd } from './extractors/securityMd'
1616
import { extractSecurityTxt } from './extractors/securityTxt'
1717
import { githubApiGet } from './githubToken'
1818
import { reconcile } from './reconcile'
19-
import { resolveCdpEmails } from './resolveCdpEmails'
19+
import { deriveGithubHandlesFromNoreplyEmails, resolveCdpEmails } from './resolveCdpEmails'
2020
import {
2121
Extractor,
2222
ExtractorDeps,
23+
ProcessRepoResult,
2324
RawContact,
2425
RepoPackage,
2526
RepoPolicies,
2627
RepoTarget,
2728
} from './types'
28-
import { markRepoAttempted, writeContacts } from './writeContacts'
29+
import { writeContactsBatch } from './writeContacts'
2930

3031
const log = getServiceChildLogger('security-contacts')
3132

@@ -120,9 +121,8 @@ export function buildBaseDeps(config: Config): Omit<ExtractorDeps, 'repoTree'> {
120121
export async function processRepo(
121122
target: RepoTarget,
122123
baseDeps: Omit<ExtractorDeps, 'repoTree'>,
123-
qx: QueryExecutor,
124124
cdpQx: QueryExecutor,
125-
): Promise<void> {
125+
): Promise<ProcessRepoResult> {
126126
// One tree fetch per repo, shared by extractors that probe well-known paths.
127127
let repoTree: ExtractorDeps['repoTree'] = { paths: null }
128128
try {
@@ -142,8 +142,7 @@ export async function processRepo(
142142
{ repoId: target.repoId, errMsg: failed.reason?.message },
143143
'Extractor failed — preserving existing data',
144144
)
145-
await markRepoAttempted(qx, target.repoId)
146-
return
145+
return { repoId: target.repoId, status: 'extractor-failed' }
147146
}
148147

149148
let contacts: RawContact[] = []
@@ -163,6 +162,8 @@ export async function processRepo(
163162
contacts = contacts.filter((c) => c.channel !== 'github-pvr')
164163
}
165164

165+
contacts.push(...deriveGithubHandlesFromNoreplyEmails(contacts))
166+
166167
const handleContacts = contacts.filter((c) => c.channel === 'github-handle')
167168
if (handleContacts.length > 0) {
168169
try {
@@ -176,7 +177,7 @@ export async function processRepo(
176177
}
177178

178179
const scored = reconcile(contacts)
179-
await writeContacts(qx, target.repoId, scored, policies)
180+
return { repoId: target.repoId, status: 'ok', contacts: scored, policies }
180181
}
181182

182183
export async function processBatch(
@@ -199,26 +200,57 @@ export async function processBatch(
199200
log.warn({ errMsg: (err as Error).message }, 'Heartbeat failed')
200201
}
201202
}, 30_000)
203+
let outcomes: ProcessRepoResult[]
204+
const extractionStartedAt = Date.now()
202205
try {
203206
// A cancelled task (superseded by a newer activity attempt) is left to throw so it stops
204207
// scheduling further repos instead of racing the new attempt.
205-
await mapWithConcurrency(targets, CONCURRENCY, async (target) => {
208+
//
209+
// Extraction is collected here and persisted afterward in one batched call (below) rather
210+
// than per-repo, since per-repo writes meant up to CONCURRENCY concurrent transactions
211+
// against a packages-db pool sized for far fewer connections — the actual sweep bottleneck.
212+
outcomes = await mapWithConcurrency(targets, CONCURRENCY, async (target) => {
206213
if (cancellationSignal().aborted) {
207214
throw new Error(
208215
'Security contacts batch cancelled — superseded by a newer activity attempt',
209216
)
210217
}
211218
try {
212-
await processRepo(target, deps, qx, cdpQx)
219+
return await processRepo(target, deps, cdpQx)
213220
} catch (err) {
214221
log.error(
215222
{ repoId: target.repoId, errMsg: (err as Error).message },
216223
'Repo processing failed',
217224
)
218-
// Best-effort: keeps a persistently-failing repo from hot-looping the sweep.
219-
await markRepoAttempted(qx, target.repoId).catch(() => undefined)
225+
return { repoId: target.repoId, status: 'extractor-failed' as const }
220226
}
221227
})
228+
229+
const extractionDurationMs = Date.now() - extractionStartedAt
230+
const ok = outcomes.filter((o) => o.status === 'ok').length
231+
log.info(
232+
{
233+
ok,
234+
failed: outcomes.length - ok,
235+
durationMs: extractionDurationMs,
236+
reposPerSec: Number((outcomes.length / (extractionDurationMs / 1000)).toFixed(1)),
237+
},
238+
'Security contacts batch extraction complete — persisting',
239+
)
240+
241+
// Heartbeat is kept alive through persistence too: a slow/lock-blocked write can outlast the
242+
// 2-minute heartbeat timeout just like extraction can, and letting the timer stop early would
243+
// let Temporal retry this activity while the original attempt is still writing.
244+
const writeStartedAt = Date.now()
245+
await writeContactsBatch(qx, outcomes)
246+
const writeDurationMs = Date.now() - writeStartedAt
247+
log.info(
248+
{
249+
durationMs: writeDurationMs,
250+
reposPerSec: Number((outcomes.length / (writeDurationMs / 1000)).toFixed(1)),
251+
},
252+
'Security contacts batch persistence complete',
253+
)
222254
} finally {
223255
clearInterval(heartbeatTimer)
224256
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { type EmailReachabilityReason, classifyEmailReachability } from '@crowd/common'
2+
13
import { scoreContact } from './score'
24
import {
35
ContactChannel,
@@ -56,6 +58,15 @@ function isJunkContact(c: RawContact): boolean {
5658
return false
5759
}
5860

61+
function classifyReachability(c: RawContact): {
62+
reachable: boolean
63+
reachabilityReason: EmailReachabilityReason | null
64+
} {
65+
if (c.channel !== 'email') return { reachable: true, reachabilityReason: null }
66+
const r = classifyEmailReachability(c.value)
67+
return { reachable: r.reachable, reachabilityReason: r.reason }
68+
}
69+
5970
function normalizeValue(channel: ContactChannel, value: string): string {
6071
const v = value.trim()
6172
return channel === 'email' || channel === 'github-handle' ? v.toLowerCase() : v
@@ -130,7 +141,7 @@ export function reconcile(contacts: RawContact[], now: Date = new Date()): Score
130141

131142
const scored: ScoredContact[] = merged.map((c) => {
132143
const contact = { ...c, provenance: dedupeProvenance(c.provenance) }
133-
return { ...contact, ...scoreContact(contact, now) }
144+
return { ...contact, ...scoreContact(contact, now), ...classifyReachability(contact) }
134145
})
135146

136147
scored.sort(

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseGitHubNoreplyEmail } from '@crowd/common'
12
import {
23
findMembersByGithubHandles,
34
findResolvableEmailsForMembers,
@@ -13,6 +14,28 @@ function latestTimestamp(provenance: ProvenanceEntry[]): string {
1314
: times.reduce((a, b) => (new Date(b).getTime() > new Date(a).getTime() ? b : a))
1415
}
1516

17+
// A GitHub noreply email already encodes its owner's handle — derive it so the handle can be
18+
// resolved to a real address the same way any other github-handle contact is. The noreply email
19+
// itself is kept as the provenance source, so the resolved contact's origin stays traceable.
20+
export function deriveGithubHandlesFromNoreplyEmails(contacts: RawContact[]): RawContact[] {
21+
const derived: RawContact[] = []
22+
for (const c of contacts) {
23+
if (c.channel !== 'email') continue
24+
const handle = parseGitHubNoreplyEmail(c.value)
25+
if (!handle) continue
26+
derived.push({
27+
channel: 'github-handle',
28+
value: handle,
29+
role: c.role,
30+
tier: c.tier,
31+
provenance: [
32+
{ source: c.value, sourceTier: c.tier, fetchedAt: latestTimestamp(c.provenance) },
33+
],
34+
})
35+
}
36+
return derived
37+
}
38+
1639
export async function resolveCdpEmails(
1740
cdpQx: QueryExecutor,
1841
handleContacts: RawContact[],
@@ -51,8 +74,9 @@ export async function resolveCdpEmails(
5174
channel: 'email' as const,
5275
value: email,
5376
role: contact.role,
77+
handle: contact.value,
5478
tier: contact.tier,
55-
provenance: [{ source, sourceTier: contact.tier, fetchedAt }],
79+
provenance: [...contact.provenance, { source, sourceTier: contact.tier, fetchedAt }],
5680
}))
5781
})
5882
})

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { EmailReachabilityReason } from '@crowd/common'
12
import type { SecurityContactConfidence } from '@crowd/data-access-layer/src/osspckgs/api'
23

34
export type ContactChannel = 'email' | 'github-pvr' | 'url' | 'github-handle' | 'web-form'
@@ -30,6 +31,8 @@ export interface RawContact {
3031
export interface ScoredContact extends RawContact {
3132
score: number
3233
confidence: SecurityContactConfidence
34+
reachable: boolean
35+
reachabilityReason: EmailReachabilityReason | null
3336
}
3437

3538
export interface RepoPolicies {
@@ -75,3 +78,7 @@ export interface ExtractorDeps {
7578
}
7679

7780
export type Extractor = (target: RepoTarget, deps: ExtractorDeps) => Promise<ExtractorResult>
81+
82+
export type ProcessRepoResult =
83+
| { repoId: string; status: 'ok'; contacts: ScoredContact[]; policies: Partial<RepoPolicies> }
84+
| { repoId: string; status: 'extractor-failed' }

0 commit comments

Comments
 (0)