@preventive/triage 1.0.0-alpha.26 → 1.0.0-alpha.28

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 (96) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +24 -24
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +29 -29
  19. package/server-common/storage-crypto.ts +182 -0
  20. package/server-common/storage-log.ts +5 -1
  21. package/server-common/vercel-blob.ts +6 -3
  22. package/server-e2e/static.ts +3 -4
  23. package/server-managed/avatar-store.ts +1 -40
  24. package/server-managed/blob-store.ts +3 -52
  25. package/server-managed/bundle-advisories.ts +53 -0
  26. package/server-managed/bundle-advisory-inventory.ts +75 -0
  27. package/server-managed/bundle-build-leases.ts +41 -0
  28. package/server-managed/bundle-build-worker.js +44 -0
  29. package/server-managed/bundle-build.ts +141 -0
  30. package/server-managed/bundle-cache.ts +22 -41
  31. package/server-managed/bundle-store.ts +3 -7
  32. package/server-managed/bundle-summary-cache.ts +27 -9
  33. package/server-managed/bundle.ts +66 -1
  34. package/server-managed/cache-storage.ts +4 -25
  35. package/server-managed/cli.js +10 -2
  36. package/server-managed/config.ts +12 -1
  37. package/server-managed/content-access.ts +1 -1
  38. package/server-managed/db-methods.ts +163 -40
  39. package/server-managed/db-neon.ts +124 -30
  40. package/server-managed/db-schema.ts +2 -0
  41. package/server-managed/db.ts +11 -1
  42. package/server-managed/github-app.ts +10 -15
  43. package/server-managed/github-errors.ts +21 -0
  44. package/server-managed/github-issues.ts +1 -1
  45. package/server-managed/github-metadata.ts +34 -1
  46. package/server-managed/github-pulls.ts +142 -30
  47. package/server-managed/http.ts +278 -167
  48. package/server-managed/index.ts +26 -9
  49. package/server-managed/management-catalog.ts +45 -0
  50. package/server-managed/object-storage-disk.ts +144 -0
  51. package/server-managed/object-storage-vercel.ts +60 -0
  52. package/server-managed/object-storage.ts +71 -0
  53. package/server-managed/public-workspace.ts +20 -14
  54. package/server-managed/report-migration.ts +1 -1
  55. package/server-managed/report-sources.ts +14 -4
  56. package/server-managed/repository-browser.ts +8 -1
  57. package/server-managed/repository-discovery.ts +1 -1
  58. package/server-managed/revisions.ts +35 -0
  59. package/server-managed/solidity-entry-points.ts +69 -0
  60. package/server-managed/sql-postgres.ts +1 -0
  61. package/server-managed/sql.ts +14 -8
  62. package/server-managed/storage-command.ts +16 -0
  63. package/server-managed/storage-db.ts +175 -0
  64. package/server-managed/storage-encryption.ts +211 -0
  65. package/server-managed/storage-maintenance.ts +197 -0
  66. package/server-managed/storage-payload.ts +72 -0
  67. package/server-managed/storage-stores.ts +81 -0
  68. package/server-managed/storage.ts +48 -24
  69. package/server-managed/team-feed.ts +50 -5
  70. package/server-managed/team-reports.ts +85 -22
  71. package/server-managed/uploads.ts +2 -2
  72. package/server-managed/workspace-shares.ts +15 -3
  73. package/report/index.js +0 -260
  74. package/report/src/finding-id.js +0 -80
  75. package/report/src/finding.js +0 -314
  76. package/report/src/labels.js +0 -35
  77. package/report/src/md-structure.js +0 -471
  78. package/report/src/md-text.js +0 -167
  79. package/report/src/meta.js +0 -76
  80. package/report/src/parse-codex.js +0 -149
  81. package/report/src/parse-deepsec.js +0 -199
  82. package/report/src/parse-deepview-fields.js +0 -375
  83. package/report/src/parse-deepview-md.js +0 -186
  84. package/report/src/parse-md-id.js +0 -137
  85. package/report/src/parse-md.js +0 -323
  86. package/report/src/parse-piolium-id.js +0 -79
  87. package/report/src/parse-piolium-rows.js +0 -126
  88. package/report/src/parse-piolium-tokens.js +0 -185
  89. package/report/src/parse-piolium.js +0 -399
  90. package/report/src/security.js +0 -63
  91. package/report/src/severity.js +0 -18
  92. package/report/src/utf8.js +0 -21
  93. package/report/src/write-md-finding.js +0 -273
  94. package/report/src/write-md.js +0 -291
  95. package/server-managed/blob-vercel.ts +0 -127
  96. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -1,5 +1,6 @@
1
1
  // Managed HTTP app and standalone boot. The combined launcher mounts this
2
2
  // same app on e2e's listener; storage, routing and cleanup stay here.
3
+ import { randomUUID } from 'node:crypto'
3
4
  import { type Server, createServer } from 'node:http'
4
5
  import { fileURLToPath } from 'node:url'
5
6
  import { createOriginGate } from '../server-common/origin.ts'
@@ -42,22 +43,37 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
42
43
  })
43
44
  const routeRequest = createManagedRequestHandler({
44
45
  ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
45
- ...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
46
+ ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}),
46
47
  isShuttingDown, track,
47
48
  })
48
49
 
49
50
  let cleanup: Promise<void> | undefined
50
51
  let nextCleanupAt = 0
51
- function reap(): Promise<void> {
52
+ const maintenance = new AbortController()
53
+ function reap(automatic = false): Promise<void> {
52
54
  if (cleanup) return cleanup
55
+ if (isShuttingDown()) return Promise.resolve()
53
56
  const startedAt = Date.now()
54
57
  nextCleanupAt = startedAt + REAP_INTERVAL_MS
55
58
  let sessions = 0, uploads = 0
56
- cleanup = runReapers({
57
- sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
58
- ...('reapUploads' in storage ? { uploads: async () => { uploads = await storage.reapUploads() } } : {}),
59
- }).then(() => {
59
+ cleanup = (async () => {
60
+ const owner = automatic && config.serverless ? randomUUID() : null
61
+ if (owner && !await db.claimMaintenanceLease(owner, startedAt, startedAt + Math.max(300_000, (config.storageEncryptionMigrateMaxMs ?? 0) + 60_000), config.storageEncryptionMigrate)) return false
62
+ let succeeded = false
63
+ try {
64
+ await runReapers({
65
+ sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
66
+ ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
67
+ ...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
68
+ })
69
+ succeeded = true
70
+ return true
71
+ } finally {
72
+ if (owner) await db.finishMaintenanceLease(owner, Date.now() + (succeeded ? REAP_INTERVAL_MS : REAP_RETRY_MS))
73
+ }
74
+ })().then(ran => {
60
75
  nextCleanupAt = Date.now() + REAP_INTERVAL_MS
76
+ if (!ran) return
61
77
  return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
62
78
  }, err => {
63
79
  nextCleanupAt = Date.now() + REAP_RETRY_MS
@@ -68,14 +84,14 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
68
84
  }
69
85
  function automaticReap(): Promise<void> {
70
86
  if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
71
- return reap().catch(err => console.warn('managed-reaper: cleanup failed:', err))
87
+ return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
72
88
  }
73
89
  async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
74
- const maintenance = automaticReap()
90
+ const pendingMaintenance = automaticReap()
75
91
  // Send the normal response without waiting for storage housekeeping, but
76
92
  // keep the invocation alive until both finish. No detached serverless work.
77
93
  try { await routeRequest(req, res) }
78
- finally { await maintenance }
94
+ finally { await pendingMaintenance }
79
95
  }
80
96
  const gcTimer = config.serverless ? null : setInterval(() => {
81
97
  void automaticReap()
@@ -84,6 +100,7 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
84
100
 
85
101
  function stop(): void {
86
102
  shuttingDown = true
103
+ maintenance.abort()
87
104
  if (gcTimer) clearInterval(gcTimer)
88
105
  }
89
106
 
@@ -0,0 +1,45 @@
1
+ import { roleAtLeast } from '../common/managed/roles.ts'
2
+ import { type RepoScope, contentAccess } from './content-access.ts'
3
+ import type { AdminBundle, AdminReport, ManagedDb } from './db-methods.ts'
4
+ import { ManagedMutationError } from './management.ts'
5
+
6
+ type CatalogAccess = { repos: { repoId: number; fullName: string }[]; repoScopes: RepoScope[] | null }
7
+ export interface ManagementCatalogStore {
8
+ getReportCatalog(sessionId: string, now: number): Promise<CatalogAccess & { reports: (AdminReport & { canChangeRepo: boolean })[] }>
9
+ getBundleCatalog(sessionId: string, now: number): Promise<CatalogAccess & { bundles: (AdminBundle & { canChangeRepo: boolean })[] }>
10
+ }
11
+ type CatalogDb = Pick<ManagedDb, 'getReportAccessSnapshot' | 'listRepoScopesForUser' | 'listSelectedRepos'
12
+ | 'listReports' | 'listBundles' | 'listReadableBundleIds'>
13
+
14
+ // The outer store method owns one read snapshot. Use raw methods here so the
15
+ // session, grants, rows and action flags agree without per-row transactions.
16
+ export function managementCatalogMethods(db: CatalogDb): ManagementCatalogStore {
17
+ async function access(sessionId: string, now: number) {
18
+ const snapshot = await db.getReportAccessSnapshot(sessionId, now, [])
19
+ if (!snapshot) throw new ManagedMutationError(401, 'unauthenticated')
20
+ if (!roleAtLeast(snapshot.user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
21
+ const scope = await contentAccess(db, snapshot.user)
22
+ const repos = (await db.listSelectedRepos()).filter(scope.bundle).map(({ repoId, fullName }) => ({ repoId, fullName }))
23
+ return { userId: snapshot.user.role === 'admin' ? undefined : snapshot.user.id, scope, repos }
24
+ }
25
+ return {
26
+ async getReportCatalog(sessionId, now) {
27
+ const { userId, scope, repos } = await access(sessionId, now)
28
+ const reports = await db.listReports(userId)
29
+ const linked = reports.flatMap(report => report.bundleId ? [report.bundleId] : [])
30
+ const readable = userId === undefined ? new Set(linked) : new Set(await db.listReadableBundleIds(userId, linked))
31
+ return { repos, repoScopes: scope.scopes, reports: reports.map(report => ({ ...report,
32
+ canChangeRepo: report.repoId === null || scope.report(report),
33
+ bundleId: report.bundleId && readable.has(report.bundleId) ? report.bundleId : null,
34
+ bundleFilename: report.bundleId && readable.has(report.bundleId) ? report.bundleFilename : null,
35
+ })) }
36
+ },
37
+ async getBundleCatalog(sessionId, now) {
38
+ const { userId, scope, repos } = await access(sessionId, now)
39
+ const bundles = await db.listBundles(userId)
40
+ return { repos, repoScopes: scope.scopes, bundles: bundles.map(bundle => ({ ...bundle,
41
+ canChangeRepo: bundle.repoId === null || scope.report(bundle),
42
+ })) }
43
+ },
44
+ }
45
+ }
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { lstat, mkdir, open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { type ObjectPage, type RawObjectStorage, objectPath } from './object-storage.ts'
5
+
6
+ function missing(err: unknown): boolean { return (err as NodeJS.ErrnoException).code === 'ENOENT' }
7
+ function version(info: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }): string {
8
+ return JSON.stringify([info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])
9
+ }
10
+
11
+ async function syncDirectory(dir: string): Promise<void> {
12
+ try {
13
+ const directory = await open(dir, 'r')
14
+ try { await directory.sync() } finally { await directory.close() }
15
+ } catch (err) {
16
+ // Windows and some mounted filesystems cannot open/fsync directories.
17
+ // Preserve real I/O failures; file fsync remains mandatory on every write.
18
+ if (!['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EISDIR', 'EBADF', 'EPERM'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err
19
+ }
20
+ }
21
+
22
+ // Keyset pagination survives concurrent file creation/deletion. A final sweep
23
+ // catches objects inserted before the cursor during migration.
24
+ async function listFiles(dir: string, prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage> {
25
+ const objects: ObjectPage['objects'] = []
26
+ async function visit(key: string): Promise<void> {
27
+ signal?.throwIfAborted()
28
+ let entries
29
+ try {
30
+ if (!(await lstat(join(dir, key))).isDirectory()) {
31
+ console.warn('managed-storage: skipping unsupported directory', JSON.stringify(key))
32
+ return
33
+ }
34
+ entries = await readdir(join(dir, key), { withFileTypes: true })
35
+ }
36
+ catch (err) { if (missing(err)) return; throw err }
37
+ const order = (entry: typeof entries[number]) => `${entry.name}${entry.isDirectory() ? '/' : ''}`
38
+ entries.sort((a, b) => order(a) < order(b) ? -1 : order(a) > order(b) ? 1 : 0)
39
+ for (const entry of entries) {
40
+ signal?.throwIfAborted()
41
+ const child = `${key}${entry.name}`
42
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
43
+ console.warn('managed-storage: skipping unsupported entry', JSON.stringify(child))
44
+ continue
45
+ }
46
+ try { objectPath(child) }
47
+ catch { console.warn('managed-storage: skipping invalid entry', JSON.stringify(child)); continue }
48
+ if (entry.isDirectory()) {
49
+ const folder = `${child}/`
50
+ if (cursor && folder < cursor && !cursor.startsWith(folder)) continue
51
+ await visit(folder)
52
+ if (objects.length > limit) return
53
+ }
54
+ else if (entry.isFile() && (!cursor || child > cursor)) {
55
+ try { objects.push({ key: objectPath(child), modifiedAt: (await stat(join(dir, child))).mtimeMs }) }
56
+ catch (err) { if (!missing(err)) throw err }
57
+ if (objects.length > limit) return
58
+ }
59
+ }
60
+ // Another migration worker can observe an unlink before its writer has
61
+ // synced the directory. Persist those observed removals before an empty
62
+ // inventory can mark the shared migration complete.
63
+ try { await syncDirectory(join(dir, key)) } catch (err) { if (!missing(err)) throw err }
64
+ }
65
+ if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
66
+ objectPath(`${prefix}validate`)
67
+ await visit(prefix)
68
+ return { objects: objects.slice(0, limit), cursor: objects.length > limit ? objects[limit - 1]!.key : null }
69
+ }
70
+
71
+ export function createDiskObjectStorage(dir: string): RawObjectStorage {
72
+ const path = (key: string) => join(dir, objectPath(key))
73
+ return {
74
+ async head(key, signal) {
75
+ signal?.throwIfAborted()
76
+ try {
77
+ const info = await stat(path(key))
78
+ return { size: info.size, version: version(info), modifiedAt: info.mtimeMs }
79
+ } catch (err) { if (missing(err)) return null; throw err }
80
+ },
81
+ async open(key, signal) {
82
+ signal?.throwIfAborted()
83
+ let file
84
+ try { file = await open(path(key), 'r') }
85
+ catch (err) { if (missing(err)) return null; throw err }
86
+ try {
87
+ const info = await file.stat()
88
+ return { size: info.size, version: version(info), modifiedAt: info.mtimeMs,
89
+ stream: file.createReadStream(signal ? { signal } : {}) }
90
+ } catch (err) { await file.close(); throw err }
91
+ },
92
+ async put(key, bytes, signal, expected) {
93
+ const target = path(key)
94
+ // Temp-file + rename makes replacement atomic to readers. Concurrent
95
+ // migrations use the same persisted data key and immutable plaintext.
96
+ const temp = `${target}.${randomUUID()}.tmp`
97
+ await mkdir(dirname(target), { recursive: true })
98
+ const file = await open(temp, 'wx', 0o600)
99
+ try {
100
+ await file.writeFile(bytes, signal ? { signal } : {})
101
+ await file.sync(); await file.close()
102
+ signal?.throwIfAborted()
103
+ if (expected !== undefined) {
104
+ try { if (version(await stat(target)) !== expected) return false }
105
+ catch (err) { if (missing(err)) return false; throw err }
106
+ }
107
+ await rename(temp, target)
108
+ await syncDirectory(dirname(target))
109
+ return true
110
+ } finally { await file.close(); await rm(temp, { force: true }) }
111
+ },
112
+ async delete(key, expected, signal) {
113
+ signal?.throwIfAborted()
114
+ try {
115
+ if (expected !== undefined && version(await stat(path(key))) !== expected) return false
116
+ await rm(path(key), { force: true })
117
+ // Persist plaintext removal before migration checkpoints completion.
118
+ // Otherwise a crash could restore the directory entry after SQL has
119
+ // durably disabled further plaintext inventory sweeps.
120
+ await syncDirectory(dirname(path(key)))
121
+ return true
122
+ } catch (err) { if (missing(err)) return true; throw err }
123
+ },
124
+ async sync(key, signal) { signal?.throwIfAborted(); await syncDirectory(dirname(path(key))) },
125
+ async prune(prefix, signal) {
126
+ if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
127
+ objectPath(`${prefix}validate`)
128
+ async function visit(folder: string): Promise<void> {
129
+ signal?.throwIfAborted()
130
+ let entries
131
+ try {
132
+ if (!(await lstat(folder)).isDirectory()) return
133
+ entries = await readdir(folder, { withFileTypes: true })
134
+ }
135
+ catch (err) { if (missing(err)) return; throw err }
136
+ for (const entry of entries) if (entry.isDirectory()) await visit(join(folder, entry.name))
137
+ try { await rmdir(folder); await syncDirectory(dirname(folder)) }
138
+ catch (err) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err }
139
+ }
140
+ await visit(join(dir, prefix))
141
+ },
142
+ list: (prefix, cursor, limit, signal) => listFiles(dir, prefix, cursor, limit, signal),
143
+ }
144
+ }
@@ -0,0 +1,60 @@
1
+ import { Readable } from 'node:stream'
2
+ import { Buffer } from 'node:buffer'
3
+ import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
4
+ import { type RawObjectStorage, objectPath } from './object-storage.ts'
5
+
6
+ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk): Promise<RawObjectStorage> {
7
+ const blobs = sdk ?? await loadVercelBlobSdk()
8
+ const path = (key: string) => `.managed/${objectPath(key)}`
9
+ return {
10
+ async head(key, signal) {
11
+ try {
12
+ const meta = await blobs.head(path(key), { token, ...(signal ? { abortSignal: signal } : {}) })
13
+ if (!meta.etag) throw new Error('Missing object version')
14
+ return { size: meta.size, version: meta.etag, modifiedAt: new Date(meta.uploadedAt ?? Date.now()).getTime() }
15
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
16
+ },
17
+ async open(key, signal) {
18
+ try {
19
+ const result = await blobs.get(path(key), { token, access: 'private', useCache: false, ...(signal ? { abortSignal: signal } : {}) })
20
+ if (!result) return null
21
+ if (result.statusCode !== 200 || !result.stream || !result.blob.etag) {
22
+ await result.stream?.cancel()
23
+ throw new Error('Unexpected blob response or missing object version')
24
+ }
25
+ return { size: result.blob.size === 0 ? null : result.blob.size, version: result.blob.etag,
26
+ modifiedAt: new Date(result.blob.uploadedAt ?? Date.now()).getTime(),
27
+ stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0], signal ? { signal } : {}) }
28
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
29
+ },
30
+ async put(key, bytes, signal, expected, sizeHint) {
31
+ const multipart = (sizeHint ?? (Buffer.isBuffer(bytes) ? bytes.length : Infinity)) >= 5 * 1024 * 1024
32
+ try { await blobs.put(path(key), bytes, { token, access: 'private', addRandomSuffix: false, allowOverwrite: true,
33
+ ...(expected ? { ifMatch: expected } : {}), multipart, contentType: 'application/octet-stream', cacheControlMaxAge: 60, ...(signal ? { abortSignal: signal } : {}) })
34
+ return true
35
+ } catch (err) {
36
+ if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
37
+ throw err
38
+ }
39
+ },
40
+ async delete(key, version, signal) {
41
+ signal?.throwIfAborted()
42
+ try { await blobs.del(path(key), { token, ...(version ? { ifMatch: version } : {}), ...(signal ? { abortSignal: signal } : {}) }); return true }
43
+ catch (err) {
44
+ if (isNotFound(err, blobs)) return true
45
+ if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
46
+ throw err
47
+ }
48
+ },
49
+ async list(prefix, cursor, limit, signal) {
50
+ signal?.throwIfAborted()
51
+ if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
52
+ objectPath(`${prefix}validate`)
53
+ const page = await blobs.list({ prefix: `.managed/${prefix}`, token, limit, ...(cursor ? { cursor } : {}), ...(signal ? { abortSignal: signal } : {}) })
54
+ if (page.hasMore && (!page.cursor || page.cursor === cursor)) throw new Error('Invalid blob pagination')
55
+ return { objects: page.blobs.filter(blob => blob.pathname.startsWith(`.managed/${prefix}`)).map(blob => ({ key: blob.pathname.slice('.managed/'.length),
56
+ modifiedAt: new Date(blob.uploadedAt ?? Date.now()).getTime() })),
57
+ cursor: page.hasMore ? page.cursor! : null }
58
+ },
59
+ }
60
+ }
@@ -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
+ }
@@ -5,13 +5,13 @@ import type { ManagedHttpDeps } from './http.ts'
5
5
  import type { ManagedBundle } from './db.ts'
6
6
  import type { OpenedBlob } from './blob-store.ts'
7
7
  import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
8
- import { sendJson } from './http-response.ts'
8
+ import { sendJson, writeResponse } from './http-response.ts'
9
9
  import { hashToken } from './crypto.ts'
10
- import { TeamReportsError, loadTeamReports, teamReportVisibility } from './team-reports.ts'
10
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
11
11
  import { triageWireEntry } from './triage-response.ts'
12
12
  import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
13
13
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
14
- import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
14
+ import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
15
15
  import { serveTeamFeed } from './team-feed.ts'
16
16
 
17
17
  function json(res: ServerResponse, status: number, body: unknown): void {
@@ -28,7 +28,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
28
28
  const method = req.method ?? 'GET'
29
29
  if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
30
30
  const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
31
- const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
31
+ const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
32
32
  const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
33
33
  const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
34
34
  if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
@@ -72,10 +72,16 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
72
72
  if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
73
73
  if (teamRoute[2] === 'feed') {
74
74
  if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
75
- await serveTeamFeed(res, deps, snapshot, recheck); 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
76
79
  }
77
80
  if (teamRoute[2] === 'shared') { await sendTeam(); return }
78
- await send({ reports: await loadTeamReports(deps.db, deps.reportStore, snapshot) }); 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
79
85
  }
80
86
  if (reportRoute) {
81
87
  const id = reportRoute[1]!, part = reportRoute[2]!
@@ -103,16 +109,16 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
103
109
  if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
104
110
  if (part === 'advisories') {
105
111
  if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
106
- const packages = await deps.bundleCache.packageVersions(bundle, url.searchParams.get('reason') ?? '')
112
+ const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
107
113
  await recheck()
108
- if (packages === null) { json(res, 413, { error: 'payload-too-large' }); return }
109
- if (packages === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
110
- const body = Buffer.from(JSON.stringify(packages))
111
- if (body.length > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
112
- const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
113
- : await fetchNpmAdvisories(body, AbortSignal.timeout(NPM_ADVISORIES_TIMEOUT_MS), deps.config.debug)
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
+ })
114
120
  await recheck()
115
- json(res, result.status, result.status === 200 ? { packages, advisories: result.body } : result.body); return
121
+ json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
116
122
  }
117
123
  let cached
118
124
  try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
@@ -4,7 +4,7 @@
4
4
  import { readFileSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
  import type { DatabaseSync } from 'node:sqlite'
7
- import { reportRepoGithub } from '../report/index.js'
7
+ import { reportRepoGithub } from '@preventive/report'
8
8
  import { readManagedReport } from '../common/managed/report-content.ts'
9
9
  import { normalizeTeamPath } from './repo-path.ts'
10
10
 
@@ -5,9 +5,10 @@ import { promisify } from 'node:util'
5
5
  import { gzip } from 'node:zlib'
6
6
  import { bundleSourcesAsMap } from '../common/bundle-sources.js'
7
7
  import { bundleSourceImports } from '../common/bundle-source-links.js'
8
+ import type { BundleDetails } from '../common/bundle-metadata.js'
8
9
  import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
9
10
  import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
10
- import type { CacheStorage } from './cache-storage.ts'
11
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
11
12
  import { readBundleDetails } from './bundle-cache.ts'
12
13
  import type { BlobStore } from './blob-store.ts'
13
14
  import type { BundleStore } from './bundle-store.ts'
@@ -28,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
28
29
  }
29
30
  function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
30
31
  const key = createHash('sha256').update(JSON.stringify([
31
- 'finding-access-v5', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
32
+ 'finding-access-v6', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
32
33
  ])).digest('hex')
33
34
  return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
34
35
  }
@@ -60,6 +61,14 @@ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
60
61
  return { files: [...files], paths: [...paths] }
61
62
  }
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
+
63
72
  // Immutable report hashes share a derivative across duplicate uploads. Bundle
64
73
  // identity and visibility are part of the key: a broader viewer's sources must
65
74
  // never populate a restricted response. Group derivatives by hash and filename
@@ -89,8 +98,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
89
98
  const details = await readBundleDetails(bundle, bundles)
90
99
  if (!details) return false
91
100
  const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
92
- const imports = [...bundleSourceImports(details, new Map(selection.files))].map(([parent, targets]) => [parent, [...targets]])
93
- const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection, imports })), { level: 6 })
101
+ const body = await encodeSources(bundle.integrity, details, selection)
94
102
  // A duplicate with the same hash AND format can use these parsed bytes.
95
103
  // Another format must not keep a deleted variant's late build alive.
96
104
  if (!(await referenced(report, bundle))) return false
@@ -128,6 +136,8 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
128
136
  async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
129
137
  const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
130
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 }
131
141
  if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
132
142
  const opened = await storage.open(target)
133
143
  return { ...opened, repo }
@@ -2,6 +2,7 @@ import type { ManagedConfig } from './config.ts'
2
2
  import type { SelectedRepo } from './db.ts'
3
3
  import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
4
  import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
+ import { readSolidityEntryPoints } from './solidity-entry-points.ts'
5
6
 
6
7
  export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
7
8
 
@@ -96,6 +97,9 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
96
97
  const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
97
98
  return {
98
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,
99
103
  async refs() {
100
104
  // Suggestions are bounded; the input also accepts any branch or tag name.
101
105
  const results = await Promise.all(['branches', 'tags'].map(async kind => {
@@ -126,7 +130,10 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
126
130
  const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
127
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))
128
132
  const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
129
- return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}) }
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
+ }
130
137
  },
131
138
  }
132
139
  }
@@ -65,7 +65,7 @@ export class RepositoryDiscovery {
65
65
  // Cache the two sources separately so failed installation discovery is
66
66
  // retried without caching a degraded combined list for another minute.
67
67
  const [userRepos, installedRepos] = await Promise.all([
68
- this.read(key, () => listUserRepos(token, this.fetchImpl)),
68
+ this.read(key, () => listUserRepos(token)),
69
69
  installed().catch((err: unknown) => {
70
70
  console.warn('managed: installed repo deduplication failed:', err)
71
71
  return []
@@ -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
+ }