@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34
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/client/linked-findings.js +5 -4
- package/common/bundle-metadata.js +4 -3
- package/common/bundle-source-links.d.ts +2 -1
- package/common/bundle-source-links.js +133 -6
- package/common/bundle-sources.d.ts +2 -0
- package/common/bundle-sources.js +2 -4
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/routes.js +20 -1
- package/common/managed/triage.ts +2 -2
- package/out/client-managed-import.js +51 -46
- package/out/client-managed.js +252 -72
- package/out/client-sync.js +14 -14
- package/out/graph.js +11 -11
- package/out/index.html +1 -0
- package/out/terminal.js +169 -169
- package/out/view.css +1 -1
- package/out/view.js +417 -201
- package/package.json +23 -17
- package/server-managed/activity.ts +15 -1
- package/server-managed/brotli.ts +3 -3
- package/server-managed/bundle-advisories.ts +8 -3
- package/server-managed/bundle-advisory-inventory.ts +27 -25
- package/server-managed/bundle-build-worker.js +45 -9
- package/server-managed/bundle-build.ts +35 -11
- package/server-managed/bundle-cache.ts +1 -1
- package/server-managed/bundle.ts +17 -1
- package/server-managed/config.ts +7 -2
- package/server-managed/db-methods.ts +170 -49
- package/server-managed/db-neon.ts +59 -4
- package/server-managed/db-schema.ts +12 -3
- package/server-managed/db.ts +9 -1
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/http-response.ts +7 -1
- package/server-managed/http.ts +248 -80
- package/server-managed/index.ts +4 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/managed-issues.ts +31 -3
- package/server-managed/management.ts +17 -4
- package/server-managed/object-storage-disk.ts +7 -3
- package/server-managed/object-storage-vercel.ts +6 -2
- package/server-managed/object-storage.ts +3 -2
- package/server-managed/public-workspace.ts +5 -2
- package/server-managed/report-sources.ts +6 -5
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/revisions.ts +2 -2
- package/server-managed/sql.ts +3 -2
- package/server-managed/storage-encryption.ts +72 -6
- package/server-managed/storage-maintenance.ts +64 -49
- package/server-managed/storage-payload.ts +11 -10
- package/server-managed/storage-stores.ts +3 -1
- package/server-managed/storage.ts +17 -0
- package/server-managed/team-feed.ts +3 -3
- package/server-managed/team-reports.ts +24 -15
- package/server-managed/upstream-cache.ts +73 -0
- package/server-managed/workspace-shares.ts +16 -12
|
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream'
|
|
|
4
4
|
import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
|
|
5
5
|
import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
|
|
6
6
|
import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
|
|
7
|
-
import { inspectStorageObject, storageOwner,
|
|
7
|
+
import { inspectStorageObject, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
|
|
8
8
|
|
|
9
9
|
const STORAGE_WRITE_MS = 180_000
|
|
10
10
|
export const STORAGE_UPLOAD_TTL_MS = 86_400_000
|
|
@@ -78,7 +78,7 @@ async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageK
|
|
|
78
78
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
79
79
|
const row = await db.getStorageRow(owner.type, owner.id)
|
|
80
80
|
if (!row) return null
|
|
81
|
-
if (!owner.cache &&
|
|
81
|
+
if (!owner.cache && !storageRowPaths(owner.type, row).includes(identity)) return null
|
|
82
82
|
if (owner.cache && !row.dataKey) return null
|
|
83
83
|
if (!owner.cache && !row.encrypted) state = (await db.getStorageEncryption())!
|
|
84
84
|
const stored = await raw.open(owner.cache ? encryptedCachePath(identity) : identity)
|
|
@@ -104,13 +104,78 @@ async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageK
|
|
|
104
104
|
}
|
|
105
105
|
// Only a pending row may read plaintext, and only the original upload.
|
|
106
106
|
// Verify first, then reopen that exact version before exposing bytes.
|
|
107
|
-
await verifyStoragePayload(owner.type, row, inspected.stream)
|
|
107
|
+
await verifyStoragePayload(owner.type, row, inspected.stream, undefined, identity)
|
|
108
108
|
const current = await openObjectVersion(raw, identity, stored.version)
|
|
109
109
|
if (current) return current
|
|
110
110
|
}
|
|
111
111
|
throw new Error('Storage changed repeatedly during read')
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
async function verifyReportConversion(db: StorageDb, identity: string, bytes: Buffer): Promise<string> {
|
|
115
|
+
const owner = storageOwner(identity)
|
|
116
|
+
if (owner?.type !== 'report' || owner.cache || identity !== `reports/${owner.id}.br`) throw new Error('Invalid report conversion')
|
|
117
|
+
const row = await db.getStorageRow('report', owner.id)
|
|
118
|
+
if (!row) throw new Error('Report deleted during compression')
|
|
119
|
+
// Representation changes must preserve the immutable upload hash.
|
|
120
|
+
await verifyStoragePayload('report', row, Readable.from([bytes]), undefined, identity)
|
|
121
|
+
return owner.id
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function finishReportConversion(raw: RawObjectStorage, db: StorageDb, id: string, encrypted: boolean): Promise<void> {
|
|
125
|
+
const row = await db.getStorageRow('report', id)
|
|
126
|
+
if (!row) { await raw.delete(`reports/${id}.br`); throw new Error('Report deleted during compression') }
|
|
127
|
+
// Conversion preserves the original row/key. Keep the original until the
|
|
128
|
+
// compressed PUT has succeeded, and mark encrypted only after removing it.
|
|
129
|
+
await raw.delete(`reports/${id}`)
|
|
130
|
+
if (encrypted) await db.markStorageEncrypted('report', id, row.dataKey!)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function existingReportConversion(raw: RawObjectStorage, identity: string, row: StorageRow, key: StorageKey | null,
|
|
134
|
+
encryptedMode: boolean): Promise<{ version: string; encrypted: boolean } | null> {
|
|
135
|
+
const stored = await raw.open(identity)
|
|
136
|
+
if (!stored) return null
|
|
137
|
+
const inspected = await inspectStorageObject(stored)
|
|
138
|
+
// Activation or a concurrent key allocation can win after the SQL read.
|
|
139
|
+
// Retry with fresh policy/row state before interpreting any ciphertext.
|
|
140
|
+
if (inspected.encrypted && (!encryptedMode || !row.dataKey)) {
|
|
141
|
+
inspected.stream.destroy()
|
|
142
|
+
return { version: stored.version, encrypted: true }
|
|
143
|
+
}
|
|
144
|
+
const source = inspected.encrypted ? await decryptOwned(inspected, key!, 'report', row, identity) : inspected
|
|
145
|
+
await verifyStoragePayload('report', row, source.stream, undefined, identity)
|
|
146
|
+
return { version: stored.version, encrypted: inspected.encrypted }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function publishReportConversion(raw: RawObjectStorage, identity: string, bytes: Buffer, row: StorageRow,
|
|
150
|
+
key: StorageKey | null, expected: string | null, encrypted: boolean): Promise<boolean> {
|
|
151
|
+
if (!encrypted) return raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS), expected)
|
|
152
|
+
const dataKey = unwrapDataKey(key!, 'report', row)
|
|
153
|
+
const source = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
|
|
154
|
+
try { return await raw.put(identity, source, AbortSignal.timeout(STORAGE_WRITE_MS), expected, bytes.length) }
|
|
155
|
+
finally { source.destroy(); dataKey.fill(0) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null, identity: string, bytes: Buffer,
|
|
159
|
+
mode: (fresh?: boolean) => Promise<StorageEncryptionState | null>): Promise<null> {
|
|
160
|
+
const id = await verifyReportConversion(db, identity, bytes)
|
|
161
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
162
|
+
const state = await mode(true)
|
|
163
|
+
const row = state ? await db.ensureStorageDataKey('report', id) : await db.getStorageRow('report', id)
|
|
164
|
+
if (!row) throw new Error('Report deleted during compression')
|
|
165
|
+
const current = await existingReportConversion(raw, identity, row, key, Boolean(state))
|
|
166
|
+
if (current?.encrypted && (!state || !row.dataKey)) continue
|
|
167
|
+
// A stale plaintext writer must never replace a concurrent encrypted
|
|
168
|
+
// winner. Retain the original on uncertain writes; the next open resumes.
|
|
169
|
+
if ((!current || state && !current.encrypted) &&
|
|
170
|
+
!await publishReportConversion(raw, identity, bytes, row, key, current?.version ?? null, Boolean(state))) continue
|
|
171
|
+
if (!state && await mode(true)) continue
|
|
172
|
+
await raw.sync?.(identity)
|
|
173
|
+
await finishReportConversion(raw, db, id, Boolean(state))
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
throw new Error('Report changed repeatedly during compression')
|
|
177
|
+
}
|
|
178
|
+
|
|
114
179
|
// Startup enables encryption before constructing stores; validate the persisted
|
|
115
180
|
// requirement even when this adapter is opened without a key.
|
|
116
181
|
async function storagePolicy(db: StorageDb, key: StorageKey | null) {
|
|
@@ -156,17 +221,18 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
|
|
|
156
221
|
const owner = storageOwner(identity), state = await mode()
|
|
157
222
|
if (state && owner) {
|
|
158
223
|
const row = await db.getStorageRow(owner.type, owner.id)
|
|
159
|
-
if (!row || (owner.cache ? !row.dataKey :
|
|
224
|
+
if (!row || (owner.cache ? !row.dataKey : !storageRowPaths(owner.type, row).includes(identity))) return false
|
|
160
225
|
}
|
|
161
226
|
// Existence is metadata only; open() authenticates the actual bytes.
|
|
162
227
|
return await raw.head(state && owner?.cache ? encryptedCachePath(identity) : identity) !== null
|
|
163
228
|
},
|
|
164
|
-
async put(identity, bytes) {
|
|
229
|
+
async put(identity, bytes, { replaceReport = false } = {}) {
|
|
165
230
|
logicalKey(identity)
|
|
231
|
+
if (replaceReport) return convertStoredReport(raw, db, key, identity, bytes, mode)
|
|
166
232
|
const state = await mode()
|
|
233
|
+
const owner = storageOwner(identity)
|
|
167
234
|
if (identity.startsWith('avatars/')) { await raw.put(identity, bytes); return null }
|
|
168
235
|
if (!state) return putPlaintext(raw, identity, bytes, () => mode(true))
|
|
169
|
-
const owner = storageOwner(identity)
|
|
170
236
|
let dataKey: Buffer, wrapped: string | null = null
|
|
171
237
|
if (owner?.cache) {
|
|
172
238
|
const row = await db.ensureStorageDataKey(owner.type, owner.id)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
1
2
|
import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../server-common/storage-crypto.ts'
|
|
2
3
|
import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
|
|
3
|
-
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
|
|
4
|
+
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, type StorageRow, type StorageRowKind, unwrapDataKey } from './storage-db.ts'
|
|
4
5
|
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
5
|
-
import { StoragePayloadError, inspectStorageObject, storageOwner,
|
|
6
|
+
import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPaths, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
|
|
6
7
|
|
|
7
8
|
async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
|
|
8
9
|
const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
|
|
@@ -21,63 +22,77 @@ async function replacementFailure(raw: RawObjectStorage, identity: string, versi
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
type MigrationPhase = 'prepare-key' | 'open-payload' | 'verify-existing' | 'encrypt-upload' | 'verify-replacement' | 'checkpoint'
|
|
25
|
+
async function migratePayload(raw: RawObjectStorage, db: StorageDb, type: StorageRowKind, row: StorageRow,
|
|
26
|
+
dataKey: Buffer, identity: string, signal: AbortSignal, progress: (phase: MigrationPhase) => void): Promise<boolean | string | null> {
|
|
27
|
+
progress('open-payload')
|
|
28
|
+
const source = await raw.open(identity, signal)
|
|
29
|
+
if (!source) return false
|
|
30
|
+
let inspected = await inspectStorageObject(source)
|
|
31
|
+
if (inspected.encrypted) {
|
|
32
|
+
progress('verify-existing')
|
|
33
|
+
// A previous PUT may have completed before a timeout or lost SQL ack.
|
|
34
|
+
try {
|
|
35
|
+
const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
|
|
36
|
+
await verifyStoragePayload(type, row, decoded.stream, signal, identity)
|
|
37
|
+
} catch {
|
|
38
|
+
signal.throwIfAborted()
|
|
39
|
+
// Legacy arbitrary bytes can share the magic prefix. A matching
|
|
40
|
+
// original upload hash is required before encrypting them as plaintext.
|
|
41
|
+
const legacy = await openObjectVersion(raw, identity, source.version, signal)
|
|
42
|
+
if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
|
|
43
|
+
inspected = { ...legacy, encrypted: false }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!inspected.encrypted) {
|
|
47
|
+
progress('encrypt-upload')
|
|
48
|
+
signal.throwIfAborted()
|
|
49
|
+
const verified = verifiedStoragePayload(type, row, inspected.stream, signal, identity)
|
|
50
|
+
const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
|
|
51
|
+
let replaced: boolean
|
|
52
|
+
try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
|
|
53
|
+
// A provider may wrap a request-body error; keep safe payload diagnostics.
|
|
54
|
+
catch (err) { throw verified.errored ?? err }
|
|
55
|
+
finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
|
|
56
|
+
if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
|
|
57
|
+
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
58
|
+
// match the original upload (including Brotli decompression).
|
|
59
|
+
progress('verify-replacement')
|
|
60
|
+
const replacement = await raw.open(identity, signal)
|
|
61
|
+
if (!replacement) {
|
|
62
|
+
if (!await db.getStorageRow(type, row.id)) return null
|
|
63
|
+
throw new StoragePayloadError('Migrated payload unavailable')
|
|
64
|
+
}
|
|
65
|
+
const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
|
|
66
|
+
await verifyStoragePayload(type, row, decoded.stream, signal, identity)
|
|
67
|
+
}
|
|
68
|
+
signal.throwIfAborted()
|
|
69
|
+
// Persist observed replacements, including both report representations
|
|
70
|
+
// if they coexist, before marking the whole row encrypted.
|
|
71
|
+
progress('checkpoint')
|
|
72
|
+
await raw.sync?.(identity, signal)
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
24
76
|
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal,
|
|
25
77
|
progress: (phase: MigrationPhase) => void): Promise<string | void> {
|
|
26
78
|
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
27
79
|
progress('prepare-key')
|
|
28
80
|
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
29
81
|
if (!row || row.encrypted) return
|
|
30
|
-
const dataKey = unwrapDataKey(key, item.type, row)
|
|
82
|
+
const dataKey = unwrapDataKey(key, item.type, row)
|
|
31
83
|
try {
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
84
|
+
let found = false
|
|
85
|
+
for (const identity of storageRowPaths(item.type, row)) {
|
|
86
|
+
const result = await migratePayload(raw, db, item.type, row, dataKey, identity, signal, progress)
|
|
87
|
+
if (result === null) return
|
|
88
|
+
if (typeof result === 'string') return result
|
|
89
|
+
found ||= result
|
|
90
|
+
}
|
|
91
|
+
if (!found) {
|
|
35
92
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
36
93
|
throw new StoragePayloadError('Migration payload unavailable')
|
|
37
94
|
}
|
|
38
|
-
let inspected = await inspectStorageObject(source)
|
|
39
|
-
if (inspected.encrypted) {
|
|
40
|
-
progress('verify-existing')
|
|
41
|
-
// A previous PUT may have completed before a timeout or lost SQL ack.
|
|
42
|
-
try {
|
|
43
|
-
const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
|
|
44
|
-
await verifyStoragePayload(item.type, row, decoded.stream, signal)
|
|
45
|
-
} catch {
|
|
46
|
-
signal.throwIfAborted()
|
|
47
|
-
// Legacy arbitrary bytes can share the magic prefix. A matching
|
|
48
|
-
// original upload hash is required before encrypting them as plaintext.
|
|
49
|
-
const legacy = await openObjectVersion(raw, identity, source.version, signal)
|
|
50
|
-
if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
|
|
51
|
-
inspected = { ...legacy, encrypted: false }
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (!inspected.encrypted) {
|
|
55
|
-
progress('encrypt-upload')
|
|
56
|
-
signal.throwIfAborted()
|
|
57
|
-
const verified = verifiedStoragePayload(item.type, row, inspected.stream, signal)
|
|
58
|
-
const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
|
|
59
|
-
let replaced: boolean
|
|
60
|
-
try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
|
|
61
|
-
// A provider may wrap a request-body error; keep safe payload diagnostics.
|
|
62
|
-
catch (err) { throw verified.errored ?? err }
|
|
63
|
-
finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
|
|
64
|
-
if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
|
|
65
|
-
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
66
|
-
// match the original upload (including decompression for sourcemaps).
|
|
67
|
-
progress('verify-replacement')
|
|
68
|
-
const replacement = await raw.open(identity, signal)
|
|
69
|
-
if (!replacement) {
|
|
70
|
-
if (!await db.getStorageRow(item.type, item.id)) return
|
|
71
|
-
throw new StoragePayloadError('Migrated payload unavailable')
|
|
72
|
-
}
|
|
73
|
-
const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
|
|
74
|
-
await verifyStoragePayload(item.type, row, decoded.stream, signal)
|
|
75
|
-
}
|
|
76
95
|
signal.throwIfAborted()
|
|
77
|
-
// Another process may have renamed ciphertext but stopped before syncing
|
|
78
|
-
// the directory. Persist the observed rename even on the resume path.
|
|
79
|
-
progress('checkpoint')
|
|
80
|
-
await raw.sync?.(identity, signal)
|
|
81
96
|
await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
|
|
82
97
|
} finally { dataKey.fill(0) }
|
|
83
98
|
}
|
|
@@ -116,7 +131,7 @@ async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: Storag
|
|
|
116
131
|
const { owner, temporary } = target
|
|
117
132
|
if (!temporary && !owner.cache) {
|
|
118
133
|
const row = await db.getStorageRow(owner.type, owner.id)
|
|
119
|
-
if (row &&
|
|
134
|
+
if (row && storageRowPaths(owner.type, row).includes(object.key)) continue
|
|
120
135
|
}
|
|
121
136
|
const stored = await raw.head(object.key, signal)
|
|
122
137
|
if (!stored) continue
|
|
@@ -11,13 +11,14 @@ import type { StorageRow, StorageRowKind } from './storage-db.ts'
|
|
|
11
11
|
// not fix it. Keep this distinct from database, storage and cancellation errors.
|
|
12
12
|
export class StoragePayloadError extends Error {}
|
|
13
13
|
|
|
14
|
-
export function
|
|
15
|
-
return
|
|
14
|
+
export function storageRowPaths(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string[] {
|
|
15
|
+
return type === 'report' ? [`reports/${row.id}.br`, `reports/${row.id}`]
|
|
16
|
+
: [`bundles/${row.id}${row.kind === 'sourcemap' ? '.map.br' : ''}`]
|
|
16
17
|
}
|
|
17
18
|
export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
|
|
18
19
|
const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
|
|
19
20
|
if (match) {
|
|
20
|
-
const id = match[1] === 'bundles' ?
|
|
21
|
+
const id = match[2]!.replace(match[1] === 'bundles' ? /\.map\.br$/u : /\.br$/u, '')
|
|
21
22
|
if (isBlobId(id)) return { type: match[1] === 'reports' ? 'report' : 'bundle', id, cache: false }
|
|
22
23
|
}
|
|
23
24
|
const cache = /^cache\/(?:bundles|report-sources)\/([a-f\d-]{36})\//u.exec(identity)
|
|
@@ -50,20 +51,20 @@ export async function inspectStorageObject(stored: RawObject): Promise<RawObject
|
|
|
50
51
|
return { ...stored, stream, encrypted: length === prefix.length && prefix.equals(STORAGE_MAGIC) }
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
//
|
|
54
|
+
// Report hashes and sourcemap integrity describe the original upload, before Brotli storage.
|
|
54
55
|
// Hash incrementally, including decompression, so migration stays streaming.
|
|
55
|
-
export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Promise<void> {
|
|
56
|
+
export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Promise<void> {
|
|
56
57
|
const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
|
|
57
58
|
const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
|
|
58
59
|
const options = signal ? { signal } : {}
|
|
59
60
|
try {
|
|
60
|
-
if (type === 'bundle' && row.kind === 'sourcemap') {
|
|
61
|
+
if (type === 'bundle' && row.kind === 'sourcemap' || type === 'report' && identity?.endsWith('.br')) {
|
|
61
62
|
await pipeline(source, createBrotliDecompress(), sink, options)
|
|
62
63
|
} else await pipeline(source, sink, options)
|
|
63
64
|
} catch (err) {
|
|
64
65
|
const code = (err as NodeJS.ErrnoException).code ?? ''
|
|
65
66
|
if (['Z_DATA_ERROR', 'Z_BUF_ERROR'].includes(code) || code.startsWith('ERR__ERROR_FORMAT_')) {
|
|
66
|
-
throw new StoragePayloadError(
|
|
67
|
+
throw new StoragePayloadError(`Stored ${type === 'report' ? 'report' : 'sourcemap'} cannot be decompressed`, { cause: err })
|
|
67
68
|
}
|
|
68
69
|
throw err
|
|
69
70
|
}
|
|
@@ -73,10 +74,10 @@ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow
|
|
|
73
74
|
|
|
74
75
|
// Forward the stored representation with backpressure, but withhold EOF until
|
|
75
76
|
// its upload hash passes. Encryption cannot emit its final frame, and an atomic
|
|
76
|
-
// PUT cannot commit, until verification (including
|
|
77
|
-
export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Readable {
|
|
77
|
+
// PUT cannot commit, until verification (including Brotli decompression) ends.
|
|
78
|
+
export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Readable {
|
|
78
79
|
const input = new PassThrough(), output = new PassThrough()
|
|
79
|
-
const verification = verifyStoragePayload(type, row, input, signal)
|
|
80
|
+
const verification = verifyStoragePayload(type, row, input, signal, identity)
|
|
80
81
|
// Propagate asynchronous decompression errors even if the source is stalled.
|
|
81
82
|
void verification.catch(err => { source.destroy(err); output.destroy(err) })
|
|
82
83
|
const forwarding = pipeline(source, async function* (chunks) {
|
|
@@ -5,6 +5,7 @@ import type { AvatarStore } from './avatar-store.ts'
|
|
|
5
5
|
import type { BlobStore } from './blob-store.ts'
|
|
6
6
|
import type { BundleCacheStorage } from './bundle-cache.ts'
|
|
7
7
|
import { createBundleStore } from './bundle-store.ts'
|
|
8
|
+
import { createReportStore } from './report-store.ts'
|
|
8
9
|
import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
9
10
|
import { type ObjectStorage, isBlobId } from './object-storage.ts'
|
|
10
11
|
|
|
@@ -71,7 +72,8 @@ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boole
|
|
|
71
72
|
delete: value => bundles.delete(id(value)),
|
|
72
73
|
}
|
|
73
74
|
return {
|
|
74
|
-
reportStore: blobStore(objects, 'reports'),
|
|
75
|
+
reportStore: createReportStore(blobStore(objects, 'reports'), blobStore(objects, 'reports', '.br'),
|
|
76
|
+
(value, bytes) => objects.put(`reports/${id(value)}.br`, bytes, { replaceReport: true })),
|
|
75
77
|
bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
|
|
76
78
|
uploadStore: blobStore(objects, 'uploads'),
|
|
77
79
|
avatarStore: avatarStore(objects, avatarSidecar),
|
|
@@ -34,6 +34,23 @@ export async function openManagedStorage(config: ManagedConfig) {
|
|
|
34
34
|
}
|
|
35
35
|
const objects = await createEncryptedObjectStorage(raw, db, key)
|
|
36
36
|
const storage = createManagedStores(objects, !config.neonUrl)
|
|
37
|
+
// Move legacy team link uploads into encrypted SQL before serving catalogs.
|
|
38
|
+
// The DB transaction commits first; a failed blob cleanup cannot lose links.
|
|
39
|
+
for (const report of await db.listLegacyLinkReports()) {
|
|
40
|
+
if (!key) throw new Error('Migrating managed link reports requires MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
41
|
+
let bytes
|
|
42
|
+
try { bytes = await storage.reportStore.get(report.id) } catch (error) {
|
|
43
|
+
if (!await db.getReport(report.id)) continue // another instance completed migration
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
if (!bytes) {
|
|
47
|
+
if (!await db.getReport(report.id)) continue
|
|
48
|
+
throw new Error(`Missing legacy link report: ${report.id}`)
|
|
49
|
+
}
|
|
50
|
+
if (await db.migrateLinkReport(report.id, bytes.toString('utf8'))) {
|
|
51
|
+
await storage.reportStore.delete(report.id).catch(error => console.warn('managed: legacy link cleanup failed:', error))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
37
54
|
return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
|
|
38
55
|
bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
|
|
39
56
|
reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
|
|
@@ -47,7 +47,7 @@ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
47
47
|
// the focused team needs report parsing or annotation reads; null is the
|
|
48
48
|
// catalog-only subscription used on landing and management pages.
|
|
49
49
|
export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
|
|
50
|
-
sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
|
|
50
|
+
sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions & { reportId?: string | null } = {}): Promise<void> {
|
|
51
51
|
let ids: string[] = [], visibilityKey: string | undefined
|
|
52
52
|
let focusedKey: string | undefined
|
|
53
53
|
let previousState: { catalog: number; annotations: number } | undefined
|
|
@@ -86,7 +86,7 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
|
|
|
86
86
|
return
|
|
87
87
|
}
|
|
88
88
|
let triage: string | undefined
|
|
89
|
-
const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
89
|
+
const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
|
|
90
90
|
if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
|
|
91
91
|
checkUser(snapshot)
|
|
92
92
|
const key = teamSnapshotKey(snapshot)
|
|
@@ -102,7 +102,7 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
|
|
105
|
-
const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
105
|
+
const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
|
|
106
106
|
// A concurrent access/content change discards only the annotation read.
|
|
107
107
|
checkUser(current)
|
|
108
108
|
if (!current || teamSnapshotKey(current) !== key) return
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Team content is filtered as one workspace, with separate report envelopes.
|
|
2
2
|
// Bounded snapshots retain filtered content and visibility for repeated reads.
|
|
3
|
+
import { mergeLinkGroups } from './link-reports.ts'
|
|
3
4
|
import { Buffer } from 'node:buffer'
|
|
4
5
|
import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
|
|
5
6
|
import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
|
|
@@ -7,6 +8,7 @@ import { filterReportData, projectFinding } from '../common/managed/report-filte
|
|
|
7
8
|
import type { BlobStore } from './blob-store.ts'
|
|
8
9
|
import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
|
|
9
10
|
import { triageWireEntry } from './triage-response.ts'
|
|
11
|
+
import { visibleManagedIssues } from './managed-issues.ts'
|
|
10
12
|
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
11
13
|
|
|
12
14
|
type Finding = Record<string, unknown>
|
|
@@ -54,16 +56,17 @@ export class TeamReportsError extends Error {
|
|
|
54
56
|
constructor(status: number, error: string) { super(error); this.status = status }
|
|
55
57
|
}
|
|
56
58
|
export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
|
|
57
|
-
return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.reports, snapshot.repositories])
|
|
59
|
+
return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
|
|
58
60
|
}
|
|
59
|
-
export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string): Promise<TeamReportAccessSnapshot> {
|
|
60
|
-
const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
61
|
+
export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot> {
|
|
62
|
+
const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, reportId)
|
|
61
63
|
if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
|
|
62
64
|
if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
|
|
65
|
+
if (reportId !== null && !snapshot.reports.some(report => report.id === reportId)) throw new TeamReportsError(404, 'no-report')
|
|
63
66
|
return snapshot
|
|
64
67
|
}
|
|
65
68
|
export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
|
|
66
|
-
if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId
|
|
69
|
+
if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!, snapshot.reportId)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
|
|
67
70
|
}
|
|
68
71
|
function groupsOf(data: unknown): Finding[][] {
|
|
69
72
|
return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
|
|
@@ -100,6 +103,7 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
|
|
|
100
103
|
}
|
|
101
104
|
}))
|
|
102
105
|
for (const result of results) if (result.status === 'rejected') throw result.reason
|
|
106
|
+
const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
|
|
103
107
|
const edges = new Map<string, Set<string>>()
|
|
104
108
|
const connect = (a: string, b: string) => {
|
|
105
109
|
if (!edges.has(a)) edges.set(a, new Set())
|
|
@@ -107,10 +111,8 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
|
|
|
107
111
|
}
|
|
108
112
|
// A star is enough for transitive security propagation, without a quadratic
|
|
109
113
|
// expansion for large links. Unknown IDs can connect known findings.
|
|
110
|
-
for (const
|
|
111
|
-
for (const
|
|
112
|
-
for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
|
|
113
|
-
}
|
|
114
|
+
for (const ids of globalLinks.concat(reports.flatMap(report => linksOf(report.data) ?? []))) {
|
|
115
|
+
for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
|
|
114
116
|
}
|
|
115
117
|
const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
|
|
116
118
|
stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
|
|
@@ -130,7 +132,9 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
|
|
|
130
132
|
}
|
|
131
133
|
for (const report of reports) {
|
|
132
134
|
const links = linksOf(report.data)
|
|
133
|
-
|
|
135
|
+
// Hidden previews are independently authorized; preserve a hidden links
|
|
136
|
+
// document even though its referenced findings are outside this response.
|
|
137
|
+
if (links && !snapshot.hiddenReportId) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
|
|
134
138
|
}
|
|
135
139
|
let outputBytes = 14
|
|
136
140
|
const parts = [Buffer.from('{"reports":[')]
|
|
@@ -141,7 +145,10 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
|
|
|
141
145
|
if (index) parts.push(Buffer.from(','))
|
|
142
146
|
parts.push(bytes)
|
|
143
147
|
}
|
|
144
|
-
|
|
148
|
+
const links = mergeLinkGroups(globalLinks, allIds)
|
|
149
|
+
const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
|
|
150
|
+
if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
151
|
+
parts.push(suffix)
|
|
145
152
|
const body = Buffer.concat(parts)
|
|
146
153
|
retainVisibility(db, teamSnapshotKey(snapshot), visible)
|
|
147
154
|
retainReports(db, teamSnapshotKey(snapshot), body)
|
|
@@ -190,13 +197,13 @@ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snap
|
|
|
190
197
|
return visible.get(reportId)!
|
|
191
198
|
}
|
|
192
199
|
export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
|
|
193
|
-
const snapshot = await teamSnapshot(db, sessionId, teamId)
|
|
200
|
+
const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
|
|
194
201
|
const visible = await teamReportVisibility(db, store, snapshot, reportId)
|
|
195
202
|
await recheckTeam(db, sessionId, snapshot)
|
|
196
203
|
return visible.ids
|
|
197
204
|
}
|
|
198
205
|
export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
|
|
199
|
-
const snapshot = await teamSnapshot(db, sessionId, teamId)
|
|
206
|
+
const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
|
|
200
207
|
const visible = await teamReportVisibility(db, store, snapshot, reportId)
|
|
201
208
|
await recheckTeam(db, sessionId, snapshot)
|
|
202
209
|
return visible.sourcePaths
|
|
@@ -211,9 +218,11 @@ export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snaps
|
|
|
211
218
|
: new Map([[reportId, await teamReportVisibility(db, store, snapshot, reportId)]])
|
|
212
219
|
const ids = new Set<string>()
|
|
213
220
|
for (const report of visible.values()) for (const id of report.ids) ids.add(id)
|
|
214
|
-
const { triage, comments } = await db.getAnnotations([...ids])
|
|
221
|
+
const { triage, comments, issues: savedIssues } = await db.getAnnotations([...ids])
|
|
222
|
+
const issues = Object.fromEntries(visibleManagedIssues(savedIssues, snapshot)
|
|
223
|
+
.map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl }]))
|
|
215
224
|
const entries = Object.fromEntries(triage.map(row => [row.findingId, triageWireEntry(row)]))
|
|
216
|
-
const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId)])
|
|
225
|
+
const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId), ...Object.keys(issues)])
|
|
217
226
|
const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))
|
|
218
|
-
return { reports, entries, comments }
|
|
227
|
+
return { reports, entries, comments, issues }
|
|
219
228
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CacheStore } from '@preventive/upstream/advisories.js'
|
|
2
|
+
import type { ManagedSql } from './sql.ts'
|
|
3
|
+
|
|
4
|
+
// Records @preventive/upstream would otherwise keep in its disk cache, shared
|
|
5
|
+
// by every instance: each repository's published advisory listing, written by
|
|
6
|
+
// dependency audits that ask repositories, keyed `github/advisories/owner/name`.
|
|
7
|
+
// Upstream stamps, validates and expires each value (90 minutes for a listing);
|
|
8
|
+
// a refresh replaces the row, and there is one row per repository, so rows are
|
|
9
|
+
// not evicted. Only public repositories publish advisories: rows are shared
|
|
10
|
+
// across viewers.
|
|
11
|
+
export const UPSTREAM_CACHE_SCHEMA = `
|
|
12
|
+
CREATE TABLE IF NOT EXISTS managed_upstream_cache (
|
|
13
|
+
cache_key TEXT PRIMARY KEY,
|
|
14
|
+
value TEXT NOT NULL,
|
|
15
|
+
cached_at INTEGER NOT NULL
|
|
16
|
+
) STRICT;
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
export interface UpstreamCacheStore {
|
|
20
|
+
getUpstreamCacheEntry(key: string): Promise<string | null>
|
|
21
|
+
setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
|
|
25
|
+
const get = db.prepare('SELECT value FROM managed_upstream_cache WHERE cache_key = ?')
|
|
26
|
+
// A slower concurrent audit cannot replace a newer listing with an older one.
|
|
27
|
+
const set = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES (?, ?, ?)
|
|
28
|
+
ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
|
|
29
|
+
WHERE managed_upstream_cache.cached_at <= excluded.cached_at`)
|
|
30
|
+
return {
|
|
31
|
+
async getUpstreamCacheEntry(key) {
|
|
32
|
+
const row = await get.get(key) as { value: string } | undefined
|
|
33
|
+
return row?.value ?? null
|
|
34
|
+
},
|
|
35
|
+
async setUpstreamCacheEntry(key, value, cachedAt) {
|
|
36
|
+
await set.run(key, value, cachedAt)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// An audit's `cache`: where the server has an upstream disk cache (off Vercel,
|
|
42
|
+
// set at startup), that keeps everything; else this store keeps listings.
|
|
43
|
+
export function auditCache(cacheDir: string | null | undefined, db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore | undefined {
|
|
44
|
+
return cacheDir ? undefined : upstreamCache(db, signal, debug)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Only listings are kept: upstream's other records (npm version documents,
|
|
48
|
+
// registries' repository lookups) read as misses and are not written.
|
|
49
|
+
const CACHED_TYPE = 'github/advisories'
|
|
50
|
+
|
|
51
|
+
// The cache is an optimization: a database failure is a miss, never a failed
|
|
52
|
+
// audit. Upstream keeps listing after an abandoned audit; once `signal` aborts,
|
|
53
|
+
// the request's database work is over and the store is left alone.
|
|
54
|
+
export function upstreamCache(db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore {
|
|
55
|
+
return {
|
|
56
|
+
async read(type, key) {
|
|
57
|
+
if (type !== CACHED_TYPE || signal.aborted) return null
|
|
58
|
+
try {
|
|
59
|
+
const value = await db.getUpstreamCacheEntry(`${type}/${key}`)
|
|
60
|
+
return value === null ? null : JSON.parse(value) as unknown
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (debug) console.warn('managed: upstream cache read failed:', error)
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
async write(type, key, value) {
|
|
67
|
+
if (type !== CACHED_TYPE || signal.aborted) return
|
|
68
|
+
try { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()) } catch (error) {
|
|
69
|
+
if (debug) console.warn('managed: upstream cache write failed:', error)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|