@preventive/triage 1.0.0-alpha.23 → 1.0.0-alpha.25
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/out/client-managed-import.js +41 -41
- package/out/client-managed.js +74 -59
- package/out/graph.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +170 -169
- 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/objstore/blob-vercel.ts +4 -4
- package/server-managed/blob-vercel.ts +4 -4
- package/server-managed/db-methods.ts +26 -9
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/http.ts +71 -82
- package/server-managed/import-triage.ts +2 -2
- 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/repository-browser.ts +6 -1
- package/server-managed/sql.ts +5 -0
package/server-managed/http.ts
CHANGED
|
@@ -92,6 +92,8 @@ import { handlePublicWorkspace } from './public-workspace.ts'
|
|
|
92
92
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
93
93
|
import { hashToken, randomToken } from './crypto.ts'
|
|
94
94
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
95
|
+
import { ManagedCommitError } from './sql.ts'
|
|
96
|
+
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
95
97
|
|
|
96
98
|
const SESSION_PATH = '/api/auth/session'
|
|
97
99
|
const AVATAR_PREFIX = '/api/avatar/'
|
|
@@ -291,6 +293,7 @@ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
291
293
|
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
292
294
|
catch { sendJson(res, 413, { error: 'too-large' }); return }
|
|
293
295
|
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
296
|
+
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
294
297
|
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
295
298
|
sendJson(res, 200, { ok: true })
|
|
296
299
|
}
|
|
@@ -462,7 +465,7 @@ async function handleListRepositories(req: IncomingMessage, res: ServerResponse,
|
|
|
462
465
|
// access (never trust a client-supplied id) and capture the server-derived read
|
|
463
466
|
// context (installation id, default branch). A PRIVATE repo with no installation
|
|
464
467
|
// can't be read server-side → 409. Stores via selectRepo (upsert).
|
|
465
|
-
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number): Promise<void> {
|
|
468
|
+
async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
|
|
466
469
|
if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
|
|
467
470
|
const userId = user.id
|
|
468
471
|
const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
|
|
@@ -489,6 +492,7 @@ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user
|
|
|
489
492
|
sendJson(res, 403, { error: 'forbidden' }); return
|
|
490
493
|
}
|
|
491
494
|
}
|
|
495
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
492
496
|
await connectRepository(res, deps, user, repo)
|
|
493
497
|
}
|
|
494
498
|
|
|
@@ -580,11 +584,12 @@ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
580
584
|
}
|
|
581
585
|
if (!selected) {
|
|
582
586
|
const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
|
|
587
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
583
588
|
await deps.db.deactivateRepo(repoId)
|
|
584
589
|
if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
|
|
585
590
|
sendJson(res, 200, { ok: true, selected: false }); return
|
|
586
591
|
}
|
|
587
|
-
await selectRepository(res, deps, s.user, repoId)
|
|
592
|
+
await selectRepository(res, deps, s.user, repoId, cookie)
|
|
588
593
|
}
|
|
589
594
|
|
|
590
595
|
async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
|
|
@@ -614,13 +619,15 @@ async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
|
|
|
614
619
|
}
|
|
615
620
|
}
|
|
616
621
|
|
|
617
|
-
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[]): Promise<string[]> {
|
|
622
|
+
async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
|
|
618
623
|
const targetIds = await repositoryFindingIds(deps, reports)
|
|
619
|
-
const triage = await deps.db.listTriage([...targetIds])
|
|
620
|
-
const commentIds = await deps.db.listCommentedFindingIds([...targetIds])
|
|
621
|
-
|
|
624
|
+
const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
|
|
625
|
+
const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
|
|
626
|
+
// Removal includes every exclusive finding, including annotations created
|
|
627
|
+
// during the blob scan; impact only needs the currently annotated subset.
|
|
628
|
+
const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
|
|
622
629
|
if (annotatedIds.size === 0) return []
|
|
623
|
-
//
|
|
630
|
+
// Impact can skip unannotated findings; deletion must also cover new writes.
|
|
624
631
|
// TODO(managed): Persist finding IDs per report at upload time and maintain
|
|
625
632
|
// the index on report deletion. Use it for repository impact and triage
|
|
626
633
|
// cleanup so overlap checks do not fetch and parse every other report blob.
|
|
@@ -661,13 +668,12 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
661
668
|
const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
|
|
662
669
|
if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
|
|
663
670
|
if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
|
|
664
|
-
const
|
|
665
|
-
const bundles = await deps.db.listBundlesForRepo(repoId)
|
|
671
|
+
const allReports = deleteTriage ? await deps.db.listReports() : []
|
|
666
672
|
const triageIds = deleteTriage
|
|
667
|
-
? await repositoryExclusiveTriageIds(deps,
|
|
673
|
+
? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
|
|
668
674
|
: []
|
|
669
|
-
const deletedReports = await deps.db.
|
|
670
|
-
|
|
675
|
+
const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
|
|
676
|
+
deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
|
|
671
677
|
// Remove metadata first so a blob-store failure leaves an orphaned blob for
|
|
672
678
|
// later cleanup, rather than a live row pointing at missing report data.
|
|
673
679
|
for (const report of reports) {
|
|
@@ -675,15 +681,11 @@ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse,
|
|
|
675
681
|
await deps.reportStore.delete(report.id).catch(() => {})
|
|
676
682
|
}
|
|
677
683
|
for (const bundle of bundles) {
|
|
678
|
-
//
|
|
679
|
-
// report rows needed to reconstruct exclusive finding IDs are gone.
|
|
684
|
+
// Best-effort derivative cleanup follows the committed metadata removal.
|
|
680
685
|
await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
681
686
|
await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
682
687
|
await deps.bundleStore.delete(bundle.id).catch(() => {})
|
|
683
688
|
}
|
|
684
|
-
const deletedTriage = await deps.db.deleteTriage(triageIds)
|
|
685
|
-
await deps.db.deleteRepo(repoId)
|
|
686
|
-
await activity(deps, s.user, 'delete', `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)`, { repo: repo.fullName })
|
|
687
689
|
sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
|
|
688
690
|
}
|
|
689
691
|
|
|
@@ -733,15 +735,6 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
|
|
|
733
735
|
return { ok: true, repoId: n }
|
|
734
736
|
}
|
|
735
737
|
|
|
736
|
-
// True iff `repoId` may be linked to a report / bundle: null (detach) or a
|
|
737
|
-
// currently-selected repo id (so the FK can't dangle and you can only attach to
|
|
738
|
-
// a managed repo).
|
|
739
|
-
async function repoIdAllowed(deps: ManagedHttpDeps, repoId: unknown): Promise<boolean> {
|
|
740
|
-
if (repoId == null) return true
|
|
741
|
-
if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) return false
|
|
742
|
-
return (await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)
|
|
743
|
-
}
|
|
744
|
-
|
|
745
738
|
async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
|
|
746
739
|
if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
|
|
747
740
|
const report = await deps.db.getReport(reportId)
|
|
@@ -761,18 +754,12 @@ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
761
754
|
const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
|
|
762
755
|
const directory = (body as { directory?: unknown } | null)?.directory ?? ''
|
|
763
756
|
if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
764
|
-
if (
|
|
765
|
-
const report = await deps.db.getReport(reportId)
|
|
766
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
767
|
-
if (!(await canChangeReportRepo(deps, s.user, reportId))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
768
|
-
if (report.repoEmbedded) { sendJson(res, 409, { error: 'repo-in-report' }); return }
|
|
769
|
-
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
757
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
770
758
|
const normalized = normalizeTeamPath(directory)
|
|
771
759
|
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
772
|
-
|
|
773
|
-
if (!(await deps.db.setReportRepo(reportId, repoId as number | null, normalized.path ?? ''))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
760
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
|
|
774
761
|
if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
|
|
775
|
-
await activity(deps,
|
|
762
|
+
await activity(deps, user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, repoId ?? report.repoId) })
|
|
776
763
|
}
|
|
777
764
|
sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
|
|
778
765
|
}
|
|
@@ -787,12 +774,8 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
|
|
|
787
774
|
const reportId = (body as { reportId?: unknown } | null)?.reportId
|
|
788
775
|
const visible = (body as { visible?: unknown } | null)?.visible
|
|
789
776
|
if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
const report = await deps.db.getReport(reportId)
|
|
793
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
794
|
-
if (!(await deps.db.setReportVisible(reportId, visible))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
795
|
-
if (report.visible !== visible) await activity(deps, s.user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
777
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
|
|
778
|
+
if (report.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
796
779
|
sendJson(res, 200, { ok: true, visible })
|
|
797
780
|
}
|
|
798
781
|
|
|
@@ -809,16 +792,9 @@ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, de
|
|
|
809
792
|
if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
|
|
810
793
|
const directory = repoId == null ? '' : normalized.path ?? ''
|
|
811
794
|
if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
812
|
-
if (
|
|
813
|
-
const bundle = await deps.db.
|
|
814
|
-
if (bundle
|
|
815
|
-
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
816
|
-
if (!(await repoIdAllowed(deps, repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
817
|
-
if (repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId as number, directory))) {
|
|
818
|
-
sendJson(res, 403, { error: 'forbidden' }); return
|
|
819
|
-
}
|
|
820
|
-
if (!(await deps.db.setBundleRepo(bundleId, repoId as number | null, directory))) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
821
|
-
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, s.user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, (repoId as number | null) ?? bundle.repoId) })
|
|
795
|
+
if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
|
|
796
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
|
|
797
|
+
if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, repoId ?? bundle.repoId) })
|
|
822
798
|
sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
|
|
823
799
|
}
|
|
824
800
|
|
|
@@ -873,8 +849,9 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
873
849
|
// headers when the report has no repository metadata. The bundle link is
|
|
874
850
|
// auto-resolved from the report's bundleHashes. New reports start hidden until
|
|
875
851
|
// published. Identical content reuses its stored identity and metadata. Bytes
|
|
876
|
-
// are written first (keyed by a fresh uuid) then the metadata row
|
|
877
|
-
// insert or concurrent duplicate drops the orphan blob
|
|
852
|
+
// are written first (keyed by a fresh uuid) then the metadata row. A definite
|
|
853
|
+
// insert failure or concurrent duplicate drops the orphan blob; an uncertain
|
|
854
|
+
// commit retains it. 413 over the cap, 400 on empty.
|
|
878
855
|
async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
879
856
|
let s = await checkMutation(req, res, deps, cookie)
|
|
880
857
|
if (s == null) return
|
|
@@ -931,9 +908,10 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
931
908
|
id, filename, contentType, byteSize: bytes.length, sha256,
|
|
932
909
|
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
|
|
933
910
|
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
934
|
-
}, Date.now())
|
|
911
|
+
}, Date.now(), s.session.id)
|
|
935
912
|
} catch (err) {
|
|
936
|
-
|
|
913
|
+
// Losing the commit acknowledgement cannot turn primary bytes into garbage.
|
|
914
|
+
if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
|
|
937
915
|
throw err
|
|
938
916
|
}
|
|
939
917
|
if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
|
|
@@ -976,15 +954,10 @@ async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
976
954
|
const s = await checkMutation(req, res, deps, cookie)
|
|
977
955
|
if (s == null) return
|
|
978
956
|
if (!requireManageRole(res, s.user)) return
|
|
979
|
-
|
|
980
|
-
if (!(await canChangeReportRepo(deps, s.user, id))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
981
|
-
const report = await deps.db.getReport(id)
|
|
982
|
-
if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
983
|
-
const existed = await deps.db.deleteReport(id)
|
|
957
|
+
const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
|
|
984
958
|
await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
985
959
|
await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
|
|
986
|
-
|
|
987
|
-
await activity(deps, s.user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
960
|
+
await activity(deps, user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
|
|
988
961
|
sendJson(res, 200, { ok: true })
|
|
989
962
|
}
|
|
990
963
|
|
|
@@ -1057,7 +1030,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1057
1030
|
if (!current) return
|
|
1058
1031
|
if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1059
1032
|
sendJson(res, 200, refs ? result : {
|
|
1060
|
-
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false } : {}), path, commit,
|
|
1033
|
+
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
|
|
1061
1034
|
})
|
|
1062
1035
|
} catch (err) {
|
|
1063
1036
|
if (err instanceof GithubApiError) {
|
|
@@ -1206,6 +1179,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1206
1179
|
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1207
1180
|
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1208
1181
|
if (existing != null) {
|
|
1182
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1183
|
+
if (!s) return
|
|
1209
1184
|
if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1210
1185
|
// Reports uploaded while this bundle was inaccessible retain only its
|
|
1211
1186
|
// integrity. An authorized re-upload repairs those pending links too.
|
|
@@ -1221,14 +1196,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1221
1196
|
await deps.db.insertBundle({
|
|
1222
1197
|
id, integrity, filename, kind,
|
|
1223
1198
|
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
|
|
1224
|
-
}, Date.now())
|
|
1199
|
+
}, Date.now(), s.session.id)
|
|
1225
1200
|
} catch (err) {
|
|
1226
|
-
|
|
1201
|
+
// A committed row may already point here even though insertion rejected.
|
|
1202
|
+
if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
|
|
1203
|
+
if (err instanceof ManagedMutationError) throw err
|
|
1227
1204
|
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1228
1205
|
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1229
1206
|
// Any other failure rethrows.
|
|
1230
1207
|
const raced = await deps.db.getBundleByIntegrity(integrity)
|
|
1231
1208
|
if (raced != null) {
|
|
1209
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1210
|
+
if (!s) return
|
|
1232
1211
|
if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1233
1212
|
await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1234
1213
|
prebuildBundle(deps, raced.id)
|
|
@@ -1236,6 +1215,8 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1236
1215
|
throw err
|
|
1237
1216
|
}
|
|
1238
1217
|
// Auto-link reports that declared this integrity before the bundle existed.
|
|
1218
|
+
s = await manageMutation(req, res, deps, cookie)
|
|
1219
|
+
if (!s) return
|
|
1239
1220
|
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1240
1221
|
prebuildBundle(deps, id)
|
|
1241
1222
|
sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
|
|
@@ -1279,16 +1260,11 @@ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1279
1260
|
const s = await checkMutation(req, res, deps, cookie)
|
|
1280
1261
|
if (s == null) return
|
|
1281
1262
|
if (!requireManageRole(res, s.user)) return
|
|
1282
|
-
|
|
1283
|
-
const bundle = await deps.db.getBundle(id)
|
|
1284
|
-
if (bundle == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
|
|
1285
|
-
if (!(await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
1286
|
-
const existed = await deps.db.deleteBundle(id)
|
|
1263
|
+
const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
|
|
1287
1264
|
await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
|
|
1288
1265
|
await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
|
|
1289
1266
|
await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
|
|
1290
|
-
|
|
1291
|
-
await activity(deps, s.user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1267
|
+
await activity(deps, user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
|
|
1292
1268
|
sendJson(res, 200, { ok: true })
|
|
1293
1269
|
}
|
|
1294
1270
|
|
|
@@ -1589,25 +1565,29 @@ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1589
1565
|
}
|
|
1590
1566
|
|
|
1591
1567
|
// GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
|
|
1592
|
-
// trail (newest first, capped)
|
|
1593
|
-
//
|
|
1568
|
+
// trail (newest first, capped). The caller must have at least triage access
|
|
1569
|
+
// to the report, and the finding is one their visibility permissions
|
|
1594
1570
|
// keep in it (a stripped or foreign id 404s without revealing whether it
|
|
1595
1571
|
// exists). Each event is the entry as written then (null = a clear), who
|
|
1596
1572
|
// wrote it and when; "what changed" is the diff against the next-older event.
|
|
1597
1573
|
async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
|
|
1598
1574
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1599
1575
|
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
1600
|
-
if (!(await
|
|
1576
|
+
if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1601
1577
|
const finding = query.get('finding') ?? ''
|
|
1602
1578
|
if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
|
|
1603
1579
|
await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
|
|
1604
1580
|
const current = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1605
|
-
if (!current || !(await
|
|
1581
|
+
if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1606
1582
|
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1607
1583
|
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1608
1584
|
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
1609
1585
|
seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
1610
1586
|
}))
|
|
1587
|
+
// A database read can outlive a role, membership, or visibility change.
|
|
1588
|
+
const latest = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
1589
|
+
if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1590
|
+
if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1611
1591
|
sendJson(res, 200, { finding, events })
|
|
1612
1592
|
}
|
|
1613
1593
|
|
|
@@ -1654,7 +1634,7 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
1654
1634
|
|
|
1655
1635
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
1656
1636
|
// writing. The store commits shared finding triage and imported comments together.
|
|
1657
|
-
async function
|
|
1637
|
+
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
|
|
1658
1638
|
const s = await adminMutation(req, res, deps, cookie)
|
|
1659
1639
|
if (!s) return
|
|
1660
1640
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
@@ -1678,10 +1658,15 @@ async function handleImportReportTriage(req: IncomingMessage, res: ServerRespons
|
|
|
1678
1658
|
parsed.push([findingId, patch])
|
|
1679
1659
|
}
|
|
1680
1660
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1661
|
+
// Workspace imports remain report-scoped. Standalone local triage can be
|
|
1662
|
+
// imported before its reports; only administrators have this global access.
|
|
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
|
+
}
|
|
1683
1668
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1684
|
-
if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1669
|
+
if (visible && ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1685
1670
|
if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
|
|
1686
1671
|
const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
|
|
1687
1672
|
sendJson(res, applied ? 200 : 409, { ok: applied })
|
|
@@ -2079,10 +2064,14 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2079
2064
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2080
2065
|
await handleSetReportVisible(req, res, deps, cookie); return
|
|
2081
2066
|
}
|
|
2067
|
+
if (path === '/api/admin/import-triage') {
|
|
2068
|
+
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2069
|
+
await handleImportTriage(req, res, deps, cookie); return
|
|
2070
|
+
}
|
|
2082
2071
|
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
2083
2072
|
if (importTriage) {
|
|
2084
2073
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2085
|
-
await
|
|
2074
|
+
await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
|
|
2086
2075
|
}
|
|
2087
2076
|
if (path.startsWith(REPORT_PREFIX)) {
|
|
2088
2077
|
const id = path.slice(REPORT_PREFIX.length)
|
|
@@ -2204,7 +2193,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2204
2193
|
return (req, res) => {
|
|
2205
2194
|
if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
|
|
2206
2195
|
const work = route(req, res).catch((err) => {
|
|
2207
|
-
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2196
|
+
if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
|
|
2208
2197
|
console.warn('managed: request handler error:', err)
|
|
2209
2198
|
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
2210
2199
|
else sendJson(res, 500, { error: 'internal' })
|
|
@@ -11,7 +11,7 @@ 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, now: number): Promise<boolean>
|
|
14
|
+
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string | undefined, now: number): Promise<boolean>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Raw store methods share one transaction for comparison, triage, and comments.
|
|
@@ -38,7 +38,7 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
|
|
|
38
38
|
changes.push([id, Object.keys(entry).length > 0 ? entry : null])
|
|
39
39
|
if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
|
|
40
40
|
await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
|
|
41
|
-
actor, createdAt: null, updatedAt: null, reportId }, now)
|
|
41
|
+
actor, createdAt: null, updatedAt: null, reportId: reportId ?? null }, now)
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AdminReport, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
|
|
2
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
3
|
+
|
|
4
|
+
export class ManagedMutationError extends Error {
|
|
5
|
+
status: number
|
|
6
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string }
|
|
10
|
+
export type ReportMutation = ContentMutation | { type: 'visibility'; visible: boolean }
|
|
11
|
+
type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
|
|
12
|
+
type RemovalAnnotations = { reports: string; ids: string[] }
|
|
13
|
+
export interface ManagementStore {
|
|
14
|
+
mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
|
|
15
|
+
mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
|
|
16
|
+
removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
|
|
17
|
+
reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
|
|
18
|
+
}>
|
|
19
|
+
}
|
|
20
|
+
type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
|
|
21
|
+
| 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
|
|
22
|
+
| 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo'
|
|
23
|
+
| 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
|
|
24
|
+
|
|
25
|
+
// Only immutable bytes/format and repository membership affect the overlap
|
|
26
|
+
// scan. Ignore publication, user display names, and unrelated bundle links.
|
|
27
|
+
export function reportReferenceSnapshot(reports: readonly Pick<AdminReport, 'id' | 'sha256' | 'filename' | 'repoId'>[]): string {
|
|
28
|
+
return JSON.stringify(reports.map(r => [r.id, r.sha256, r.filename, r.repoId]).toSorted((a, b) => String(a[0]).localeCompare(String(b[0]))))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// These methods are wrapped by scopeManagedMethods: authorization, comparison,
|
|
32
|
+
// metadata changes (and repository-removal audit) share the writer transaction. Blob reads
|
|
33
|
+
// and deletes stay outside it. Use the raw store here, never nested scopes.
|
|
34
|
+
export function managementMethods(db: ManagementDb): ManagementStore {
|
|
35
|
+
async function authorize(sessionId: string, role: 'admin' | 'manage') {
|
|
36
|
+
const s = await db.sessionWithUser(sessionId, Date.now())
|
|
37
|
+
if (!s) throw new ManagedMutationError(401, 'unauthenticated')
|
|
38
|
+
if (!roleAtLeast(s.user.role, role)) throw new ManagedMutationError(403, 'forbidden')
|
|
39
|
+
return s.user
|
|
40
|
+
}
|
|
41
|
+
async function destination(user: { id: string; role: string }, change: ContentMutation) {
|
|
42
|
+
if (change.type !== 'repo' || change.repoId === null) return
|
|
43
|
+
if (!(await db.listSelectedRepos()).some(repo => repo.repoId === change.repoId)) throw new ManagedMutationError(400, 'bad-repo')
|
|
44
|
+
if (user.role !== 'admin' && !await db.userCanReadRepoPath(user.id, change.repoId, change.directory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
async mutateReport(sessionId: string, id: string, change: ReportMutation) {
|
|
48
|
+
const user = await authorize(sessionId, 'manage')
|
|
49
|
+
const report = await db.getReport(id)
|
|
50
|
+
if (!report || (user.role !== 'admin' && report.uploadedBy !== user.id && !await db.userCanReadReport(user.id, id))) throw new ManagedMutationError(404, 'no-report')
|
|
51
|
+
if (user.role !== 'admin' && report.repoId !== null && !await db.userCanReadReport(user.id, id)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
52
|
+
if (change.type === 'repo' && report.repoEmbedded) throw new ManagedMutationError(409, 'repo-in-report')
|
|
53
|
+
if (change.type !== 'visibility') await destination(user, change)
|
|
54
|
+
if (change.type === 'delete') await db.deleteReport(id)
|
|
55
|
+
else if (change.type === 'visibility') await db.setReportVisible(id, change.visible)
|
|
56
|
+
else await db.setReportRepo(id, change.repoId, change.directory)
|
|
57
|
+
return { report, user }
|
|
58
|
+
},
|
|
59
|
+
async mutateBundle(sessionId: string, id: string, change: ContentMutation) {
|
|
60
|
+
const user = await authorize(sessionId, 'manage')
|
|
61
|
+
const bundle = await db.getBundle(id)
|
|
62
|
+
if (!bundle || (user.role !== 'admin' && !await db.userCanReadBundle(user.id, id))) throw new ManagedMutationError(404, 'no-bundle')
|
|
63
|
+
if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
64
|
+
await destination(user, change)
|
|
65
|
+
if (change.type === 'delete') await db.deleteBundle(id)
|
|
66
|
+
else await db.setBundleRepo(id, change.repoId, change.directory)
|
|
67
|
+
return { bundle, user }
|
|
68
|
+
},
|
|
69
|
+
async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
|
|
70
|
+
const user = await authorize(sessionId, 'admin')
|
|
71
|
+
const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
|
|
72
|
+
if (!repo) throw new ManagedMutationError(404, 'no-repo')
|
|
73
|
+
if (repo.fullName !== expected.fullName || repo.addedAt !== expected.addedAt) throw new ManagedMutationError(409, 'repository-changed')
|
|
74
|
+
if (annotations && reportReferenceSnapshot(await db.listReports()) !== annotations.reports) throw new ManagedMutationError(409, 'repository-changed')
|
|
75
|
+
const reports = await db.listReportsForRepo(repo.repoId)
|
|
76
|
+
const bundles = await db.listBundlesForRepo(repo.repoId)
|
|
77
|
+
const deletedReports = await db.deleteReportsForRepo(repo.repoId)
|
|
78
|
+
const deletedBundles = await db.deleteBundlesForRepo(repo.repoId)
|
|
79
|
+
const deletedTriage = await db.deleteTriage(annotations?.ids ?? [])
|
|
80
|
+
await db.deleteRepo(repo.repoId)
|
|
81
|
+
await db.recordActivity({ kind: 'delete', actor: user.login, actorId: user.id, repo: repo.fullName,
|
|
82
|
+
action: `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)` }, Date.now())
|
|
83
|
+
return { reports, bundles, deletedReports, deletedBundles, deletedTriage }
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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>
|