@preventive/triage 1.0.0-alpha.21 → 1.0.0-alpha.23

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.
@@ -4,6 +4,7 @@ import { extname } from 'node:path'
4
4
  import { promisify } from 'node:util'
5
5
  import { gzip } from 'node:zlib'
6
6
  import { bundleSourcesAsMap } from '../common/bundle-sources.js'
7
+ import { bundleSourceImports } from '../common/bundle-source-links.js'
7
8
  import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
8
9
  import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
9
10
  import type { CacheStorage } from './cache-storage.ts'
@@ -27,7 +28,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
27
28
  }
28
29
  function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
29
30
  const key = createHash('sha256').update(JSON.stringify([
30
- 'finding-access-v4', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
31
+ 'finding-access-v5', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
31
32
  ])).digest('hex')
32
33
  return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
33
34
  }
@@ -88,7 +89,8 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
88
89
  const details = await readBundleDetails(bundle, bundles)
89
90
  if (!details) return false
90
91
  const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
91
- const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection })), { level: 6 })
92
+ const imports = [...bundleSourceImports(details, new Map(selection.files))].map(([parent, targets]) => [parent, [...targets]])
93
+ const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection, imports })), { level: 6 })
92
94
  // A duplicate with the same hash AND format can use these parsed bytes.
93
95
  // Another format must not keep a deleted variant's late build alive.
94
96
  if (!(await referenced(report, bundle))) return false
@@ -0,0 +1,127 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { SelectedRepo } from './db.ts'
3
+ import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
+
5
+ export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
6
+
7
+ // Restricted managers can walk the ancestors of their grants, without seeing
8
+ // sibling names or files outside those grants. null means the entire repo.
9
+ export function scopedDirectory(path: string, scopes: (string | null)[]): RepositoryEntry[] | null {
10
+ if (scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))) return null
11
+ const prefix = path ? path + '/' : ''
12
+ const names = new Set(scopes.filter((scope): scope is string => !!scope && scope.startsWith(prefix)).map(scope => scope.slice(prefix.length).split('/')[0]!))
13
+ return [...names].toSorted().map(name => ({ name, path: prefix + name, type: 'dir' }))
14
+ }
15
+
16
+ export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
17
+ type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
18
+
19
+ function credentialFailure(err: unknown): err is GithubApiError {
20
+ return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
21
+ }
22
+
23
+ // Create once per HTTP request. Coalesce concurrent credential lookups without
24
+ // caching repository visibility or permissions, or sharing credentials across users.
25
+ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
26
+ const tokens = new Map<number, Promise<string | null>>()
27
+ let identity: Promise<GithubIdentity> | undefined
28
+ const getIdentity = (fresh = false): Promise<GithubIdentity> => {
29
+ if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
30
+ if (!identity || fresh) {
31
+ identity = githubUserIdentity(user.token, fetchImpl).then(value => {
32
+ if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
33
+ return value
34
+ })
35
+ }
36
+ return identity
37
+ }
38
+ return {
39
+ reader(repo: SelectedRepo) {
40
+ const installationToken = () => {
41
+ if (repo.installationId == null) return Promise.resolve(null)
42
+ const cached = tokens.get(repo.installationId)
43
+ const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
44
+ if (!credentialFailure(err)) throw err
45
+ // Stale installations cannot prevent verified public access.
46
+ return null
47
+ })
48
+ if (!cached) tokens.set(repo.installationId, token)
49
+ return token
50
+ }
51
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
52
+ },
53
+ }
54
+ }
55
+
56
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
57
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
58
+ // Public reads can use the signed-in user's quota without repository grants.
59
+ // Nonpublic reads still require the repository App and the effective-user gate.
60
+ let token = userToken ?? await installationToken()
61
+ const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
62
+ const metadata = async () => {
63
+ try { return await read('') }
64
+ catch (err) {
65
+ if (!token || !credentialFailure(err)) throw err
66
+ const fallback = await installationToken()
67
+ if (fallback && fallback !== token) {
68
+ token = fallback
69
+ try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
70
+ }
71
+ // Anonymous fallback is only for unavailable credentials, never rate
72
+ // limits, network errors, or GitHub outages.
73
+ token = null
74
+ return read('')
75
+ }
76
+ }
77
+ const checkAccess = async (freshIdentity = false) => {
78
+ // Stored visibility can be stale (or internal with private=false). Require
79
+ // current, explicit public visibility or the caller's effective permission.
80
+ const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
81
+ if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
82
+ throw new GithubApiError(404, 'no-repository')
83
+ }
84
+ if (data.private === false && data.visibility === 'public') return data
85
+ if (!token) throw new GithubApiError(404, 'no-repository')
86
+ const appToken = await installationToken()
87
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
88
+ if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
89
+ throw new GithubApiError(404, 'no-repository')
90
+ }
91
+ token = appToken
92
+ return data
93
+ }
94
+ const current = await checkAccess()
95
+ const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
96
+ return {
97
+ recheckAccess: () => checkAccess(true),
98
+ async refs() {
99
+ // Suggestions are bounded; the input also accepts any branch or tag name.
100
+ const results = await Promise.all(['branches', 'tags'].map(async kind => {
101
+ const data = await read(`/${kind}?per_page=100`)
102
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
103
+ return data.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
104
+ }))
105
+ return { defaultBranch, branches: results[0], tags: results[1] }
106
+ },
107
+ async commit(ref: string) {
108
+ if (/^[a-f\d]{40}$/iu.test(ref)) return ref
109
+ const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
110
+ if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
111
+ return data.sha
112
+ },
113
+ async directory(path: string, commit: string) {
114
+ const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
115
+ if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
116
+ const entries: RepositoryEntry[] = []
117
+ for (const item of data) {
118
+ if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
119
+ const expectedPath = path ? `${path}/${item.name}` : item.name
120
+ if (item.path !== expectedPath) continue
121
+ const type = item.submodule_git_url ? 'submodule' : item.type
122
+ if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
123
+ }
124
+ return { entries, limited: data.length >= 1000 }
125
+ },
126
+ }
127
+ }
@@ -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 })),