@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.
- package/LICENSE +21 -0
- package/advisories.d.ts +82 -0
- package/advisories.js +2 -0
- package/git.d.ts +21 -0
- package/git.js +1 -0
- package/github/write.d.ts +42 -0
- package/github/write.js +2 -0
- package/github.d.ts +68 -0
- package/github.js +2 -0
- package/npm.d.ts +89 -0
- package/npm.js +4 -0
- package/package.d.ts +18 -0
- package/package.js +1 -0
- package/package.json +95 -0
- package/semver.d.ts +11 -0
- package/semver.js +1 -0
- package/src/advisories/common.js +34 -0
- package/src/advisories/github.js +90 -0
- package/src/advisories/index.js +50 -0
- package/src/advisories/npm.js +58 -0
- package/src/advisories/osv.js +108 -0
- package/src/advisories/repos.js +72 -0
- package/src/args.js +72 -0
- package/src/cache.js +57 -0
- package/src/git.js +58 -0
- package/src/github/client.js +31 -0
- package/src/github/read.js +124 -0
- package/src/github/write.js +112 -0
- package/src/http.js +112 -0
- package/src/npm/repos.js +68 -0
- package/src/npm/tarball.js +115 -0
- package/src/package.js +49 -0
- package/src/pool.js +12 -0
- package/src/remote.js +15 -0
- package/src/semver.js +41 -0
|
@@ -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)
|