@preventive/upstream 1.1.0 → 1.2.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
@@ -56,28 +56,37 @@ export interface AdvisoryOptions {
56
56
  // those of npm releases taken down that GitHub's database has no
57
57
  // advisory of. A version a registry row reports under the same advisory,
58
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.
59
+ // which takes the record's ids among its aliases; the versions a record
60
+ // of the row's own id covers beyond the registry's range join it too.
61
+ // Sends each npm package's name and versions to OSV, so not for a
62
+ // private package.
61
63
  osv?: boolean
62
64
  }
63
65
 
64
66
  // One advisory on one package, with the versions asked about that it
65
- // covers (never empty).
67
+ // covers (never empty): one row an id, however many of its sources answer
68
+ // it, or however often one does.
66
69
  export interface Advisory {
67
70
  ecosystem: Ecosystem
68
71
  name: string
69
- // `registry`: npm's, as `npm audit` has it, one row per vulnerable
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.
77
- // `repository`: published on the package's repository, one row per
78
- // range, holding only the versions the others' answer does not report
79
- // under that GHSA. The only source for `soldeer` and `github`, where
80
- // every range counts, whichever package it names.
72
+ // `registry`: npm's, as `npm audit` has it. `osv`: OSV's records for
73
+ // cargo and composer, and with `osv` for npm, the versions they cover
74
+ // that no registry row reports under the same advisory; RustSec's,
75
+ // GitHub's and malicious packages' (MAL-...) among them; of the records
76
+ // naming each other as aliases, directly or through others, a version is
77
+ // reported under one only: RustSec's, else the GHSA, else the first by
78
+ // id. Each has the others' ids among its aliases, and stays a row of its
79
+ // own, as its source rates it.
80
+ // `repository`: published on the package's repository, holding only the
81
+ // versions the others' answer does not report under that GHSA. The only
82
+ // source for `soldeer` and `github`, where every range counts, whichever
83
+ // package it names.
84
+ // An id answered more than once -- by npm's registry once a range, by
85
+ // OSV's record of it or its repository for versions beyond the registry's
86
+ // range, by its repository once a range -- is one row: the registry's,
87
+ // else OSV's, else the repository's, with what it lacks of theirs (title,
88
+ // details), the worst severity of them, the highest CVSS score with its
89
+ // own vector, and all their ranges, CWEs and versions.
81
90
  source: 'registry' | 'osv' | 'repository'
82
91
  // A GHSA, RUSTSEC-..., MAL-..., DRUPAL-CORE-... or another OSV id, or
83
92
  // npm:<id> for a registry row without a GHSA.
@@ -109,11 +118,17 @@ export interface Advisory {
109
118
  cvssVector?: string
110
119
  cwe: string[]
111
120
  // As written: npm's syntax from the registry, GitHub's
112
- // (`>= 1.0.0, < 1.2.6`) from a repository; OSV rows have none. A
121
+ // (`>= 1.0.0, < 1.2.6`) from a repository; OSV's records have none. A
113
122
  // repository range semver cannot read, or none, covers every version.
123
+ // Each range its id was answered with that covers an asked version,
124
+ // joined by ` || `; none ('') where one is none. A version only OSV
125
+ // reports may be outside it: hermes-engine's GHSA-f5x2-xv93-4p23 is
126
+ // `<=0.4.3` on npm, the last version known affected, and fixed in 0.5.2
127
+ // by OSV's record of it, which reports 0.5.0 too.
114
128
  range?: string
115
129
  // RustSec's kind for an advisory that is not a vulnerability as such:
116
- // `unmaintained`, `unsound`, `notice`.
130
+ // `unmaintained`, `unsound`, `notice`. For an id answered more than once,
131
+ // only where each answer gives it.
117
132
  informational?: string
118
133
  versions: string[]
119
134
  }
package/github.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // encoded segments and has to come back out of URL parsing unchanged.
8
8
  // Redirects are refused, so an answer is about the repo asked for, except
9
9
  // the tarballs' and, where asked, getRepo's for a repo renamed or
10
- // transferred. A response is read up to a size limit (64 MiB of JSON, 128
10
+ // transferred. A response is read up to a size limit (128 MiB of JSON, 128
11
11
  // MiB of a file, 512 MiB of a tarball) and within a timeout (30 seconds, 5
12
12
  // minutes for a tarball), and what it says goes into an error message
13
13
  // escaped.
package/npm.d.ts CHANGED
@@ -127,7 +127,11 @@ export interface Dist {
127
127
  // `bugs` an object, of which only the strings named below are read. Each is
128
128
  // absent where the document has none, or has another shape, or an object
129
129
  // with none of those strings. All of it is the publisher's word, held to no
130
- // repository.
130
+ // repository. `unpackedSize` is the document's dist.unpackedSize: the bytes
131
+ // of the files in the tarball, as the registry counted them when the
132
+ // version was published, held to no tarball. It is absent where the
133
+ // document has none, as for a version published before the registry
134
+ // counted them, or has one that is not a whole number of bytes.
131
135
  export interface PackageMeta {
132
136
  name: string
133
137
  version: string
@@ -136,6 +140,7 @@ export interface PackageMeta {
136
140
  repository?: string | PackageRepository
137
141
  homepage?: string
138
142
  bugs?: string | PackageBugs
143
+ unpackedSize?: number
139
144
  }
140
145
 
141
146
  export interface PackageRepository {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/upstream",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Minimal npm, crates.io, Soldeer, GitHub and git checkout clients",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,6 +34,57 @@ function collect(packages) {
34
34
  return byEcosystem
35
35
  }
36
36
 
37
+ // Which of an advisory's rows stands for it: npm's registry's, then OSV's,
38
+ // then its repository's.
39
+ const SOURCES = ['registry', 'osv', 'repository']
40
+ const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info']
41
+ const worse = (a, b) => (b === undefined || (a !== undefined && SEVERITIES.indexOf(a) < SEVERITIES.indexOf(b)) ? a : b)
42
+
43
+ // An advisory's rows as one: the first's by source, what it lacks from the
44
+ // first of the rest that has it, the others' aliases, and of them all the
45
+ // worst severity, the highest CVSS score with its own vector (else the first
46
+ // vector), the ranges (` || `), CWEs and versions (in the order `asked` has
47
+ // them). RustSec's kind of advisory that is not a vulnerability as such only
48
+ // where each of them has that kind: a row without one is a vulnerability.
49
+ function joinRows(rows, asked) {
50
+ const [head, ...rest] = rows.toSorted((a, b) => SOURCES.indexOf(a.source) - SOURCES.indexOf(b.source))
51
+ const all = [head, ...rest]
52
+ const ranges = [...new Set(all.map((row) => row.range).filter((range) => range !== undefined))]
53
+ const versions = new Set(all.flatMap((row) => row.versions))
54
+ const joined = {
55
+ ...head,
56
+ aliases: [...new Set(all.flatMap((row) => row.aliases))].filter((id) => id !== head.id),
57
+ cwe: [...new Set(all.flatMap((row) => row.cwe))],
58
+ versions: asked.filter((version) => versions.has(version)),
59
+ }
60
+ if (rest.some((row) => row.informational !== head.informational)) delete joined.informational
61
+ for (const key of ['title', 'details']) {
62
+ const from = joined[key] === undefined && rest.find((row) => row[key] !== undefined)
63
+ if (from) joined[key] = from[key]
64
+ }
65
+ const severity = all.map((row) => row.severity).reduce(worse)
66
+ if (severity !== undefined) joined.severity = severity
67
+ const scored = all.filter((row) => row.cvss !== undefined).toSorted((a, b) => b.cvss - a.cvss)[0] ?? all.find((row) => row.cvssVector !== undefined)
68
+ delete joined.cvss
69
+ delete joined.cvssVector
70
+ if (scored?.cvss !== undefined) joined.cvss = scored.cvss
71
+ if (scored?.cvssVector !== undefined) joined.cvssVector = scored.cvssVector
72
+ // A range of none ('') covers every version, as they then do together.
73
+ if (ranges.length > 0) joined.range = ranges.includes('') ? '' : ranges.join(' || ')
74
+ return joined
75
+ }
76
+
77
+ // One row for each advisory id on a package, where its sources answer more
78
+ // than one: npm's registry one per range, and OSV's record of it, or its
79
+ // repository, another for the versions they cover beyond that. Records of
80
+ // other ids that name it as an alias stay rows of their own, each version
81
+ // reported under one, as their sources rate them. Rows keep the place of the
82
+ // first answered.
83
+ function oneRowPerAdvisory(rows, asked) {
84
+ const byId = Map.groupBy(rows, (row) => `${row.name}\0${row.id}`)
85
+ return [...byId.values()].map((same) => (same.length === 1 ? same[0] : joinRows(same, asked.get(same[0].name))))
86
+ }
87
+
37
88
  export async function advisories(packages, options = {}) {
38
89
  assertArgs('advisories', options, { github: optional(assertClient), repoAdvisories: optional(assertBoolean), details: optional(assertBoolean), osv: optional(assertBoolean), cache: optional(assertCache) })
39
90
  assert.ok(!options.repoAdvisories || options.github, 'advisories: repoAdvisories needs a github client')
@@ -45,7 +96,7 @@ export async function advisories(packages, options = {}) {
45
96
  const asked = new Map(names.map((name) => [name, [...named.get(name).versions].toSorted(compare)]))
46
97
  const known = new Map(names.filter((name) => named.get(name).github).map((name) => [name, named.get(name).github]))
47
98
  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
- return rows.map((row) => ({ ecosystem, ...row }))
99
+ return oneRowPerAdvisory(rows.filter((row) => row.versions.length > 0), asked).map((row) => ({ ecosystem, ...row }))
49
100
  }))
50
- return found.flat().filter((row) => row.versions.length > 0).toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
101
+ return found.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name))
51
102
  }
@@ -68,7 +68,7 @@ async function withDetails(rows) {
68
68
  // advisory, the row's id one of an OSV row's ids or aliases, is left to
69
69
  // that row, which takes the OSV row's ids among its aliases, and its text
70
70
  // where it is the same record; an OSV row keeps the versions no such row
71
- // reports.
71
+ // reports, which advisories() joins to a registry row of its own id.
72
72
  function withOsv(registry, osv) {
73
73
  const rows = registry.map((row) => ({ ...row }))
74
74
  const added = []
package/src/http.js CHANGED
@@ -26,7 +26,7 @@ const isQuery = ([key, value]) => isQueryKey(key) && (key.endsWith('[]') ? Array
26
26
  // A line break in a value would start a header of its own.
27
27
  const isHeader = ([name, value]) => /^[A-Za-z][\w-]*$/u.test(name) && matches(/^[ -~]*$/u)(value)
28
28
  const LIMITS = {
29
- json: { bytes: 64 * 1024 * 1024, ms: 30_000 },
29
+ json: { bytes: 128 * 1024 * 1024, ms: 30_000 },
30
30
  text: { bytes: 128 * 1024 * 1024, ms: 30_000 },
31
31
  bytes: { bytes: MAX_BYTES, ms: 300_000 },
32
32
  }
@@ -73,24 +73,28 @@ async function npmCachedVersion(name, version, given) {
73
73
  return undefined
74
74
  }
75
75
 
76
+ const countOr = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : undefined)
77
+
76
78
  // The version's document: with the `dist` the caller has, npm's own cache's
77
79
  // where it matches (npmCachedVersion), and neither `cache` nor the cache
78
80
  // set is read or written; else as getVersion has it. Beside its dist, the
79
81
  // document's gitHead where that is a full commit id, and its repository,
80
- // homepage and bugs, in the shapes a package.json gives them. Each but
81
- // dist is left out where there is none.
82
+ // homepage and bugs, in the shapes a package.json gives them; and its
83
+ // dist's unpackedSize where that is a count. Each but dist is left out
84
+ // where there is none.
82
85
  export async function getMeta(name, version, options = {}) {
83
86
  assertPackage('getMeta', name, version)
84
87
  assertArgs('getMeta', options, { dist: null, cache: optional(assertCache) })
85
88
  const given = options.dist === undefined ? undefined : checkedDist('getMeta', name, version, options.dist)
86
89
  const { dist, json } = (given && await npmCachedVersion(name, version, given)) ?? await getVersion('getMeta', name, version, given, options.cache)
87
- const about = {
90
+ const said = {
88
91
  gitHead: isSha(own(json, 'gitHead')) ? json.gitHead : undefined,
89
92
  repository: linkOf(own(json, 'repository'), ['type', 'url', 'directory']),
90
93
  homepage: stringOr(own(json, 'homepage')),
91
94
  bugs: linkOf(own(json, 'bugs'), ['url', 'email']),
95
+ unpackedSize: countOr(own(json.dist, 'unpackedSize')),
92
96
  }
93
- return { name, version, dist, ...Object.fromEntries(Object.entries(about).filter(([, value]) => value !== undefined)) }
97
+ return { name, version, dist, ...Object.fromEntries(Object.entries(said).filter(([, value]) => value !== undefined)) }
94
98
  }
95
99
 
96
100
  // Only the whole package's document has `time`: megabytes for some.