@preventive/triage 1.0.0-alpha.34 → 1.0.0-alpha.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.34",
3
+ "version": "1.0.0-alpha.36",
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.
@@ -48,10 +63,11 @@ export async function buildStasisBundle({ input, github, token, maxBytes, scopes
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
- // Do not return upstream bodies or paths outside the caller's grants.
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
- const options: BundleBuildInput['options'] = {}
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')
@@ -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. The bundle link is
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 }