Skip to content

Commit 8970c7e

Browse files
authored
fix: security contacts ingestion improvements [CM-1297] (#4295)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent d4833ae commit 8970c7e

13 files changed

Lines changed: 233 additions & 109 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE security_contacts ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
2+
3+
CREATE INDEX IF NOT EXISTS security_contacts_repo_active_idx
4+
ON security_contacts (repo_id)
5+
WHERE deleted_at IS NULL;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ExtractorDeps } from '../types'
2+
3+
/** null means unresolved (empty repo, truncated, fetch failure) — callers must fall back to probing. */
4+
export interface RepoTree {
5+
paths: Set<string> | null
6+
}
7+
8+
interface GitTreeEntry {
9+
path?: unknown
10+
type?: unknown
11+
}
12+
13+
interface GitTreeResponse {
14+
tree?: GitTreeEntry[]
15+
truncated?: boolean
16+
}
17+
18+
// `HEAD` resolves to the default branch on the git data API.
19+
export async function fetchRepoTree(
20+
owner: string,
21+
name: string,
22+
githubGet: ExtractorDeps['githubGet'],
23+
): Promise<RepoTree> {
24+
try {
25+
const { text } = await githubGet(`/repos/${owner}/${name}/git/trees/HEAD?recursive=1`)
26+
if (!text) return { paths: null }
27+
28+
const doc = JSON.parse(text) as GitTreeResponse
29+
if (doc.truncated || !Array.isArray(doc.tree)) return { paths: null }
30+
31+
const paths = new Set<string>()
32+
for (const entry of doc.tree) {
33+
if (entry.type === 'blob' && typeof entry.path === 'string') paths.add(entry.path)
34+
}
35+
return { paths }
36+
} catch {
37+
return { paths: null }
38+
}
39+
}

services/apps/packages_worker/src/security-contacts/extractors/http.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@ export function registryHeaders(userAgent: string): Record<string, string> {
44
return { 'User-Agent': userAgent }
55
}
66

7-
// Genuinely-absent / not-determinable → null body; every other non-200 throws so transient
8-
// failures (5xx/...) are treated as failures and the pipeline preserves data instead of
9-
// wiping it. 422 is included because GitHub's PVR endpoint returns it (per-repo, non-transient)
10-
// when the flag can't be determined — that must read as "unknown", not block the whole repo.
11-
const ABSENT_STATUSES = new Set([404, 410, 422])
7+
// Genuinely-absent/not-determinable → null body; every other non-200 throws so the pipeline
8+
// preserves existing data instead of wiping it. 422 covers GitHub's PVR "can't determine".
9+
const ABSENT_STATUSES = new Set([404, 410, 422, 451])
1210

13-
// Registry rate-limit / overload responses: retried in-process (honoring Retry-After) so a brief
14-
// throttle doesn't fail the extractor and cost the repo a whole refresh cadence.
11+
// Retried in-process (honoring Retry-After) so a brief throttle doesn't cost a whole cadence.
1512
const RATE_LIMIT_STATUSES = new Set([429, 503])
1613
const MAX_RATE_LIMIT_RETRIES = 3
1714

services/apps/packages_worker/src/security-contacts/extractors/securityContactsFile.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export const extractSecurityContactsFile: Extractor = async (target, deps) => {
5555
return { contacts: [], policies: {} }
5656
}
5757

58+
if (deps.repoTree.paths && !deps.repoTree.paths.has(PATH)) return { contacts: [], policies: {} }
59+
5860
const { text } = await deps.githubGet(`/repos/${owner}/${name}/contents/${PATH}`, { raw: true })
5961
if (!text) return { contacts: [], policies: {} }
6062

services/apps/packages_worker/src/security-contacts/extractors/securityInsights.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,10 @@ export const extractSecurityInsights: Extractor = async (target, deps) => {
211211
}
212212

213213
const fetchedAt = new Date().toISOString()
214+
const { paths: treePaths } = deps.repoTree
214215

215216
for (const path of PATHS) {
217+
if (treePaths && !treePaths.has(path)) continue
216218
const { text } = await deps.githubGet(`/repos/${owner}/${name}/contents/${path}`, { raw: true })
217219
if (!text) continue
218220

services/apps/packages_worker/src/security-contacts/extractors/securityMd.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ export const extractSecurityMd: Extractor = async (target, deps) => {
109109
}
110110

111111
const fetchedAt = new Date().toISOString()
112+
const { paths: treePaths } = deps.repoTree
112113
for (const path of PATHS) {
114+
if (treePaths && !treePaths.has(path)) continue
113115
const { text } = await deps.githubGet(`/repos/${owner}/${name}/contents/${path}`, { raw: true })
114116
if (text) return parseSecurityMd(text, owner, name, path, fetchedAt)
115117
}

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

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,13 @@ const log = getServiceChildLogger('security-contacts:github-token')
1515

1616
const GITHUB_API = 'https://api.github.com'
1717

18-
// Genuinely-absent / not-determinable → null body (see http.ts for the same set). 422 covers the
19-
// PVR endpoint returning "can't determine" per-repo; it must read as unknown, not a hard failure.
20-
const ABSENT_STATUSES = new Set([404, 410, 422])
18+
// Genuinely-absent / not-determinable → null body, not a failure (see http.ts for the same set).
19+
// 422 covers GitHub's PVR endpoint returning "can't determine" per-repo; 451 is permanent removal.
20+
const ABSENT_STATUSES = new Set([404, 410, 422, 451])
2121

22-
// App-wide ceiling on concurrent GitHub requests. GitHub's secondary limit rejects bursts of
23-
// >100 concurrent requests from one app; staying under that (across all installations) is the
24-
// single most effective guard against secondary-limit 429s at high repo concurrency.
22+
// App-wide ceiling on concurrent requests — GitHub's secondary limit rejects bursts >100/app.
2523
const MAX_CONCURRENT_GITHUB_REQUESTS = 50
2624

27-
// Bound the park/switch/backoff retry loop so a persistently-limited request eventually surfaces
28-
// as a failure (which the pipeline treats as transient and preserves existing data).
2925
const MAX_RATE_LIMIT_RETRIES = 6
3026

3127
/** Minimal async semaphore with fair FIFO hand-off, used to cap concurrent GitHub requests. */
@@ -126,13 +122,9 @@ async function fetchOnce(
126122
}
127123

128124
/**
129-
* Rate-limit-safe GitHub API GET. Selects an installation from the pool, sleeps if all are parked,
130-
* feeds response budget headers back so exhausted installations get parked before they 403, and on
131-
* a rate-limit response parks (primary) or waits out Retry-After (secondary, app-wide) then retries
132-
* on another installation. Falls back to a single unauthenticated request when no App is configured.
133-
*
134-
* Returns text on 200; null body for absent resources (404/410/422); throws on other non-200s and
135-
* once the retry budget is exhausted (callers treat throws as transient and preserve existing data).
125+
* Rate-limit-safe GitHub API GET. Rotates across installations in the pool, parking exhausted
126+
* or rate-limited ones and retrying, up to MAX_RATE_LIMIT_RETRIES. Falls back to a single
127+
* unauthenticated request when no App is configured.
136128
*/
137129
export async function githubApiGet(
138130
path: string,

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

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import { cancellationSignal, heartbeat } from '@temporalio/activity'
2+
13
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
24
import { getServiceChildLogger } from '@crowd/logging'
35

46
import { getSecurityContactsConfig } from '../config'
7+
import { parseGithubUrl } from '../enricher/fetchLightRepo'
58
import { mapWithConcurrency } from '../utils/concurrency'
69

10+
import { fetchRepoTree } from './extractors/gitTree'
711
import { extractPvr } from './extractors/pvr'
812
import { extractManifest } from './extractors/registry'
913
import { extractSecurityContactsFile } from './extractors/securityContactsFile'
@@ -31,18 +35,11 @@ export interface BatchResult {
3135
processed: number
3236
}
3337

34-
// Two-tier refresh cadence (the worker runs on a daily cron). Just under 24h/168h so a repo
35-
// processed at ~06:00 is eligible again at the next daily/weekly tick rather than slipping a day.
36-
const DAILY_INTERVAL_HOURS = 20 // repos never evaluated or with no contacts yet
37-
const WEEKLY_INTERVAL_HOURS = 156 // already-enriched repos (have contacts)
38-
39-
// Tuned for throughput within the platform ceilings:
40-
// - CONCURRENCY: parallel repos. GitHub calls go through the authed Contents API via a
41-
// rate-limit-aware pool (per-installation budget parking + app-wide concurrency gate +
42-
// Retry-After backoff in githubToken), so high repo concurrency won't trip GitHub limits.
43-
// - FETCH_TIMEOUT_MS: generous enough for slow registries (Maven metadata/POM) without hanging slots.
44-
// - BATCH_SIZE: bounded by the 30-min activity timeout (worst case: an all-cargo batch throttled
45-
// to crates.io's 1 req/s finishes ~8 min).
38+
// Just under 24h/168h so a repo processed at ~06:00 is still eligible at the next tick.
39+
const DAILY_INTERVAL_HOURS = 20 // no contacts found yet
40+
const WEEKLY_INTERVAL_HOURS = 156 // already has contacts
41+
42+
// GitHub calls are rate-limit-aware (see githubToken.ts), so high repo concurrency is safe.
4643
const CONCURRENCY = 100
4744
const FETCH_TIMEOUT_MS = 15000
4845
const BATCH_SIZE = 500
@@ -81,12 +78,12 @@ async function fetchBatch(qx: QueryExecutor): Promise<SweepRow[]> {
8178
r.contacts_last_refreshed IS NULL
8279
-- evaluated but no contacts found yet → retry on the daily cadence
8380
OR (
84-
NOT EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id)
81+
NOT EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id AND sc.deleted_at IS NULL)
8582
AND r.contacts_last_refreshed < NOW() - INTERVAL '$(dailyIntervalHours) hours'
8683
)
8784
-- already enriched (has contacts) → refresh on the weekly cadence
8885
OR (
89-
EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id)
86+
EXISTS (SELECT 1 FROM security_contacts sc WHERE sc.repo_id = r.id AND sc.deleted_at IS NULL)
9087
AND r.contacts_last_refreshed < NOW() - INTERVAL '$(weeklyIntervalHours) hours'
9188
)
9289
)
@@ -114,14 +111,22 @@ function toTarget(row: SweepRow): RepoTarget {
114111

115112
async function processRepo(
116113
target: RepoTarget,
117-
deps: ExtractorDeps,
114+
baseDeps: Omit<ExtractorDeps, 'repoTree'>,
118115
qx: QueryExecutor,
119116
): Promise<void> {
117+
// One tree fetch per repo, shared by extractors that probe well-known paths.
118+
let repoTree: ExtractorDeps['repoTree'] = { paths: null }
119+
try {
120+
const { owner, name } = parseGithubUrl(target.url)
121+
repoTree = await fetchRepoTree(owner, name, baseDeps.githubGet)
122+
} catch {
123+
// not a github.com URL
124+
}
125+
const deps: ExtractorDeps = { ...baseDeps, repoTree }
126+
120127
const results = await Promise.allSettled(EXTRACTORS.map((extract) => extract(target, deps)))
121128

122-
// Replace the repo's contacts only when every extractor succeeded. If any failed (transient
123-
// error — non-200s throw), a destructive rewrite would drop contacts a failed tier-A/B extractor
124-
// still has, so preserve existing data and just record the attempt; retried next cadence.
129+
// Write only when every extractor succeeded, so a failed one can't wipe contacts it didn't see.
125130
const failed = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined
126131
if (failed) {
127132
log.warn(
@@ -144,8 +149,7 @@ async function processRepo(
144149
}
145150
}
146151

147-
// A2 veto: B1 may emit a github-pvr contact from redirect language; drop it when A2
148-
// authoritatively reports PVR disabled (Option C from the design discussion).
152+
// A2 veto: drop B1's github-pvr guess when A2 authoritatively reports PVR disabled.
149153
if (policies.pvrEnabled === false) {
150154
contacts = contacts.filter((c) => c.channel !== 'github-pvr')
151155
}
@@ -158,26 +162,45 @@ export async function processBatch(qx: QueryExecutor, config: Config): Promise<B
158162
const batch = await fetchBatch(qx)
159163
if (batch.length === 0) return { processed: 0 }
160164

161-
const deps: ExtractorDeps = {
165+
const deps: Omit<ExtractorDeps, 'repoTree'> = {
162166
fetchTimeoutMs: FETCH_TIMEOUT_MS,
163167
userAgent: config.userAgent,
164168
githubGet: (path, opts) => githubApiGet(path, FETCH_TIMEOUT_MS, opts),
165169
}
166170

167171
const targets = batch.map(toTarget)
168-
// Isolate per-repo failures: mapWithConcurrency is fail-fast, so a single repo's DB error must
169-
// not abort the batch (and, across Temporal retries, halt the whole sweep). Unmarked repos on
170-
// error are simply retried next sweep.
171-
await mapWithConcurrency(targets, CONCURRENCY, async (target) => {
172+
// Fixed-cadence heartbeat: a slow repo can outlast the 2-minute heartbeatTimeout even while
173+
// every concurrency slot is still busy, so this can't rely on task completions alone.
174+
const heartbeatTimer = setInterval(() => {
172175
try {
173-
await processRepo(target, deps, qx)
176+
heartbeat()
174177
} catch (err) {
175-
log.error({ repoId: target.repoId, errMsg: (err as Error).message }, 'Repo processing failed')
176-
// Best-effort mark so a persistently-failing repo drains on cadence rather than making the
177-
// sweep hot-loop (it would otherwise stay eligible and keep processed > 0 forever).
178-
await markRepoAttempted(qx, target.repoId).catch(() => undefined)
178+
log.warn({ errMsg: (err as Error).message }, 'Heartbeat failed')
179179
}
180-
})
180+
}, 30_000)
181+
try {
182+
// A cancelled task (superseded by a newer activity attempt) is left to throw so it stops
183+
// scheduling further repos instead of racing the new attempt.
184+
await mapWithConcurrency(targets, CONCURRENCY, async (target) => {
185+
if (cancellationSignal().aborted) {
186+
throw new Error(
187+
'Security contacts batch cancelled — superseded by a newer activity attempt',
188+
)
189+
}
190+
try {
191+
await processRepo(target, deps, qx)
192+
} catch (err) {
193+
log.error(
194+
{ repoId: target.repoId, errMsg: (err as Error).message },
195+
'Repo processing failed',
196+
)
197+
// Best-effort: keeps a persistently-failing repo from hot-looping the sweep.
198+
await markRepoAttempted(qx, target.repoId).catch(() => undefined)
199+
}
200+
})
201+
} finally {
202+
clearInterval(heartbeatTimer)
203+
}
181204

182205
log.info({ processed: targets.length }, 'Security contacts batch complete')
183206
return { processed: targets.length }

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,44 @@ const ROLE_PRIORITY: Record<ContactRole, number> = {
2020

2121
const TIER_RANK: Record<SourceTier, number> = { A: 4, B: 3, C: 2, D: 1 }
2222

23+
// RFC 2606 reserved domains — always unedited template placeholders (e.g. security@example.com).
24+
const PLACEHOLDER_EMAIL_DOMAINS = new Set([
25+
'example.com',
26+
'example.org',
27+
'example.net',
28+
'example.edu',
29+
])
30+
31+
// Generic GitHub/Dependabot help pages that templated SECURITY.md files link to — never a
32+
// project-specific contact, unlike an actual github.com/<owner>/<repo>/... URL.
33+
const GENERIC_URL_HOSTS = new Set(['docs.github.com', 'dependabot.com', 'www.dependabot.com'])
34+
35+
const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i
36+
37+
// Some upstream sources (e.g. SECURITY-INSIGHTS {type:"url"} entries) don't enforce a scheme —
38+
// only add one to resolve the host, never mutate the stored contact value.
39+
function urlHost(value: string): string | null {
40+
const candidate = HAS_SCHEME_RE.test(value) ? value : `https://${value}`
41+
try {
42+
return new URL(candidate).hostname.toLowerCase()
43+
} catch {
44+
return null
45+
}
46+
}
47+
48+
function isJunkContact(c: RawContact): boolean {
49+
if (c.channel === 'email') {
50+
const domain = c.value.split('@')[1]?.toLowerCase().trim()
51+
return domain != null && PLACEHOLDER_EMAIL_DOMAINS.has(domain)
52+
}
53+
if (c.channel === 'url' || c.channel === 'web-form') {
54+
const host = urlHost(c.value)
55+
if (host == null) return true
56+
return GENERIC_URL_HOSTS.has(host) || host === 'localhost' || host.startsWith('127.')
57+
}
58+
return false
59+
}
60+
2361
function normalizeValue(channel: ContactChannel, value: string): string {
2462
const v = value.trim()
2563
return channel === 'email' || channel === 'github-handle' ? v.toLowerCase() : v
@@ -90,7 +128,7 @@ function identityLinkMerge(contacts: RawContact[]): RawContact[] {
90128
}
91129

92130
export function reconcile(contacts: RawContact[], now: Date = new Date()): ScoredContact[] {
93-
const merged = identityLinkMerge(exactMatchMerge(contacts))
131+
const merged = identityLinkMerge(exactMatchMerge(contacts.filter((c) => !isJunkContact(c))))
94132

95133
const scored: ScoredContact[] = merged.map((c) => {
96134
const contact = { ...c, provenance: dedupeProvenance(c.provenance) }

0 commit comments

Comments
 (0)