@preventive/triage 1.0.0-alpha.25 → 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 +2 -2
- package/out/client-managed.js +65 -64
- package/out/prism.js +2 -2
- package/out/view.js +162 -162
- package/package.json +1 -1
- package/server-e2e/config.ts +4 -0
- package/server-managed/blob-vercel.ts +6 -4
- package/server-managed/config.ts +2 -1
- package/server-managed/db-methods.ts +15 -5
- package/server-managed/http.ts +56 -23
- package/server-managed/import-triage.ts +6 -3
- package/server-managed/index.ts +30 -8
- package/server-managed/report-query.ts +6 -0
- package/server-managed/uploads.ts +28 -10
package/package.json
CHANGED
package/server-e2e/config.ts
CHANGED
|
@@ -54,6 +54,10 @@ Environment:
|
|
|
54
54
|
DEEPVIEW_SCAN_SERVER optional HTTP(S) scan-service URL; advertised
|
|
55
55
|
in /api/config and allowed by the UI CSP.
|
|
56
56
|
Unset by default (no external scan service).
|
|
57
|
+
GITHUB_NEW_ISSUE_LABELS optional comma-separated labels for the client's
|
|
58
|
+
prefilled GitHub issue form, in addition to
|
|
59
|
+
deepview and security (for security findings).
|
|
60
|
+
Advertised in /api/config and sync connections.
|
|
57
61
|
DB_PATH sqlite file (default: server-e2e/data/data.db);
|
|
58
62
|
ignored when an e2e database URL is set
|
|
59
63
|
DATABASE_URL shared Neon Postgres URL for enabled modes.
|
|
@@ -100,11 +100,13 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
|
|
|
100
100
|
},
|
|
101
101
|
delete: id => deletePrefix(blobs, token, cachePath(id, ''), remove),
|
|
102
102
|
}
|
|
103
|
-
// Staging uploads are never published.
|
|
104
|
-
//
|
|
105
|
-
function reapUploads(now = Date.now()) {
|
|
106
|
-
|
|
103
|
+
// Staging uploads are never published. Automatic maintenance and explicit
|
|
104
|
+
// cleanup remove abandoned parts after a full day.
|
|
105
|
+
async function reapUploads(now = Date.now()) {
|
|
106
|
+
let removed = 0
|
|
107
|
+
await deletePrefix(blobs, token, '.managed/uploads/', async path => { await remove(path); removed++ },
|
|
107
108
|
blob => new Date(blob.uploadedAt ?? now).getTime() < now - 86_400_000)
|
|
109
|
+
return removed
|
|
108
110
|
}
|
|
109
111
|
const sourcesPath = (key: string) => `.managed/cache/report-sources/${validateCacheKey(key)}`
|
|
110
112
|
const reportSourcesStorage: CacheStorage = {
|
package/server-managed/config.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// GitHub App can provide both flows, with repository grants at installation.
|
|
4
4
|
import { env } from 'node:process'
|
|
5
5
|
import { databaseUrls } from '../server-common/database-config.ts'
|
|
6
|
+
import { MAX_UPLOAD_BYTES } from './uploads.ts'
|
|
6
7
|
|
|
7
8
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
|
|
8
9
|
|
|
@@ -124,7 +125,7 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
|
|
|
124
125
|
githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
|
|
125
126
|
githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
|
|
126
127
|
maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
|
|
127
|
-
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1,
|
|
128
|
+
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, MAX_UPLOAD_BYTES),
|
|
128
129
|
triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
|
|
129
130
|
}
|
|
130
131
|
}
|
|
@@ -204,7 +204,9 @@ export interface TriageEventRow {
|
|
|
204
204
|
comment: string | null
|
|
205
205
|
fix: string | null
|
|
206
206
|
flagged: boolean | null
|
|
207
|
+
actorId: string | null
|
|
207
208
|
actorLogin: string | null
|
|
209
|
+
actorName: string | null
|
|
208
210
|
at: number
|
|
209
211
|
}
|
|
210
212
|
|
|
@@ -354,6 +356,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
354
356
|
insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
|
|
355
357
|
getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
|
|
356
358
|
listReports(userId?: string): Promise<AdminReport[]>
|
|
359
|
+
listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
|
|
357
360
|
getReport(id: string): Promise<ReportRecord | null>
|
|
358
361
|
getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
|
|
359
362
|
getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
|
|
@@ -571,6 +574,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
571
574
|
WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
|
|
572
575
|
ORDER BY r.uploaded_at DESC, r.filename ASC`,
|
|
573
576
|
),
|
|
577
|
+
findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
|
|
574
578
|
selectReportStmt: db.prepare(
|
|
575
579
|
`SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
|
|
576
580
|
sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
|
|
@@ -646,7 +650,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
646
650
|
selectTriageHistoryStmt: db.prepare(
|
|
647
651
|
`SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
|
|
648
652
|
e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
|
|
649
|
-
COALESCE(u.login, e.actor_login) AS actorLogin, e.at AS at
|
|
653
|
+
u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
|
|
650
654
|
FROM managed_finding_triage_event e
|
|
651
655
|
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
652
656
|
WHERE e.finding_id = ?
|
|
@@ -963,6 +967,9 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
963
967
|
uploadedAt: r.uploadedAt,
|
|
964
968
|
}))
|
|
965
969
|
},
|
|
970
|
+
async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
|
|
971
|
+
return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
|
|
972
|
+
},
|
|
966
973
|
async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
|
|
967
974
|
const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
|
|
968
975
|
if (!session) return null
|
|
@@ -1019,7 +1026,9 @@ type TriageDbRow = {
|
|
|
1019
1026
|
updatedByLogin: string | null; updatedAt: number
|
|
1020
1027
|
}
|
|
1021
1028
|
type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
|
|
1022
|
-
type TriageEventDbRow = TriageStateDbRow & {
|
|
1029
|
+
type TriageEventDbRow = TriageStateDbRow & {
|
|
1030
|
+
seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
|
|
1031
|
+
}
|
|
1023
1032
|
|
|
1024
1033
|
// The per-finding triage slice of ManagedDb. Closes over its prepared
|
|
1025
1034
|
// statements (and the handle, for the batch write's transaction). A
|
|
@@ -1074,7 +1083,7 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
|
|
|
1074
1083
|
seq: r.seq, findingId: r.findingId, batchId: r.batchId,
|
|
1075
1084
|
color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
|
|
1076
1085
|
flagged: r.flagged == null ? null : r.flagged === 1,
|
|
1077
|
-
actorLogin: r.actorLogin, at: r.at,
|
|
1086
|
+
actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
|
|
1078
1087
|
}))
|
|
1079
1088
|
},
|
|
1080
1089
|
async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
|
|
@@ -1307,6 +1316,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1307
1316
|
const comments = commentMethods(db)
|
|
1308
1317
|
const activity = activityMethods(db)
|
|
1309
1318
|
const stmts = prepareStatements(db)
|
|
1319
|
+
const reports = reportMethods(stmts)
|
|
1310
1320
|
const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
|
|
1311
1321
|
const {
|
|
1312
1322
|
upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
|
|
@@ -1371,9 +1381,9 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1371
1381
|
...activity,
|
|
1372
1382
|
...comments,
|
|
1373
1383
|
...workspaceShareMethods(db),
|
|
1374
|
-
...
|
|
1384
|
+
...reports,
|
|
1375
1385
|
...triage,
|
|
1376
|
-
...importTriageMethods({ ...triage, ...comments }),
|
|
1386
|
+
...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
|
|
1377
1387
|
...bundleMethods(stmts),
|
|
1378
1388
|
...teamMethods(stmts),
|
|
1379
1389
|
async close() {
|
package/server-managed/http.ts
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
49
49
|
import { pipeline } from 'node:stream/promises'
|
|
50
50
|
import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
|
|
51
|
-
import { UPLOAD_CHUNK_BYTES, type UploadKind, putUploadPart, readUpload, validUploadPart } from './uploads.ts'
|
|
51
|
+
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
|
|
52
52
|
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
53
53
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
54
54
|
import type { BundleStore } from './bundle-store.ts'
|
|
@@ -82,7 +82,7 @@ import { clearCookie, endSession, readSession } from './session.ts'
|
|
|
82
82
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
83
83
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
84
84
|
import { TeamReportsError, loadTeamReports, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
85
|
-
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
85
|
+
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
86
86
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
87
87
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
88
88
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
@@ -280,19 +280,32 @@ function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: st
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
283
|
-
|
|
283
|
+
const cancel = req.method === 'DELETE'
|
|
284
|
+
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
284
285
|
const s = await checkMutation(req, res, deps, cookie)
|
|
285
286
|
if (!s || !requireManageRole(res, s.user)) return
|
|
286
287
|
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
287
|
-
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)
|
|
288
|
-
if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
289
|
-
const id = match[2]!,
|
|
288
|
+
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
289
|
+
if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
290
|
+
const id = match[2]!, kind = match[1] as UploadKind
|
|
291
|
+
if (cancel) {
|
|
292
|
+
const count = Number(req.headers['x-upload-parts'])
|
|
293
|
+
if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
294
|
+
await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
|
|
295
|
+
sendJson(res, 200, { ok: true }); return
|
|
296
|
+
}
|
|
297
|
+
const index = Number(match[3])
|
|
290
298
|
const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
|
|
291
|
-
|
|
299
|
+
const discard = async () => {
|
|
300
|
+
if (validUpload(id, index + 1)) {
|
|
301
|
+
await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
292
305
|
let bytes
|
|
293
306
|
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
294
|
-
catch { sendJson(res, 413, { error: 'too-large' }); return }
|
|
295
|
-
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
307
|
+
catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
|
|
308
|
+
if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
|
|
296
309
|
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
297
310
|
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
298
311
|
sendJson(res, 200, { ok: true })
|
|
@@ -1582,7 +1595,8 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
|
|
|
1582
1595
|
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1583
1596
|
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1584
1597
|
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
1585
|
-
seq: row.seq, at: row.at,
|
|
1598
|
+
seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
|
|
1599
|
+
batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
1586
1600
|
}))
|
|
1587
1601
|
// A database read can outlive a role, membership, or visibility change.
|
|
1588
1602
|
const latest = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
@@ -1632,9 +1646,31 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
1632
1646
|
sendJson(res, 200, { ok: true })
|
|
1633
1647
|
}
|
|
1634
1648
|
|
|
1649
|
+
// GET only: clients match local annotations against these IDs without sending
|
|
1650
|
+
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
1651
|
+
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
1652
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1653
|
+
if (!s) return
|
|
1654
|
+
const after = search.get('after') ?? ''
|
|
1655
|
+
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
1656
|
+
const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
|
|
1657
|
+
const reports = []
|
|
1658
|
+
let bytes = 0, cursor = after, processed = 0
|
|
1659
|
+
for (const report of stored) {
|
|
1660
|
+
if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
|
|
1661
|
+
bytes += report.byteSize
|
|
1662
|
+
processed++
|
|
1663
|
+
cursor = report.id
|
|
1664
|
+
const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
|
|
1665
|
+
if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
|
|
1666
|
+
}
|
|
1667
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1668
|
+
sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1635
1671
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
1636
1672
|
// writing. The store commits shared finding triage and imported comments together.
|
|
1637
|
-
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id
|
|
1673
|
+
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1638
1674
|
const s = await adminMutation(req, res, deps, cookie)
|
|
1639
1675
|
if (!s) return
|
|
1640
1676
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
@@ -1658,15 +1694,10 @@ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1658
1694
|
parsed.push([findingId, patch])
|
|
1659
1695
|
}
|
|
1660
1696
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
let visible: Set<string> | undefined
|
|
1664
|
-
if (id !== undefined) {
|
|
1665
|
-
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1666
|
-
visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
1667
|
-
}
|
|
1697
|
+
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1698
|
+
const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
1668
1699
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1669
|
-
if (
|
|
1700
|
+
if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1670
1701
|
if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
|
|
1671
1702
|
const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
|
|
1672
1703
|
sendJson(res, applied ? 200 : 409, { ok: applied })
|
|
@@ -1925,7 +1956,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1925
1956
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1926
1957
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
1927
1958
|
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
1928
|
-
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
1959
|
+
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
1960
|
+
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
1961
|
+
} : {}) } })
|
|
1929
1962
|
return
|
|
1930
1963
|
}
|
|
1931
1964
|
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
@@ -2064,9 +2097,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2064
2097
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2065
2098
|
await handleSetReportVisible(req, res, deps, cookie); return
|
|
2066
2099
|
}
|
|
2067
|
-
if (path === '/api/admin/
|
|
2068
|
-
if (method !== '
|
|
2069
|
-
await
|
|
2100
|
+
if (path === '/api/admin/reports/finding-ids') {
|
|
2101
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2102
|
+
await handleImportFindingIds(res, deps, cookie, url.searchParams); return
|
|
2070
2103
|
}
|
|
2071
2104
|
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
2072
2105
|
if (importTriage) {
|
|
@@ -11,11 +11,11 @@ export interface ImportTriageSnapshot {
|
|
|
11
11
|
}
|
|
12
12
|
export interface ImportTriageStore {
|
|
13
13
|
getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
|
|
14
|
-
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string
|
|
14
|
+
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Raw store methods share one transaction for comparison, triage, and comments.
|
|
18
|
-
export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
|
|
18
|
+
export function importTriageMethods(store: Pick<ManagedDb, 'getReport' | 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
|
|
19
19
|
async function snapshot(ids: readonly string[]) {
|
|
20
20
|
const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
|
|
21
21
|
const comments = await store.listComments(ids)
|
|
@@ -30,6 +30,9 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
|
|
|
30
30
|
return {
|
|
31
31
|
getImportTriage: snapshot,
|
|
32
32
|
async importTriage(entries, expected, actor, reportId, now) {
|
|
33
|
+
// A report deleted after the HTTP membership check must not leave newly
|
|
34
|
+
// imported annotations behind. Check existence in the write transaction.
|
|
35
|
+
if (!reportId || !(await store.getReport(reportId))) return false
|
|
33
36
|
const current = await snapshot(entries.map(([id]) => id))
|
|
34
37
|
if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
|
|
35
38
|
const changes: [string, TriageEntryPatch | null][] = []
|
|
@@ -38,7 +41,7 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
|
|
|
38
41
|
changes.push([id, Object.keys(entry).length > 0 ? entry : null])
|
|
39
42
|
if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
|
|
40
43
|
await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
|
|
41
|
-
actor, createdAt: null, updatedAt: null, reportId
|
|
44
|
+
actor, createdAt: null, updatedAt: null, reportId }, now)
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
|
package/server-managed/index.ts
CHANGED
|
@@ -12,9 +12,10 @@ import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
|
12
12
|
import { loadManagedStatic } from './static.ts'
|
|
13
13
|
import { openManagedStorage } from './storage.ts'
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
const
|
|
15
|
+
// Housekeeping runs on ordinary traffic too, including serverless instances
|
|
16
|
+
// without a timer or cron. Failed sweeps retry on later traffic after a minute.
|
|
17
|
+
const REAP_INTERVAL_MS = 3_600_000
|
|
18
|
+
const REAP_RETRY_MS = 60_000
|
|
18
19
|
|
|
19
20
|
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
|
|
20
21
|
|
|
@@ -39,25 +40,46 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
39
40
|
const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
|
|
40
41
|
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
41
42
|
})
|
|
42
|
-
const
|
|
43
|
+
const routeRequest = createManagedRequestHandler({
|
|
43
44
|
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
|
|
44
45
|
...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
|
|
45
46
|
isShuttingDown, track,
|
|
46
47
|
})
|
|
47
48
|
|
|
48
49
|
let cleanup: Promise<void> | undefined
|
|
50
|
+
let nextCleanupAt = 0
|
|
49
51
|
function reap(): Promise<void> {
|
|
50
52
|
if (cleanup) return cleanup
|
|
53
|
+
const startedAt = Date.now()
|
|
54
|
+
nextCleanupAt = startedAt + REAP_INTERVAL_MS
|
|
55
|
+
let sessions = 0, uploads = 0
|
|
51
56
|
cleanup = runReapers({
|
|
52
|
-
sessions: () => db.deleteExpiredSessions(Date.now()),
|
|
53
|
-
...('reapUploads' in storage ? { uploads: () => storage.reapUploads() } : {}),
|
|
57
|
+
sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
|
|
58
|
+
...('reapUploads' in storage ? { uploads: async () => { uploads = await storage.reapUploads() } } : {}),
|
|
59
|
+
}).then(() => {
|
|
60
|
+
nextCleanupAt = Date.now() + REAP_INTERVAL_MS
|
|
61
|
+
return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
|
|
62
|
+
}, err => {
|
|
63
|
+
nextCleanupAt = Date.now() + REAP_RETRY_MS
|
|
64
|
+
throw err
|
|
54
65
|
}).finally(() => { cleanup = undefined })
|
|
55
66
|
track(cleanup)
|
|
56
67
|
return cleanup
|
|
57
68
|
}
|
|
69
|
+
function automaticReap(): Promise<void> {
|
|
70
|
+
if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
|
|
71
|
+
return reap().catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
72
|
+
}
|
|
73
|
+
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
74
|
+
const maintenance = automaticReap()
|
|
75
|
+
// Send the normal response without waiting for storage housekeeping, but
|
|
76
|
+
// keep the invocation alive until both finish. No detached serverless work.
|
|
77
|
+
try { await routeRequest(req, res) }
|
|
78
|
+
finally { await maintenance }
|
|
79
|
+
}
|
|
58
80
|
const gcTimer = config.serverless ? null : setInterval(() => {
|
|
59
|
-
void
|
|
60
|
-
},
|
|
81
|
+
void automaticReap()
|
|
82
|
+
}, REAP_INTERVAL_MS)
|
|
61
83
|
rollback.defer(stop)
|
|
62
84
|
|
|
63
85
|
function stop(): void {
|
|
@@ -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
|
|
@@ -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
|
}
|