@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/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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/ ./
|
|
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
|
|
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
|
|
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
|
package/server-e2e/static.ts
CHANGED
|
@@ -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
|
|
198
|
-
//
|
|
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]:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|