@preventive/triage 1.0.0-alpha.42 → 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 (33) 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-WOTMRDM3.js → chunk-7RU5EDRO.js} +61 -61
  5. package/out/{chunk-D7AF2JXL.js → chunk-A7M4GV7M.js} +1 -1
  6. package/out/{chunk-DGVQPIYB.js → chunk-LAAB5SZT.js} +1 -1
  7. package/out/{chunk-YQRVLRDH.js → chunk-NWZ4PVPN.js} +1 -1
  8. package/out/{chunk-YP2537WX.js → chunk-OSIMGFUF.js} +1 -1
  9. package/out/{chunk-Q7ZSIEHP.js → chunk-XOCAPDCM.js} +1 -1
  10. package/out/client-managed.js +19 -19
  11. package/out/index.html +2 -2
  12. package/out/view.js +54 -54
  13. package/package.json +9 -3
  14. package/risk/index.d.ts +26 -0
  15. package/risk/index.js +1 -1
  16. package/risk/npm/assess.js +60 -14
  17. package/risk/npm/dependencies.js +11 -3
  18. package/risk/npm/new-dependency.js +42 -12
  19. package/risk/npm/registry.js +122 -21
  20. package/server-managed/http.ts +63 -26
  21. package/server-managed/locked-packages.ts +18 -6
  22. package/server-managed/npm-kept.ts +38 -14
  23. package/server-managed/npm-packages.ts +27 -13
  24. package/server-managed/npm-risk.ts +69 -11
  25. package/server-managed/queue.ts +23 -0
  26. package/server-managed/report-sources.ts +1 -1
  27. package/server-managed/repository-risk-assess.ts +105 -0
  28. package/server-managed/repository-risk-audit.ts +66 -0
  29. package/server-managed/repository-risk.ts +255 -201
  30. package/server-managed/scan-downloads.ts +87 -0
  31. package/server-managed/sql.ts +1 -1
  32. package/server-managed/team-reports.ts +6 -11
  33. package/server-managed/upstream-cache.ts +16 -0
@@ -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))
@@ -9,8 +9,13 @@
9
9
  // - `risk/npm/versions`, what a version's document says, which npm never
10
10
  // changes once published: a public package's in the database's upstream
11
11
  // cache table, shared by every instance; a private one's in memory.
12
- // - `risk/npm/packages`, `risk/npm/public`, `risk/npm/advisories` and
13
- // `risk/npm/downloads`, kept an hour or a day by risk itself: in memory.
12
+ // - `risk/npm/downloads`, a package's downloads a day, which risk tells as
13
+ // kept for a week: a public package's in the database too, so that another
14
+ // instance, or this one restarted, asks npm's downloads API, which answers
15
+ // some 40 lookups a minute, none of them again that week, each removed a
16
+ // day past it (npm-kept.ts pruneKept); a private one's in memory.
17
+ // - `risk/npm/packages`, `risk/npm/public` and `risk/npm/advisories`, kept an
18
+ // hour or a day by risk itself: in memory.
14
19
  // - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
15
20
  // read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
16
21
  // an assessment kept needs none.
@@ -28,11 +33,12 @@ import { Buffer } from 'node:buffer'
28
33
  import { createHash } from 'node:crypto'
29
34
  import { buffer } from 'node:stream/consumers'
30
35
  import type { CacheStore } from '@preventive/upstream/npm.js'
31
- import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm } from '../risk/index.js'
36
+ import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
32
37
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
33
38
  import { loadNpmTarball } from './npm-loads.ts'
34
- import { NpmPackageError, type NpmVersionDocument, readNpmPackument, readNpmVersion } from './npm-packages.ts'
39
+ import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
35
40
  import { npmPackageScope } from '../common/managed/npm-packages.js'
41
+ import { pruneKept } from './npm-kept.ts'
36
42
  import type { UpstreamCacheStore } from './upstream-cache.ts'
37
43
 
38
44
  const MEMORY_ENTRIES = 2000
@@ -63,6 +69,10 @@ function remember(key: string, value: unknown) {
63
69
  }
64
70
 
65
71
  const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
72
+ // What of a public package is kept in the database, and for how long its
73
+ // downloads are: risk's week, and a day more.
74
+ const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
75
+ const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
66
76
 
67
77
  export interface NpmRiskStores {
68
78
  db: UpstreamCacheStore
@@ -70,16 +80,38 @@ export interface NpmRiskStores {
70
80
  debug?: boolean | undefined
71
81
  }
72
82
 
83
+ // A cache that reads and writes many of a type at once too, as risk's
84
+ // readying of downloads asks (npmReadyDownloads): one read and one write of
85
+ // the database each, not one a key, as a request's queries take turns on its
86
+ // connection (db-neon.ts).
87
+ export interface NpmRiskCache extends CacheStore {
88
+ readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
89
+ writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
90
+ }
91
+
73
92
  // Risk's cache for one package: `isPublic` whether anyone can read it, so
74
93
  // that what is kept of it can be shared.
75
- export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): CacheStore {
94
+ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
76
95
  const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
77
- return {
96
+ const cache: NpmRiskCache = {
97
+ async readMany(type, keys) {
98
+ if (!isPublic || !IN_DATABASE.has(type)) return new Map(await Promise.all(keys.map(async key => [key, await cache.read(type, key)] as const)))
99
+ try {
100
+ const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
101
+ return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
102
+ } catch (error) { warn('read', error); return new Map() }
103
+ },
104
+ async writeMany(type, entries) {
105
+ if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
106
+ const at = Date.now()
107
+ try { await db.setUpstreamCacheEntries(entries.map(([key, value]) => [`${type}/${key}`, JSON.stringify(value)]), at) } catch (error) { warn('write', error); return }
108
+ if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
109
+ },
78
110
  async read(type, key) {
79
111
  if (type === 'npm/tarballs') return null
80
112
  try {
81
113
  if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
82
- if (isPublic && type === 'risk/npm/versions') { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
114
+ if (isPublic && IN_DATABASE.has(type)) { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
83
115
  } catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
84
116
  return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
85
117
  },
@@ -87,11 +119,17 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
87
119
  if (type === 'npm/tarballs') return
88
120
  try {
89
121
  if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
90
- if (isPublic && type === 'risk/npm/versions') { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()); return }
122
+ if (isPublic && IN_DATABASE.has(type)) {
123
+ const at = Date.now()
124
+ await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), at)
125
+ if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
126
+ return
127
+ }
91
128
  } catch (error) { warn('write', error); return }
92
129
  remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
93
130
  },
94
131
  }
132
+ return cache
95
133
  }
96
134
 
97
135
  // A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
@@ -160,8 +198,9 @@ async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Pr
160
198
  // by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
161
199
  // where anyone can read its package, OSV's, which alone has some of those
162
200
  // npm removed (nx's), a private package's name never sent to OSV. Its
163
- // package's document read anonymously, else with npm's token where the
164
- // reader may have it (`privileged`), and kept as a private package's is; no
201
+ // package's document read anonymously, else with npm's token alone where
202
+ // that reads more for this reader (`privileged`, npmTokenReads), and kept as
203
+ // a private package's is; no
165
204
  // tarball read, nothing of the version being left. Spare, within
166
205
  // MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
167
206
  // nothing tells of it.
@@ -171,7 +210,26 @@ export function npmRiskRemoved(name: string, version: string,
171
210
  const open = await readNpmPackument(name, false, signal)
172
211
  return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
173
212
  osv: open !== null, cache: npmRiskCache(stores, false), signal,
174
- packument: (read, { authorized }) => read === name && open ? Promise.resolve(open) : readNpmPackument(read, privileged && authorized, signal),
213
+ packument: (read, { authorized }) => read === name
214
+ ? open || !npmTokenReads(name, privileged && authorized) ? Promise.resolve(open) : readNpmPackument(name, true, signal, { anonymous: false })
215
+ : readNpmPackument(read, privileged && authorized, signal),
175
216
  tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
176
217
  })
177
218
  }
219
+
220
+ // What a scan readies before assessing its packages (repository-risk.ts):
221
+ // their downloads, as risk asks them, kept where a public package's are
222
+ // (npmRiskCache), so that their assessments wait on npm's downloads API for
223
+ // none. At once, those kept and unscoped ones, in bulk, all public, as only a
224
+ // scoped package can be private; each other one on its own
225
+ // (npmReadyScanDownload), kept only where npm counts some, which it does of
226
+ // public packages alone: a private one's none, as of one it has no such
227
+ // package of, isn't kept where public ones are, for its assessment to ask
228
+ // its own where private ones are.
229
+ export const npmReadyScanDownloads = (names: string[], ready: (name: string) => void, stores: NpmRiskStores, signal: AbortSignal) =>
230
+ npmReadyDownloads(names, ready, { cache: npmRiskCache(stores, true), signal })
231
+ export function npmReadyScanDownload(name: string, stores: NpmRiskStores, signal: AbortSignal) {
232
+ const cache = npmRiskCache(stores, true)
233
+ const counted = (value: unknown) => !(value && typeof value === 'object' && 'none' in value)
234
+ return npmReadyDownload(name, { cache: { read: cache.read, write: (type, key, value) => counted(value) ? cache.write(type, key, value) : Promise.resolve() }, signal })
235
+ }
@@ -0,0 +1,23 @@
1
+ // Items in the order they are added, each taken once by whichever reader asks
2
+ // first: none once closed and all taken, or once `signal` aborts, but those
3
+ // added by then where `drain`, none waited for.
4
+ export interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
5
+ export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T> {
6
+ const items: T[] = []
7
+ const waiting: (() => void)[] = []
8
+ let closed = false
9
+ const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
10
+ signal.addEventListener('abort', wake, { once: true })
11
+ return {
12
+ add(item) { items.push(item); wake() },
13
+ close() { closed = true; wake() },
14
+ async take() {
15
+ for (;;) {
16
+ if (signal.aborted && !drain) return undefined
17
+ const item = items.shift()
18
+ if (item !== undefined || closed || signal.aborted) return item
19
+ await new Promise<void>(resolve => { waiting.push(resolve) })
20
+ }
21
+ },
22
+ }
23
+ }
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
29
29
  }
30
30
  function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
31
31
  const key = createHash('sha256').update(JSON.stringify([
32
- 'finding-access-v9', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
32
+ 'finding-access-v10', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
33
33
  ])).digest('hex')
34
34
  return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
35
35
  }