@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.44

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.
Files changed (75) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/managed/report-filter.ts +2 -1
  6. package/common/managed/routes.js +3 -3
  7. package/common/security-links.d.ts +5 -0
  8. package/common/security-links.js +33 -0
  9. package/common/server-info.ts +8 -0
  10. package/common/workspace-app.js +14 -1
  11. package/out/chunk-2ABLKCOY.js +46 -0
  12. package/out/chunk-5LSXOLUS.js +1 -0
  13. package/out/chunk-5RCM2J63.js +1 -0
  14. package/out/chunk-7B6HB4D7.js +1 -0
  15. package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
  16. package/out/chunk-CH4UN6WK.js +225 -0
  17. package/out/chunk-EDKLBQQN.js +17 -0
  18. package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
  19. package/out/chunk-FK3DZEKL.js +1 -0
  20. package/out/chunk-G6AJWENO.js +1 -0
  21. package/out/chunk-GZWM7UQM.js +2 -0
  22. package/out/chunk-JCIIBLKR.js +1 -0
  23. package/out/chunk-LPCTJYNA.js +7 -0
  24. package/out/{chunk-DGVQPIYB.js → chunk-O4RJY5TP.js} +1 -1
  25. package/out/chunk-OD4LPN3H.js +1 -0
  26. package/out/{chunk-D7AF2JXL.js → chunk-QWDWTBM7.js} +1 -1
  27. package/out/chunk-QYWKC557.js +1 -0
  28. package/out/chunk-R75XOKUP.js +1 -0
  29. package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
  30. package/out/chunk-T7OKH4VW.js +1 -0
  31. package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
  32. package/out/chunk-ZESLQCTF.js +6 -0
  33. package/out/client-managed-import.js +1 -1
  34. package/out/client-managed.js +21 -21
  35. package/out/client-sync.js +1 -1
  36. package/out/graph.js +1 -1
  37. package/out/index.html +14 -10
  38. package/out/terminal.js +1 -1
  39. package/out/view.css +1 -1
  40. package/out/view.js +58 -58
  41. package/package.json +13 -3
  42. package/risk/index.d.ts +26 -0
  43. package/risk/index.js +1 -1
  44. package/risk/npm/assess.js +66 -15
  45. package/risk/npm/dependencies.js +22 -3
  46. package/risk/npm/new-dependency.js +42 -12
  47. package/risk/npm/registry.js +122 -21
  48. package/server-managed/comparison.ts +41 -0
  49. package/server-managed/http.ts +161 -56
  50. package/server-managed/locked-packages.ts +29 -6
  51. package/server-managed/npm-kept.ts +38 -14
  52. package/server-managed/npm-packages.ts +27 -13
  53. package/server-managed/npm-risk.ts +69 -11
  54. package/server-managed/pretty-print.ts +14 -9
  55. package/server-managed/public-workspace.ts +26 -8
  56. package/server-managed/queue.ts +23 -0
  57. package/server-managed/report-sources.ts +1 -1
  58. package/server-managed/repository-risk-assess.ts +105 -0
  59. package/server-managed/repository-risk-audit.ts +66 -0
  60. package/server-managed/repository-risk.ts +265 -209
  61. package/server-managed/scan-downloads.ts +87 -0
  62. package/server-managed/sql.ts +1 -1
  63. package/server-managed/team-app.ts +38 -14
  64. package/server-managed/team-reports.ts +6 -11
  65. package/server-managed/upstream-cache.ts +16 -0
  66. package/out/chunk-CLPKNNC7.js +0 -51
  67. package/out/chunk-Q7ZSIEHP.js +0 -1
  68. package/out/chunk-RFZQQXSG.js +0 -17
  69. package/out/chunk-VQJCXGIY.js +0 -7
  70. package/out/chunk-WOTMRDM3.js +0 -226
  71. package/out/chunk-WZDTZE64.js +0 -1
  72. package/out/chunk-YP2537WX.js +0 -1
  73. package/out/chunk-YQRVLRDH.js +0 -1
  74. package/out/chunk-YVZH5SHP.js +0 -1
  75. package/out/chunk-ZBBOBQ43.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.42",
3
+ "version": "1.0.0-alpha.44",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -108,6 +108,7 @@
108
108
  "server-managed/cache-storage.ts",
109
109
  "server-managed/cli.js",
110
110
  "server-managed/combined.ts",
111
+ "server-managed/comparison.ts",
111
112
  "server-managed/comments.ts",
112
113
  "server-managed/config.ts",
113
114
  "server-managed/content-access.ts",
@@ -152,6 +153,7 @@
152
153
  "server-managed/rename-diff.ts",
153
154
  "server-managed/rename-diff-worker.js",
154
155
  "server-managed/public-workspace.ts",
156
+ "server-managed/queue.ts",
155
157
  "server-managed/repo-path.ts",
156
158
  "server-managed/report-migration.ts",
157
159
  "server-managed/report-query.ts",
@@ -164,6 +166,9 @@
164
166
  "server-managed/repository-discovery.ts",
165
167
  "server-managed/repository-policy.ts",
166
168
  "server-managed/repository-risk.ts",
169
+ "server-managed/repository-risk-assess.ts",
170
+ "server-managed/repository-risk-audit.ts",
171
+ "server-managed/scan-downloads.ts",
167
172
  "server-managed/session.ts",
168
173
  "server-managed/slugs.ts",
169
174
  "server-managed/solidity-entry-points.ts",
@@ -202,6 +207,9 @@
202
207
  "common/bundle-commit.d.ts",
203
208
  "common/bundle-commit.js",
204
209
  "common/bundle-compare-diff.js",
210
+ "common/bundle-compare-versions.js",
211
+ "common/bundle-comparison.d.ts",
212
+ "common/bundle-comparison.js",
205
213
  "common/bundle-compare-diff.d.ts",
206
214
  "common/bundle-compare-inputs.d.ts",
207
215
  "common/bundle-compare-inputs.js",
@@ -255,6 +263,8 @@
255
263
  "common/revalidation-input-groups.js",
256
264
  "common/server-info.ts",
257
265
  "common/scan-server.ts",
266
+ "common/security-links.d.ts",
267
+ "common/security-links.js",
258
268
  "common/utf8.d.ts",
259
269
  "common/utf8.js",
260
270
  "common/workspace-app.js",
@@ -346,9 +356,9 @@
346
356
  "dependencies": {
347
357
  "@exodus/stasis": "1.0.0-beta.9",
348
358
  "@exodus/stasis-core": "1.0.0-beta.9",
349
- "@preventive/compare": "^1.2.3",
359
+ "@preventive/compare": "^1.2.10",
350
360
  "@preventive/diff": "^1.0.2",
351
- "@preventive/lockfile": "1.0.0",
361
+ "@preventive/lockfile": "1.1.1",
352
362
  "@preventive/publishers": "^1.1.0",
353
363
  "@preventive/report": "1.0.0-alpha.9",
354
364
  "@preventive/sourcemap": "1.0.0-alpha.1",
package/risk/index.d.ts CHANGED
@@ -114,6 +114,13 @@ export interface DependencyRisk {
114
114
  uncounted?: true
115
115
  created?: string
116
116
  gap?: number
117
+ // For one nothing imports, the version its spec installs (registry.js
118
+ // npmPublicFacts): its package's latest beside it, when it was published,
119
+ // whether it has install scripts, and whether that was since the version
120
+ // compared with (`new`).
121
+ installs?: { version: string; latest: string | null; published: string | null; scripts: boolean; new: boolean }
122
+ // Where its package was looked up and npm has none of it for anyone.
123
+ missing?: true
117
124
  }
118
125
 
119
126
  // A version's risk as one number (risk/npm/score.js): what its findings call
@@ -147,6 +154,25 @@ export interface NpmAssessment {
147
154
 
148
155
  export function assessNpm(name: string, version: string, options?: AssessNpmOptions): Promise<NpmAssessment>
149
156
 
157
+ // Readies, at once, the downloads assessNpm asks of each of these packages
158
+ // that can be, kept in `cache` as it keeps them, so that their assessments
159
+ // ask npm none: those kept within a week, and unscoped ones, asked of npm 128
160
+ // at a time. `ready` is told each once its downloads are kept; the rest (scoped
161
+ // ones, which npm answers only one by one) are left for npmReadyDownload.
162
+ // npm asking to be asked later is thrown as an NpmRiskError
163
+ // (`upstream-busy`), those readied by then told.
164
+ // A cache that reads and writes many at once too (`readMany`, `writeMany`)
165
+ // is read once and written once a lookup.
166
+ export function npmReadyDownloads(names: string[], ready: (name: string) => void, options: { cache: CacheStore & {
167
+ readMany?(type: string, keys: string[]): Promise<Map<string, unknown>>
168
+ writeMany?(type: string, entries: [key: string, value: unknown][]): Promise<void>
169
+ }; signal?: AbortSignal; now?: number }): Promise<void>
170
+
171
+ // Readies the downloads assessNpm asks of a package, asked of npm on their
172
+ // own where they aren't kept; npm asking to be asked later thrown as an
173
+ // NpmRiskError (`upstream-busy`).
174
+ export function npmReadyDownload(name: string, options: { cache: CacheStore; signal?: AbortSignal; now?: number }): Promise<void>
175
+
150
176
  // Whether an advisory is of malware rather than a vulnerability: OSV's of a
151
177
  // malicious package (`MAL-…`), or one such a record names (its `aliases`),
152
178
  // CWE-506, embedded malicious code, whatever its title, or a title naming
package/risk/index.js CHANGED
@@ -3,5 +3,5 @@
3
3
  export { assessNpm } from './npm/assess.js'
4
4
  export { npmMalwareAdvisory } from './npm/malware.js'
5
5
  export { DEFAULT_MINIMUM_RELEASE_AGE } from './npm/release.js'
6
- export { NpmRiskError } from './npm/registry.js'
6
+ export { NpmRiskError, npmReadyDownload, npmReadyDownloads } from './npm/registry.js'
7
7
  export { SEVERITIES, npmFileRisk } from './files/stamps.js'
@@ -6,19 +6,20 @@ import { npmBaseFilesTell, npmCompareFindings } from './compare.js'
6
6
  import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
7
7
  import { npmHeldFindings, npmVersionFindings } from './held.js'
8
8
  import { npmVersionFileRisk } from './files.js'
9
- import { npmDependencyChanges, npmFlagDependencyChanges } from './dependencies.js'
9
+ import { npmDependencyChanges, npmFlagDependencyChanges, npmUnusedDependencyCounted } from './dependencies.js'
10
10
  import { npmDownloadMonths, npmDownloadTier, npmSettledDownloads, npmTierLevel } from './downloads.js'
11
11
  import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
12
12
  import { npmMalwareFinding } from './malware.js'
13
13
  import { npmKnownScope } from './manifest.js'
14
14
  import { DEFAULT_MINIMUM_RELEASE_AGE, npmAccountStanding } from './release.js'
15
- import { npmAddedDependency } from './new-dependency.js'
15
+ import { npmAddedDependency, npmUnusedDependencySeverity } from './new-dependency.js'
16
+ import { SEVERITIES } from '../files/stamps.js'
16
17
  import { npmRiskScore } from './score.js'
17
18
 
18
19
  // Which reading of a version an assessment kept (`risk/npm/assessments`) is:
19
20
  // one more whenever a heuristic changes what it finds, so one kept by an
20
21
  // older reading is read anew.
21
- const RISK_VERSION = 16
22
+ const RISK_VERSION = 17
22
23
 
23
24
  // How long an assessment kept may be as JSON, and what risk reads of either
24
25
  // version's files: a package past it, whose files name thousands of hosts or
@@ -123,26 +124,33 @@ const MAX_TOLD_DEPENDENCIES = 100
123
124
  // with what risk tells of its package
124
125
  // (new-dependency.js npmAddedDependency): its tier, its last whole month's
125
126
  // downloads by what its tier goes by where it has one (`month`, `{ total,
126
- // from }`), `uncounted` where npm counts none of them, and `created` and `gap`
127
+ // from }`), `uncounted` where npm counts none of them, `created` and `gap`
127
128
  // where it is new since the version compared with (`published`, when that
128
- // and the open version were); four asked at a time, whether each is the
129
+ // and the open version were), and for one nothing imports (flagged so, or
130
+ // among `unusedKeys`) that risk counts (dependencies.js
131
+ // npmUnusedDependencyCounted), the version its spec installs (`installs`,
132
+ // registry.js npmPublicFacts); `missing` where
133
+ // its package was looked up and npm has none of it for anyone, as once
134
+ // unpublished or taken down; four asked at a time, whether each is the
129
135
  // caller's own among them, none more once one fails or MAX_TOLD_DEPENDENCIES
130
136
  // not the caller's own were taken, however many a version adds.
131
- async function addedDependencies(added, published, context, owned) {
137
+ async function addedDependencies(added, published, context, owned, unusedKeys = new Set()) {
132
138
  const told = [...added]
133
139
  const asked = told.flatMap((row, at) => row.opens ? [at] : [])
134
140
  let next = 0, taken = 0
135
141
  const tell = async row => {
136
142
  const downloads = await npmDownloads(row.opens, context)
137
- let facts = npmAddedDependency({ downloads, published }, context.now)
143
+ const unused = (row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))) && npmUnusedDependencyCounted(row)
144
+ let facts = npmAddedDependency({ downloads, published, unused }, context.now), missing = false
138
145
  if (facts.wanted) {
139
- const listed = await npmPublicFacts(row.opens, context)
140
- facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published }, context.now)
146
+ const listed = await npmPublicFacts(row.opens, context, unused ? row.range : null)
147
+ missing = listed === null
148
+ facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published, unused }, context.now)
141
149
  }
142
150
  const settled = npmSettledDownloads(downloads, context.now)
143
151
  const month = npmDownloadMonths(settled).at(-1)
144
152
  return { ...row, ...facts.tier === null ? {} : { ...tierOf(facts.tier), ...settled ? month && { month: { total: month.total, from: month.from.toISOString().slice(0, 10) } } : { uncounted: true } },
145
- ...facts.created ? { created: facts.created, gap: facts.gap } : {} }
153
+ ...facts.created ? { created: facts.created, gap: facts.gap } : {}, ...facts.installs ? { installs: facts.installs } : {}, ...missing ? { missing } : {} }
146
154
  }
147
155
  await Promise.all(Array.from({ length: 4 }, async () => {
148
156
  while (next < asked.length && taken < MAX_TOLD_DEPENDENCIES) {
@@ -201,21 +209,64 @@ export async function assessNpm(name, version, { oldVersion = null, fromEmpty =
201
209
  const own = npmVersionFindings({ deprecated: facts.deprecated[version], published, minimumReleaseAge, now, known: Boolean(manifest.knownAccounts), scope: npmKnownScope(name),
202
210
  provenance: Boolean(manifest.provenance), downloads: counted })
203
211
  const was = old === null ? null : facts.versions.get(old).published
204
- const added = downloads && dependencies ? await addedDependencies(kept.dependencies.added, [was, published], context, owned) : kept.dependencies.added
205
- const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added)
212
+ // Those it holds that nothing imports by another spec than the version
213
+ // compared with's, told by their new spec as an added one nothing imports
214
+ // is (`respecified`), and an added one nothing imports however it was
215
+ // flagged, as one moved from another kind of dependency is.
216
+ const unusedKeys = unusedKeysOf(kept)
217
+ const respecified = kept.dependencies.changed.filter(row => unusedKeys.has(keyOf(row))).map(row => ({ ...row, range: row.to }))
218
+ const told = downloads && dependencies ? await addedDependencies([...kept.dependencies.added, ...respecified], [was, published], context, owned, unusedKeys)
219
+ : [...kept.dependencies.added, ...respecified]
220
+ const added = told.slice(0, kept.dependencies.added.length)
221
+ const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added, told.slice(added.length))
206
222
  return { name, version, oldVersion: old, published, ...assessment, score: npmRiskScore(assessment.findings, { published, now, tier: counted ? npmDownloadTier(counted) : null }) }
207
223
  }
208
224
 
225
+ // A dependency's key among a version's, by its kind and name.
226
+ const keyOf = row => `${row.kind ?? ''}\0${row.name}`
227
+
228
+ // The keys of the dependencies a version holds that nothing of it imports
229
+ // (`kept`, npmLastingRisk's: its `held-unused-deps`), those risk counts
230
+ // (dependencies.js npmUnusedDependencyCounted).
231
+ const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).filter(npmUnusedDependencyCounted).map(keyOf))
232
+
233
+ // The dependencies nothing of a version imports (`held-unused-deps`), each
234
+ // graded by what risk tells of its package fresh (`told`, as
235
+ // addedDependencies tells it; new-dependency.js
236
+ // npmUnusedDependencySeverity), with its tier, when its package was first
237
+ // published and the version its spec installs where told: kept from the
238
+ // version compared with where there was one (`compared`) and it named it as
239
+ // the same kind of dependency by the same spec: not one marked new, nor
240
+ // `added` (its kind and name new, as one moved from another kind is), nor
241
+ // `changed` (each said with the spec it had, `was`); one risk doesn't count
242
+ // (dependencies.js npmUnusedDependencyCounted) a notice, told all the same.
243
+ // The finding as its most severe.
244
+ function unusedGraded(finding, told, compared, { added, changed }) {
245
+ const facts = new Map(told.map(row => [keyOf(row), row])), was = new Map(changed.map(row => [keyOf(row), row.from]))
246
+ const anew = new Set(added.map(keyOf))
247
+ const dependencies = finding.dependencies.map(row => {
248
+ const of = facts.get(keyOf(row))
249
+ return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencyCounted(row)
250
+ ? npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))) : 'notice',
251
+ ...of?.level ? { tier: of.tier, level: of.level } : {}, ...of?.created ? { created: of.created, gap: of.gap } : {}, ...of?.installs ? { installs: of.installs } : {},
252
+ ...of?.missing ? { missing: true } : {} }
253
+ })
254
+ return { ...finding, severity: SEVERITIES[Math.min(...dependencies.map(row => SEVERITIES.indexOf(row.severity)))], dependencies }
255
+ }
256
+
209
257
  // An assessment of what of it holds whatever the time (`kept`,
210
258
  // npmLastingRisk's) and what is told fresh: the open version's own findings
211
259
  // (`own`, held.js npmVersionFindings, and its malware), its malware and
212
260
  // deprecation first and the rest last, its tier as JSON holds it (tierOf),
213
- // and its dependencies `added`, as addedDependencies tells them. The managed
261
+ // and its dependencies `added`, as addedDependencies tells them, those it
262
+ // holds by another spec that nothing imports beside them (`respecified`),
263
+ // for its unused dependencies' grades. The managed
214
264
  // server's (assessNpm), and a test's of versions it holds.
215
265
  const FIRST = new Set(['malware', 'deprecated'])
216
- export function npmAssessmentOf(kept, own, added = kept.dependencies.added) {
266
+ export function npmAssessmentOf(kept, own, added = kept.dependencies.added, respecified = []) {
217
267
  return {
218
- findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings,
268
+ findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings.map(finding => finding.id === 'held-unused-deps'
269
+ ? unusedGraded(finding, [...added, ...respecified], Boolean(kept.dependencies.held), kept.dependencies) : finding),
219
270
  ...own.filter(({ id }) => !FIRST.has(id)).map(finding => finding.id === 'popularity' ? { ...finding, ...tierOf(finding.tier) } : finding)],
220
271
  files: kept.files,
221
272
  dependencies: { ...kept.dependencies, added },
@@ -108,15 +108,34 @@ const typesStubOf = ({ json, npm }, name, spec) => npm?.name === typesOf(name) &
108
108
  // (importsOf); never where its files aren't read. Not a peer dependency,
109
109
  // which the package expects whoever installs it to provide, nor types from
110
110
  // DefinitelyTyped (`@types/`), imported by another name, where no one else
111
- // publishes; nor the package a stub of DefinitelyTyped's types of it
112
- // depends on (typesStubOf), with no code to import it.
111
+ // publishes; nor tslib, TypeScript's helpers, which a package compiled
112
+ // with them inlined names all the same, as a third of popular packages
113
+ // naming a dependency nothing imports do; each where npm installs that
114
+ // package from its registry (installsAs), not another under its name
115
+ // (`"tslib": "npm:other@1"`, a repository); nor the package a stub of
116
+ // DefinitelyTyped's types of it depends on (typesStubOf), with no code to
117
+ // import it.
118
+ const installsAs = (name, spec) => npmRegistrySpec(spec) ? npmAlias(spec)?.name ?? name : null
113
119
  export function npmDependencyUnimported(details, { name, kind, range, to }) {
114
- if (!details?.json || kind === 'peer' || name.startsWith('@types/') || typesStubOf(details, name, to ?? range)) return false
120
+ const installs = installsAs(name, to ?? range)
121
+ if (!details?.json || kind === 'peer' || (name.startsWith('@types/') && installs?.startsWith('@types/')) || (name === 'tslib' && installs === 'tslib')
122
+ || typesStubOf(details, name, to ?? range)) return false
115
123
  const { whole, parts, commands } = importsOf(details)
116
124
  return !whole.has(name) && ![...name.slice(1).matchAll(/[-/]/gu)].some(({ index }) => parts.has(name.slice(0, index + 2)))
117
125
  && !commands.some(command => commandNames(command, name))
118
126
  }
119
127
 
128
+ // Whether a dependency nothing of a version imports (`{ name, range }`,
129
+ // npmDependencyUnimported) counts toward its risk (assess.js): not lodash,
130
+ // which packages keep naming long after their code stopped importing it, nor
131
+ // Babel's own presets (`@babel/preset-*`), which a config names by a string
132
+ // (an ESLint config's parser options); each where npm installs that package
133
+ // from its registry (installsAs), not another under its name.
134
+ export function npmUnusedDependencyCounted({ name, range }) {
135
+ const installs = installsAs(name, range)
136
+ return !(name === 'lodash' && installs === 'lodash') && !(name.startsWith('@babel/preset-') && installs?.startsWith('@babel/preset-'))
137
+ }
138
+
120
139
  // Where a source takes a dependency from, in a sentence.
121
140
  const SOURCE_NAMES = new Map([['URL', 'a URL'], ['git', 'a git repository']])
122
141
 
@@ -10,21 +10,51 @@ import { npmDownloadTier, npmSettledDownloads, npmTierLow } from './downloads.js
10
10
  // A dependency added, from its package's facts so far: `downloads`, npm's
11
11
  // count of them (null where npm counts none, as it counts none of a package's
12
12
  // until the day after it was first published, nor any of a private one's);
13
- // `listed`, once its version list is read, `{ created, private }`, when its
14
- // first version was published and whether only a token reads it; and
15
- // `published`, when the version compared with and the open one were. Its
16
- // `tier` (Infinity, unpopular, where npm counts none of a public package's;
17
- // null where it can't yet be told), whether its version list is `wanted` to
18
- // tell more (below the top 20,000, or none counted), and `created`, `gap`
19
- // where its package is new since the version compared with: when its first
20
- // version was published, and how long before the open version that was
21
- // (negative for after, as a name someone took once a release named it).
22
- export function npmAddedDependency({ downloads, listed = null, published = [] }, now = Date.now()) {
13
+ // `listed`, once its version list is read, `{ created, private, installs? }`,
14
+ // when its first version was published, whether only a token reads it, and
15
+ // the version of it its spec installs (registry.js npmPublicFacts); and
16
+ // `published`, when the version compared with and the open one were; and
17
+ // whether nothing of the version imports it (`unused`). Its `tier`
18
+ // (Infinity, unpopular, where npm counts none of a public package's; null
19
+ // where it can't yet be told), whether its version list is `wanted` to tell
20
+ // more (below the top 20,000, or none counted, or unused whatever its tier,
21
+ // as how new its package is grades it, npmUnusedDependencySeverity),
22
+ // `created`, `gap` where its package is new since the version compared
23
+ // with: when its first version was published, and how long before the open
24
+ // version that was (negative for after, as a name someone took once a
25
+ // release named it); and where told, the version its spec `installs`,
26
+ // `new` where that was published since the version compared with.
27
+ export function npmAddedDependency({ downloads, listed = null, published = [], unused = false }, now = Date.now()) {
23
28
  const settled = npmSettledDownloads(downloads, now)
24
29
  const counted = settled ? npmDownloadTier(settled) : null
25
- const wanted = !settled || (counted !== null && npmTierLow(counted))
30
+ const wanted = !settled || unused || (counted !== null && npmTierLow(counted))
26
31
  const tier = settled ? counted : listed && !listed.private ? Infinity : null
27
32
  const [was, open] = published
28
33
  const created = wanted && tier !== null && listed?.created && was && open && Date.parse(listed.created) > Date.parse(was) ? listed.created : null
29
- return { tier, wanted, created, gap: created ? Date.parse(open) - Date.parse(created) : null }
34
+ const installs = listed?.installs ? { ...listed.installs, new: Boolean(was && listed.installs.published && Date.parse(listed.installs.published) > Date.parse(was)) } : null
35
+ return { tier, wanted, created, gap: created ? Date.parse(open) - Date.parse(created) : null, ...installs ? { installs } : {} }
36
+ }
37
+
38
+ // What a dependency nothing of a version imports calls for (compare.js
39
+ // `unused-deps`), by what risk tells of its package (`told`, its row as
40
+ // assess.js addedDependencies tells it, or none): one kept from the version
41
+ // compared with (`kept`) yellow, as that version, risk's pick of the most
42
+ // likely free of an attack, held it; else red where its package is new
43
+ // since that version (`created`), as plain-crypto-js was to axios 1.14.1,
44
+ // or the version its spec installs (`installs`) is, has install scripts and
45
+ // isn't its package's latest, as an old package's old major line given a
46
+ // release to run them would, where a package still releasing its latest
47
+ // wouldn't; orange where that version has install scripts, as installing an unused
48
+ // dependency runs them and nothing else, or npm has none of its package
49
+ // for anyone (`missing`), as once unpublished or taken down, whatever
50
+ // downloads it still counts; yellow among npm's top 20,000, as
51
+ // a dependency named for another's peer or by a tool's config is (react,
52
+ // typescript, eslint), older and more downloaded than the package naming
53
+ // it; orange past them, or where its package isn't told.
54
+ export function npmUnusedDependencySeverity(told, kept) {
55
+ if (kept) return 'moderate'
56
+ const { scripts, new: anew, version, latest } = told?.installs ?? {}
57
+ if (told?.created || (scripts && anew && version !== latest)) return 'critical'
58
+ if (scripts || told?.missing) return 'high'
59
+ return told?.level && !npmTierLow(told.tier ?? Infinity) ? 'moderate' : 'high'
30
60
  }
@@ -33,7 +33,9 @@
33
33
  // tarball unpacking past tarball.js's, `package-too-large`.
34
34
  import { advisories } from '@preventive/upstream/advisories.js'
35
35
  import { getTarball } from '@preventive/upstream/npm.js'
36
+ import { compareVersions, satisfies, validRange } from '@preventive/upstream/semver.js'
36
37
  import { npmVersionManifest } from './manifest.js'
38
+ import { npmAlias } from './specs.js'
37
39
  import { npmPlaceholder } from './malware.js'
38
40
  import { npmOldVersion } from './old-version.js'
39
41
  import { npmTarballVersion } from './tarball.js'
@@ -50,6 +52,14 @@ const ADVISORIES_MS = 60 * 60_000
50
52
  // months' downloads a day.
51
53
  const PACKUMENT_BYTES = 64 * 1024 * 1024
52
54
  const DOWNLOADS_BYTES = 1024 * 1024
55
+ // How long a package's downloads are kept: a week, as the tier they put it in,
56
+ // by the most of its latest six weeks' (downloads.js npmDownloadTier), hardly
57
+ // moves in one, and npm's downloads API answers only some 40 lookups a minute.
58
+ const DOWNLOADS_MS = 7 * DAY_MS
59
+ // How many packages npm's downloads API answers in one lookup, unscoped ones
60
+ // alone (it refuses a scoped one in a bulk lookup), and how much of it is read.
61
+ const BULK_DOWNLOADS = 128
62
+ const BULK_DOWNLOADS_BYTES = 8 * 1024 * 1024
53
63
  // How long npm is waited on for an answer, as the managed server waits on its
54
64
  // upstreams (server-managed/npm-packages.ts askUpstream).
55
65
  const REQUEST_MS = 30_000
@@ -59,6 +69,9 @@ export class NpmRiskError extends Error {
59
69
  }
60
70
 
61
71
  const isTime = value => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
72
+ // An object as JSON holds one, not an array (manifest.js plainObject).
73
+ const plainObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
74
+
62
75
  // A scoped name in a downloads API path, its slash escaped.
63
76
  const encoded = name => name.replace('/', '%2f')
64
77
  const token = () => typeof process === 'undefined' ? null
@@ -245,13 +258,42 @@ export async function npmVersionFiles(name, version, { manifest, published, dist
245
258
  }
246
259
  }
247
260
 
261
+ // The version of a package's document npm installs for a dependency's spec
262
+ // (`spec`, an alias's by its own range, `npm:name@^1`, as specs.js npmAlias
263
+ // reads one), as npm picks one:
264
+ // the tag it names, else its `latest` where that satisfies it, or any range
265
+ // does (`*`, a prerelease latest too), and isn't deprecated, else the highest
266
+ // that satisfies it, one not deprecated first; null for none, or for a spec
267
+ // that is no range nor tag (a URL, a repository).
268
+ function installedVersion(doc, spec) {
269
+ const alias = npmAlias(spec)
270
+ const range = (alias ? alias.spec : String(spec)).trim()
271
+ const tags = plainObject(doc['dist-tags']) ? doc['dist-tags'] : {}, versions = plainObject(doc.versions) ? doc.versions : {}
272
+ if (Object.hasOwn(tags, range) && Object.hasOwn(versions, tags[range])) return tags[range]
273
+ if (validRange(range || '*') === null) return null
274
+ if (Object.hasOwn(versions, tags.latest) && typeof versions[tags.latest]?.deprecated !== 'string' && ((range || '*') === '*' || satisfies(tags.latest, range))) return tags.latest
275
+ const matching = Object.keys(versions).filter(version => satisfies(version, range || '*'))
276
+ const current = matching.filter(version => typeof versions[version]?.deprecated !== 'string')
277
+ return (current.length > 0 ? current : matching).reduce((best, version) => best === null || compareVersions(version, best) > 0 ? version : best, null)
278
+ }
279
+
248
280
  // When a package anyone can read was first published, `{ created }`, or null
249
- // for one npm has none of for anyone.
250
- export async function npmPublicFacts(name, context) {
251
- const facts = await kept(context, 'risk/npm/public', name,
252
- async () => { const doc = await packageDocument(name, context, false); return doc ? { at: context.now, created: firstPublished(doc) } : { at: context.now, missing: true } },
253
- hit => context.now - hit.at < PACKAGE_MS)
254
- return facts.missing ? null : { created: facts.created }
281
+ // for one npm has none of for anyone; and for a dependency's spec (`spec`),
282
+ // the version of it npm installs (installedVersion), `installs`: `{ version,
283
+ // latest, published, scripts }`, its package's `latest` beside it, when it
284
+ // was published and whether it has install scripts: its manifest's, or
285
+ // node-gyp's for a `binding.gyp` (`gypfile`), or as the registry says
286
+ // (`hasInstallScript`); null for none.
287
+ export async function npmPublicFacts(name, context, spec = null) {
288
+ const facts = await kept(context, 'risk/npm/public', spec === null ? name : `${name}@${spec}`, async () => {
289
+ const doc = await packageDocument(name, context, false)
290
+ if (!doc) return { at: context.now, missing: true }
291
+ const version = spec === null ? null : installedVersion(doc, spec)
292
+ return { at: context.now, created: firstPublished(doc), ...spec === null ? {} : { installs: version === null ? null
293
+ : { version, latest: typeof doc['dist-tags']?.latest === 'string' ? doc['dist-tags'].latest : null, published: isTime(doc.time?.[version]) ? doc.time[version] : null,
294
+ scripts: Boolean(npmVersionManifest(doc.versions[version]).installScripts) || doc.versions[version]?.gypfile === true || doc.versions[version]?.hasInstallScript === true } } }
295
+ }, hit => context.now - hit.at < PACKAGE_MS)
296
+ return facts.missing ? null : { created: facts.created, ...spec === null ? {} : { installs: facts.installs } }
255
297
  }
256
298
 
257
299
  // The days a package's downloads are asked over at `now`: from the first of
@@ -262,21 +304,80 @@ function rangeAt(now) {
262
304
  return `${day(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 2, 1))}:${day(now - DAY_MS)}`
263
305
  }
264
306
 
265
- // A package's downloads a day over rangeAt's days, `{ start, end, days }`,
266
- // null where npm counts none.
307
+ // A package's downloads as kept, from npm's answer of them over a range of
308
+ // days `asked` then: `{ at, start, end, days }`, a count a day from
309
+ // `start`, or `{ at, none }` where npm counts none (null).
310
+ function downloadsOf(json, asked) {
311
+ if (json === null) return { at: asked, none: true }
312
+ const start = Date.parse(json.start)
313
+ const length = Math.round((Date.parse(json.end) - start) / DAY_MS) + 1
314
+ const days = Array.from({ length: Number.isSafeInteger(length) && length > 0 && length <= 400 ? length : 0 }, () => 0)
315
+ for (const row of json.downloads ?? []) {
316
+ const at = Math.round((Date.parse(row.day) - start) / DAY_MS)
317
+ if (at >= 0 && at < days.length && Number.isSafeInteger(row.downloads)) days[at] = row.downloads
318
+ }
319
+ return { at: asked, start: json.start, end: json.end, days }
320
+ }
321
+ // Whether downloads kept are told as they are: kept within DOWNLOADS_MS.
322
+ const freshDownloads = (held, now) => held != null && now - held.at < DOWNLOADS_MS
323
+
324
+ // A package's downloads a day over rangeAt's days when asked, `{ start, end,
325
+ // days }`, kept a week (DOWNLOADS_MS), null where npm counts none.
267
326
  export async function npmDownloads(name, context) {
268
- const range = rangeAt(context.now)
269
- const answer = await kept(context, 'risk/npm/downloads', `${name}@${range}`, async () => {
270
- const json = await askNpm(`${DOWNLOADS}/${range}/${encoded(name)}`, {}, DOWNLOADS_BYTES, `${name}'s downloads`, context)
271
- if (json === null) return { none: true }
272
- const start = Date.parse(json.start)
273
- const length = Math.round((Date.parse(json.end) - start) / DAY_MS) + 1
274
- const days = Array.from({ length: Number.isSafeInteger(length) && length > 0 && length <= 400 ? length : 0 }, () => 0)
275
- for (const row of json.downloads ?? []) {
276
- const at = Math.round((Date.parse(row.day) - start) / DAY_MS)
277
- if (at >= 0 && at < days.length && Number.isSafeInteger(row.downloads)) days[at] = row.downloads
278
- }
279
- return { start: json.start, end: json.end, days }
280
- })
327
+ const answer = await kept(context, 'risk/npm/downloads', name,
328
+ async () => downloadsOf(await askNpm(`${DOWNLOADS}/${rangeAt(context.now)}/${encoded(name)}`, {}, DOWNLOADS_BYTES, `${name}'s downloads`, context), context.now),
329
+ hit => freshDownloads(hit, context.now))
281
330
  return answer.none ? null : answer
282
331
  }
332
+
333
+ // Readies, at once, the downloads assessNpm asks of each of these packages
334
+ // (`names`) that can be, kept in `cache` as npmDownloads keeps them, so that
335
+ // their assessments ask npm none: those kept within a week (DOWNLOADS_MS), as
336
+ // they are, and unscoped ones, asked of npm BULK_DOWNLOADS at a time, as its downloads API
337
+ // answers them; `ready` is told each once its downloads are kept. The rest,
338
+ // scoped ones (it answers those only one by one) and any a bulk lookup
339
+ // didn't answer, are left for npmReadyDownload, one at a time. npm asking to
340
+ // be asked later is thrown (`upstream-busy`), as is any other failure, those
341
+ // readied by then told.
342
+ export async function npmReadyDownloads(names, ready, { cache, signal, now = Date.now() }) {
343
+ const context = { cache, signal, now }
344
+ const range = rangeAt(now)
345
+ const unique = [...new Set(names)]
346
+ const unscoped = []
347
+ const ask = async part => {
348
+ const json = await askNpm(`${DOWNLOADS}/${range}/${part.join(',')}`, {}, BULK_DOWNLOADS_BYTES, `${part.length} packages' downloads`, context)
349
+ const answered = part.filter(name => json && Object.hasOwn(json, name))
350
+ await keepMany(cache, answered.map(name => [name, downloadsOf(json[name], now)]))
351
+ for (const name of answered) ready(name)
352
+ }
353
+ // Those kept read BULK_DOWNLOADS at a time, the rest asked as they fill a
354
+ // lookup.
355
+ for (let at = 0; at < unique.length; at += BULK_DOWNLOADS) {
356
+ const part = unique.slice(at, at + BULK_DOWNLOADS)
357
+ const held = await keptMany(cache, part)
358
+ for (const name of part) {
359
+ if (freshDownloads(held.get(name), now)) ready(name)
360
+ else if (!name.startsWith('@')) unscoped.push(name)
361
+ }
362
+ while (unscoped.length >= BULK_DOWNLOADS) await ask(unscoped.splice(0, BULK_DOWNLOADS))
363
+ }
364
+ // A lookup of one is answered as that package's own, not by its name.
365
+ if (unscoped.length > 1) await ask(unscoped)
366
+ }
367
+
368
+ // Downloads kept under each of `keys`, by key, and many kept at once: in one
369
+ // read and one write where the cache reads and writes many at once
370
+ // (`readMany`, `writeMany`), else a key at a time.
371
+ const keptMany = async (cache, keys) => cache.readMany ? await cache.readMany('risk/npm/downloads', keys)
372
+ : new Map(await Promise.all(keys.map(async key => [key, await cache.read('risk/npm/downloads', key)])))
373
+ const keepMany = async (cache, entries) => {
374
+ if (cache.writeMany) await cache.writeMany('risk/npm/downloads', entries)
375
+ else for (const [key, value] of entries) await cache.write('risk/npm/downloads', key, value)
376
+ }
377
+
378
+ // Readies the downloads assessNpm asks of a package, asked of npm on their
379
+ // own where they aren't kept (npmDownloads), as npmReadyDownloads leaves a
380
+ // scoped one's: npm asking to be asked later thrown (`upstream-busy`).
381
+ export async function npmReadyDownload(name, { cache, signal, now = Date.now() }) {
382
+ await npmDownloads(name, { cache, signal, now })
383
+ }
@@ -0,0 +1,41 @@
1
+ // Compare's comparison of two managed bundles or two versions of an npm
2
+ // package (GET /api/bundles/:id/comparison?other=, GET /api/npm/comparison):
3
+ // their files paired, at one path or renamed, with the sizes, resolutions,
4
+ // dependency versions and line counts its Overview and Code read beside them,
5
+ // as common/bundle-comparison.js prepareComparison works them out. The viewer
6
+ // of a managed bundle or a package has every comparison worked out here and
7
+ // loads none of the pairing itself. Answered as Brotli-encoded JSON, its
8
+ // `files.basePaths` (a Map) as `[path, as laid out]` pairs where the two
9
+ // sides' roots differ and none else, as the viewer reads none then. Access is
10
+ // the caller's to check.
11
+ import { Buffer } from 'node:buffer'
12
+ import { Readable } from 'node:stream'
13
+ import { prepareComparison } from '../common/bundle-comparison.js'
14
+ import type { BundleDetails } from '../common/bundle-metadata.js'
15
+ import type { OpenedBlob } from './blob-store.ts'
16
+ import { encodeBrotli } from './brotli.ts'
17
+ import { type NpmPackageFile, npmFileRows } from './npm-packages.ts'
18
+
19
+ // A side with no files, as Review compares a package's first version with:
20
+ // one for each comparison, as working one out keeps its line counts on it.
21
+ export const emptySide = (): BundleDetails => ({ integrity: '(empty)', kind: 'sourcemap', size: 0, json: { version: 3, sources: [], sourcesContent: [] } })
22
+
23
+ // A version's files as the viewer reads them for a comparison (ui/view/npm-package.js
24
+ // npmPackageDetails): its texts, and those that are not text by their size and digest.
25
+ export function npmComparedSide(integrity: string, files: NpmPackageFile[]): BundleDetails & { npmBinaries: Map<string, { size: number; digest: string }> } {
26
+ const rows = npmFileRows(files)
27
+ return {
28
+ integrity, kind: 'sourcemap', size: 0,
29
+ json: { version: 3, sources: rows.map(([path]) => path), sourcesContent: rows.map(([, , text]) => text) },
30
+ npmBinaries: new Map(rows.flatMap(([path, size, text, digest]) => (text === null ? [[path, { size, digest: digest! }] as const] : []))),
31
+ }
32
+ }
33
+
34
+ // The comparison of `base` with `other`, within a reason `scope` where given, as sent.
35
+ export async function comparisonBody(base: BundleDetails, other: BundleDetails, scope = ''): Promise<OpenedBlob> {
36
+ const comparison = prepareComparison(base, other, scope)
37
+ const { files } = comparison
38
+ const json = JSON.stringify({ ...comparison, files: { ...files, basePaths: files.root ? [...files.basePaths] : [] } })
39
+ const body = await encodeBrotli(Buffer.from(json))
40
+ return { size: body.byteLength, stream: Readable.from([body]) }
41
+ }