@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50

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 (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,454 @@
1
+ // The managed npm package viewer: a published version's files, read from the
2
+ // registry's tarball, for the Overview and Code tabs the bundle view shows.
3
+ //
4
+ // Anyone with workspace access may read a public package. A private one also
5
+ // needs NPM_TOKEN on the server, and a reader with access to it: an admin or
6
+ // manager, or a member of a visible team listing its scope (team-npm-scopes.ts).
7
+ // For everyone else, a version is public only when the registry answers for
8
+ // it without credentials, asked on every request and never from a cache:
9
+ // upstream's caches, which bundle builds fill using the token, hold no
10
+ // answer here. Its tarball may come from them (npm-loads.ts), as only bytes
11
+ // matching the integrity that anonymous answer gives are served; else it is
12
+ // read without credentials too, and held to that integrity.
13
+ import { Buffer } from 'node:buffer'
14
+ import { createHash } from 'node:crypto'
15
+ import { rm } from 'node:fs/promises'
16
+ import { resolve } from 'node:path'
17
+ import { ArchiveError, type Entry, unpack } from '@preventive/archive/tar.js'
18
+ import { isExactVersion } from '@preventive/upstream/semver.js'
19
+ import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
20
+ import type { Role } from '../common/managed/roles.ts'
21
+ import { npmManifest, npmVersionFields, plainObject, stringRecord } from './npm-manifest.ts'
22
+ import { keepJson, readKeptJson, upstreamCachePath } from './upstream-cache.ts'
23
+
24
+ export { npmManifest, plainObject }
25
+
26
+ export const NPM_REGISTRY = 'https://registry.npmjs.org'
27
+ const VERSION_DOCUMENT_BYTES = 8 * 1024 * 1024
28
+ // Abbreviated packuments of packages with thousands of versions run to tens of MiB.
29
+ const PACKUMENT_BYTES = 64 * 1024 * 1024
30
+ // What registry documents being read at once may hold, each counted at its
31
+ // limit until it is read and parsed: four packuments, or 32 version documents.
32
+ const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
33
+ // What a package may unpack to: the files' bytes and their count; the tar
34
+ // stream holding them is bounded in npm-loads.ts. A repository directory's
35
+ // are its own (repository-code.ts).
36
+ export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
37
+ export const MAX_NPM_PACKAGE_FILES = 20_000
38
+ // The entries its tar stream may hold, read before any is counted as a file:
39
+ // a directory's too, though npm packs none.
40
+ export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
41
+ // What the files may take as the viewer's JSON. Escaping grows a text up to
42
+ // six times (a control character becomes `\u0001`), so it is counted before
43
+ // anything is serialized.
44
+ export const MAX_NPM_JSON_LENGTH = 96 * 1024 * 1024
45
+ export class NpmPackageError extends Error {
46
+ status: number
47
+ constructor(status: number, code: string) { super(code); this.status = status }
48
+ }
49
+
50
+ // Who reads, as the session has it now: the role, and the scopes of the
51
+ // visible teams they are a member of, where those count (npmScopesCount).
52
+ export interface NpmReader { role: Role; scopes: ReadonlySet<string>; userId: string }
53
+
54
+ // Whether a reader may have a package read with the server's token.
55
+ export function canReadPrivateNpm(reader: NpmReader, name: string): boolean {
56
+ if (reader.role === 'admin' || reader.role === 'manage') return true
57
+ const scope = npmPackageScope(name)
58
+ return scope !== null && reader.scopes.has(scope)
59
+ }
60
+
61
+ // Whether a reader's team scopes count for any of `names`, as
62
+ // canReadPrivateNpm reads them: a scoped one, read by someone below manager.
63
+ export const npmScopesCount = (role: Role, names: readonly string[]): boolean =>
64
+ role !== 'admin' && role !== 'manage' && names.some(name => npmPackageScope(name) !== null)
65
+
66
+ export const npmToken = () => process.env['NPM_TOKEN'] || null
67
+
68
+ // Whether a read of `name` that npm answers none anonymously is asked again
69
+ // with the server's token: by a reader with private access (`privileged`),
70
+ // where there is a token and the package is scoped, as npm's private packages
71
+ // always are. Else the anonymous answer is the reader's.
72
+ export const npmTokenReads = (name: string, privileged: boolean): boolean => privileged && npmToken() !== null && npmPackageScope(name) !== null
73
+
74
+ function registryUrl(name: string, spec?: string): string {
75
+ return `${NPM_REGISTRY}/${name}${spec === undefined ? '' : `/${encodeURIComponent(spec)}`}`
76
+ }
77
+
78
+ // What an upstream (npm's registry and APIs, Socket, GitHub) answers, read
79
+ // whole: null where it says it has none (`absent`, 404 unless said). Asked
80
+ // without redirects, and given up on after `ms`, 30 seconds unless said, or
81
+ // once `signal` aborts, its abort rethrown as it is; a 502 where it can't be
82
+ // asked, answers otherwise or stops, and `upstream-too-large` past `limit`
83
+ // bytes (readLimited). `answered` sees the response first, its status and
84
+ // headers.
85
+ export async function askUpstream(url: string, { limit, ms = 30_000, signal, absent = [404], answered, ...init }: Omit<RequestInit, 'signal'>
86
+ & { limit: number; ms?: number; signal?: AbortSignal; absent?: number[]; answered?: (res: Response) => void }): Promise<Buffer | null> {
87
+ try {
88
+ const res = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.any([AbortSignal.timeout(ms), ...signal ? [signal] : []]) })
89
+ answered?.(res)
90
+ if (absent.includes(res.status)) { await res.body?.cancel(); return null }
91
+ if (!res.ok) { await res.body?.cancel(); throw new NpmPackageError(502, 'upstream-unavailable') }
92
+ return await readLimited(res, limit)
93
+ } catch (err) {
94
+ if (signal?.aborted || err instanceof NpmPackageError) throw err
95
+ throw new NpmPackageError(502, 'upstream-unavailable')
96
+ }
97
+ }
98
+
99
+ // The same, read as JSON (by `parse`, as one line of ndjson): a 502
100
+ // `upstream-invalid` where it isn't.
101
+ export async function askUpstreamJson(url: string, options: Parameters<typeof askUpstream>[1], parse = (text: string): unknown => JSON.parse(text)): Promise<unknown> {
102
+ const body = await askUpstream(url, options)
103
+ if (body === null) return null
104
+ try { return parse(body.toString('utf8')) } catch { throw new NpmPackageError(502, 'upstream-invalid') }
105
+ }
106
+
107
+ export async function readLimited(res: Response, limit: number): Promise<Buffer> {
108
+ const declared = Number(res.headers.get('content-length'))
109
+ if (declared > limit) { await res.body?.cancel(); throw new NpmPackageError(502, 'upstream-too-large') }
110
+ const chunks: Uint8Array[] = []
111
+ let size = 0
112
+ for await (const chunk of res.body ?? []) {
113
+ size += chunk.byteLength
114
+ if (size > limit) throw new NpmPackageError(502, 'upstream-too-large')
115
+ chunks.push(chunk)
116
+ }
117
+ return Buffer.concat(chunks)
118
+ }
119
+
120
+ // A registry document, or null where the registry has none for this caller:
121
+ // 404, or 401/403 as it answers some private packages asked anonymously.
122
+ // Credentials go only with `token`; nothing is cached on either side, but for
123
+ // public whole documents (publicWholeDocument).
124
+ async function registryDocument(url: string, { token, accept, limit, signal }: { token: string | null; accept: string; limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
125
+ const json = await askUpstreamJson(url, { headers: { accept, ...(token ? { authorization: `Bearer ${token}` } : {}) }, limit, signal, absent: [401, 403, 404] })
126
+ if (json === null || plainObject(json)) return json
127
+ throw new NpmPackageError(502, 'upstream-invalid')
128
+ }
129
+
130
+ // Where public packages' whole documents are kept, off Vercel: the upstream
131
+ // cache dir, set at startup (index.ts); null keeps none.
132
+ let documentCache: string | null = null
133
+ export const setNpmDocumentCache = (dir: string | null) => { documentCache = dir === null ? null : resolve(dir) }
134
+
135
+ // A package's whole document as npm answers anyone (`url` its own), kept on
136
+ // disk where the server keeps them (setNpmDocumentCache) with npm's ETag, and
137
+ // asked for again each time with it: npm answers 304, and nothing more, while
138
+ // it is unchanged. Null where npm has none for anyone, nothing then kept.
139
+ async function publicWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
140
+ const path = documentCache === null ? null : upstreamCachePath(documentCache, 'npm/packuments', `${name}.json.gz`)
141
+ const kept = path === null ? null : await readKeptJson(path, limit)
142
+ let etag: string | null = null, status = 0
143
+ const body = await askUpstream(url, { headers: { accept: 'application/json', ...kept && { 'if-none-match': kept.etag } }, limit, signal, absent: [304, 401, 403, 404],
144
+ answered: res => { status = res.status; etag = res.headers.get('etag') } })
145
+ if (status === 304 && kept) return kept.json
146
+ if (body === null) {
147
+ if (path !== null && kept) await rm(path, { force: true }).catch(() => {})
148
+ return null
149
+ }
150
+ let json: unknown
151
+ try { json = JSON.parse(body.toString('utf8')) } catch { throw new NpmPackageError(502, 'upstream-invalid') }
152
+ if (!plainObject(json)) throw new NpmPackageError(502, 'upstream-invalid')
153
+ if (path !== null && etag !== null) await keepJson(path, etag, body)
154
+ return json
155
+ }
156
+
157
+ // Whole documents being asked of npm as anyone asks, by URL and limit:
158
+ // readers asking for one meanwhile share it, as opening a version asks for
159
+ // its package's for its versions, its advisories and its risk at once; each
160
+ // gives up on it alone once its own signal aborts, and it is given up on once
161
+ // they all have. None is asked with the token. Read alike by every reader,
162
+ // none changing it.
163
+ const wholeAsks = new Map<string, { asked: Promise<Record<string, unknown> | null>; controller: AbortController; readers: number }>()
164
+
165
+ function sharedWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
166
+ if (signal.aborted) return Promise.reject(signal.reason)
167
+ const key = `${url} ${limit}`
168
+ let ask = wholeAsks.get(key)
169
+ if (!ask) {
170
+ const controller = new AbortController()
171
+ const created = { asked: publicWholeDocument(name, url, { limit, signal: controller.signal }), controller, readers: 0 }
172
+ const settle = () => { if (wholeAsks.get(key) === created) wholeAsks.delete(key) }
173
+ created.asked.then(settle, settle)
174
+ wholeAsks.set(key, ask = created)
175
+ }
176
+ const held = ask
177
+ held.readers++
178
+ return new Promise((answer, fail) => {
179
+ let left = false
180
+ const leave = () => {
181
+ if (left) return false
182
+ left = true
183
+ held.readers--
184
+ signal.removeEventListener('abort', onAbort)
185
+ return true
186
+ }
187
+ const onAbort = () => {
188
+ if (!leave()) return
189
+ if (held.readers === 0) {
190
+ if (wholeAsks.get(key) === held) wholeAsks.delete(key)
191
+ held.controller.abort(signal.reason)
192
+ }
193
+ fail(signal.reason)
194
+ }
195
+ const answered = (json: Record<string, unknown> | null) => { if (leave()) answer(json) }
196
+ const failed = (err: unknown) => { if (leave()) fail(err) }
197
+ signal.addEventListener('abort', onAbort, { once: true })
198
+ held.asked.then(answered, failed)
199
+ })
200
+ }
201
+
202
+ // Asked anonymously first, unless its caller knows npm has none for anyone
203
+ // (`anonymous` false): a reader without private access stops there. One
204
+ // with it asks again with the token where npmTokenReads says so, and the
205
+ // answer is private. A whole document (`whole`) anyone may read is kept
206
+ // (publicWholeDocument), and asked once for those asking at once
207
+ // (sharedWholeDocument).
208
+ async function readDocument(name: string, url: string, privileged: boolean,
209
+ { whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
210
+ const open = anonymous ? whole ? await sharedWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
211
+ if (open) return { json: open, private: false }
212
+ if (!npmTokenReads(name, privileged)) return null
213
+ const json = await registryDocument(url, { ...options, token: npmToken() })
214
+ return json ? { json, private: true } : null
215
+ }
216
+
217
+ let documentBytes = 0
218
+
219
+ // A `spare` read, one no reader waits on, is made only where it leaves room
220
+ // for two of the largest documents more: readers' own reads come first.
221
+ async function withDocumentBudget<T>(limit: number, read: () => Promise<T>, spare = false): Promise<T> {
222
+ if (documentBytes + limit + (spare ? 2 * PACKUMENT_BYTES : 0) > MAX_DOCUMENT_BYTES) throw new NpmPackageError(429, 'npm-busy')
223
+ documentBytes += limit
224
+ try { return await read() } finally { documentBytes -= limit }
225
+ }
226
+
227
+ export interface NpmVersionDocument {
228
+ name: string
229
+ version: string
230
+ private: boolean
231
+ // `shasum`, the tarball's sha1 in hex, where npm tells it: held, with the
232
+ // integrity, to the tarball its files are read from (npm-loads.ts).
233
+ dist: { tarball: string; integrity: string; shasum: string | null; unpackedSize: number | null; fileCount: number | null }
234
+ manifest: Record<string, unknown>
235
+ }
236
+
237
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null
238
+
239
+ // The version `json`, a document of package `name`, tells of.
240
+ function versionDocument(name: string, json: Record<string, unknown>, isPrivate: boolean): NpmVersionDocument {
241
+ const dist = json['dist']
242
+ if (json['name'] !== name || typeof json['version'] !== 'string' || !isNpmPackageSpec(json['version']) || !plainObject(dist)
243
+ || typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
244
+ || (dist['shasum'] !== undefined && typeof dist['shasum'] !== 'string')) throw new NpmPackageError(502, 'upstream-invalid')
245
+ return {
246
+ name, version: json['version'], private: isPrivate,
247
+ dist: { tarball: dist['tarball'], integrity: dist['integrity'], shasum: typeof dist['shasum'] === 'string' ? dist['shasum'] : null,
248
+ unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
249
+ manifest: npmManifest(json),
250
+ }
251
+ }
252
+
253
+ // Where public versions' documents are kept, of the fields read of them
254
+ // (npm-manifest.ts npmVersionFields), as the database keeps them (npm-kept.ts).
255
+ export interface NpmVersionStore {
256
+ read(name: string, version: string): Promise<{ at: number; json: Record<string, unknown> } | null>
257
+ // Those of `versions` kept, by `name@version`, in one read.
258
+ readMany?(versions: { name: string; version: string }[]): Promise<Map<string, { at: number; json: Record<string, unknown> }>>
259
+ write(name: string, version: string, json: Record<string, unknown>): Promise<void>
260
+ }
261
+
262
+ // The version a spec names, a dist-tag resolved by the registry, with its
263
+ // dist. Null where the registry has none for this reader. With `store`, an
264
+ // exact version kept there for less than a day (VERSION_KEPT_MS) is read from
265
+ // it, not asked of npm, and a public one npm answers is kept. npm never
266
+ // changes a version's files, but its document can change: a deprecation, the
267
+ // package made private (a scoped one can be), the version removed (which whoever
268
+ // reads it from a store learns elsewhere too: risk does). A day bounds each.
269
+ // One kept with no shasum, as none was before it was kept (npmVersionFields),
270
+ // is asked of npm again, which tells one of each version.
271
+ export const VERSION_KEPT_MS = 24 * 60 * 60_000
272
+ export function readNpmVersion(name: string, spec: string, privileged: boolean, signal: AbortSignal, store?: NpmVersionStore): Promise<NpmVersionDocument | null> {
273
+ if (!isNpmPackageName(name) || !isNpmPackageSpec(spec)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
274
+ // A kept one is read within the same budget as npm's answer.
275
+ return withDocumentBudget(VERSION_DOCUMENT_BYTES, async () => {
276
+ const kept = store && isExactVersion(spec) ? await store.read(name, spec) : null
277
+ const keptDist = kept?.json['dist']
278
+ if (kept && Date.now() - kept.at < VERSION_KEPT_MS && plainObject(keptDist) && typeof keptDist['shasum'] === 'string') {
279
+ try { return versionDocument(name, kept.json, false) } catch {}
280
+ }
281
+ const found = await readDocument(name, registryUrl(name, spec), privileged, { accept: 'application/json', limit: VERSION_DOCUMENT_BYTES, signal })
282
+ if (!found) return null
283
+ const doc = versionDocument(name, found.json, found.private)
284
+ if (store && !found.private) await store.write(name, doc.version, npmVersionFields(found.json))
285
+ return doc
286
+ })
287
+ }
288
+
289
+ // `store`, where it reads many at once, with the exact ones of `versions`
290
+ // read from it in one read, for readNpmVersion to read each of those from as
291
+ // it asks: a version and the one it is compared with, read together.
292
+ export function readTogether(store: NpmVersionStore, versions: { name: string; version: string }[]): NpmVersionStore {
293
+ const exact = versions.filter(({ version }) => isExactVersion(version))
294
+ if (!store.readMany || exact.length < 2) return store
295
+ const keys = new Set(exact.map(({ name, version }) => `${name}@${version}`))
296
+ const kept = store.readMany(exact)
297
+ return { ...store, async read(name, version) {
298
+ const key = `${name}@${version}`
299
+ return keys.has(key) ? (await kept).get(key) ?? null : store.read(name, version)
300
+ } }
301
+ }
302
+
303
+ // The package's dist-tags and versions, newest published first, from its
304
+ // abbreviated document; with `times`, from its whole one, which alone has
305
+ // when each was published, `times` by version (ISO dates), and `created`,
306
+ // when its first version was, one since unpublished among them: the earliest
307
+ // time it lists, which npm's own `created` can postdate, as a placeholder's
308
+ // does for a package npm took down. With `spare`, as a read no reader waits
309
+ // on (withDocumentBudget). Null where the registry has none for this reader.
310
+ export function readNpmVersions(name: string, privileged: boolean, signal: AbortSignal, { times = false, spare = false } = {}) {
311
+ if (!isNpmPackageName(name)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
312
+ return withDocumentBudget(PACKUMENT_BYTES, async () => {
313
+ const accept = times ? 'application/json' : 'application/vnd.npm.install-v1+json'
314
+ const found = await readDocument(name, registryUrl(name), privileged, { accept, limit: PACKUMENT_BYTES, signal, whole: times })
315
+ if (!found) return null
316
+ const { json } = found
317
+ if (json['name'] !== name || !plainObject(json['versions'])) throw new NpmPackageError(502, 'upstream-invalid')
318
+ const versions = Object.keys(json['versions']).filter(isNpmPackageSpec).toReversed()
319
+ const distTags = Object.fromEntries(Object.entries(stringRecord(json['dist-tags']) ?? {}).filter(([, version]) => versions.includes(version)))
320
+ const listed = { name, private: found.private, distTags, versions }
321
+ if (!times) return listed
322
+ const time = stringRecord(json['time']) ?? {}
323
+ const published = Object.fromEntries(versions.filter(version => isPublishTime(time[version])).map(version => [version, time[version]!]))
324
+ for (const [version, at] of Object.entries(published)) publishTimes.set(`${name}@${version}`, at)
325
+ while (publishTimes.size > PUBLISH_TIMES) publishTimes.delete(publishTimes.keys().next().value!)
326
+ const listedAt = Object.entries(time).filter(([key, at]) => key !== 'modified' && isPublishTime(at)).map(([, at]) => at)
327
+ const created = listedAt.reduce<string | null>((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null)
328
+ return { ...listed, times: published, created }
329
+ }, spare)
330
+ }
331
+
332
+ // A package's whole document as npm has it for this reader, for risk
333
+ // (npm-risk.ts), within the bounds and budget every document read here is
334
+ // held to; null where npm has none for this reader, or the name is none npm
335
+ // takes. With `anonymous` false, where npm is known to have none for anyone,
336
+ // it is asked only with the token (readDocument).
337
+ export function readNpmPackument(name: string, privileged: boolean, signal: AbortSignal, { anonymous = true } = {}): Promise<Record<string, unknown> | null> {
338
+ if (!isNpmPackageName(name)) return Promise.resolve(null)
339
+ return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
340
+ { accept: 'application/json', limit: PACKUMENT_BYTES, signal, whole: true, anonymous }))?.json ?? null)
341
+ }
342
+
343
+ const isPublishTime = (value: string | undefined) => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
344
+
345
+ // When each version was published, by `name@version`, as whole documents read
346
+ // with `times` have said: a version keeps its time once published, so it is
347
+ // kept for as long as the server runs, the first noted going first past a
348
+ // bound, and answered only with the version, once its reader's access is.
349
+ const PUBLISH_TIMES = 100_000
350
+ const publishTimes = new Map<string, string>()
351
+
352
+ // Where public versions' publish times are kept beyond the instance, as the
353
+ // database keeps them (npm-kept.ts npmPublishTimeStore).
354
+ export interface NpmPublishTimeStore {
355
+ read(name: string, version: string): Promise<string | null>
356
+ write(name: string, version: string, at: string): Promise<void>
357
+ }
358
+
359
+ // When `doc` was published: as noted here, else as `store` keeps a public
360
+ // version's, so that another instance, or this one restarted, has it before
361
+ // it reads the whole document again. The store is read once, at once, beside
362
+ // whatever its caller reads meanwhile, a failure read as none; `read` looks
363
+ // here again as it is asked, as the whole document read meanwhile may have
364
+ // noted it by then, and `keep` keeps there the time noted here where it read
365
+ // none.
366
+ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version' | 'private'>, store?: NpmPublishTimeStore) {
367
+ const noted = () => publishTimes.get(`${doc.name}@${doc.version}`) ?? null
368
+ const kept = store && !doc.private ? store.read(doc.name, doc.version).catch(() => null) : Promise.resolve(null)
369
+ return {
370
+ read: async (): Promise<string | null> => noted() ?? await kept ?? noted(),
371
+ async keep() {
372
+ const at = noted()
373
+ if (at !== null && store && !doc.private && await kept === null) await store.write(doc.name, doc.version, at)
374
+ },
375
+ }
376
+ }
377
+
378
+ export interface NpmPackageFile { path: string; bytes: Uint8Array }
379
+
380
+ // How a tar stream is read (readNpmTar): how far its symlinks may climb above
381
+ // it, and the entries, files and bytes it may hold, a package's unless given.
382
+ export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
383
+
384
+ // The regular files of a tar stream, by package path: npm drops the first
385
+ // component (`package/`, or whatever the archive used), and a file at the top
386
+ // is not extracted. The stream is read by @preventive/archive, which refuses
387
+ // one malformed, a name or a symlink that would climb out of it -- a symlink
388
+ // past `symlinksAbove` levels above it, none unless given -- and a name
389
+ // repeated as another entry, and gives up past `maxEntries` before reading
390
+ // on; a file under another top directory at the same path replaces an
391
+ // earlier one, as extraction would. Directories, links and devices hold no
392
+ // file content and are skipped.
393
+ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
394
+ maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
395
+ let entries: Entry[]
396
+ try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
397
+ catch (err) {
398
+ if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
399
+ throw err
400
+ }
401
+ const files = new Map<string, Uint8Array>()
402
+ let total = 0
403
+ for (const { name, type, data } of entries) {
404
+ const slash = name.indexOf('/')
405
+ if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
406
+ const path = name.slice(slash + 1)
407
+ total += data.length - (files.get(path)?.length ?? 0)
408
+ files.set(path, data)
409
+ if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
410
+ }
411
+ return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
412
+ }
413
+
414
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
415
+
416
+ // A file's text, or null for bytes that are not UTF-8 text.
417
+ export function npmFileText(bytes: Uint8Array): string | null {
418
+ if (bytes.includes(0)) return null
419
+ try { return utf8.decode(bytes) } catch { return null }
420
+ }
421
+
422
+ // A string's length in JSON: quotes, backslashes and the control characters
423
+ // with a short escape take two characters, the other control characters six.
424
+ function jsonLength(text: string): number {
425
+ let length = text.length + 2
426
+ for (let i = 0; i < text.length; i++) {
427
+ const code = text.codePointAt(i)!
428
+ if (code === 0x22 || code === 0x5c) length += 1
429
+ else if (code < 0x20) length += code >= 0x08 && code <= 0x0d && code !== 0x0b ? 1 : 5
430
+ }
431
+ return length
432
+ }
433
+
434
+ // A row's brackets, size, null and digest, past its strings.
435
+ const ROW_LENGTH = 80
436
+
437
+ // The files as the viewer gets them: `[path, bytes, text]`, text null for a
438
+ // file that is not UTF-8, which instead carries its bytes' sha256, so a
439
+ // comparison of two versions tells a changed one from an unchanged one. They
440
+ // may take `maxLength` characters of JSON, a package's unless given.
441
+ export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
442
+ let length = 0
443
+ return files.map(({ path, bytes }) => {
444
+ const text = npmFileText(bytes)
445
+ length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
446
+ if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
447
+ return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
448
+ })
449
+ }
450
+
451
+ // The tarball's filename as `npm pack` writes it.
452
+ export function npmTarballFilename(name: string, version: string): string {
453
+ return `${name.replace(/^@/u, '').replace('/', '-')}-${version}.tgz`
454
+ }
@@ -0,0 +1,123 @@
1
+ // npm's provenance for a public version (npm-packages.ts npmManifest marks
2
+ // one that has it): the SLSA statement npm's registry keeps beside it,
3
+ // saying where the version was built: from which repository and commit, by
4
+ // which workflow, on which builder, in which run, with its entry in
5
+ // Sigstore's transparency log. Only a statement naming this version's
6
+ // tarball (its name, version and sha512 digest) is read; its signature isn't
7
+ // checked here, as npm checks it when the version is published. npm is asked
8
+ // without the server's token, and only about a version it answers for
9
+ // without one (http.ts), each answer kept as npm-insights.ts keeps its own.
10
+ import { Buffer } from 'node:buffer'
11
+ import { kept } from './kept.ts'
12
+ import { NPM_REGISTRY, askUpstreamJson, plainObject } from './npm-packages.ts'
13
+
14
+ const ATTESTATIONS_API = `${NPM_REGISTRY}/-/npm/v1/attestations`
15
+ const ATTESTATIONS_BYTES = 1024 * 1024
16
+ // SLSA's v1 statement, as npm writes it now, and v0.2, as it first did.
17
+ const SLSA = /^https:\/\/slsa\.dev\/provenance\/v(?:1|0\.2)$/u
18
+ const COMMIT = /^[0-9a-f]{40}$/u
19
+ const GITHUB_REPOSITORY = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)$/u
20
+
21
+ // Where a version was built: its source `repository` (an https URL) and
22
+ // `commit`, the `workflow` file and the `ref` it ran on, the `builder` and
23
+ // the `run` (an https URL), its transparency log entry (`logIndex`), and
24
+ // whose runner built it (`runner`: `github-hosted`, `gitlab-hosted` or
25
+ // `self-hosted`, as its signing certificate says); each null where they
26
+ // don't say.
27
+ export interface NpmProvenance {
28
+ repository: string; commit: string | null; workflow: string | null; ref: string | null
29
+ builder: string | null; run: string | null; logIndex: number | null; runner: string | null
30
+ }
31
+
32
+ const text = (value: unknown) => typeof value === 'string' && value !== '' && value.length <= 2048 ? value : null
33
+ const https = (value: unknown) => {
34
+ const url = text(value)
35
+ try { return url && new URL(url).protocol === 'https:' ? url : null } catch { return null }
36
+ }
37
+ // `git+https://github.com/org/repo@refs/heads/main` → the repository and
38
+ // ref; GitLab's names no ref (`git+https://gitlab.com/group/project`).
39
+ function sourceOf(uri: unknown) {
40
+ const match = /^git\+(https:\/\/[^@\s]+?)(?:\.git)?(?:@(.+))?$/u.exec(text(uri) ?? '')
41
+ return match ? { repository: https(match[1]), ref: match[2] ?? null } : { repository: null, ref: null }
42
+ }
43
+ // What `keys` lead to in `value`, through objects and arrays.
44
+ const field = (value: unknown, ...keys: string[]): unknown =>
45
+ keys.reduce<unknown>((at, key) => plainObject(at) || Array.isArray(at) ? (at as Record<string, unknown>)[key] : undefined, value)
46
+
47
+ // What a statement says of the build, as v1 or v0.2 says it; null where it
48
+ // names no source repository.
49
+ function readStatement(predicateType: string, predicate: unknown): NpmProvenance | null {
50
+ if (predicateType.endsWith('v1')) {
51
+ const workflow = field(predicate, 'buildDefinition', 'externalParameters', 'workflow')
52
+ const source = field(predicate, 'buildDefinition', 'resolvedDependencies', '0')
53
+ const repository = https(field(workflow, 'repository')) ?? sourceOf(field(source, 'uri')).repository
54
+ if (!repository) return null
55
+ const commit = text(field(source, 'digest', 'gitCommit'))
56
+ return {
57
+ repository, commit: commit && COMMIT.test(commit) ? commit : null, workflow: text(field(workflow, 'path')),
58
+ ref: text(field(workflow, 'ref')) ?? sourceOf(field(source, 'uri')).ref, builder: https(field(predicate, 'runDetails', 'builder', 'id')),
59
+ run: https(field(predicate, 'runDetails', 'metadata', 'invocationId')), logIndex: null, runner: null,
60
+ }
61
+ }
62
+ const config = field(predicate, 'invocation', 'configSource')
63
+ const { repository, ref } = sourceOf(field(config, 'uri'))
64
+ if (!repository) return null
65
+ const commit = text(field(config, 'digest', 'sha1'))
66
+ // Its run: GitLab's job URL, or GitHub's as `<run id>-<attempt>`.
67
+ const invocation = field(predicate, 'metadata', 'buildInvocationId')
68
+ const run = /^(\d+)-(\d+)$/u.exec(text(invocation) ?? '')
69
+ const github = GITHUB_REPOSITORY.exec(repository)?.[1]
70
+ return {
71
+ repository, commit: commit && COMMIT.test(commit) ? commit : null, workflow: text(field(config, 'entryPoint')), ref,
72
+ builder: https(field(predicate, 'builder', 'id')),
73
+ run: https(invocation) ?? (run && github ? `https://github.com/${github}/actions/runs/${run[1]}/attempts/${run[2]}` : null), logIndex: null, runner: null,
74
+ }
75
+ }
76
+
77
+ // Fulcio's Runner Environment extension (1.3.6.1.4.1.57264.1.11) in the
78
+ // certificate that signed it: its OID, then its value, a short UTF8String
79
+ // in an OCTET STRING, as the CI's token gave it. A builder's id doesn't
80
+ // tell GitLab's own runners from a project's.
81
+ const RUNNER_ENVIRONMENT = Buffer.from('060a2b0601040183bf30010b', 'hex')
82
+ function runnerOf(bundle: unknown): string | null {
83
+ const raw = field(bundle, 'verificationMaterial', 'certificate', 'rawBytes') ?? field(bundle, 'verificationMaterial', 'x509CertificateChain', 'certificates', '0', 'rawBytes')
84
+ if (typeof raw !== 'string') return null
85
+ const der = Buffer.from(raw, 'base64')
86
+ const at = der.indexOf(RUNNER_ENVIRONMENT) + RUNNER_ENVIRONMENT.length
87
+ if (at < RUNNER_ENVIRONMENT.length || der[at] !== 0x04 || der[at + 2] !== 0x0c || der[at + 1] !== der[at + 3]! + 2) return null
88
+ return text(der.subarray(at + 4, at + 4 + der[at + 3]!).toString('utf8'))
89
+ }
90
+
91
+ async function askProvenance(name: string, version: string, integrity: string): Promise<NpmProvenance | null> {
92
+ const json = await askUpstreamJson(`${ATTESTATIONS_API}/${name.replace('/', '%2f')}@${encodeURIComponent(version)}`,
93
+ { headers: { accept: 'application/json' }, limit: ATTESTATIONS_BYTES })
94
+ if (json === null) return null
95
+ const attestations: unknown[] = Array.isArray(field(json, 'attestations')) ? field(json, 'attestations') as unknown[] : []
96
+ const slsa = attestations.find(item => SLSA.test(text(field(item, 'predicateType')) ?? ''))
97
+ if (!slsa) return null
98
+ // The statement, whatever its length (the answer is bounded as a whole),
99
+ // unlike the fields read out of it.
100
+ const payload = field(slsa, 'bundle', 'dsseEnvelope', 'payload')
101
+ let statement: unknown
102
+ try { statement = JSON.parse(Buffer.from(typeof payload === 'string' ? payload : '', 'base64').toString('utf8')) } catch { return null }
103
+ // Only one naming this version's tarball: its purl, and its digest as the
104
+ // version's integrity has it.
105
+ const purl = `pkg:npm/${name.replace(/^@/u, '%40')}@${version}`
106
+ const digest = /^sha512-(.+)$/u.exec(integrity)?.[1]
107
+ const subjects: unknown[] = Array.isArray(field(statement, 'subject')) ? field(statement, 'subject') as unknown[] : []
108
+ if (!digest || !subjects.some(subject => field(subject, 'name') === purl && field(subject, 'digest', 'sha512') === Buffer.from(digest, 'base64').toString('hex'))) return null
109
+ const provenance = readStatement(text(field(slsa, 'predicateType'))!, field(statement, 'predicate'))
110
+ const logIndex = Number(field(slsa, 'bundle', 'verificationMaterial', 'tlogEntries', '0', 'logIndex'))
111
+ return provenance && { ...provenance, logIndex: Number.isSafeInteger(logIndex) && logIndex >= 0 ? logIndex : null, runner: runnerOf(field(slsa, 'bundle')) }
112
+ }
113
+
114
+ const provenances = kept<NpmProvenance | null>(1000)
115
+
116
+ // npm's provenance for a version of a public package, its tarball's
117
+ // `integrity` as the registry has it; null where npm keeps none for it.
118
+ export function npmProvenance(name: string, version: string, integrity: string): Promise<NpmProvenance | null> {
119
+ return provenances(`${name}@${version}`, () => askProvenance(name, version, integrity))
120
+ }
121
+
122
+ // One kept, asking for nothing; undefined where none is.
123
+ export const keptNpmProvenance = (name: string, version: string) => provenances.peek(`${name}@${version}`)