@preventive/upstream 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertArgs, assertBoolean, assertRepo, assertion, optional, sameName } from '../args.js'
4
+ import { order } from './common.js'
5
+ import { GITHUB, assertClient } from './github.js'
6
+ import { NPM } from './npm.js'
7
+ import { CARGO, COMPOSER } from './osv.js'
8
+
9
+ const ECOSYSTEMS = { npm: NPM, cargo: CARGO, composer: COMPOSER, github: GITHUB }
10
+ const byNumbers = new Intl.Collator('en', { numeric: true }).compare
11
+ const assertEcosystem = assertion(`one of ${Object.keys(ECOSYSTEMS).join(', ')}`, (value) => typeof value === 'string' && Object.hasOwn(ECOSYSTEMS, value))
12
+ const assertVersions = assertion('a non-empty array', (value) => Array.isArray(value) && value.length > 0)
13
+
14
+ // Ecosystem → name → { versions, github? }, a name given twice merged.
15
+ // Every package is checked before any is used.
16
+ function collect(packages) {
17
+ assert.ok(typeof packages?.[Symbol.iterator] === 'function' && typeof packages !== 'string', 'advisories: packages must be an iterable of { ecosystem, name, github?, versions }')
18
+ const byEcosystem = new Map()
19
+ for (const pkg of packages) {
20
+ assertArgs('advisories', pkg, { ecosystem: assertEcosystem, name: null, github: optional(assertRepo), versions: assertVersions }, 'package')
21
+ const { assertName, assertVersion } = ECOSYSTEMS[pkg.ecosystem]
22
+ assertName('advisories', 'package.name', pkg.name)
23
+ for (const version of pkg.versions) assertVersion('advisories', 'package.versions', version)
24
+ assert.ok(pkg.ecosystem !== 'github' || pkg.github === undefined, 'advisories: a github package is its own repository, and takes no package.github')
25
+ const named = byEcosystem.get(pkg.ecosystem) ?? byEcosystem.set(pkg.ecosystem, new Map()).get(pkg.ecosystem)
26
+ // A repository's name is GitHub's, in any case: the first spelling stands.
27
+ const name = pkg.ecosystem === 'github' ? [...named.keys()].find((known) => sameName(known, pkg.name)) ?? pkg.name : pkg.name
28
+ const entry = named.get(name) ?? named.set(name, { versions: new Set() }).get(name)
29
+ assert.ok(pkg.github === undefined || entry.github === undefined || sameName(entry.github, pkg.github), `advisories: ${pkg.name} is given two repositories`)
30
+ entry.github ??= pkg.github
31
+ for (const version of pkg.versions) entry.versions.add(version)
32
+ }
33
+ return byEcosystem
34
+ }
35
+
36
+ export async function advisories(packages, options = {}) {
37
+ assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean) })
38
+ assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
39
+ const byEcosystem = collect(packages)
40
+ assert.ok(!byEcosystem.has('github') || options.github, 'advisories: github packages need a github client')
41
+ const found = await Promise.all([...byEcosystem].map(async ([ecosystem, named]) => {
42
+ const { compare = byNumbers, advisories: find } = ECOSYSTEMS[ecosystem]
43
+ const names = [...named.keys()].toSorted()
44
+ const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
45
+ const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
46
+ const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, known })
47
+ return rows.map((row) => ({ ecosystem, ...row }))
48
+ }))
49
+ return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
50
+ }
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertPackageName, assertPackageVersion, isGhsa, isStrings, show } from '../args.js'
4
+ import { NPM_REGISTRY, buildUrl, request } from '../http.js'
5
+ import { compareVersions } from '../semver.js'
6
+ import { covered, isText, metrics } from './common.js'
7
+ import { withRepositories } from './github.js'
8
+ import { npmRepos } from './repos.js'
9
+
10
+ const BULK_URL = buildUrl(NPM_REGISTRY, ['-', 'npm', 'v1', 'security', 'advisories', 'bulk'])
11
+ const NAMES_PER_REQUEST = 250
12
+ const GHSA_PAGE = 'https://github.com/advisories/'
13
+
14
+ const isRow = (row) => row && typeof row === 'object' && Number.isSafeInteger(row.id)
15
+ && ['url', 'severity', 'vulnerable_versions'].every((key) => typeof row[key] === 'string') && isText(row.title)
16
+ && (row.cwe === undefined || isStrings(row.cwe))
17
+
18
+ function fromRegistry(name, row, asked) {
19
+ const tail = row.url.startsWith(GHSA_PAGE) ? row.url.slice(GHSA_PAGE.length) : ''
20
+ const ghsa = isGhsa(tail) ? tail : undefined
21
+ return {
22
+ name,
23
+ source: 'registry',
24
+ id: ghsa ?? `npm:${row.id}`,
25
+ ...(ghsa && { ghsa }),
26
+ aliases: [],
27
+ title: row.title,
28
+ ...metrics({ severity: row.severity, score: row.cvss?.score, vector: row.cvss?.vectorString, cwe: row.cwe }),
29
+ range: row.vulnerable_versions,
30
+ versions: covered(asked, row.vulnerable_versions),
31
+ }
32
+ }
33
+
34
+ async function registryAdvisories(asked) {
35
+ const names = [...asked.keys()]
36
+ const advisories = []
37
+ for (let i = 0; i < names.length; i += NAMES_PER_REQUEST) {
38
+ const chunk = names.slice(i, i + NAMES_PER_REQUEST)
39
+ const body = Object.fromEntries(chunk.map((name) => [name, asked.get(name)]))
40
+ const answer = await request(BULK_URL, { method: 'POST', body, as: 'json' })
41
+ assert.ok(answer && typeof answer === 'object' && !Array.isArray(answer), 'advisories: expected an object from the registry')
42
+ for (const name of Object.keys(answer)) assert.ok(Object.hasOwn(body, name), `advisories: the registry answered for ${show(name)}, which was not asked`)
43
+ for (const name of chunk) {
44
+ const rows = Object.hasOwn(answer, name) ? answer[name] : []
45
+ assert.ok(Array.isArray(rows) && rows.every(isRow), `advisories: malformed advisories for ${name}`)
46
+ advisories.push(...rows.map((row) => fromRegistry(name, row, asked.get(name))))
47
+ }
48
+ }
49
+ return advisories
50
+ }
51
+
52
+ // What `npm audit` asks the registry, one row per vulnerable range.
53
+ export const NPM = {
54
+ assertName: assertPackageName,
55
+ assertVersion: assertPackageVersion,
56
+ compare: compareVersions,
57
+ advisories: async (asked, options) => await withRepositories(await registryAdvisories(asked), asked, options, { ecosystem: 'npm', lookUp: npmRepos }),
58
+ }
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertion, isGhsa, isStrings, matches, show } from '../args.js'
4
+ import { OSV_API, buildUrl, request } from '../http.js'
5
+ import { pool } from '../pool.js'
6
+ import { isExactVersion } from '../semver.js'
7
+ import { inRange, isText, metrics, order } from './common.js'
8
+ import { withRepositories } from './github.js'
9
+ import { composerRepos, crateRepos } from './repos.js'
10
+
11
+ const QUERY_URL = buildUrl(OSV_API, ['v1', 'querybatch'])
12
+ const QUERIES_PER_REQUEST = 1000
13
+ const RECORDS_AT_ONCE = 8
14
+ const isOsvId = matches(/^(?=.{1,128}$)[A-Z][\dA-Z]*(?:-[\dA-Za-z]+)+$/u)
15
+ const INFORMATIONAL = new Set(['unmaintained', 'unsound', 'notice'])
16
+
17
+ export const CARGO = {
18
+ osv: 'crates.io',
19
+ github: 'rust',
20
+ lookUp: crateRepos,
21
+ assertName: assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u)),
22
+ assertVersion: assertion('a semver version', matches(/^(?=.{5,256}$)(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u)),
23
+ keep: (id) => id.startsWith('RUSTSEC-'), // What `cargo audit` reads; the GHSA records mirror it.
24
+ advisories: (asked, options) => osvAdvisories(CARGO, asked, options),
25
+ }
26
+ // TODO: packagist.org's API, what `composer audit` reads, also has the
27
+ // FriendsOfPHP advisories OSV lacks; using it means matching Composer
28
+ // version ranges here.
29
+ export const COMPOSER = {
30
+ osv: 'Packagist',
31
+ github: 'composer',
32
+ lookUp: composerRepos,
33
+ assertName: assertion('a Composer package name', matches(/^(?=.{3,256}$)[a-z\d](?:[_.-]?[a-z\d]+)*\/[a-z\d](?:(?:[_.]|-{1,2})?[a-z\d]+)*$/u)),
34
+ assertVersion: assertion('a Composer release version', matches(/^(?=.{1,64}$)v?\d+(?:\.\d+){0,3}(?:[._-]?(?:stable|beta|b|RC|alpha|a|patch|pl|p)(?:[.-]?\d+)*)?$/iu)),
35
+ // Composer's `v1.2.3` is semver's 1.2.3; what semver cannot read at all
36
+ // (1.2.3.4, 1.0.0-p1) is covered by every range.
37
+ covers: (version, range) => {
38
+ const plain = version.replace(/^v/iu, '')
39
+ return !isExactVersion(plain) || inRange(plain, range)
40
+ },
41
+ advisories: (asked, options) => osvAdvisories(COMPOSER, asked, options),
42
+ }
43
+
44
+ async function getVuln(id) {
45
+ const record = await request(buildUrl(OSV_API, ['v1', 'vulns', id]), { as: 'json' })
46
+ assert.ok(record?.id === id, `advisories: OSV answered for ${show(record?.id)}, not ${id}`)
47
+ assert.ok((record.aliases === undefined || isStrings(record.aliases)) && (record.summary === undefined || isText(record.summary))
48
+ && (record.withdrawn === undefined || typeof record.withdrawn === 'string'), `advisories: malformed OSV record ${id}`)
49
+ return { ...record, aliases: (record.aliases ?? []).filter(isOsvId) }
50
+ }
51
+
52
+ function toAdvisory(ecosystem, name, versions, record) {
53
+ const { aliases } = record
54
+ const ghsas = aliases.filter(isGhsa)
55
+ const ghsa = isGhsa(record.id) ? record.id : (ghsas.length === 1 ? ghsas[0] : undefined)
56
+ const vector = record.severity?.find?.((entry) => /^CVSS_V[34]$/u.test(entry?.type))?.score
57
+ const affected = record.affected?.find?.((entry) => entry?.package?.ecosystem === ecosystem && entry.package.name === name)
58
+ const informational = affected?.database_specific?.informational
59
+ return {
60
+ name,
61
+ source: 'osv',
62
+ id: record.id,
63
+ ...(ghsa && { ghsa }),
64
+ aliases,
65
+ ...(record.summary && { title: record.summary }),
66
+ ...metrics({ severity: record.database_specific?.severity, vector, cwe: record.database_specific?.cwe_ids }),
67
+ ...(INFORMATIONAL.has(informational) && { informational }),
68
+ versions,
69
+ }
70
+ }
71
+
72
+ // OSV's batch query matches versions on its side, but answers ids only:
73
+ // each record is fetched once after. A record another database also
74
+ // publishes comes back under both ids, so one that aliases a GHSA keeps
75
+ // only the versions that GHSA was not answered for.
76
+ async function osvAdvisories(ecosystem, asked, options) {
77
+ const { osv, github, lookUp, covers, keep = () => true } = ecosystem
78
+ const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
79
+ const hits = new Map() // id → name → versions, in `list` order
80
+ for (let i = 0; i < list.length; i += QUERIES_PER_REQUEST) {
81
+ const chunk = list.slice(i, i + QUERIES_PER_REQUEST)
82
+ const body = { queries: chunk.map(({ name, version }) => ({ package: { name, ecosystem: osv }, version })) }
83
+ const answer = await request(QUERY_URL, { method: 'POST', body, as: 'json' })
84
+ assert.ok(Array.isArray(answer?.results) && answer.results.length === chunk.length, 'advisories: expected one OSV result per query')
85
+ for (const [j, result] of answer.results.entries()) {
86
+ const { name, version } = chunk[j]
87
+ const vulns = result?.vulns ?? []
88
+ assert.ok(result && result.next_page_token === undefined && Array.isArray(vulns) && vulns.every((vuln) => isOsvId(vuln?.id)), `advisories: malformed OSV result for ${name}@${version}`)
89
+ for (const { id } of vulns.filter((vuln) => keep(vuln.id))) {
90
+ const byName = hits.get(id) ?? hits.set(id, new Map()).get(id)
91
+ byName.set(name, (byName.get(name) ?? new Set()).add(version))
92
+ }
93
+ }
94
+ }
95
+ const records = (await pool([...hits.keys()], RECORDS_AT_ONCE, getVuln)).filter((record) => !record.withdrawn)
96
+ const live = new Set(records.map((record) => record.id))
97
+ const rows = []
98
+ for (const record of records) {
99
+ for (const [name, versions] of hits.get(record.id)) {
100
+ // Only the versions a live GHSA it aliases answered for too.
101
+ const shadowed = isGhsa(record.id) ? [] : record.aliases.filter((alias) => isGhsa(alias) && live.has(alias)).flatMap((alias) => [...(hits.get(alias).get(name) ?? [])])
102
+ const rest = [...versions].filter((version) => !shadowed.includes(version))
103
+ if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, record))
104
+ }
105
+ }
106
+ rows.sort((a, b) => order(a.name, b.name) || order(a.id, b.id))
107
+ return await withRepositories(rows, asked, options, { ecosystem: github, lookUp, covers })
108
+ }
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+
4
+ import { isRepo, show } from '../args.js'
5
+ import { readCacheJSON, writeCacheJSON } from '../cache.js'
6
+ import { CRATES_API, HttpError, PACKAGIST_REPO, buildUrl, request } from '../http.js'
7
+ import { lookUpPackageRepo } from '../npm/repos.js'
8
+ import { pool } from '../pool.js'
9
+ import { githubRepoOfUrl } from '../remote.js'
10
+
11
+ const TTL_MS = 30 * 24 * 60 * 60 * 1000
12
+ const CRATES_PER_REQUEST = 100
13
+ const CRATES_PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
14
+ const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
15
+ const PACKAGES_AT_ONCE = 8
16
+
17
+ // Name → its GitHub repo, or null where the registry has no such package
18
+ // or it names no GitHub repo; any other failure throws. Only a repo found
19
+ // is cached, for a month.
20
+ async function cachedRepos(dir, names, fetchMissing) {
21
+ const repos = new Map()
22
+ for (const name of names) {
23
+ const entry = await readCacheJSON(dir, `${name}.json`)
24
+ const age = typeof entry?.at === 'number' ? Date.now() - entry.at : Number.NaN
25
+ if (age >= 0 && age <= TTL_MS && entry.name === name && isRepo(entry.github)) repos.set(name, entry.github)
26
+ }
27
+ const missing = names.filter((name) => !repos.has(name))
28
+ for (const [name, github] of await fetchMissing(missing)) {
29
+ repos.set(name, github)
30
+ if (github) await writeCacheJSON(dir, `${name}.json`, { at: Date.now(), name, github })
31
+ }
32
+ return repos
33
+ }
34
+
35
+ // A crate crates.io leaves out of its answer is one it does not have.
36
+ async function fetchCrates(names) {
37
+ const found = new Map(names.map((name) => [name, null]))
38
+ for (let i = 0; i < names.length; i += CRATES_PER_REQUEST) {
39
+ if (i > 0) await sleep(CRATES_PACE_MS)
40
+ const chunk = names.slice(i, i + CRATES_PER_REQUEST)
41
+ const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': chunk, per_page: CRATES_PER_REQUEST })
42
+ const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
43
+ assert.ok(Array.isArray(answer?.crates), 'advisories: expected a list of crates from crates.io')
44
+ for (const crate of answer.crates) {
45
+ assert.ok(chunk.includes(crate?.id), `advisories: crates.io answered for ${show(crate?.id)}, which was not asked`)
46
+ found.set(crate.id, githubRepoOfUrl(crate.repository) ?? null)
47
+ }
48
+ }
49
+ return found
50
+ }
51
+
52
+ async function fetchEach(names, fetch) {
53
+ const found = await pool(names, PACKAGES_AT_ONCE, fetch)
54
+ return new Map(names.map((name, i) => [name, found[i]]))
55
+ }
56
+
57
+ // The first version in Packagist's file is its latest, in full.
58
+ async function fetchComposerRepo(name) {
59
+ const [vendor, pkg] = name.split('/')
60
+ const answer = await request(buildUrl(PACKAGIST_REPO, ['p2', vendor, `${pkg}.json`]), { as: 'json' }).catch((err) => {
61
+ if (err instanceof HttpError && err.status === 404) return null
62
+ throw err
63
+ })
64
+ if (answer === null) return null
65
+ const versions = Object.hasOwn(answer?.packages ?? {}, name) ? answer.packages[name] : undefined
66
+ assert.ok(Array.isArray(versions), `advisories: Packagist answered without ${name}`)
67
+ return githubRepoOfUrl(versions[0]?.source?.url) ?? null
68
+ }
69
+
70
+ export const crateRepos = (names) => cachedRepos('cargo/repos', names, fetchCrates)
71
+ export const composerRepos = (names) => cachedRepos('composer/repos', names, (missing) => fetchEach(missing, fetchComposerRepo))
72
+ export const npmRepos = (names) => fetchEach(names, async (name) => (await lookUpPackageRepo(name))?.github ?? null)
package/src/args.js ADDED
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { isExactVersion } from './semver.js'
4
+
5
+ const isControl = (char) => char < ' ' || (char >= '\u007F' && char <= '\u009F')
6
+ export const matches = (regex) => (value) => typeof value === 'string' && regex.test(value)
7
+ // Bidi controls can make a log line read as something it doesn't say.
8
+ const isBidi = (char) => (char >= '\u202A' && char <= '\u202E') || (char >= '\u2066' && char <= '\u2069')
9
+ // A prototype's properties would be read as options without being checked.
10
+ const isPlainObject = (value) => value != null && [Object.prototype, null].includes(Object.getPrototypeOf(value))
11
+ // Lone surrogates make encodeURIComponent throw.
12
+ const isString = (value, max, allowed = '') => typeof value === 'string' && value.length <= max && value.isWellFormed()
13
+ && ![...value].some((char) => isControl(char) && !allowed.includes(char))
14
+ const isLogin = matches(/^(?=.{1,39}$)[a-z\d](?:-?[a-z\d])*$/iu)
15
+ const isRepoName = matches(/^(?!\.\.?$)[\w.-]{1,100}$/u)
16
+ // `.git` is never in a repo's tree, and no commit should write into it.
17
+ export const isRepoPath = (value) => isString(value, 4096) && value.split('/').every((part) => !['', '.', '..', '.git'].includes(part.toLowerCase()))
18
+ const BAD_REF = /^$|^@$|^-|[ ~^:?*[\\]|\.\.|@\{|^\/|\/$|\/\/|(?:^|\/)\.|\.lock(?:\/|$)|\.$/u // git check-ref-format, a rule per alternative
19
+ const isToken = matches(/^[!-~]+$/u)
20
+ // npm's rules for existing names: capitals allowed (JSONStream), the
21
+ // legacy `~'!()*` not.
22
+ // npm's rule: an unscoped name starts with neither `.`, `_` nor `-`.
23
+ const isPackageName = matches(/^(?=.{1,214}$)(?:@[\w.-]+\/[\w-]|[\dA-Za-z])[\w.-]*$/u)
24
+
25
+ export function printable(text) {
26
+ return [...String(text)].map((char) => (isControl(char) || isBidi(char) ? `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}` : char)).join('')
27
+ }
28
+
29
+ export function show(value) {
30
+ if (typeof value === 'string') return printable(JSON.stringify(value))
31
+ return value && (typeof value === 'object' || typeof value === 'function') ? typeof value : String(value)
32
+ }
33
+
34
+ export function isRepo(value) {
35
+ const [owner, name, ...rest] = typeof value === 'string' ? value.split('/') : []
36
+ return isLogin(owner) && isRepoName(name) && rest.length === 0
37
+ }
38
+
39
+ export const isStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string')
40
+ export const sameName = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase()
41
+ export const isRefName = (value) => isString(value, 255) && !BAD_REF.test(value)
42
+ export const isSha = matches(/^(?:[\da-f]{40}|[\da-f]{64})$/u)
43
+ export const isGhsa = matches(/^GHSA(?:-[\da-hj-km-np-tv-z]{4}){3}$/u)
44
+ export const assertion = (must, predicate) => (method, what, value) => assert.ok(predicate(value), `${method}: ${what} must be ${must}, got ${show(value)}`)
45
+ export const optional = (check) => (method, what, value) => value === undefined || check(method, what, value)
46
+
47
+ // A `null` in `spec` allows the key and leaves its check to the caller.
48
+ export function assertArgs(method, options, spec, name) {
49
+ const label = (key) => printable(name ? `${name}.${String(key)}` : String(key))
50
+ assert.ok(isPlainObject(options), `${method}: ${name ?? 'options'} must be an options object, got ${show(options)}`)
51
+ for (const key of Reflect.ownKeys(options)) assert.ok(Object.hasOwn(spec, key), `${method}: unknown option ${label(key)}`)
52
+ for (const [key, check] of Object.entries(spec)) check?.(method, label(key), options[key])
53
+ }
54
+
55
+ export const assertBoolean = assertion('a boolean', (value) => typeof value === 'boolean')
56
+ export const assertNumber = assertion('a positive integer', (value) => Number.isSafeInteger(value) && value > 0)
57
+ export const assertLine = assertion('a non-empty single line', (value) => isString(value, 1024) && value.trim() !== '')
58
+ // 65,536 is GitHub's own limit on a body.
59
+ export const assertText = assertion('text with no control characters', (value) => isString(value, 65_536, '\t\n\r'))
60
+ export const assertDirectoryPath = assertion('a directory path', (value) => isString(value, 4096) && value !== '')
61
+ export const assertLogin = assertion('a GitHub login', isLogin)
62
+ export const assertRepoName = assertion('a repository name', isRepoName)
63
+ export const assertRepo = assertion('"owner/name"', isRepo)
64
+ export const assertRef = assertion('a branch or tag name', isRefName)
65
+ export const assertSha = assertion('a full commit sha', isSha)
66
+ export const assertPath = assertion('a path inside a repository', isRepoPath)
67
+ export const assertToken = assertion('a token', isToken)
68
+ export const assertTokenOrNull = assertion('a token, or null for anonymous access', (value) => value === null || isToken(value))
69
+ export const assertUserAgent = assertion('a printable user agent', matches(/^[ -~]*[!-~][ -~]*$/u))
70
+ export const assertPackageName = assertion('an npm package name', isPackageName)
71
+ export const assertPackageVersion = assertion('an exact semver version', isExactVersion)
72
+ export const assertGhsa = assertion('a GHSA id', isGhsa)
package/src/cache.js ADDED
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve } from 'node:path'
4
+
5
+ import { assertDirectoryPath } from './args.js'
6
+ import { decode } from './http.js'
7
+
8
+ const DIRS = new Set(['npm/repos', 'npm/tarballs', 'cargo/repos', 'composer/repos'])
9
+
10
+ let root
11
+ let tmpSeq = 0
12
+
13
+ export function setCacheDir(dir) {
14
+ assertDirectoryPath('setCacheDir', 'dir', dir)
15
+ root = resolve(dir)
16
+ }
17
+
18
+ // URI-encoded, so a `/` or `..` in a key stays inside one file name, then
19
+ // `@` kept and `/` written `+`, as pnpm names its store: `@babel+core@7.29.7`.
20
+ // encodeURIComponent writes neither `@` nor `+` bare, so no two keys meet.
21
+ function cachePath(dir, key) {
22
+ assert.ok(DIRS.has(dir) && key && typeof key === 'string', `Unexpected cache entry: ${dir}`)
23
+ return root === undefined ? null : join(root, dir, encodeURIComponent(key).replaceAll('%40', '@').replaceAll('%2F', '+'))
24
+ }
25
+
26
+ export async function readCache(dir, key) {
27
+ const path = cachePath(dir, key)
28
+ return path && await readFile(path).then((bytes) => new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), () => null)
29
+ }
30
+
31
+ export async function readCacheJSON(dir, key) {
32
+ const bytes = await readCache(dir, key)
33
+ try {
34
+ return bytes && JSON.parse(decode(bytes, key))
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ export async function writeCache(dir, key, data) {
41
+ const path = cachePath(dir, key)
42
+ if (path === null) return false
43
+ const tmp = `${path}.${process.pid}.${++tmpSeq}.tmp`
44
+ try {
45
+ await mkdir(dirname(path), { recursive: true })
46
+ await writeFile(tmp, data)
47
+ await rename(tmp, path)
48
+ return true
49
+ } catch {
50
+ await rm(tmp, { force: true }).catch(() => {})
51
+ return false
52
+ }
53
+ }
54
+
55
+ export async function writeCacheJSON(dir, key, value) {
56
+ return await writeCache(dir, key, JSON.stringify(value))
57
+ }
package/src/git.js ADDED
@@ -0,0 +1,58 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises'
2
+ import { dirname, join, relative, resolve, sep } from 'node:path'
3
+
4
+ import { assertDirectoryPath, isRefName, isSha } from './args.js'
5
+ import { githubRepoOfUrl } from './remote.js'
6
+
7
+ const ORIGIN = '[remote "origin"]\n\turl = ' // As git writes it. Only `owner/name` leaves here: the URL may carry a token.
8
+ const read = (path) => readFile(path, 'utf8').catch(() => null)
9
+ const isDirectory = async (path) => (await stat(path).catch(() => null))?.isDirectory() ?? false
10
+
11
+ // A `.git` file points at the real git dir (worktrees, submodules), and a
12
+ // worktree keeps its refs and config in the dir `commondir` names.
13
+ async function findGitDirs(start) {
14
+ for (let dir = start; ; dir = dirname(dir)) {
15
+ const dotGit = join(dir, '.git')
16
+ const info = await stat(dotGit).catch(() => null)
17
+ if (info?.isDirectory()) return { root: dir, gitDir: dotGit, commonDir: dotGit }
18
+ if (info?.isFile()) {
19
+ const pointer = /^gitdir: (?<path>.+)$/u.exec((await read(dotGit))?.trim() ?? '')?.groups.path
20
+ if (!pointer) return null
21
+ const gitDir = resolve(dir, pointer)
22
+ if (!await isDirectory(gitDir)) return null
23
+ const common = (await read(join(gitDir, 'commondir')))?.trim()
24
+ return { root: dir, gitDir, commonDir: common ? resolve(gitDir, common) : gitDir }
25
+ }
26
+ if (dirname(dir) === dir) return null
27
+ }
28
+ }
29
+
30
+ async function headCommit(gitDir, commonDir) {
31
+ const head = (await read(join(gitDir, 'HEAD')))?.trim()
32
+ if (isSha(head)) return head
33
+ // Checked before it is joined to a path, so `ref: ../../x` can't read
34
+ // outside the git dir.
35
+ const ref = /^ref: (?<ref>refs\/\S+)$/u.exec(head ?? '')?.groups.ref
36
+ if (!isRefName(ref)) return null
37
+ const loose = (await read(join(commonDir, ...ref.split('/'))))?.trim()
38
+ if (isSha(loose)) return loose
39
+ const lines = (await read(join(commonDir, 'packed-refs')))?.split('\n').map((line) => line.trim().split(' ')) ?? []
40
+ return lines.find(([sha, name, ...rest]) => name === ref && rest.length === 0 && isSha(sha))?.[0] ?? null
41
+ }
42
+
43
+ async function originGitHub(commonDir) {
44
+ const config = `\n${await read(join(commonDir, 'config')) ?? ''}`
45
+ const at = config.indexOf(`\n${ORIGIN}`)
46
+ return at === -1 ? undefined : githubRepoOfUrl(config.slice(at + 1 + ORIGIN.length).split('\n')[0].trim())
47
+ }
48
+
49
+ export async function findGitCheckout(dir) {
50
+ assertDirectoryPath('findGitCheckout', 'dir', dir)
51
+ const start = await realpath(resolve(dir)).catch(() => null)
52
+ const dirs = start && await isDirectory(start) ? await findGitDirs(start) : null
53
+ if (dirs === null) return {}
54
+ const directory = relative(dirs.root, start).split(sep).join('/')
55
+ const github = await originGitHub(dirs.commonDir)
56
+ const commit = await headCommit(dirs.gitDir, dirs.commonDir)
57
+ return { ...(github && { github }), ...(directory && { directory }), ...(github && { url: `https://github.com/${github}` }), ...(commit && { commit }) }
58
+ }
@@ -0,0 +1,31 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { assertArgs, assertToken, assertTokenOrNull, assertUserAgent, optional } from '../args.js'
4
+ import { GITHUB_API, HttpError, buildUrl, request } from '../http.js'
5
+
6
+ export const api = (segments, query) => buildUrl(GITHUB_API, segments, query)
7
+ export const repoApi = (repo, segments = [], query) => api(['repos', ...repo.split('/'), ...segments], query)
8
+ export const call = (headers, url, options) => request(url, { as: 'json', headers, ...options })
9
+ // A repository renamed (a redirect, refused), deleted or blocked, not a
10
+ // failure that could pass.
11
+ export const isGone = (err) => err instanceof HttpError && [301, 404, 410, 451].includes(err.status)
12
+
13
+ // `null` is explicit anonymous access, so a forgotten token is an error.
14
+ export function clientHeaders(method, options, anonymous) {
15
+ assertArgs(method, options, { token: anonymous ? assertTokenOrNull : assertToken, userAgent: optional(assertUserAgent) })
16
+ const { token, userAgent = '@preventive/upstream' } = options
17
+ return {
18
+ ...(token !== null && { Authorization: `Bearer ${token}` }),
19
+ Accept: 'application/vnd.github+json',
20
+ 'X-GitHub-Api-Version': '2022-11-28',
21
+ 'User-Agent': userAgent,
22
+ }
23
+ }
24
+
25
+ // All async, so a bad argument is always a rejection, never a throw.
26
+ export function bindMethods(headers, methods) {
27
+ return Object.fromEntries(Object.entries(methods).map(([name, fn]) => [name, async (...args) => {
28
+ assert.ok(args.length < fn.length, `${name}: unexpected arguments`)
29
+ return await fn(headers, ...args)
30
+ }]))
31
+ }
@@ -0,0 +1,124 @@
1
+ import assert from 'node:assert/strict'
2
+ import { Buffer } from 'node:buffer'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ import { assertArgs, assertGhsa, assertLogin, assertNumber, assertPath, assertRef, assertRepo, assertSha, isSha, optional, sameName, show } from '../args.js'
6
+ import { decode, encodeSegment } from '../http.js'
7
+ import { api, bindMethods, call, clientHeaders, isGone, repoApi } from './client.js'
8
+
9
+ const PER_PAGE = 100
10
+ const MAX_PAGES = 100
11
+
12
+ // A list of exactly `maxPages` full pages is whole only if the next is empty.
13
+ async function* pages(method, headers, pageUrl, maxPages = MAX_PAGES) {
14
+ for (let page = 1; ; page++) {
15
+ const body = await call(headers, pageUrl({ per_page: PER_PAGE, page }))
16
+ assert.ok(Array.isArray(body), `${method}: expected an array for page ${page}`)
17
+ assert.ok(page <= maxPages || body.length === 0, `${method}: more than ${maxPages} pages`)
18
+ yield body
19
+ if (body.length < PER_PAGE) return
20
+ }
21
+ }
22
+
23
+ const paginate = async (...args) => (await Array.fromAsync(pages(...args))).flat()
24
+ const getCurrentUser = (headers) => call(headers, api(['user']))
25
+
26
+ // No default for `options`: bindMethods counts it to refuse extra arguments.
27
+ async function listUserRepos(headers, options) {
28
+ assertArgs('listUserRepos', options === undefined ? {} : options, { maxPages: optional(assertNumber) })
29
+ return await paginate('listUserRepos', headers, (paging) => api(['user', 'repos'], { ...paging, sort: 'full_name' }), options?.maxPages)
30
+ }
31
+
32
+ async function getRepo(headers, options) {
33
+ assertArgs('getRepo', options, { repo: assertRepo })
34
+ const info = await call(headers, repoApi(options.repo))
35
+ assert.ok(sameName(info?.full_name, options.repo), `getRepo: answered for ${show(info?.full_name)}, not ${options.repo}`)
36
+ return info
37
+ }
38
+
39
+ export async function getRepoHead(headers, options) {
40
+ assertArgs('getRepoHead', options, { repo: assertRepo, branch: optional(assertRef) })
41
+ const { repo, branch } = options
42
+ const ref = branch ?? (await getRepo(headers, { repo })).default_branch
43
+ assertRef('getRepoHead', 'default branch', ref)
44
+ const data = await call(headers, repoApi(repo, ['git', 'ref', 'heads', encodeSegment(ref)]))
45
+ assert.ok(isSha(data?.object?.sha), `getRepoHead: no commit sha for ${repo}@${ref}`)
46
+ return { branch: ref, oid: data.object.sha }
47
+ }
48
+
49
+ // GitHub's object for the path first: a directory, symlink or submodule is
50
+ // refused rather than read as a file's text. Up to 1 MB the object holds
51
+ // the content; past it, `encoding: none`, and the blob it names is read,
52
+ // which a push to the ref since cannot change. Either way the bytes must
53
+ // hash to that blob.
54
+ async function getRepoFile(headers, options) {
55
+ assertArgs('getRepoFile', options, { repo: assertRepo, path: assertPath, ref: optional(assertRef) })
56
+ const { repo, path, ref } = options
57
+ const url = repoApi(repo, ['contents', ...path.split('/').map(encodeSegment)], ref === undefined ? {} : { ref })
58
+ const file = await call(headers, url)
59
+ assert.ok(file?.type === 'file' && file.path === path && Number.isSafeInteger(file.size) && isSha(file.sha), `getRepoFile: ${repo} has no file at ${show(path)}`)
60
+ assert.ok(file.encoding === 'none' || (file.encoding === 'base64' && /^[\dA-Za-z+/=\n]*$/u.test(file.content ?? '')), `getRepoFile: unexpected encoding for ${show(path)}`)
61
+ const bytes = file.encoding === 'none'
62
+ ? await call({ ...headers, Accept: 'application/vnd.github.raw' }, repoApi(repo, ['git', 'blobs', file.sha]), { as: 'bytes' })
63
+ : Buffer.from(file.content, 'base64')
64
+ const blob = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
65
+ assert.ok(bytes.length === file.size && blob === file.sha, `getRepoFile: ${show(path)} came back as blob ${blob}, not ${file.sha}`)
66
+ return decode(bytes, url)
67
+ }
68
+
69
+ // The one request that follows a redirect, to codeload.github.com. A full
70
+ // sha only, so the bytes are that commit's, not wherever a ref points now.
71
+ async function getRepoTarball(headers, options) {
72
+ assertArgs('getRepoTarball', options, { repo: assertRepo, sha: assertSha })
73
+ return await call(headers, repoApi(options.repo, ['tarball', options.sha]), { as: 'bytes', redirect: 'follow' })
74
+ }
75
+
76
+ async function getPullRequest(headers, options) {
77
+ assertArgs('getPullRequest', options, { repo: assertRepo, number: assertNumber })
78
+ const { repo, number } = options
79
+ const pr = await call(headers, repoApi(repo, ['pulls', String(number)]))
80
+ const base = pr?.base?.repo?.full_name
81
+ assert.ok(pr?.number === number && sameName(base, repo), `getPullRequest: answered for ${show(base)}#${show(pr?.number)}, not ${repo}#${number}`)
82
+ assert.ok(typeof pr.title === 'string' && pr.title.trim() !== '', `getPullRequest: ${repo}#${number} has no title`)
83
+ assert.ok(['open', 'closed'].includes(pr.state) && typeof pr.merged === 'boolean', `getPullRequest: ${repo}#${number} has no state`)
84
+ const status = pr.merged ? 'merged' : (pr.state === 'closed' ? 'closed' : (pr.draft === true ? 'draft' : 'open'))
85
+ return { title: pr.title, status }
86
+ }
87
+
88
+ async function getCollaboratorPermission(headers, options) {
89
+ assertArgs('getCollaboratorPermission', options, { repo: assertRepo, username: assertLogin })
90
+ const { repo, username } = options
91
+ const body = await call(headers, repoApi(repo, ['collaborators', username, 'permission']))
92
+ const login = body?.user?.login
93
+ assert.ok(typeof body?.permission === 'string' && sameName(login, username), `getCollaboratorPermission: answered for ${show(login)}, not ${username}`)
94
+ return body
95
+ }
96
+
97
+ // The repository's copy is the maintainer's latest text, there from the
98
+ // start; the global database has it only once GitHub has reviewed it, and
99
+ // is the fallback for a repository that is gone, not for one that failed.
100
+ async function getAdvisory(headers, options) {
101
+ assertArgs('getAdvisory', options, { ghsa: assertGhsa, repo: optional(assertRepo) })
102
+ const { ghsa, repo } = options
103
+ const global = () => call(headers, api(['advisories', ghsa]))
104
+ const advisory = await (repo === undefined ? global() : call(headers, repoApi(repo, ['security-advisories', ghsa])).catch((err) => {
105
+ if (!isGone(err)) throw err
106
+ return global()
107
+ }))
108
+ assert.ok(advisory?.ghsa_id === ghsa, `getAdvisory: answered for ${show(advisory?.ghsa_id)}, not ${ghsa}`)
109
+ assert.ok(advisory.state === undefined || advisory.state === 'published', `getAdvisory: ${ghsa} is ${show(advisory.state)}, not published`)
110
+ return advisory
111
+ }
112
+
113
+ // One page: GitHub pages this list by a cursor in the Link header, which
114
+ // `call` does not read, so a full page is refused rather than cut short.
115
+ async function listRepoAdvisories(headers, options) {
116
+ assertArgs('listRepoAdvisories', options, { repo: assertRepo })
117
+ const list = await call(headers, repoApi(options.repo, ['security-advisories'], { state: 'published', per_page: PER_PAGE }))
118
+ assert.ok(Array.isArray(list), `listRepoAdvisories: expected an array for ${options.repo}`)
119
+ assert.ok(list.length < PER_PAGE, `listRepoAdvisories: ${options.repo} has ${PER_PAGE} or more published advisories, more than a page`)
120
+ return list
121
+ }
122
+
123
+ export const readMethods = { getCurrentUser, listUserRepos, getRepo, getRepoHead, getRepoFile, getRepoTarball, getPullRequest, getCollaboratorPermission, getAdvisory, listRepoAdvisories }
124
+ export const createClient = (options) => bindMethods(clientHeaders('createClient', options, true), readMethods)