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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +2 -0
  50. package/common/managed/finding-detail.js +32 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +5 -0
  54. package/common/managed/npm-packages.js +34 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2WDZPXVL.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +362 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +21 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +73 -0
  237. package/server-managed/comparison.ts +41 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +458 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +142 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +415 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +39 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5329 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedDb } from './db-methods.ts'
3
+ import type { ManagedComment } from '../common/managed/comments.ts'
4
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
5
+ import { triageWireEntry } from './triage-response.ts'
6
+
7
+ export interface ImportTriageSnapshot {
8
+ entry: TriageEntryPatch | null
9
+ comments: ManagedComment[]
10
+ version: string
11
+ }
12
+ export interface ImportTriageStore {
13
+ getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
14
+ importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
15
+ }
16
+
17
+ // Raw store methods share one transaction for comparison, triage, and comments.
18
+ export function importTriageMethods(store: Pick<ManagedDb, 'getReport' | 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
19
+ async function snapshot(ids: readonly string[]) {
20
+ const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
21
+ const comments = await store.listComments(ids)
22
+ return Object.fromEntries(ids.map(id => {
23
+ const row = rows.get(id)
24
+ const entry = row ? triageWireEntry(row) : null
25
+ const own = comments.filter(comment => comment.findingId === id)
26
+ const version = createHash('sha256').update(JSON.stringify([entry, row?.updatedAt, own])).digest('hex')
27
+ return [id, { entry, comments: own, version }]
28
+ }))
29
+ }
30
+ return {
31
+ getImportTriage: snapshot,
32
+ async importTriage(entries, expected, actor, reportId, now) {
33
+ // A report deleted after the HTTP membership check must not leave newly
34
+ // imported annotations behind. Check existence in the write transaction.
35
+ if (!reportId || !(await store.getReport(reportId))) return false
36
+ const current = await snapshot(entries.map(([id]) => id))
37
+ if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
38
+ const changes: [string, TriageEntryPatch | null][] = []
39
+ for (const [id, value] of entries) {
40
+ const { comment, ...entry } = value ?? {}
41
+ changes.push([id, Object.keys(entry).length > 0 ? entry : null])
42
+ if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
43
+ await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
44
+ actor, createdAt: null, updatedAt: null, reportId }, now)
45
+ }
46
+ }
47
+ await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
48
+ return true
49
+ },
50
+ }
51
+ }
@@ -0,0 +1,192 @@
1
+ // Managed HTTP app and standalone boot. The combined launcher mounts this
2
+ // same app on e2e's listener; storage, routing and cleanup stay here.
3
+ import { randomUUID } from 'node:crypto'
4
+ import { type Server, createServer } from 'node:http'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createOriginGate } from '../server-common/origin.ts'
7
+ import { managedStorageLines } from '../server-common/storage-log.ts'
8
+ import { runReapers, withReap } from '../server-common/reap.ts'
9
+ import { startServer } from '../server-common/standalone.ts'
10
+ import { initializeApp } from '../server-common/initialize.ts'
11
+ import { type ManagedConfig, loadManagedConfig } from './config.ts'
12
+ import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
13
+ import { setNpmTarballCache } from './npm-loads.ts'
14
+ import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
15
+ import { setNpmDocumentCache } from './npm-packages.ts'
16
+ import { loadManagedStatic } from './static.ts'
17
+ import { openManagedStorage } from './storage.ts'
18
+ import { setCacheDir } from '@preventive/upstream/npm.js'
19
+
20
+ // Housekeeping runs on ordinary traffic too, including serverless instances
21
+ // without a timer or cron. Failed sweeps retry on later traffic after a minute.
22
+ const REAP_INTERVAL_MS = 3_600_000
23
+ const REAP_RETRY_MS = 60_000
24
+
25
+ // `ui: false` serves only the API: `out/` is never read.
26
+ type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
27
+
28
+ export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
29
+ // Audits keep upstream's records on disk where the config has a cache
30
+ // directory; bundle builds set it in their own worker. The npm viewer
31
+ // keeps the tarballs it downloads there too, beside theirs, and public
32
+ // packages' whole documents (npm-packages.ts).
33
+ setCacheDir(config.upstreamCacheDir ?? false)
34
+ setNpmTarballCache(config.upstreamCacheDir ?? null)
35
+ setNpmDocumentCache(config.upstreamCacheDir ?? null)
36
+ // Load the optional peer only for serverless managed deployments. Embedding
37
+ // hosts can discard listener promises, so retain maintenance inside the app.
38
+ const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
39
+ // The host's reviewed dependencies, registered before; none after.
40
+ const reviewedDependencies = assembledReviewedDependencies()
41
+ const assembled = { ...options, ...reviewedDependencies ? { reviewedDependencies } : {} }
42
+ return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
43
+ }
44
+
45
+ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
46
+ const storage = await openManagedStorage(config)
47
+ const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
48
+ rollback.defer(() => db.close())
49
+ const originGate = createOriginGate(config.host, config.trustProxyEnv)
50
+
51
+ let shuttingDown = false
52
+ const isShuttingDown = () => shuttingDown || options.isShuttingDown?.() === true
53
+ const inFlight = new Set<Promise<unknown>>()
54
+ function track(p: Promise<unknown>): void {
55
+ inFlight.add(p)
56
+ p.finally(() => inFlight.delete(p)).catch(() => {})
57
+ }
58
+
59
+ const serveStatic = ui ? loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
60
+ indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
61
+ }) : () => false
62
+ const routeRequest = createManagedRequestHandler({
63
+ ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
64
+ ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
65
+ isShuttingDown, track,
66
+ })
67
+
68
+ let cleanup: Promise<void> | undefined, nextCleanupAt = 0
69
+ const maintenance = new AbortController()
70
+ function reap(automatic = false): Promise<void> {
71
+ if (cleanup) return cleanup
72
+ if (isShuttingDown()) return Promise.resolve()
73
+ const startedAt = Date.now()
74
+ nextCleanupAt = startedAt + REAP_INTERVAL_MS
75
+ let sessions = 0, uploads = 0
76
+ cleanup = (async () => {
77
+ const owner = automatic && config.serverless ? randomUUID() : null
78
+ if (owner && !await db.claimMaintenanceLease(owner, startedAt, startedAt + Math.max(300_000, (config.storageEncryptionMigrateMaxMs ?? 0) + 60_000), config.storageEncryptionMigrate)) return false
79
+ let succeeded = false
80
+ try {
81
+ await runReapers({
82
+ sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
83
+ ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
84
+ ...('reapPrettyCopies' in storage ? { prettyCopies: () => storage.reapPrettyCopies(maintenance.signal) } : {}),
85
+ ...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
86
+ })
87
+ succeeded = true
88
+ return true
89
+ } finally {
90
+ if (owner) await db.finishMaintenanceLease(owner, Date.now() + (succeeded ? REAP_INTERVAL_MS : REAP_RETRY_MS))
91
+ }
92
+ })().then(ran => {
93
+ nextCleanupAt = Date.now() + REAP_INTERVAL_MS
94
+ if (!ran) return
95
+ return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
96
+ }, err => {
97
+ nextCleanupAt = Date.now() + REAP_RETRY_MS
98
+ throw err
99
+ }).finally(() => { cleanup = undefined })
100
+ track(cleanup)
101
+ return cleanup
102
+ }
103
+ function automaticReap(): Promise<void> {
104
+ if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
105
+ return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
106
+ }
107
+ async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
108
+ // Register before routing can end the response, including when init()'s
109
+ // listener is mounted in Fastify rather than using api/managed.ts.
110
+ const pendingMaintenance = automaticReap(); waitUntil?.(pendingMaintenance)
111
+ try { await routeRequest(req, res) }
112
+ finally { await pendingMaintenance }
113
+ }
114
+ const gcTimer = config.serverless ? null : setInterval(() => {
115
+ void automaticReap()
116
+ }, REAP_INTERVAL_MS)
117
+ rollback.defer(stop)
118
+
119
+ function stop(): void {
120
+ shuttingDown = true
121
+ maintenance.abort()
122
+ if (gcTimer) clearInterval(gcTimer)
123
+ }
124
+
125
+ async function close(): Promise<void> {
126
+ stop()
127
+ if (inFlight.size > 0) await Promise.allSettled([...inFlight])
128
+ await db.close()
129
+ }
130
+ return { handleRequest, reap, isShuttingDown, stop, close }
131
+ }
132
+
133
+ export function logManagedStartup(config: ManagedConfig, port: number, mode = 'managed'): void {
134
+ console.log([
135
+ `DeepView managed server (${mode}):`,
136
+ ` HTTP: http://${config.host}:${port}/`,
137
+ ...managedStorageLines(config),
138
+ ].join('\n'))
139
+ }
140
+
141
+ export async function init(config = loadManagedConfig(), options: Pick<ManagedAppOptions, 'ui'> = {}): Promise<Server> {
142
+ const app = await createManagedApp(config, options)
143
+ const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
144
+ let disposal: Promise<void> | undefined
145
+ function dispose(): Promise<void> {
146
+ if (!disposal) {
147
+ app.stop()
148
+ disposal = (async () => {
149
+ try {
150
+ if (server.listening) {
151
+ await new Promise<void>((resolve, reject) => {
152
+ server.close(err => { if (err) reject(err); else resolve() })
153
+ })
154
+ }
155
+ } finally {
156
+ try { await app.close() }
157
+ finally {
158
+ server.off('error', onError)
159
+ server.off('close', onClose)
160
+ server.off('listening', onListening)
161
+ }
162
+ }
163
+ })()
164
+ }
165
+ return disposal
166
+ }
167
+ // Embedding hosts can await full cleanup even when they only copied the
168
+ // request listener and never bound this server. Repeated disposal is safe.
169
+ server[Symbol.asyncDispose] = dispose
170
+
171
+ function onError(): void { onClose() }
172
+ function onClose(): void {
173
+ if (!disposal) void dispose().catch(err => { console.error('Managed server cleanup failed:', err) })
174
+ }
175
+ function onListening(): void {
176
+ const address = server.address()
177
+ const port = typeof address === 'object' && address ? address.port : config.port
178
+ logManagedStartup(config, port)
179
+ }
180
+ server.on('error', onError)
181
+ server.once('close', onClose)
182
+ server.on('listening', onListening)
183
+ return server
184
+ }
185
+
186
+ export async function start(): Promise<void> {
187
+ const config = loadManagedConfig()
188
+ const server = await init(config)
189
+ startServer(server, config)
190
+ }
191
+
192
+ if (import.meta.main) await start()
@@ -0,0 +1,37 @@
1
+ // What was lately worked out, kept a little while in memory for whoever asks
2
+ // for it next, by key: each for `keptMs` from when it was kept, the least
3
+ // lately read going first past `maxEntries` of them or `maxBytes` between
4
+ // them, and one that alone takes more than `maxBytes` not kept at all.
5
+ // Whoever reads one is the caller's to check, as they would one worked out.
6
+ export function keptBriefly<T>({ keptMs, maxBytes, maxEntries = Infinity }: { keptMs: number; maxBytes: number; maxEntries?: number }) {
7
+ const held = new Map<string, { value: T; bytes: number; timer: ReturnType<typeof setTimeout> }>()
8
+ let heldBytes = 0
9
+ const forget = (key: string) => {
10
+ const entry = held.get(key)
11
+ if (!entry) return
12
+ clearTimeout(entry.timer)
13
+ held.delete(key)
14
+ heldBytes -= entry.bytes
15
+ }
16
+ return {
17
+ get(key: string): T | undefined {
18
+ const entry = held.get(key)
19
+ if (!entry) return undefined
20
+ held.delete(key)
21
+ held.set(key, entry)
22
+ return entry.value
23
+ },
24
+ keep(key: string, value: T, bytes: number) {
25
+ if (bytes > maxBytes) return
26
+ forget(key)
27
+ for (const old of held.keys()) {
28
+ if (held.size < maxEntries && heldBytes + bytes <= maxBytes) break
29
+ forget(old)
30
+ }
31
+ const timer = setTimeout(() => { forget(key) }, keptMs)
32
+ timer.unref()
33
+ held.set(key, { value, bytes, timer })
34
+ heldBytes += bytes
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,28 @@
1
+ // Answers kept for an hour, by what they answer (npm-insights.ts,
2
+ // npm-provenance.ts), at most `max` of them, the oldest asked going first;
3
+ // one still being asked for is shared, and one that failed, or that `whole`
4
+ // says is partial, is dropped once it is answered. `peek` gives one already
5
+ // answered, asking for nothing; undefined where none is.
6
+ const KEPT_MS = 60 * 60_000
7
+
8
+ export function kept<T>(max: number, whole: (value: T) => boolean = () => true) {
9
+ const answers = new Map<string, { at: number; value: Promise<T>; answered?: { value: T } }>()
10
+ const get = (key: string, ask: () => Promise<T>): Promise<T> => {
11
+ const now = Date.now()
12
+ const hit = answers.get(key)
13
+ if (hit && now - hit.at < KEPT_MS) return hit.value
14
+ const value = ask()
15
+ const entry: { at: number; value: Promise<T>; answered?: { value: T } } = { at: now, value }
16
+ answers.delete(key)
17
+ answers.set(key, entry)
18
+ while (answers.size > max) answers.delete(answers.keys().next().value!)
19
+ const drop = () => { if (answers.get(key) === entry) answers.delete(key) }
20
+ value.then(answer => { if (whole(answer)) entry.answered = { value: answer }; else drop(); return answer }, drop)
21
+ return value
22
+ }
23
+ const peek = (key: string): T | undefined => {
24
+ const hit = answers.get(key)
25
+ return hit?.answered && Date.now() - hit.at < KEPT_MS ? hit.answered.value : undefined
26
+ }
27
+ return Object.assign(get, { peek })
28
+ }
@@ -0,0 +1,162 @@
1
+ // Global deduplication reports live entirely in SQL. The payload is authenticated
2
+ // ciphertext bound to its row, using the deployment's existing storage key.
3
+ import { Buffer } from 'node:buffer'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
6
+ import { parseLinkedFindings } from '../client/linked-findings.js'
7
+ import type { ManagedSql } from './sql.ts'
8
+ import { ManagedMutationError } from './management.ts'
9
+
10
+ export const LINK_REPORT_SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS managed_link_report (
12
+ id TEXT PRIMARY KEY, filename TEXT NOT NULL, sha256 TEXT NOT NULL UNIQUE,
13
+ encrypted_groups TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
14
+ group_count INTEGER NOT NULL, finding_count INTEGER NOT NULL,
15
+ uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
16
+ uploaded_by_login TEXT, uploaded_at INTEGER NOT NULL
17
+ ) STRICT;
18
+ `
19
+ export interface LinkReport {
20
+ id: string; filename: string; enabled: boolean; groupCount: number; findingCount: number
21
+ uploadedByLogin: string | null; uploadedAt: number
22
+ }
23
+ export interface LinkReportStore {
24
+ listLegacyLinkReports(): Promise<{ id: string; filename: string }[]>
25
+ migrateLinkReport(id: string, content: string): Promise<boolean>
26
+ listLinkReports(): Promise<LinkReport[]>
27
+ getLinkReport(sessionId: string, id: string): Promise<LinkReport & { groups: string[][] }>
28
+ getLinkRevision(): Promise<string>
29
+ getEnabledLinkGroups(revision: string): Promise<string[][]>
30
+ importLinkReport(sessionId: string, filename: string, content: string): Promise<{ report: LinkReport; reused: boolean }>
31
+ setLinkReportEnabled(sessionId: string, id: string, enabled: boolean): Promise<void>
32
+ }
33
+ const columns = `id, filename, enabled, group_count AS groupCount, finding_count AS findingCount,
34
+ uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt`
35
+ const identity = (id: string) => `managed_link_report.encrypted_groups:${id}`
36
+ function decryptGroups(key: StorageKey | null, id: string, payload: string): string[][] {
37
+ if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
38
+ const plain = unwrapStorageValue(key, identity(id), payload)
39
+ try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
40
+ }
41
+ export async function linkRevision(db: ManagedSql): Promise<string> {
42
+ return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
43
+ }
44
+ // The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
45
+ // however they were read.
46
+ export function linkRevisionOf(rows: unknown[]): string {
47
+ return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
48
+ }
49
+ function parseLinkReport(content: string) {
50
+ const parsed = parseLinkedFindings(content)
51
+ if (parsed) return parsed
52
+ // Older managed exports may carry the normalized links envelope.
53
+ try {
54
+ const data = JSON.parse(content)
55
+ if (data?.source !== 'links' || !Array.isArray(data.links) || !Array.isArray(data.findings) || data.findings.length > 0) return null
56
+ return data.links.length === 0 ? { groups: [], skipped: 0 } : parseLinkedFindings(JSON.stringify(data.links))
57
+ } catch { return null }
58
+ }
59
+ export function linkReportMethods(db: ManagedSql, key: StorageKey | null): LinkReportStore {
60
+ const map = (row: Omit<LinkReport, 'enabled'> & { enabled: number }): LinkReport => ({ ...row, enabled: row.enabled === 1 })
61
+ async function authorize(sessionId: string) {
62
+ const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
63
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
64
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
65
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
66
+ return user
67
+ }
68
+ return {
69
+ async listLegacyLinkReports() {
70
+ return await db.prepare("SELECT id, filename FROM managed_report WHERE analyzer = 'links' ORDER BY id").all() as { id: string; filename: string }[]
71
+ },
72
+ async migrateLinkReport(id, content) {
73
+ const row = await db.prepare(`SELECT filename, sha256, visible, uploaded_by AS uploadedBy,
74
+ uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt FROM managed_report WHERE id = ? AND analyzer = 'links'`).get(id) as {
75
+ filename: string; sha256: string; visible: number; uploadedBy: string | null; uploadedByLogin: string | null; uploadedAt: number
76
+ } | undefined
77
+ if (!row) return false
78
+ if (!key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
79
+ if (createHash('sha256').update(content).digest('base64url') !== row.sha256) throw new Error('Legacy link report content changed')
80
+ const parsed = parseLinkReport(content)
81
+ if (!parsed) throw new Error('Invalid legacy link report')
82
+ const plain = Buffer.from(JSON.stringify(parsed.groups))
83
+ try {
84
+ const sha256 = createHash('sha256').update(plain).digest('base64url')
85
+ await db.prepare(`INSERT INTO managed_link_report
86
+ (id, filename, sha256, encrypted_groups, enabled, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sha256) DO NOTHING`).run(
88
+ id, row.filename, sha256, wrapStorageValue(key, identity(id), plain), row.visible,
89
+ parsed.groups.length, new Set(parsed.groups.flat()).size, row.uploadedBy, row.uploadedByLogin, row.uploadedAt)
90
+ await db.prepare('DELETE FROM managed_report WHERE id = ?').run(id)
91
+ } finally { plain.fill(0) }
92
+ return true
93
+ },
94
+ async listLinkReports() {
95
+ return (await db.prepare(`SELECT ${columns} FROM managed_link_report ORDER BY uploaded_at DESC, id`).all() as Parameters<typeof map>[0][]).map(map)
96
+ },
97
+ async getLinkReport(sessionId, id) {
98
+ await authorize(sessionId)
99
+ const row = await db.prepare(`SELECT ${columns}, encrypted_groups AS payload FROM managed_link_report WHERE id = ?`).get(id) as (Parameters<typeof map>[0] & { payload: string }) | undefined
100
+ if (!row) throw new ManagedMutationError(404, 'no-link-report')
101
+ const { payload, ...report } = row
102
+ return { ...map(report), groups: decryptGroups(key, id, payload) }
103
+ },
104
+ getLinkRevision: () => linkRevision(db),
105
+ async getEnabledLinkGroups(revision) {
106
+ if (await linkRevision(db) !== revision) throw new ManagedMutationError(409, 'deduplication-changed')
107
+ const rows = await db.prepare('SELECT id, encrypted_groups AS payload FROM managed_link_report WHERE enabled = 1 ORDER BY id').all() as { id: string; payload: string }[]
108
+ if (rows.length > 0 && !key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
109
+ return rows.flatMap(row => decryptGroups(key, row.id, row.payload))
110
+ },
111
+ async importLinkReport(sessionId, filename, content) {
112
+ const user = await authorize(sessionId)
113
+ if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
114
+ const parsed = parseLinkReport(content)
115
+ if (!parsed || parsed.skipped) throw new ManagedMutationError(400, 'invalid-link-report')
116
+ const plain = Buffer.from(JSON.stringify(parsed.groups))
117
+ const sha256 = createHash('sha256').update(plain).digest('base64url')
118
+ try {
119
+ const existing = await db.prepare(`SELECT ${columns} FROM managed_link_report WHERE sha256 = ?`).get(sha256) as Parameters<typeof map>[0] | undefined
120
+ if (existing) return { report: map(existing), reused: true }
121
+ const id = randomUUID(), uploadedAt = Date.now()
122
+ const findingCount = new Set(parsed.groups.flat()).size, groupCount = parsed.groups.length
123
+ await db.prepare(`INSERT INTO managed_link_report
124
+ (id, filename, sha256, encrypted_groups, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, filename, sha256, wrapStorageValue(key, identity(id), plain), groupCount, findingCount, user.id, user.login, uploadedAt)
126
+ return { report: { id, filename, enabled: true, groupCount, findingCount, uploadedByLogin: user.login, uploadedAt }, reused: false }
127
+ } finally { plain.fill(0) }
128
+ },
129
+ async setLinkReportEnabled(sessionId, id, enabled) {
130
+ await authorize(sessionId)
131
+ if (!(await db.prepare('UPDATE managed_link_report SET enabled = ? WHERE id = ?').run(+enabled, id)).changes) throw new ManagedMutationError(404, 'no-link-report')
132
+ },
133
+ }
134
+ }
135
+
136
+ // Merge complete components BEFORE projecting them onto the response. An ID
137
+ // absent from every returned report can still bridge two visible findings.
138
+ export function mergeLinkGroups(groups: readonly string[][], present?: ReadonlySet<string>): string[][] {
139
+ const parents = new Map<string, string>()
140
+ const root = (id: string): string => {
141
+ if (!parents.has(id)) parents.set(id, id)
142
+ let value = id
143
+ while (parents.get(value) !== value) {
144
+ parents.set(value, parents.get(parents.get(value)!)!)
145
+ value = parents.get(value)!
146
+ }
147
+ return value
148
+ }
149
+ for (const ids of groups) {
150
+ if (ids.length === 0) continue
151
+ const first = root(ids[0]!)
152
+ for (const id of ids.slice(1)) { const other = root(id); if (other !== first) parents.set(other, first) }
153
+ }
154
+ const merged = new Map<string, string[]>()
155
+ for (const id of parents.keys()) {
156
+ if (present && !present.has(id)) continue
157
+ const key = root(id)
158
+ const ids = merged.get(key) ?? []
159
+ ids.push(id); merged.set(key, ids)
160
+ }
161
+ return [...merged.values()].filter(ids => ids.length >= 2)
162
+ }
@@ -0,0 +1,129 @@
1
+ // The packages a lockfile of npm's ecosystem locks, as @preventive/lockfile
2
+ // reads package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
3
+ // pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, each with where it
4
+ // is installed from and whether only devDependencies need it
5
+ // (repository-risk.ts).
6
+ import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
7
+ import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
8
+ import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
9
+ import type { LockfileFormat } from '@preventive/upstream/github.js'
10
+ import type { ReviewedLockfile } from './reviewed-dependencies.ts'
11
+
12
+ export interface LockedPackage {
13
+ name: string
14
+ // None for a git dependency whose package.json has none.
15
+ version: string | null
16
+ // Where it is installed from: npm's registry, another host's tarball with
17
+ // an integrity, such as a company's mirror of npm's, a git repository, a
18
+ // tarball with none, or a file of the project.
19
+ source: 'registry' | 'mirror' | 'git' | 'tarball' | 'file'
20
+ // Where one from elsewhere than npm's registry comes from, without any
21
+ // credentials its URL has.
22
+ from?: string
23
+ // As the lockfile has it: a mirror's tarball's, npm's package of its name
24
+ // and version only where npm's integrity is among it; and, where its
25
+ // reviews ask (`review`), npm's registry's too.
26
+ integrity?: string
27
+ // Where its reviews ask: the sha1 Yarn 1 writes after a tarball's `#`, in
28
+ // hex (`shasum`); the hash of the patch pnpm applies to it
29
+ // (`patchHash`, its patchedDependencies'); and the lockfile's format.
30
+ shasum?: string
31
+ patchHash?: string
32
+ lockfile?: ReviewedLockfile
33
+ // Where only the importers' devDependencies reach it, as npm's `dev` flag
34
+ // says, and an install of no devDependencies leaves it out: in npm's and
35
+ // pnpm's lockfiles. Yarn 1's says nothing of it, which only the
36
+ // package.json files do, and none of its are.
37
+ dev?: true
38
+ }
39
+
40
+ // A package as each reader resolves one, its name the one it is published
41
+ // under, and a registry tarball's URL held to its name and version.
42
+ type Resolution =
43
+ | { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; sha1?: string | undefined; gitHosted?: boolean }
44
+ | { type: 'git'; repo: string }
45
+ | { type: 'directory' }
46
+ interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined }
47
+
48
+ // The packages of a lockfile of `format`, as its reader resolves them.
49
+ // Versions go unchecked against ranges, as only what is locked is asked for.
50
+ function packagesOf(format: LockfileFormat, text: string): ReadPackage[] {
51
+ if (format === 'npm') return Object.values(parseNpmLockfile(text, { checkVersions: false }).packages)
52
+ if (format === 'pnpm') {
53
+ const { lockfile, env } = parsePnpmLockfile(text)
54
+ return [...Object.values(env?.packages ?? {}), ...Object.values(lockfile?.packages ?? {})]
55
+ }
56
+ if (format !== 'yarn') throw new TypeError(`lockedPackages: ${format} lockfiles are not read`)
57
+ // Yarn 2 and later write another format, which only the text tells apart.
58
+ if (/^__metadata:/mu.test(text)) throw new LockfileError('a yarn.lock of Yarn 2 or later, which is not read')
59
+ // Yarn 1 names an alias, `my-q@npm:q@^1.5.1`, by its own name.
60
+ return Object.values(parseYarn1Lockfile(text, { checkVersions: false }).packages)
61
+ .map(pkg => ({ ...pkg, name: /@npm:((?:@[^/@]+\/)?[^/@]+)/u.exec(pkg.patterns[0] ?? '')?.[1] ?? pkg.name }))
62
+ }
63
+
64
+ // npm's registry, by either of its names, and yarn's mirror of it, as
65
+ // @preventive/lockfile holds their tarballs to a package's name and version:
66
+ // its link to one, as it names it, a hash after it alone (Yarn 1's sha1).
67
+ const REGISTRY_HOSTS = new Set(['registry.npmjs.org', 'registry.npmjs.com', 'registry.yarnpkg.com'])
68
+ const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password && !url.search
69
+ && REGISTRY_HOSTS.has(url.hostname.replace(/\.+$/u, ''))
70
+ // Where a URL is, as it may be shown: its credentials, query and fragment
71
+ // left out.
72
+ const shown = (url: URL | null) => url ? { from: `${url.protocol}//${url.host}${url.pathname}` } : {}
73
+
74
+ // Where a package read comes from (LockedPackage), with npm's registry's
75
+ // integrity where `review`; null for one a package bundles, with no
76
+ // resolution, or one of the project's own directories.
77
+ function located({ name, version, resolution }: ReadPackage, review: boolean): LockedPackage | null {
78
+ const at = { name, version: version ?? null }
79
+ if (resolution === undefined || resolution.type === 'directory') return null
80
+ if (resolution.type === 'git') return { ...at, source: 'git', ...shown(URL.parse(resolution.repo)) }
81
+ const { tarball, integrity } = resolution
82
+ if (resolution.gitHosted) return { ...at, source: 'git', ...shown(URL.parse(tarball ?? '')) }
83
+ const registry = { ...at, source: 'registry' as const, ...review && integrity ? { integrity } : {} }
84
+ if (tarball === undefined) return registry
85
+ if (tarball.startsWith('file:')) return { ...at, source: 'file' }
86
+ const url = URL.parse(tarball)
87
+ if (url && isRegistry(url)) return registry
88
+ return integrity ? { ...at, ...shown(url), source: 'mirror', integrity } : { ...at, ...shown(url), source: 'tarball' }
89
+ }
90
+ // As above, and with what its reviews ask (LockedPackage `shasum`,
91
+ // `patchHash`, `lockfile`) where `review`.
92
+ function locked(pkg: ReadPackage, format: LockfileFormat, review: boolean): LockedPackage | null {
93
+ const one = located(pkg, review)
94
+ if (!one || !review) return one
95
+ const sha1 = pkg.resolution?.type === 'tarball' ? pkg.resolution.sha1 : undefined
96
+ return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {}, lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
97
+ }
98
+
99
+ // The packages a lockfile of `format` (npm, pnpm or yarn, as
100
+ // findRepoLockfiles names them) locks, each once, in the lockfile's order:
101
+ // `dev` only where it is wherever it is installed, at each path of npm's
102
+ // tree, and with each of pnpm's peers; with `review`, each with what its
103
+ // reviews hold it to (LockedPackage `integrity`, `shasum`, `patchHash`),
104
+ // each patch of it apart. Throws the reader's LockfileError or YamlError for
105
+ // one it refuses.
106
+ export function lockedPackages(format: LockfileFormat, text: string, { review = false } = {}): LockedPackage[] {
107
+ const found = new Map<string, LockedPackage>()
108
+ for (const pkg of packagesOf(format, text)) {
109
+ const one = locked(pkg, format, review)
110
+ if (!one) continue
111
+ if (pkg.dev === true) one.dev = true
112
+ const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}\0${one.shasum ?? ''}\0${one.patchHash ?? ''}`
113
+ const had = found.get(key)
114
+ if (!had) found.set(key, one)
115
+ else if (had.dev && !one.dev) delete had.dev
116
+ }
117
+ return [...found.values()]
118
+ }
119
+
120
+ // Whether a lockfile at `path` is a test's fixture or example: under a
121
+ // directory of tests (`test`, `tests`, `__tests__` and such), in one of
122
+ // fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
123
+ // it, as a test may keep one its readers refuse on purpose.
124
+ const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
125
+ export function isTestFixture(path: string) {
126
+ const dirs = path.split('/').slice(0, -1)
127
+ const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
128
+ return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
129
+ }
@@ -0,0 +1,49 @@
1
+ // The lockfiles a repository's dependency review holds to, as an admin sets
2
+ // it (repository-dependency-reviews.ts): every one but those it names
3
+ // (`exclude`, naming none for all of them), or those it names alone
4
+ // (`include`). Each is named by its path in the repository, or, ending in
5
+ // `/`, by a directory, every lockfile under it. A directory of a pull
6
+ // request's lockfiles (pull-dependencies.ts) is reviewed where one of its
7
+ // lockfiles is held to; one none of whose are, not at all (`excluded`), nor
8
+ // failing the review where it couldn't be read.
9
+ export interface LockfileScope { mode: 'exclude' | 'include'; paths: string[] }
10
+ export const EVERY_LOCKFILE: LockfileScope = { mode: 'exclude', paths: [] }
11
+ export const MAX_SCOPED_LOCKFILES = 100
12
+ const MAX_PATH = 300
13
+
14
+ // A path as one is named: relative, of `/`-separated names none of which is
15
+ // empty, `.` or `..`, nor holding a backslash or a control character; a
16
+ // directory's ending in `/`.
17
+ function namedPath(path: unknown): path is string {
18
+ if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PATH || path.startsWith('/')) return false
19
+ if (/[\\\p{Cc}]/u.test(path)) return false
20
+ const names = path.endsWith('/') ? path.slice(0, -1).split('/') : path.split('/')
21
+ return names.every(name => name !== '' && name !== '.' && name !== '..')
22
+ }
23
+
24
+ // A scope as a request sends it, each path once, in order; null for one
25
+ // that isn't one.
26
+ export function scopeOf(value: unknown): LockfileScope | null {
27
+ const { mode, paths } = (value ?? {}) as { mode?: unknown; paths?: unknown }
28
+ if ((mode !== 'exclude' && mode !== 'include') || !Array.isArray(paths) || paths.length > MAX_SCOPED_LOCKFILES || !paths.every(namedPath)) return null
29
+ return { mode, paths: [...new Set(paths as string[])].toSorted() }
30
+ }
31
+
32
+ // A scope as stored (JSON, none for every lockfile), every lockfile where
33
+ // it can't be read.
34
+ export function storedScope(text: string | null): LockfileScope {
35
+ if (!text) return EVERY_LOCKFILE
36
+ try { return scopeOf(JSON.parse(text)) ?? EVERY_LOCKFILE } catch { return EVERY_LOCKFILE }
37
+ }
38
+ export const scopeText = (scope: LockfileScope) => isEvery(scope) ? null : JSON.stringify({ mode: scope.mode, paths: scope.paths })
39
+ export const isEvery = (scope: LockfileScope) => scope.mode === 'exclude' && scope.paths.length === 0
40
+
41
+ const names = (path: string, named: string) => named.endsWith('/') ? path.startsWith(named) : path === named
42
+ // Whether the lockfile at `path` is held to.
43
+ export function heldTo(scope: LockfileScope, path: string) {
44
+ const named = scope.paths.some(one => names(path, one))
45
+ return scope.mode === 'include' ? named : !named
46
+ }
47
+ // Whether a directory of lockfiles at `paths` is reviewed: where one is
48
+ // held to, or it names none.
49
+ export const reviewedIn = (scope: LockfileScope, paths: string[]) => paths.length === 0 || paths.some(path => heldTo(scope, path))