@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.
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/managed/routes.js +3 -0
- package/common/server-info.ts +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +74 -58
- package/out/client-sync.js +3 -3
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +186 -159
- package/package.json +12 -1
- package/server-e2e/app.ts +2 -1
- package/server-e2e/config.ts +2 -0
- package/server-managed/config.ts +9 -16
- package/server-managed/db-methods.ts +52 -21
- package/server-managed/db-neon.ts +6 -1
- package/server-managed/db-schema.ts +1 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/github-app.ts +107 -49
- package/server-managed/github-errors.ts +28 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-oauth.ts +7 -23
- package/server-managed/http.ts +181 -9
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/report-sources.ts +4 -2
- package/server-managed/repository-browser.ts +127 -0
- package/server-managed/workspace-shares.ts +3 -3
|
@@ -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-
|
|
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
|
|
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 })),
|