@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,194 @@
1
+ // The lines a version of a package changes against another, or against none,
2
+ // its whole package's (GET /api/npm/delta), as a list of versions wanting
3
+ // reviews tells them beside each: added and removed (`additions`,
4
+ // `deletions`), as git's --numstat counts them, of their text files, a
5
+ // renamed one diffed with what it was; and, where some of the files changed
6
+ // are declaration files @preventive/compare's compareTrees proves changed in
7
+ // types alone (`typeOnly`, proveTypeOnly: types and comments alone erased,
8
+ // the runtime they leave as it was, an added one's empty or a single bare
9
+ // `export {}`), those of the rest (`typeless`), what a reviewer may pass by
10
+ // leaving those out; and whether a binary file, which they count none of,
11
+ // was added, removed or changed (`binary`), one moved as it was not. Worked
12
+ // out in a worker, off the event loop, as many at once per process as
13
+ // MAX_ACTIVE, both sides' files read together only once their turn has come,
14
+ // those waiting past MAX_WAITING refused, each waiting a minute at most and
15
+ // counting in a minute at most; one asked again meanwhile sharing the
16
+ // first's; and kept by both sides' integrities, in this instance and beyond
17
+ // it (`kept`), what a version holds never changing. Access is the caller's
18
+ // to check.
19
+ import { Worker } from 'node:worker_threads'
20
+ import { createHash } from 'node:crypto'
21
+ import type { KeptStore } from './npm-kept.ts'
22
+ import { type Lru, lru } from './lru.ts'
23
+ import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
24
+
25
+ export interface NpmDelta { additions: number; deletions: number; typeless: { additions: number; deletions: number } | null; binary: boolean }
26
+
27
+ const MAX_ACTIVE = 2
28
+ const MAX_WAITING = 64
29
+ const DELTA_TIMEOUT_MS = 60_000
30
+ // The text the two sides may hold together, past which none is counted.
31
+ const MAX_TEXT_LENGTH = 64 * 1024 * 1024
32
+
33
+ const deltas: Lru<NpmDelta> = lru(20_000)
34
+ const working = new Map<string, Promise<NpmDelta>>()
35
+ let active = 0
36
+ const waiting: Array<() => void> = []
37
+
38
+ async function turn(deadline: number): Promise<void> {
39
+ if (active < MAX_ACTIVE) { active++; return }
40
+ if (waiting.length >= MAX_WAITING) throw new NpmPackageError(429, 'delta-busy')
41
+ await new Promise<void>((resolve, reject) => {
42
+ const next = () => { clearTimeout(timer); resolve() }
43
+ const timer = setTimeout(() => {
44
+ waiting.splice(waiting.indexOf(next), 1)
45
+ reject(new NpmPackageError(504, 'delta-timeout'))
46
+ }, deadline - Date.now())
47
+ waiting.push(next)
48
+ })
49
+ }
50
+ function done() {
51
+ const next = waiting.shift()
52
+ if (next) next()
53
+ else active--
54
+ }
55
+
56
+ const lines = (told: unknown): told is { additions: number; deletions: number } => typeof told === 'object' && told !== null
57
+ && Number.isSafeInteger((told as { additions?: unknown }).additions) && Number.isSafeInteger((told as { deletions?: unknown }).deletions)
58
+ // A delta as kept, none where it isn't one, or was kept before it told of
59
+ // binary files.
60
+ const deltaOf = (told: unknown): NpmDelta | null => lines(told) && ((told as NpmDelta).typeless === null || lines((told as NpmDelta).typeless))
61
+ && typeof (told as NpmDelta).binary === 'boolean' ? { additions: told.additions, deletions: told.deletions, typeless: (told as NpmDelta).typeless,
62
+ binary: (told as NpmDelta).binary } : null
63
+
64
+ type Counted = { additions: number; deletions: number; typeAdditions: number; typeDeletions: number; typeFiles: number }
65
+ const isCounted = (told: unknown): told is Counted => typeof told === 'object' && told !== null
66
+ && ['additions', 'deletions', 'typeAdditions', 'typeDeletions', 'typeFiles'].every(key => Number.isSafeInteger((told as Record<string, unknown>)[key]))
67
+
68
+ // What the worker counts of `base` against `other`, each its text files' paths and texts.
69
+ function counted(base: [string, string][], other: [string, string][], deadline: number): Promise<Counted> {
70
+ let worker: Worker
71
+ try {
72
+ worker = new Worker(new URL('./npm-delta-worker.js', import.meta.url), {
73
+ workerData: { base, other }, execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1536 },
74
+ })
75
+ } catch (err) {
76
+ console.warn('managed: a package delta could not start:', err)
77
+ throw new NpmPackageError(503, 'unavailable')
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ let answer: Counted | null = null, failure = new NpmPackageError(422, 'delta-failed')
81
+ const timer = setTimeout(() => { failure = new NpmPackageError(504, 'delta-timeout'); void worker.terminate() }, deadline - Date.now())
82
+ worker.once('message', (told: unknown) => {
83
+ if (isCounted(told)) answer = told
84
+ void worker.terminate()
85
+ })
86
+ worker.once('error', err => { console.warn('managed: a package delta failed:', err) })
87
+ worker.once('exit', () => {
88
+ clearTimeout(timer)
89
+ if (answer) resolve(answer)
90
+ else reject(failure)
91
+ })
92
+ })
93
+ }
94
+
95
+ // A side's text files, by path, how long their texts are, and its other,
96
+ // binary, files.
97
+ function texts(files: NpmPackageFile[] | null) {
98
+ let length = 0
99
+ const binaries: NpmPackageFile[] = []
100
+ const read = (files ?? []).flatMap(file => {
101
+ const text = npmFileText(file.bytes)
102
+ if (text === null) { binaries.push(file); return [] }
103
+ length += text.length
104
+ return [[file.path, text] as [string, string]]
105
+ })
106
+ return { read, length, binaries }
107
+ }
108
+
109
+ const same = (a: Uint8Array, b: Uint8Array) => Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(b)
110
+ const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('base64')
111
+
112
+ // Whether `news`, a side's binary files, differ from `olds`, the other's:
113
+ // one added, removed or changed, one moved where it is the same as one that
114
+ // was elsewhere not, matched by their digests, each file looked at once.
115
+ function binaryChanged(olds: NpmPackageFile[], news: NpmPackageFile[]): boolean {
116
+ if (olds.length !== news.length) return true
117
+ const left = new Map(olds.map(({ path, bytes }) => [path, bytes]))
118
+ const moved: Uint8Array[] = []
119
+ for (const { path, bytes } of news) {
120
+ const was = left.get(path)
121
+ if (was && same(was, bytes)) left.delete(path)
122
+ else moved.push(bytes)
123
+ }
124
+ // Those not where they were, by their digests, as many of each as there are.
125
+ const unmoved = new Map<string, number>()
126
+ for (const bytes of left.values()) {
127
+ const key = digest(bytes)
128
+ unmoved.set(key, (unmoved.get(key) ?? 0) + 1)
129
+ }
130
+ return moved.some(bytes => {
131
+ const key = digest(bytes)
132
+ const count = unmoved.get(key) ?? 0
133
+ if (count === 0) return true
134
+ unmoved.set(key, count - 1)
135
+ return false
136
+ })
137
+ }
138
+
139
+ type Side = { integrity: string; files: () => Promise<NpmPackageFile[]> }
140
+
141
+ // The delta of `other` (its integrity, and how to read its files) against
142
+ // `base`, or none, as held, else shared with one asked before it, else
143
+ // looked for where it is kept, else worked out and kept: shared from before
144
+ // it is looked for, lest those asked together each look, find none, and work
145
+ // it out.
146
+ export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null = null): Promise<NpmDelta> {
147
+ const key = `${base?.integrity ?? '(empty)'}\0${other.integrity}`
148
+ const known = deltas.get(key)
149
+ if (known) return Promise.resolve(known)
150
+ const shared = working.get(key)
151
+ if (shared) return shared
152
+ const work = worked(key, base, other, kept).finally(() => working.delete(key))
153
+ working.set(key, work)
154
+ return work
155
+ }
156
+
157
+ // The deltas of those of `sides` (each the integrities of the version it is
158
+ // against, or none, and of the version) held or kept, by their keys
159
+ // (npmDelta's), in one read of `kept` for those not held: none worked out.
160
+ export async function keptNpmDeltas(sides: [string | null, string][], kept: KeptStore<NpmDelta>): Promise<Map<string, NpmDelta>> {
161
+ const keys = [...new Set(sides.map(([base, other]) => `${base ?? '(empty)'}\0${other}`))]
162
+ const found = new Map(keys.flatMap(key => { const known = deltas.get(key); return known ? [[key, known] as const] : [] }))
163
+ const unheld = keys.filter(key => !found.has(key))
164
+ for (const [key, row] of unheld.length > 0 ? await kept.readMany(unheld) : []) {
165
+ const known = deltaOf(row.value)
166
+ if (!known) continue
167
+ deltas.set(key, known)
168
+ found.set(key, known)
169
+ }
170
+ return found
171
+ }
172
+
173
+ // As kept, else its turn awaited, then both sides' files read, counted and kept.
174
+ async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
175
+ const known = deltaOf((await kept?.read(key))?.value)
176
+ if (known) { deltas.set(key, known); return known }
177
+ await turn(Date.now() + DELTA_TIMEOUT_MS)
178
+ let binary, told
179
+ try {
180
+ // Its turn held till both sides are read, or have failed.
181
+ const [baseRead, otherRead] = await Promise.allSettled([base ? base.files() : Promise.resolve(null), other.files()])
182
+ if (baseRead.status === 'rejected') throw baseRead.reason
183
+ if (otherRead.status === 'rejected') throw otherRead.reason
184
+ const [olds, news] = [texts(baseRead.value), texts(otherRead.value)]
185
+ if (olds.length + news.length > MAX_TEXT_LENGTH) throw new NpmPackageError(413, 'package-too-large')
186
+ binary = binaryChanged(olds.binaries, news.binaries)
187
+ told = await counted(olds.read, news.read, Date.now() + DELTA_TIMEOUT_MS)
188
+ } finally { done() }
189
+ const delta = { additions: told.additions, deletions: told.deletions,
190
+ typeless: told.typeFiles > 0 ? { additions: told.additions - told.typeAdditions, deletions: told.deletions - told.typeDeletions } : null, binary }
191
+ deltas.set(key, delta)
192
+ await kept?.write(key, delta, Date.now())
193
+ return delta
194
+ }
@@ -0,0 +1,449 @@
1
+ // Figures and advisories for the npm package viewer's Overview, beside a
2
+ // version's files (npm-packages.ts, npm-loads.ts): the package's downloads
3
+ // over the last year, from npm's public downloads API; its GitHub
4
+ // repository's stars, forks and open issues; and its advisories across every
5
+ // published version, as `npm audit` has them and as its repository publishes
6
+ // them on GitHub, asked as bundle advisories ask (bundle-advisories.ts); and
7
+ // Socket's scores and alerts for a version. All of it is public: npm is asked
8
+ // without the server's token, Socket only about a version npm answers for
9
+ // without one (http.ts), and a repository's figures, tags and advisories are
10
+ // asked for, and kept, only where GitHub says it is public. Each
11
+ // answer is kept for an hour, a failure not at all; the reader's access to
12
+ // the package is checked on every request before any of it is answered
13
+ // (http.ts handleNpm).
14
+ import type { Advisory, CacheStore } from '@preventive/upstream/advisories.js'
15
+ import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
16
+ import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
17
+ import { isExactVersion } from '@preventive/upstream/semver.js'
18
+ import { kept } from './kept.ts'
19
+ import { type NpmProvenance, keptNpmProvenance } from './npm-provenance.ts'
20
+ import { NpmPackageError, askUpstreamJson, plainObject, readLimited } from './npm-packages.ts'
21
+
22
+ const DOWNLOADS_API = 'https://api.npmjs.org/downloads/range'
23
+ // What npm's range API may answer for each package asked: a year of days is
24
+ // some 16 KiB.
25
+ const DOWNLOADS_BYTES = 64 * 1024
26
+ // Socket's answer for a package version, by its purl, as Socket Firewall
27
+ // asks it: no key needed. Its scores are cut to two decimals; its badge's,
28
+ // the supply chain score, is rounded (askSocketBadge).
29
+ const SOCKET_API = 'https://firewall-api.socket.dev/purl'
30
+ const SOCKET_BADGE = 'https://badge.socket.dev/npm/package'
31
+ const BADGE_BYTES = 16 * 1024
32
+ const API_TIMEOUT_MS = 30_000
33
+ const API_BYTES = 1024 * 1024
34
+ const DAY_MS = 24 * 60 * 60_000
35
+ const SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, moderate: 2, low: 3 }
36
+
37
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
38
+ const isDay = (value: unknown): value is string => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value) && !Number.isNaN(Date.parse(value))
39
+
40
+ // A package's downloads, one count a day from `start` to `end` (UTC dates),
41
+ // none for a day npm leaves out; null where npm has none for it.
42
+ export interface NpmDownloads { start: string; end: string; days: number[] }
43
+
44
+ const isoDay = (time: number) => new Date(time).toISOString().slice(0, 10)
45
+
46
+ // Its last year, as npm counts it (to the last day it has counted), from
47
+ // the first of the month that year starts in, so that month is whole too.
48
+ async function askDownloads(name: string): Promise<NpmDownloads | null> {
49
+ const year = await askDownloadRange('last-year', name)
50
+ const start = new Date(year?.start ?? 0)
51
+ if (year === null || start.getUTCDate() === 1) return year
52
+ const first = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)
53
+ const before = await askDownloadRange(`${isoDay(first)}:${isoDay(start.getTime() - DAY_MS)}`, name).catch(() => null)
54
+ return before?.start === isoDay(first) && before.days.length === (start.getTime() - first) / DAY_MS
55
+ ? { start: before.start, end: year.end, days: [...before.days, ...year.days] } : year
56
+ }
57
+
58
+ // npm's downloads of `name` over `range`: null where npm has none.
59
+ async function askDownloadRange(range: string, name: string): Promise<NpmDownloads | null> {
60
+ const json = await askUpstreamJson(`${DOWNLOADS_API}/${range}/${name.replace('/', '%2f')}`, { headers: { accept: 'application/json' }, limit: DOWNLOADS_BYTES })
61
+ return json === null ? null : readDownloads(json)
62
+ }
63
+
64
+ // One package's downloads as npm's range API gives them.
65
+ function readDownloads(json: unknown): NpmDownloads {
66
+ if (!plainObject(json) || !isDay(json['start']) || !isDay(json['end']) || !Array.isArray(json['downloads'])) throw new NpmPackageError(502, 'upstream-invalid')
67
+ const start = Date.parse(json['start'])
68
+ const length = (Date.parse(json['end']) - start) / DAY_MS + 1
69
+ if (!Number.isSafeInteger(length) || length < 1 || length > 400) throw new NpmPackageError(502, 'upstream-invalid')
70
+ const days = Array.from({ length }, () => 0)
71
+ for (const row of json['downloads']) {
72
+ if (!plainObject(row) || !isDay(row['day'])) continue
73
+ const at = (Date.parse(row['day']) - start) / DAY_MS
74
+ if (Number.isSafeInteger(at) && at >= 0 && at < length) days[at] = count(row['downloads'])
75
+ }
76
+ return { start: json['start'], end: json['end'], days }
77
+ }
78
+
79
+ const downloads = kept<NpmDownloads | null>(500)
80
+ // The repository each package's latest version names (owner/name, null where
81
+ // it names none), as its figures were first asked with within the hour: what
82
+ // npmKnown finds them by.
83
+ const latestRepos = kept<string | null>(500)
84
+
85
+ export const npmLatestRepo = (name: string, repo: string | null): void => void latestRepos(name, () => Promise.resolve(repo))
86
+
87
+ export function npmDownloads(name: string): Promise<NpmDownloads | null> {
88
+ return downloads(name, () => askDownloads(name))
89
+ }
90
+
91
+ const SOCKET_SCORES = ['overall', 'supplyChain', 'vulnerability', 'quality', 'maintenance', 'license'] as const
92
+ const SOCKET_SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, middle: 2, low: 3 }
93
+ const SOCKET_ALERTS = 50
94
+ const SOCKET_NOTE_CHARS = 4000
95
+
96
+ // Socket's report on a version: its scores, each 0 to 1 (null where Socket
97
+ // gave none), and the alerts it raises, such as malware, most severe first,
98
+ // each with the file it was found in, by its path in the package (the
99
+ // tarball's root left out, as the package's files have it), where it names
100
+ // one, and Socket's note on it.
101
+ export interface NpmSocketAlert { type: string; severity: string; category: string | null; file: string | null; note: string | null }
102
+ export interface NpmSocketReport { scores: Record<typeof SOCKET_SCORES[number], number> | null; alerts: NpmSocketAlert[] }
103
+
104
+ const text = (value: unknown) => typeof value === 'string' && value !== '' ? value : null
105
+
106
+ // The supply chain score, out of 100, that the version's badge shows; null
107
+ // where it shows none, and undefined where it can't be had, as while Socket
108
+ // limits how often it's asked.
109
+ async function askSocketBadge(name: string, version: string): Promise<number | null | undefined> {
110
+ try {
111
+ const res = await fetch(`${SOCKET_BADGE}/${name}/${encodeURIComponent(version)}`,
112
+ { headers: { accept: 'image/svg+xml' }, redirect: 'error', signal: AbortSignal.timeout(API_TIMEOUT_MS) })
113
+ if (!res.ok) { await res.body?.cancel(); return res.status === 429 || res.status >= 500 ? undefined : null }
114
+ const shown = /<title>Socket: (\d{1,3})<\/title>/u.exec((await readLimited(res, BADGE_BYTES)).toString('utf8'))
115
+ return shown ? Number(shown[1]) : null
116
+ } catch { return undefined }
117
+ }
118
+
119
+ // Scores the badge couldn't round: Socket's own, a point low at most, their
120
+ // report answered but not kept, so the next view asks again.
121
+ const unbadged = new WeakSet<object>()
122
+
123
+ async function askSocket(name: string, version: string): Promise<NpmSocketReport | null> {
124
+ const badge = askSocketBadge(name, version)
125
+ // One line, as one purl was asked.
126
+ const json = await askUpstreamJson(`${SOCKET_API}/${encodeURIComponent(`pkg:npm/${name}@${version}`)}`,
127
+ { headers: { accept: 'application/x-ndjson' }, limit: API_BYTES }, body => JSON.parse(body.split('\n', 1)[0]!))
128
+ if (json === null) return null
129
+ if (!plainObject(json) || json['version'] !== version || [json['namespace'], json['name']].filter(Boolean).join('/') !== name) throw new NpmPackageError(502, 'upstream-invalid')
130
+ // A version Socket hasn't seen is answered with a made-up id and nothing else.
131
+ if (String(json['id']).startsWith('synthetic:')) return null
132
+ const score = json['score']
133
+ const scores = plainObject(score) && SOCKET_SCORES.every(key => typeof score[key] === 'number' && score[key] >= 0 && score[key] <= 1)
134
+ ? Object.fromEntries(SOCKET_SCORES.map(key => [key, score[key]])) as NpmSocketReport['scores'] : null
135
+ // The supply chain score as the badge rounds it, at most one more than the
136
+ // score cut to two decimals; lower where the badge is, as when the score
137
+ // has fallen since.
138
+ const shown = await badge
139
+ if (scores && typeof shown === 'number') scores.supplyChain = Math.min(Math.round(scores.supplyChain * 100) + 1, shown) / 100
140
+ else if (scores && shown === undefined) unbadged.add(scores)
141
+ const alerts = (Array.isArray(json['alerts']) ? json['alerts'] : [])
142
+ .filter((alert): alert is Record<string, unknown> => plainObject(alert) && text(alert['type']) !== null && typeof alert['severity'] === 'string' && alert['severity'] in SOCKET_SEVERITY_RANK)
143
+ .slice(0, SOCKET_ALERTS)
144
+ .map(alert => ({
145
+ type: alert['type'] as string, severity: alert['severity'] as string, category: text(alert['category']),
146
+ file: text(text(alert['file'])?.replace(/^[^/]*\//u, '')),
147
+ note: text(plainObject(alert['props']) ? alert['props']['note'] : null)?.slice(0, SOCKET_NOTE_CHARS) ?? null,
148
+ }))
149
+ .toSorted((a, b) => SOCKET_SEVERITY_RANK[a.severity]! - SOCKET_SEVERITY_RANK[b.severity]!)
150
+ return scores === null && alerts.length === 0 ? null : { scores, alerts }
151
+ }
152
+
153
+ const socketReports = kept<NpmSocketReport | null>(1000, report => !report?.scores || !unbadged.has(report.scores))
154
+
155
+ // Socket's report on a version of a public package; null where Socket has
156
+ // none. Its name goes to Socket: never ask it about a private one.
157
+ export function npmSocketReport(name: string, version: string): Promise<NpmSocketReport | null> {
158
+ return socketReports(`${name}@${version}`, () => askSocket(name, version))
159
+ }
160
+
161
+ // A public repository's figures: GitHub's `openIssues` takes in its open pull
162
+ // requests, which `openPulls` counts on their own, null where GitHub didn't
163
+ // say.
164
+ export interface NpmGithubStats {
165
+ repo: string; stars: number; forks: number; openIssues: number; openPulls: number | null; archived: boolean; pushedAt: string | null
166
+ }
167
+
168
+ // How many pull requests a repository has open: the last page of their list,
169
+ // one a page, as its Link header names it, or the one page's length.
170
+ async function countOpenPulls(repo: string, token: string | null): Promise<number | null> {
171
+ const path = repo.split('/').map(encodeURIComponent).join('/')
172
+ let res: Response
173
+ try {
174
+ res = await fetch(`https://api.github.com/repos/${path}/pulls?state=open&per_page=1`, {
175
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'deepview-triage', ...token && { authorization: `Bearer ${token}` } },
176
+ redirect: 'error', signal: AbortSignal.timeout(API_TIMEOUT_MS),
177
+ })
178
+ } catch { return null }
179
+ if (!res.ok) { await res.body?.cancel(); return null }
180
+ const last = /[?&]page=(\d+)>;\s*rel="last"/u.exec(res.headers.get('link') ?? '')?.[1]
181
+ if (last !== undefined) { await res.body?.cancel(); return Number(last) }
182
+ try {
183
+ const json = JSON.parse((await readLimited(res, API_BYTES)).toString('utf8')) as unknown
184
+ return Array.isArray(json) ? json.length : null
185
+ } catch { return null }
186
+ }
187
+
188
+ // GitHub has no repository to show: none by that name, or a redirect upstream
189
+ // won't follow, anywhere but to the repository by its id, or a second.
190
+ const NONE = new Set([301, 302, 307, 308, 404])
191
+
192
+ // What GitHub answers for `repo`, or where it was renamed or transferred, for
193
+ // the repository it is now (its `full_name`), where it says it is public,
194
+ // with the token it was asked with: `token`, or none where that is revoked.
195
+ // Upstream follows GitHub's redirect for such a repository to it by its id
196
+ // (`followRedirect`), once, with the same token. Null where GitHub has no such
197
+ // repository to show, or it is not public; anything else fails, to be asked
198
+ // again next time. Public is `visibility: 'public'`, as github-app.ts
199
+ // requires: an internal repository answers `private: false`.
200
+ async function publicRepo(repo: string, token: string | null): Promise<{ json: Record<string, unknown> & { full_name: string }; auth: string | null } | null> {
201
+ const ask = (auth: string | null) => createClient({ token: auth, userAgent: 'deepview-triage' }).getRepo({ repo, followRedirect: true }) as Promise<unknown>
202
+ let auth = token, json: unknown
203
+ try { json = await ask(token) }
204
+ catch (error) {
205
+ if (!(error instanceof HttpError) || !(error.status === 401 || NONE.has(error.status))) throw error
206
+ if (error.status !== 401 || token === null) return null
207
+ auth = null
208
+ try { json = await ask(null) }
209
+ catch (retry) { if (retry instanceof HttpError && NONE.has(retry.status)) return null; throw retry }
210
+ }
211
+ if (!plainObject(json) || json['private'] !== false || json['visibility'] !== 'public' || typeof json['full_name'] !== 'string') return null
212
+ return { json: json as Record<string, unknown> & { full_name: string }, auth }
213
+ }
214
+
215
+ async function askGithub(repo: string, token: string | null): Promise<NpmGithubStats | null> {
216
+ const found = await publicRepo(repo, token)
217
+ if (found === null) return null
218
+ const { json, auth } = found
219
+ return {
220
+ repo: json['full_name'], stars: count(json['stargazers_count']), forks: count(json['forks_count']), openIssues: count(json['open_issues_count']),
221
+ openPulls: await countOpenPulls(json['full_name'], auth),
222
+ archived: json['archived'] === true, pushedAt: typeof json['pushed_at'] === 'string' ? json['pushed_at'] : null,
223
+ }
224
+ }
225
+
226
+ const repositories = kept<NpmGithubStats | null>(1000)
227
+
228
+ // `repo` as a package's manifest names it (owner/name); `githubToken` the
229
+ // reader's own, where they have one, for GitHub's rate limits, never for what
230
+ // it shows, asked for only where the figures aren't kept.
231
+ export function npmGithubStats(repo: string, githubToken: () => Promise<string | null>): Promise<NpmGithubStats | null> {
232
+ return repositories(repo.toLowerCase(), async () => askGithub(repo, await githubToken()))
233
+ }
234
+
235
+ const GITHUB_GRAPHQL = 'https://api.github.com/graphql'
236
+ // A ref's target is a commit, or for an annotated tag a tag object, which may
237
+ // itself name another tag before the commit.
238
+ const TAGS_QUERY = `query($owner: String!, $name: String!, $query: String!) {
239
+ repository(owner: $owner, name: $name) {
240
+ visibility
241
+ refs(refPrefix: "refs/tags/", query: $query, first: 100) {
242
+ nodes { name target { __typename oid ... on Tag { target { __typename oid ... on Tag { target { __typename oid } } } } } }
243
+ }
244
+ }
245
+ }`
246
+
247
+ function taggedCommit(target: unknown): unknown {
248
+ let at = target
249
+ for (let depth = 0; depth < 3 && plainObject(at) && at['__typename'] === 'Tag'; depth++) at = at['target']
250
+ return plainObject(at) && at['__typename'] === 'Commit' ? at['oid'] : null
251
+ }
252
+
253
+ async function askTags(repo: string, sha: string, version: string, token: string): Promise<string[]> {
254
+ const [owner, name] = repo.split('/')
255
+ const json = await askUpstreamJson(GITHUB_GRAPHQL, {
256
+ method: 'POST', limit: API_BYTES, absent: [],
257
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': 'deepview-triage' },
258
+ body: JSON.stringify({ query: TAGS_QUERY, variables: { owner, name, query: version } }),
259
+ })
260
+ const data = plainObject(json) ? json['data'] : null
261
+ const repository = plainObject(data) ? data['repository'] : null
262
+ // A repository gone, or not public (private, or internal to an enterprise),
263
+ // has none to show anyone.
264
+ if (!plainObject(repository) || repository['visibility'] !== 'PUBLIC') return []
265
+ const refs = repository['refs']
266
+ const nodes = plainObject(refs) && Array.isArray(refs['nodes']) ? refs['nodes'] as unknown[] : []
267
+ return nodes.filter((node): node is { name: string; target: unknown } => plainObject(node) && typeof node['name'] === 'string' && node['name'] !== '')
268
+ .filter(node => taggedCommit(node.target) === sha).map(node => node.name).toSorted()
269
+ }
270
+
271
+ // Null where no token asked: nothing to keep for a reader who has one.
272
+ const commitTags = kept<string[] | null>(500, tags => tags !== null)
273
+
274
+ // The tags of a public repository (owner/name) that point to `sha`, a
275
+ // version's publish commit, among those naming its `version` (`v1.2.3`,
276
+ // `pkg@1.2.3`, …), as GitHub's GraphQL API finds them by name. GraphQL needs
277
+ // a token: the reader's own (`githubToken`, asked for only where the tags
278
+ // aren't kept), where they have one; without, none are asked.
279
+ export async function npmCommitTags(repo: string, sha: string, version: string, githubToken: () => Promise<string | null>): Promise<string[]> {
280
+ return await commitTags(`${repo.toLowerCase()}\0${sha}\0${version}`, async () => {
281
+ const token = await githubToken()
282
+ return token === null ? null : askTags(repo, sha, version, token)
283
+ }) ?? []
284
+ }
285
+
286
+ // An advisory on the package, with the versions it covers as their indexes in
287
+ // the list it was asked with: npm's (`registry`), or one its repository
288
+ // publishes on GitHub before GitHub reviews it into npm's (`repository`); its
289
+ // text (markdown) where its source has one.
290
+ export interface NpmAdvisory {
291
+ id: string; source: Advisory['source']; ghsa?: string; url?: string; title?: string; severity?: string; cvss?: number; cwe: string[]; range?: string
292
+ details?: string; affected: number[]
293
+ }
294
+
295
+ // `versions` those `affected` indexes; `repository` false where the
296
+ // repository's advisories could not be had (GitHub refusing, its rate limit
297
+ // spent), leaving npm's alone; `keptAt`, where GitHub couldn't give them but
298
+ // their listing was kept before, however long ago, when it was.
299
+ export interface NpmAdvisoryList { versions: string[]; advisories: NpmAdvisory[]; repository: boolean; keptAt?: string }
300
+
301
+ export interface NpmAdvisoryOptions {
302
+ // The reader's GitHub token, and the repository the package names (owner/
303
+ // name, null where it names none), each asked for only where the list is
304
+ // not kept.
305
+ githubToken: () => Promise<string | null>
306
+ repo: () => Promise<string | null>
307
+ // Where repositories' listings are kept, as bundle advisories keep them
308
+ // (upstream-cache.ts auditCache), for as long as `signal` asks; and a
309
+ // repository's as last kept there, however old (lastListing).
310
+ cache: (signal: AbortSignal) => CacheStore | undefined
311
+ lastListing: (repo: string) => Promise<{ at: number; cache: CacheStore } | null>
312
+ debug?: boolean
313
+ }
314
+
315
+ async function askAdvisories(name: string, versions: string[], { githubToken, repo, cache, lastListing, debug = false }: NpmAdvisoryOptions): Promise<NpmAdvisoryList> {
316
+ // Every version upstream takes, as it checks each one it is asked about.
317
+ const asked = versions.filter(version => isExactVersion(version))
318
+ if (asked.length === 0) return { versions, advisories: [], repository: true }
319
+ // Its repository's own advisories are asked for only where GitHub says, now
320
+ // and not from an answer kept, that it is public, and that repository is the
321
+ // one asked, by the name GitHub gives it now where it was renamed or
322
+ // transferred: the list is kept for every reader of the package, and the
323
+ // reader's token could read a private repository's, or a listing of one
324
+ // kept for another reader.
325
+ let github: string | null = null, named: string | null = null, repository = true
326
+ try {
327
+ named = await repo()
328
+ github = named === null ? null : (await publicRepo(named, await githubToken()))?.json.full_name ?? null
329
+ } catch { repository = false }
330
+ // With the advisories `listed` publishes, where it isn't null, GitHub asked
331
+ // with the reader's token or by a client of the caller's.
332
+ const ask = (listed: string | null, store: (signal: AbortSignal) => CacheStore | undefined, client: { githubToken: string | null } | { github: Client }) => {
333
+ const signal = AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)
334
+ const packages = [{ ecosystem: 'npm' as const, name, versions: asked, ...listed !== null && { github: listed } }]
335
+ return fetchBundleAdvisories(packages, signal, { debug, repoAdvisories: listed !== null, details: true, ...client, cache: store(signal) })
336
+ }
337
+ let result = repository ? await ask(github, cache, { githubToken: github === null ? null : await githubToken() }) : null
338
+ // Where GitHub can't say whether it is public, or refuses its listing, its
339
+ // listing as last kept is taken instead, however old (lastListing), as
340
+ // audits take a kept listing for any reader: only public repositories
341
+ // publish advisories (upstream-cache.ts). GitHub is asked nothing for it:
342
+ // upstream asks for a listing only once npm has answered, and where it
343
+ // won't take the kept one, which leaves npm's alone, as where none is kept.
344
+ // An audit failing otherwise is npm's, not asked a third time. Neither list
345
+ // is kept, so GitHub is asked again next time.
346
+ let keptAt: string | undefined
347
+ if (result === null || (result.status !== 200 && github !== null)) {
348
+ const unreached = github ?? named
349
+ const last = unreached === null ? null : await lastListing(unreached)
350
+ let untaken = false
351
+ const offline: Client = { ...createClient({ token: null, userAgent: 'deepview-triage' }),
352
+ listRepoAdvisories: () => { untaken = true; return Promise.reject(new Error('GitHub can\'t be reached')) } }
353
+ const listed = last && await ask(unreached, () => last.cache, { github: offline })
354
+ if (last && listed?.status === 200) {
355
+ result = listed
356
+ repository = true
357
+ keptAt = new Date(last.at).toISOString()
358
+ } else {
359
+ result = listed && !untaken ? listed : await ask(null, cache, { githubToken: null })
360
+ repository = false
361
+ }
362
+ }
363
+ if (result.status !== 200) throw new NpmPackageError(502, 'upstream-unavailable')
364
+ const index = new Map(versions.map((version, i) => [version, i]))
365
+ // npm's registry answers an advisory once a range it covers, and upstream
366
+ // answers the versions OSV's record of it, or its repository, covers beyond
367
+ // npm's under the same id, in a row of their own (as hermes-engine's
368
+ // GHSA-f5x2-xv93-4p23: npm's `<=0.4.3`, OSV's fixed in 0.5.2): here it is
369
+ // one row, the first's (npm's, then OSV's, then the repository's, as
370
+ // upstream orders them) with what it lacks taken from the rest, its ranges
371
+ // joined as semver joins them, the versions and CWEs they cover together,
372
+ // and the worst severity and highest score of them: a version only the
373
+ // repository reports keeps the severity it gives.
374
+ const merged = new Map<string, NpmAdvisory>()
375
+ for (const row of result.body) {
376
+ const affected = row.versions.map(version => index.get(version)).filter(at => at !== undefined)
377
+ const own: NpmAdvisory = {
378
+ id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
379
+ ...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
380
+ ...row.details && { details: row.details }, affected,
381
+ }
382
+ const known = merged.get(row.id)
383
+ if (!known) {
384
+ merged.set(row.id, own)
385
+ continue
386
+ }
387
+ const range = [known.range, row.range].filter(Boolean).join(' || ')
388
+ const cvss = Math.max(known.cvss ?? -1, row.cvss ?? -1)
389
+ const rank = (severity: string | undefined) => SEVERITY_RANK[severity ?? ''] ?? 4
390
+ const severity = rank(row.severity) < rank(known.severity) ? row.severity : known.severity
391
+ merged.set(row.id, { ...own, ...known, ...range && { range }, ...severity && { severity }, ...cvss >= 0 && { cvss }, cwe: [...new Set([...known.cwe, ...row.cwe])],
392
+ affected: [...known.affected, ...affected] })
393
+ }
394
+ return {
395
+ versions, repository, ...keptAt && { keptAt },
396
+ advisories: [...merged.values()].map(row => ({ ...row, affected: [...new Set(row.affected)].toSorted((a, b) => a - b) })).filter(row => row.affected.length > 0)
397
+ .toSorted((a, b) => (SEVERITY_RANK[a.severity ?? ''] ?? 4) - (SEVERITY_RANK[b.severity ?? ''] ?? 4) || a.id.localeCompare(b.id)),
398
+ }
399
+ }
400
+
401
+ const ADVISORY_LISTS = 100
402
+ const advisoryLists = kept<NpmAdvisoryList>(ADVISORY_LISTS, list => list.repository && !list.keptAt)
403
+ // What each package's list was last asked by, for npmKnown.
404
+ const advisoryKeys = new Map<string, string>()
405
+
406
+ // The package's advisories, `versions` its published versions as the version
407
+ // list has them; asked again once a version is published, and a list without
408
+ // its repository's, next time.
409
+ export function npmAdvisories(name: string, versions: string[], options: NpmAdvisoryOptions): Promise<NpmAdvisoryList> {
410
+ const key = `${name}\0${versions.length}\0${versions[0] ?? ''}`
411
+ advisoryKeys.delete(name)
412
+ advisoryKeys.set(name, key)
413
+ while (advisoryKeys.size > ADVISORY_LISTS) advisoryKeys.delete(advisoryKeys.keys().next().value!)
414
+ return advisoryLists(key, () => askAdvisories(name, versions, options))
415
+ }
416
+
417
+ // What is kept about a version's package, each part as its own route answers
418
+ // it (http.ts handleNpm), for the version's response to carry: its figures,
419
+ // where those of the repository its latest version names are kept too; its
420
+ // advisories, where kept for a version list that has this version; the tags
421
+ // of its publish commit (`sha`, in `repo`, the repository it names);
422
+ // Socket's report on it; and npm's provenance for it (npm-provenance.ts).
423
+ // Nothing is asked for: what isn't kept is left out, for the viewer to ask
424
+ // for. A private package's name never leaves for Socket, so its report is
425
+ // none, nor is it asked for provenance.
426
+ export interface NpmKnown {
427
+ stats?: { name: string; downloads: NpmDownloads | null; github: NpmGithubStats | null }
428
+ advisories?: { name: string } & NpmAdvisoryList
429
+ tags?: { name: string; version: string; tags: string[] }
430
+ socket?: { name: string; version: string; socket: NpmSocketReport | null }
431
+ provenance?: { name: string; version: string; provenance: NpmProvenance | null }
432
+ }
433
+
434
+ export function npmKnown(name: string, version: string, isPrivate: boolean, repo: string | null, sha: string | null): NpmKnown {
435
+ const known: NpmKnown = {}
436
+ const days = downloads.peek(name), latest = latestRepos.peek(name)
437
+ const github = latest === null ? null : latest === undefined ? undefined : repositories.peek(latest.toLowerCase())
438
+ if (days !== undefined && github !== undefined) known.stats = { name, downloads: days, github }
439
+ const key = advisoryKeys.get(name)
440
+ const list = key === undefined ? undefined : advisoryLists.peek(key)
441
+ if (list?.versions.includes(version)) known.advisories = { name, ...list }
442
+ const tags = repo === null || sha === null ? [] : commitTags.peek(`${repo.toLowerCase()}\0${sha}\0${version}`)
443
+ if (tags) known.tags = { name, version, tags }
444
+ const socket = isPrivate ? null : socketReports.peek(`${name}@${version}`)
445
+ if (socket !== undefined) known.socket = { name, version, socket }
446
+ const provenance = isPrivate ? null : keptNpmProvenance(name, version)
447
+ if (provenance !== undefined) known.provenance = { name, version, provenance }
448
+ return known
449
+ }