Skip to content

Commit 2712873

Browse files
committed
feat: wire security contacts pipeline + DB write
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 17ddd3d commit 2712873

16 files changed

Lines changed: 660 additions & 54 deletions

File tree

services/apps/packages_worker/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export function getSecurityContactsConfig() {
5050
return {
5151
// A repo is re-evaluated once its contacts are older than this many hours.
5252
updateIntervalHours: requireEnvInt('SECURITY_CONTACTS_UPDATE_INTERVAL_HOURS'),
53+
// Sent on all registry calls; crates.io rejects requests without an identifying UA.
54+
userAgent: requireEnv('SECURITY_CONTACTS_USER_AGENT'),
5355
// Lower than the enricher: each repo fans out to several HTTP calls across extractors
5456
concurrency: parseInt(process.env.SECURITY_CONTACTS_CONCURRENCY ?? '20', 10),
5557
fetchTimeoutMs: parseInt(process.env.SECURITY_CONTACTS_FETCH_TIMEOUT_MS ?? '10000', 10),

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
export const RAW_BASE = 'https://raw.githubusercontent.com'
22
export const GITHUB_API = 'https://api.github.com'
33

4+
// crates.io rejects requests without a descriptive User-Agent (HTTP 403); harmless elsewhere.
5+
// https://crates.io/policies#crawlers
6+
export function registryHeaders(userAgent: string): Record<string, string> {
7+
return { 'User-Agent': userAgent }
8+
}
9+
410
export interface FetchTextResult {
511
status: number
612
text: string | null
@@ -48,11 +54,17 @@ export function githubAuthHeaders(token: string): Record<string, string> {
4854
}
4955

5056
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
57+
const EMAIL_GLOBAL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
5158

5259
export function isEmail(value: string): boolean {
5360
return EMAIL_RE.test(value.trim())
5461
}
5562

63+
// Pulls all email addresses out of free-form strings like "Name <a@b.com>, Name2 <c@d.com>".
64+
export function extractEmails(value: string): string[] {
65+
return value.match(EMAIL_GLOBAL_RE) ?? []
66+
}
67+
5668
/** Returns the login if the URL is a bare github profile (github.com/<login>), else null. */
5769
export function githubHandleFromUrl(value: string): string | null {
5870
const m = value.trim().match(/^https?:\/\/github\.com\/([^/\s]+)\/?$/i)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export const extractPvr: Extractor = async (target, deps) => {
4343
}
4444

4545
// The endpoint works unauthenticated, but we use the token pool for rate-limit budget.
46-
const headers = deps.getToken ? githubAuthHeaders(await deps.getToken()) : {}
46+
const token = deps.getToken ? await deps.getToken() : null
47+
const headers = token ? githubAuthHeaders(token) : {}
4748
const { json } = await fetchJson(
4849
`${GITHUB_API}/repos/${owner}/${name}/private-vulnerability-reporting`,
4950
deps.fetchTimeoutMs,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
2+
import { fetchJson, registryHeaders } from '../http'
3+
4+
import { ParsedPurl } from './purl'
5+
6+
const SOURCE = 'crates.io'
7+
8+
// crates.io policy: max 1 request/second. Serialize calls process-wide.
9+
// https://crates.io/policies#crawlers
10+
let nextAllowedAt = 0
11+
async function throttle(): Promise<void> {
12+
const now = Date.now()
13+
const waitMs = Math.max(0, nextAllowedAt - now)
14+
nextAllowedAt = Math.max(now, nextAllowedAt) + 1000
15+
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs))
16+
}
17+
18+
/* eslint-disable @typescript-eslint/no-explicit-any */
19+
20+
export function mapCargoOwners(doc: unknown, sourceUrl: string, fetchedAt: string): RawContact[] {
21+
if (!doc || typeof doc !== 'object') return []
22+
const users = (doc as any).users
23+
if (!Array.isArray(users)) return []
24+
25+
const prov = (): ProvenanceEntry[] => [
26+
{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt },
27+
]
28+
const contacts: RawContact[] = []
29+
const seen = new Set<string>()
30+
for (const u of users) {
31+
const login = u?.login
32+
// crates.io logins like "github:org:team" are teams, not personal handles.
33+
if (typeof login !== 'string' || login.includes(':')) continue
34+
const key = login.toLowerCase()
35+
if (seen.has(key)) continue
36+
seen.add(key)
37+
contacts.push({
38+
channel: 'github-handle',
39+
value: login,
40+
name: typeof u.name === 'string' ? u.name : undefined,
41+
role: 'maintainer',
42+
tier: 'B',
43+
provenance: prov(),
44+
})
45+
}
46+
return contacts
47+
}
48+
49+
export async function fetchCargo(
50+
parsed: ParsedPurl,
51+
timeoutMs: number,
52+
userAgent: string,
53+
): Promise<ExtractorResult> {
54+
// crates.io does not expose author emails; owners give GitHub handles.
55+
await throttle()
56+
const url = `https://crates.io/api/v1/crates/${encodeURIComponent(parsed.name)}/owners`
57+
const { json } = await fetchJson(url, timeoutMs, registryHeaders(userAgent))
58+
if (!json) return { contacts: [], policies: {} }
59+
return { contacts: mapCargoOwners(json, url, new Date().toISOString()), policies: {} }
60+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ExtractorResult, ProvenanceEntry, RawContact, RepoPolicies } from '../../types'
2+
import { fetchJson, isEmail, registryHeaders } from '../http'
3+
4+
import { ParsedPurl } from './purl'
5+
6+
const SOURCE = 'packagist'
7+
8+
/* eslint-disable @typescript-eslint/no-explicit-any */
9+
10+
export function mapComposer(
11+
doc: unknown,
12+
fullName: string,
13+
sourceUrl: string,
14+
fetchedAt: string,
15+
): ExtractorResult {
16+
const contacts: RawContact[] = []
17+
const policies: Partial<RepoPolicies> = {}
18+
if (!doc || typeof doc !== 'object') return { contacts, policies }
19+
20+
const versions = (doc as any).packages?.[fullName]
21+
const latest = Array.isArray(versions) ? versions[0] : undefined
22+
if (!latest || typeof latest !== 'object') return { contacts, policies }
23+
24+
const prov = (): ProvenanceEntry[] => [
25+
{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt },
26+
]
27+
const seen = new Set<string>()
28+
const add = (
29+
channel: RawContact['channel'],
30+
value: string,
31+
role: RawContact['role'],
32+
name?: string,
33+
): void => {
34+
const key = `${channel}:${value.toLowerCase()}`
35+
if (seen.has(key)) return
36+
seen.add(key)
37+
contacts.push({ channel, value, name, role, tier: 'B', provenance: prov() })
38+
}
39+
40+
for (const a of Array.isArray(latest.authors) ? latest.authors : []) {
41+
if (typeof a?.email === 'string' && isEmail(a.email)) {
42+
add('email', a.email, 'maintainer', typeof a.name === 'string' ? a.name : undefined)
43+
}
44+
}
45+
46+
const support = latest.support
47+
if (support && typeof support === 'object') {
48+
// support.security is Composer's dedicated security channel.
49+
if (typeof support.security === 'string' && /^https?:\/\//i.test(support.security)) {
50+
policies.securityPolicyUrl = support.security
51+
add('url', support.security, 'security-team')
52+
}
53+
if (typeof support.email === 'string' && isEmail(support.email)) {
54+
add('email', support.email, 'security-team')
55+
}
56+
}
57+
58+
return { contacts, policies }
59+
}
60+
61+
export async function fetchComposer(
62+
parsed: ParsedPurl,
63+
timeoutMs: number,
64+
userAgent: string,
65+
): Promise<ExtractorResult> {
66+
if (!parsed.namespace) return { contacts: [], policies: {} }
67+
const fullName = `${parsed.namespace}/${parsed.name}`
68+
const url = `https://repo.packagist.org/p2/${fullName}.json`
69+
const { json } = await fetchJson(url, timeoutMs, registryHeaders(userAgent))
70+
if (!json) return { contacts: [], policies: {} }
71+
return mapComposer(json, fullName, url, new Date().toISOString())
72+
}

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1-
import { Extractor, RawContact, RepoPackage, RepoPolicies } from '../../types'
1+
import { Extractor, ExtractorResult, RawContact, RepoPolicies } from '../../types'
22

3+
import { fetchCargo } from './cargo'
4+
import { fetchComposer } from './composer'
5+
import { fetchMaven } from './maven'
36
import { fetchNpm } from './npm'
4-
import { parsePurl } from './purl'
7+
import { fetchNuget } from './nuget'
8+
import { ParsedPurl, parsePurl } from './purl'
9+
import { fetchPypi } from './pypi'
10+
import { fetchRubygems } from './rubygems'
511

612
type EcosystemFetcher = (
7-
parsed: ReturnType<typeof parsePurl> & object,
8-
pkg: RepoPackage,
13+
parsed: ParsedPurl,
914
timeoutMs: number,
10-
) => Promise<RawContact[]>
15+
userAgent: string,
16+
) => Promise<ExtractorResult>
1117

12-
// Keyed by the lowercased packages.ecosystem value. go has no manifest contacts.
18+
// Keyed by the lowercased packages.ecosystem value. go has no package-manifest contacts.
1319
const FETCHERS: Record<string, EcosystemFetcher> = {
1420
npm: fetchNpm,
21+
pypi: fetchPypi,
22+
maven: fetchMaven,
23+
cargo: fetchCargo,
24+
nuget: fetchNuget,
25+
rubygems: fetchRubygems,
26+
composer: fetchComposer,
1527
}
1628

1729
export const extractManifest: Extractor = async (target, deps) => {
@@ -30,7 +42,13 @@ export const extractManifest: Extractor = async (target, deps) => {
3042
if (!parsed) continue
3143

3244
try {
33-
contacts.push(...(await fetcher(parsed, pkg, deps.fetchTimeoutMs)))
45+
const result = await fetcher(parsed, deps.fetchTimeoutMs, deps.userAgent)
46+
contacts.push(...result.contacts)
47+
for (const [key, value] of Object.entries(result.policies)) {
48+
if (!(policies as Record<string, unknown>)[key] && value != null) {
49+
;(policies as Record<string, unknown>)[key] = value
50+
}
51+
}
3452
} catch {
3553
// one bad package must not sink the rest
3654
continue
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { XMLParser } from 'fast-xml-parser'
2+
3+
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
4+
import { fetchText, isEmail, registryHeaders } from '../http'
5+
6+
import { ParsedPurl } from './purl'
7+
8+
const SOURCE = 'maven-pom'
9+
const BASE = 'https://repo1.maven.org/maven2'
10+
const parser = new XMLParser({ ignoreAttributes: true })
11+
12+
/* eslint-disable @typescript-eslint/no-explicit-any */
13+
14+
function asArray<T>(x: T | T[] | undefined): T[] {
15+
if (x === undefined || x === null) return []
16+
return Array.isArray(x) ? x : [x]
17+
}
18+
19+
export function mapMavenPom(xml: string, sourceUrl: string, fetchedAt: string): RawContact[] {
20+
let doc: any
21+
try {
22+
doc = parser.parse(xml)
23+
} catch {
24+
return []
25+
}
26+
const project = doc?.project
27+
if (!project) return []
28+
29+
const prov = (): ProvenanceEntry[] => [
30+
{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt },
31+
]
32+
const contacts: RawContact[] = []
33+
const seen = new Set<string>()
34+
for (const dev of asArray(project.developers?.developer)) {
35+
const email = dev?.email
36+
if (typeof email !== 'string' || !isEmail(email)) continue
37+
const key = email.toLowerCase()
38+
if (seen.has(key)) continue
39+
seen.add(key)
40+
contacts.push({
41+
channel: 'email',
42+
value: email,
43+
name: typeof dev.name === 'string' ? dev.name : undefined,
44+
role: 'maintainer',
45+
tier: 'B',
46+
provenance: prov(),
47+
})
48+
}
49+
return contacts
50+
}
51+
52+
async function resolveVersion(
53+
groupPath: string,
54+
artifact: string,
55+
timeoutMs: number,
56+
userAgent: string,
57+
): Promise<string | null> {
58+
const url = `${BASE}/${groupPath}/${artifact}/maven-metadata.xml`
59+
const { text } = await fetchText(url, timeoutMs, registryHeaders(userAgent))
60+
if (!text) return null
61+
let doc: any
62+
try {
63+
doc = parser.parse(text)
64+
} catch {
65+
return null
66+
}
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
72+
}
73+
74+
export async function fetchMaven(
75+
parsed: ParsedPurl,
76+
timeoutMs: number,
77+
userAgent: string,
78+
): Promise<ExtractorResult> {
79+
if (!parsed.namespace) return { contacts: [], policies: {} }
80+
const groupPath = parsed.namespace.replace(/\./g, '/')
81+
const artifact = parsed.name
82+
83+
const version = await resolveVersion(groupPath, artifact, timeoutMs, userAgent)
84+
if (!version) return { contacts: [], policies: {} }
85+
86+
const url = `${BASE}/${groupPath}/${artifact}/${version}/${artifact}-${version}.pom`
87+
const { text } = await fetchText(url, timeoutMs, registryHeaders(userAgent))
88+
if (!text) return { contacts: [], policies: {} }
89+
return { contacts: mapMavenPom(text, url, new Date().toISOString()), policies: {} }
90+
}

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ProvenanceEntry, RawContact, RepoPackage } from '../../types'
2-
import { fetchJson, isEmail } from '../http'
1+
import { ExtractorResult, ProvenanceEntry, RawContact } from '../../types'
2+
import { fetchJson, isEmail, registryHeaders } from '../http'
33

44
import { ParsedPurl } from './purl'
55

@@ -50,11 +50,11 @@ export function mapNpm(doc: unknown, sourceUrl: string, fetchedAt: string): RawC
5050

5151
export async function fetchNpm(
5252
parsed: ParsedPurl,
53-
_pkg: RepoPackage,
5453
timeoutMs: number,
55-
): Promise<RawContact[]> {
54+
userAgent: string,
55+
): Promise<ExtractorResult> {
5656
const url = `https://registry.npmjs.org/${npmPackagePath(parsed)}`
57-
const { json } = await fetchJson(url, timeoutMs)
58-
if (!json) return []
59-
return mapNpm(json, url, new Date().toISOString())
57+
const { json } = await fetchJson(url, timeoutMs, registryHeaders(userAgent))
58+
if (!json) return { contacts: [], policies: {} }
59+
return { contacts: mapNpm(json, url, new Date().toISOString()), policies: {} }
6060
}

0 commit comments

Comments
 (0)