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

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 (368) 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 +3 -0
  50. package/common/managed/finding-detail.js +66 -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 +7 -0
  54. package/common/managed/npm-packages.js +51 -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 +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
  237. package/server-managed/comparison.ts +52 -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 +468 -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 +143 -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 +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -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 +376 -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 +136 -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 +5503 -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/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,325 @@
1
+ // A pnpm patch of an npm package version (patchedDependencies), as its
2
+ // dependency review reads it (pull-dependency-reviews.ts): its file, which a
3
+ // lockfile names (locked-packages.ts `patchPath`), read of a repository at a
4
+ // commit, and held to the hash the lockfile names it by, as pnpm hashes it,
5
+ // a sha256 in hex from pnpm 10 and an md5 in base32 before, of its text with
6
+ // its CRLFs read as LFs; and applied to the version's files as pnpm applies
7
+ // it as it installs it (patch-package's applyPatch, which pnpm 9, 10 and 11
8
+ // bundle): each file it deletes, creates, renames or changes, a change's
9
+ // hunks each found in the file as it was, where its header says, else as
10
+ // many as FUZZ lines either side, its lines the same but for the space they
11
+ // end in, and those it keeps kept as the file has them. A patch of a file's
12
+ // bytes (git's binary patch), or of a file outside the package, isn't read,
13
+ // and one that doesn't apply fails as a whole; what a patch changes of a
14
+ // file's mode is left out, as the files it reads have none.
15
+ import { Buffer } from 'node:buffer'
16
+ import { createHash } from 'node:crypto'
17
+ import { createClient } from '@preventive/upstream/github.js'
18
+ import { isPatchHash, isPatchPath } from '../common/managed/npm-packages.js'
19
+ import { GithubApiError } from './github-app.ts'
20
+ import { upstreamGithub } from './github-errors.ts'
21
+ import { type Lru, lru } from './lru.ts'
22
+ import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
23
+ import type { GithubAsked } from './pull-dependencies.ts'
24
+
25
+ // Where a patch is read: its file in a repository (by its id) at a commit,
26
+ // from the repository's root; and the patch, by its hash besides.
27
+ export interface PatchSource { repoId: number; commit: string; path: string }
28
+ export interface NpmPatch extends PatchSource { hash: string }
29
+
30
+ // The most a patch's file may hold, and how many lines either side of where a
31
+ // hunk says its lines are they are looked for.
32
+ export const MAX_PATCH_BYTES = 8 * 1024 * 1024
33
+ const FUZZ = 20
34
+
35
+ const COMMIT = /^[\da-f]{40}$/u
36
+
37
+ // The patch a request names, by its hash (`hash`), its repository's id
38
+ // (`repoId`, a number or its digits), the commit and its file's path; null
39
+ // where it names none of where it is, and 'invalid' where it names one
40
+ // otherwise.
41
+ export function requestedPatch({ hash, repoId, commit, path }: { hash: unknown; repoId: unknown; commit: unknown; path: unknown }): NpmPatch | null | 'invalid' {
42
+ if ([repoId, commit, path].every(field => field == null)) return null
43
+ const id = typeof repoId === 'string' && /^[1-9]\d{0,15}$/u.test(repoId) ? Number(repoId) : repoId
44
+ if (!isPatchHash(hash) || !Number.isSafeInteger(id) || (id as number) <= 0 || typeof commit !== 'string' || !COMMIT.test(commit)
45
+ || !isPatchPath(path)) return 'invalid'
46
+ return { hash, repoId: id as number, commit, path }
47
+ }
48
+
49
+ // RFC 4648's base32 of `bytes`, in lowercase and unpadded, as pnpm 9 spells
50
+ // an md5.
51
+ const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
52
+ function base32(bytes: Uint8Array) {
53
+ let bits = 0, spelled = '', value = 0
54
+ for (const byte of bytes) {
55
+ value = ((value << 8) | byte) & 0xfff
56
+ bits += 8
57
+ for (; bits >= 5; bits -= 5) spelled += BASE32[(value >>> (bits - 5)) & 31]
58
+ }
59
+ return bits > 0 ? spelled + BASE32[(value << (5 - bits)) & 31] : spelled
60
+ }
61
+
62
+ // Whether `text` is the patch `hash` names: of its text with its CRLFs as
63
+ // LFs, as pnpm reads it, or as it is.
64
+ export function isPatchOf(text: string, hash: string): boolean {
65
+ return [...new Set([text.replaceAll('\r\n', '\n'), text])].some(read => hash.length === 64
66
+ ? createHash('sha256').update(read).digest('hex') === hash : base32(createHash('md5').update(read).digest()) === hash)
67
+ }
68
+
69
+ // A change a patch makes to a file at `path` of the package: deleting it,
70
+ // creating it, renaming it from another (`from`), and its hunks, as
71
+ // patch-package reads them: where its header says its lines are (`at`, the
72
+ // first's index, as it reads `-0`, the first line), how many it finds
73
+ // (`length`), and its lines, each without its LF, in runs of a kind
74
+ // (`parts`): those it keeps, removes or adds, the last of a side told where
75
+ // the file has no LF after it (`unended`).
76
+ export interface HunkPart { kind: 'context' | 'deletion' | 'insertion'; lines: string[]; unended?: true }
77
+ export interface PatchHunk { at: number; length: number; parts: HunkPart[] }
78
+ export interface PatchedFile { path: string; from?: string; deleted?: true; created?: true; hunks: PatchHunk[] }
79
+
80
+ const unreadable = () => new NpmPackageError(422, 'patch-unreadable')
81
+
82
+ // A path as git writes it, unquoted where quoted (as C writes a string, its
83
+ // bytes past ASCII in octal); null where it is quoted otherwise.
84
+ const ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
85
+ function unquoted(path: string): string | null {
86
+ if (!path.startsWith('"')) return path
87
+ const bytes: number[] = []
88
+ for (let at = 1; at < path.length; at++) {
89
+ const char = path[at]!
90
+ if (char === '"') return at === path.length - 1 ? new TextDecoder().decode(Uint8Array.from(bytes)) : null
91
+ if (char !== '\\') { bytes.push(...Buffer.from(char)); continue }
92
+ const next = path[++at] ?? ''
93
+ const octal = /^[0-3][0-7]{2}/u.exec(path.slice(at))?.[0]
94
+ if (octal) { bytes.push(Number.parseInt(octal, 8)); at += 2 }
95
+ else if (next in ESCAPES) bytes.push(ESCAPES[next]!)
96
+ else return null
97
+ }
98
+ return null
99
+ }
100
+
101
+ // A side's path in the package, as a `---` or `+++` line names it after its
102
+ // prefix (`a/` or `b/`), git's tab after one with a space in it left out;
103
+ // null for none (`/dev/null`).
104
+ function sidePath(named: string, prefix: string): string | null {
105
+ const path = unquoted(named.endsWith('\t') ? named.slice(0, -1) : named)
106
+ if (path === '/dev/null') return null
107
+ if (path === null || !path.startsWith(prefix)) throw unreadable()
108
+ return path.slice(prefix.length)
109
+ }
110
+
111
+ // The paths a `diff --git` line names, where they are the same, as git
112
+ // writes them for a file neither renamed nor copied: `a/<path> b/<path>`.
113
+ function gitPath(named: string): string | null {
114
+ if (named.startsWith('"')) {
115
+ const quoted = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/u.exec(named)
116
+ const [a, b] = quoted ? [unquoted(quoted[1]!), unquoted(quoted[2]!)] : []
117
+ return a?.startsWith('a/') && b?.startsWith('b/') && a.slice(2) === b.slice(2) ? a.slice(2) : null
118
+ }
119
+ const length = (named.length - 5) / 2
120
+ const path = named.slice(2, 2 + length)
121
+ return Number.isInteger(length) && named.startsWith('a/') && named.slice(2 + length) === ` b/${path}` ? path : null
122
+ }
123
+
124
+ // The hunk whose `@@` line is at `at` of `lines`, those before `end` read,
125
+ // and the line after it, as patch-package reads it: a line of its own
126
+ // kind, else of those it keeps where it is empty, or a CR alone.
127
+ function readHunk(lines: string[], at: number, end: number): { hunk: PatchHunk; next: number } {
128
+ const range = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/u.exec(lines[at++]!)
129
+ if (!range) throw unreadable()
130
+ let news = Number(range[4] ?? 1), olds = Number(range[2] ?? 1)
131
+ const hunk: PatchHunk = { at: Math.max(Number(range[1]), 1) - 1, length: olds, parts: [] }
132
+ while (olds > 0 || news > 0) {
133
+ // A line its count reads past those read isn't one.
134
+ if (at >= end) throw unreadable()
135
+ const line = lines[at++]!
136
+ const kind = line === '' || line === '\r' || line[0] === ' ' ? 'context' : line[0] === '-' ? 'deletion' : line[0] === '+' ? 'insertion' : null
137
+ if (!kind) throw unreadable()
138
+ const last = hunk.parts.at(-1)
139
+ const part = last?.kind === kind ? last : hunk.parts[hunk.parts.push({ kind, lines: [] }) - 1]!
140
+ part.lines.push(line.slice(1))
141
+ if (kind !== 'insertion') olds--
142
+ if (kind !== 'deletion') news--
143
+ if (olds < 0 || news < 0) throw unreadable()
144
+ // The line before it the last of its side, with no LF after it.
145
+ if (lines[at]?.startsWith('\\')) {
146
+ if (!lines[at++]!.startsWith('\')) throw unreadable()
147
+ part.unended = true
148
+ }
149
+ }
150
+ return { hunk, next: at }
151
+ }
152
+
153
+ // A patch's files as git's diff writes them, as pnpm writes a patch: a file's
154
+ // `diff --git` line, its headers, the paths its sides have, and its hunks;
155
+ // what comes before the first file, or between one file and the next, left
156
+ // out. One whose own lines end in CRLFs, as an editor or git's autocrlf
157
+ // leaves one, is read as patch-package reads it: its headers but their CRs,
158
+ // its hunks' lines with them. Throws for one it can't read, or of bytes.
159
+ export function parsePatch(patch: string): PatchedFile[] {
160
+ const lines = patch.split('\n')
161
+ // The lines read: those but the empty one past the patch's last ending.
162
+ const end = lines.length - (patch.endsWith('\n') ? 1 : 0)
163
+ // A header's line but its CR.
164
+ const headerAt = (at: number) => lines[at]?.replace(/\r$/u, '') ?? ''
165
+ const files: PatchedFile[] = []
166
+ for (let at = 0; at < lines.length;) {
167
+ const head = headerAt(at++)
168
+ if (!head.startsWith('diff --git ')) continue
169
+ let created = false, deleted = false, from: string | null = null, to: string | null = null
170
+ let sides: [string | null, string | null] | null = null
171
+ for (; at < lines.length && !/^(?:diff --git |@@ |--- )/u.test(lines[at]!); at++) {
172
+ const header = headerAt(at)
173
+ if (header === 'GIT binary patch' || header.startsWith('Binary files ')) throw new NpmPackageError(422, 'patch-unsupported')
174
+ if (header.startsWith('copy from ') || header.startsWith('copy to ')) throw new NpmPackageError(422, 'patch-unsupported')
175
+ if (header.startsWith('deleted file mode ')) deleted = true
176
+ else if (header.startsWith('new file mode ')) created = true
177
+ else if (header.startsWith('rename from ')) from = unquoted(header.slice('rename from '.length))
178
+ else if (header.startsWith('rename to ')) to = unquoted(header.slice('rename to '.length))
179
+ }
180
+ if (lines[at]?.startsWith('--- ')) {
181
+ if (!lines[at + 1]?.startsWith('+++ ')) throw unreadable()
182
+ sides = [sidePath(headerAt(at).slice(4), 'a/'), sidePath(headerAt(at + 1).slice(4), 'b/')]
183
+ at += 2
184
+ }
185
+ const named = gitPath(head.slice('diff --git '.length))
186
+ const after = to ?? (sides ? sides[1] : named), before = from ?? (sides ? sides[0] : named)
187
+ if ((from === null) !== (to === null) || (deleted && created)) throw unreadable()
188
+ const path = deleted ? before : after
189
+ if (path == null || (!created && before == null) || (!deleted && after == null) || ![before, after].every(side => side == null || isPatchPath(side))
190
+ || (from === null && !deleted && !created && before !== after)) throw unreadable()
191
+ const hunks: PatchHunk[] = []
192
+ while (lines[at]?.startsWith('@@ ')) {
193
+ const { hunk, next } = readHunk(lines, at, end)
194
+ hunks.push(hunk)
195
+ at = next
196
+ }
197
+ files.push({ path, ...from !== null && to !== null ? { from: before! } : {}, ...deleted ? { deleted: true as const } : {},
198
+ ...created ? { created: true as const } : {}, hunks: deleted ? [] : hunks })
199
+ }
200
+ return files
201
+ }
202
+
203
+ const doesNotApply = () => new NpmPackageError(422, 'patch-does-not-apply')
204
+
205
+ // A change a hunk makes to a file's lines (`lines`, split at its LFs): those
206
+ // at `splice` that it removes (`remove`) and adds in their place (`insert`),
207
+ // or an empty last line it adds or removes, its LF after the last.
208
+ type LineChange = { splice: number; remove: number; insert: string[] } | 'push' | 'pop'
209
+
210
+ // The changes `hunk` makes where its lines are at `at` of `lines`, as
211
+ // patch-package works them out (evaluateHunk): those it finds the same but
212
+ // for the space they end in, as it compares them; else null.
213
+ function changesAt(hunk: PatchHunk, lines: string[], at: number): LineChange[] | null {
214
+ if (at < 0 || lines.length - at < hunk.length) return null
215
+ const changes: LineChange[] = []
216
+ for (const part of hunk.parts) {
217
+ if (part.kind === 'insertion') {
218
+ changes.push({ splice: at, remove: 0, insert: part.lines })
219
+ if (part.unended) changes.push('pop')
220
+ continue
221
+ }
222
+ for (const line of part.lines) if (lines[at++]!.trimEnd() !== line.trimEnd()) return null
223
+ if (part.kind === 'deletion') {
224
+ changes.push({ splice: at - part.lines.length, remove: part.lines.length, insert: [] })
225
+ if (part.unended) changes.push('push')
226
+ }
227
+ }
228
+ return changes
229
+ }
230
+
231
+ // `text` with `hunks` applied as patch-package applies them (applyPatch):
232
+ // each one's lines found in the text as it was, where its header says, else
233
+ // as near as FUZZ lines either side, the nearer first, and the earlier of
234
+ // two as near; then its changes made in turn, past the lines those before
235
+ // added or removed.
236
+ function applied(text: string, hunks: PatchHunk[]): string {
237
+ const lines = text.split('\n')
238
+ const changes = hunks.flatMap(hunk => {
239
+ for (let offset = 0; Math.abs(offset) <= FUZZ; offset = offset < 0 ? -offset : -offset - 1) {
240
+ const found = changesAt(hunk, lines, hunk.at + offset)
241
+ if (found) return found
242
+ }
243
+ throw doesNotApply()
244
+ })
245
+ let shift = 0
246
+ for (const change of changes) {
247
+ if (change === 'push') lines.push('')
248
+ else if (change === 'pop') lines.pop()
249
+ else {
250
+ lines.splice(change.splice + shift, change.remove, ...change.insert)
251
+ shift += change.insert.length - change.remove
252
+ }
253
+ }
254
+ return lines.join('\n')
255
+ }
256
+
257
+ // A version's `files` with `patch` applied, in the order of their paths, as
258
+ // its tarball's are read; a file it changes the text of must be text. One it
259
+ // creates holds its first hunk's first lines, in place of any there, as
260
+ // patch-package writes it.
261
+ export function patchedFiles(files: NpmPackageFile[], patch: PatchedFile[]): NpmPackageFile[] {
262
+ const byPath = new Map(files.map(file => [file.path, file.bytes]))
263
+ const encoder = new TextEncoder()
264
+ for (const file of patch) {
265
+ if (file.created) {
266
+ const first = file.hunks[0]?.parts[0]
267
+ byPath.set(file.path, encoder.encode(first ? `${first.lines.join('\n')}${first.unended ? '' : '\n'}` : ''))
268
+ continue
269
+ }
270
+ const source = file.from ?? file.path
271
+ const bytes = byPath.get(source)
272
+ if (!bytes) throw doesNotApply()
273
+ if (file.deleted) { byPath.delete(source); continue }
274
+ let next = bytes
275
+ if (file.hunks.length > 0) {
276
+ const text = npmFileText(bytes)
277
+ if (text === null) throw doesNotApply()
278
+ next = encoder.encode(applied(text, file.hunks))
279
+ }
280
+ if (file.from !== undefined) {
281
+ if (byPath.has(file.path)) throw doesNotApply()
282
+ byPath.delete(source)
283
+ }
284
+ byPath.set(file.path, next)
285
+ }
286
+ return [...byPath].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
287
+ }
288
+
289
+ // About the bytes a patch read takes: the characters of its paths and of the
290
+ // lines its hunks hold, and the room past them each line takes (LINE_COST)
291
+ // and each file, hunk and part (HUNK_COST), as V8 takes it; so a patch of a
292
+ // few long lines (a minified file's) weighs about its size, and one of many
293
+ // short lines or tiny hunks many times it, as it takes.
294
+ const HUNK_COST = 256, LINE_COST = 40
295
+ const held = (lines: string[]) => lines.reduce((sum, line) => sum + LINE_COST + line.length, HUNK_COST)
296
+ export const patchWeight = (files: PatchedFile[]) => files.reduce((sum, file) => sum + HUNK_COST + file.path.length + (file.from?.length ?? 0)
297
+ + file.hunks.reduce((bytes, hunk) => bytes + HUNK_COST + hunk.parts.reduce((parts, part) => parts + held(part.lines), 0), 0), 1)
298
+
299
+ // Patches read lately, by their source and hash, as what a commit holds never
300
+ // changes: as many as MAX_KEPT_PATCHES of the bytes they take (patchWeight).
301
+ const MAX_KEPT_PATCHES = 64 * 1024 * 1024
302
+ const patches: Lru<PatchedFile[]> = lru(MAX_KEPT_PATCHES, patchWeight)
303
+
304
+ // The patch `patch` names, read as kept, else of repository `fullName` with
305
+ // `token`, held to its hash and read (parsePatch), and kept; `asked` told
306
+ // where GitHub was. Its file missing there, or other than its hash says, or
307
+ // past MAX_PATCH_BYTES, fails.
308
+ export async function readPatch(fullName: string, token: string, patch: NpmPatch, asked?: GithubAsked): Promise<PatchedFile[]> {
309
+ const key = `${patch.repoId}\0${patch.commit}\0${patch.path}\0${patch.hash}`
310
+ const known = patches.get(key)
311
+ if (known) return known
312
+ if (asked) asked.github = true
313
+ let text: string
314
+ try {
315
+ text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path: patch.path, ref: patch.commit }))
316
+ } catch (err) {
317
+ if (err instanceof GithubApiError && err.message === 'github-not-found') throw new NpmPackageError(404, 'patch-not-found')
318
+ throw err
319
+ }
320
+ if (Buffer.byteLength(text) > MAX_PATCH_BYTES) throw new NpmPackageError(413, 'patch-too-large')
321
+ if (!isPatchOf(text, patch.hash)) throw new NpmPackageError(409, 'patch-mismatch')
322
+ const files = parsePatch(text)
323
+ patches.set(key, files)
324
+ return files
325
+ }
@@ -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}`)