@preventive/upstream 1.0.1 → 1.1.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/advisories.d.ts CHANGED
@@ -50,6 +50,15 @@ export interface AdvisoryOptions {
50
50
  // and its rows take OSV's record of their GHSA, one more request each
51
51
  // (no GitHub client needed).
52
52
  details?: boolean
53
+ // Also asks OSV of each npm package's versions, as cargo and composer
54
+ // packages always are, for what the registry does not have: above all
55
+ // the malicious packages' records (MAL-...), which it has none of, as
56
+ // those of npm releases taken down that GitHub's database has no
57
+ // advisory of. A version a registry row reports under the same advisory,
58
+ // its id among an OSV record's id and aliases, is left to that row,
59
+ // which takes the record's ids among its aliases. Sends each npm
60
+ // package's name and versions to OSV, so not for a private package.
61
+ osv?: boolean
53
62
  }
54
63
 
55
64
  // One advisory on one package, with the versions asked about that it
@@ -58,11 +67,13 @@ export interface Advisory {
58
67
  ecosystem: Ecosystem
59
68
  name: string
60
69
  // `registry`: npm's, as `npm audit` has it, one row per vulnerable
61
- // range. `osv`: OSV's records for cargo and composer, RustSec's, GitHub's
62
- // and malicious packages' (MAL-...) among them; of the records naming each
63
- // other as aliases, directly or through others, a version is reported
64
- // under one only: RustSec's, else the GHSA, else the first by id. Each
65
- // has the others' ids among its aliases.
70
+ // range. `osv`: OSV's records for cargo and composer, and with `osv` for
71
+ // npm, the versions they cover that no registry row reports under the
72
+ // same advisory; RustSec's, GitHub's and malicious packages' (MAL-...)
73
+ // among them; of the records naming each other as aliases, directly or
74
+ // through others, a version is reported under one only: RustSec's, else
75
+ // the GHSA, else the first by id. Each has the others' ids among its
76
+ // aliases.
66
77
  // `repository`: published on the package's repository, one row per
67
78
  // range, holding only the versions the others' answer does not report
68
79
  // under that GHSA. The only source for `soldeer` and `github`, where
@@ -81,6 +92,8 @@ export interface Advisory {
81
92
  // (https://osv.dev/vulnerability/RUSTSEC-...). Built, never fetched: only
82
93
  // a registry row without a GHSA (npm:<id>) has none.
83
94
  url?: string
95
+ // The advisory's other ids: an `osv` row's record's own and its group's,
96
+ // and with `osv`, a registry row's OSV records' of it.
84
97
  aliases: string[]
85
98
  title?: string
86
99
  // With `details`, the advisory's full text, Markdown as published: an
@@ -110,14 +123,18 @@ export interface Advisory {
110
123
  // merged, and must not name two repositories. npm is asked 250 names a
111
124
  // request, OSV 1000 versions, Soldeer one project, and with `details`, OSV
112
125
  // one GHSA among npm's rows; a project Soldeer does not have or that names
113
- // no GitHub repository, and a repository gone, renamed or blocked, add
114
- // nothing, and a GHSA OSV does not have adds no `details`. Any other
115
- // failure, a malformed answer, or one about something not asked, throws:
116
- // nothing is left out quietly.
126
+ // no GitHub repository, a repository gone or blocked, and one GitHub
127
+ // redirects anywhere but to a repository by its id, add nothing, and a
128
+ // GHSA OSV does not have adds no `details`. A repository renamed or
129
+ // transferred, which GitHub redirects to it by its id, is listed by the
130
+ // name it has now, as getRepo's `followRedirect` (github.js) finds it, its
131
+ // rows' `url` on it. Any other failure, a malformed answer, or one about
132
+ // something not asked, throws: nothing is left out quietly.
117
133
  // A repository's listing is kept for 90 minutes, as one entry for every
118
- // spelling of its name, holding only what rows are made from, `details`
119
- // included: in setCacheDir's cache gzipped, in a `cache` store as it is,
120
- // or with `cache` false, nowhere. 90 minutes is how late an advisory its
134
+ // spelling of the name it is asked by, one renamed or transferred too,
135
+ // holding only what rows are made from, `details` included: in
136
+ // setCacheDir's cache gzipped, in a `cache` store as it is, or with
137
+ // `cache` false, nowhere. 90 minutes is how late an advisory its
121
138
  // maintainer publishes can be seen. One gone or malformed is not kept.
122
139
  // Sorted by ecosystem and name. Versions are matched by npm's semver, from
123
140
  // the npm beside node, or by the semver peer where there is no npm.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.0.1",
4
- "description": "Minimal npm, crates.io and Soldeer registry clients, a GitHub client, and a git checkout reader: the calls we use, nothing more",
3
+ "version": "1.1.0",
4
+ "description": "Minimal npm, crates.io, Soldeer, GitHub and git checkout clients",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./advisories.js": {
@@ -1,9 +1,9 @@
1
1
  import assert from 'node:assert/strict'
2
2
 
3
- import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa, isRefName, isStrings } from '../args.js'
3
+ import { assertRepo, assertSoldeerName, assertSoldeerVersion, assertion, isGhsa, isRefName, isRepo, isStrings } from '../args.js'
4
4
  import { readRecord, writeRecord } from '../cache.js'
5
5
  import { isGone } from '../github/client.js'
6
- import { recover } from '../http.js'
6
+ import { HttpError, recover } from '../http.js'
7
7
  import { pool } from '../pool.js'
8
8
  import { isExactVersion } from '../semver.js'
9
9
  import { advisoryUrl, covered, detailsOf, isText, mayBeInRange, metrics } from './common.js'
@@ -21,7 +21,7 @@ const isRepoAdvisory = (advisory) => advisory && typeof advisory === 'object' &&
21
21
  && advisory.state === 'published' && isText(advisory.summary)
22
22
  && (advisory.vulnerabilities == null || Array.isArray(advisory.vulnerabilities)) && (advisory.cwe_ids == null || isStrings(advisory.cwe_ids))
23
23
 
24
- export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function')
24
+ export const assertClient = assertion('a GitHub client from createClient', (value) => typeof value?.listRepoAdvisories === 'function' && typeof value.getRepo === 'function')
25
25
 
26
26
  // A maintainer may give a vulnerable range only its lower bound, and the
27
27
  // version that fixed it apart (`>= 5.0.0-beta.1`, patched in `5.0.0-rc.2`),
@@ -67,18 +67,37 @@ const isDigest = (advisory) => advisory && typeof advisory === 'object' && isGhs
67
67
  && (advisory.description === undefined || isText(advisory.description))
68
68
  && Array.isArray(advisory.ranges) && advisory.ranges.every((entry) => typeof entry?.range === 'string')
69
69
 
70
- // A repository's listing, digested, through the cache: GitHub's names are
71
- // case-insensitive, so one entry answers every spelling. A repository gone,
72
- // renamed or blocked has none, and that is not kept.
70
+ // GitHub answers for a repository renamed or transferred with a redirect,
71
+ // which getRepo follows to the repository it is now, listed by that name.
72
+ const isRedirect = (err) => err instanceof HttpError && err.status >= 300 && err.status < 400
73
+
74
+ async function listingOf(github, repo) {
75
+ try {
76
+ return { repo, list: await github.listRepoAdvisories({ repo }) }
77
+ } catch (err) {
78
+ if (!isRedirect(err)) throw err
79
+ }
80
+ const moved = (await github.getRepo({ repo, followRedirect: true })).full_name
81
+ return { repo: moved, list: await github.listRepoAdvisories({ repo: moved }) }
82
+ }
83
+
84
+ // A repository's listing, digested, through the cache, and the repository
85
+ // it is of: GitHub's names are case-insensitive, so one entry answers every
86
+ // spelling, and one renamed or transferred is kept under the name asked,
87
+ // with `repo` the one it has now. A repository gone or blocked, or
88
+ // redirected anywhere getRepo does not follow, has none, and that is not
89
+ // kept.
73
90
  async function listAdvisories(github, repo, cache) {
74
91
  const name = repo.toLowerCase()
75
92
  const entry = await readRecord(DIR, name, cache, LISTING_TTL_MS)
76
- if (entry?.v === VERSION && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) return entry.advisories
77
- const list = await github.listRepoAdvisories({ repo }).catch(recover(isGone, null))
78
- if (list === null) return []
79
- const advisories = digest(repo, list)
80
- if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, advisories }, cache)
81
- return advisories
93
+ if (entry?.v === VERSION && (entry.repo === undefined || isRepo(entry.repo)) && Array.isArray(entry.advisories) && entry.advisories.every(isDigest)) {
94
+ return { repo: entry.repo ?? repo, advisories: entry.advisories }
95
+ }
96
+ const found = await listingOf(github, repo).catch(recover((err) => isGone(err) || isRedirect(err), null))
97
+ if (found === null) return { repo, advisories: [] }
98
+ const advisories = digest(found.repo, found.list)
99
+ if (advisories.every(isDigest)) await writeRecord(DIR, name, { v: VERSION, ...(found.repo !== repo && { repo: found.repo }), advisories }, cache)
100
+ return { repo: found.repo, advisories }
82
101
  }
83
102
 
84
103
  // GitHub's `>= 1.0.0, < 1.2.6` is npm's with the commas dropped.
@@ -109,10 +128,10 @@ function fromRepository(repo, name, advisory, range, asked, { covers, details })
109
128
  async function repositoryAdvisories(github, asked, { repoOf, takes = () => true, covers, details, cache }) {
110
129
  // GitHub's names are case-insensitive: one spelling asks for all.
111
130
  const namesOf = Map.groupBy([...asked.keys()].filter(repoOf), (name) => repoOf(name).toLowerCase())
112
- const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ repo: repoOf(names[0]), names, list: await listAdvisories(github, repoOf(names[0]), cache) }))
131
+ const listed = await pool([...namesOf.values()], REPOS_AT_ONCE, async (names) => ({ names, ...(await listAdvisories(github, repoOf(names[0]), cache)) }))
113
132
  const rows = new Map()
114
- for (const { repo, names, list } of listed) {
115
- for (const advisory of list) {
133
+ for (const { repo, names, advisories } of listed) {
134
+ for (const advisory of advisories) {
116
135
  for (const { range, ...pkg } of advisory.ranges) {
117
136
  for (const name of names.filter((candidate) => takes(candidate, pkg))) {
118
137
  rows.set(`${name} ${advisory.ghsa} ${range}`, fromRepository(repo, name, advisory, range, asked.get(name), { covers, details }))
@@ -35,7 +35,7 @@ function collect(packages) {
35
35
  }
36
36
 
37
37
  export async function advisories(packages, options = {}) {
38
- assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), cache: optional(assertCache) })
38
+ assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), osv: optional(assertBoolean), cache: optional(assertCache) })
39
39
  assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
40
40
  const byEcosystem = collect(packages)
41
41
  for (const ecosystem of byEcosystem.keys()) assert.ok(!ECOSYSTEMS[ecosystem].repositoryOnly || options.github, `advisories: ${ecosystem} packages need a github client`)
@@ -44,7 +44,7 @@ export async function advisories(packages, options = {}) {
44
44
  const names = [...named.keys()].toSorted()
45
45
  const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
46
46
  const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
47
- const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, known, cache: options.cache })
47
+ const rows = await find(asked, { github: options.github, repoAdvisories: options.repoAdvisories === true, details: options.details === true, osv: options.osv === true, known, cache: options.cache })
48
48
  return rows.map((row) => ({ ecosystem, ...row }))
49
49
  }))
50
50
  return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
@@ -6,7 +6,7 @@ import { chunks, pool } from '../pool.js'
6
6
  import { compareVersions } from '../semver.js'
7
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
+ import { RECORDS_AT_ONCE, getVuln, osvRows } from './osv.js'
10
10
  import { npmRepos } from './repos.js'
11
11
 
12
12
  const BULK_URL = buildUrl(NPM_REGISTRY, ['-', 'npm', 'v1', 'security', 'advisories', 'bulk'])
@@ -52,21 +52,48 @@ async function registryAdvisories(asked) {
52
52
 
53
53
  // The registry's rows carry no text: each GHSA's is OSV's record of it,
54
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.
55
+ // cover an asked version, but where `osv` already read it. One OSV does
56
+ // not have yet has none.
56
57
  async function withDetails(rows) {
57
- const ghsas = [...new Set(rows.filter((row) => row.ghsa && row.versions.length > 0).map((row) => row.ghsa))]
58
+ const asks = (row) => row.source === 'registry' && row.details === undefined
59
+ const ghsas = [...new Set(rows.filter((row) => asks(row) && row.ghsa && row.versions.length > 0).map((row) => row.ghsa))]
58
60
  const records = await pool(ghsas, RECORDS_AT_ONCE, (ghsa) => getVuln(ghsa).catch(recover(isNotFound, undefined)))
59
61
  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) }))
62
+ return rows.map((row) => (asks(row) ? { ...row, ...detailsOf(text.get(row.ghsa), row.ghsa) } : row))
61
63
  }
62
64
 
63
- // What `npm audit` asks the registry, one row per vulnerable range.
65
+ // With `osv`, OSV's records of the asked versions besides the registry's
66
+ // rows, the malicious packages' (MAL-...), which the registry has none
67
+ // of, among them. A version a registry row reports under the same
68
+ // advisory, the row's id one of an OSV row's ids or aliases, is left to
69
+ // that row, which takes the OSV row's ids among its aliases, and its text
70
+ // where it is the same record; an OSV row keeps the versions no such row
71
+ // reports.
72
+ function withOsv(registry, osv) {
73
+ const rows = registry.map((row) => ({ ...row }))
74
+ const added = []
75
+ for (const record of osv) {
76
+ const ids = [record.id, ...record.aliases]
77
+ const same = rows.filter((row) => row.name === record.name && ids.includes(row.id) && row.versions.some((version) => record.versions.includes(version)))
78
+ for (const row of same) {
79
+ row.aliases = [...new Set([...row.aliases, ...ids])].filter((id) => id !== row.id)
80
+ if (record.id === row.id && record.details !== undefined) row.details = record.details
81
+ }
82
+ const versions = record.versions.filter((version) => !same.some((row) => row.versions.includes(version)))
83
+ if (versions.length > 0) added.push({ ...record, versions })
84
+ }
85
+ return [...rows, ...added]
86
+ }
87
+
88
+ // What `npm audit` asks the registry, one row per vulnerable range, and
89
+ // with `osv`, what OSV has besides.
64
90
  export const NPM = {
65
91
  assertName: assertPackageName,
66
92
  assertVersion: assertPackageVersion,
67
93
  compare: compareVersions,
68
94
  advisories: async (asked, options) => {
69
- const rows = await registryAdvisories(asked)
95
+ const [registry, osv] = await Promise.all([registryAdvisories(asked), options.osv ? osvRows({ osv: 'npm' }, asked, options) : []])
96
+ const rows = withOsv(registry, osv)
70
97
  return await withRepositories(options.details ? await withDetails(rows) : rows, asked, options, { ecosystem: 'npm', lookUp: npmRepos })
71
98
  },
72
99
  }
@@ -84,9 +84,9 @@ function toAdvisory(ecosystem, name, versions, record, details) {
84
84
  // publishes comes back under each one's id, the records naming each other
85
85
  // as aliases, either way round, directly or through others: one group, in
86
86
  // which each version is reported under the first answered for it, and
87
- // each record has the others' ids as aliases.
88
- async function osvAdvisories(ecosystem, asked, options) {
89
- const { osv, github, lookUp, covers } = ecosystem
87
+ // each record has the others' ids as aliases. `osv` is OSV's name for the
88
+ // ecosystem.
89
+ export async function osvRows({ osv }, asked, { details }) {
90
90
  const list = [...asked].flatMap(([name, versions]) => versions.map((version) => ({ name, version })))
91
91
  const hits = new Map() // id -> name -> versions, in `list` order
92
92
  for (const chunk of chunks(list, QUERIES_PER_REQUEST)) {
@@ -118,9 +118,14 @@ async function osvAdvisories(ecosystem, asked, options) {
118
118
  const aliases = [...new Set([...record.aliases, ...group.map((other) => other.id)])].filter((id) => id !== record.id)
119
119
  for (const [name, versions] of hits.get(record.id)) {
120
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))
121
+ if (rest.length > 0) rows.push(toAdvisory(osv, name, rest, { ...record, aliases }, details))
122
122
  }
123
123
  }
124
124
  rows.sort((a, b) => order(a.name, b.name) || order(a.id, b.id))
125
- return await withRepositories(rows, asked, options, { ecosystem: github, lookUp, covers })
125
+ return rows
126
+ }
127
+
128
+ async function osvAdvisories(ecosystem, asked, options) {
129
+ const { github, lookUp, covers } = ecosystem
130
+ return await withRepositories(await osvRows(ecosystem, asked, options), asked, options, { ecosystem: github, lookUp, covers })
126
131
  }