@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.41

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 (312) 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 +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -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 +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2AFQKRAM.js +51 -0
  72. package/out/chunk-2KBF5TZ7.js +2 -0
  73. package/out/chunk-2XUEZHIP.js +28 -0
  74. package/out/chunk-35VE6CDH.js +2 -0
  75. package/out/chunk-3BZ7JERB.js +1 -0
  76. package/out/chunk-4F6JSF57.js +3 -0
  77. package/out/chunk-4XCDFGDP.js +10 -0
  78. package/out/chunk-C4Z6ZBXV.js +10 -0
  79. package/out/chunk-CMQA4DM6.js +1 -0
  80. package/out/chunk-CVVJAXVY.js +2 -0
  81. package/out/chunk-DIJJNTRH.js +10 -0
  82. package/out/chunk-GJ4IUCNS.js +1 -0
  83. package/out/chunk-GNXOKIGP.js +80 -0
  84. package/out/chunk-GZ4MS3TE.js +13 -0
  85. package/out/chunk-IU3CCINR.js +3 -0
  86. package/out/chunk-K3X5EJBQ.js +2 -0
  87. package/out/chunk-KEX2ATEW.js +2 -0
  88. package/out/chunk-KVEUXIEU.js +1 -0
  89. package/out/chunk-KYCNJVB7.js +1 -0
  90. package/out/chunk-NRPC74AM.js +1 -0
  91. package/out/chunk-OOJM4CTU.js +1 -0
  92. package/out/chunk-PNHBZGDD.js +1 -0
  93. package/out/chunk-QZ3A376W.js +1 -0
  94. package/out/chunk-R5O6FAW4.js +2 -0
  95. package/out/chunk-RFZQQXSG.js +17 -0
  96. package/out/chunk-RSHMVYNS.js +1 -0
  97. package/out/chunk-TUII34K2.js +1 -0
  98. package/out/chunk-TY4RZD3Z.js +226 -0
  99. package/out/chunk-TZ5KB2Y5.js +10 -0
  100. package/out/chunk-U6OPEXIA.js +1 -0
  101. package/out/chunk-UNTRGE26.js +2 -0
  102. package/out/chunk-UXNUAIIC.js +33 -0
  103. package/out/chunk-UYAXVRVL.js +1 -0
  104. package/out/chunk-UZAZM2SX.js +2 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XL35ZV6R.js +1 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +298 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +595 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +156 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +223 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/score.js +59 -0
  154. package/risk/npm/specs.js +124 -0
  155. package/risk/npm/tarball.js +113 -0
  156. package/server-common/database-config.ts +16 -0
  157. package/server-common/initialize.ts +18 -0
  158. package/server-common/npm-advisories.ts +101 -0
  159. package/{server → server-common}/origin.ts +5 -5
  160. package/server-common/reap.ts +48 -0
  161. package/server-common/scan-config.ts +19 -0
  162. package/server-common/standalone.ts +29 -0
  163. package/server-common/storage-crypto.ts +182 -0
  164. package/server-common/storage-log.ts +38 -0
  165. package/server-common/vercel-blob.ts +103 -0
  166. package/server-e2e/app.ts +486 -0
  167. package/{server → server-e2e}/auth.ts +5 -1
  168. package/{server → server-e2e}/bus-receiver.ts +2 -1
  169. package/{server → server-e2e}/cli.js +9 -4
  170. package/{server → server-e2e}/config.ts +56 -35
  171. package/{server → server-e2e}/db-neon.ts +2 -2
  172. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  173. package/{server → server-e2e}/db-stmt.ts +2 -2
  174. package/{server → server-e2e}/db.ts +7 -7
  175. package/{server → server-e2e}/http.ts +116 -18
  176. package/{server → server-e2e}/hub.ts +40 -9
  177. package/server-e2e/index.ts +17 -0
  178. package/server-e2e/lifecycle.ts +95 -0
  179. package/{server → server-e2e}/neon-driver.ts +2 -2
  180. package/{server → server-e2e}/npm-proxy.ts +12 -145
  181. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  182. package/{server → server-e2e}/objstore/blob.ts +1 -1
  183. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  184. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  185. package/{server → server-e2e}/objstore/init.ts +35 -18
  186. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  187. package/server-e2e/objstore/rest-deny.ts +28 -0
  188. package/server-e2e/objstore/rest-mint.ts +224 -0
  189. package/{server → server-e2e}/objstore/rest.ts +27 -26
  190. package/{server → server-e2e}/objstore/sign.ts +105 -0
  191. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  192. package/{server → server-e2e}/objstore/store.ts +7 -7
  193. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  194. package/{server → server-e2e}/pubsub.ts +12 -9
  195. package/{server → server-e2e}/sign.ts +4 -4
  196. package/{server → server-e2e}/sse-server.ts +102 -68
  197. package/{server → server-e2e}/sse-session.ts +28 -8
  198. package/{server → server-e2e}/static.ts +31 -21
  199. package/server-e2e/sync-handlers.ts +408 -0
  200. package/{server → server-e2e}/ws-server.ts +20 -9
  201. package/server-managed/activity.ts +245 -0
  202. package/server-managed/avatar-store.ts +12 -0
  203. package/server-managed/blob-store.ts +17 -0
  204. package/server-managed/brotli.ts +10 -0
  205. package/server-managed/bundle-advisories.ts +120 -0
  206. package/server-managed/bundle-advisory-inventory.ts +77 -0
  207. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  208. package/server-managed/bundle-build-diagnostics.js +23 -0
  209. package/server-managed/bundle-build-leases.ts +41 -0
  210. package/server-managed/bundle-build-worker.js +111 -0
  211. package/server-managed/bundle-build.ts +198 -0
  212. package/server-managed/bundle-cache.ts +199 -0
  213. package/server-managed/bundle-catalog.ts +29 -0
  214. package/server-managed/bundle-commits.ts +140 -0
  215. package/server-managed/bundle-store.ts +24 -0
  216. package/server-managed/bundle-summary-cache.ts +130 -0
  217. package/server-managed/bundle.ts +120 -0
  218. package/server-managed/cache-storage.ts +19 -0
  219. package/server-managed/cli.js +21 -0
  220. package/server-managed/combined.ts +46 -0
  221. package/server-managed/comments.ts +151 -0
  222. package/server-managed/config.ts +158 -0
  223. package/server-managed/content-access.ts +15 -0
  224. package/server-managed/crypto.ts +25 -0
  225. package/server-managed/db-methods.ts +1725 -0
  226. package/server-managed/db-neon.ts +367 -0
  227. package/server-managed/db-schema.ts +227 -0
  228. package/server-managed/db-table-names.ts +22 -0
  229. package/server-managed/db.ts +132 -0
  230. package/server-managed/dependency-reviews.ts +248 -0
  231. package/server-managed/github-app.ts +391 -0
  232. package/server-managed/github-errors.ts +68 -0
  233. package/server-managed/github-issue-links.ts +127 -0
  234. package/server-managed/github-issue-oauth.ts +47 -0
  235. package/server-managed/github-issues.ts +152 -0
  236. package/server-managed/github-metadata.ts +213 -0
  237. package/server-managed/github-oauth.ts +235 -0
  238. package/server-managed/github-pulls.ts +227 -0
  239. package/server-managed/http-response.ts +50 -0
  240. package/server-managed/http.ts +3472 -0
  241. package/server-managed/import-triage.ts +51 -0
  242. package/server-managed/index.ts +187 -0
  243. package/server-managed/kept.ts +28 -0
  244. package/server-managed/link-reports.ts +158 -0
  245. package/server-managed/locked-packages.ts +85 -0
  246. package/server-managed/lru.ts +30 -0
  247. package/server-managed/managed-issues.ts +75 -0
  248. package/server-managed/management-catalog.ts +45 -0
  249. package/server-managed/management.ts +99 -0
  250. package/server-managed/npm-insights.ts +440 -0
  251. package/server-managed/npm-kept.ts +65 -0
  252. package/server-managed/npm-loads.ts +167 -0
  253. package/server-managed/npm-manifest.ts +115 -0
  254. package/server-managed/npm-packages.ts +378 -0
  255. package/server-managed/npm-provenance.ts +123 -0
  256. package/server-managed/npm-risk.ts +177 -0
  257. package/server-managed/object-storage-disk.ts +148 -0
  258. package/server-managed/object-storage-vercel.ts +67 -0
  259. package/server-managed/object-storage.ts +74 -0
  260. package/server-managed/package-entry-points.ts +51 -0
  261. package/server-managed/pretty-print.ts +352 -0
  262. package/server-managed/public-workspace.ts +220 -0
  263. package/server-managed/rename-diff-worker.js +26 -0
  264. package/server-managed/rename-diff.ts +270 -0
  265. package/server-managed/repo-path.ts +21 -0
  266. package/server-managed/report-migration.ts +35 -0
  267. package/server-managed/report-query.ts +10 -0
  268. package/server-managed/report-response.ts +16 -0
  269. package/server-managed/report-sources.ts +178 -0
  270. package/server-managed/report-store.ts +47 -0
  271. package/server-managed/repository-aliases.ts +91 -0
  272. package/server-managed/repository-browser.ts +153 -0
  273. package/server-managed/repository-discovery.ts +82 -0
  274. package/server-managed/repository-policy.ts +27 -0
  275. package/server-managed/repository-risk.ts +420 -0
  276. package/server-managed/revisions.ts +35 -0
  277. package/server-managed/session.ts +92 -0
  278. package/server-managed/slugs.ts +38 -0
  279. package/server-managed/solidity-entry-points.ts +69 -0
  280. package/server-managed/sql-postgres.ts +31 -0
  281. package/server-managed/sql.ts +76 -0
  282. package/server-managed/static.ts +28 -0
  283. package/server-managed/storage-command.ts +16 -0
  284. package/server-managed/storage-db.ts +175 -0
  285. package/server-managed/storage-encryption.ts +279 -0
  286. package/server-managed/storage-maintenance.ts +256 -0
  287. package/server-managed/storage-payload.ts +103 -0
  288. package/server-managed/storage-stores.ts +85 -0
  289. package/server-managed/storage.ts +78 -0
  290. package/server-managed/team-app.ts +94 -0
  291. package/server-managed/team-catalog.ts +7 -0
  292. package/server-managed/team-feed.ts +173 -0
  293. package/server-managed/team-npm-scopes.ts +77 -0
  294. package/server-managed/team-reports.ts +228 -0
  295. package/server-managed/triage-response.ts +16 -0
  296. package/server-managed/type-only-worker.js +19 -0
  297. package/server-managed/type-only.ts +103 -0
  298. package/server-managed/uploads.ts +86 -0
  299. package/server-managed/upstream-cache.ts +215 -0
  300. package/server-managed/view-as.ts +58 -0
  301. package/server-managed/workspace-shares.ts +166 -0
  302. package/server.ts +50 -0
  303. package/server/index.ts +0 -477
  304. package/server/lifecycle.ts +0 -204
  305. package/server/sync-handlers.ts +0 -320
  306. /package/{LICENSE → LICENSE-MIT} +0 -0
  307. /package/{server → server-e2e}/config.example.json +0 -0
  308. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  309. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  310. /package/{server → server-e2e}/peer.ts +0 -0
  311. /package/{server → server-e2e}/util.ts +0 -0
  312. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,420 @@
1
+ // Every connected repository's npm packages, as its lockfiles lock them, each
2
+ // assessed by risk (npm-risk.ts), for admins: GET /api/admin/repositories/risk
3
+ // answers a line at a time as each is found and assessed, and stops once its
4
+ // reader leaves (`signal`).
5
+ //
6
+ // A repository's lockfiles are listed at its default branch's head by
7
+ // @preventive/upstream's findRepoLockfiles; of them, those of npm's ecosystem
8
+ // that @preventive/lockfile reads (package-lock.json and npm-shrinkwrap.json
9
+ // of `lockfileVersion: 3`, pnpm-lock.yaml of pnpm 9 to 12, and Yarn 1's
10
+ // yarn.lock) are read for their packages. A package from npm's registry is
11
+ // assessed once a scan, however many lockfiles lock it; one from another
12
+ // host with an integrity, such as a company's mirror of npm's, as npm's
13
+ // package of its name and version where npm's integrity is among it; one
14
+ // from elsewhere (git, a tarball with no integrity, a file) is listed, not
15
+ // assessed. Repositories are read one at a time, as GitHub's limits are the
16
+ // caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
17
+ // makes spare assessments. A lockfile's packages of npm's registry or a
18
+ // mirror are asked of npm's advisories all at once, as `npm audit` asks, each
19
+ // advisory told with whether it is of malware (risk's npmMalwareAdvisory),
20
+ // which alone is a supply chain's risk; the rest are vulnerabilities, told
21
+ // beside.
22
+ import { setTimeout as delay } from 'node:timers/promises'
23
+ import type { Advisory } from '@preventive/upstream/advisories.js'
24
+ import type { LockfileFormat, RepoLockfile } from '@preventive/upstream/github.js'
25
+ import { isExactVersion } from '@preventive/upstream/semver.js'
26
+ import { NpmRiskError, SEVERITIES, type Severity, npmMalwareAdvisory } from '../risk/index.js'
27
+ import { isNpmPackageName, isNpmPackageSpec } from '../common/managed/npm-packages.js'
28
+ import { GithubApiError } from './github-errors.ts'
29
+ import { NpmPackageError, type NpmVersionDocument } from './npm-packages.ts'
30
+ import { type Lru, lru } from './lru.ts'
31
+ import type { KeptStore } from './npm-kept.ts'
32
+ import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
33
+ import { type LockedPackage, lockedPackages } from './locked-packages.ts'
34
+
35
+ // The formats of npm's ecosystem, as findRepoLockfiles names them, and those
36
+ // of them read here.
37
+ const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
38
+ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
39
+
40
+ // An assessment refused as busy (`npm-busy`, by npm-risk.ts or the npm reads
41
+ // beneath it), or that npm asked to ask later (risk's `upstream-busy`, as its
42
+ // downloads API answers past a burst of requests), is asked again after a
43
+ // wait, up to this many times.
44
+ const BUSY_RETRIES = 20
45
+ const BUSY_WAIT_MS = 1000
46
+
47
+ // The most severe of `findings`, or null for none.
48
+ export function highestSeverity(findings: readonly { severity: Severity }[]): Severity | null {
49
+ return SEVERITIES.find(severity => findings.some(finding => finding.severity === severity)) ?? null
50
+ }
51
+
52
+ // A repository to read: where `paths` are given, a team's directories in it,
53
+ // only the lockfiles in them or above them, as a workspace's root lockfile
54
+ // locks them too.
55
+ export interface RepositoryRiskRepo { repoId: number; fullName: string; paths?: readonly string[] }
56
+
57
+ // Whether a lockfile at `path` is of `paths` (RepositoryRiskRepo).
58
+ function lockfileOf(path: string, paths: readonly string[] | undefined) {
59
+ const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
60
+ return !paths || paths.some(scope => dir === '' || dir === scope || dir.startsWith(`${scope}/`) || scope.startsWith(`${dir}/`))
61
+ }
62
+
63
+ type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
64
+
65
+ // A repository opened at its default branch's head (`commit`): its lockfiles,
66
+ // as findRepoLockfiles lists them, each with its blob's id, and a file's
67
+ // text; `recheck` checks access to it again, throwing where it is gone.
68
+ export interface OpenedRepository {
69
+ commit: string
70
+ lockfiles(): Promise<ListedLockfile[]>
71
+ file(path: string): Promise<string>
72
+ recheck(): Promise<void>
73
+ }
74
+
75
+ type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
76
+
77
+ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
78
+ repos(): Promise<R[]>
79
+ open(repo: R): Promise<OpenedRepository>
80
+ // Whether a team lists a package's scope, its own packages.
81
+ teamScoped(name: string): Promise<boolean>
82
+ // npm's document of a version; an NpmPackageError where npm has none.
83
+ version(name: string, version: string, signal: AbortSignal): Promise<NpmVersionDocument>
84
+ assess(doc: NpmVersionDocument, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
85
+ // A version npm has no document of, as risk tells one npm removed: its
86
+ // malware alone, its package taken down by npm; an NpmRiskError where
87
+ // nothing tells of it (npm-risk.ts npmRiskRemoved).
88
+ removed(name: string, version: string, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
89
+ // npm's registry's advisories of these packages' versions, asked at once.
90
+ advisories(packages: { name: string; versions: string[] }[], signal: AbortSignal): Promise<Advisory[]>
91
+ // Where risk lines are kept beyond this instance, as RiskCaches keeps them
92
+ // in it (npm-kept.ts riskLineStore, the database's).
93
+ risks?: KeptStore<RiskLine>
94
+ }
95
+
96
+ type AdvisoryRow = Advisory & { malware: boolean }
97
+
98
+ // Why a step failed, as a line says it: an error's code, and for a lockfile
99
+ // refused, what of it its reader refused.
100
+ type RiskFailure = { error: string; detail?: string }
101
+
102
+ // A risk line is of npm's package of its name and version, or, with an
103
+ // `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
104
+ // of it, out of 100 (assessNpm `score`, risk/npm/score.js).
105
+ export type RepositoryRiskLine =
106
+ | { type: 'repos'; repos: { id: number; fullName: string }[] }
107
+ | ({ type: 'repo'; id: number } & ({ commit: string; lockfiles: { path: string; format: LockfileFormat }[] } | RiskFailure))
108
+ | ({ type: 'lockfile'; repo: number; path: string; format: LockfileFormat } & ({ packages: LockedPackage[] } | RiskFailure))
109
+ | ({ type: 'risk'; name: string; version: string; integrity?: string }
110
+ & ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
111
+ | ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
112
+ | { type: 'done' }
113
+
114
+ // What an error is known by: a GitHub or npm failure's code, or `failed`;
115
+ // GitHub not reached with what failed (githubUnreachable), while `doing` what
116
+ // of a repository, and risk's with what it asked of npm and how that failed,
117
+ // where they say.
118
+ function failureOf(error: unknown, doing?: string): RiskFailure {
119
+ if (error instanceof GithubApiError && error.detail) return { error: error.message, detail: doing ? `${doing}: ${error.detail}` : error.detail }
120
+ if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
121
+ if (!(error instanceof NpmRiskError)) return { error: 'failed' }
122
+ return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
123
+ }
124
+
125
+ const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
126
+
127
+ type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
128
+ type LockfileRead = { packages: LockedPackage[] } | RiskFailure
129
+
130
+ // What scans keep between them, in this instance's memory: a repository's
131
+ // lockfiles as listed at a commit, and a lockfile's packages by its blob,
132
+ // neither of which changes; and a package's risk line and a lockfile's
133
+ // advisories, asked again after RISK_KEPT_MS, as risk tells again what of a
134
+ // package changes in time (a deprecation, its age, its downloads) and npm
135
+ // publishes advisories. Each is told only once access to its repository is
136
+ // checked as when it was read.
137
+ export interface RiskCaches {
138
+ listings: Lru<ListedLockfile[]>
139
+ lockfiles: Lru<LockfileRead>
140
+ risks: Lru<{ at: number; line: RiskLine }>
141
+ advisories: Lru<{ at: number; rows: AdvisoryRow[] }>
142
+ }
143
+ export const riskCaches = (): RiskCaches => ({
144
+ listings: lru(1000),
145
+ // By the packages they hold, a few hundred bytes each.
146
+ lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
147
+ risks: lru(50_000),
148
+ advisories: lru(50_000, kept => kept.rows.length + 1),
149
+ })
150
+ export const RISK_KEPT_MS = 60 * 60_000
151
+ const shared = riskCaches()
152
+
153
+ // Items in the order they are added, each taken once by whichever reader asks
154
+ // first: none once closed and all taken, or once `signal` aborts.
155
+ interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
156
+ function queue<T>(signal: AbortSignal): Queue<T> {
157
+ const items: T[] = []
158
+ const waiting: (() => void)[] = []
159
+ let closed = false
160
+ const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
161
+ signal.addEventListener('abort', wake, { once: true })
162
+ return {
163
+ add(item) { items.push(item); wake() },
164
+ close() { closed = true; wake() },
165
+ async take() {
166
+ for (;;) {
167
+ if (signal.aborted) return undefined
168
+ const item = items.shift()
169
+ if (item !== undefined || closed) return item
170
+ await new Promise<void>(resolve => { waiting.push(resolve) })
171
+ }
172
+ },
173
+ }
174
+ }
175
+
176
+ // A package to assess: npm's of its name and version, or, with an
177
+ // `integrity`, a mirror's held to it.
178
+ interface AssessedPackage { name: string; version: string; integrity?: string }
179
+
180
+ interface Scan {
181
+ source: RepositoryRiskSource<RepositoryRiskRepo>
182
+ caches: RiskCaches
183
+ signal: AbortSignal
184
+ lines: Queue<RepositoryRiskLine>
185
+ // Packages to assess, and those already asked for, by name, version and a
186
+ // mirror's integrity.
187
+ pending: Queue<AssessedPackage>
188
+ seen: Set<string>
189
+ }
190
+
191
+ // A package's risk line, its version's document kept and the assessment asked
192
+ // again after a wait while the server is busy; null once the scan stops. A
193
+ // version npm has no document of is told as risk tells one npm removed
194
+ // (removedOf), its malware keeping whether its package was taken down.
195
+ async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
196
+ const at = { type: 'risk', ...pkg } as const
197
+ let doc: NpmVersionDocument | NpmPackageError | undefined
198
+ for (let attempt = 0; ; attempt++) {
199
+ try {
200
+ const read = doc ??= await documentOf(source, pkg, signal)
201
+ if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
202
+ const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
203
+ const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
204
+ return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
205
+ } catch (error) {
206
+ if (signal.aborted) return null
207
+ if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
208
+ try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
209
+ }
210
+ }
211
+ }
212
+
213
+ // npm's document of a version, or its answer that it has none (a 404).
214
+ async function documentOf(source: Scan['source'], pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
215
+ try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
216
+ if (error instanceof NpmPackageError && error.status === 404) return error
217
+ throw error
218
+ }
219
+ }
220
+
221
+ // A version's assessment; where risk finds npm no longer has it, as a document
222
+ // read from where versions' are kept doesn't tell, as one npm removed.
223
+ async function assessedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
224
+ try { return await source.assess(doc, teamScoped, signal) } catch (error) {
225
+ if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
226
+ return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
227
+ }
228
+ }
229
+
230
+ // A version npm has no document of, as risk tells one npm removed, as it
231
+ // removes every one it finds malware in (`removed`): `gone`, npm's answer of
232
+ // none, thrown where nothing tells of it.
233
+ async function removedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
234
+ try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
235
+ throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
236
+ }
237
+ }
238
+
239
+ // A package's risk line as kept (RiskCaches, else the source's `risks`), else
240
+ // assessed and kept, in both, where it tells what risk found, or that a
241
+ // mirror's copy isn't npm's.
242
+ async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | null> {
243
+ let teamScoped
244
+ try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
245
+ const key = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
246
+ const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
247
+ // One kept before risk told a score (assessNpm `score`) is asked again.
248
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS && ('error' in kept.line || typeof kept.line.score === 'number')) return kept.line
249
+ const line = await assess(scan, pkg, teamScoped)
250
+ if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
251
+ const at = Date.now()
252
+ scan.caches.risks.set(key, { at, line })
253
+ await scan.source.risks?.write(key, line, at)
254
+ }
255
+ return line
256
+ }
257
+
258
+ async function keptLine({ source, caches }: Scan, key: string) {
259
+ const hit = await source.risks?.read(key)
260
+ if (!hit) return null
261
+ caches.risks.set(key, { at: hit.at, line: hit.value })
262
+ return { at: hit.at, line: hit.value }
263
+ }
264
+
265
+ async function assessor(scan: Scan) {
266
+ for (let next = await scan.pending.take(); next !== undefined; next = await scan.pending.take()) {
267
+ const line = await assessed(scan, next)
268
+ if (line) scan.lines.add(line)
269
+ }
270
+ }
271
+
272
+ type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
273
+
274
+ // A lockfile's line, its packages or why they couldn't be had: as kept by its
275
+ // blob, else read from GitHub and kept.
276
+ async function readLockfile({ caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
277
+ const at = { type: 'lockfile', repo, path, format } as const
278
+ if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
279
+ const key = `${format}\0${sha}`
280
+ let read = caches.lockfiles.get(key)
281
+ if (!read) {
282
+ let text
283
+ try { text = await opened.file(path) } catch (error) { return { ...at, ...failureOf(error) } }
284
+ try { read = { packages: lockedPackages(format, text) } } catch (error) {
285
+ read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
286
+ }
287
+ caches.lockfiles.set(key, read)
288
+ }
289
+ return { ...at, ...read }
290
+ }
291
+
292
+ // A lockfile's packages of npm's registry or a mirror not yet seen this scan,
293
+ // given to the assessors.
294
+ function assessLater(scan: Scan, packages: LockedPackage[]) {
295
+ for (const { name, version, source, integrity } of packages) {
296
+ const key = `${name}@${version}\0${integrity ?? ''}`
297
+ if ((source !== 'registry' && source !== 'mirror') || version === null || scan.seen.has(key) || !isNpmPackageName(name) || !isNpmPackageSpec(version)) continue
298
+ scan.seen.add(key)
299
+ scan.pending.add({ name, version, ...integrity === undefined ? {} : { integrity } })
300
+ }
301
+ }
302
+
303
+ // A repository opened and its lockfiles of npm's ecosystem listed, as kept
304
+ // for its commit or else from GitHub, those of its `paths` alone; null, with
305
+ // its line telling why, where either failed, or where the scan stopped.
306
+ async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
307
+ let doing = 'opening it'
308
+ try {
309
+ const opened = await scan.source.open(repo)
310
+ if (scan.signal.aborted) return null
311
+ const key = `${repo.repoId}@${opened.commit}`
312
+ let listed = scan.caches.listings.get(key)
313
+ doing = 'listing its lockfiles'
314
+ if (!listed) scan.caches.listings.set(key, listed = await opened.lockfiles())
315
+ const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
316
+ return { opened, lockfiles }
317
+ } catch (error) {
318
+ if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, doing) })
319
+ return null
320
+ }
321
+ }
322
+
323
+ type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
324
+
325
+ // A lockfile's advisories line: npm's registry's advisories of its packages
326
+ // of the registry or a mirror, by its blob as kept, else asked at once and
327
+ // kept; null where it locks none, or once the scan stops.
328
+ async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
329
+ const at = { type: 'advisories', repo, path } as const
330
+ const key = `${format}\0${sha}`
331
+ const kept = scan.caches.advisories.get(key)
332
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) return { ...at, advisories: kept.rows }
333
+ const asked = new Map<string, Set<string>>()
334
+ for (const { name, version, source } of packages) {
335
+ if ((source !== 'registry' && source !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
336
+ if (!asked.has(name)) asked.set(name, new Set())
337
+ asked.get(name)!.add(version)
338
+ }
339
+ if (asked.size === 0) return null
340
+ try {
341
+ const rows = (await scan.source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), scan.signal))
342
+ .map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
343
+ scan.caches.advisories.set(key, { at: Date.now(), rows })
344
+ return { ...at, advisories: rows }
345
+ } catch (error) {
346
+ return scan.signal.aborted ? null : { ...at, ...failureOf(error) }
347
+ }
348
+ }
349
+
350
+ // A repository's line, its lockfiles or why they couldn't be listed, and
351
+ // then its lockfiles', and their advisories: told once they are all read
352
+ // and access to the repository is checked again, as GitHub may have
353
+ // withdrawn it meanwhile.
354
+ async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
355
+ const listed = await listLockfiles(scan, repo)
356
+ if (!listed) return
357
+ const lockfiles: LockfileLine[] = []
358
+ for (const lockfile of listed.lockfiles) {
359
+ if (scan.signal.aborted) return
360
+ lockfiles.push(await readLockfile(scan, repo.repoId, listed.opened, lockfile))
361
+ }
362
+ try { await listed.opened.recheck() } catch (error) {
363
+ if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
364
+ return
365
+ }
366
+ if (scan.signal.aborted) return
367
+ scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: listed.lockfiles.map(({ path, format }) => ({ path, format })) })
368
+ for (const line of lockfiles) {
369
+ scan.lines.add(line)
370
+ if ('packages' in line) assessLater(scan, line.packages)
371
+ }
372
+ for (const [at, line] of lockfiles.entries()) {
373
+ if (scan.signal.aborted) return
374
+ const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
375
+ if (advisories) scan.lines.add(advisories)
376
+ }
377
+ }
378
+
379
+ // Each repository, a repository at a time.
380
+ async function readRepositories(scan: Scan) {
381
+ const repos = await scan.source.repos()
382
+ scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
383
+ for (const repo of repos) {
384
+ if (scan.signal.aborted) return
385
+ await readRepository(scan, repo)
386
+ }
387
+ }
388
+
389
+ // The scan's lines, in the order they are found: the repositories first,
390
+ // then each repository's lockfiles and their packages once read and access
391
+ // is checked again, and each package's risk once assessed, while the
392
+ // repositories are still being read; `done` last. Ends at once, the work
393
+ // under way given up on, where `signal` aborts or the lines are no longer read. A failure of
394
+ // a repository, a lockfile or a package is told in its line; one of the scan
395
+ // itself is thrown, the lines before it told. What it reads is kept in
396
+ // `caches` (RiskCaches), the instance's own unless given.
397
+ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared): AsyncGenerator<RepositoryRiskLine> {
398
+ const stopped = new AbortController()
399
+ const running = AbortSignal.any([signal, stopped.signal])
400
+ const scan: Scan = { source, caches, signal: running, lines: queue(signal), pending: queue(running), seen: new Set() }
401
+ const failed: unknown[] = []
402
+ void (async () => {
403
+ const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan))
404
+ try {
405
+ try { await readRepositories(scan) } finally { scan.pending.close() }
406
+ await Promise.all(assessors)
407
+ if (!running.aborted) scan.lines.add({ type: 'done' })
408
+ } catch (error) {
409
+ failed.push(error)
410
+ stopped.abort()
411
+ } finally { scan.lines.close() }
412
+ })()
413
+ try {
414
+ for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) yield line
415
+ if (failed.length > 0 && !signal.aborted) throw failed[0]
416
+ } finally {
417
+ // A GitHub request under way isn't waited on: it settles on its own.
418
+ stopped.abort()
419
+ }
420
+ }
@@ -0,0 +1,35 @@
1
+ // Internal invalidation counters. Never expose these global values to clients:
2
+ // feeds still publish only the existing user/visibility-scoped revisions.
3
+ const catalogTables = ['managed_link_report', 'managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
4
+ const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment', 'managed_finding_issue']
5
+ export function revisionSchema(postgres = false): string {
6
+ let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
7
+ id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
8
+ ); CREATE TABLE IF NOT EXISTS managed_change_revision (
9
+ id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
10
+ ); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
11
+ for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
12
+ const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
13
+ if (postgres) {
14
+ sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
15
+ BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
16
+ }
17
+ for (const table of tables) {
18
+ if (postgres) {
19
+ sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
20
+ for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
21
+ sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
22
+ CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
23
+ REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
24
+ FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
25
+ }
26
+ } else {
27
+ for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
28
+ sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
29
+ AFTER ${event} ON ${table} BEGIN ${update} END;`
30
+ }
31
+ }
32
+ }
33
+ }
34
+ return sql
35
+ }
@@ -0,0 +1,92 @@
1
+ // Session + cookie plumbing for the managed auth server. The raw session
2
+ // token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
3
+ // so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
4
+ // minted per session and returned to the client (echoed back on mutations).
5
+ //
6
+ // Async throughout — the store (db.ts) is async so a future non-SQLite
7
+ // backend slots in without touching these helpers.
8
+ import type { ManagedConfig } from './config.ts'
9
+ import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
10
+ import { hashToken, randomToken } from './crypto.ts'
11
+
12
+ // Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
13
+ // `cookieName` when cookies are Secure.
14
+ export const STATE_COOKIE = 'dvstate'
15
+
16
+ // Base names for GitHub issue authorization state (github-issue-oauth.ts) and
17
+ // an administrator's read-only view as another user (view-as.ts).
18
+ export const ISSUE_STATE_COOKIE = 'dvissuestate'
19
+ export const VIEW_COOKIE = 'dvview'
20
+ // The session cookie cannot share a name with these, or one would overwrite
21
+ // or be read as the other.
22
+ export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, VIEW_COOKIE]
23
+
24
+ // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
25
+ // Secure + Path=/ + no Domain, which `buildCookie` already emits).
26
+ export function cookieName(config: ManagedConfig, base: string): string {
27
+ return config.cookieSecure ? `__Host-${base}` : base
28
+ }
29
+
30
+ export function buildCookie(
31
+ name: string, value: string,
32
+ opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
33
+ ): string {
34
+ const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
35
+ if (opts.secure) parts.push('Secure')
36
+ return parts.join('; ')
37
+ }
38
+
39
+ // Expire a cookie now (Max-Age=0).
40
+ export function clearCookie(name: string, secure: boolean): string {
41
+ return buildCookie(name, '', { maxAgeS: 0, secure })
42
+ }
43
+
44
+ export function parseCookies(header: string | undefined): Map<string, string> {
45
+ const out = new Map<string, string>()
46
+ if (!header) return out
47
+ for (const part of header.split(';')) {
48
+ const eq = part.indexOf('=')
49
+ if (eq < 0) continue
50
+ const k = part.slice(0, eq).trim()
51
+ if (k) out.set(k, part.slice(eq + 1).trim())
52
+ }
53
+ return out
54
+ }
55
+
56
+ // Mint a session for a freshly-authenticated user. Upserts the identity, then
57
+ // stores the session keyed by the cookie token's hash. Returns the cookie to
58
+ // set and the CSRF token (also persisted on the row).
59
+ export async function createSession(
60
+ config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
61
+ ): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
62
+ const token = randomToken()
63
+ const csrfToken = randomToken()
64
+ const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
65
+ await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
66
+ const setCookie = buildCookie(config.sessionCookieName, token, {
67
+ maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
68
+ })
69
+ return { setCookie, csrfToken, userId }
70
+ }
71
+
72
+ // Resolve the current session (+ user) from the request's cookies, or null.
73
+ // A view cookie resolves only to that admin's view as another user: a view
74
+ // that has ended never falls back to the admin's own session. `own` skips the
75
+ // view and reads the signed-in session itself.
76
+ export function readSession(
77
+ config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
78
+ ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
79
+ const cookies = parseCookies(cookieHeader)
80
+ const token = cookies.get(config.sessionCookieName)
81
+ if (token == null || token === '') return Promise.resolve(null)
82
+ const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
83
+ if (view != null && view !== '') return db.viewSessionWithUser(hashToken(view), hashToken(token), now)
84
+ return db.sessionWithUser(hashToken(token), now)
85
+ }
86
+
87
+ // Drop the session named by the request's cookie (logout). The caller also
88
+ // clears the cookie client-side.
89
+ export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
90
+ const token = parseCookies(cookieHeader).get(config.sessionCookieName)
91
+ if (token != null && token !== '') await db.deleteSession(hashToken(token))
92
+ }
@@ -0,0 +1,38 @@
1
+ import type { DatabaseSync } from 'node:sqlite'
2
+
3
+ // Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
4
+ // component is proposed as a slug, never an arbitrary prefix or abbreviation.
5
+ export function preferredSlug(id: string): string {
6
+ return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
7
+ }
8
+
9
+ export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
10
+ // Reserve legacy IDs before UUID suffixes can claim them.
11
+ const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
12
+ const allocated = []
13
+ for (const row of rows) {
14
+ if (row.slug != null) continue
15
+ const candidate = preferredSlug(row.id)
16
+ const slug = used.has(candidate) ? row.id : candidate
17
+ allocated.push({ id: row.id, slug })
18
+ used.add(slug)
19
+ }
20
+ return allocated
21
+ }
22
+
23
+ // Allocate once, across the entire namespace rather than a viewer's teams.
24
+ // Keep existing slugs stable when names, memberships, or collisions change.
25
+ export function migrateSlugs(db: DatabaseSync): void {
26
+ db.exec('BEGIN IMMEDIATE')
27
+ try {
28
+ for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
29
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
30
+ if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
31
+ const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
32
+ const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
33
+ for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
34
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
35
+ }
36
+ db.exec('COMMIT')
37
+ } catch (err) { db.exec('ROLLBACK'); throw err }
38
+ }
@@ -0,0 +1,69 @@
1
+ import { normalizeTeamPath } from './repo-path.ts'
2
+
3
+ const MAX_SUGGESTIONS = 100
4
+ const EXCLUDED_DIRECTORIES = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'node_modules', 'vendor', 'out', 'artifacts', 'cache', 'build', 'dist', 'broadcast'])
5
+
6
+ function sourcePath(value: unknown): value is string {
7
+ if (typeof value !== 'string' || !value.endsWith('.sol') || /\.(?:t|s|test|spec)\.sol$/iu.test(value)) return false
8
+ const normalized = normalizeTeamPath(value)
9
+ const directories = value.split('/').slice(0, -1).map(part => part.toLowerCase())
10
+ const sourceRoot = directories.findIndex(part => part === 'src' || part === 'contracts')
11
+ return normalized.ok && normalized.path === value && !directories.some((part, index) =>
12
+ EXCLUDED_DIRECTORIES.has(part) || (part === 'lib' && (sourceRoot < 0 || index < sourceRoot)))
13
+ }
14
+
15
+ type ListingEntry = { name?: unknown; path?: unknown; type?: unknown; sha?: unknown; submodule_git_url?: unknown }
16
+
17
+ // Maintain the alphabetically first 100 unique candidates even when GitHub's
18
+ // input is unordered. Return whether a unique candidate exceeds the capacity.
19
+ export function addSoliditySuggestion(paths: string[], path: string): boolean {
20
+ let start = 0
21
+ let end = paths.length
22
+ while (start < end) {
23
+ const middle = Math.floor((start + end) / 2)
24
+ if (paths[middle]! < path) start = middle + 1
25
+ else end = middle
26
+ }
27
+ if (paths[start] === path) return false
28
+ const full = paths.length === MAX_SUGGESTIONS
29
+ if (start < MAX_SUGGESTIONS) {
30
+ // Evict before inserting, so retained candidates never exceed the cap.
31
+ if (full) paths.pop()
32
+ paths.splice(start, 0, path)
33
+ }
34
+ return full
35
+ }
36
+
37
+ // Suggestions from filenames, not deployability or import analysis. Read at
38
+ // most two source trees, using SHAs from the authorized pinned directory.
39
+ export async function readSolidityEntryPoints(directory: string, listing: unknown[], read: (suffix: string) => Promise<unknown>) {
40
+ const prefix = directory ? directory + '/' : ''
41
+ const entries = listing.filter((entry): entry is ListingEntry => !!entry && typeof entry === 'object').filter(entry =>
42
+ typeof entry.name === 'string' && !entry.name.includes('/') && entry.path === prefix + entry.name && !entry.submodule_git_url)
43
+ const hasFramework = entries.some(entry => entry.type === 'file' && typeof entry.name === 'string'
44
+ && (entry.name === 'foundry.toml' || /^hardhat\.config\.(?:[cm]?js|ts)$/u.test(entry.name)))
45
+ const roots = entries.filter(entry => entry.type === 'dir' && (entry.name === 'contracts' || (hasFramework && entry.name === 'src'))
46
+ && typeof entry.sha === 'string' && /^[a-f\d]{40}$/iu.test(entry.sha)).slice(0, 2)
47
+ const paths: string[] = []
48
+ let limited = false
49
+ for (const entry of entries) {
50
+ if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
51
+ }
52
+ for (const root of roots) {
53
+ if (!sourcePath(`${root.path}/Contract.sol`)) continue
54
+ try {
55
+ const data = await read(`/git/trees/${root.sha}?recursive=1`) as { tree?: unknown; truncated?: unknown }
56
+ if (!Array.isArray(data?.tree)) continue
57
+ limited ||= data.truncated === true
58
+ for (const entry of data.tree) {
59
+ if (entry?.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || typeof entry.path !== 'string') continue
60
+ const path = `${root.path}/${entry.path}`
61
+ if (sourcePath(path) && addSoliditySuggestion(paths, path)) limited = true
62
+ }
63
+ } catch {
64
+ // Optional suggestions must not block browsing. The request handler
65
+ // rechecks GitHub access and local path grants after all reads.
66
+ }
67
+ }
68
+ return { paths, limited }
69
+ }