Skip to content

Commit 9f4ac47

Browse files
authored
feat: improve maven security contacts coverage [CM-1341] (#4365)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 6561b1b commit 9f4ac47

4 files changed

Lines changed: 278 additions & 23 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { getServiceChildLogger } from '@crowd/logging'
2+
3+
const log = getServiceChildLogger('security-contacts:http')
4+
15
// crates.io rejects requests without a descriptive User-Agent (HTTP 403); harmless elsewhere.
26
// https://crates.io/policies#crawlers
37
export function registryHeaders(userAgent: string): Record<string, string> {
@@ -12,6 +16,24 @@ const ABSENT_STATUSES = new Set([404, 410, 422, 451])
1216
const RATE_LIMIT_STATUSES = new Set([429, 503])
1317
const MAX_RATE_LIMIT_RETRIES = 3
1418

19+
// A registry throttling event hits every in-flight request at once (batch concurrency is ~100),
20+
// so waits are logged at most once per host per interval to keep visibility without the burst.
21+
const RATE_LIMIT_LOG_INTERVAL_MS = 10_000
22+
const lastRateLimitLogAt = new Map<string, number>()
23+
24+
function logRateLimitWait(url: string, status: number, attempt: number, waitMs: number): void {
25+
let host: string
26+
try {
27+
host = new URL(url).hostname
28+
} catch {
29+
host = url
30+
}
31+
const now = Date.now()
32+
if ((lastRateLimitLogAt.get(host) ?? 0) > now - RATE_LIMIT_LOG_INTERVAL_MS) return
33+
lastRateLimitLogAt.set(host, now)
34+
log.warn({ host, status, attempt: attempt + 1, waitMs }, 'Registry rate limited — backing off')
35+
}
36+
1537
async function fetchWithRetry(
1638
url: string,
1739
timeoutMs: number,
@@ -29,6 +51,7 @@ async function fetchWithRetry(
2951
if (!RATE_LIMIT_STATUSES.has(res.status) || attempt >= MAX_RATE_LIMIT_RETRIES) return res
3052
const retryAfterSec = parseInt(res.headers.get('retry-after') ?? '0', 10)
3153
const waitMs = retryAfterSec ? retryAfterSec * 1000 : Math.min(30_000, 1_000 * 2 ** attempt)
54+
logRateLimitWait(url, res.status, attempt, waitMs)
3255
await new Promise((r) => setTimeout(r, waitMs))
3356
}
3457
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type EcosystemFetcher = (
1313
parsed: ParsedPurl,
1414
timeoutMs: number,
1515
userAgent: string,
16+
repoUrl?: string,
1617
) => Promise<ExtractorResult>
1718

1819
// Keyed by the lowercased packages.ecosystem value. go has no package-manifest contacts.
@@ -43,7 +44,7 @@ export const extractManifest: Extractor = async (target, deps) => {
4344
if (!parsed) continue
4445

4546
try {
46-
const result = await fetcher(parsed, deps.fetchTimeoutMs, deps.userAgent)
47+
const result = await fetcher(parsed, deps.fetchTimeoutMs, deps.userAgent, target.url)
4748
contacts.push(...result.contacts)
4849
for (const candidate of result.handleCandidates ?? []) {
4950
const key = candidate.value.toLowerCase()

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

Lines changed: 228 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@ import { XMLParser } from 'fast-xml-parser'
33
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
44
import { fetchText, isEmail, registryHeaders } from '../http'
55

6+
import { toHandleCandidates } from './handles'
67
import { ParsedPurl } from './purl'
78

89
const SOURCE = 'maven-pom'
910
const BASE = 'https://repo1.maven.org/maven2'
10-
const parser = new XMLParser({ ignoreAttributes: true })
11+
// parseTagValue: false — the default coerces version strings to numbers ("3.0" -> 3,
12+
// "1.10" -> 1.1), which builds wrong POM URLs for any artifact with a trailing-zero version.
13+
const parser = new XMLParser({ ignoreAttributes: true, parseTagValue: false })
14+
15+
// Developer emails often live only in a shared parent POM (jackson-bom, eclipse-ee4j:project,
16+
// commons-parent, …), so when a leaf yields none the parent chain is walked. Generic convenience
17+
// parents (sonatype oss-parent, spring-boot-starter-parent) would donate maintainers unrelated
18+
// to the project, so each level must prove relatedness before its contacts are accepted: shared
19+
// groupId prefix (Central groups are verified publisher namespaces) or scm/url pointing at the
20+
// target repo's owner. An unrelated level stops the walk — anything above it is even less related.
21+
const MAX_PARENT_DEPTH = 5
22+
const PARENT_CACHE_MAX = 5000
1123

1224
/* eslint-disable @typescript-eslint/no-explicit-any */
1325

@@ -16,31 +28,45 @@ function asArray<T>(x: T | T[] | undefined): T[] {
1628
return Array.isArray(x) ? x : [x]
1729
}
1830

19-
export function mapMavenPom(xml: string, sourceUrl: string, fetchedAt: string): RawContact[] {
31+
function parseProject(xml: string): any | null {
2032
let doc: any
2133
try {
2234
doc = parser.parse(xml)
2335
} catch {
24-
return []
36+
return null
2537
}
26-
const project = doc?.project
27-
if (!project) return []
38+
return doc?.project ?? null
39+
}
2840

41+
// Apache POMs conventionally obfuscate developer emails ("ggregory at apache.org",
42+
// "mikl at apache dot org", "x (a) gmail.com"). A rewrite is only accepted when the result is a
43+
// valid address, so free-form prose in the email field cannot produce a contact.
44+
export function deobfuscateEmail(value: string): string | null {
45+
const trimmed = value.trim()
46+
if (isEmail(trimmed)) return trimmed
47+
let v = trimmed
48+
v = v.replace(/\s*[([](?:a|at)[)\]]\s*|\s+at\s+/gi, '@')
49+
v = v.replace(/\s*[([](?:d|dot)[)\]]\s*|\s+dot\s+/gi, '.')
50+
return isEmail(v) ? v : null
51+
}
52+
53+
function mapProjectDevelopers(project: any, sourceUrl: string, fetchedAt: string): RawContact[] {
2954
const prov = (): ProvenanceEntry[] => [
3055
{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt },
3156
]
3257
const contacts: RawContact[] = []
3358
const seen = new Set<string>()
3459
for (const dev of asArray(project.developers?.developer)) {
35-
const email = dev?.email
36-
if (typeof email !== 'string' || !isEmail(email)) continue
60+
const raw = (dev as any)?.email
61+
const email = typeof raw === 'string' ? deobfuscateEmail(raw) : null
62+
if (!email) continue
3763
const key = email.toLowerCase()
3864
if (seen.has(key)) continue
3965
seen.add(key)
4066
contacts.push({
4167
channel: 'email',
4268
value: email,
43-
name: typeof dev.name === 'string' ? dev.name : undefined,
69+
name: typeof (dev as any).name === 'string' ? (dev as any).name : undefined,
4470
role: 'maintainer',
4571
tier: 'B',
4672
provenance: prov(),
@@ -49,6 +75,43 @@ export function mapMavenPom(xml: string, sourceUrl: string, fetchedAt: string):
4975
return contacts
5076
}
5177

78+
// Developer <id>s are frequently GitHub logins but Central does not guarantee it (Apache ids
79+
// are LDAP usernames) — emitted as candidates for repo-contributor corroboration, and only for
80+
// developers that did not already yield an email contact.
81+
function mapProjectDeveloperHandles(
82+
project: any,
83+
sourceUrl: string,
84+
fetchedAt: string,
85+
): RawContact[] {
86+
const handles: unknown[] = []
87+
for (const dev of asArray(project.developers?.developer)) {
88+
const raw = (dev as any)?.email
89+
if (typeof raw === 'string' && deobfuscateEmail(raw)) continue
90+
handles.push((dev as any)?.id)
91+
}
92+
return toHandleCandidates(handles, SOURCE, sourceUrl, fetchedAt)
93+
}
94+
95+
export function mapMavenPom(xml: string, sourceUrl: string, fetchedAt: string): RawContact[] {
96+
const project = parseProject(xml)
97+
if (!project) return []
98+
return mapProjectDevelopers(project, sourceUrl, fetchedAt)
99+
}
100+
101+
export function pickVersion(metadataXml: string): string | null {
102+
let doc: any
103+
try {
104+
doc = parser.parse(metadataXml)
105+
} catch {
106+
return null
107+
}
108+
const versioning = doc?.metadata?.versioning
109+
const release = versioning?.release ?? versioning?.latest
110+
if (typeof release === 'string' && release.length > 0) return release
111+
const versions = asArray(versioning?.versions?.version)
112+
return versions.length ? String(versions[versions.length - 1]) : null
113+
}
114+
52115
async function resolveVersion(
53116
groupPath: string,
54117
artifact: string,
@@ -58,23 +121,153 @@ async function resolveVersion(
58121
const url = `${BASE}/${groupPath}/${artifact}/maven-metadata.xml`
59122
const { text } = await fetchText(url, timeoutMs, registryHeaders(userAgent))
60123
if (!text) return null
61-
let doc: any
62-
try {
63-
doc = parser.parse(text)
64-
} catch {
124+
return pickVersion(text)
125+
}
126+
127+
interface ParentRef {
128+
group: string
129+
artifact: string
130+
version: string
131+
}
132+
133+
function parentRef(project: any): ParentRef | null {
134+
const p = project?.parent
135+
if (
136+
typeof p?.groupId !== 'string' ||
137+
typeof p?.artifactId !== 'string' ||
138+
typeof p?.version !== 'string'
139+
) {
65140
return null
66141
}
67-
const versioning = doc?.metadata?.versioning
68-
const release = versioning?.release ?? versioning?.latest
69-
if (typeof release === 'string') return release
70-
const versions = asArray(versioning?.versions?.version)
71-
return versions.length ? String(versions[versions.length - 1]) : null
142+
// unflattened CI-friendly versions (${revision}) cannot be resolved to a registry path
143+
if (p.version.includes('${')) return null
144+
return { group: p.groupId, artifact: p.artifactId, version: p.version }
145+
}
146+
147+
function groupRelated(leafGroup: string, parentGroup: string): boolean {
148+
return (
149+
parentGroup === leafGroup ||
150+
leafGroup.startsWith(`${parentGroup}.`) ||
151+
parentGroup.startsWith(`${leafGroup}.`)
152+
)
153+
}
154+
155+
// Owner segment of a forge URL; Apache's svn/gitbox/git-wip hosts all map to the "apache" owner
156+
// so github.com/apache scm entries match repos hosted on Apache infrastructure. The host must
157+
// start at a boundary so lookalikes (notgithub.com, fake-apache.org) cannot pass the gate.
158+
function repoOwnerOf(url: string): string | null {
159+
const m = /(?:^|[/@.])(?:github\.com|gitlab\.com|bitbucket\.org)[:/]+([^/:]+)[/:]/i.exec(url)
160+
if (m) return m[1].toLowerCase()
161+
if (/(?:^|[/@.])apache\.org(?:[:/]|$)/i.test(url)) return 'apache'
162+
return null
163+
}
164+
165+
function scmOwners(project: any): string[] {
166+
const urls = [
167+
project?.scm?.url,
168+
project?.scm?.connection,
169+
project?.scm?.developerConnection,
170+
project?.url,
171+
]
172+
const owners = new Set<string>()
173+
for (const u of urls) {
174+
if (typeof u !== 'string') continue
175+
const owner = repoOwnerOf(u)
176+
if (owner) owners.add(owner)
177+
}
178+
return [...owners]
179+
}
180+
181+
interface ParentPom {
182+
group: string
183+
contacts: RawContact[]
184+
handles: RawContact[]
185+
owners: string[]
186+
parent: ParentRef | null
187+
}
188+
189+
// The same few parents sit above thousands of artifacts, so results are memoized per gav for
190+
// the worker's lifetime. Failed fetches are evicted so a transient error is not pinned.
191+
const parentPomCache = new Map<string, Promise<ParentPom | null>>()
192+
193+
async function fetchParentPom(
194+
ref: ParentRef,
195+
timeoutMs: number,
196+
userAgent: string,
197+
): Promise<ParentPom | null> {
198+
const groupPath = ref.group.replace(/\./g, '/')
199+
const url = `${BASE}/${groupPath}/${ref.artifact}/${ref.version}/${ref.artifact}-${ref.version}.pom`
200+
const { status, text } = await fetchText(url, timeoutMs, registryHeaders(userAgent))
201+
// null only for genuinely-absent statuses (immutable gav → safe to negative-cache); a 200
202+
// with an empty or unparseable body is treated as an error so the cache evicts it.
203+
if (!text) {
204+
if (status === 200) throw new Error(`empty parent POM body: ${url}`)
205+
return null
206+
}
207+
const project = parseProject(text)
208+
if (!project) throw new Error(`unparseable parent POM: ${url}`)
209+
const fetchedAt = new Date().toISOString()
210+
return {
211+
group: typeof project.groupId === 'string' ? project.groupId : ref.group,
212+
contacts: mapProjectDevelopers(project, url, fetchedAt),
213+
handles: mapProjectDeveloperHandles(project, url, fetchedAt),
214+
owners: scmOwners(project),
215+
parent: parentRef(project),
216+
}
217+
}
218+
219+
function getParentPom(
220+
ref: ParentRef,
221+
timeoutMs: number,
222+
userAgent: string,
223+
): Promise<ParentPom | null> {
224+
const key = `${ref.group}:${ref.artifact}:${ref.version}`
225+
const cached = parentPomCache.get(key)
226+
if (cached) return cached
227+
const pending = fetchParentPom(ref, timeoutMs, userAgent).catch(() => {
228+
parentPomCache.delete(key)
229+
return null
230+
})
231+
if (parentPomCache.size >= PARENT_CACHE_MAX) {
232+
const oldest = parentPomCache.keys().next().value
233+
if (oldest !== undefined) parentPomCache.delete(oldest)
234+
}
235+
parentPomCache.set(key, pending)
236+
return pending
237+
}
238+
239+
async function traverseParents(
240+
leafProject: any,
241+
leafGroup: string,
242+
repoUrl: string | undefined,
243+
timeoutMs: number,
244+
userAgent: string,
245+
): Promise<{ contacts: RawContact[]; handleCandidates: RawContact[] }> {
246+
const repoOwner = repoUrl ? repoOwnerOf(repoUrl) : null
247+
const handleCandidates: RawContact[] = []
248+
const seen = new Set<string>()
249+
let ref = parentRef(leafProject)
250+
for (let depth = 0; ref !== null && depth < MAX_PARENT_DEPTH; depth++) {
251+
const key = `${ref.group}:${ref.artifact}:${ref.version}`
252+
if (seen.has(key)) break
253+
seen.add(key)
254+
const pom = await getParentPom(ref, timeoutMs, userAgent)
255+
if (!pom) break
256+
const related =
257+
groupRelated(leafGroup, pom.group) || (repoOwner !== null && pom.owners.includes(repoOwner))
258+
if (!related) break
259+
handleCandidates.push(...pom.handles)
260+
if (pom.contacts.length > 0) return { contacts: pom.contacts, handleCandidates }
261+
ref = pom.parent
262+
}
263+
return { contacts: [], handleCandidates }
72264
}
73265

74266
export async function fetchMaven(
75267
parsed: ParsedPurl,
76268
timeoutMs: number,
77269
userAgent: string,
270+
repoUrl?: string,
78271
): Promise<ExtractorResult> {
79272
if (!parsed.namespace) return { contacts: [], policies: {} }
80273
const groupPath = parsed.namespace.replace(/\./g, '/')
@@ -86,5 +279,22 @@ export async function fetchMaven(
86279
const url = `${BASE}/${groupPath}/${artifact}/${version}/${artifact}-${version}.pom`
87280
const { text } = await fetchText(url, timeoutMs, registryHeaders(userAgent))
88281
if (!text) return { contacts: [], policies: {} }
89-
return { contacts: mapMavenPom(text, url, new Date().toISOString()), policies: {} }
282+
const project = parseProject(text)
283+
if (!project) return { contacts: [], policies: {} }
284+
285+
const fetchedAt = new Date().toISOString()
286+
let contacts = mapProjectDevelopers(project, url, fetchedAt)
287+
const handleCandidates = mapProjectDeveloperHandles(project, url, fetchedAt)
288+
if (contacts.length === 0) {
289+
const inherited = await traverseParents(
290+
project,
291+
parsed.namespace,
292+
repoUrl,
293+
timeoutMs,
294+
userAgent,
295+
)
296+
contacts = inherited.contacts
297+
handleCandidates.push(...inherited.handleCandidates)
298+
}
299+
return { contacts, policies: {}, handleCandidates }
90300
}

0 commit comments

Comments
 (0)