@preventive/upstream 1.0.0-alpha.1 → 1.0.0-alpha.10

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/semver.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // Hand-written against semver.js; a change to either belongs with the other.
2
2
 
3
3
  // npm's own semver, borrowed from the npm beside node and passed through
4
- // as it is. Without it, every call throws, except valid() on a plain
4
+ // as it is; where there is no npm, the `semver` package, an optional peer
5
+ // dependency. Without either, every call throws, except valid() on a plain
5
6
  // MAJOR.MINOR.PATCH with no options.
6
7
  export function satisfies(version: string, range: string, options?: { includePrerelease?: boolean; loose?: boolean }): boolean
7
- export function compareVersions(a: string, b: string): -1 | 0 | 1
8
+ export function compareVersions(a: string, b: string, options?: { loose?: boolean }): -1 | 0 | 1
8
9
  // The version as semver spells it, or null: `v1.2.3` answers `1.2.3`.
9
10
  export function valid(version: unknown, options?: { loose?: boolean }): string | null
10
11
  // A string that valid() answers unchanged.
@@ -13,3 +14,8 @@ export function isExactVersion(version: unknown): version is string
13
14
  export function validRange(range: unknown, options?: { loose?: boolean; includePrerelease?: boolean }): string | null
14
15
  // Whether some version is in both ranges; throws for one that is not a range.
15
16
  export function intersects(a: string, b: string, options?: { loose?: boolean; includePrerelease?: boolean }): boolean
17
+ // The version as semver cleans it, spaces and a leading `=` or `v` dropped,
18
+ // or null: ` =v1.2.3 ` answers `1.2.3`.
19
+ export function clean(version: string, options?: { loose?: boolean } | boolean): string | null
20
+ // The version's major, as semver reads it; throws for one that is not a version.
21
+ export function major(version: string, options?: { loose?: boolean } | boolean): number
package/semver.js CHANGED
@@ -1 +1 @@
1
- export { compareVersions, intersects, isExactVersion, satisfies, valid, validRange } from './src/semver.js'
1
+ export { clean, compareVersions, intersects, isExactVersion, major, satisfies, valid, validRange } from './src/semver.js'
@@ -1,21 +1,43 @@
1
- import { isStrings, matches } from '../args.js'
2
- import { satisfies, validRange } from '../semver.js'
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { isGhsa, isRepo, isStrings, matches, show } from '../args.js'
4
+ import { satisfies, valid, validRange } from '../semver.js'
3
5
 
4
6
  export const order = (a, b) => (a > b) - (a < b)
5
7
 
8
+ // GitHub's page for a GHSA: the advisory database's, which has every one
9
+ // the registry and OSV answer with, or the page of the `repo` that
10
+ // published it, which has it before GitHub reviews it into the database.
11
+ export function advisoryUrl(ghsa, repo) {
12
+ assert.ok(isGhsa(ghsa) && (repo === undefined || isRepo(repo)), `advisories: no GitHub advisory page for ${show(ghsa)} on ${show(repo)}`)
13
+ return repo === undefined ? `https://github.com/advisories/${ghsa}` : `https://github.com/${repo}/security/advisories/${ghsa}`
14
+ }
15
+
6
16
  // As `npm audit` matches: a prerelease is in a range around it.
7
17
  const SEMVER = { includePrerelease: true, loose: true }
8
18
 
9
19
  export const inRange = (version, range) => satisfies(version, range, SEMVER)
10
20
 
11
- // The asked versions a range `covers`. One semver cannot read covers them
12
- // all: a missed advisory is worse than a spare one.
13
- export const covered = (asked, range, covers = inRange) => (validRange(range, SEMVER) === null ? [...asked] : asked.filter((version) => covers(version, range)))
21
+ // A version semver cannot read may be in any range. Whatever it reads is
22
+ // a version, `v1.2.3` and `1.2.3+build` included.
23
+ export const mayBeInRange = (version, range) => valid(version) === null || inRange(version, range)
24
+
25
+ // The asked versions a range `covers`. One semver cannot read, range or
26
+ // version, covers them all: a missed advisory is worse than a spare one.
27
+ export const covered = (asked, range, covers = mayBeInRange) => (validRange(range, SEMVER) === null ? [...asked] : asked.filter((version) => covers(version, range)))
14
28
 
15
29
  // Remote text, as long as it is well-formed; anything else is refused as
16
30
  // malformed by whoever reads it.
17
31
  export const isText = (value) => typeof value === 'string' && value.isWellFormed()
18
32
 
33
+ // An advisory's full text, for `details`: none where its source has none,
34
+ // and refused where it is not well-formed.
35
+ export function detailsOf(text, id) {
36
+ if (text == null || text === '') return {}
37
+ assert.ok(isText(text), `advisories: malformed details in ${id}`)
38
+ return { details: text }
39
+ }
40
+
19
41
  const SEVERITIES = new Set(['critical', 'high', 'moderate', 'low', 'info'])
20
42
  const isCvssVector = matches(/^CVSS:[34]\.\d(?:\/[A-Z]{1,4}:[A-Z]{1,2})+$/u)
21
43
  const isCwe = matches(/^CWE-\d{1,6}$/u)
@@ -1,11 +1,11 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertRepo, assertion, isGhsa, isRefName, isStrings } from '../args.js'
3
+ import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa, isRefName, isStrings } from '../args.js'
4
4
  import { isGone } from '../github/client.js'
5
5
  import { recover } from '../http.js'
6
6
  import { pool } from '../pool.js'
7
- import { valid } from '../semver.js'
8
- import { covered, inRange, isText, metrics } from './common.js'
7
+ import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from './common.js'
8
+ import { soldeerRepos } from './repos.js'
9
9
 
10
10
  const REPOS_AT_ONCE = 4
11
11
 
@@ -19,16 +19,20 @@ const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).cat
19
19
 
20
20
  // GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
21
21
  // Maintainers write these unreviewed: one without a range, or with one
22
- // semver cannot read, covers every version.
23
- function fromRepository(name, advisory, range, asked, covers) {
22
+ // semver cannot read, covers every version. Its page is on `repo`, which
23
+ // may publish it long before the advisory database has one, and its
24
+ // `description` is the text `details` asks for: the listing has it all.
25
+ function fromRepository(repo, name, advisory, range, asked, { covers, details }) {
24
26
  const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
25
27
  return {
26
28
  name,
27
29
  source: 'repository',
28
30
  id: advisory.ghsa_id,
29
31
  ghsa: advisory.ghsa_id,
32
+ url: advisoryUrl(advisory.ghsa_id, repo),
30
33
  aliases: [],
31
34
  title: advisory.summary,
35
+ ...(details && detailsOf(advisory.description, advisory.ghsa_id)),
32
36
  ...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
33
37
  range,
34
38
  versions: covered(asked, range.replaceAll(',', ' '), covers),
@@ -38,8 +42,8 @@ function fromRepository(name, advisory, range, asked, covers) {
38
42
  // Each asked name's repository, `repoOf` it, is asked once, and its
39
43
  // advisories' vulnerable ranges become rows for the names that `takes` a
40
44
  // vulnerability, one per name, advisory and range, holding the asked
41
- // versions it `covers`.
42
- async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers }) {
45
+ // versions it `covers`, and with `details`, its text.
46
+ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details }) {
43
47
  // GitHub's names are case-insensitive: one spelling asks for all.
44
48
  const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
45
49
  const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0])) }))
@@ -51,7 +55,7 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
51
55
  const range = vulnerability?.vulnerable_version_range ?? ''
52
56
  assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
53
57
  for (const name of names.filter((candidate) => takes(candidate, vulnerability?.package))) {
54
- rows.set(`${name} ${advisory.ghsa_id} ${range}`, fromRepository(name, advisory, range, asked.get(name), covers))
58
+ rows.set(`${name} ${advisory.ghsa_id} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
55
59
  }
56
60
  }
57
61
  }
@@ -59,30 +63,46 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
59
63
  return [...rows.values()]
60
64
  }
61
65
 
66
+ // Each asked name's repository: as given, `known`, or else looked up.
67
+ async function reposOf(asked, known, lookUp) {
68
+ const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)))
69
+ return (name) => known.get(name) ?? found.get(name)
70
+ }
71
+
62
72
  // A maintainer's advisory is on the repository before GitHub reviews it
63
73
  // into the databases the registries answer from. With `repoAdvisories`,
64
74
  // each package's repository, `known` or else looked up, adds what it
65
75
  // publishes for GitHub's `ecosystem` entries naming the package, for the
66
76
  // versions `rows` do not already report under that GHSA.
67
- export async function withRepositories(rows, asked, { github, repoAdvisories, known }, { ecosystem, lookUp, covers }) {
77
+ export async function withRepositories(rows, asked, { github, repoAdvisories, known, details }, { ecosystem, lookUp, covers }) {
68
78
  if (!repoAdvisories) return rows
69
79
  const reported = new Set(rows.flatMap((row) => [row.id, row.ghsa, ...row.aliases].filter(isGhsa).flatMap((id) => row.versions.map((version) => `${row.name} ${id} ${version}`))))
70
- const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)))
71
- const repoOf = (name) => known.get(name) ?? found.get(name)
80
+ const repoOf = await reposOf(asked, known, lookUp)
72
81
  const takes = (name, pkg) => pkg?.ecosystem === ecosystem && pkg.name === name
73
- const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers })
82
+ const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details })
74
83
  return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
75
84
  }
76
85
 
77
86
  // stasis versions a repository with no version of its own by its branch,
78
- // or 0.0.0: every range covers those. Whatever semver reads is a version,
79
- // `v1.2.3` and `1.2.3+build` included.
80
- const coversPlaceholder = (version, range) => version === '0.0.0' || valid(version) === null || inRange(version, range)
87
+ // or 0.0.0: every range covers those.
88
+ const coversPlaceholder = (version, range) => version === '0.0.0' || mayBeInRange(version, range)
81
89
 
82
90
  // Dependencies that are GitHub repositories themselves: every range their
83
91
  // own published advisories list counts, whichever package it names.
84
92
  export const GITHUB = {
93
+ repositoryOnly: true,
85
94
  assertName: assertRepo,
86
95
  assertVersion: assertion('a version or a branch name', isRefName),
87
- advisories: (asked, { github }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder }),
96
+ advisories: (asked, { github, details }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details }),
97
+ }
98
+
99
+ // Soldeer packages, which no advisory database has: the repository each
100
+ // is published from, as given or else as Soldeer's project names it, is
101
+ // their only source, and every range its advisories list counts, whichever
102
+ // package it names (often the npm package the same release went out as).
103
+ export const SOLDEER = {
104
+ repositoryOnly: true,
105
+ assertName: assertSoldeerName,
106
+ assertVersion: assertSoldeerVersion,
107
+ advisories: async (asked, { github, known, details }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos), details }),
88
108
  }
@@ -1,12 +1,12 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertArgs, assertBoolean, assertRepo, assertion, optional, sameName } from '../args.js'
3
+ import { assertArgs, assertBoolean, assertRepo, assertion, isIterable, optional, sameName } from '../args.js'
4
4
  import { order } from './common.js'
5
- import { GITHUB, assertClient } from './github.js'
5
+ import { GITHUB, SOLDEER, assertClient } from './github.js'
6
6
  import { NPM } from './npm.js'
7
7
  import { CARGO, COMPOSER } from './osv.js'
8
8
 
9
- const ECOSYSTEMS = { npm: NPM, cargo: CARGO, composer: COMPOSER, github: GITHUB }
9
+ const ECOSYSTEMS = { npm: NPM, cargo: CARGO, composer: COMPOSER, soldeer: SOLDEER, github: GITHUB }
10
10
  const byNumbers = new Intl.Collator('en', { numeric: true }).compare
11
11
  const assertEcosystem = assertion(`one of ${Object.keys(ECOSYSTEMS).join(', ')}`, (value) => typeof value === 'string' && Object.hasOwn(ECOSYSTEMS, value))
12
12
  const assertVersions = assertion('a non-empty array', (value) => Array.isArray(value) && value.length > 0)
@@ -14,7 +14,7 @@ const assertVersions = assertion('a non-empty array', (value) => Array.isArray(v
14
14
  // Ecosystem → name → { versions, github? }, a name given twice merged.
15
15
  // Every package is checked before any is used.
16
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 }')
17
+ assert.ok(isIterable(packages), 'advisories: packages must be an iterable of { ecosystem, name, github?, versions }')
18
18
  const byEcosystem = new Map()
19
19
  for (const pkg of packages) {
20
20
  assertArgs('advisories', pkg, { ecosystem: assertEcosystem, name: null, github: optional(assertRepo), versions: assertVersions }, 'package')
@@ -34,16 +34,16 @@ function collect(packages) {
34
34
  }
35
35
 
36
36
  export async function advisories(packages, options = {}) {
37
- assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean) })
37
+ assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean) })
38
38
  assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
39
39
  const byEcosystem = collect(packages)
40
- assert.ok(!byEcosystem.has('github') || options.github, 'advisories: github packages need a github client')
40
+ for (const ecosystem of byEcosystem.keys()) assert.ok(!ECOSYSTEMS[ecosystem].repositoryOnly || options.github, `advisories: ${ecosystem} packages need a github client`)
41
41
  const found = await Promise.all([...byEcosystem].map(async ([ecosystem, named]) => {
42
42
  const { compare = byNumbers, advisories: find } = ECOSYSTEMS[ecosystem]
43
43
  const names = [...named.keys()].toSorted()
44
44
  const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
45
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 })
46
+ const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, known })
47
47
  return rows.map((row) => ({ ecosystem, ...row }))
48
48
  }))
49
49
  return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
@@ -1,11 +1,12 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
3
  import { assertPackageName, assertPackageVersion, isGhsa, isStrings, show } from '../args.js'
4
- import { NPM_REGISTRY, buildUrl, request } from '../http.js'
5
- import { chunks } from '../pool.js'
4
+ import { NPM_REGISTRY, buildUrl, isNotFound, recover, request } from '../http.js'
5
+ import { chunks, pool } from '../pool.js'
6
6
  import { compareVersions } from '../semver.js'
7
- import { covered, isText, metrics } from './common.js'
7
+ import { advisoryUrl, covered, detailsOf, isText, metrics } from './common.js'
8
8
  import { withRepositories } from './github.js'
9
+ import { RECORDS_AT_ONCE, getVuln } from './osv.js'
9
10
  import { npmRepos } from './repos.js'
10
11
 
11
12
  const BULK_URL = buildUrl(NPM_REGISTRY, ['-', 'npm', 'v1', 'security', 'advisories', 'bulk'])
@@ -23,7 +24,7 @@ function fromRegistry(name, row, asked) {
23
24
  name,
24
25
  source: 'registry',
25
26
  id: ghsa ?? `npm:${row.id}`,
26
- ...(ghsa && { ghsa }),
27
+ ...(ghsa && { ghsa, url: advisoryUrl(ghsa) }),
27
28
  aliases: [],
28
29
  title: row.title,
29
30
  ...metrics({ severity: row.severity, score: row.cvss?.score, vector: row.cvss?.vectorString, cwe: row.cwe }),
@@ -49,10 +50,23 @@ async function registryAdvisories(asked) {
49
50
  return advisories
50
51
  }
51
52
 
53
+ // The registry's rows carry no text: each GHSA's is OSV's record of it,
54
+ // GitHub's advisory database as published, asked once for the rows that
55
+ // cover an asked version. One OSV does not have yet has none.
56
+ async function withDetails(rows) {
57
+ const ghsas = [...new Set(rows.filter((row) => row.ghsa && row.versions.length > 0).map((row) => row.ghsa))]
58
+ const records = await pool(ghsas, RECORDS_AT_ONCE, (ghsa) => getVuln(ghsa).catch(recover(isNotFound, undefined)))
59
+ const text = new Map(ghsas.map((ghsa, i) => [ghsa, records[i]?.details]))
60
+ return rows.map((row) => ({ ...row, ...detailsOf(text.get(row.ghsa), row.ghsa) }))
61
+ }
62
+
52
63
  // What `npm audit` asks the registry, one row per vulnerable range.
53
64
  export const NPM = {
54
65
  assertName: assertPackageName,
55
66
  assertVersion: assertPackageVersion,
56
67
  compare: compareVersions,
57
- advisories: async (asked, options) => await withRepositories(await registryAdvisories(asked), asked, options, { ecosystem: 'npm', lookUp: npmRepos }),
68
+ advisories: async (asked, options) => {
69
+ const rows = await registryAdvisories(asked)
70
+ return await withRepositories(options.details ? await withDetails(rows) : rows, asked, options, { ecosystem: 'npm', lookUp: npmRepos })
71
+ },
58
72
  }
@@ -4,15 +4,24 @@ import { assertCrateName, assertCrateVersion, assertion, isGhsa, isStrings, matc
4
4
  import { OSV_API, buildUrl, request } from '../http.js'
5
5
  import { chunks, pool } from '../pool.js'
6
6
  import { isExactVersion } from '../semver.js'
7
- import { inRange, isText, metrics, order } from './common.js'
7
+ import { advisoryUrl, detailsOf, inRange, isText, metrics, order } from './common.js'
8
8
  import { withRepositories } from './github.js'
9
9
  import { composerRepos, crateRepos } from './repos.js'
10
10
 
11
11
  const QUERY_URL = buildUrl(OSV_API, ['v1', 'querybatch'])
12
12
  const QUERIES_PER_REQUEST = 1000
13
- const RECORDS_AT_ONCE = 8
13
+ export const RECORDS_AT_ONCE = 8
14
14
  const isOsvId = matches(/^(?=.{1,128}$)[A-Z][\dA-Z]*(?:-[\dA-Za-z]+)+$/u)
15
+ // osv.dev's page for one of its records. An id that is only another's
16
+ // alias (PKSA-…) has none, and redirects to a search.
17
+ const osvPage = (id) => `https://osv.dev/vulnerability/${id}`
15
18
  const INFORMATIONAL = new Set(['unmaintained', 'unsound', 'notice'])
19
+ // Of the records that publish one advisory, the first answered for a
20
+ // version stands for the rest on it: RustSec's, what `cargo audit` reads
21
+ // and GitHub mirrors, then the GHSA, then any other (MAL-…), then by id.
22
+ const rank = (id) => (id.startsWith('RUSTSEC-') ? 0 : (isGhsa(id) ? 1 : 2))
23
+ const first = (a, b) => rank(a.id) - rank(b.id) || order(a.id, b.id)
24
+ const named = (a, b) => a.aliases.includes(b.id) || b.aliases.includes(a.id)
16
25
 
17
26
  export const CARGO = {
18
27
  osv: 'crates.io',
@@ -20,7 +29,6 @@ export const CARGO = {
20
29
  lookUp: crateRepos,
21
30
  assertName: assertCrateName,
22
31
  assertVersion: assertCrateVersion,
23
- keep: (id) => id.startsWith('RUSTSEC-'), // What `cargo audit` reads; the GHSA records mirror it.
24
32
  advisories: (asked, options) => osvAdvisories(CARGO, asked, options),
25
33
  }
26
34
  // TODO: packagist.org's API, what `composer audit` reads, also has the
@@ -41,7 +49,7 @@ export const COMPOSER = {
41
49
  advisories: (asked, options) => osvAdvisories(COMPOSER, asked, options),
42
50
  }
43
51
 
44
- async function getVuln(id) {
52
+ export async function getVuln(id) {
45
53
  const record = await request(buildUrl(OSV_API, ['v1', 'vulns', id]), { as: 'json' })
46
54
  assert.ok(record?.id === id, `advisories: OSV answered for ${show(record?.id)}, not ${id}`)
47
55
  assert.ok((record.aliases === undefined || isStrings(record.aliases)) && (record.summary === undefined || isText(record.summary))
@@ -49,7 +57,7 @@ async function getVuln(id) {
49
57
  return { ...record, aliases: (record.aliases ?? []).filter(isOsvId) }
50
58
  }
51
59
 
52
- function toAdvisory(ecosystem, name, versions, record) {
60
+ function toAdvisory(ecosystem, name, versions, record, details) {
53
61
  const { aliases } = record
54
62
  const ghsas = aliases.filter(isGhsa)
55
63
  const ghsa = isGhsa(record.id) ? record.id : (ghsas.length === 1 ? ghsas[0] : undefined)
@@ -61,8 +69,10 @@ function toAdvisory(ecosystem, name, versions, record) {
61
69
  source: 'osv',
62
70
  id: record.id,
63
71
  ...(ghsa && { ghsa }),
72
+ url: ghsa ? advisoryUrl(ghsa) : osvPage(record.id),
64
73
  aliases,
65
74
  ...(record.summary && { title: record.summary }),
75
+ ...(details && detailsOf(record.details, record.id)),
66
76
  ...metrics({ severity: record.database_specific?.severity, vector, cwe: record.database_specific?.cwe_ids }),
67
77
  ...(INFORMATIONAL.has(informational) && { informational }),
68
78
  versions,
@@ -70,11 +80,13 @@ function toAdvisory(ecosystem, name, versions, record) {
70
80
  }
71
81
 
72
82
  // 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.
83
+ // each record is fetched once after. An advisory more than one database
84
+ // publishes comes back under each one's id, the records naming each other
85
+ // as aliases, either way round, directly or through others: one group, in
86
+ // which each version is reported under the first answered for it, and
87
+ // each record has the others' ids as aliases.
76
88
  async function osvAdvisories(ecosystem, asked, options) {
77
- const { osv, github, lookUp, covers, keep = () => true } = ecosystem
89
+ const { osv, github, lookUp, covers } = ecosystem
78
90
  const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
79
91
  const hits = new Map() // id → name → versions, in `list` order
80
92
  for (const chunk of chunks(list, QUERIES_PER_REQUEST)) {
@@ -85,21 +97,28 @@ async function osvAdvisories(ecosystem, asked, options) {
85
97
  const { name, version } = chunk[j]
86
98
  const vulns = result?.vulns ?? []
87
99
  assert.ok(result && result.next_page_token === undefined && Array.isArray(vulns) && vulns.every((vuln) => isOsvId(vuln?.id)), `advisories: malformed OSV result for ${name}@${version}`)
88
- for (const { id } of vulns.filter((vuln) => keep(vuln.id))) {
100
+ for (const { id } of vulns) {
89
101
  const byName = hits.get(id) ?? hits.set(id, new Map()).get(id)
90
102
  byName.set(name, (byName.get(name) ?? new Set()).add(version))
91
103
  }
92
104
  }
93
105
  }
94
106
  const records = (await pool([...hits.keys()], RECORDS_AT_ONCE, getVuln)).filter((record) => !record.withdrawn)
95
- const live = new Set(records.map((record) => record.id))
107
+ const groups = new Map()
108
+ for (const record of records) {
109
+ if (groups.has(record)) continue
110
+ const group = [record]
111
+ for (const member of group) group.push(...records.filter((other) => !group.includes(other) && named(member, other)))
112
+ for (const member of group) groups.set(member, group)
113
+ }
96
114
  const rows = []
97
115
  for (const record of records) {
116
+ const group = groups.get(record)
117
+ const above = group.filter((other) => first(other, record) < 0)
118
+ const aliases = [...new Set([...record.aliases, ...group.map((other) => other.id)])].filter((id) => id !== record.id)
98
119
  for (const [name, versions] of hits.get(record.id)) {
99
- // Only the versions a live GHSA it aliases answered for too.
100
- const shadowed = isGhsa(record.id) ? [] : record.aliases.filter((alias) => isGhsa(alias) && live.has(alias)).flatMap((alias) => [...(hits.get(alias).get(name) ?? [])])
101
- const rest = [...versions].filter((version) => !shadowed.includes(version))
102
- if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, record))
120
+ const rest = [...versions].filter((version) => !above.some((other) => hits.get(other.id).get(name)?.has(version)))
121
+ if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases }, options.details))
103
122
  }
104
123
  }
105
124
  rows.sort((a, b) => order(a.name, b.name) || order(a.id, b.id))
@@ -1,51 +1,24 @@
1
1
  import assert from 'node:assert/strict'
2
- import { setTimeout as sleep } from 'node:timers/promises'
3
2
 
4
- import { isRepo, show } from '../args.js'
5
- import { readRecord, writeRecord } from '../cache.js'
6
- import { CRATES_API, PACKAGIST_REPO, buildUrl, isNotFound, recover, request } from '../http.js'
3
+ import { show } from '../args.js'
4
+ import { addRepos, readRepos } from '../cache.js'
5
+ import { lookUpCrateRepos } from '../cargo/repos.js'
6
+ import { PACKAGIST_REPO, SOLDEER_API, buildUrl, isNotFound, recover, request } from '../http.js'
7
7
  import { lookUpPackageRepo } from '../npm/repos.js'
8
- import { chunks, pool } from '../pool.js'
8
+ import { pool } from '../pool.js'
9
9
  import { githubRepoOfUrl } from '../remote.js'
10
10
 
11
- const CRATES_PER_REQUEST = 100
12
- const CRATES_PACE_MS = 1000 // crates.io asks for a request a second at most, and a user agent.
13
- const USER_AGENT = '@preventive/upstream (https://github.com/PreventiveMeasures/libraries)'
14
11
  const PACKAGES_AT_ONCE = 8
15
12
 
16
13
  // Name → its GitHub repo, or null where the registry has no such package
17
14
  // or it names no GitHub repo; any other failure throws. Only a repo found
18
15
  // is cached, for a month.
19
16
  async function cachedRepos(dir, names, fetchMissing) {
20
- const repos = new Map()
21
- for (const name of names) {
22
- const entry = await readRecord(dir, name)
23
- if (isRepo(entry?.github)) repos.set(name, entry.github)
24
- }
25
- const missing = names.filter((name) => !repos.has(name))
26
- for (const [name, github] of await fetchMissing(missing)) {
27
- repos.set(name, github)
28
- if (github) await writeRecord(dir, name, { github })
29
- }
17
+ const repos = await readRepos(dir, names)
18
+ await addRepos(dir, repos, await fetchMissing(names.filter((name) => !repos.has(name))))
30
19
  return repos
31
20
  }
32
21
 
33
- // A crate crates.io leaves out of its answer is one it does not have.
34
- async function fetchCrates(names) {
35
- const found = new Map(names.map((name) => [name, null]))
36
- for (const [i, chunk] of chunks(names, CRATES_PER_REQUEST).entries()) {
37
- if (i > 0) await sleep(CRATES_PACE_MS)
38
- const url = buildUrl(CRATES_API, ['api', 'v1', 'crates'], { 'ids[]': chunk, per_page: CRATES_PER_REQUEST })
39
- const answer = await request(url, { as: 'json', headers: { 'User-Agent': USER_AGENT } })
40
- assert.ok(Array.isArray(answer?.crates), 'advisories: expected a list of crates from crates.io')
41
- for (const crate of answer.crates) {
42
- assert.ok(chunk.includes(crate?.id), `advisories: crates.io answered for ${show(crate?.id)}, which was not asked`)
43
- found.set(crate.id, githubRepoOfUrl(crate.repository) ?? null)
44
- }
45
- }
46
- return found
47
- }
48
-
49
22
  async function fetchEach(names, fetch) {
50
23
  const found = await pool(names, PACKAGES_AT_ONCE, fetch)
51
24
  return new Map(names.map((name, i) => [name, found[i]]))
@@ -61,6 +34,15 @@ async function fetchComposerRepo(name) {
61
34
  return githubRepoOfUrl(versions[0]?.source?.url) ?? null
62
35
  }
63
36
 
64
- export const crateRepos = (names) => cachedRepos('cargo/repos', names, fetchCrates)
37
+ // Soldeer answers a project it does not have with an empty list.
38
+ async function fetchSoldeerRepo(name) {
39
+ const answer = await request(buildUrl(SOLDEER_API, ['api', 'v1', 'project'], { project_name: name }), { as: 'json' })
40
+ assert.ok(Array.isArray(answer?.data), `advisories: expected a list of projects from Soldeer for ${name}`)
41
+ for (const project of answer.data) assert.ok(project?.name === name, `advisories: Soldeer answered for ${show(project?.name)}, not ${name}`)
42
+ return githubRepoOfUrl(answer.data[0]?.github_url) ?? null
43
+ }
44
+
45
+ export const crateRepos = (names) => lookUpCrateRepos('advisories', names)
65
46
  export const composerRepos = (names) => cachedRepos('composer/repos', names, (missing) => fetchEach(missing, fetchComposerRepo))
66
47
  export const npmRepos = (names) => fetchEach(names, async (name) => (await lookUpPackageRepo(name))?.github ?? null)
48
+ export const soldeerRepos = (names) => cachedRepos('soldeer/repos', names, (missing) => fetchEach(missing, fetchSoldeerRepo))
package/src/args.js CHANGED
@@ -15,7 +15,9 @@ const isLogin = matches(/^(?=.{1,39}$)[a-z\d](?:-?[a-z\d])*$/iu)
15
15
  const isRepoName = matches(/^(?!\.\.?$)[\w.-]{1,100}$/u)
16
16
  // `.git` is never in a repo's tree, and no commit should write into it.
17
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
18
+ // git check-ref-format, a rule per alternative. A tag's name may be `@` or
19
+ // start with `-`; a branch's may not.
20
+ const BAD_REF = /^$|[ ~^:?*[\\]|\.\.|@\{|^\/|\/$|\/\/|(?:^|\/)\.|\.lock(?:\/|$)|\.$/u
19
21
  const isToken = matches(/^[!-~]+$/u)
20
22
  // npm's rules for existing names: capitals allowed (JSONStream), the
21
23
  // legacy `~'!()*` not.
@@ -37,8 +39,10 @@ export function isRepo(value) {
37
39
  }
38
40
 
39
41
  export const isStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string')
42
+ export const isIterable = (value) => typeof value?.[Symbol.iterator] === 'function' && typeof value !== 'string'
40
43
  export const sameName = (a, b) => typeof a === 'string' && a.toLowerCase() === b.toLowerCase()
41
- export const isRefName = (value) => isString(value, 255) && !BAD_REF.test(value)
44
+ export const isTagName = (value) => isString(value, 255) && !BAD_REF.test(value)
45
+ export const isRefName = (value) => isTagName(value) && !/^(?:@$|-)/u.test(value)
42
46
  export const isSha = matches(/^(?:[\da-f]{40}|[\da-f]{64})$/u)
43
47
  export const isGhsa = matches(/^GHSA(?:-[\da-hj-km-np-tv-z]{4}){3}$/u)
44
48
  export const assertion = (must, predicate) => (method, what, value) => assert.ok(predicate(value), `${method}: ${what} must be ${must}, got ${show(value)}`)
@@ -52,17 +56,33 @@ export function assertArgs(method, options, spec, name) {
52
56
  for (const [key, check] of Object.entries(spec)) check?.(method, label(key), options[key])
53
57
  }
54
58
 
59
+ // The names a resolve*Repos call is given, each checked and given once,
60
+ // and its options.
61
+ export function assertNames(method, what, names, options, assertName) {
62
+ assert.ok(isIterable(names), `${method}: ${what} must be an iterable of names`)
63
+ assertArgs(method, options, { cachedOnly: optional(assertBoolean) })
64
+ const unique = [...new Set(names)]
65
+ for (const name of unique) assertName(method, 'name', name)
66
+ return unique
67
+ }
68
+
55
69
  export const assertBoolean = assertion('a boolean', (value) => typeof value === 'boolean')
56
70
  export const assertNumber = assertion('a positive integer', (value) => Number.isSafeInteger(value) && value > 0)
57
71
  export const assertLine = assertion('a non-empty single line', (value) => isString(value, 1024) && value.trim() !== '')
58
72
  // 65,536 is GitHub's own limit on a body.
59
73
  export const assertText = assertion('text with no control characters', (value) => isString(value, 65_536, '\t\n\r'))
60
74
  export const assertDirectoryPath = assertion('a directory path', (value) => isString(value, 4096) && value !== '')
75
+ // A short ASCII name, which no filesystem refuses, reads as another or takes
76
+ // for a device: none of those is six characters of these alone.
77
+ export const assertCacheName = assertion('6 to 32 of a-z, A-Z, 0-9, _ and -', matches(/^[a-zA-Z0-9_-]{6,32}$/u))
61
78
  export const assertLogin = assertion('a GitHub login', isLogin)
62
79
  export const assertRepoName = assertion('a repository name', isRepoName)
63
80
  export const assertRepo = assertion('"owner/name"', isRepo)
64
81
  export const assertRef = assertion('a branch or tag name', isRefName)
82
+ export const assertTagName = assertion('a tag name', isTagName)
65
83
  export const assertSha = assertion('a full commit sha', isSha)
84
+ export const isSha1 = matches(/^[\da-f]{40}$/u)
85
+ export const assertTreeId = assertion('a full tree id', isSha1)
66
86
  export const assertPath = assertion('a path inside a repository', isRepoPath)
67
87
  export const assertToken = assertion('a token', isToken)
68
88
  export const assertTokenOrNull = assertion('a token, or null for anonymous access', (value) => value === null || isToken(value))
@@ -72,5 +92,19 @@ export const assertPackageVersion = assertion('an exact semver version', isExact
72
92
  export const assertGhsa = assertion('a GHSA id', isGhsa)
73
93
  export const assertCrateName = assertion('a crate name', matches(/^[A-Za-z][\w-]{0,63}$/u))
74
94
  export const assertCrateVersion = 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))
95
+ // Soldeer's own rule for a name: 3 to 100 of lowercase letters, digits and
96
+ // `-`, starting with one of them or `@`, ending with a letter or digit.
97
+ export const assertSoldeerName = assertion('a Soldeer package name', matches(/^(?=.{3,100}$)[@\da-z][\da-z-]*[\da-z]$/u))
98
+ // Soldeer takes any version but an empty one. Its registry has semver
99
+ // (`5.7.0-rc.0`, `1.0.2-solc-0.8-simulate`), bare numbers and commit hashes.
100
+ export const assertSoldeerVersion = assertion('letters, digits, `.`, `_`, `+` and `-`', matches(/^(?=.{1,128}$)[\dA-Za-z][\w.+-]*$/u))
101
+ // A package's name and version, as a registry's getters take them.
102
+ const assertNameVersion = (assertName, assertVersion) => (method, name, version) => {
103
+ assertName(method, 'name', name)
104
+ assertVersion(method, 'version', version)
105
+ }
106
+ export const assertCrate = assertNameVersion(assertCrateName, assertCrateVersion)
107
+ export const assertPackage = assertNameVersion(assertPackageName, assertPackageVersion)
108
+ export const assertSoldeerPackage = assertNameVersion(assertSoldeerName, assertSoldeerVersion)
75
109
  // As Cargo.lock and soldeer.lock write a checksum.
76
110
  export const assertSha256 = assertion('a sha256 in lowercase hex', matches(/^[\da-f]{64}$/u))