@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +83 -0
  41. package/out/client-managed.js +169 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +103 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +63 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,139 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { SelectedRepo } from './db.ts'
3
+ import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
+ import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
+ import { readSolidityEntryPoints } from './solidity-entry-points.ts'
6
+
7
+ export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
8
+
9
+ // Restricted managers can walk the ancestors of their grants, without seeing
10
+ // sibling names or files outside those grants. null means the entire repo.
11
+ export function scopedDirectory(path: string, scopes: (string | null)[]): RepositoryEntry[] | null {
12
+ if (scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))) return null
13
+ const prefix = path ? path + '/' : ''
14
+ const names = new Set(scopes.filter((scope): scope is string => !!scope && scope.startsWith(prefix)).map(scope => scope.slice(prefix.length).split('/')[0]!))
15
+ return [...names].toSorted().map(name => ({ name, path: prefix + name, type: 'dir' }))
16
+ }
17
+
18
+ export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
19
+ type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
20
+
21
+ function credentialFailure(err: unknown): err is GithubApiError {
22
+ return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
23
+ }
24
+
25
+ // Create once per HTTP request. Coalesce concurrent credential lookups without
26
+ // caching repository visibility or permissions, or sharing credentials across users.
27
+ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
28
+ const tokens = new Map<number, Promise<string | null>>()
29
+ let identity: Promise<GithubIdentity> | undefined
30
+ const getIdentity = (fresh = false): Promise<GithubIdentity> => {
31
+ if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
32
+ if (!identity || fresh) {
33
+ identity = githubUserIdentity(user.token, fetchImpl).then(value => {
34
+ if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
35
+ return value
36
+ })
37
+ }
38
+ return identity
39
+ }
40
+ return {
41
+ reader(repo: SelectedRepo) {
42
+ const installationToken = () => {
43
+ if (repo.installationId == null) return Promise.resolve(null)
44
+ const cached = tokens.get(repo.installationId)
45
+ const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
46
+ if (!credentialFailure(err)) throw err
47
+ // Stale installations cannot prevent verified public access.
48
+ return null
49
+ })
50
+ if (!cached) tokens.set(repo.installationId, token)
51
+ return token
52
+ }
53
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
54
+ },
55
+ }
56
+ }
57
+
58
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
59
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
60
+ // Public reads can use the signed-in user's quota without repository grants.
61
+ // Nonpublic reads still require the repository App and the effective-user gate.
62
+ let token = userToken ?? await installationToken()
63
+ const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
64
+ const metadata = async () => {
65
+ try { return await read('') }
66
+ catch (err) {
67
+ if (!token || !credentialFailure(err)) throw err
68
+ const fallback = await installationToken()
69
+ if (fallback && fallback !== token) {
70
+ token = fallback
71
+ try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
72
+ }
73
+ // Anonymous fallback is only for unavailable credentials, never rate
74
+ // limits, network errors, or GitHub outages.
75
+ token = null
76
+ return read('')
77
+ }
78
+ }
79
+ const checkAccess = async (freshIdentity = false) => {
80
+ // Stored visibility can be stale (or internal with private=false). Require
81
+ // current, explicit public visibility or the caller's effective permission.
82
+ const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
83
+ if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
84
+ throw new GithubApiError(404, 'no-repository')
85
+ }
86
+ if (data.private === false && data.visibility === 'public') return data
87
+ if (!token) throw new GithubApiError(404, 'no-repository')
88
+ const appToken = await installationToken()
89
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
90
+ if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
91
+ throw new GithubApiError(404, 'no-repository')
92
+ }
93
+ token = appToken
94
+ return data
95
+ }
96
+ const current = await checkAccess()
97
+ const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
98
+ return {
99
+ recheckAccess: () => checkAccess(true),
100
+ // Server-only credential, selected by the same live visibility/permission
101
+ // gate as directory browsing. Never included in an HTTP response.
102
+ readToken: () => token,
103
+ async refs() {
104
+ // Suggestions are bounded; the input also accepts any branch or tag name.
105
+ const results = await Promise.all(['branches', 'tags'].map(async kind => {
106
+ const data = await read(`/${kind}?per_page=100`)
107
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
108
+ return data.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
109
+ }))
110
+ return { defaultBranch, branches: results[0], tags: results[1] }
111
+ },
112
+ async commit(ref: string) {
113
+ if (/^[a-f\d]{40}$/iu.test(ref)) return ref
114
+ const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
115
+ if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
116
+ return data.sha
117
+ },
118
+ async directory(path: string, commit: string) {
119
+ const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
120
+ if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
121
+ const entries: RepositoryEntry[] = []
122
+ for (const item of data) {
123
+ if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
124
+ const expectedPath = path ? `${path}/${item.name}` : item.name
125
+ if (item.path !== expectedPath) continue
126
+ const type = item.submodule_git_url ? 'submodule' : item.type
127
+ if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
128
+ }
129
+ const manifestPath = path ? `${path}/package.json` : 'package.json'
130
+ const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
131
+ && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
132
+ const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
133
+ const solidity = await readSolidityEntryPoints(path, data, read)
134
+ return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}),
135
+ ...(solidity.paths.length > 0 || solidity.limited ? { solidityEntryPoints: solidity.paths, soliditySuggestionsLimited: solidity.limited } : {}),
136
+ }
137
+ },
138
+ }
139
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import { GithubApiError, filterInstalledRepos, listInstalledRepos, listUserRepos } from './github-app.ts'
4
+ import type { ConnectedRepo, RepoListing } from './github-app.ts'
5
+
6
+ const CACHE_MS = 60_000
7
+ const MAX_ENTRIES = 100
8
+ interface Entry {
9
+ expires: number
10
+ pending: boolean
11
+ value: Promise<ConnectedRepo[]>
12
+ }
13
+
14
+ // One directory per HTTP handler. Searches/pages reuse discovery, but selected
15
+ // flags still come from the database on every request. User results are scoped
16
+ // to both the managed identity and its current GitHub credential. This cache is
17
+ // only for discovery; connecting a repo still verifies fresh installation access.
18
+ export class RepositoryDiscovery {
19
+ private readonly cache = new Map<string, Entry>()
20
+ private readonly config: ManagedConfig
21
+ private readonly fetchImpl: typeof fetch
22
+ private readonly now: () => number
23
+
24
+ constructor(config: ManagedConfig, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args), now: () => number = Date.now) {
25
+ this.config = config
26
+ this.fetchImpl = fetchImpl
27
+ this.now = now
28
+ }
29
+
30
+ private read(key: string, load: () => Promise<ConnectedRepo[]>): Promise<ConnectedRepo[]> {
31
+ const cached = this.cache.get(key)
32
+ if (cached && (cached.pending || cached.expires > this.now())) return cached.value
33
+ this.cache.delete(key)
34
+ while (this.cache.size >= MAX_ENTRIES) this.cache.delete(this.cache.keys().next().value!)
35
+ const entry: Entry = { expires: 0, pending: true, value: Promise.resolve([]) }
36
+ entry.value = load().then(repos => {
37
+ entry.expires = this.now() + CACHE_MS
38
+ entry.pending = false
39
+ return repos
40
+ }, (err: unknown) => {
41
+ if (this.cache.get(key) === entry) this.cache.delete(key)
42
+ throw err
43
+ })
44
+ this.cache.set(key, entry)
45
+ return entry.value
46
+ }
47
+
48
+ async list(scope: 'installed' | 'public', userId: string, token: string | null, showAll = false, refresh = false): Promise<RepoListing> {
49
+ // Invalidate derived user results along with the installation catalogue.
50
+ // An older in-flight read cannot repopulate this map after refresh.
51
+ if (refresh) this.cache.clear()
52
+ const installed = () => this.read('installed', () => listInstalledRepos(this.config, this.fetchImpl))
53
+ if (scope === 'installed' && showAll) return { repositories: await installed(), tokenMissing: false }
54
+ const publicInstalled = async (): Promise<ConnectedRepo[]> => (await installed()).filter(repo => repo.visibility === 'public')
55
+ if (token == null) return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
56
+ const credential = createHash('sha256').update(token).digest('hex')
57
+ const key = JSON.stringify([scope, userId, credential])
58
+ try {
59
+ if (scope === 'installed') {
60
+ const repositories = await this.read(key, async () => filterInstalledRepos(this.config, await installed(), token, this.fetchImpl))
61
+ return { repositories, tokenMissing: false }
62
+ }
63
+ // Installations only deduplicate the public picker. Their failure must
64
+ // not block otherwise readable public repos or look like a stale login.
65
+ // Cache the two sources separately so failed installation discovery is
66
+ // retried without caching a degraded combined list for another minute.
67
+ const [userRepos, installedRepos] = await Promise.all([
68
+ this.read(key, () => listUserRepos(token)),
69
+ installed().catch((err: unknown) => {
70
+ console.warn('managed: installed repo deduplication failed:', err)
71
+ return []
72
+ }),
73
+ ])
74
+ const installedIds = new Set(installedRepos.map(repo => repo.id))
75
+ const repositories = userRepos.filter(repo => repo.visibility === 'public' && !installedIds.has(repo.id))
76
+ return { repositories, tokenMissing: false }
77
+ } catch (err) {
78
+ if (!(err instanceof GithubApiError) || err.status !== 401) throw err
79
+ return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,27 @@
1
+ import type { StoredUser } from './db.ts'
2
+
3
+ const WHITEHAT = [
4
+ 291301, // ChALkeR
5
+ 259122746, // exo-nikita
6
+ 247161625, // ex0sec
7
+ ]
8
+ const whitehatIds = new Set(WHITEHAT)
9
+
10
+ // (1) Server permission to add repositories. Keep this independent of GitHub
11
+ // identity and ecosystem exceptions if other install modes broaden it later.
12
+ export function canAddRepositories(user: StoredUser): boolean {
13
+ return user.role === 'admin'
14
+ }
15
+
16
+ // (3b) Only the ecosystem safeguard for public repositories is bypassed by
17
+ // WHITEHAT. Callers must separately establish (0) server readability, (1) add
18
+ // permission, and (2) server admin or GitHub access to this repository.
19
+ export function passesPublicRepositorySafeguard(githubUserId: number | null, involved: boolean): boolean {
20
+ return involved || (githubUserId != null && whitehatIds.has(githubUserId))
21
+ }
22
+
23
+ // Arbitrary public repositories have no involvement evidence, so (2) requires
24
+ // admin even if (1) expands in the future. This capability is also the UI gate.
25
+ export function canAddAnyPublicRepository(user: StoredUser, githubUserId: number | null): boolean {
26
+ return canAddRepositories(user) && user.role === 'admin' && passesPublicRepositorySafeguard(githubUserId, false)
27
+ }
@@ -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_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']
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,78 @@
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
+ // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
17
+ // Secure + Path=/ + no Domain, which `buildCookie` already emits).
18
+ export function cookieName(config: ManagedConfig, base: string): string {
19
+ return config.cookieSecure ? `__Host-${base}` : base
20
+ }
21
+
22
+ export function buildCookie(
23
+ name: string, value: string,
24
+ opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
25
+ ): string {
26
+ const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
27
+ if (opts.secure) parts.push('Secure')
28
+ return parts.join('; ')
29
+ }
30
+
31
+ // Expire a cookie now (Max-Age=0).
32
+ export function clearCookie(name: string, secure: boolean): string {
33
+ return buildCookie(name, '', { maxAgeS: 0, secure })
34
+ }
35
+
36
+ export function parseCookies(header: string | undefined): Map<string, string> {
37
+ const out = new Map<string, string>()
38
+ if (!header) return out
39
+ for (const part of header.split(';')) {
40
+ const eq = part.indexOf('=')
41
+ if (eq < 0) continue
42
+ const k = part.slice(0, eq).trim()
43
+ if (k) out.set(k, part.slice(eq + 1).trim())
44
+ }
45
+ return out
46
+ }
47
+
48
+ // Mint a session for a freshly-authenticated user. Upserts the identity, then
49
+ // stores the session keyed by the cookie token's hash. Returns the cookie to
50
+ // set and the CSRF token (also persisted on the row).
51
+ export async function createSession(
52
+ config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
53
+ ): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
54
+ const token = randomToken()
55
+ const csrfToken = randomToken()
56
+ const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
57
+ await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
58
+ const setCookie = buildCookie(config.sessionCookieName, token, {
59
+ maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
60
+ })
61
+ return { setCookie, csrfToken, userId }
62
+ }
63
+
64
+ // Resolve the current session (+ user) from the request's cookies, or null.
65
+ export function readSession(
66
+ config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number,
67
+ ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
68
+ const token = parseCookies(cookieHeader).get(config.sessionCookieName)
69
+ if (token == null || token === '') return Promise.resolve(null)
70
+ return db.sessionWithUser(hashToken(token), now)
71
+ }
72
+
73
+ // Drop the session named by the request's cookie (logout). The caller also
74
+ // clears the cookie client-side.
75
+ export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
76
+ const token = parseCookies(cookieHeader).get(config.sessionCookieName)
77
+ if (token != null && token !== '') await db.deleteSession(hashToken(token))
78
+ }
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ // The small SQL dialect used by the shared store. Parameter values never pass
2
+ // through these rewrites; only our own SQL does. Keep aliases case-sensitive.
3
+ export function postgresSql(source: string): { sql: string; names: string[] } {
4
+ const names: string[] = []
5
+ let position = 0
6
+ let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
7
+ .replaceAll("CAST(unixepoch('subsec') * 1000 AS INTEGER)", 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)')
8
+ .replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
9
+ .replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
10
+ .replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
11
+ .replaceAll('CROSS JOIN', 'JOIN')
12
+ .replaceAll(/\binstr\(/gu, 'strpos(')
13
+ // Quoted literals (including ':') are never placeholders or identifiers.
14
+ sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
15
+ .replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
16
+ if (!name) return `$${++position}`
17
+ let index = names.indexOf(name)
18
+ if (index < 0) { index = names.length; names.push(name) }
19
+ return `$${index + 1}`
20
+ })
21
+ .replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
22
+ ).join('')
23
+ sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
24
+ if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
25
+ return { sql, names }
26
+ }
27
+
28
+ export function postgresSchema(sql: string): string {
29
+ return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
30
+ .replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
31
+ }
@@ -0,0 +1,72 @@
1
+ // Each public store operation owns a transaction. SQLite serializes access to
2
+ // its single connection across awaits; Neon can reuse a request-owned connection.
3
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
4
+ import type { ManagedDb } from './db-methods.ts'
5
+
6
+ type MaybePromise<T> = T | Promise<T>
7
+ export interface ManagedSql {
8
+ prepare(sql: string): {
9
+ get(...params: unknown[]): MaybePromise<unknown>
10
+ all(...params: unknown[]): MaybePromise<unknown[]>
11
+ run(...params: unknown[]): MaybePromise<{ changes: number | bigint }>
12
+ }
13
+ close(): MaybePromise<void>
14
+ }
15
+ export interface ManagedSqlDriver extends ManagedSql {
16
+ scope<T>(write: boolean, work: () => Promise<T>, options?: { lock?: boolean; statement?: boolean }): Promise<T>
17
+ }
18
+
19
+ // These operations execute exactly one SQL statement. Postgres already gives
20
+ // that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
21
+ // Lease writes coordinate through their conditional UPSERT/UPDATE alone.
22
+ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
23
+ 'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
+ 'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease'])
25
+
26
+ export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
27
+ const entries = Object.entries(methods).map(([name, method]) => {
28
+ if (name === 'close') return [name, async () => { await driver.close() }]
29
+ const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
30
+ return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
31
+ // Presence writes only one user's timestamp; they need no application
32
+ // writer lock. Keep the session/user read and timestamp in one transaction.
33
+ lock: write && name !== 'sessionWithUser', statement: SINGLE_STATEMENTS.has(name),
34
+ })]
35
+ })
36
+ return Object.fromEntries(entries) as ManagedDb
37
+ }
38
+
39
+ export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
40
+ let queue = Promise.resolve()
41
+ let closed = false
42
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
43
+ const result = queue.then(work)
44
+ queue = result.then(() => undefined, () => undefined)
45
+ return result
46
+ }
47
+ return {
48
+ prepare(sql) {
49
+ const stmt = db.prepare(sql)
50
+ return {
51
+ get: (...args) => stmt.get(...args as SQLInputValue[]),
52
+ all: (...args) => stmt.all(...args as SQLInputValue[]),
53
+ run: (...args) => stmt.run(...args as SQLInputValue[]),
54
+ }
55
+ },
56
+ scope(write, work) {
57
+ if (closed) return Promise.reject(new Error('Managed database is closed'))
58
+ return enqueue(async () => {
59
+ db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
60
+ try {
61
+ const result = await work()
62
+ db.exec('COMMIT')
63
+ return result
64
+ } catch (err) {
65
+ db.exec('ROLLBACK')
66
+ throw err
67
+ }
68
+ })
69
+ },
70
+ async close() { closed = true; await queue; db.close() },
71
+ }
72
+ }
@@ -0,0 +1,28 @@
1
+ import { type StaticHandler, loadStatic } from '../server-e2e/static.ts'
2
+ import { parseManagedRoute } from '../common/managed/routes.js'
3
+
4
+ // A deep page URL must still resolve every asset at the app root. Preload
5
+ // headers resolve against the request URL, so their hrefs must be absolute too.
6
+ export function managedPageHtml(html: string): string {
7
+ return html.replaceAll(/(href|src)="\.\//gu, '$1="/')
8
+ }
9
+
10
+ export function loadManagedStatic(staticDir: string, { indexOnly = false, scanServer = null }: { indexOnly?: boolean, scanServer?: string | null } = {}): StaticHandler {
11
+ const serve = loadStatic(staticDir, scanServer, { transformIndex: managedPageHtml, indexOnly })
12
+ return (req, res) => {
13
+ if (!req.url?.startsWith('/') || req.url.startsWith('//')) return false
14
+ const url = new URL(req.url, 'http://localhost')
15
+ let path
16
+ try { path = decodeURIComponent(url.pathname) } catch { return false }
17
+ // No API request, including an unknown API route, may receive app HTML.
18
+ if (path === '/api' || path.startsWith('/api/')) return false
19
+ if (serve(req, res)) return true
20
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
21
+ // Finding IDs can contain dots (including URL-shaped IDs). Recognised
22
+ // pages still receive HTML; missing assets retain a 404.
23
+ if (path.split('/').at(-1)?.includes('.') && !parseManagedRoute(url)) return false
24
+ const original = req.url
25
+ req.url = '/'
26
+ try { return serve(req, res) } finally { req.url = original }
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { loadManagedConfig } from './config.ts'
2
+ import { openManagedStorageDb } from './storage.ts'
3
+
4
+ // Require the deployment mode: DB_PATH belongs to e2e in combined deployments.
5
+ // Read the database requirement without opening bytes or enabling encryption.
6
+ export async function storageCommand(mode: string | undefined): Promise<void> {
7
+ if (mode !== 'managed' && mode !== 'managed-e2e' && mode !== 'e2e-managed') throw new Error('Storage status requires a deployment mode: --storage-encryption-status managed|managed-e2e|e2e-managed')
8
+ const db = await openManagedStorageDb(loadManagedConfig({ combined: mode !== 'managed' }))
9
+ try {
10
+ const status = await db.getStorageEncryption()
11
+ console.log(JSON.stringify(status ? {
12
+ encryption: 'chacha20-poly1305', complete: status.complete === 1, cleanupComplete: status.cleanupComplete === 1,
13
+ migrated: status.migrated, cursor: status.cursor,
14
+ } : { encryption: 'disabled' }))
15
+ } finally { await db.close() }
16
+ }