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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,9 +39,15 @@
39
39
  "./soldeer.js": {
40
40
  "types": "./soldeer.d.ts",
41
41
  "default": "./soldeer.js"
42
+ },
43
+ "./weak-edges.js": {
44
+ "types": "./weak-edges.d.ts",
45
+ "default": "./weak-edges.js"
42
46
  }
43
47
  },
44
48
  "files": [
49
+ "LICENSE-APACHE",
50
+ "LICENSE-MIT",
45
51
  "advisories.d.ts",
46
52
  "advisories.js",
47
53
  "cargo.d.ts",
@@ -60,6 +66,8 @@
60
66
  "semver.js",
61
67
  "soldeer.d.ts",
62
68
  "soldeer.js",
69
+ "weak-edges.d.ts",
70
+ "weak-edges.js",
63
71
  "src/advisories/common.js",
64
72
  "src/advisories/github.js",
65
73
  "src/advisories/index.js",
@@ -67,21 +75,27 @@
67
75
  "src/advisories/osv.js",
68
76
  "src/advisories/repos.js",
69
77
  "src/args.js",
78
+ "src/attributes.js",
70
79
  "src/cache.js",
71
- "src/cargo.js",
80
+ "src/cargo/crate.js",
81
+ "src/cargo/repos.js",
72
82
  "src/download.js",
73
83
  "src/git.js",
74
84
  "src/github/client.js",
75
85
  "src/github/read.js",
76
86
  "src/github/write.js",
77
87
  "src/http.js",
88
+ "src/npm/registry.js",
78
89
  "src/npm/repos.js",
79
90
  "src/npm/tarball.js",
80
91
  "src/package.js",
81
92
  "src/pool.js",
82
93
  "src/remote.js",
94
+ "src/semver-peer.cjs",
83
95
  "src/semver.js",
84
- "src/soldeer.js"
96
+ "src/tree.js",
97
+ "src/soldeer.js",
98
+ "src/weak-edges.js"
85
99
  ],
86
100
  "engines": {
87
101
  "node": ">=24.14.0"
@@ -89,7 +103,7 @@
89
103
  "scripts": {
90
104
  "test": "node --test ./tests/*.test.js"
91
105
  },
92
- "license": "MIT",
106
+ "license": "MIT OR Apache-2.0",
93
107
  "author": {
94
108
  "name": "Nikita Skovoroda",
95
109
  "email": "chalkerx@gmail.com",
@@ -106,5 +120,13 @@
106
120
  "homepage": "https://github.com/PreventiveMeasures/libraries/tree/main/upstream",
107
121
  "publishConfig": {
108
122
  "access": "public"
123
+ },
124
+ "peerDependencies": {
125
+ "semver": "^7.5.2"
126
+ },
127
+ "peerDependenciesMeta": {
128
+ "semver": {
129
+ "optional": true
130
+ }
109
131
  }
110
132
  }
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,35 +1,90 @@
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
+ import { readRecord, writeRecord } from '../cache.js'
4
5
  import { isGone } from '../github/client.js'
5
6
  import { recover } from '../http.js'
6
7
  import { pool } from '../pool.js'
7
- import { valid } from '../semver.js'
8
- import { covered, inRange, isText, metrics } from './common.js'
8
+ import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from './common.js'
9
+ import { soldeerRepos } from './repos.js'
9
10
 
10
11
  const REPOS_AT_ONCE = 4
12
+ const DIR = 'github/advisories'
13
+ // A maintainer publishes one whenever they are ready, and seeing it before
14
+ // GitHub reviews it is what a repository is asked for: 90 minutes, not a month.
15
+ const LISTING_TTL_MS = 90 * 60 * 1000
16
+ // Stamped on each entry, and raised when a listing is kept differently.
17
+ const VERSION = 1
11
18
 
12
19
  const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa_id)
13
20
  && advisory.state === 'published' && isText(advisory.summary)
14
21
  && (advisory.vulnerabilities == null || Array.isArray(advisory.vulnerabilities)) && (advisory.cwe_ids == null || isStrings(advisory.cwe_ids))
15
22
 
16
23
  export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
24
+ export const assertCacheStore = assertion('a store with read and write', (value) => typeof value?.read === 'function' && typeof value?.write === 'function')
17
25
 
18
- const listAdvisories = (github, repo) => github.listRepoAdvisories({ repo }).catch(recover(isGone, []))
26
+ // What rows are made from, and all that is kept of a listing: each advisory
27
+ // not withdrawn, with its CVSS vector GitHub prefers, and each vulnerable
28
+ // range with the package it names. A listing that is malformed is refused
29
+ // whole, and never kept; one whose only fault is a description (isDigest)
30
+ // is answered, but not kept either.
31
+ function digest(repo, list) {
32
+ return list.flatMap((advisory) => {
33
+ assert.ok(isRepoAdvisory(advisory), `advisories: malformed advisory from ${repo}`)
34
+ if (advisory.withdrawn_at) return []
35
+ const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
36
+ const ranges = (advisory.vulnerabilities ?? []).map((vulnerability) => {
37
+ const range = vulnerability?.vulnerable_version_range ?? ''
38
+ assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
39
+ const { ecosystem, name } = vulnerability?.package ?? {}
40
+ return { range, ...(typeof ecosystem === 'string' && { ecosystem }), ...(typeof name === 'string' && { name }) }
41
+ })
42
+ return [{
43
+ ghsa: advisory.ghsa_id,
44
+ title: advisory.summary,
45
+ ...(advisory.description != null && advisory.description !== '' && { description: advisory.description }),
46
+ ...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
47
+ ranges,
48
+ }]
49
+ })
50
+ }
51
+
52
+ // A description is checked only for `details` (detailsOf), so a malformed
53
+ // one leaves the listing usable, but not kept.
54
+ const isDigest = (advisory) => advisory && typeof advisory === 'object' && isGhsa(advisory.ghsa) && isText(advisory.title)
55
+ && (advisory.description === undefined || isText(advisory.description))
56
+ && Array.isArray(advisory.ranges) && advisory.ranges.every((entry) => typeof entry?.range === 'string')
57
+
58
+ // A repository's listing, digested, through the cache: GitHub's names are
59
+ // case-insensitive, so one entry answers every spelling. A repository gone,
60
+ // renamed or blocked has none, and that is not kept.
61
+ async function listAdvisories(github, repo, store) {
62
+ const name = repo.toLowerCase()
63
+ const entry = await readRecord(DIR, name, { ttl: LISTING_TTL_MS, store })
64
+ if (entry?.v === VERSION && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) return entry.advisories
65
+ const list = await github.listRepoAdvisories({ repo }).catch(recover(isGone, null))
66
+ if (list === null) return []
67
+ const advisories = digest(repo, list)
68
+ if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, advisories }, { store })
69
+ return advisories
70
+ }
19
71
 
20
72
  // GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
21
73
  // 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) {
24
- const cvss = [advisory.cvss_severities?.cvss_v3, advisory.cvss_severities?.cvss_v4, advisory.cvss].find((entry) => typeof entry?.vector_string === 'string')
74
+ // semver cannot read, covers every version. Its page is on `repo`, which
75
+ // may publish it long before the advisory database has one, and its
76
+ // `description` is the text `details` asks for: the listing has it all.
77
+ function fromRepository(repo, name, advisory, range, asked, { covers, details }) {
25
78
  return {
26
79
  name,
27
80
  source: 'repository',
28
- id: advisory.ghsa_id,
29
- ghsa: advisory.ghsa_id,
81
+ id: advisory.ghsa,
82
+ ghsa: advisory.ghsa,
83
+ url: advisoryUrl(advisory.ghsa, repo),
30
84
  aliases: [],
31
- title: advisory.summary,
32
- ...metrics({ severity: advisory.severity, score: cvss?.score, vector: cvss?.vector_string, cwe: advisory.cwe_ids }),
85
+ title: advisory.title,
86
+ ...(details && detailsOf(advisory.description, advisory.ghsa)),
87
+ ...metrics({ severity: advisory.severity, score: advisory.cvss, vector: advisory.cvssVector, cwe: advisory.cwe }),
33
88
  range,
34
89
  versions: covered(asked, range.replaceAll(',', ' '), covers),
35
90
  }
@@ -37,21 +92,18 @@ function fromRepository(name, advisory, range, asked, covers) {
37
92
 
38
93
  // Each asked name's repository, `repoOf` it, is asked once, and its
39
94
  // advisories' vulnerable ranges become rows for the names that `takes` a
40
- // vulnerability, one per name, advisory and range, holding the asked
41
- // versions it `covers`.
42
- async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers }) {
95
+ // range's package, one per name, advisory and range, holding the asked
96
+ // versions it `covers`, and with `details`, its text.
97
+ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details, cache }) {
43
98
  // GitHub's names are case-insensitive: one spelling asks for all.
44
99
  const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
45
- const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0])) }))
100
+ const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0]), cache) }))
46
101
  const rows = new Map()
47
102
  for (const { repo, names, list } of listed) {
48
103
  for (const advisory of list) {
49
- assert.ok(isRepoAdvisory(advisory), `advisories: malformed advisory from ${repo}`)
50
- for (const vulnerability of advisory.withdrawn_at ? [] : advisory.vulnerabilities ?? []) {
51
- const range = vulnerability?.vulnerable_version_range ?? ''
52
- assert.ok(typeof range === 'string', `advisories: malformed range in ${advisory.ghsa_id}`)
53
- 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))
104
+ for (const { range, ...pkg } of advisory.ranges) {
105
+ for (const name of names.filter((candidate) => takes(candidate, pkg))) {
106
+ rows.set(`${name} ${advisory.ghsa} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
55
107
  }
56
108
  }
57
109
  }
@@ -59,30 +111,46 @@ async function repositoryAdvisories(github, asked, { repoOf, takes = () => true,
59
111
  return [...rows.values()]
60
112
  }
61
113
 
114
+ // Each asked name's repository: as given, `known`, or else looked up.
115
+ async function reposOf(asked, known, lookUp) {
116
+ const found = await lookUp([...asked.keys()].filter((name) => !known.has(name)))
117
+ return (name) => known.get(name) ?? found.get(name)
118
+ }
119
+
62
120
  // A maintainer's advisory is on the repository before GitHub reviews it
63
121
  // into the databases the registries answer from. With `repoAdvisories`,
64
122
  // each package's repository, `known` or else looked up, adds what it
65
123
  // publishes for GitHub's `ecosystem` entries naming the package, for the
66
124
  // versions `rows` do not already report under that GHSA.
67
- export async function withRepositories(rows, asked, { github, repoAdvisories, known }, { ecosystem, lookUp, covers }) {
125
+ export async function withRepositories(rows, asked, { github, repoAdvisories, known, details, cache }, { ecosystem, lookUp, covers }) {
68
126
  if (!repoAdvisories) return rows
69
127
  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)
72
- const takes = (name, pkg) => pkg?.ecosystem === ecosystem && pkg.name === name
73
- const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers })
128
+ const repoOf = await reposOf(asked, known, lookUp)
129
+ const takes = (name, pkg) => pkg.ecosystem === ecosystem && pkg.name === name
130
+ const added = await repositoryAdvisories(github, asked, { repoOf, takes, covers, details, cache })
74
131
  return [...rows, ...added.map((row) => ({ ...row, versions: row.versions.filter((version) => !reported.has(`${row.name} ${row.id} ${version}`)) }))]
75
132
  }
76
133
 
77
134
  // 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)
135
+ // or 0.0.0: every range covers those.
136
+ const coversPlaceholder = (version, range) => version === '0.0.0' || mayBeInRange(version, range)
81
137
 
82
138
  // Dependencies that are GitHub repositories themselves: every range their
83
139
  // own published advisories list counts, whichever package it names.
84
140
  export const GITHUB = {
141
+ repositoryOnly: true,
85
142
  assertName: assertRepo,
86
143
  assertVersion: assertion('a version or a branch name', isRefName),
87
- advisories: (asked, { github }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder }),
144
+ advisories: (asked, { github, details, cache }) => repositoryAdvisories(github, asked, { repoOf: (repo) => repo, covers: coversPlaceholder, details, cache }),
145
+ }
146
+
147
+ // Soldeer packages, which no advisory database has: the repository each
148
+ // is published from, as given or else as Soldeer's project names it, is
149
+ // their only source, and every range its advisories list counts, whichever
150
+ // package it names (often the npm package the same release went out as).
151
+ export const SOLDEER = {
152
+ repositoryOnly: true,
153
+ assertName: assertSoldeerName,
154
+ assertVersion: assertSoldeerVersion,
155
+ advisories: async (asked, { github, known, details, cache }) => await repositoryAdvisories(github, asked, { repoOf: await reposOf(asked, known, soldeerRepos), details, cache }),
88
156
  }
@@ -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, assertCacheStore, 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), cache: optional(assertCacheStore) })
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, cache: options.cache })
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))