Skip to content

Commit 17ddd3d

Browse files
committed
feat: add npm registry contact extractor (B2/npm)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 6eb4137 commit 17ddd3d

3 files changed

Lines changed: 134 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Extractor, RawContact, RepoPackage, RepoPolicies } from '../../types'
2+
3+
import { fetchNpm } from './npm'
4+
import { parsePurl } from './purl'
5+
6+
type EcosystemFetcher = (
7+
parsed: ReturnType<typeof parsePurl> & object,
8+
pkg: RepoPackage,
9+
timeoutMs: number,
10+
) => Promise<RawContact[]>
11+
12+
// Keyed by the lowercased packages.ecosystem value. go has no manifest contacts.
13+
const FETCHERS: Record<string, EcosystemFetcher> = {
14+
npm: fetchNpm,
15+
}
16+
17+
export const extractManifest: Extractor = async (target, deps) => {
18+
const contacts: RawContact[] = []
19+
const policies: Partial<RepoPolicies> = {}
20+
const seenPurls = new Set<string>()
21+
22+
for (const pkg of target.packages) {
23+
if (seenPurls.has(pkg.purl)) continue
24+
seenPurls.add(pkg.purl)
25+
26+
const fetcher = FETCHERS[pkg.ecosystem?.toLowerCase()]
27+
if (!fetcher) continue
28+
29+
const parsed = parsePurl(pkg.purl)
30+
if (!parsed) continue
31+
32+
try {
33+
contacts.push(...(await fetcher(parsed, pkg, deps.fetchTimeoutMs)))
34+
} catch {
35+
// one bad package must not sink the rest
36+
continue
37+
}
38+
}
39+
40+
return { contacts, policies }
41+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ProvenanceEntry, RawContact, RepoPackage } from '../../types'
2+
import { fetchJson, isEmail } from '../http'
3+
4+
import { ParsedPurl } from './purl'
5+
6+
const SOURCE = 'npm-registry'
7+
8+
/* eslint-disable @typescript-eslint/no-explicit-any */
9+
10+
function npmPackagePath(parsed: ParsedPurl): string {
11+
const full = parsed.namespace ? `${parsed.namespace}/${parsed.name}` : parsed.name
12+
// Scoped packages must percent-encode the slash for the registry path.
13+
return full.startsWith('@') ? full.replace('/', '%2F') : full
14+
}
15+
16+
export function mapNpm(doc: unknown, sourceUrl: string, fetchedAt: string): RawContact[] {
17+
if (!doc || typeof doc !== 'object') return []
18+
const d = doc as any
19+
20+
const declaredAt = typeof d.time?.modified === 'string' ? d.time.modified : undefined
21+
const prov = (): ProvenanceEntry[] => [
22+
{ source: SOURCE, sourceTier: 'B', path: sourceUrl, fetchedAt, declaredAt },
23+
]
24+
25+
const contacts: RawContact[] = []
26+
const seen = new Set<string>()
27+
const addEmail = (email: string, name?: string): void => {
28+
if (!isEmail(email)) return
29+
const key = email.toLowerCase()
30+
if (seen.has(key)) return
31+
seen.add(key)
32+
contacts.push({
33+
channel: 'email',
34+
value: email,
35+
name,
36+
role: 'maintainer',
37+
tier: 'B',
38+
provenance: prov(),
39+
})
40+
}
41+
42+
if (typeof d.bugs?.email === 'string') addEmail(d.bugs.email)
43+
for (const m of Array.isArray(d.maintainers) ? d.maintainers : []) {
44+
if (typeof m?.email === 'string')
45+
addEmail(m.email, typeof m.name === 'string' ? m.name : undefined)
46+
}
47+
48+
return contacts
49+
}
50+
51+
export async function fetchNpm(
52+
parsed: ParsedPurl,
53+
_pkg: RepoPackage,
54+
timeoutMs: number,
55+
): Promise<RawContact[]> {
56+
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())
60+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export interface ParsedPurl {
2+
type: string
3+
namespace: string | null
4+
name: string
5+
}
6+
7+
/**
8+
* Minimal Package URL parser: pkg:TYPE/NAMESPACE/NAME@VERSION?QUALIFIERS#SUBPATH.
9+
* Only type/namespace/name are needed to address a registry.
10+
*/
11+
export function parsePurl(purl: string): ParsedPurl | null {
12+
if (!purl || !purl.startsWith('pkg:')) return null
13+
14+
let rest = purl.slice('pkg:'.length)
15+
rest = rest.split('#')[0].split('?')[0]
16+
17+
const slash = rest.indexOf('/')
18+
if (slash === -1) return null
19+
20+
const type = rest.slice(0, slash).toLowerCase()
21+
let path = rest.slice(slash + 1)
22+
23+
// Version is the last @ segment (but not a leading @scope on the name).
24+
const at = path.lastIndexOf('@')
25+
if (at > 0) path = path.slice(0, at)
26+
27+
const segments = path.split('/').filter(Boolean).map(decodeURIComponent)
28+
if (segments.length === 0) return null
29+
30+
const name = segments[segments.length - 1]
31+
const namespace = segments.length > 1 ? segments.slice(0, -1).join('/') : null
32+
return { type, namespace, name }
33+
}

0 commit comments

Comments
 (0)