@preventive/triage 1.0.0-alpha.23 → 1.0.0-alpha.26
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/common/server-info.ts +1 -0
- package/out/client-managed-import.js +41 -41
- package/out/client-managed.js +79 -63
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +187 -186
- package/package.json +4 -1
- package/report/index.js +7 -1
- package/report/src/finding-id.js +1 -1
- package/report/src/finding.js +6 -4
- package/report/src/labels.js +3 -1
- package/report/src/parse-codex.js +3 -1
- package/report/src/parse-deepsec.js +3 -1
- package/report/src/parse-deepview-md.js +2 -1
- package/report/src/parse-md.js +3 -2
- package/report/src/parse-piolium-rows.js +7 -12
- package/report/src/parse-piolium-tokens.js +12 -2
- package/report/src/parse-piolium.js +7 -8
- package/report/src/severity.js +18 -0
- package/server-common/vercel-blob.ts +7 -18
- package/server-e2e/config.ts +4 -0
- package/server-e2e/objstore/blob-vercel.ts +4 -4
- package/server-managed/blob-vercel.ts +10 -8
- package/server-managed/config.ts +2 -1
- package/server-managed/db-methods.ts +41 -14
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/http.ts +112 -90
- package/server-managed/import-triage.ts +4 -1
- package/server-managed/index.ts +30 -8
- package/server-managed/management.ts +86 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +1 -11
- package/server-managed/report-query.ts +6 -0
- package/server-managed/repository-browser.ts +6 -1
- package/server-managed/sql.ts +5 -0
- package/server-managed/uploads.ts +28 -10
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
3
|
+
|
|
4
|
+
export const MAX_PACKAGE_BYTES = 256 * 1024
|
|
5
|
+
const MAX_ENTRY_POINTS = 100
|
|
6
|
+
|
|
7
|
+
// Declared paths, not a Node resolver: never guess defaults, expand export
|
|
8
|
+
// patterns, or execute package code. Keep every suggestion in its package.
|
|
9
|
+
export function packageEntryPoints(manifest: unknown, directory: string): string[] {
|
|
10
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []
|
|
11
|
+
const pkg = manifest as { main?: unknown; exports?: unknown; bin?: unknown }
|
|
12
|
+
const paths = new Set<string>()
|
|
13
|
+
const add = (value: unknown) => {
|
|
14
|
+
if (typeof value !== 'string' || value.startsWith('/') || value.endsWith('/') || /[*?]|^[a-z][a-z\d+.-]*:/iu.test(value)) return
|
|
15
|
+
const normalized = normalizeTeamPath(value)
|
|
16
|
+
if (!normalized.ok || !normalized.path || normalized.path === 'package.json') return
|
|
17
|
+
const path = directory ? `${directory}/${normalized.path}` : normalized.path
|
|
18
|
+
if (path.length <= 500 && paths.size < MAX_ENTRY_POINTS) paths.add(path)
|
|
19
|
+
}
|
|
20
|
+
add(pkg.main)
|
|
21
|
+
const pending = [pkg.exports]
|
|
22
|
+
for (let visited = 0; pending.length > 0 && visited < 4096; visited++) {
|
|
23
|
+
const value = pending.pop()
|
|
24
|
+
if (typeof value === 'string') { if (value.startsWith('./')) add(value); continue }
|
|
25
|
+
if (Array.isArray(value)) pending.push(...value.slice(0, 4096 - visited).toReversed())
|
|
26
|
+
else if (value && typeof value === 'object') {
|
|
27
|
+
const entries = Object.entries(value).filter(([key]) => key !== 'types' && !key.startsWith('types@'))
|
|
28
|
+
pending.push(...entries.slice(0, 4096 - visited).toReversed().map(([, target]) => target))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (typeof pkg.bin === 'string') add(pkg.bin)
|
|
32
|
+
else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) Object.values(pkg.bin).forEach(add)
|
|
33
|
+
return [...paths]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use the immutable blob from the authorized listing, never download_url or a
|
|
37
|
+
// resolved symlink target. Malformed or oversized manifests are optional.
|
|
38
|
+
export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
|
|
39
|
+
try {
|
|
40
|
+
const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
|
|
41
|
+
if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
|
|
42
|
+
|| blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
|
|
43
|
+
const bytes = Buffer.from(blob.content, 'base64')
|
|
44
|
+
if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
|
|
45
|
+
return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
|
|
46
|
+
} catch {
|
|
47
|
+
// Suggestions must not prevent browsing. The handler still rechecks
|
|
48
|
+
// GitHub and managed access after this optional upstream read.
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -8,7 +8,6 @@ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
|
8
8
|
import { sendJson } from './http-response.ts'
|
|
9
9
|
import { hashToken } from './crypto.ts'
|
|
10
10
|
import { TeamReportsError, loadTeamReports, teamReportVisibility } from './team-reports.ts'
|
|
11
|
-
import { MAX_FINDING_ID, MAX_TRIAGE_HISTORY } from '../common/managed/triage.ts'
|
|
12
11
|
import { triageWireEntry } from './triage-response.ts'
|
|
13
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
14
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
@@ -30,7 +29,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
30
29
|
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
31
30
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
32
31
|
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
|
|
33
|
-
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|
|
|
32
|
+
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
34
33
|
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
35
34
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
36
35
|
const tokenHash = hashToken(token)
|
|
@@ -85,15 +84,6 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
85
84
|
await recheck()
|
|
86
85
|
if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
|
|
87
86
|
if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
|
|
88
|
-
if (part === 'triage/history') {
|
|
89
|
-
const finding = url.searchParams.get('finding') ?? ''
|
|
90
|
-
if (!finding || finding.length > MAX_FINDING_ID) { json(res, 400, { error: 'bad-request' }); return }
|
|
91
|
-
if (!visible.ids.has(finding)) { json(res, 404, { error: 'no-finding' }); return }
|
|
92
|
-
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map(row => ({
|
|
93
|
-
seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
94
|
-
}))
|
|
95
|
-
await send({ finding, events }); return
|
|
96
|
-
}
|
|
97
87
|
const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
|
|
98
88
|
await send({ entries }); return
|
|
99
89
|
}
|
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
// the encoded response, independently of the per-upload size limit.
|
|
3
3
|
export const MAX_REPORT_QUERY_COUNT = 4096
|
|
4
4
|
export const MAX_REPORT_QUERY_BYTES = 1024 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
// The import catalog scans a bounded page, not the whole installation. A
|
|
7
|
+
// report larger than the byte target occupies a page by itself; upload limits
|
|
8
|
+
// still bound that single report and the cursor must always make progress.
|
|
9
|
+
export const FINDING_CATALOG_PAGE_COUNT = 128
|
|
10
|
+
export const FINDING_CATALOG_PAGE_BYTES = 32 * 1024 * 1024
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ManagedConfig } from './config.ts'
|
|
2
2
|
import type { SelectedRepo } from './db.ts'
|
|
3
3
|
import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
|
+
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
4
5
|
|
|
5
6
|
export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
|
|
6
7
|
|
|
@@ -121,7 +122,11 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
121
122
|
const type = item.submodule_git_url ? 'submodule' : item.type
|
|
122
123
|
if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
+
const manifestPath = path ? `${path}/package.json` : 'package.json'
|
|
126
|
+
const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
|
|
127
|
+
&& typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
|
|
128
|
+
const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
|
|
129
|
+
return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}) }
|
|
125
130
|
},
|
|
126
131
|
}
|
|
127
132
|
}
|
package/server-managed/sql.ts
CHANGED
|
@@ -4,6 +4,11 @@ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
|
4
4
|
import type { ManagedDb } from './db-methods.ts'
|
|
5
5
|
|
|
6
6
|
type MaybePromise<T> = T | Promise<T>
|
|
7
|
+
// A failed COMMIT acknowledgement (or connection close after COMMIT) does not
|
|
8
|
+
// establish rollback. Callers must retain primary bytes until reconciled.
|
|
9
|
+
export class ManagedCommitError extends Error {
|
|
10
|
+
constructor(cause: unknown) { super('Managed commit outcome is uncertain', { cause }) }
|
|
11
|
+
}
|
|
7
12
|
export interface ManagedSql {
|
|
8
13
|
prepare(sql: string): {
|
|
9
14
|
get(...params: unknown[]): MaybePromise<unknown>
|
|
@@ -7,6 +7,8 @@ import type { IncomingMessage } from 'node:http'
|
|
|
7
7
|
import type { BlobStore } from './blob-store.ts'
|
|
8
8
|
|
|
9
9
|
export const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024
|
|
10
|
+
export const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024
|
|
11
|
+
const MAX_UPLOAD_PARTS = Math.ceil(MAX_UPLOAD_BYTES / UPLOAD_CHUNK_BYTES)
|
|
10
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
11
13
|
export type UploadKind = 'reports' | 'bundles'
|
|
12
14
|
|
|
@@ -16,7 +18,24 @@ function partId(session: string, kind: UploadKind, id: string, index: number): s
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export function validUploadPart(id: string, index: number, maxBytes: number): boolean {
|
|
19
|
-
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(maxBytes / UPLOAD_CHUNK_BYTES)
|
|
21
|
+
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(Math.min(maxBytes, MAX_UPLOAD_BYTES) / UPLOAD_CHUNK_BYTES)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validUpload(id: string, count: number): boolean {
|
|
25
|
+
return UUID.test(id) && Number.isSafeInteger(count) && count > 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Limit cleanup to indices that any configured server could have accepted,
|
|
29
|
+
// even if the request advertises an enormous count or the limit was lowered.
|
|
30
|
+
export async function deleteUpload(store: BlobStore, session: string, kind: UploadKind, id: string, count: number): Promise<void> {
|
|
31
|
+
if (!validUpload(id, count)) throw new Error('bad-upload')
|
|
32
|
+
const end = Math.min(count, MAX_UPLOAD_PARTS), errors = []
|
|
33
|
+
for (let start = 0; start < end; start += 8) {
|
|
34
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(8, end - start) },
|
|
35
|
+
(_, index) => store.delete(partId(session, kind, id, start + index))))
|
|
36
|
+
for (const result of results) if (result.status === 'rejected') errors.push(result.reason)
|
|
37
|
+
}
|
|
38
|
+
if (errors.length > 0) throw new AggregateError(errors, 'Upload cleanup failed')
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
export function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
|
|
@@ -26,14 +45,13 @@ export function putUploadPart(store: BlobStore, session: string, kind: UploadKin
|
|
|
26
45
|
export async function readUpload(store: BlobStore, req: IncomingMessage, session: string, kind: UploadKind, maxBytes: number): Promise<Buffer> {
|
|
27
46
|
const id = req.headers['x-upload-id']
|
|
28
47
|
const count = Number(req.headers['x-upload-parts']), size = Number(req.headers['x-upload-size'])
|
|
29
|
-
if (typeof id !== 'string' || !
|
|
30
|
-
|| !Number.isSafeInteger(count) || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
|
|
31
|
-
if (size > maxBytes) throw new Error('too-large')
|
|
32
|
-
const ids = Array.from({ length: count }, (_, index) => partId(session, kind, id, index))
|
|
33
|
-
const parts: Buffer[] = []
|
|
48
|
+
if (typeof id !== 'string' || !validUpload(id, count)) throw new Error('bad-upload')
|
|
34
49
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
if (!Number.isSafeInteger(size) || size <= 0 || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
|
|
51
|
+
if (size > maxBytes || size > MAX_UPLOAD_BYTES) throw new Error('too-large')
|
|
52
|
+
const parts: Buffer[] = []
|
|
53
|
+
for (let index = 0; index < count; index++) {
|
|
54
|
+
const bytes = await store.get(partId(session, kind, id, index))
|
|
37
55
|
const expected = Math.min(UPLOAD_CHUNK_BYTES, size - index * UPLOAD_CHUNK_BYTES)
|
|
38
56
|
if (!bytes || bytes.length !== expected) throw new Error('bad-upload')
|
|
39
57
|
parts.push(bytes)
|
|
@@ -41,7 +59,7 @@ export async function readUpload(store: BlobStore, req: IncomingMessage, session
|
|
|
41
59
|
return Buffer.concat(parts, size)
|
|
42
60
|
} finally {
|
|
43
61
|
// These bytes can no longer be finalized. Failed/abandoned chunk uploads
|
|
44
|
-
// are also covered by
|
|
45
|
-
|
|
62
|
+
// are also covered by automatic staging maintenance.
|
|
63
|
+
await deleteUpload(store, session, kind, id, count).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
46
64
|
}
|
|
47
65
|
}
|