@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
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.26",
3
+ "version": "1.0.0-alpha.28",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
- "license": "MIT",
5
+ "license": "MIT OR Apache-2.0",
6
6
  "author": {
7
7
  "name": "Nikita Skovoroda",
8
8
  "email": "chalkerx@gmail.com",
@@ -30,7 +30,6 @@
30
30
  },
31
31
  "exports": {
32
32
  "./reap": "./api/reap.ts",
33
- "./report": "./report/index.js",
34
33
  "./server": "./server.ts",
35
34
  "./server-e2e": "./server-e2e/index.ts",
36
35
  "./strip-types-loader": "./strip-types-loader.js",
@@ -42,6 +41,8 @@
42
41
  "triage-managed-server": "server-managed/cli.js"
43
42
  },
44
43
  "files": [
44
+ "LICENSE-APACHE",
45
+ "LICENSE-MIT",
45
46
  "api/reap.ts",
46
47
  "cli.js",
47
48
  "server.ts",
@@ -89,9 +90,13 @@
89
90
  "server-managed/activity.ts",
90
91
  "server-managed/avatar-store.ts",
91
92
  "server-managed/blob-store.ts",
92
- "server-managed/blob-vercel.ts",
93
93
  "server-managed/brotli.ts",
94
+ "server-managed/bundle-advisories.ts",
95
+ "server-managed/bundle-advisory-inventory.ts",
94
96
  "server-managed/bundle-cache.ts",
97
+ "server-managed/bundle-build.ts",
98
+ "server-managed/bundle-build-leases.ts",
99
+ "server-managed/bundle-build-worker.js",
95
100
  "server-managed/bundle-catalog.ts",
96
101
  "server-managed/bundle-store.ts",
97
102
  "server-managed/bundle-summary-cache.ts",
@@ -120,6 +125,10 @@
120
125
  "server-managed/import-triage.ts",
121
126
  "server-managed/managed-issues.ts",
122
127
  "server-managed/management.ts",
128
+ "server-managed/management-catalog.ts",
129
+ "server-managed/object-storage.ts",
130
+ "server-managed/object-storage-disk.ts",
131
+ "server-managed/object-storage-vercel.ts",
123
132
  "server-managed/index.ts",
124
133
  "server-managed/package-entry-points.ts",
125
134
  "server-managed/public-workspace.ts",
@@ -128,15 +137,23 @@
128
137
  "server-managed/report-query.ts",
129
138
  "server-managed/report-response.ts",
130
139
  "server-managed/report-sources.ts",
140
+ "server-managed/revisions.ts",
131
141
  "server-managed/repository-browser.ts",
132
142
  "server-managed/repository-discovery.ts",
133
143
  "server-managed/repository-policy.ts",
134
144
  "server-managed/session.ts",
135
145
  "server-managed/slugs.ts",
146
+ "server-managed/solidity-entry-points.ts",
136
147
  "server-managed/sql-postgres.ts",
137
148
  "server-managed/sql.ts",
138
149
  "server-managed/static.ts",
139
150
  "server-managed/storage.ts",
151
+ "server-managed/storage-command.ts",
152
+ "server-managed/storage-db.ts",
153
+ "server-managed/storage-encryption.ts",
154
+ "server-managed/storage-maintenance.ts",
155
+ "server-managed/storage-payload.ts",
156
+ "server-managed/storage-stores.ts",
140
157
  "server-managed/team-catalog.ts",
141
158
  "server-managed/team-feed.ts",
142
159
  "server-managed/team-reports.ts",
@@ -150,30 +167,9 @@
150
167
  "server-common/origin.ts",
151
168
  "server-common/standalone.ts",
152
169
  "server-common/storage-log.ts",
170
+ "server-common/storage-crypto.ts",
153
171
  "server-common/vercel-blob.ts",
154
172
  "server-common/scan-config.ts",
155
- "report/index.js",
156
- "report/src/finding-id.js",
157
- "report/src/finding.js",
158
- "report/src/severity.js",
159
- "report/src/security.js",
160
- "report/src/labels.js",
161
- "report/src/md-structure.js",
162
- "report/src/md-text.js",
163
- "report/src/meta.js",
164
- "report/src/parse-codex.js",
165
- "report/src/parse-deepsec.js",
166
- "report/src/parse-deepview-fields.js",
167
- "report/src/parse-deepview-md.js",
168
- "report/src/parse-md-id.js",
169
- "report/src/parse-md.js",
170
- "report/src/parse-piolium-id.js",
171
- "report/src/parse-piolium-rows.js",
172
- "report/src/parse-piolium-tokens.js",
173
- "report/src/parse-piolium.js",
174
- "report/src/utf8.js",
175
- "report/src/write-md-finding.js",
176
- "report/src/write-md.js",
177
173
  "common/bundle-metadata.d.ts",
178
174
  "common/bundle-metadata.js",
179
175
  "common/bundle-reasons.d.ts",
@@ -228,7 +224,7 @@
228
224
  ],
229
225
  "scripts": {
230
226
  "lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
231
- "lint:js": "oxlint -c .oxlintrc.json --ignore-pattern=ui/logs.js ./cli.js ./server.ts ./tests/ ./common/ ./report/ ./server-common/ ./server-e2e/ ./server-managed/ ./client/ ./ui/ ./api/",
227
+ "lint:js": "oxlint -c .oxlintrc.json --ignore-pattern=ui/logs.js ./cli.js ./server.ts ./tests/ ./common/ ./server-common/ ./server-e2e/ ./server-managed/ ./client/ ./ui/ ./api/",
232
228
  "lint:css": "stylelint 'ui/**/*.css'",
233
229
  "lint:ts": "tsc --noEmit",
234
230
  "prepack": "node --run build",
@@ -238,12 +234,13 @@
238
234
  "server-e2e": "node server-e2e/index.ts",
239
235
  "server-managed": "node server-managed/index.ts",
240
236
  "server-managed-test": "node server-managed/test-server.ts",
241
- "test": "node --js-base-64 --import=./tests/_resource-loader.js --experimental-test-module-mocks --test-timeout=120000 --test ./tests/*.test.js ./report/tests/*.test.js"
237
+ "test": "node --js-base-64 --import=./tests/_resource-loader.js --experimental-test-module-mocks --test-timeout=120000 --test ./tests/*.test.js"
242
238
  },
243
239
  "devDependencies": {
244
240
  "@electric-sql/pglite": "^0.5.5",
245
241
  "@noble/ciphers": "^2.3.0",
246
242
  "@preventive/diff": "^1.0.0",
243
+ "@preventive/lockfile": "1.0.0-alpha.5",
247
244
  "@preventive/terminal": "2.0.0-beta.1",
248
245
  "@rray/frontend": "^1.0.0",
249
246
  "@stylistic/stylelint-plugin": "^5.3.0",
@@ -260,7 +257,10 @@
260
257
  "typescript": "^7.0.2"
261
258
  },
262
259
  "dependencies": {
263
- "@exodus/stasis-core": "^1.0.0-beta.4",
260
+ "@exodus/stasis": "1.0.0-beta.5",
261
+ "@exodus/stasis-core": "1.0.0-beta.5",
262
+ "@preventive/report": "1.0.0-alpha.4",
263
+ "@preventive/upstream": "1.0.0-alpha.4",
264
264
  "ws": "^8.20.1"
265
265
  },
266
266
  "peerDependencies": {
@@ -0,0 +1,182 @@
1
+ // Storage envelope v1: header, then 64 KiB plaintext chunks with full AEAD tags.
2
+ // The nonce layout follows age's STREAM construction (counter || final flag).
3
+ // Every write derives a new key, including rewrites of the same logical object.
4
+ import { Buffer } from 'node:buffer'
5
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from 'node:crypto'
6
+ import { Readable } from 'node:stream'
7
+
8
+ export const STORAGE_MAGIC = Buffer.from('DeepView.storage')
9
+ const VERSION = 1
10
+ export const STORAGE_CHUNK_BYTES = 65_536
11
+ export const STORAGE_HEADER_BYTES = 57
12
+ const TAG_BYTES = 16
13
+ const UNKNOWN_SIZE = 0xffff_ffff_ffff_ffffn
14
+
15
+ export interface StorageKey { bytes: Buffer; id: string }
16
+
17
+ export function parseStorageKey(value: string | null | undefined): StorageKey | null {
18
+ if (value == null || value === '') return null
19
+ const bytes = Buffer.from(value, 'base64')
20
+ if (bytes.length !== 32 || bytes.toString('base64') !== value) {
21
+ throw new Error('MANAGED_STORAGE_ENCRYPTION_KEY must be canonical base64 encoding of 32 random bytes')
22
+ }
23
+ const id = createHash('sha256').update('deepview.storage.key.v1\0').update(bytes).digest('hex').slice(0, 32)
24
+ return { bytes, id }
25
+ }
26
+
27
+ // Re-chunk without accumulating a file, regardless of the input's read sizes.
28
+ async function* chunks(source: AsyncIterable<Uint8Array>, size: number): AsyncGenerator<Buffer> {
29
+ let length = 0, pending: Buffer[] = []
30
+ for await (const value of source) {
31
+ const input = Buffer.from(value.buffer, value.byteOffset, value.byteLength)
32
+ for (let offset = 0; offset < input.length;) {
33
+ const n = Math.min(size - length, input.length - offset)
34
+ pending.push(input.subarray(offset, offset + n)); length += n; offset += n
35
+ if (length === size) { yield Buffer.concat(pending, length); pending = []; length = 0 }
36
+ }
37
+ }
38
+ if (length) yield Buffer.concat(pending, length)
39
+ }
40
+
41
+ async function* frames(source: AsyncIterable<Uint8Array>, size: number) {
42
+ const iterator = chunks(source, size)[Symbol.asyncIterator]()
43
+ try {
44
+ let current = await iterator.next()
45
+ if (current.done) { yield { bytes: Buffer.alloc(0), last: true }; return }
46
+ while (!current.done) {
47
+ const next = await iterator.next()
48
+ yield { bytes: current.value, last: next.done === true }
49
+ current = next
50
+ }
51
+ } finally { await iterator.return(undefined) }
52
+ }
53
+
54
+ function nonce(index: number, last: boolean): Buffer {
55
+ if (!Number.isSafeInteger(index) || index < 0 || index > 0xffff_ffff) throw new Error('Storage object has too many chunks')
56
+ const value = Buffer.alloc(12)
57
+ value.writeUInt32BE(index, 7)
58
+ value[11] = last ? 1 : 0
59
+ return value
60
+ }
61
+
62
+ function objectKey(key: Uint8Array, header: Buffer, identity: string): Buffer {
63
+ return Buffer.from(hkdfSync('sha256', key, header.subarray(17, 49),
64
+ Buffer.from(JSON.stringify(['deepview.storage.v1', identity])), 32))
65
+ }
66
+
67
+ function makeHeader(size: number | null): Buffer {
68
+ if (size !== null && (!Number.isSafeInteger(size) || size < 0)) throw new Error('Invalid storage size')
69
+ const header = Buffer.alloc(STORAGE_HEADER_BYTES)
70
+ STORAGE_MAGIC.copy(header); header[16] = VERSION
71
+ randomBytes(32).copy(header, 17)
72
+ header.writeBigUInt64BE(size === null ? UNKNOWN_SIZE : BigInt(size), 49)
73
+ return header
74
+ }
75
+
76
+ export function encryptStorageStream(source: Readable, key: Uint8Array, identity: string, size: number | null): Readable {
77
+ async function* encrypt() {
78
+ const header = makeHeader(size)
79
+ const derived = objectKey(key, header, identity)
80
+ let index = 0, length = 0
81
+ try {
82
+ yield header
83
+ for await (const { bytes, last } of frames(source, STORAGE_CHUNK_BYTES)) {
84
+ length += bytes.length
85
+ if (last && size !== null && length !== size) throw new Error('Storage size changed while encrypting')
86
+ const cipher = createCipheriv('chacha20-poly1305', derived, nonce(index++, last), { authTagLength: TAG_BYTES })
87
+ cipher.setAAD(header)
88
+ yield Buffer.concat([cipher.update(bytes), cipher.final(), cipher.getAuthTag()])
89
+ }
90
+ } finally { derived.fill(0); source.destroy() }
91
+ }
92
+ const stream = Readable.from(encrypt(), { objectMode: false })
93
+ stream.once('close', () => source.destroy())
94
+ return stream
95
+ }
96
+
97
+ // Read just the envelope and authenticate the first frame before returning.
98
+ // In particular, never trust a corrupted size or release update() output
99
+ // before final() authenticates the complete chunk.
100
+ export async function decryptStorageStream(source: Readable, key: Uint8Array, identity: string) {
101
+ const iterator = source[Symbol.asyncIterator]()
102
+ let done = false
103
+ let peek: Buffer = Buffer.alloc(0), remainder: Buffer = Buffer.alloc(0)
104
+ async function read(size: number): Promise<Buffer> {
105
+ const parts: Buffer[] = peek.length > 0 ? [peek] : []
106
+ let length = peek.length
107
+ peek = Buffer.alloc(0)
108
+ while (length < size) {
109
+ if (remainder.length === 0 && !done) {
110
+ const next = await iterator.next(); done = next.done === true
111
+ remainder = done ? Buffer.alloc(0) : next.value
112
+ }
113
+ if (remainder.length === 0 && done) break
114
+ const n = Math.min(size - length, remainder.length)
115
+ parts.push(remainder.subarray(0, n)); remainder = remainder.subarray(n); length += n
116
+ }
117
+ return Buffer.concat(parts, length)
118
+ }
119
+ let derived: Buffer | undefined
120
+ try {
121
+ const header = await read(STORAGE_HEADER_BYTES)
122
+ if (header.length !== STORAGE_HEADER_BYTES || !header.subarray(0, 16).equals(STORAGE_MAGIC) || header[16] !== VERSION) {
123
+ throw new Error('Invalid encrypted storage envelope')
124
+ }
125
+ const rawSize = header.readBigUInt64BE(49)
126
+ if (rawSize !== UNKNOWN_SIZE && rawSize > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Invalid encrypted storage size')
127
+ const size = rawSize === UNKNOWN_SIZE ? null : Number(rawSize)
128
+ derived = objectKey(key, header, identity)
129
+ const derivedKey = derived
130
+ let index = 0, length = 0
131
+ const nextFrame = async () => {
132
+ const bytes = await read(STORAGE_CHUNK_BYTES + TAG_BYTES)
133
+ // Peek beyond the frame: EOF authenticates the final flag, including
134
+ // exact chunk boundaries. Keep the byte for the next read.
135
+ peek = await read(1)
136
+ const last = peek.length === 0
137
+ if (bytes.length < TAG_BYTES || (index > 0 && bytes.length === TAG_BYTES)) throw new Error('Truncated encrypted storage object')
138
+ const decipher = createDecipheriv('chacha20-poly1305', derivedKey, nonce(index++, last), { authTagLength: TAG_BYTES })
139
+ decipher.setAAD(header); decipher.setAuthTag(bytes.subarray(-TAG_BYTES))
140
+ const plain = Buffer.concat([decipher.update(bytes.subarray(0, -TAG_BYTES)), decipher.final()])
141
+ length += plain.length
142
+ if (size !== null && (length > size || (last && length !== size))) throw new Error('Encrypted storage size mismatch')
143
+ return { plain, last }
144
+ }
145
+ const first = await nextFrame()
146
+ const decrypt = async function* () {
147
+ try {
148
+ yield first.plain
149
+ let last = first.last
150
+ while (!last) { const frame = await nextFrame(); last = frame.last; yield frame.plain }
151
+ } finally { derivedKey.fill(0); await iterator.return?.(); source.destroy() }
152
+ }
153
+ const stream = Readable.from(decrypt(), { objectMode: false })
154
+ stream.once('close', () => { derivedKey.fill(0); source.destroy() })
155
+ return { size, stream }
156
+ } catch (err) { derived?.fill(0); source.destroy(); throw err }
157
+ }
158
+
159
+ // Small SQL values (data keys, OAuth tokens and the installation sentinel).
160
+ // Fresh salt derives a one-use wrapping key, so the nonce can be all zeroes.
161
+ // The version and master-key ID are authenticated along with the row/field.
162
+ export function wrapStorageValue(key: StorageKey, identity: string, value: Uint8Array): string {
163
+ const header = Buffer.concat([Buffer.from([1]), Buffer.from(key.id, 'hex'), randomBytes(32)])
164
+ const derived = Buffer.from(hkdfSync('sha256', key.bytes, header.subarray(17), 'deepview.wrap.v1', 32))
165
+ try {
166
+ const cipher = createCipheriv('chacha20-poly1305', derived, Buffer.alloc(12), { authTagLength: TAG_BYTES })
167
+ cipher.setAAD(Buffer.concat([header, Buffer.from(identity)]))
168
+ return Buffer.concat([header, cipher.update(value), cipher.final(), cipher.getAuthTag()]).toString('base64')
169
+ } finally { derived.fill(0) }
170
+ }
171
+
172
+ export function unwrapStorageValue(key: StorageKey, identity: string, value: string): Buffer {
173
+ const bytes = Buffer.from(value, 'base64'), header = bytes.subarray(0, 49)
174
+ if (bytes.length < 65 || bytes.toString('base64') !== value || header[0] !== 1
175
+ || header.subarray(1, 17).toString('hex') !== key.id) throw new Error('Invalid wrapped storage value or encryption key')
176
+ const derived = Buffer.from(hkdfSync('sha256', key.bytes, header.subarray(17), 'deepview.wrap.v1', 32))
177
+ try {
178
+ const decipher = createDecipheriv('chacha20-poly1305', derived, Buffer.alloc(12), { authTagLength: TAG_BYTES })
179
+ decipher.setAAD(Buffer.concat([header, Buffer.from(identity)])); decipher.setAuthTag(bytes.subarray(-TAG_BYTES))
180
+ return Buffer.concat([decipher.update(bytes.subarray(49, -TAG_BYTES)), decipher.final()])
181
+ } finally { derived.fill(0) }
182
+ }
@@ -19,11 +19,15 @@ export function e2eStorageLines(config: DatabaseConfig & { objstoreDir: string }
19
19
  ]
20
20
  }
21
21
 
22
- export function managedStorageLines(config: DatabaseConfig): string[] {
22
+ export function managedStorageLines(config: DatabaseConfig & { storageEncryptionKey?: string | null }): string[] {
23
23
  const dir = dirname(config.dbPath)
24
24
  const location = (path: string) => config.neonUrl ? `Vercel Blob (private), .managed/${path}/` : resolve(dir, path)
25
25
  return [
26
26
  ` Managed database: ${databaseLocation(config)}`,
27
+ ...(config.storageEncryptionKey ? [
28
+ ' Managed storage encryption: ChaCha20-Poly1305',
29
+ ` Managed encrypted caches: ${location('cache-encrypted-v1')}`,
30
+ ] : []),
27
31
  ` Managed reports: ${location('reports')}`,
28
32
  ` Managed bundles: ${location('bundles')}`,
29
33
  ` Managed avatars: ${location('avatars')}`,
@@ -18,6 +18,7 @@ import type { Buffer } from 'node:buffer'
18
18
  type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
19
19
  export type VercelBlobSdk = {
20
20
  BlobNotFoundError: new () => Error
21
+ BlobPreconditionFailedError?: new () => Error
21
22
  put: (
22
23
  pathname: string,
23
24
  body: VercelBlobBody,
@@ -27,6 +28,7 @@ export type VercelBlobSdk = {
27
28
  allowOverwrite?: boolean
28
29
  contentType?: string
29
30
  token?: string
31
+ ifMatch?: string
30
32
  multipart?: boolean
31
33
  abortSignal?: AbortSignal
32
34
  cacheControlMaxAge?: number
@@ -35,7 +37,7 @@ export type VercelBlobSdk = {
35
37
  head: (
36
38
  pathname: string,
37
39
  options?: { token?: string; abortSignal?: AbortSignal },
38
- ) => Promise<{ size: number; pathname: string; url: string }>
40
+ ) => Promise<{ size: number; pathname: string; url: string; etag?: string; uploadedAt?: Date | string | number }>
39
41
  get: (
40
42
  pathname: string,
41
43
  options: {
@@ -47,7 +49,7 @@ export type VercelBlobSdk = {
47
49
  ) => Promise<{
48
50
  statusCode: 200 | 304
49
51
  stream: ReadableStream<Uint8Array> | null
50
- blob: { size: number | null }
52
+ blob: { size: number | null; etag?: string; uploadedAt?: Date | string | number }
51
53
  } | null>
52
54
  copy: (
53
55
  fromPathname: string,
@@ -63,7 +65,7 @@ export type VercelBlobSdk = {
63
65
  ) => Promise<{ url: string; pathname: string }>
64
66
  del: (
65
67
  urlOrPathname: string | string[],
66
- options?: { token?: string; abortSignal?: AbortSignal },
68
+ options?: { token?: string; abortSignal?: AbortSignal; ifMatch?: string },
67
69
  ) => Promise<void>
68
70
  list: (options: {
69
71
  prefix?: string
@@ -71,6 +73,7 @@ export type VercelBlobSdk = {
71
73
  limit?: number
72
74
  mode?: 'expanded' | 'folded'
73
75
  token?: string
76
+ abortSignal?: AbortSignal
74
77
  }) => Promise<{
75
78
  // `uploadedAt` (a Date per the SDK v2 surface) is the blob's
76
79
  // creation time — used by the reaper's GC grace window. Optional
@@ -194,13 +194,12 @@ function buildEntry(staticDir: string, name: string, deepviewScanServer: string
194
194
  // collision-resistance against the ~tens of files we load.
195
195
  const etag = `"${createHash('sha256').update(identity).digest('hex').slice(0, 32)}"`
196
196
  const isCompressible = COMPRESSIBLE.has(ext)
197
- // Brotli quality 11 is the maximum — slow at compress time but
198
- // we pay it once at boot and ship the smaller bytes on every
199
- // request thereafter. text mode tells the encoder we're working
197
+ // Brotli quality 9 limits boot-time compression work, including on
198
+ // serverless cold starts. Text mode tells the encoder we're working
200
199
  // on UTF-8 text (every COMPRESSIBLE entry is text-shaped).
201
200
  const br = isCompressible ? brotliCompressSync(identity, {
202
201
  params: {
203
- [zlibConstants.BROTLI_PARAM_QUALITY]: 11,
202
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 9,
204
203
  [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
205
204
  },
206
205
  }) : null
@@ -1,12 +1,4 @@
1
- // Avatar cache for the managed server. The user's GitHub avatar is fetched once
2
- // at login (see github-oauth.ts) and served same-origin from GET
3
- // /api/auth/avatar — the app's CSP is `img-src 'self'`, so the GitHub-hosted
4
- // original can't be loaded by the page directly.
5
- //
6
- // The disk backend uses a dir beside SQLite; blob-vercel.ts implements the
7
- // same AvatarStore interface for remote storage. Keyed by the user's opaque `id`.
8
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
9
- import { dirname, join } from 'node:path'
1
+ // Shared avatar cache interface, implemented by storage-stores.ts.
10
2
  import type { Buffer } from 'node:buffer'
11
3
 
12
4
  export interface CachedAvatar {
@@ -18,34 +10,3 @@ export interface AvatarStore {
18
10
  put(id: string, contentType: string, bytes: Buffer): Promise<void>
19
11
  get(id: string): Promise<CachedAvatar | null>
20
12
  }
21
-
22
- // The user id is a v4 uuid (crypto.randomUUID) — lowercase hex + dashes, no
23
- // path separators. Validated anyway so a crafted id can't escape the store dir.
24
- const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
25
-
26
- export function createDiskAvatarStore(dir: string): AvatarStore {
27
- function pathFor(id: string): string {
28
- if (!ID_RE.test(id)) throw new Error('invalid avatar id')
29
- return join(dir, id)
30
- }
31
- return {
32
- async put(id, contentType, bytes) {
33
- const p = pathFor(id)
34
- await mkdir(dirname(p), { recursive: true })
35
- // Bytes first, then the content-type sidecar — a crash between the two
36
- // leaves a typeless blob that `get` treats as missing (re-fetched next
37
- // login) rather than serving with a wrong/absent type.
38
- await writeFile(p, bytes)
39
- await writeFile(`${p}.type`, contentType, 'utf8')
40
- },
41
- async get(id) {
42
- try {
43
- const p = pathFor(id)
44
- const [bytes, contentType] = await Promise.all([readFile(p), readFile(`${p}.type`, 'utf8')])
45
- return { bytes, contentType: contentType.trim() || 'application/octet-stream' }
46
- } catch {
47
- return null
48
- }
49
- },
50
- }
51
- }
@@ -1,14 +1,4 @@
1
- // Disk byte-store for the managed server's stored blobs (uploaded reports and
2
- // bundles). Unlike the e2e relay (which only ever holds opaque client-encrypted
3
- // blobs), a managed server is TRUSTED and stores the bytes in the clear so it
4
- // can operate on them — the metadata + attribution live in the DB, the bytes
5
- // live here, keyed by the blob's opaque `id`.
6
- //
7
- // The disk backend uses a dir beside SQLite (data/reports/<uuid> or
8
- // data/bundles/<uuid>); blob-vercel.ts implements the same interface remotely. Bytes only — the
9
- // content-type / filename / integrity ride the DB row, so there's no sidecar.
10
- import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises'
11
- import { dirname, join } from 'node:path'
1
+ // Logical upload-store interfaces shared by disk and Vercel storage.
12
2
  import type { Buffer } from 'node:buffer'
13
3
  import type { Readable } from 'node:stream'
14
4
 
@@ -19,48 +9,9 @@ export interface OpenedBlob {
19
9
  }
20
10
 
21
11
  export interface BlobStore {
22
- put(id: string, bytes: Buffer): Promise<void>
12
+ // Encrypted upload stores return the wrapped data key for the SQL insert.
13
+ put(id: string, bytes: Buffer): Promise<string | null>
23
14
  get(id: string): Promise<Buffer | null>
24
15
  open(id: string): Promise<OpenedBlob | null>
25
16
  delete(id: string): Promise<void>
26
17
  }
27
-
28
- // The blob id is a v4 uuid (crypto.randomUUID) — lowercase hex + dashes, no
29
- // path separators. Validated anyway so a crafted id can't escape the store dir.
30
- const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
31
-
32
- export function createDiskBlobStore(dir: string, suffix = ''): BlobStore {
33
- function pathFor(id: string): string {
34
- if (!ID_RE.test(id)) throw new Error('invalid blob id')
35
- return join(dir, id + suffix)
36
- }
37
- return {
38
- async put(id, bytes) {
39
- const p = pathFor(id)
40
- await mkdir(dirname(p), { recursive: true })
41
- await writeFile(p, bytes)
42
- },
43
- async get(id) {
44
- try {
45
- return await readFile(pathFor(id))
46
- } catch {
47
- return null
48
- }
49
- },
50
- async open(id) {
51
- let file
52
- try { file = await open(pathFor(id), 'r') }
53
- catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err }
54
- try {
55
- const info = await file.stat()
56
- // Remain paused until piped. HEAD can close without reading the body.
57
- return { size: info.size, stream: file.createReadStream() }
58
- } catch (err) { await file.close(); throw err }
59
- },
60
- async delete(id) {
61
- // `force` so a missing file (already gone) is a no-op, not a throw — the
62
- // metadata row is the source of truth for whether a blob "exists".
63
- await rm(pathFor(id), { force: true })
64
- },
65
- }
66
- }
@@ -0,0 +1,53 @@
1
+ import { type Advisory, type Package, advisories } from '@preventive/upstream/advisories.js'
2
+ import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
3
+
4
+ export const ADVISORIES_TIMEOUT_MS = 30_000
5
+
6
+ function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
7
+ const authenticated = createClient({ token, userAgent: 'deepview-triage' })
8
+ if (token === null) return authenticated
9
+ let client = authenticated
10
+ return {
11
+ ...authenticated,
12
+ async listRepoAdvisories(options) {
13
+ const current = client
14
+ try { return await current.listRepoAdvisories(options) } catch (error) {
15
+ if (current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
16
+ signal.throwIfAborted()
17
+ // Published advisories remain readable after a token is revoked. Reuse
18
+ // anonymous access for subsequent repositories in this audit as well.
19
+ if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
20
+ return client.listRepoAdvisories(options)
21
+ }
22
+ },
23
+ }
24
+ }
25
+
26
+ // The managed caller supplies its user's token when available; public shares
27
+ // remain anonymous. Upstream sends this credential only to GitHub and returns
28
+ // published advisories, matching versions across npm, OSV and repository rows.
29
+ export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
30
+ { debug = false, repoAdvisories = false, githubToken = null }: { debug?: boolean; repoAdvisories?: boolean; githubToken?: string | null } = {}): Promise<
31
+ { status: 200; body: Advisory[] } | { status: 502; body: { error: string } }
32
+ > {
33
+ let onAbort: (() => void) | undefined
34
+ try {
35
+ signal.throwIfAborted()
36
+ if (packages.length === 0) return { status: 200, body: [] }
37
+ // Upstream owns each transport's timeout and exposes no cancellation
38
+ // option. Bound the caller's wait for the multi-request audit as well.
39
+ const deadline = new Promise<never>((_resolve, reject) => {
40
+ onAbort = () => reject(signal.reason)
41
+ signal.addEventListener('abort', onAbort, { once: true })
42
+ })
43
+ const result = await Promise.race([
44
+ advisories(packages, { github: advisoryGithubClient(githubToken, signal), repoAdvisories }), deadline,
45
+ ])
46
+ return { status: 200, body: result }
47
+ } catch (error) {
48
+ if (debug) console.warn('managed: advisories failed:', error)
49
+ return { status: 502, body: { error: 'upstream-unavailable' } }
50
+ } finally {
51
+ if (onAbort) signal.removeEventListener('abort', onAbort)
52
+ }
53
+ }
@@ -0,0 +1,75 @@
1
+ import type { Package } from '@preventive/upstream/advisories.js'
2
+ import { satisfies, valid } from '@preventive/upstream/semver.js'
3
+ import type { BundleDetails } from '../common/bundle-metadata.js'
4
+
5
+ export interface SkippedAdvisoryPackage {
6
+ ecosystem: string
7
+ name: string
8
+ version: string
9
+ because: string
10
+ }
11
+ export interface BundleAdvisoryInventory { packages: Package[]; skipped: SkippedAdvisoryPackage[] }
12
+ interface BundleModule { ecosystem?: string; name?: string; version?: string; files: Record<string, unknown> }
13
+
14
+ // Match Stasis's audit evidence rules, including their verified version bounds:
15
+ // https://github.com/PreventiveMeasures/stasis/blob/2bd4c14354da9888c00ab45fa89740413099b5dc/stasis/src/audit-corrections.js
16
+ // Keep this on the server: upstream's semver implementation uses Node's npm.
17
+ const cargoManifests = ['Cargo.toml', 'Cargo.lock', '.cargo-checksum.json']
18
+ const solidityManifests = ['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']
19
+ const manifests = new Map([
20
+ ['npm', ['package.json']],
21
+ ['cargo', cargoManifests], ['cargo-git', cargoManifests], ['cargo-unknown', cargoManifests],
22
+ ['composer', ['composer.json', 'composer.lock']],
23
+ ['soldeer', solidityManifests], ['github', solidityManifests],
24
+ ])
25
+ const browserStubRanges = new Map([['ws', '<=8.21.1'], ['node-fetch', '<=2.7.0']])
26
+
27
+ function isEvidence(ecosystem: string, name: string, version: string, file: string): boolean {
28
+ if (manifests.get(ecosystem)?.includes(file.slice(file.lastIndexOf('/') + 1))) return false
29
+ const stubRange = ecosystem === 'npm' && file === 'browser.js' ? browserStubRanges.get(name) : undefined
30
+ return !stubRange || !valid(version) || !satisfies(version, stubRange)
31
+ }
32
+
33
+ function supported(ecosystem: string): ecosystem is Package['ecosystem'] {
34
+ return ['npm', 'cargo', 'composer', 'soldeer', 'github'].includes(ecosystem)
35
+ }
36
+
37
+ function skipReason(ecosystem: string, version: string): string | undefined {
38
+ if (ecosystem === 'cargo-git') return 'Crate vendored from git; its identity is not a crates.io package.'
39
+ if (ecosystem === 'cargo-unknown') return 'Crate source is unknown because .cargo-checksum.json is missing.'
40
+ if (ecosystem === 'composer' && /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))) return 'Composer dev versions cannot be matched against release advisories.'
41
+ return undefined
42
+ }
43
+
44
+ // Package and reason presence both require a recorded evidence file. Entry and
45
+ // manually added files count even when they have no incoming import edge.
46
+ export function bundleAdvisoryInventory(details: BundleDetails, paths: Iterable<string> | null = null): BundleAdvisoryInventory {
47
+ const modules = (details.bundle as { modules?: ReadonlyMap<string, BundleModule> } | undefined)?.modules
48
+ if (details.kind !== 'stasis' || !modules) return { packages: [], skipped: [] }
49
+ const selected = paths === null ? null : new Set(paths)
50
+ const packages = new Map<string, { ecosystem: Package['ecosystem']; name: string; versions: Set<string> }>()
51
+ const skipped = new Map<string, SkippedAdvisoryPackage>()
52
+ for (const [dir, info] of modules) {
53
+ if (dir === '.') continue
54
+ const ecosystem = info.ecosystem ?? (/(?:^|\/)node_modules\//u.test(dir) ? 'npm' : undefined)
55
+ const { name, version } = info
56
+ if (ecosystem === undefined || typeof name !== 'string' || !name || typeof version !== 'string' || !version) continue
57
+ if (!Object.keys(info.files).some(file => (!selected || selected.has(`${dir}/${file}`)) && isEvidence(ecosystem, name, version, file))) continue
58
+ const because = skipReason(ecosystem, version)
59
+ if (!supported(ecosystem) || because !== undefined) {
60
+ skipped.set(JSON.stringify([ecosystem, name, version]), { ecosystem, name, version, because: because ?? `No advisory source is supported for ${ecosystem}.` })
61
+ continue
62
+ }
63
+ // Foundry's branch '.' means the superproject's branch. As in Stasis,
64
+ // 0.0.0 tells upstream its actual version is unknown, so every range covers it.
65
+ const auditedVersion = ecosystem === 'github' && version === '.' ? '0.0.0' : version
66
+ const key = JSON.stringify([ecosystem, ecosystem === 'github' ? name.toLowerCase() : name])
67
+ if (!packages.has(key)) packages.set(key, { ecosystem, name, versions: new Set() })
68
+ packages.get(key)!.versions.add(auditedVersion)
69
+ }
70
+ const byPackage = (a: { ecosystem: string; name: string }, b: { ecosystem: string; name: string }) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name)
71
+ return {
72
+ packages: [...packages.values()].map(pkg => ({ ...pkg, versions: [...pkg.versions].toSorted() })).toSorted(byPackage),
73
+ skipped: [...skipped.values()].toSorted((a, b) => byPackage(a, b) || a.version.localeCompare(b.version)),
74
+ }
75
+ }