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

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 (207) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +88 -7
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +3 -0
  17. package/common/workspace-app.js +6 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +30 -34
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +53 -6
  71. package/risk/actions/findings.js +161 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +169 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -11,6 +11,7 @@ import { initializeApp } from '../server-common/initialize.ts'
11
11
  import { type ManagedConfig, loadManagedConfig } from './config.ts'
12
12
  import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
13
13
  import { setNpmTarballCache } from './npm-loads.ts'
14
+ import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
14
15
  import { setNpmDocumentCache } from './npm-packages.ts'
15
16
  import { loadManagedStatic } from './static.ts'
16
17
  import { openManagedStorage } from './storage.ts'
@@ -21,7 +22,8 @@ import { setCacheDir } from '@preventive/upstream/npm.js'
21
22
  const REAP_INTERVAL_MS = 3_600_000
22
23
  const REAP_RETRY_MS = 60_000
23
24
 
24
- type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
25
+ // `ui: false` serves only the API: `out/` is never read.
26
+ type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
25
27
 
26
28
  export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
27
29
  // Audits keep upstream's records on disk where the config has a cache
@@ -34,10 +36,13 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
34
36
  // Load the optional peer only for serverless managed deployments. Embedding
35
37
  // hosts can discard listener promises, so retain maintenance inside the app.
36
38
  const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
37
- return await initializeApp(rollback => assembleManagedApp(config, options, rollback, waitUntil))
39
+ // The host's reviewed dependencies, registered before; none after.
40
+ const reviewedDependencies = assembledReviewedDependencies()
41
+ const assembled = { ...options, ...reviewedDependencies ? { reviewedDependencies } : {} }
42
+ return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
38
43
  }
39
44
 
40
- async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
45
+ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
41
46
  const storage = await openManagedStorage(config)
42
47
  const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
43
48
  rollback.defer(() => db.close())
@@ -51,12 +56,12 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
51
56
  p.finally(() => inFlight.delete(p)).catch(() => {})
52
57
  }
53
58
 
54
- const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
59
+ const serveStatic = ui ? loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
55
60
  indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
56
- })
61
+ }) : () => false
57
62
  const routeRequest = createManagedRequestHandler({
58
63
  ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
59
- ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}),
64
+ ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
60
65
  isShuttingDown, track,
61
66
  })
62
67
 
@@ -133,8 +138,8 @@ export function logManagedStartup(config: ManagedConfig, port: number, mode = 'm
133
138
  ].join('\n'))
134
139
  }
135
140
 
136
- export async function init(config = loadManagedConfig()): Promise<Server> {
137
- const app = await createManagedApp(config)
141
+ export async function init(config = loadManagedConfig(), options: Pick<ManagedAppOptions, 'ui'> = {}): Promise<Server> {
142
+ const app = await createManagedApp(config, options)
138
143
  const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
139
144
  let disposal: Promise<void> | undefined
140
145
  function dispose(): Promise<void> {
@@ -0,0 +1,37 @@
1
+ // What was lately worked out, kept a little while in memory for whoever asks
2
+ // for it next, by key: each for `keptMs` from when it was kept, the least
3
+ // lately read going first past `maxEntries` of them or `maxBytes` between
4
+ // them, and one that alone takes more than `maxBytes` not kept at all.
5
+ // Whoever reads one is the caller's to check, as they would one worked out.
6
+ export function keptBriefly<T>({ keptMs, maxBytes, maxEntries = Infinity }: { keptMs: number; maxBytes: number; maxEntries?: number }) {
7
+ const held = new Map<string, { value: T; bytes: number; timer: ReturnType<typeof setTimeout> }>()
8
+ let heldBytes = 0
9
+ const forget = (key: string) => {
10
+ const entry = held.get(key)
11
+ if (!entry) return
12
+ clearTimeout(entry.timer)
13
+ held.delete(key)
14
+ heldBytes -= entry.bytes
15
+ }
16
+ return {
17
+ get(key: string): T | undefined {
18
+ const entry = held.get(key)
19
+ if (!entry) return undefined
20
+ held.delete(key)
21
+ held.set(key, entry)
22
+ return entry.value
23
+ },
24
+ keep(key: string, value: T, bytes: number) {
25
+ if (bytes > maxBytes) return
26
+ forget(key)
27
+ for (const old of held.keys()) {
28
+ if (held.size < maxEntries && heldBytes + bytes <= maxBytes) break
29
+ forget(old)
30
+ }
31
+ const timer = setTimeout(() => { forget(key) }, keptMs)
32
+ timer.unref()
33
+ held.set(key, { value, bytes, timer })
34
+ heldBytes += bytes
35
+ },
36
+ }
37
+ }
@@ -7,6 +7,7 @@ import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
7
7
  import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
8
8
  import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
9
9
  import type { LockfileFormat } from '@preventive/upstream/github.js'
10
+ import type { ReviewedLockfile } from './reviewed-dependencies.ts'
10
11
 
11
12
  export interface LockedPackage {
12
13
  name: string
@@ -19,9 +20,16 @@ export interface LockedPackage {
19
20
  // Where one from elsewhere than npm's registry comes from, without any
20
21
  // credentials its URL has.
21
22
  from?: string
22
- // A mirror's tarball's, as the lockfile has it: npm's package of its name
23
- // and version only where npm's integrity is among it.
23
+ // As the lockfile has it: a mirror's tarball's, npm's package of its name
24
+ // and version only where npm's integrity is among it; and, where its
25
+ // reviews ask (`review`), npm's registry's too.
24
26
  integrity?: string
27
+ // Where its reviews ask: the sha1 Yarn 1 writes after a tarball's `#`, in
28
+ // hex (`shasum`); the hash of the patch pnpm applies to it
29
+ // (`patchHash`, its patchedDependencies'); and the lockfile's format.
30
+ shasum?: string
31
+ patchHash?: string
32
+ lockfile?: ReviewedLockfile
25
33
  // Where only the importers' devDependencies reach it, as npm's `dev` flag
26
34
  // says, and an install of no devDependencies leaves it out: in npm's and
27
35
  // pnpm's lockfiles. Yarn 1's says nothing of it, which only the
@@ -32,10 +40,10 @@ export interface LockedPackage {
32
40
  // A package as each reader resolves one, its name the one it is published
33
41
  // under, and a registry tarball's URL held to its name and version.
34
42
  type Resolution =
35
- | { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; gitHosted?: boolean }
43
+ | { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; sha1?: string | undefined; gitHosted?: boolean }
36
44
  | { type: 'git'; repo: string }
37
45
  | { type: 'directory' }
38
- interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined }
46
+ interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined }
39
47
 
40
48
  // The packages of a lockfile of `format`, as its reader resolves them.
41
49
  // Versions go unchecked against ranges, as only what is locked is asked for.
@@ -54,41 +62,54 @@ function packagesOf(format: LockfileFormat, text: string): ReadPackage[] {
54
62
  }
55
63
 
56
64
  // npm's registry, by either of its names, and yarn's mirror of it, as
57
- // @preventive/lockfile holds their tarballs to a package's name and version.
65
+ // @preventive/lockfile holds their tarballs to a package's name and version:
66
+ // its link to one, as it names it, a hash after it alone (Yarn 1's sha1).
58
67
  const REGISTRY_HOSTS = new Set(['registry.npmjs.org', 'registry.npmjs.com', 'registry.yarnpkg.com'])
59
- const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password
68
+ const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password && !url.search
60
69
  && REGISTRY_HOSTS.has(url.hostname.replace(/\.+$/u, ''))
61
70
  // Where a URL is, as it may be shown: its credentials, query and fragment
62
71
  // left out.
63
72
  const shown = (url: URL | null) => url ? { from: `${url.protocol}//${url.host}${url.pathname}` } : {}
64
73
 
65
- // Where a package read comes from (LockedPackage); null for one a package
66
- // bundles, with no resolution, or one of the project's own directories.
67
- function locked({ name, version, resolution }: ReadPackage): LockedPackage | null {
74
+ // Where a package read comes from (LockedPackage), with npm's registry's
75
+ // integrity where `review`; null for one a package bundles, with no
76
+ // resolution, or one of the project's own directories.
77
+ function located({ name, version, resolution }: ReadPackage, review: boolean): LockedPackage | null {
68
78
  const at = { name, version: version ?? null }
69
79
  if (resolution === undefined || resolution.type === 'directory') return null
70
80
  if (resolution.type === 'git') return { ...at, source: 'git', ...shown(URL.parse(resolution.repo)) }
71
81
  const { tarball, integrity } = resolution
72
82
  if (resolution.gitHosted) return { ...at, source: 'git', ...shown(URL.parse(tarball ?? '')) }
73
- if (tarball === undefined) return { ...at, source: 'registry' }
83
+ const registry = { ...at, source: 'registry' as const, ...review && integrity ? { integrity } : {} }
84
+ if (tarball === undefined) return registry
74
85
  if (tarball.startsWith('file:')) return { ...at, source: 'file' }
75
86
  const url = URL.parse(tarball)
76
- if (url && isRegistry(url)) return { ...at, source: 'registry' }
87
+ if (url && isRegistry(url)) return registry
77
88
  return integrity ? { ...at, ...shown(url), source: 'mirror', integrity } : { ...at, ...shown(url), source: 'tarball' }
78
89
  }
90
+ // As above, and with what its reviews ask (LockedPackage `shasum`,
91
+ // `patchHash`, `lockfile`) where `review`.
92
+ function locked(pkg: ReadPackage, format: LockfileFormat, review: boolean): LockedPackage | null {
93
+ const one = located(pkg, review)
94
+ if (!one || !review) return one
95
+ const sha1 = pkg.resolution?.type === 'tarball' ? pkg.resolution.sha1 : undefined
96
+ return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {}, lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
97
+ }
79
98
 
80
99
  // The packages a lockfile of `format` (npm, pnpm or yarn, as
81
100
  // findRepoLockfiles names them) locks, each once, in the lockfile's order:
82
101
  // `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.
85
- export function lockedPackages(format: LockfileFormat, text: string): LockedPackage[] {
102
+ // tree, and with each of pnpm's peers; with `review`, each with what its
103
+ // reviews hold it to (LockedPackage `integrity`, `shasum`, `patchHash`),
104
+ // each patch of it apart. Throws the reader's LockfileError or YamlError for
105
+ // one it refuses.
106
+ export function lockedPackages(format: LockfileFormat, text: string, { review = false } = {}): LockedPackage[] {
86
107
  const found = new Map<string, LockedPackage>()
87
108
  for (const pkg of packagesOf(format, text)) {
88
- const one = locked(pkg)
109
+ const one = locked(pkg, format, review)
89
110
  if (!one) continue
90
111
  if (pkg.dev === true) one.dev = true
91
- const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
112
+ const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}\0${one.shasum ?? ''}\0${one.patchHash ?? ''}`
92
113
  const had = found.get(key)
93
114
  if (!had) found.set(key, one)
94
115
  else if (had.dev && !one.dev) delete had.dev
@@ -0,0 +1,49 @@
1
+ // The lockfiles a repository's dependency review holds to, as an admin sets
2
+ // it (repository-dependency-reviews.ts): every one but those it names
3
+ // (`exclude`, naming none for all of them), or those it names alone
4
+ // (`include`). Each is named by its path in the repository, or, ending in
5
+ // `/`, by a directory, every lockfile under it. A directory of a pull
6
+ // request's lockfiles (pull-dependencies.ts) is reviewed where one of its
7
+ // lockfiles is held to; one none of whose are, not at all (`excluded`), nor
8
+ // failing the review where it couldn't be read.
9
+ export interface LockfileScope { mode: 'exclude' | 'include'; paths: string[] }
10
+ export const EVERY_LOCKFILE: LockfileScope = { mode: 'exclude', paths: [] }
11
+ export const MAX_SCOPED_LOCKFILES = 100
12
+ const MAX_PATH = 300
13
+
14
+ // A path as one is named: relative, of `/`-separated names none of which is
15
+ // empty, `.` or `..`, nor holding a backslash or a control character; a
16
+ // directory's ending in `/`.
17
+ function namedPath(path: unknown): path is string {
18
+ if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PATH || path.startsWith('/')) return false
19
+ if (/[\\\p{Cc}]/u.test(path)) return false
20
+ const names = path.endsWith('/') ? path.slice(0, -1).split('/') : path.split('/')
21
+ return names.every(name => name !== '' && name !== '.' && name !== '..')
22
+ }
23
+
24
+ // A scope as a request sends it, each path once, in order; null for one
25
+ // that isn't one.
26
+ export function scopeOf(value: unknown): LockfileScope | null {
27
+ const { mode, paths } = (value ?? {}) as { mode?: unknown; paths?: unknown }
28
+ if ((mode !== 'exclude' && mode !== 'include') || !Array.isArray(paths) || paths.length > MAX_SCOPED_LOCKFILES || !paths.every(namedPath)) return null
29
+ return { mode, paths: [...new Set(paths as string[])].toSorted() }
30
+ }
31
+
32
+ // A scope as stored (JSON, none for every lockfile), every lockfile where
33
+ // it can't be read.
34
+ export function storedScope(text: string | null): LockfileScope {
35
+ if (!text) return EVERY_LOCKFILE
36
+ try { return scopeOf(JSON.parse(text)) ?? EVERY_LOCKFILE } catch { return EVERY_LOCKFILE }
37
+ }
38
+ export const scopeText = (scope: LockfileScope) => isEvery(scope) ? null : JSON.stringify({ mode: scope.mode, paths: scope.paths })
39
+ export const isEvery = (scope: LockfileScope) => scope.mode === 'exclude' && scope.paths.length === 0
40
+
41
+ const names = (path: string, named: string) => named.endsWith('/') ? path.startsWith(named) : path === named
42
+ // Whether the lockfile at `path` is held to.
43
+ export function heldTo(scope: LockfileScope, path: string) {
44
+ const named = scope.paths.some(one => names(path, one))
45
+ return scope.mode === 'include' ? named : !named
46
+ }
47
+ // Whether a directory of lockfiles at `paths` is reviewed: where one is
48
+ // held to, or it names none.
49
+ export const reviewedIn = (scope: LockfileScope, paths: string[]) => paths.length === 0 || paths.some(path => heldTo(scope, path))
@@ -0,0 +1,23 @@
1
+ // The lines a package's files change from one side to another (npm-delta.ts),
2
+ // as @preventive/compare's compareTrees counts them (git's --numstat), off
3
+ // the server's event loop: of its text files, renames found as git finds
4
+ // them, a side not there empty; and, apart, those of its changes it stamps
5
+ // `typeOnly`, declaration files changed in types alone.
6
+ import { parentPort, workerData } from 'node:worker_threads'
7
+ import { compareTrees } from '@preventive/compare'
8
+
9
+ const tree = files => files.map(([name, data]) => ({ name, data }))
10
+ const changes = compareTrees(tree(workerData.base), tree(workerData.other), { paths: 'keys', lines: true, renames: true })
11
+ const answer = { additions: 0, deletions: 0, typeAdditions: 0, typeDeletions: 0, typeFiles: 0 }
12
+ for (const change of changes) {
13
+ if (change.binary) continue
14
+ answer.additions += change.insertions ?? 0
15
+ answer.deletions += change.deletions ?? 0
16
+ if (change.typeOnly !== true) continue
17
+ answer.typeAdditions += change.insertions ?? 0
18
+ answer.typeDeletions += change.deletions ?? 0
19
+ answer.typeFiles++
20
+ }
21
+ // Node worker messages have no browser target origin.
22
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
23
+ parentPort.postMessage(answer)
@@ -0,0 +1,178 @@
1
+ // The lines a version of a package changes against another, or against none,
2
+ // its whole package's (GET /api/npm/delta), as a list of versions wanting
3
+ // reviews tells them beside each: added and removed (`additions`,
4
+ // `deletions`), as git's --numstat counts them, of their text files, a
5
+ // renamed one diffed with what it was; and, where some of the files changed
6
+ // are declaration files @preventive/compare's compareTrees proves changed in
7
+ // types alone (`typeOnly`, proveTypeOnly: types and comments alone erased,
8
+ // the runtime they leave as it was, an added one's empty or a single bare
9
+ // `export {}`), those of the rest (`typeless`), what a reviewer may pass by
10
+ // leaving those out; and whether a binary file, which they count none of,
11
+ // was added, removed or changed (`binary`), one moved as it was not. Worked
12
+ // out in a worker, off the event loop, as many at once per process as
13
+ // MAX_ACTIVE, both sides' files read together only once their turn has come,
14
+ // those waiting past MAX_WAITING refused, each waiting a minute at most and
15
+ // counting in a minute at most; one asked again meanwhile sharing the
16
+ // first's; and kept by both sides' integrities, in this instance and beyond
17
+ // it (`kept`), what a version holds never changing. Access is the caller's
18
+ // to check.
19
+ import { Worker } from 'node:worker_threads'
20
+ import { createHash } from 'node:crypto'
21
+ import type { KeptStore } from './npm-kept.ts'
22
+ import { type Lru, lru } from './lru.ts'
23
+ import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
24
+
25
+ export interface NpmDelta { additions: number; deletions: number; typeless: { additions: number; deletions: number } | null; binary: boolean }
26
+
27
+ const MAX_ACTIVE = 2
28
+ const MAX_WAITING = 64
29
+ const DELTA_TIMEOUT_MS = 60_000
30
+ // The text the two sides may hold together, past which none is counted.
31
+ const MAX_TEXT_LENGTH = 64 * 1024 * 1024
32
+
33
+ const deltas: Lru<NpmDelta> = lru(20_000)
34
+ const working = new Map<string, Promise<NpmDelta>>()
35
+ let active = 0
36
+ const waiting: Array<() => void> = []
37
+
38
+ async function turn(deadline: number): Promise<void> {
39
+ if (active < MAX_ACTIVE) { active++; return }
40
+ if (waiting.length >= MAX_WAITING) throw new NpmPackageError(429, 'delta-busy')
41
+ await new Promise<void>((resolve, reject) => {
42
+ const next = () => { clearTimeout(timer); resolve() }
43
+ const timer = setTimeout(() => {
44
+ waiting.splice(waiting.indexOf(next), 1)
45
+ reject(new NpmPackageError(504, 'delta-timeout'))
46
+ }, deadline - Date.now())
47
+ waiting.push(next)
48
+ })
49
+ }
50
+ function done() {
51
+ const next = waiting.shift()
52
+ if (next) next()
53
+ else active--
54
+ }
55
+
56
+ const lines = (told: unknown): told is { additions: number; deletions: number } => typeof told === 'object' && told !== null
57
+ && Number.isSafeInteger((told as { additions?: unknown }).additions) && Number.isSafeInteger((told as { deletions?: unknown }).deletions)
58
+ // A delta as kept, none where it isn't one, or was kept before it told of
59
+ // binary files.
60
+ const deltaOf = (told: unknown): NpmDelta | null => lines(told) && ((told as NpmDelta).typeless === null || lines((told as NpmDelta).typeless))
61
+ && typeof (told as NpmDelta).binary === 'boolean' ? { additions: told.additions, deletions: told.deletions, typeless: (told as NpmDelta).typeless,
62
+ binary: (told as NpmDelta).binary } : null
63
+
64
+ type Counted = { additions: number; deletions: number; typeAdditions: number; typeDeletions: number; typeFiles: number }
65
+ const isCounted = (told: unknown): told is Counted => typeof told === 'object' && told !== null
66
+ && ['additions', 'deletions', 'typeAdditions', 'typeDeletions', 'typeFiles'].every(key => Number.isSafeInteger((told as Record<string, unknown>)[key]))
67
+
68
+ // What the worker counts of `base` against `other`, each its text files' paths and texts.
69
+ function counted(base: [string, string][], other: [string, string][], deadline: number): Promise<Counted> {
70
+ let worker: Worker
71
+ try {
72
+ worker = new Worker(new URL('./npm-delta-worker.js', import.meta.url), {
73
+ workerData: { base, other }, execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1536 },
74
+ })
75
+ } catch (err) {
76
+ console.warn('managed: a package delta could not start:', err)
77
+ throw new NpmPackageError(503, 'unavailable')
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ let answer: Counted | null = null, failure = new NpmPackageError(422, 'delta-failed')
81
+ const timer = setTimeout(() => { failure = new NpmPackageError(504, 'delta-timeout'); void worker.terminate() }, deadline - Date.now())
82
+ worker.once('message', (told: unknown) => {
83
+ if (isCounted(told)) answer = told
84
+ void worker.terminate()
85
+ })
86
+ worker.once('error', err => { console.warn('managed: a package delta failed:', err) })
87
+ worker.once('exit', () => {
88
+ clearTimeout(timer)
89
+ if (answer) resolve(answer)
90
+ else reject(failure)
91
+ })
92
+ })
93
+ }
94
+
95
+ // A side's text files, by path, how long their texts are, and its other,
96
+ // binary, files.
97
+ function texts(files: NpmPackageFile[] | null) {
98
+ let length = 0
99
+ const binaries: NpmPackageFile[] = []
100
+ const read = (files ?? []).flatMap(file => {
101
+ const text = npmFileText(file.bytes)
102
+ if (text === null) { binaries.push(file); return [] }
103
+ length += text.length
104
+ return [[file.path, text] as [string, string]]
105
+ })
106
+ return { read, length, binaries }
107
+ }
108
+
109
+ const same = (a: Uint8Array, b: Uint8Array) => Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(b)
110
+ const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('base64')
111
+
112
+ // Whether `news`, a side's binary files, differ from `olds`, the other's:
113
+ // one added, removed or changed, one moved where it is the same as one that
114
+ // was elsewhere not, matched by their digests, each file looked at once.
115
+ function binaryChanged(olds: NpmPackageFile[], news: NpmPackageFile[]): boolean {
116
+ if (olds.length !== news.length) return true
117
+ const left = new Map(olds.map(({ path, bytes }) => [path, bytes]))
118
+ const moved: Uint8Array[] = []
119
+ for (const { path, bytes } of news) {
120
+ const was = left.get(path)
121
+ if (was && same(was, bytes)) left.delete(path)
122
+ else moved.push(bytes)
123
+ }
124
+ // Those not where they were, by their digests, as many of each as there are.
125
+ const unmoved = new Map<string, number>()
126
+ for (const bytes of left.values()) {
127
+ const key = digest(bytes)
128
+ unmoved.set(key, (unmoved.get(key) ?? 0) + 1)
129
+ }
130
+ return moved.some(bytes => {
131
+ const key = digest(bytes)
132
+ const count = unmoved.get(key) ?? 0
133
+ if (count === 0) return true
134
+ unmoved.set(key, count - 1)
135
+ return false
136
+ })
137
+ }
138
+
139
+ type Side = { integrity: string; files: () => Promise<NpmPackageFile[]> }
140
+
141
+ // The delta of `other` (its integrity, and how to read its files) against
142
+ // `base`, or none, as held, else shared with one asked before it, else
143
+ // looked for where it is kept, else worked out and kept: shared from before
144
+ // it is looked for, lest those asked together each look, find none, and work
145
+ // it out.
146
+ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null = null): Promise<NpmDelta> {
147
+ const key = `${base?.integrity ?? '(empty)'}\0${other.integrity}`
148
+ const known = deltas.get(key)
149
+ if (known) return Promise.resolve(known)
150
+ const shared = working.get(key)
151
+ if (shared) return shared
152
+ const work = worked(key, base, other, kept).finally(() => working.delete(key))
153
+ working.set(key, work)
154
+ return work
155
+ }
156
+
157
+ // As kept, else its turn awaited, then both sides' files read, counted and kept.
158
+ async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
159
+ const known = deltaOf((await kept?.read(key))?.value)
160
+ if (known) { deltas.set(key, known); return known }
161
+ await turn(Date.now() + DELTA_TIMEOUT_MS)
162
+ let binary, told
163
+ try {
164
+ // Its turn held till both sides are read, or have failed.
165
+ const [baseRead, otherRead] = await Promise.allSettled([base ? base.files() : Promise.resolve(null), other.files()])
166
+ if (baseRead.status === 'rejected') throw baseRead.reason
167
+ if (otherRead.status === 'rejected') throw otherRead.reason
168
+ const [olds, news] = [texts(baseRead.value), texts(otherRead.value)]
169
+ if (olds.length + news.length > MAX_TEXT_LENGTH) throw new NpmPackageError(413, 'package-too-large')
170
+ binary = binaryChanged(olds.binaries, news.binaries)
171
+ told = await counted(olds.read, news.read, Date.now() + DELTA_TIMEOUT_MS)
172
+ } finally { done() }
173
+ const delta = { additions: told.additions, deletions: told.deletions,
174
+ typeless: told.typeFiles > 0 ? { additions: told.additions - told.typeAdditions, deletions: told.deletions - told.typeDeletions } : null, binary }
175
+ deltas.set(key, delta)
176
+ await kept?.write(key, delta, Date.now())
177
+ return delta
178
+ }
@@ -362,25 +362,34 @@ async function askAdvisories(name: string, versions: string[], { githubToken, re
362
362
  }
363
363
  if (result.status !== 200) throw new NpmPackageError(502, 'upstream-unavailable')
364
364
  const index = new Map(versions.map((version, i) => [version, i]))
365
- // npm's registry answers an advisory once a range it covers: here it is one
366
- // row, its ranges joined as semver joins them, the versions and CWEs they
367
- // cover together, and the highest score.
365
+ // npm's registry answers an advisory once a range it covers, and upstream
366
+ // answers the versions OSV's record of it, or its repository, covers beyond
367
+ // npm's under the same id, in a row of their own (as hermes-engine's
368
+ // GHSA-f5x2-xv93-4p23: npm's `<=0.4.3`, OSV's fixed in 0.5.2): here it is
369
+ // one row, the first's (npm's, then OSV's, then the repository's, as
370
+ // upstream orders them) with what it lacks taken from the rest, its ranges
371
+ // joined as semver joins them, the versions and CWEs they cover together,
372
+ // and the worst severity and highest score of them: a version only the
373
+ // repository reports keeps the severity it gives.
368
374
  const merged = new Map<string, NpmAdvisory>()
369
375
  for (const row of result.body) {
370
376
  const affected = row.versions.map(version => index.get(version)).filter(at => at !== undefined)
371
- const key = `${row.source}\0${row.id}`
372
- const known = merged.get(key)
377
+ const own: NpmAdvisory = {
378
+ id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
379
+ ...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
380
+ ...row.details && { details: row.details }, affected,
381
+ }
382
+ const known = merged.get(row.id)
373
383
  if (!known) {
374
- merged.set(key, {
375
- id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
376
- ...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
377
- ...row.details && { details: row.details }, affected,
378
- })
384
+ merged.set(row.id, own)
379
385
  continue
380
386
  }
381
387
  const range = [known.range, row.range].filter(Boolean).join(' || ')
382
388
  const cvss = Math.max(known.cvss ?? -1, row.cvss ?? -1)
383
- merged.set(key, { ...known, ...range && { range }, ...cvss >= 0 && { cvss }, cwe: [...new Set([...known.cwe, ...row.cwe])], affected: [...known.affected, ...affected] })
389
+ const rank = (severity: string | undefined) => SEVERITY_RANK[severity ?? ''] ?? 4
390
+ const severity = rank(row.severity) < rank(known.severity) ? row.severity : known.severity
391
+ merged.set(row.id, { ...own, ...known, ...range && { range }, ...severity && { severity }, ...cvss >= 0 && { cvss }, cwe: [...new Set([...known.cwe, ...row.cwe])],
392
+ affected: [...known.affected, ...affected] })
384
393
  }
385
394
  return {
386
395
  versions, repository, ...keptAt && { keptAt },