@preventive/triage 1.0.0-alpha.35 → 1.0.0-alpha.37
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/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/common/managed/report-content.ts +31 -1
- package/out/client-managed-import.js +41 -41
- package/out/client-managed.js +58 -58
- package/out/view.js +164 -164
- package/package.json +3 -1
- package/server-managed/bundle-build-worker.js +28 -7
- package/server-managed/bundle-build.ts +10 -4
- package/server-managed/http.ts +57 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.37",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -211,6 +211,8 @@
|
|
|
211
211
|
"common/scan-server.ts",
|
|
212
212
|
"common/utf8.d.ts",
|
|
213
213
|
"common/utf8.js",
|
|
214
|
+
"client/dependency-paths.d.ts",
|
|
215
|
+
"client/dependency-paths.js",
|
|
214
216
|
"client/finding-link.js",
|
|
215
217
|
"client/linked-findings.d.ts",
|
|
216
218
|
"client/linked-findings.js",
|
|
@@ -32,6 +32,21 @@ async function packageName(input, github, project, bundle, client) {
|
|
|
32
32
|
return typeof name === 'string' ? name : null
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Prisma Client is what `prisma generate` writes, not what the repo holds: for
|
|
36
|
+
// a JS project whose package.json depends on a @prisma/ package, Stasis
|
|
37
|
+
// generates it before the scan (generate: ['prisma']).
|
|
38
|
+
async function prismaGenerate(input, github, project, client) {
|
|
39
|
+
if (input.entries[0].endsWith('.sol')) return []
|
|
40
|
+
const listing = await client.listRepoDir({ repo: github, sha: input.commit, directory: project || undefined })
|
|
41
|
+
if (!listing.some(entry => entry.path === 'package.json' && regularFile(entry))) return []
|
|
42
|
+
const text = await client.getRepoFile({ repo: github, path: posix.join(project, 'package.json'), ref: input.commit })
|
|
43
|
+
let manifest
|
|
44
|
+
try { manifest = JSON.parse(text) } catch { return [] }
|
|
45
|
+
const named = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
46
|
+
.flatMap(field => manifest?.[field] !== null && typeof manifest?.[field] === 'object' ? Object.keys(manifest[field]) : [])
|
|
47
|
+
return named.some(name => name.startsWith('@prisma/')) ? ['prisma'] : []
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
export async function buildStasisBundle({ input, github, token, maxBytes, scopes, cacheDir = null }, client = createClient({ token }), progress = () => {}) {
|
|
36
51
|
const allowed = path => scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))
|
|
37
52
|
// Reuse these immutable listings when Stasis discovers the lockfile root.
|
|
@@ -43,15 +58,16 @@ export async function buildStasisBundle({ input, github, token, maxBytes, scopes
|
|
|
43
58
|
} }
|
|
44
59
|
// Upstream's disk cache keeps the npm tarballs and version documents, and
|
|
45
60
|
// the repo's tree, in `cacheDir`; without one (Vercel), nothing is kept on
|
|
46
|
-
// disk. This fresh worker inherits no application credentials.
|
|
61
|
+
// disk. This fresh worker inherits no application credentials but NPM_TOKEN.
|
|
47
62
|
setCacheDir(cacheDir ?? false)
|
|
48
63
|
progress('build')
|
|
49
64
|
const project = await projectDirectory(input, github, buildClient)
|
|
50
65
|
if (!allowed(project)) throw new Error('build-scope')
|
|
66
|
+
const generate = await prismaGenerate(input, github, project, buildClient)
|
|
51
67
|
const { bundle } = await buildGitHubBundle({
|
|
52
68
|
github, sha: input.commit, directory: project || undefined,
|
|
53
69
|
entries: input.entries.map(entry => posix.relative(project || '.', entry)),
|
|
54
|
-
...input.options, client: buildClient, ...(cacheDir === null && { cache: false }),
|
|
70
|
+
...input.options, client: buildClient, ...(cacheDir === null && { cache: false }), ...(generate.length > 0 && { generate }),
|
|
55
71
|
})
|
|
56
72
|
const directory = bundle.repo?.directory ?? ''
|
|
57
73
|
progress('scope')
|
|
@@ -68,6 +84,15 @@ export async function buildStasisBundle({ input, github, token, maxBytes, scopes
|
|
|
68
84
|
return { bytes, directory, filename: githubBundleFilename(github, project, input.commit, name) }
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
// The code a failed build answers with. Never upstream bodies or paths outside
|
|
88
|
+
// the caller's grants: those stay in the diagnostic. A missing or ambiguous
|
|
89
|
+
// lockfile, or the named one missing, is the lockfile's.
|
|
90
|
+
export function buildErrorCode(error, message) {
|
|
91
|
+
if (['build-scope', 'too-large'].includes(message)) return message
|
|
92
|
+
if (error instanceof HttpError) return error.status === 429 ? 'github-rate-limited' : 'github-build-failed'
|
|
93
|
+
return /no packageManager given|lockfile installs|no (?:pnpm-lock\.yaml|yarn\.lock|package-lock\.json|soldeer\.lock) found in /u.test(message) ? 'build-lockfile' : 'build-failed'
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
if (parentPort && workerData?.type === 'managed-bundle-build') {
|
|
72
97
|
// Send diagnostics to the request's parent thread, which writes them before
|
|
73
98
|
// returning the HTTP response. Worker stdout can be lost on termination.
|
|
@@ -80,11 +105,7 @@ if (parentPort && workerData?.type === 'managed-bundle-build') {
|
|
|
80
105
|
send(await buildStasisBundle(workerData, undefined, stage => send({ type: 'progress', stage })))
|
|
81
106
|
} catch (error) {
|
|
82
107
|
const diagnostic = bundleBuildDiagnostic(error, workerData.token)
|
|
83
|
-
|
|
84
|
-
const code = ['build-scope', 'too-large'].includes(diagnostic.message) ? diagnostic.message
|
|
85
|
-
: error instanceof HttpError ? (error.status === 429 ? 'github-rate-limited' : 'github-build-failed')
|
|
86
|
-
: /no packageManager given|lockfile installs/u.test(diagnostic.message) ? 'build-lockfile'
|
|
87
|
-
: 'build-failed'
|
|
108
|
+
const code = buildErrorCode(error, diagnostic.message)
|
|
88
109
|
send({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : 422, diagnostic })
|
|
89
110
|
}
|
|
90
111
|
}
|
|
@@ -17,7 +17,7 @@ export interface BundleBuildInput {
|
|
|
17
17
|
commit: string
|
|
18
18
|
entries: string[]
|
|
19
19
|
directory: string
|
|
20
|
-
options: { conditions?: string[]; mainFields?: string[]; metro?: boolean; platforms?: string[]; jsx?: boolean }
|
|
20
|
+
options: { conditions?: string[]; mainFields?: string[]; metro?: boolean; platforms?: string[]; jsx?: boolean; packageManager?: 'soldeer' }
|
|
21
21
|
// What the stored bundle records; null for Solidity, which takes no conditions.
|
|
22
22
|
conditions: BundleBuildConditions | null
|
|
23
23
|
}
|
|
@@ -42,7 +42,9 @@ export function parseBundleBuild(value: unknown): BundleBuildInput {
|
|
|
42
42
|
const parent = posix.dirname(entry).split('/')
|
|
43
43
|
while (parts.some((part, i) => parent[i] !== part)) parts.pop()
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
// Stasis builds Solidity with Soldeer alone. Named, a JS lockfile beside or
|
|
46
|
+
// above the project no longer leaves it unable to tell which one installs it.
|
|
47
|
+
const options: BundleBuildInput['options'] = scripts ? {} : { packageManager: 'soldeer' }
|
|
46
48
|
let recorded: BundleBuildConditions | null = null
|
|
47
49
|
if (scripts) {
|
|
48
50
|
if (conditions == null || typeof conditions !== 'object' || Array.isArray(conditions)) return fail('bad-conditions')
|
|
@@ -97,7 +99,7 @@ export interface BuiltBundle { bytes: Uint8Array; directory: string; filename: s
|
|
|
97
99
|
export interface BuildRequest {
|
|
98
100
|
input: BundleBuildInput; github: string; token: string | null; maxBytes: number; scopes: (string | null)[]
|
|
99
101
|
// Upstream's disk cache for what a build fetches (setCacheDir), or null for
|
|
100
|
-
// none, as on Vercel. Resolved here: the worker
|
|
102
|
+
// none, as on Vercel. Resolved here: the worker's env holds NPM_TOKEN alone.
|
|
101
103
|
cacheDir: string | null
|
|
102
104
|
}
|
|
103
105
|
// An extra per-process ceiling; admission must also hold the shared DB lease.
|
|
@@ -150,8 +152,12 @@ export async function buildRepositoryBundle(userId: string, request: BuildReques
|
|
|
150
152
|
try {
|
|
151
153
|
workerUrl = new URL('./bundle-build-worker.js', import.meta.url)
|
|
152
154
|
log('started')
|
|
155
|
+
// Upstream reads NPM_TOKEN from the env for scoped packages, which npm
|
|
156
|
+
// answers with 404 when private and asked without it. Nothing else passes.
|
|
157
|
+
const npmToken = process.env['NPM_TOKEN']
|
|
158
|
+
const env = npmToken ? { NPM_TOKEN: npmToken } : {}
|
|
153
159
|
worker = new Worker(workerUrl, {
|
|
154
|
-
workerData: { ...request, type: 'managed-bundle-build' }, env
|
|
160
|
+
workerData: { ...request, type: 'managed-bundle-build' }, env, execArgv: [], resourceLimits: { maxOldGenerationSizeMb: 512 },
|
|
155
161
|
})
|
|
156
162
|
const running = worker
|
|
157
163
|
return await new Promise((resolve, reject) => {
|
package/server-managed/http.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// GET /api/admin/reports/<id> → admin|manage downloads a stored report | 401/403/404
|
|
31
31
|
// DELETE /api/admin/reports/<id> → admin|manage deletes a report | 401/403/404
|
|
32
32
|
// POST /api/admin/reports/set-repo → admin|manage attaches/detaches a report's repo | 401/403/404
|
|
33
|
+
// GET /api/admin/reports/<id>/location → admin|manage live location suggestion | 401/403/404/503
|
|
33
34
|
// GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
|
|
34
35
|
// GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
|
|
35
36
|
// GET /api/bundles/<id>/download → authorized original upload | 401/404
|
|
@@ -73,7 +74,7 @@ import { filterReportData } from '../common/managed/report-filter.ts'
|
|
|
73
74
|
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
74
75
|
import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
|
|
75
76
|
import { reportRepoGithub } from '@preventive/report'
|
|
76
|
-
import { loadManagedFindings, readManagedReport } from '../common/managed/report-content.ts'
|
|
77
|
+
import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
|
|
77
78
|
import type { ReportSourcesCache } from './report-sources.ts'
|
|
78
79
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
79
80
|
import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
|
|
@@ -877,7 +878,8 @@ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
877
878
|
// markdown / CSV — archived as-is, like the e2e objstore; the server parses them
|
|
878
879
|
// downstream). Display name rides X-Report-Filename; the repository and
|
|
879
880
|
// directory come from the report header, or from optional repository/directory
|
|
880
|
-
// headers when the report has no repository metadata
|
|
881
|
+
// headers when the report has no repository metadata, else from the repository
|
|
882
|
+
// its findings name when that resolves within the uploader's access. The bundle link is
|
|
881
883
|
// auto-resolved from the report's bundleHashes. New reports start hidden until
|
|
882
884
|
// published. Identical content reuses its stored identity and metadata. Bytes
|
|
883
885
|
// are written first (keyed by a fresh uuid) then the metadata row. A definite
|
|
@@ -944,6 +946,19 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
944
946
|
const legacyRepo = await resolveUploadRepoId(req, res, deps)
|
|
945
947
|
if (!legacyRepo.ok) return
|
|
946
948
|
repoId = legacyRepo.repoId
|
|
949
|
+
// Otherwise the repository its findings name assigns a new report, like
|
|
950
|
+
// embedded metadata, when it resolves to a connected destination within
|
|
951
|
+
// the uploader's access. X-Repo-Directory still overrides the directory.
|
|
952
|
+
if (repoId == null && !existing) {
|
|
953
|
+
const suggestion = await suggestReportLocation(deps, s.user, parsed.data)
|
|
954
|
+
if (suggestion?.repoId != null) {
|
|
955
|
+
const inferred = req.headers['x-repo-directory'] == null ? suggestion.directory ?? '' : directory
|
|
956
|
+
if (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, suggestion.repoId, inferred)) {
|
|
957
|
+
repoId = suggestion.repoId
|
|
958
|
+
directory = inferred
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
947
962
|
}
|
|
948
963
|
if (repoId != null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
|
|
949
964
|
if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
|
|
@@ -1604,6 +1619,44 @@ async function handleRepositorySuggestion(req: IncomingMessage, res: ServerRespo
|
|
|
1604
1619
|
mapped: github.toLowerCase() !== repo.fullName.toLowerCase() || (directory.path ?? '') !== location.directory } : null })
|
|
1605
1620
|
}
|
|
1606
1621
|
|
|
1622
|
+
// Where a report without an embedded repository suggests it belongs, resolved
|
|
1623
|
+
// through current connections and aliases like bundle metadata. `repoId` is
|
|
1624
|
+
// set only for an active destination within the user's grants; the named
|
|
1625
|
+
// repository itself is part of the report. `directory` is null when neither
|
|
1626
|
+
// the report nor an alias specifies one.
|
|
1627
|
+
async function suggestReportLocation(deps: ManagedHttpDeps, user: StoredUser, data: { repo?: { directory?: unknown } } | null | undefined) {
|
|
1628
|
+
const github = findingsRepository(data)
|
|
1629
|
+
if (github == null) return null
|
|
1630
|
+
// An invalid optional directory is ignored, as for bundle metadata.
|
|
1631
|
+
const declared = normalizeTeamPath(data?.repo?.directory)
|
|
1632
|
+
const directory = declared.ok ? declared.path : null
|
|
1633
|
+
const location = await deps.db.getRepositoryImportLocation(github, directory ?? '', ownFileDirectory(data))
|
|
1634
|
+
const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
|
|
1635
|
+
const allowed = repo && (user.role === 'admin' || await deps.db.userCanReadRepoPath(user.id, repo.repoId, location.directory))
|
|
1636
|
+
if (!allowed) return { repoId: null, github, directory }
|
|
1637
|
+
const mapped = github.toLowerCase() !== repo.fullName.toLowerCase() || (directory ?? '') !== location.directory
|
|
1638
|
+
return { repoId: repo.repoId, github: repo.fullName, directory: mapped ? location.directory : directory }
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// GET /api/admin/reports/<id>/location — the live suggestion for a report's
|
|
1642
|
+
// location editor. It never changes the stored assignment.
|
|
1643
|
+
async function handleReportLocationSuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1644
|
+
if (req.method !== 'GET') { send405(res, 'GET'); return }
|
|
1645
|
+
const s = await readManageSession(res, deps, cookie)
|
|
1646
|
+
if (!s) return
|
|
1647
|
+
const report = await deps.db.getReport(id)
|
|
1648
|
+
if (!report || !(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1649
|
+
if (report.repoEmbedded) { sendJson(res, 200, { location: null }); return }
|
|
1650
|
+
const bytes = await deps.reportStore.get(id)
|
|
1651
|
+
if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1652
|
+
const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
|
|
1653
|
+
// Storage reads can outlast a session, role or grant change.
|
|
1654
|
+
const current = await readManageSession(res, deps, cookie)
|
|
1655
|
+
if (!current) return
|
|
1656
|
+
if (!(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1657
|
+
sendJson(res, 200, { location: await suggestReportLocation(deps, current.user, data) })
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1607
1660
|
// GET /api/reports/<id> — admin/manager preview, within management access.
|
|
1608
1661
|
// Accept: application/json includes parsed, filtered data and the server's repo
|
|
1609
1662
|
// assignment. Other callers retain the raw text/plain response. The
|
|
@@ -2365,6 +2418,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2365
2418
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2366
2419
|
await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
|
|
2367
2420
|
}
|
|
2421
|
+
const reportLocation = /^\/api\/admin\/reports\/([a-f\d-]{36})\/location$/iu.exec(path)
|
|
2422
|
+
if (reportLocation) { await handleReportLocationSuggestion(req, res, deps, cookie, reportLocation[1]!); return }
|
|
2368
2423
|
if (path.startsWith(REPORT_PREFIX)) {
|
|
2369
2424
|
const id = path.slice(REPORT_PREFIX.length)
|
|
2370
2425
|
if (method === 'GET') { await handleGetReport(res, deps, cookie, id); return }
|