@preventive/triage 1.0.0-alpha.41 → 1.0.0-alpha.43

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 (42) hide show
  1. package/common/managed/report-filter.ts +2 -1
  2. package/common/security-links.d.ts +5 -0
  3. package/common/security-links.js +33 -0
  4. package/out/{chunk-TY4RZD3Z.js → chunk-7RU5EDRO.js} +61 -61
  5. package/out/{chunk-XL35ZV6R.js → chunk-A7M4GV7M.js} +1 -1
  6. package/out/{chunk-2AFQKRAM.js → chunk-CLPKNNC7.js} +7 -7
  7. package/out/{chunk-UZAZM2SX.js → chunk-LAAB5SZT.js} +1 -1
  8. package/out/{chunk-KVEUXIEU.js → chunk-NWZ4PVPN.js} +1 -1
  9. package/out/{chunk-QZ3A376W.js → chunk-OSIMGFUF.js} +1 -1
  10. package/out/{chunk-U6OPEXIA.js → chunk-XOCAPDCM.js} +1 -1
  11. package/out/client-managed.js +19 -19
  12. package/out/index.html +3 -3
  13. package/out/view.js +54 -54
  14. package/package.json +9 -3
  15. package/risk/files/runs-code.js +13 -5
  16. package/risk/index.d.ts +26 -0
  17. package/risk/index.js +1 -1
  18. package/risk/npm/assess.js +60 -14
  19. package/risk/npm/combined.js +4 -1
  20. package/risk/npm/compare.js +10 -5
  21. package/risk/npm/dependencies.js +11 -3
  22. package/risk/npm/diff.js +42 -6
  23. package/risk/npm/held.js +5 -2
  24. package/risk/npm/new-dependency.js +42 -12
  25. package/risk/npm/registry.js +122 -21
  26. package/server-managed/http.ts +63 -26
  27. package/server-managed/locked-packages.ts +18 -6
  28. package/server-managed/npm-kept.ts +38 -14
  29. package/server-managed/npm-packages.ts +27 -13
  30. package/server-managed/npm-risk.ts +69 -11
  31. package/server-managed/pretty-print.ts +1 -1
  32. package/server-managed/queue.ts +23 -0
  33. package/server-managed/report-sources.ts +1 -1
  34. package/server-managed/repository-risk-assess.ts +105 -0
  35. package/server-managed/repository-risk-audit.ts +66 -0
  36. package/server-managed/repository-risk.ts +255 -201
  37. package/server-managed/scan-downloads.ts +87 -0
  38. package/server-managed/sql.ts +1 -1
  39. package/server-managed/team-reports.ts +6 -11
  40. package/server-managed/type-only-worker.js +3 -3
  41. package/server-managed/type-only.ts +4 -2
  42. package/server-managed/upstream-cache.ts +16 -0
@@ -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
+ }
@@ -89,7 +89,7 @@ import { mergeLinkGroups } from './link-reports.ts'
89
89
  import { backfillFindingIds, reportEntries } from '@preventive/report'
90
90
  import type { IncomingMessage, ServerResponse } from 'node:http'
91
91
  import { pipeline } from 'node:stream/promises'
92
- import { npmVersionStore, riskLineStore } from './npm-kept.ts'
92
+ import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
93
93
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
94
94
  import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
95
95
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
@@ -128,7 +128,7 @@ import { createClient } from '@preventive/upstream/github.js'
128
128
  import type { ConnectedRepo } from './github-app.ts'
129
129
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
130
130
  import { RepositoryDiscovery } from './repository-discovery.ts'
131
- import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
131
+ import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
132
132
  import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
133
133
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
134
134
  import { clearCookie, endSession, readSession } from './session.ts'
@@ -151,14 +151,14 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
151
151
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
152
152
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
153
153
  import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
154
- import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
154
+ import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
155
155
  import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
156
156
  import { computeBundleDiff } from '../common/bundle-compare-diff.js'
157
157
  import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
158
158
  import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
159
159
  import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
160
160
  import { npmProvenance } from './npm-provenance.ts'
161
- import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
161
+ import { npmReadyScanDownload, npmReadyScanDownloads, npmRisk, npmRiskRemoved } from './npm-risk.ts'
162
162
  import { NpmRiskError } from '../risk/index.js'
163
163
  import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
164
164
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
@@ -1351,6 +1351,10 @@ async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: Open
1351
1351
  // go idle, unread or with no line to send, before the scan is stopped.
1352
1352
  const REPO_RISK_RECHECK_MS = 10_000
1353
1353
  const REPO_RISK_IDLE_MS = 5 * 60_000
1354
+ // A scan's part on a serverless deployment, within its function's 300s
1355
+ // (vercel.managed.json): past it the scan stops, its last line `continue`,
1356
+ // and the page asks again, from where it stopped (repository-risk.ts).
1357
+ const REPO_RISK_PART_MS = 270_000
1354
1358
 
1355
1359
  // The repositories a scan of risk reads, by its query: every connected one;
1356
1360
  // a team's (`team`, its id), each in the team's directories of it; or one
@@ -1379,6 +1383,14 @@ async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<
1379
1383
  })
1380
1384
  }
1381
1385
 
1386
+ // Where a scan in parts keeps its progress (repository-risk.ts
1387
+ // ScanProgress) under `key`, for a day; none where the page gave no scan id.
1388
+ function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
1389
+ if (scan === null || !/^[\w-]{1,64}$/u.test(scan)) return undefined
1390
+ const kept = scanReadStore<ScanProgress>(deps.db, 'progress', SCAN_READ_KEPT_MS, deps.config.debug)
1391
+ return { read: () => kept.read(key), write: (progress: ScanProgress) => kept.write(key, progress, Date.now()) }
1392
+ }
1393
+
1382
1394
  // GET /api/admin/repositories/risk: every connected repository's npm
1383
1395
  // packages, or a team's or one repository's (riskRepositories), as its
1384
1396
  // lockfiles lock them at its default branch's head, each assessed by risk
@@ -1404,8 +1416,19 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1404
1416
  res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
1405
1417
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
1406
1418
  const scopes = new Map<string, Promise<boolean>>()
1419
+ const teamScoped = (name: string) => {
1420
+ const scope = npmPackageScope(name)
1421
+ if (scope === null) return Promise.resolve(false)
1422
+ if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
1423
+ return scopes.get(scope)!
1424
+ }
1407
1425
  const versions = npmVersionStore(deps.db, deps.config.debug)
1426
+ const stores = { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug }
1408
1427
  const lines = repositoryRiskLines({
1428
+ // Which repositories a scan in parts told whole, by the admin, the id the
1429
+ // page gives the scan (`scan`) and its scope, for the next part to go on
1430
+ // past them.
1431
+ progress: scanProgress(deps, `${s.user.id}\0${query.get('scan')}\0${query.get('team') ?? ''}\0${query.get('repo') ?? ''}`, query.get('scan')),
1409
1432
  repos: () => Promise.resolve(repos),
1410
1433
  async open(repo) {
1411
1434
  const reader = await browser.reader(repo)
@@ -1418,21 +1441,32 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1418
1441
  recheck: async () => { await reader.recheckAccess() },
1419
1442
  }
1420
1443
  },
1421
- teamScoped(name) {
1422
- const scope = npmPackageScope(name)
1423
- if (scope === null) return Promise.resolve(false)
1424
- if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
1425
- return scopes.get(scope)!
1444
+ teamScoped,
1445
+ // Each package's downloads readied before it is assessed
1446
+ // (scan-downloads.ts), so that none waits on npm's downloads API; a
1447
+ // team's own not asked, as risk asks none of theirs.
1448
+ downloads: {
1449
+ async atOnce(names, ready, signal) {
1450
+ const asked: string[] = []
1451
+ for (const name of names) if (await teamScoped(name)) ready(name); else asked.push(name)
1452
+ await npmReadyScanDownloads(asked, ready, stores, signal)
1453
+ },
1454
+ one: (name, signal) => npmReadyScanDownload(name, stores, signal),
1426
1455
  },
1427
1456
  // Public versions' documents kept in the database (npm-kept.ts), as the
1428
1457
  // viewer keeps them: whether npm still has one, its whole document tells
1429
1458
  // risk.
1430
1459
  version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
1431
- assess: (doc, teamScoped, signal) => npmRisk(doc, 'pick', { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
1432
- teamScoped, privileged: true, signal, spare: true }),
1433
- removed: (name, version, teamScoped, signal) => npmRiskRemoved(name, version, { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug,
1434
- teamScoped, privileged: true, signal }),
1435
- risks: riskLineStore(deps.db, RISK_KEPT_MS, deps.config.debug),
1460
+ assess: (doc, ownScope, signal) => npmRisk(doc, 'pick', { ...stores, teamScoped: ownScope, privileged: true, signal, spare: true }),
1461
+ removed: (name, version, ownScope, signal) => npmRiskRemoved(name, version, { ...stores, teamScoped: ownScope, privileged: true, signal }),
1462
+ // Kept for good, a scan telling one past RISK_KEPT_MS at once, as stale.
1463
+ risks: riskLineStore(deps.db, Infinity, deps.config.debug),
1464
+ // What it read of GitHub kept a day, for a scan answered in parts to go
1465
+ // on from where the last stopped, on whichever instance answers the next.
1466
+ listings: scanReadStore(deps.db, 'listings', SCAN_READ_KEPT_MS, deps.config.debug),
1467
+ lockfiles: scanReadStore(deps.db, 'lockfiles', SCAN_READ_KEPT_MS, deps.config.debug),
1468
+ // And each lockfile's advisories, for as long as a scan tells them.
1469
+ audits: scanReadStore(deps.db, 'advisories', RISK_KEPT_MS, deps.config.debug),
1436
1470
  // As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
1437
1471
  // and, for packages anyone may read, OSV: no repository's. Each lockfile's
1438
1472
  // audit is bounded on its own, for one npm doesn't answer to fail alone and
@@ -1444,7 +1478,7 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
1444
1478
  if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
1445
1479
  return audited.body
1446
1480
  },
1447
- }, controller.signal)
1481
+ }, controller.signal, undefined, { partMs: deps.config.serverless ? REPO_RISK_PART_MS : null })
1448
1482
  let checked = Date.now()
1449
1483
  await sendLines(res, lines, async () => {
1450
1484
  if (deps.isShuttingDown()) return false
@@ -2614,12 +2648,15 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
2614
2648
  sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
2615
2649
  }
2616
2650
 
2617
- // Who reads npm packages now: a session with workspace access, its role and
2618
- // the npm scopes of its visible teams. Null for none.
2619
- async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined): Promise<NpmReader | null> {
2651
+ // Who reads npm packages now: a session with workspace access, its role and,
2652
+ // where they count for any of `names` it is asked about (npmScopesCount), the
2653
+ // npm scopes of its visible teams, else none: none are read for an unscoped
2654
+ // package, an admin or a manager, or an answer checked again that is public.
2655
+ // Null for none.
2656
+ async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
2620
2657
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
2621
2658
  if (s == null || !roleAtLeast(s.user.role, 'view')) return null
2622
- return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
2659
+ return { role: s.user.role, scopes: new Set(npmScopesCount(s.user.role, names) ? await deps.db.listUserNpmScopes(s.user.id) : []), userId: s.user.id }
2623
2660
  }
2624
2661
 
2625
2662
  // What a registry read found, or a 404 for the package (handleNpm's catch
@@ -2636,7 +2673,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
2636
2673
  const onClose = () => { if (!res.writableEnded) controller.abort() }
2637
2674
  res.on('close', onClose)
2638
2675
  try {
2639
- const reader = await npmReader(deps, cookie)
2676
+ const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
2640
2677
  if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2641
2678
  const documents = new Map<string, Promise<NpmVersionDocument | null>>()
2642
2679
  // Public versions' documents as the viewer keeps them (npm-kept.ts), a
@@ -2650,7 +2687,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
2650
2687
  const packages = await Promise.all(reviews.map(review => Promise.all([
2651
2688
  read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
2652
2689
  ])))
2653
- const current = await npmReader(deps, cookie)
2690
+ const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
2654
2691
  if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
2655
2692
  return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
2656
2693
  } catch (error) {
@@ -2760,7 +2797,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2760
2797
  const onClose = () => { if (!res.writableEnded) controller.abort() }
2761
2798
  res.on('close', onClose)
2762
2799
  try {
2763
- const reader = await npmReader(deps, cookie)
2800
+ const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
2764
2801
  if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2765
2802
  const kept = npmVersionStore(deps.db, deps.config.debug)
2766
2803
  const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
@@ -2791,7 +2828,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
2791
2828
  }
2792
2829
  const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
2793
2830
  ? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
2794
- const current = await npmReader(deps, cookie)
2831
+ const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
2795
2832
  if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2796
2833
  if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
2797
2834
  if (!change) {
@@ -2834,7 +2871,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2834
2871
  }
2835
2872
  const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
2836
2873
  if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
2837
- const reader = await npmReader(deps, cookie)
2874
+ const reader = await npmReader(deps, cookie, [name])
2838
2875
  if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2839
2876
  const controller = new AbortController()
2840
2877
  const onClose = () => { if (!res.writableEnded) controller.abort() }
@@ -2842,7 +2879,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2842
2879
  // A reader who lost their session, or their private access to an answer
2843
2880
  // that needed it, while the registry was asked gets nothing.
2844
2881
  const recheck = async (isPrivate: boolean) => {
2845
- const current = await npmReader(deps, cookie)
2882
+ const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
2846
2883
  if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
2847
2884
  if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
2848
2885
  return !res.destroyed
@@ -2963,7 +3000,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
2963
3000
  if (!(await recheck(isPrivate))) return
2964
3001
  res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
2965
3002
  await sendLines(res, lines, async () => {
2966
- const current = await npmReader(deps, cookie)
3003
+ const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
2967
3004
  return current != null && (!isPrivate || canReadPrivateNpm(current, name))
2968
3005
  })
2969
3006
  return
@@ -1,7 +1,8 @@
1
1
  // The packages a lockfile of npm's ecosystem locks, as @preventive/lockfile
2
2
  // reads package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
3
3
  // pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, each with where it
4
- // is installed from (repository-risk.ts).
4
+ // is installed from and whether only devDependencies need it
5
+ // (repository-risk.ts).
5
6
  import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
6
7
  import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
7
8
  import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
@@ -21,6 +22,11 @@ export interface LockedPackage {
21
22
  // A mirror's tarball's, as the lockfile has it: npm's package of its name
22
23
  // and version only where npm's integrity is among it.
23
24
  integrity?: string
25
+ // Where only the importers' devDependencies reach it, as npm's `dev` flag
26
+ // says, and an install of no devDependencies leaves it out: in npm's and
27
+ // pnpm's lockfiles. Yarn 1's says nothing of it, which only the
28
+ // package.json files do, and none of its are.
29
+ dev?: true
24
30
  }
25
31
 
26
32
  // A package as each reader resolves one, its name the one it is published
@@ -29,7 +35,7 @@ type Resolution =
29
35
  | { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; gitHosted?: boolean }
30
36
  | { type: 'git'; repo: string }
31
37
  | { type: 'directory' }
32
- interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined }
38
+ interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined }
33
39
 
34
40
  // The packages of a lockfile of `format`, as its reader resolves them.
35
41
  // Versions go unchecked against ranges, as only what is locked is asked for.
@@ -72,14 +78,20 @@ function locked({ name, version, resolution }: ReadPackage): LockedPackage | nul
72
78
  }
73
79
 
74
80
  // The packages a lockfile of `format` (npm, pnpm or yarn, as
75
- // findRepoLockfiles names them) locks, each once, in the lockfile's order.
76
- // Throws the reader's LockfileError or YamlError for one it refuses.
81
+ // findRepoLockfiles names them) locks, each once, in the lockfile's order:
82
+ // `dev` only where it is wherever it is installed, at each path of npm's
83
+ // tree, and with each of pnpm's peers. Throws the reader's LockfileError or
84
+ // YamlError for one it refuses.
77
85
  export function lockedPackages(format: LockfileFormat, text: string): LockedPackage[] {
78
86
  const found = new Map<string, LockedPackage>()
79
87
  for (const pkg of packagesOf(format, text)) {
80
88
  const one = locked(pkg)
81
- const key = one && `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
82
- if (one && key && !found.has(key)) found.set(key, one)
89
+ if (!one) continue
90
+ if (pkg.dev === true) one.dev = true
91
+ const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
92
+ const had = found.get(key)
93
+ if (!had) found.set(key, one)
94
+ else if (had.dev && !one.dev) delete had.dev
83
95
  }
84
96
  return [...found.values()]
85
97
  }
@@ -5,9 +5,10 @@
5
5
  // (npm-manifest.ts npmVersionFields), for a day (readNpmVersion asks npm again
6
6
  // then, as a version can be deprecated, its package made private, or itself
7
7
  // removed); and, for scans, each package's risk line, for as long as a scan
8
- // keeps it (repository-risk.ts). Each store removes its own rows kept longer
9
- // than that as it writes, at most once an hour a database, so that rows no
10
- // read will take again don't pile up.
8
+ // keeps it, and what a scan read of GitHub, for a day, and of npm's
9
+ // advisories, for an hour (repository-risk.ts). Each store removes its own rows kept longer
10
+ // than that as it writes, at most once an hour a database (pruneKept), so
11
+ // that rows no read will take again don't pile up.
11
12
  // The database is an optimization: a failure reads as a miss, and writes
12
13
  // nothing.
13
14
  import { createHash } from 'node:crypto'
@@ -18,30 +19,45 @@ import type { UpstreamCacheStore } from './upstream-cache.ts'
18
19
  // A value kept by key, as JSON, `at` when it was.
19
20
  export interface KeptStore<T> {
20
21
  read(key: string): Promise<{ at: number; value: T } | null>
22
+ // Those of `keys` kept, by key, in one read of the database.
23
+ readMany(keys: string[]): Promise<Map<string, { at: number; value: T }>>
21
24
  write(key: string, value: T, at: number): Promise<void>
22
25
  }
23
26
 
24
- // When each database last had each type's old rows removed.
27
+ // When each database last had the rows under each prefix removed.
25
28
  const PRUNE_EVERY_MS = 60 * 60_000
26
29
  const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
27
30
 
31
+ // Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
32
+ // hour a database, as rows under it are written: here, and risk's downloads
33
+ // (npm-risk.ts). A failure is `warn`ed of, and removes nothing.
34
+ export async function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void) {
35
+ const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
36
+ if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
37
+ times.set(prefix, at)
38
+ try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
39
+ }
40
+
28
41
  // Values of `type`, each read for `keptMs` after it was kept.
29
42
  function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debug: boolean): KeptStore<T> {
30
43
  const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: kept ${type} ${what} failed:`, error) }
31
- const prune = async (at: number) => {
32
- const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
33
- if (at - (times.get(type) ?? -Infinity) < PRUNE_EVERY_MS) return
34
- times.set(type, at)
35
- try { await db.deleteUpstreamCacheEntries(`${type}/`, at - keptMs) } catch (error) { warn('prune', error) }
36
- }
44
+ // Kept for good (Infinity): none removed.
45
+ const prune = (at: number) => keptMs === Infinity ? Promise.resolve() : pruneKept(db, `${type}/`, keptMs, at, error => { warn('prune', error) })
37
46
  // Keys of any length or character, as the database's: hashed.
38
47
  const keyOf = (key: string) => `${type}/${createHash('sha256').update(key).digest('hex')}`
48
+ const valueOf = (row: string | null | undefined) => {
49
+ const kept: unknown = JSON.parse(row ?? 'null')
50
+ return plainObject(kept) && typeof kept['at'] === 'number' && 'value' in kept ? { at: kept['at'], value: kept['value'] as T } : null
51
+ }
39
52
  return {
40
53
  async read(key) {
54
+ try { return valueOf(await db.getUpstreamCacheEntry(keyOf(key))) } catch (error) { warn('read', error); return null }
55
+ },
56
+ async readMany(keys) {
41
57
  try {
42
- const kept: unknown = JSON.parse(await db.getUpstreamCacheEntry(keyOf(key)) ?? 'null')
43
- return plainObject(kept) && typeof kept['at'] === 'number' && 'value' in kept ? { at: kept['at'], value: kept['value'] as T } : null
44
- } catch (error) { warn('read', error); return null }
58
+ const rows = await db.getUpstreamCacheEntries(keys.map(keyOf))
59
+ return new Map(keys.flatMap(key => { const kept = valueOf(rows.get(keyOf(key))); return kept ? [[key, kept] as const] : [] }))
60
+ } catch (error) { warn('read', error); return new Map() }
45
61
  },
46
62
  async write(key, value, at) {
47
63
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
@@ -61,5 +77,13 @@ export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersi
61
77
  }
62
78
  }
63
79
 
64
- // Risk lines, each read for `keptMs`, as long as a scan keeps them.
80
+ // Risk lines, each kept `keptMs`, for good where Infinity, a scan telling one
81
+ // past RISK_KEPT_MS as stale (repository-risk.ts).
65
82
  export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
83
+ // What a scan read, each read for keptMs (repository-risk.ts): of GitHub, a
84
+ // repository's lockfiles as listed at a commit (`listings`) and a lockfile's
85
+ // packages by its blob (`lockfiles`), for SCAN_READ_KEPT_MS; of npm, a
86
+ // lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS; and the
87
+ // repositories a scan in parts told whole (`progress`).
88
+ export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles' | 'advisories' | 'progress', keptMs: number, debug = false): KeptStore<T> =>
89
+ keptStore<T>(db, `repository-risk/${type}`, keptMs, debug)
@@ -43,7 +43,7 @@ export class NpmPackageError extends Error {
43
43
  }
44
44
 
45
45
  // Who reads, as the session has it now: the role, and the scopes of the
46
- // visible teams they are a member of.
46
+ // visible teams they are a member of, where those count (npmScopesCount).
47
47
  export interface NpmReader { role: Role; scopes: ReadonlySet<string>; userId: string }
48
48
 
49
49
  // Whether a reader may have a package read with the server's token.
@@ -53,8 +53,19 @@ export function canReadPrivateNpm(reader: NpmReader, name: string): boolean {
53
53
  return scope !== null && reader.scopes.has(scope)
54
54
  }
55
55
 
56
+ // Whether a reader's team scopes count for any of `names`, as
57
+ // canReadPrivateNpm reads them: a scoped one, read by someone below manager.
58
+ export const npmScopesCount = (role: Role, names: readonly string[]): boolean =>
59
+ role !== 'admin' && role !== 'manage' && names.some(name => npmPackageScope(name) !== null)
60
+
56
61
  export const npmToken = () => process.env['NPM_TOKEN'] || null
57
62
 
63
+ // Whether a read of `name` that npm answers none anonymously is asked again
64
+ // with the server's token: by a reader with private access (`privileged`),
65
+ // where there is a token and the package is scoped, as npm's private packages
66
+ // always are. Else the anonymous answer is the reader's.
67
+ export const npmTokenReads = (name: string, privileged: boolean): boolean => privileged && npmToken() !== null && npmPackageScope(name) !== null
68
+
58
69
  function registryUrl(name: string, spec?: string): string {
59
70
  return `${NPM_REGISTRY}/${name}${spec === undefined ? '' : `/${encodeURIComponent(spec)}`}`
60
71
  }
@@ -138,16 +149,17 @@ async function publicWholeDocument(name: string, url: string, { limit, signal }:
138
149
  return json
139
150
  }
140
151
 
141
- // Asked anonymously first: a reader without private access stops there. One
142
- // with it asks again with the token, where there is one and the package is
143
- // scoped (npm's private packages always are), and the answer is private. A
144
- // whole document (`whole`) anyone may read is kept (publicWholeDocument).
145
- async function readDocument(name: string, url: string, privileged: boolean, { whole = false, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean }) {
146
- const open = whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null })
152
+ // Asked anonymously first, unless its caller knows npm has none for anyone
153
+ // (`anonymous` false): a reader without private access stops there. One
154
+ // with it asks again with the token where npmTokenReads says so, and the
155
+ // answer is private. A whole document (`whole`) anyone may read is kept
156
+ // (publicWholeDocument).
157
+ async function readDocument(name: string, url: string, privileged: boolean,
158
+ { whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
159
+ const open = anonymous ? whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
147
160
  if (open) return { json: open, private: false }
148
- const token = npmToken()
149
- if (!privileged || !token || npmPackageScope(name) === null) return null
150
- const json = await registryDocument(url, { ...options, token })
161
+ if (!npmTokenReads(name, privileged)) return null
162
+ const json = await registryDocument(url, { ...options, token: npmToken() })
151
163
  return json ? { json, private: true } : null
152
164
  }
153
165
 
@@ -244,10 +256,12 @@ export function readNpmVersions(name: string, privileged: boolean, signal: Abort
244
256
  // A package's whole document as npm has it for this reader, for risk
245
257
  // (npm-risk.ts), within the bounds and budget every document read here is
246
258
  // held to; null where npm has none for this reader, or the name is none npm
247
- // takes.
248
- export function readNpmPackument(name: string, privileged: boolean, signal: AbortSignal): Promise<Record<string, unknown> | null> {
259
+ // takes. With `anonymous` false, where npm is known to have none for anyone,
260
+ // it is asked only with the token (readDocument).
261
+ export function readNpmPackument(name: string, privileged: boolean, signal: AbortSignal, { anonymous = true } = {}): Promise<Record<string, unknown> | null> {
249
262
  if (!isNpmPackageName(name)) return Promise.resolve(null)
250
- return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged, { accept: 'application/json', limit: PACKUMENT_BYTES, signal, whole: true }))?.json ?? null)
263
+ return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
264
+ { accept: 'application/json', limit: PACKUMENT_BYTES, signal, whole: true, anonymous }))?.json ?? null)
251
265
  }
252
266
 
253
267
  const isPublishTime = (value: string | undefined) => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))