@preventive/triage 1.0.0-alpha.22 → 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.
@@ -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|triage\/history|comments|sources)$/u.exec(url.pathname)
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
 
@@ -16,6 +17,10 @@ export function scopedDirectory(path: string, scopes: (string | null)[]): Reposi
16
17
  export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
17
18
  type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
18
19
 
20
+ function credentialFailure(err: unknown): err is GithubApiError {
21
+ return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
22
+ }
23
+
19
24
  // Create once per HTTP request. Coalesce concurrent credential lookups without
20
25
  // caching repository visibility or permissions, or sharing credentials across users.
21
26
  export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
@@ -32,31 +37,40 @@ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryB
32
37
  return identity
33
38
  }
34
39
  return {
35
- async reader(repo: SelectedRepo) {
36
- let token = Promise.resolve<string | null>(null)
37
- if (repo.installationId != null) {
40
+ reader(repo: SelectedRepo) {
41
+ const installationToken = () => {
42
+ if (repo.installationId == null) return Promise.resolve(null)
38
43
  const cached = tokens.get(repo.installationId)
39
- token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
40
- if (!(err instanceof GithubApiError)) throw err
41
- // Stale installations cannot prevent verified anonymous public access.
44
+ const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
45
+ if (!credentialFailure(err)) throw err
46
+ // Stale installations cannot prevent verified public access.
42
47
  return null
43
48
  })
44
49
  if (!cached) tokens.set(repo.installationId, token)
50
+ return token
45
51
  }
46
- return repositoryReader(repo, await token, getIdentity, fetchImpl)
52
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
47
53
  },
48
54
  }
49
55
  }
50
56
 
51
- async function repositoryReader(repo: SelectedRepo, token: string | null, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
57
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
52
58
  const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
59
+ // Public reads can use the signed-in user's quota without repository grants.
60
+ // Nonpublic reads still require the repository App and the effective-user gate.
61
+ let token = userToken ?? await installationToken()
53
62
  const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
54
63
  const metadata = async () => {
55
64
  try { return await read('') }
56
65
  catch (err) {
57
- if (!token || !(err instanceof GithubApiError)) throw err
58
- // A valid installation token may have lost this particular repository.
59
- // After falling back, all reads and rechecks stay anonymous.
66
+ if (!token || !credentialFailure(err)) throw err
67
+ const fallback = await installationToken()
68
+ if (fallback && fallback !== token) {
69
+ token = fallback
70
+ try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
71
+ }
72
+ // Anonymous fallback is only for unavailable credentials, never rate
73
+ // limits, network errors, or GitHub outages.
60
74
  token = null
61
75
  return read('')
62
76
  }
@@ -70,9 +84,12 @@ async function repositoryReader(repo: SelectedRepo, token: string | null, getIde
70
84
  }
71
85
  if (data.private === false && data.visibility === 'public') return data
72
86
  if (!token) throw new GithubApiError(404, 'no-repository')
73
- if (!(await githubRepoReadPermission(repo.fullName, token, await getIdentity(freshIdentity), fetchImpl))) {
87
+ const appToken = await installationToken()
88
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
89
+ if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
74
90
  throw new GithubApiError(404, 'no-repository')
75
91
  }
92
+ token = appToken
76
93
  return data
77
94
  }
78
95
  const current = await checkAccess()
@@ -105,7 +122,11 @@ async function repositoryReader(repo: SelectedRepo, token: string | null, getIde
105
122
  const type = item.submodule_git_url ? 'submodule' : item.type
106
123
  if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
107
124
  }
108
- return { entries, limited: data.length >= 1000 }
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 } : {}) }
109
130
  },
110
131
  }
111
132
  }
@@ -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>
@@ -71,7 +71,7 @@ function shareQueries(db: ManagedSql) {
71
71
  const repositories = db.prepare(`SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path
72
72
  FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
73
73
  WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`)
74
- const reports = db.prepare(`SELECT DISTINCT r.id, r.slug, r.filename, r.byte_size AS byteSize,
74
+ const reports = db.prepare(`SELECT DISTINCT r.id, r.slug, r.filename, r.analyzer, r.byte_size AS byteSize,
75
75
  r.sha256, r.repo_directory AS directory, sr.full_name AS github
76
76
  FROM managed_team_repo tr JOIN managed_report r ON r.repo_id = tr.repo_id
77
77
  JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
@@ -128,7 +128,7 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
128
128
  if (!grant) return null
129
129
  const { dependencies, security, ...team } = grant
130
130
  const permissions = { dependencies: dependencies === 1, security: security === 1 }
131
- const rows = await q.reports.all(team.id) as { id: string; slug: string; filename: string; byteSize: number; sha256: string; directory: string; github: string }[]
131
+ const rows = await q.reports.all(team.id) as { id: string; slug: string; filename: string; analyzer: string | null; byteSize: number; sha256: string; directory: string; github: string }[]
132
132
  const bundleRows = await q.bundles.all(team.id) as (ManagedBundle & { repoFullName: string })[]
133
133
  return {
134
134
  user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
@@ -138,7 +138,7 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
138
138
  reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
139
139
  repo: { github: row.github, directory: row.directory }, permissions })),
140
140
  team: { ...team,
141
- reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, repoFullName: row.github, repoDirectory: row.directory,
141
+ reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
142
142
  cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions]) })),
143
143
  bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,
144
144
  filename: row.filename, kind: row.kind, byteSize: row.byteSize, repoId: row.repoId!, repoDirectory: row.repoDirectory, repoFullName: row.repoFullName })),