@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,71 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { Readable } from 'node:stream'
3
+ import type { OpenedBlob } from './blob-store.ts'
4
+ import { validateCacheKey } from './cache-storage.ts'
5
+
6
+ export const ENCRYPTED_CACHE_PREFIX = 'cache-encrypted-v1/'
7
+ const STORAGE_PREFIXES = ['reports/', 'bundles/', 'avatars/', 'uploads/', 'cache/', ENCRYPTED_CACHE_PREFIX]
8
+ export interface RawObject extends OpenedBlob { version: string; modifiedAt: number }
9
+ type ObjectMetadata = Omit<RawObject, 'stream'>
10
+ interface ListedObject { key: string; modifiedAt: number }
11
+ export interface ObjectPage { objects: ListedObject[]; cursor: string | null }
12
+ export interface RawObjectStorage {
13
+ open(key: string, signal?: AbortSignal): Promise<RawObject | null>
14
+ head(key: string, signal?: AbortSignal): Promise<ObjectMetadata | null>
15
+ prune?(prefix: string, signal?: AbortSignal): Promise<void>
16
+ // Persist an observed disk replacement before its SQL migration checkpoint.
17
+ sync?(key: string, signal?: AbortSignal): Promise<void>
18
+ // A plaintext size hint selects multipart uploads without buffering streams.
19
+ put(key: string, bytes: Buffer | Readable, signal?: AbortSignal, expected?: string, sizeHint?: number): Promise<boolean>
20
+ // Return false if the original version changed; do not delete its replacement.
21
+ delete(key: string, version?: string, signal?: AbortSignal): Promise<boolean>
22
+ list(prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage>
23
+ }
24
+ export interface ObjectStorage {
25
+ open(key: string): Promise<OpenedBlob | null>
26
+ get(key: string): Promise<Buffer | null>
27
+ exists(key: string): Promise<boolean>
28
+ put(key: string, bytes: Buffer): Promise<string | null>
29
+ delete(key: string): Promise<void>
30
+ deletePrefix(prefix: string): Promise<void>
31
+ }
32
+
33
+ export function isBlobId(value: string): boolean {
34
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value)
35
+ }
36
+
37
+ export function objectPath(key: string): string {
38
+ validateCacheKey(key)
39
+ if (!STORAGE_PREFIXES.some(prefix => key.startsWith(prefix))) throw new Error('Invalid managed storage namespace')
40
+ return key
41
+ }
42
+
43
+ // Hash verification and migration may consume a stream before it can be used.
44
+ // Reopen only the same object version, closing any concurrent replacement.
45
+ export async function openObjectVersion(raw: RawObjectStorage, key: string, version: string, signal?: AbortSignal): Promise<RawObject | null> {
46
+ const current = await raw.open(key, signal)
47
+ if (current?.version === version) return current
48
+ current?.stream.destroy()
49
+ return null
50
+ }
51
+
52
+ export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number): Promise<number> {
53
+ const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
54
+ // Finish listing before deleting: provider cursors may be offset-based.
55
+ const cursors = new Set<string>(), keys = new Set<string>()
56
+ let cursor: string | null = null
57
+ do {
58
+ const page = await raw.list(prefix, cursor, 100, signal)
59
+ for (const object of page.objects) if (eligible(object.modifiedAt)) keys.add(object.key)
60
+ cursor = page.cursor
61
+ if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
62
+ if (cursor !== null) cursors.add(cursor)
63
+ } while (cursor !== null)
64
+ let removed = 0
65
+ for (const key of keys) {
66
+ const stored = await raw.head(key, signal)
67
+ // Ignore stale listings and preserve concurrent replacements.
68
+ if (stored && eligible(stored.modifiedAt) && await raw.delete(key, stored.version, signal)) removed++
69
+ }
70
+ return removed
71
+ }
@@ -0,0 +1,51 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { normalizeTeamPath } from './repo-path.ts'
3
+
4
+ export const MAX_PACKAGE_BYTES = 256 * 1024
5
+ const MAX_ENTRY_POINTS = 100
6
+
7
+ // Declared paths, not a Node resolver: never guess defaults, expand export
8
+ // patterns, or execute package code. Keep every suggestion in its package.
9
+ export function packageEntryPoints(manifest: unknown, directory: string): string[] {
10
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []
11
+ const pkg = manifest as { main?: unknown; exports?: unknown; bin?: unknown }
12
+ const paths = new Set<string>()
13
+ const add = (value: unknown) => {
14
+ if (typeof value !== 'string' || value.startsWith('/') || value.endsWith('/') || /[*?]|^[a-z][a-z\d+.-]*:/iu.test(value)) return
15
+ const normalized = normalizeTeamPath(value)
16
+ if (!normalized.ok || !normalized.path || normalized.path === 'package.json') return
17
+ const path = directory ? `${directory}/${normalized.path}` : normalized.path
18
+ if (path.length <= 500 && paths.size < MAX_ENTRY_POINTS) paths.add(path)
19
+ }
20
+ add(pkg.main)
21
+ const pending = [pkg.exports]
22
+ for (let visited = 0; pending.length > 0 && visited < 4096; visited++) {
23
+ const value = pending.pop()
24
+ if (typeof value === 'string') { if (value.startsWith('./')) add(value); continue }
25
+ if (Array.isArray(value)) pending.push(...value.slice(0, 4096 - visited).toReversed())
26
+ else if (value && typeof value === 'object') {
27
+ const entries = Object.entries(value).filter(([key]) => key !== 'types' && !key.startsWith('types@'))
28
+ pending.push(...entries.slice(0, 4096 - visited).toReversed().map(([, target]) => target))
29
+ }
30
+ }
31
+ if (typeof pkg.bin === 'string') add(pkg.bin)
32
+ else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) Object.values(pkg.bin).forEach(add)
33
+ return [...paths]
34
+ }
35
+
36
+ // Use the immutable blob from the authorized listing, never download_url or a
37
+ // resolved symlink target. Malformed or oversized manifests are optional.
38
+ export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
39
+ try {
40
+ const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
41
+ if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
42
+ || blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
43
+ const bytes = Buffer.from(blob.content, 'base64')
44
+ if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
45
+ return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
46
+ } catch {
47
+ // Suggestions must not prevent browsing. The handler still rechecks
48
+ // GitHub and managed access after this optional upstream read.
49
+ return []
50
+ }
51
+ }
@@ -0,0 +1,146 @@
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, writeResponse } from './http-response.ts'
9
+ import { hashToken } from './crypto.ts'
10
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
11
+ import { triageWireEntry } from './triage-response.ts'
12
+ import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
13
+ import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
14
+ import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
15
+ import { serveTeamFeed } from './team-feed.ts'
16
+
17
+ function json(res: ServerResponse, status: number, body: unknown): void {
18
+ sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
19
+ }
20
+
21
+ // Called before ALL other routing, including OAuth, discovery, static and the
22
+ // combined server fallback. A supplied capability never inherits cookie auth.
23
+ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, url: URL): Promise<void> {
24
+ const token = req.headers['x-deepview-share']
25
+ if (!deps.config.allowShare || typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(token)) {
26
+ json(res, 401, { error: 'invalid-share' }); return
27
+ }
28
+ const method = req.method ?? 'GET'
29
+ if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
30
+ const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
31
+ const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
32
+ const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
33
+ const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
34
+ if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
35
+ const tokenHash = hashToken(token)
36
+ const snapshot = await deps.db.getWorkspaceShare(tokenHash)
37
+ if (!snapshot) { json(res, 401, { error: 'invalid-share' }); return }
38
+ const recheck = async () => {
39
+ const current = deps.config.allowShare && await deps.db.getWorkspaceShare(tokenHash)
40
+ if (!current || JSON.stringify(current) !== JSON.stringify(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
41
+ }
42
+ const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
43
+ const sendTeam = async () => {
44
+ const summaries = await bundleSummaries(snapshot.team.bundles, deps.bundleCache)
45
+ await send({ user: snapshot.user, team: { ...snapshot.team,
46
+ bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
47
+ } })
48
+ await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
49
+ }
50
+ const stream = async (stored: OpenedBlob, encoding: string | null, contentType = 'application/json') => {
51
+ try { await recheck() } catch (error) { stored.stream.destroy(); throw error }
52
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'private, no-store',
53
+ 'x-content-type-options': 'nosniff', ...(encoding ? { 'content-encoding': encoding } : {}),
54
+ ...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
55
+ })
56
+ if (method === 'HEAD') { stored.stream.destroy(); res.end(); return }
57
+ try { await pipeline(stored.stream, res) } catch { res.destroy() }
58
+ }
59
+ if (url.searchParams.has('team') && (url.searchParams.getAll('team').length !== 1 || url.searchParams.get('team') !== snapshot.teamId)) {
60
+ json(res, 404, { error: 'no-team' }); return
61
+ }
62
+ if (shareRoute) {
63
+ // The short hash is a recognizable link ID, never an access grant. Resolve
64
+ // with the full token so colliding IDs or slugs cannot broaden its scope.
65
+ // Existing links used the team ID as their fragment prefix.
66
+ if (shareRoute[1] !== tokenHash.slice(0, 8) && shareRoute[1] !== snapshot.teamId) {
67
+ json(res, 404, { error: 'no-share' }); return
68
+ }
69
+ await sendTeam(); return
70
+ }
71
+ if (teamRoute) {
72
+ if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
73
+ if (teamRoute[2] === 'feed') {
74
+ if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
75
+ await serveTeamFeed(res, deps, snapshot, recheck, { readState: () => deps.db.getWorkspaceShareFeedState(tokenHash) }); return
76
+ }
77
+ if (teamRoute[2] === 'annotations') {
78
+ await send(await loadTeamAnnotations(deps.db, deps.reportStore, snapshot, url.searchParams.get('reportId'))); return
79
+ }
80
+ if (teamRoute[2] === 'shared') { await sendTeam(); return }
81
+ const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
82
+ await recheck()
83
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
84
+ writeResponse(res, body); return
85
+ }
86
+ if (reportRoute) {
87
+ const id = reportRoute[1]!, part = reportRoute[2]!
88
+ if (!snapshot.reports.some(report => report.id === id)) { json(res, 404, { error: 'no-report' }); return }
89
+ const visible = await teamReportVisibility(deps.db, deps.reportStore, snapshot, id)
90
+ await recheck()
91
+ if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
92
+ if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
93
+ const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
94
+ await send({ entries }); return
95
+ }
96
+ const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
97
+ if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
98
+ if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
99
+ await serveBundle(res, deps, bundle, bundleRoute![2]!, url, recheck, stream)
100
+ }
101
+
102
+ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, part: string, url: URL,
103
+ recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
104
+ if (part === 'download') {
105
+ const stored = await deps.bundleStore.open(bundle.id, bundle.kind)
106
+ if (!stored) { json(res, 503, { error: 'unavailable' }); return }
107
+ await stream(stored, bundle.kind === 'sourcemap' ? 'br' : null, 'application/octet-stream'); return
108
+ }
109
+ if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
110
+ if (part === 'advisories') {
111
+ if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
112
+ const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
113
+ await recheck()
114
+ if (inventory === null) { json(res, 413, { error: 'payload-too-large' }); return }
115
+ if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
116
+ if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
117
+ const result = await fetchBundleAdvisories(inventory.packages, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS), {
118
+ debug: deps.config.debug, repoAdvisories: url.searchParams.get('repoAdvisories') === 'true',
119
+ })
120
+ await recheck()
121
+ json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
122
+ }
123
+ let cached
124
+ try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
125
+ catch { json(res, 422, { error: 'bundle-unavailable' }); return }
126
+ await stream(cached, 'br')
127
+ }
128
+
129
+ async function sources(res: ServerResponse, deps: ManagedHttpDeps, snapshot: WorkspaceShareSnapshot,
130
+ id: string, paths: Set<string>, recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null) => Promise<void>) {
131
+ const report = await deps.db.getReport(id)
132
+ const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
133
+ const empty = async () => { await recheck(); res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
134
+ if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || bundle.repoId !== report.repoId) { await empty(); return }
135
+ if (!deps.reportSourcesCache) { json(res, 503, { error: 'unavailable' }); return }
136
+ let cached
137
+ try { cached = await deps.reportSourcesCache.open(report, bundle, snapshot.permissions, paths) }
138
+ catch { await empty(); return }
139
+ if (!cached) { await empty(); return }
140
+ const current = await deps.db.getReport(id), currentBundle = await deps.db.getBundle(bundle.id)
141
+ if (JSON.stringify(current) !== JSON.stringify(report) || JSON.stringify(currentBundle) !== JSON.stringify(bundle)
142
+ || snapshot.reports.find(item => item.id === id)?.repo.github !== cached.repo.github) {
143
+ cached.stream.destroy(); throw new TeamReportsError(404, 'workspace-changed')
144
+ }
145
+ await stream(cached, 'gzip')
146
+ }
@@ -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 '@preventive/report'
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,10 @@
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
5
+
6
+ // The import catalog scans a bounded page, not the whole installation. A
7
+ // report larger than the byte target occupies a page by itself; upload limits
8
+ // still bound that single report and the cursor must always make progress.
9
+ export const FINDING_CATALOG_PAGE_COUNT = 128
10
+ export const FINDING_CATALOG_PAGE_BYTES = 32 * 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,166 @@
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 { bundleSourceImports } from '../common/bundle-source-links.js'
8
+ import type { BundleDetails } from '../common/bundle-metadata.js'
9
+ import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
10
+ import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
11
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
12
+ import { readBundleDetails } from './bundle-cache.ts'
13
+ import type { BlobStore } from './blob-store.ts'
14
+ import type { BundleStore } from './bundle-store.ts'
15
+ import type { ManagedBundle, ManagedDb, ReportRecord } from './db.ts'
16
+
17
+ const compress = promisify(gzip)
18
+ const reportFormat = (filename: string) => extname(filename).toLowerCase()
19
+
20
+ function bundleDirectory(id: string) {
21
+ if (!/^[a-f\d-]{36}$/iu.test(id)) throw new Error('Invalid bundle id')
22
+ return id
23
+ }
24
+ function reportDirectory(bundleId: string, sha256: string) {
25
+ return `${bundleDirectory(bundleId)}/v1-${createHash('sha256').update(sha256).digest('hex')}`
26
+ }
27
+ function formatDirectory(bundleId: string, sha256: string, name: string) {
28
+ return `${reportDirectory(bundleId, sha256)}/${createHash('sha256').update(reportFormat(name)).digest('hex')}`
29
+ }
30
+ function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
31
+ const key = createHash('sha256').update(JSON.stringify([
32
+ 'finding-access-v6', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
33
+ ])).digest('hex')
34
+ return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
35
+ }
36
+
37
+ // Match the report's spelling exactly, then an unambiguous path suffix. Never
38
+ // guess between duplicate basenames or treat a bundle path as a disk pathname.
39
+ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
40
+ const byBasename = new Map<string, string[]>()
41
+ for (const file of sources.keys()) {
42
+ const basename = file.split('/').at(-1)!
43
+ const candidates = byBasename.get(basename) ?? []
44
+ candidates.push(file)
45
+ byBasename.set(basename, candidates)
46
+ }
47
+ const files = new Map<string, string>(), paths = new Map<string, string>()
48
+ function add(path: unknown) {
49
+ if (typeof path !== 'string' || !path || paths.has(path)) return
50
+ let file = sources.has(path) ? path : null
51
+ if (file === null) {
52
+ const suffix = path.startsWith('/') ? path : `/${path}`
53
+ const matches = byBasename.get(path.split('/').at(-1)!)?.filter(candidate => candidate.endsWith(suffix)) ?? []
54
+ if (matches.length === 1) file = matches[0]!
55
+ }
56
+ if (file === null) return
57
+ paths.set(path, file)
58
+ files.set(file, sources.get(file)!)
59
+ }
60
+ for (const path of sourcePaths) add(path)
61
+ return { files: [...files], paths: [...paths] }
62
+ }
63
+
64
+ function encodeSources(integrity: string, details: BundleDetails, selection: ReturnType<typeof selectSources>) {
65
+ const sources = new Map(selection.files)
66
+ const imports = [...bundleSourceImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
67
+ const bundle = details.kind === 'stasis' ? details.bundle as { formats: Map<string, string> } : null
68
+ const formats = [...(bundle?.formats ?? [])].filter(([file]) => sources.has(file))
69
+ return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats })), { level: 6 })
70
+ }
71
+
72
+ // Immutable report hashes share a derivative across duplicate uploads. Bundle
73
+ // identity and visibility are part of the key: a broader viewer's sources must
74
+ // never populate a restricted response. Group derivatives by hash and filename
75
+ // format so each format's permissions can be removed after its last deletion.
76
+ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, reports: BlobStore, bundles: BundleStore) {
77
+ const pending = new Map<string, { reportId: string; job: Promise<boolean> }>()
78
+ let queue = Promise.resolve()
79
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
80
+ const job = queue.then(work)
81
+ queue = job.then(() => undefined, () => undefined)
82
+ return job
83
+ }
84
+ async function referenced(report: ReportRecord, bundle: ManagedBundle) {
85
+ const names = await db.listReportFilenamesWithBundleHash(bundle.id, report.sha256)
86
+ return names.some(name => reportFormat(name) === reportFormat(report.filename)) && await db.getBundle(bundle.id) != null
87
+ }
88
+ async function build(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
89
+ // Team requests supply paths from their already-filtered members. Reopening
90
+ // the original report by ID would conflate visible and hidden occurrences.
91
+ if (!sourcePaths) {
92
+ const bytes = await reports.get(report.id)
93
+ if (!bytes) return false
94
+ const parsed = await loadManagedFindings(filterReportContent(bytes.toString('utf8'), permissions, report.filename, repo), report.filename)
95
+ if (!parsed) return false
96
+ sourcePaths = managedFindingSourcePaths(parsed.findings)
97
+ }
98
+ const details = await readBundleDetails(bundle, bundles)
99
+ if (!details) return false
100
+ const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
101
+ const body = await encodeSources(bundle.integrity, details, selection)
102
+ // A duplicate with the same hash AND format can use these parsed bytes.
103
+ // Another format must not keep a deleted variant's late build alive.
104
+ if (!(await referenced(report, bundle))) return false
105
+ await storage.put(target, body)
106
+ // Another instance may finish deletion while this write is in flight.
107
+ // Recheck after publishing so a late builder cannot resurrect its cache.
108
+ if (!(await referenced(report, bundle))) {
109
+ await storage.delete(formatDirectory(bundle.id, report.sha256, report.filename))
110
+ return false
111
+ }
112
+ return true
113
+ }
114
+ async function ensure(target: string, report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>): Promise<boolean> {
115
+ const existing = pending.get(target)
116
+ if (existing) {
117
+ const ready = await existing.job
118
+ if (ready || existing.reportId === report.id) return ready
119
+ // Deletion can also win before the initiator reads its blob. Retry with
120
+ // this request's row instead of caching that other row's miss as 204.
121
+ const initiator = await db.getReport(existing.reportId)
122
+ if (initiator?.bundleId === bundle.id && initiator.sha256 === report.sha256) return false
123
+ if (pending.get(target) === existing) pending.delete(target)
124
+ return ensure(target, report, bundle, permissions, repo, sourcePaths)
125
+ }
126
+ const job = (async () => {
127
+ if (await storage.exists(target)) return true
128
+ // Serial cold builds bound peak decompression/parsing memory.
129
+ return enqueue(() => build(target, report, bundle, permissions, repo, sourcePaths))
130
+ })()
131
+ const entry = { reportId: report.id, job }
132
+ pending.set(target, entry)
133
+ try { return await job } finally { if (pending.get(target) === entry) pending.delete(target) }
134
+ }
135
+ return {
136
+ async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
137
+ const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
138
+ const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
139
+ try { return { ...await storage.open(target), repo } }
140
+ catch (error) { if (!(error instanceof CacheMissError)) throw error }
141
+ if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
142
+ const opened = await storage.open(target)
143
+ return { ...opened, repo }
144
+ },
145
+ async deleteBundle(id: string) {
146
+ const prefix = `${bundleDirectory(id)}/`
147
+ await Promise.allSettled([...pending].filter(([key]) => key.startsWith(prefix)).map(([, entry]) => entry.job))
148
+ await storage.delete(bundleDirectory(id))
149
+ },
150
+ async deleteReport(report: Pick<ReportRecord, 'bundleId' | 'sha256' | 'filename'>) {
151
+ const { bundleId, sha256 } = report
152
+ if (bundleId === null) return
153
+ // Called after metadata deletion. Serialize with builders so a cold
154
+ // request cannot recreate a derivative after its cleanup has finished.
155
+ await enqueue(async () => {
156
+ const names = await db.listReportFilenamesWithBundleHash(bundleId, sha256)
157
+ if (names.length === 0) {
158
+ await storage.delete(reportDirectory(bundleId, sha256))
159
+ } else if (!names.some(name => reportFormat(name) === reportFormat(report.filename))) {
160
+ await storage.delete(formatDirectory(bundleId, sha256, report.filename))
161
+ }
162
+ })
163
+ },
164
+ }
165
+ }
166
+ export type ReportSourcesCache = ReturnType<typeof createReportSourcesCache>