@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.
- package/LICENSE-APACHE +202 -0
- package/client/linked-findings.js +1 -1
- package/common/bundle-metadata.js +12 -10
- package/common/code-language.js +17 -2
- package/common/default-scan-models.ts +3 -3
- package/common/finding-id.js +1 -1
- package/common/managed/report-content.ts +1 -1
- package/common/managed/report-filter.ts +1 -1
- package/out/client-managed-import.js +24 -24
- package/out/client-managed.js +93 -68
- package/out/graph.js +11 -11
- package/out/index.html +1 -1
- package/out/prism.js +2 -2
- package/out/stasis.svg +0 -25
- package/out/terminal.js +163 -163
- package/out/view.css +1 -1
- package/out/view.js +223 -190
- package/package.json +29 -29
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +5 -1
- package/server-common/vercel-blob.ts +6 -3
- package/server-e2e/static.ts +3 -4
- package/server-managed/avatar-store.ts +1 -40
- package/server-managed/blob-store.ts +3 -52
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +44 -0
- package/server-managed/bundle-build.ts +141 -0
- package/server-managed/bundle-cache.ts +22 -41
- package/server-managed/bundle-store.ts +3 -7
- package/server-managed/bundle-summary-cache.ts +27 -9
- package/server-managed/bundle.ts +66 -1
- package/server-managed/cache-storage.ts +4 -25
- package/server-managed/cli.js +10 -2
- package/server-managed/config.ts +12 -1
- package/server-managed/content-access.ts +1 -1
- package/server-managed/db-methods.ts +163 -40
- package/server-managed/db-neon.ts +124 -30
- package/server-managed/db-schema.ts +2 -0
- package/server-managed/db.ts +11 -1
- package/server-managed/github-app.ts +10 -15
- package/server-managed/github-errors.ts +21 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/github-metadata.ts +34 -1
- package/server-managed/github-pulls.ts +142 -30
- package/server-managed/http.ts +278 -167
- package/server-managed/index.ts +26 -9
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +60 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/public-workspace.ts +20 -14
- package/server-managed/report-migration.ts +1 -1
- package/server-managed/report-sources.ts +14 -4
- package/server-managed/repository-browser.ts +8 -1
- package/server-managed/repository-discovery.ts +1 -1
- package/server-managed/revisions.ts +35 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +1 -0
- package/server-managed/sql.ts +14 -8
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +197 -0
- package/server-managed/storage-payload.ts +72 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +48 -24
- package/server-managed/team-feed.ts +50 -5
- package/server-managed/team-reports.ts +85 -22
- package/server-managed/uploads.ts +2 -2
- package/server-managed/workspace-shares.ts +15 -3
- package/report/index.js +0 -260
- package/report/src/finding-id.js +0 -80
- package/report/src/finding.js +0 -314
- package/report/src/labels.js +0 -35
- package/report/src/md-structure.js +0 -471
- package/report/src/md-text.js +0 -167
- package/report/src/meta.js +0 -76
- package/report/src/parse-codex.js +0 -149
- package/report/src/parse-deepsec.js +0 -199
- package/report/src/parse-deepview-fields.js +0 -375
- package/report/src/parse-deepview-md.js +0 -186
- package/report/src/parse-md-id.js +0 -137
- package/report/src/parse-md.js +0 -323
- package/report/src/parse-piolium-id.js +0 -79
- package/report/src/parse-piolium-rows.js +0 -126
- package/report/src/parse-piolium-tokens.js +0 -185
- package/report/src/parse-piolium.js +0 -399
- package/report/src/security.js +0 -63
- package/report/src/severity.js +0 -18
- package/report/src/utf8.js +0 -21
- package/report/src/write-md-finding.js +0 -273
- package/report/src/write-md.js +0 -291
- package/server-managed/blob-vercel.ts +0 -127
- /package/{LICENSE → LICENSE-MIT} +0 -0
package/server-managed/index.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
-
|
|
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 =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
112
|
+
const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
|
|
107
113
|
await recheck()
|
|
108
|
-
if (
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 ? {
|
|
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 '
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|