@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1336 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +27 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedDb } from './db-methods.ts'
3
+ import type { ManagedComment } from '../common/managed/comments.ts'
4
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
5
+ import { triageWireEntry } from './triage-response.ts'
6
+
7
+ export interface ImportTriageSnapshot {
8
+ entry: TriageEntryPatch | null
9
+ comments: ManagedComment[]
10
+ version: string
11
+ }
12
+ export interface ImportTriageStore {
13
+ getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
14
+ importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
15
+ }
16
+
17
+ // Raw store methods share one transaction for comparison, triage, and comments.
18
+ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
19
+ async function snapshot(ids: readonly string[]) {
20
+ const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
21
+ const comments = await store.listComments(ids)
22
+ return Object.fromEntries(ids.map(id => {
23
+ const row = rows.get(id)
24
+ const entry = row ? triageWireEntry(row) : null
25
+ const own = comments.filter(comment => comment.findingId === id)
26
+ const version = createHash('sha256').update(JSON.stringify([entry, row?.updatedAt, own])).digest('hex')
27
+ return [id, { entry, comments: own, version }]
28
+ }))
29
+ }
30
+ return {
31
+ getImportTriage: snapshot,
32
+ async importTriage(entries, expected, actor, reportId, now) {
33
+ const current = await snapshot(entries.map(([id]) => id))
34
+ if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
35
+ const changes: [string, TriageEntryPatch | null][] = []
36
+ for (const [id, value] of entries) {
37
+ const { comment, ...entry } = value ?? {}
38
+ changes.push([id, Object.keys(entry).length > 0 ? entry : null])
39
+ if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
40
+ await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
41
+ actor, createdAt: null, updatedAt: null, reportId }, now)
42
+ }
43
+ }
44
+ await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
45
+ return true
46
+ },
47
+ }
48
+ }
@@ -0,0 +1,135 @@
1
+ // Managed HTTP app and standalone boot. The combined launcher mounts this
2
+ // same app on e2e's listener; storage, routing and cleanup stay here.
3
+ import { type Server, createServer } from 'node:http'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { createOriginGate } from '../server-common/origin.ts'
6
+ import { managedStorageLines } from '../server-common/storage-log.ts'
7
+ import { runReapers, withReap } from '../server-common/reap.ts'
8
+ import { startServer } from '../server-common/standalone.ts'
9
+ import { initializeApp } from '../server-common/initialize.ts'
10
+ import { type ManagedConfig, loadManagedConfig } from './config.ts'
11
+ import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
12
+ import { loadManagedStatic } from './static.ts'
13
+ import { openManagedStorage } from './storage.ts'
14
+
15
+ // Expired-session sweep period. Lookups already exclude expired rows
16
+ // (`WHERE expires_at > now`), so this is housekeeping, not a security control.
17
+ const SESSION_GC_INTERVAL_MS = 3_600_000
18
+
19
+ type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
20
+
21
+ export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
22
+ return await initializeApp(rollback => assembleManagedApp(config, options, rollback))
23
+ }
24
+
25
+ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack) {
26
+ const storage = await openManagedStorage(config)
27
+ const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache } = storage
28
+ rollback.defer(() => db.close())
29
+ const originGate = createOriginGate(config.host, config.trustProxyEnv)
30
+
31
+ let shuttingDown = false
32
+ const isShuttingDown = () => shuttingDown || options.isShuttingDown?.() === true
33
+ const inFlight = new Set<Promise<unknown>>()
34
+ function track(p: Promise<unknown>): void {
35
+ inFlight.add(p)
36
+ p.finally(() => inFlight.delete(p)).catch(() => {})
37
+ }
38
+
39
+ const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
40
+ indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
41
+ })
42
+ const handleRequest = createManagedRequestHandler({
43
+ ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
44
+ ...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
45
+ isShuttingDown, track,
46
+ })
47
+
48
+ let cleanup: Promise<void> | undefined
49
+ function reap(): Promise<void> {
50
+ if (cleanup) return cleanup
51
+ cleanup = runReapers({
52
+ sessions: () => db.deleteExpiredSessions(Date.now()),
53
+ ...('reapUploads' in storage ? { uploads: () => storage.reapUploads() } : {}),
54
+ }).finally(() => { cleanup = undefined })
55
+ track(cleanup)
56
+ return cleanup
57
+ }
58
+ const gcTimer = config.serverless ? null : setInterval(() => {
59
+ void reap().catch(err => console.warn('managed: maintenance failed:', err))
60
+ }, SESSION_GC_INTERVAL_MS)
61
+ rollback.defer(stop)
62
+
63
+ function stop(): void {
64
+ shuttingDown = true
65
+ if (gcTimer) clearInterval(gcTimer)
66
+ }
67
+
68
+ async function close(): Promise<void> {
69
+ stop()
70
+ if (inFlight.size > 0) await Promise.allSettled([...inFlight])
71
+ await db.close()
72
+ }
73
+ return { handleRequest, reap, isShuttingDown, stop, close }
74
+ }
75
+
76
+ export function logManagedStartup(config: ManagedConfig, port: number, mode = 'managed'): void {
77
+ console.log([
78
+ `DeepView managed server (${mode}):`,
79
+ ` HTTP: http://${config.host}:${port}/`,
80
+ ...managedStorageLines(config),
81
+ ].join('\n'))
82
+ }
83
+
84
+ export async function init(config = loadManagedConfig()): Promise<Server> {
85
+ const app = await createManagedApp(config)
86
+ const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
87
+ let disposal: Promise<void> | undefined
88
+ function dispose(): Promise<void> {
89
+ if (!disposal) {
90
+ app.stop()
91
+ disposal = (async () => {
92
+ try {
93
+ if (server.listening) {
94
+ await new Promise<void>((resolve, reject) => {
95
+ server.close(err => { if (err) reject(err); else resolve() })
96
+ })
97
+ }
98
+ } finally {
99
+ try { await app.close() }
100
+ finally {
101
+ server.off('error', onError)
102
+ server.off('close', onClose)
103
+ server.off('listening', onListening)
104
+ }
105
+ }
106
+ })()
107
+ }
108
+ return disposal
109
+ }
110
+ // Embedding hosts can await full cleanup even when they only copied the
111
+ // request listener and never bound this server. Repeated disposal is safe.
112
+ server[Symbol.asyncDispose] = dispose
113
+
114
+ function onError(): void { onClose() }
115
+ function onClose(): void {
116
+ if (!disposal) void dispose().catch(err => { console.error('Managed server cleanup failed:', err) })
117
+ }
118
+ function onListening(): void {
119
+ const address = server.address()
120
+ const port = typeof address === 'object' && address ? address.port : config.port
121
+ logManagedStartup(config, port)
122
+ }
123
+ server.on('error', onError)
124
+ server.once('close', onClose)
125
+ server.on('listening', onListening)
126
+ return server
127
+ }
128
+
129
+ export async function start(): Promise<void> {
130
+ const config = loadManagedConfig()
131
+ const server = await init(config)
132
+ startServer(server, config)
133
+ }
134
+
135
+ if (import.meta.main) await start()
@@ -0,0 +1,150 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import { pipeline } from 'node:stream/promises'
3
+ import { Buffer } from 'node:buffer'
4
+ import type { ManagedHttpDeps } from './http.ts'
5
+ import type { ManagedBundle } from './db.ts'
6
+ import type { OpenedBlob } from './blob-store.ts'
7
+ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
8
+ import { sendJson } from './http-response.ts'
9
+ import { hashToken } from './crypto.ts'
10
+ import { TeamReportsError, loadTeamReports, teamReportVisibility } from './team-reports.ts'
11
+ import { MAX_FINDING_ID, MAX_TRIAGE_HISTORY } from '../common/managed/triage.ts'
12
+ import { triageWireEntry } from './triage-response.ts'
13
+ import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
14
+ import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
15
+ import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
16
+ import { serveTeamFeed } from './team-feed.ts'
17
+
18
+ function json(res: ServerResponse, status: number, body: unknown): void {
19
+ sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
20
+ }
21
+
22
+ // Called before ALL other routing, including OAuth, discovery, static and the
23
+ // combined server fallback. A supplied capability never inherits cookie auth.
24
+ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, url: URL): Promise<void> {
25
+ const token = req.headers['x-deepview-share']
26
+ if (!deps.config.allowShare || typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(token)) {
27
+ json(res, 401, { error: 'invalid-share' }); return
28
+ }
29
+ const method = req.method ?? 'GET'
30
+ if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
31
+ const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
32
+ const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
33
+ const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|triage\/history|comments|sources)$/u.exec(url.pathname)
34
+ const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
35
+ if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
36
+ const tokenHash = hashToken(token)
37
+ const snapshot = await deps.db.getWorkspaceShare(tokenHash)
38
+ if (!snapshot) { json(res, 401, { error: 'invalid-share' }); return }
39
+ const recheck = async () => {
40
+ const current = deps.config.allowShare && await deps.db.getWorkspaceShare(tokenHash)
41
+ if (!current || JSON.stringify(current) !== JSON.stringify(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
42
+ }
43
+ const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
44
+ const sendTeam = async () => {
45
+ const summaries = await bundleSummaries(snapshot.team.bundles, deps.bundleCache)
46
+ await send({ user: snapshot.user, team: { ...snapshot.team,
47
+ bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
48
+ } })
49
+ await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
50
+ }
51
+ const stream = async (stored: OpenedBlob, encoding: string | null, contentType = 'application/json') => {
52
+ try { await recheck() } catch (error) { stored.stream.destroy(); throw error }
53
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'private, no-store',
54
+ 'x-content-type-options': 'nosniff', ...(encoding ? { 'content-encoding': encoding } : {}),
55
+ ...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
56
+ })
57
+ if (method === 'HEAD') { stored.stream.destroy(); res.end(); return }
58
+ try { await pipeline(stored.stream, res) } catch { res.destroy() }
59
+ }
60
+ if (url.searchParams.has('team') && (url.searchParams.getAll('team').length !== 1 || url.searchParams.get('team') !== snapshot.teamId)) {
61
+ json(res, 404, { error: 'no-team' }); return
62
+ }
63
+ if (shareRoute) {
64
+ // The short hash is a recognizable link ID, never an access grant. Resolve
65
+ // with the full token so colliding IDs or slugs cannot broaden its scope.
66
+ // Existing links used the team ID as their fragment prefix.
67
+ if (shareRoute[1] !== tokenHash.slice(0, 8) && shareRoute[1] !== snapshot.teamId) {
68
+ json(res, 404, { error: 'no-share' }); return
69
+ }
70
+ await sendTeam(); return
71
+ }
72
+ if (teamRoute) {
73
+ if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
74
+ if (teamRoute[2] === 'feed') {
75
+ if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
76
+ await serveTeamFeed(res, deps, snapshot, recheck); return
77
+ }
78
+ if (teamRoute[2] === 'shared') { await sendTeam(); return }
79
+ await send({ reports: await loadTeamReports(deps.db, deps.reportStore, snapshot) }); return
80
+ }
81
+ if (reportRoute) {
82
+ const id = reportRoute[1]!, part = reportRoute[2]!
83
+ if (!snapshot.reports.some(report => report.id === id)) { json(res, 404, { error: 'no-report' }); return }
84
+ const visible = await teamReportVisibility(deps.db, deps.reportStore, snapshot, id)
85
+ await recheck()
86
+ if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
87
+ if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
88
+ if (part === 'triage/history') {
89
+ const finding = url.searchParams.get('finding') ?? ''
90
+ if (!finding || finding.length > MAX_FINDING_ID) { json(res, 400, { error: 'bad-request' }); return }
91
+ if (!visible.ids.has(finding)) { json(res, 404, { error: 'no-finding' }); return }
92
+ const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map(row => ({
93
+ seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
94
+ }))
95
+ await send({ finding, events }); return
96
+ }
97
+ const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
98
+ await send({ entries }); return
99
+ }
100
+ const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
101
+ if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
102
+ if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
103
+ await serveBundle(res, deps, bundle, bundleRoute![2]!, url, recheck, stream)
104
+ }
105
+
106
+ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, part: string, url: URL,
107
+ recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
108
+ if (part === 'download') {
109
+ const stored = await deps.bundleStore.open(bundle.id, bundle.kind)
110
+ if (!stored) { json(res, 503, { error: 'unavailable' }); return }
111
+ await stream(stored, bundle.kind === 'sourcemap' ? 'br' : null, 'application/octet-stream'); return
112
+ }
113
+ if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
114
+ if (part === 'advisories') {
115
+ if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
116
+ const packages = await deps.bundleCache.packageVersions(bundle, url.searchParams.get('reason') ?? '')
117
+ await recheck()
118
+ if (packages === null) { json(res, 413, { error: 'payload-too-large' }); return }
119
+ if (packages === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
120
+ const body = Buffer.from(JSON.stringify(packages))
121
+ if (body.length > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
122
+ const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
123
+ : await fetchNpmAdvisories(body, AbortSignal.timeout(NPM_ADVISORIES_TIMEOUT_MS), deps.config.debug)
124
+ await recheck()
125
+ json(res, result.status, result.status === 200 ? { packages, advisories: result.body } : result.body); return
126
+ }
127
+ let cached
128
+ try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
129
+ catch { json(res, 422, { error: 'bundle-unavailable' }); return }
130
+ await stream(cached, 'br')
131
+ }
132
+
133
+ async function sources(res: ServerResponse, deps: ManagedHttpDeps, snapshot: WorkspaceShareSnapshot,
134
+ id: string, paths: Set<string>, recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null) => Promise<void>) {
135
+ const report = await deps.db.getReport(id)
136
+ const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
137
+ const empty = async () => { await recheck(); res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
138
+ if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || bundle.repoId !== report.repoId) { await empty(); return }
139
+ if (!deps.reportSourcesCache) { json(res, 503, { error: 'unavailable' }); return }
140
+ let cached
141
+ try { cached = await deps.reportSourcesCache.open(report, bundle, snapshot.permissions, paths) }
142
+ catch { await empty(); return }
143
+ if (!cached) { await empty(); return }
144
+ const current = await deps.db.getReport(id), currentBundle = await deps.db.getBundle(bundle.id)
145
+ if (JSON.stringify(current) !== JSON.stringify(report) || JSON.stringify(currentBundle) !== JSON.stringify(bundle)
146
+ || snapshot.reports.find(item => item.id === id)?.repo.github !== cached.repo.github) {
147
+ cached.stream.destroy(); throw new TeamReportsError(404, 'workspace-changed')
148
+ }
149
+ await stream(cached, 'gzip')
150
+ }
@@ -0,0 +1,21 @@
1
+ const MAX_TEAM_PATH = 500
2
+
3
+ // Canonicalize only equivalent Git paths: '/' separates components and empty
4
+ // or '.' components do not change the directory. Reject ambiguous input rather
5
+ // than deleting characters from directory names used for authorization.
6
+ // `{ ok:false }` makes the handler 400; `{ path:null }` means the whole repo.
7
+ export function normalizeTeamPath(raw: unknown): { ok: true; path: string | null } | { ok: false } {
8
+ if (typeof raw !== 'string') return { ok: true, path: null }
9
+ // Spaces and backslashes can be literal parts of Git directory names.
10
+ // Neither trimming nor treating '\\' as a separator preserves that identity.
11
+ if (/\p{Cc}/u.test(raw) || raw.includes('\\') || raw !== raw.trim()) return { ok: false }
12
+ const segments: string[] = []
13
+ for (const seg of raw.split('/')) {
14
+ if (seg === '' || seg === '.') continue
15
+ if (seg === '..' || seg !== seg.trim()) return { ok: false }
16
+ segments.push(seg)
17
+ }
18
+ const path = segments.join('/')
19
+ if (path.length > MAX_TEAM_PATH) return { ok: false }
20
+ return { ok: true, path: path === '' ? null : path }
21
+ }
@@ -0,0 +1,35 @@
1
+ // SQLite deployments store report blobs beside the database. Recover location
2
+ // metadata before introducing directory-based team access. Schema and data move
3
+ // together so a missing/corrupt blob leaves the migration retryable after repair.
4
+ import { readFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import type { DatabaseSync } from 'node:sqlite'
7
+ import { reportRepoGithub } from '../report/index.js'
8
+ import { readManagedReport } from '../common/managed/report-content.ts'
9
+ import { normalizeTeamPath } from './repo-path.ts'
10
+
11
+ export function migrateReportLocations(db: DatabaseSync, reportDir: string): void {
12
+ const columns = db.prepare('PRAGMA table_info(managed_report)').all() as { name: string }[]
13
+ if (columns.some((column) => column.name === 'repo_directory')) return
14
+ db.exec('BEGIN IMMEDIATE')
15
+ try {
16
+ db.exec("ALTER TABLE managed_report ADD COLUMN repo_directory TEXT NOT NULL DEFAULT ''")
17
+ const update = db.prepare('UPDATE managed_report SET repo_directory = ?, repo_embedded = ? WHERE id = ?')
18
+ const reports = db.prepare('SELECT id, filename FROM managed_report').all() as { id: string, filename: string }[]
19
+ for (const { id, filename } of reports) {
20
+ // Same opaque UUID boundary as the blob store; never splice arbitrary
21
+ // database text into a filesystem path during an upgrade.
22
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(id)) throw new Error(`Invalid legacy report id: ${id}`)
23
+ const { data } = readManagedReport(readFileSync(join(reportDir, id), 'utf8'), filename)
24
+ if (data == null) throw new Error(`Cannot migrate location of unreadable report ${id}`)
25
+ const embedded = reportRepoGithub(data) != null
26
+ const directory = normalizeTeamPath(data.repo?.directory)
27
+ if (!directory.ok) throw new Error(`Cannot migrate invalid directory of report ${id}`)
28
+ update.run(directory.path ?? '', embedded ? 1 : 0, id)
29
+ }
30
+ db.exec('COMMIT')
31
+ } catch (err) {
32
+ db.exec('ROLLBACK')
33
+ throw err
34
+ }
35
+ }
@@ -0,0 +1,4 @@
1
+ // Large workspaces remain a single request. Bound both the stored input and
2
+ // the encoded response, independently of the per-upload size limit.
3
+ export const MAX_REPORT_QUERY_COUNT = 4096
4
+ export const MAX_REPORT_QUERY_BYTES = 1024 * 1024 * 1024
@@ -0,0 +1,16 @@
1
+ import { MIMEType } from 'node:util'
2
+
3
+ // The metadata envelope is opt-in. Wildcards keep the legacy raw report;
4
+ // an explicit, acceptable application/json range requests the envelope.
5
+ export function acceptsReportMetadata(accept: string | undefined): boolean {
6
+ // Commas inside quoted media-type parameters do not separate ranges.
7
+ for (const range of accept?.match(/(?:[^",]|"(?:\\.|[^"\\])*")+/gu) ?? []) {
8
+ try {
9
+ const media = new MIMEType(range.trim())
10
+ if (media.essence !== 'application/json') continue
11
+ const quality = media.params.get('q') ?? '1'
12
+ if (/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u.test(quality) && Number(quality) > 0) return true
13
+ } catch { /* Ignore malformed media ranges. */ }
14
+ }
15
+ return false
16
+ }
@@ -0,0 +1,154 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { extname } from 'node:path'
4
+ import { promisify } from 'node:util'
5
+ import { gzip } from 'node:zlib'
6
+ import { bundleSourcesAsMap } from '../common/bundle-sources.js'
7
+ import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
8
+ import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
9
+ import type { CacheStorage } from './cache-storage.ts'
10
+ import { readBundleDetails } from './bundle-cache.ts'
11
+ import type { BlobStore } from './blob-store.ts'
12
+ import type { BundleStore } from './bundle-store.ts'
13
+ import type { ManagedBundle, ManagedDb, ReportRecord } from './db.ts'
14
+
15
+ const compress = promisify(gzip)
16
+ const reportFormat = (filename: string) => extname(filename).toLowerCase()
17
+
18
+ function bundleDirectory(id: string) {
19
+ if (!/^[a-f\d-]{36}$/iu.test(id)) throw new Error('Invalid bundle id')
20
+ return id
21
+ }
22
+ function reportDirectory(bundleId: string, sha256: string) {
23
+ return `${bundleDirectory(bundleId)}/v1-${createHash('sha256').update(sha256).digest('hex')}`
24
+ }
25
+ function formatDirectory(bundleId: string, sha256: string, name: string) {
26
+ return `${reportDirectory(bundleId, sha256)}/${createHash('sha256').update(reportFormat(name)).digest('hex')}`
27
+ }
28
+ function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
29
+ const key = createHash('sha256').update(JSON.stringify([
30
+ 'finding-access-v4', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
31
+ ])).digest('hex')
32
+ return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
33
+ }
34
+
35
+ // Match the report's spelling exactly, then an unambiguous path suffix. Never
36
+ // guess between duplicate basenames or treat a bundle path as a disk pathname.
37
+ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
38
+ const byBasename = new Map<string, string[]>()
39
+ for (const file of sources.keys()) {
40
+ const basename = file.split('/').at(-1)!
41
+ const candidates = byBasename.get(basename) ?? []
42
+ candidates.push(file)
43
+ byBasename.set(basename, candidates)
44
+ }
45
+ const files = new Map<string, string>(), paths = new Map<string, string>()
46
+ function add(path: unknown) {
47
+ if (typeof path !== 'string' || !path || paths.has(path)) return
48
+ let file = sources.has(path) ? path : null
49
+ if (file === null) {
50
+ const suffix = path.startsWith('/') ? path : `/${path}`
51
+ const matches = byBasename.get(path.split('/').at(-1)!)?.filter(candidate => candidate.endsWith(suffix)) ?? []
52
+ if (matches.length === 1) file = matches[0]!
53
+ }
54
+ if (file === null) return
55
+ paths.set(path, file)
56
+ files.set(file, sources.get(file)!)
57
+ }
58
+ for (const path of sourcePaths) add(path)
59
+ return { files: [...files], paths: [...paths] }
60
+ }
61
+
62
+ // Immutable report hashes share a derivative across duplicate uploads. Bundle
63
+ // identity and visibility are part of the key: a broader viewer's sources must
64
+ // never populate a restricted response. Group derivatives by hash and filename
65
+ // format so each format's permissions can be removed after its last deletion.
66
+ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore) {
67
+ const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
68
+ let queue = Promise.resolve()
69
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
70
+ const job = queue.then(work)
71
+ queue = job.then(() => undefined, () => undefined)
72
+ return job
73
+ }
74
+ async function referenced(report: ReportRecord, bundle: ManagedBundle) {
75
+ const names = await db.listReportFilenamesWithBundleHash(bundle.id, report.sha256)
76
+ return names.some(name => reportFormat(name) === reportFormat(report.filename)) && await db.getBundle(bundle.id) != null
77
+ }
78
+ async function build(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
79
+ // Team requests supply paths from their already-filtered members. Reopening
80
+ // the original report by ID would conflate visible and hidden occurrences.
81
+ if (!sourcePaths) {
82
+ const bytes = await reports.get(report.id)
83
+ if (!bytes) return false
84
+ const parsed = await loadManagedFindings(filterReportContent(bytes.toString('utf8'), permissions, report.filename, repo), report.filename)
85
+ if (!parsed) return false
86
+ sourcePaths = managedFindingSourcePaths(parsed.findings)
87
+ }
88
+ const details = await readBundleDetails(bundle, bundles)
89
+ if (!details) return false
90
+ const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
91
+ const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection })), { level: 6 })
92
+ // A duplicate with the same hash AND format can use these parsed bytes.
93
+ // Another format must not keep a deleted variant's late build alive.
94
+ if (!(await referenced(report, bundle))) return false
95
+ await storage.put(target, body)
96
+ // Another instance may finish deletion while this write is in flight.
97
+ // Recheck after publishing so a late builder cannot resurrect its cache.
98
+ if (!(await referenced(report, bundle))) {
99
+ await storage.delete(formatDirectory(bundle.id, report.sha256, report.filename))
100
+ return false
101
+ }
102
+ return true
103
+ }
104
+ async function ensure(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>): Promise<boolean> {
105
+ const existing = pending.get(target)
106
+ if (existing) {
107
+ const ready = await existing.job
108
+ if (ready || existing.reportId === report.id) return ready
109
+ // Deletion can also win before the initiator reads its blob. Retry with
110
+ // this request's row instead of caching that other row's miss as 204.
111
+ const initiator = await db.getReport(existing.reportId)
112
+ if (initiator?.bundleId === bundle.id && initiator.sha256 === report.sha256) return false
113
+ if (pending.get(target) === existing) pending.delete(target)
114
+ return ensure(target, report, bundle, permissions, repo, sourcePaths)
115
+ }
116
+ const job = (async () => {
117
+ if (await storage.exists(target)) return true
118
+ // Serial cold builds bound peak decompression/parsing memory.
119
+ return enqueue(() => build(target, report, bundle, permissions, repo, sourcePaths))
120
+ })()
121
+ const entry = { reportId: report.id, job }
122
+ pending.set(target, entry)
123
+ try { return await job } finally { if (pending.get(target) === entry) pending.delete(target) }
124
+ }
125
+ return {
126
+ async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
127
+ const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
128
+ const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
129
+ if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
130
+ const opened = await storage.open(target)
131
+ return { ...opened, repo }
132
+ },
133
+ async deleteBundle(id: string) {
134
+ const prefix = `${bundleDirectory(id)}/`
135
+ await Promise.allSettled([...pending].filter(([key]) => key.startsWith(prefix)).map(([, entry]) => entry.job))
136
+ await storage.delete(bundleDirectory(id))
137
+ },
138
+ async deleteReport(report: Pick<ReportRecord, 'bundleId' | 'sha256' | 'filename'>) {
139
+ const { bundleId, sha256 } = report
140
+ if (bundleId === null) return
141
+ // Called after metadata deletion. Serialize with builders so a cold
142
+ // request cannot recreate a derivative after its cleanup has finished.
143
+ await enqueue(async () => {
144
+ const names = await db.listReportFilenamesWithBundleHash(bundleId, sha256)
145
+ if (names.length === 0) {
146
+ await storage.delete(reportDirectory(bundleId, sha256))
147
+ } else if (!names.some(name => reportFormat(name) === reportFormat(report.filename))) {
148
+ await storage.delete(formatDirectory(bundleId, sha256, report.filename))
149
+ }
150
+ })
151
+ },
152
+ }
153
+ }
154
+ export type ReportSourcesCache = ReturnType<typeof createReportSourcesCache>
@@ -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, this.fetchImpl)),
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
+ }