@preventive/triage 1.0.0-alpha.20 → 1.0.0-alpha.22

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,111 @@
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
+ // Create once per HTTP request. Coalesce concurrent credential lookups without
20
+ // caching repository visibility or permissions, or sharing credentials across users.
21
+ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
22
+ const tokens = new Map<number, Promise<string | null>>()
23
+ let identity: Promise<GithubIdentity> | undefined
24
+ const getIdentity = (fresh = false): Promise<GithubIdentity> => {
25
+ if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
26
+ if (!identity || fresh) {
27
+ identity = githubUserIdentity(user.token, fetchImpl).then(value => {
28
+ if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
29
+ return value
30
+ })
31
+ }
32
+ return identity
33
+ }
34
+ return {
35
+ async reader(repo: SelectedRepo) {
36
+ let token = Promise.resolve<string | null>(null)
37
+ if (repo.installationId != null) {
38
+ 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.
42
+ return null
43
+ })
44
+ if (!cached) tokens.set(repo.installationId, token)
45
+ }
46
+ return repositoryReader(repo, await token, getIdentity, fetchImpl)
47
+ },
48
+ }
49
+ }
50
+
51
+ async function repositoryReader(repo: SelectedRepo, token: string | null, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
52
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
53
+ const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
54
+ const metadata = async () => {
55
+ try { return await read('') }
56
+ 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.
60
+ token = null
61
+ return read('')
62
+ }
63
+ }
64
+ const checkAccess = async (freshIdentity = false) => {
65
+ // Stored visibility can be stale (or internal with private=false). Require
66
+ // current, explicit public visibility or the caller's effective permission.
67
+ const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
68
+ if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
69
+ throw new GithubApiError(404, 'no-repository')
70
+ }
71
+ if (data.private === false && data.visibility === 'public') return data
72
+ if (!token) throw new GithubApiError(404, 'no-repository')
73
+ if (!(await githubRepoReadPermission(repo.fullName, token, await getIdentity(freshIdentity), fetchImpl))) {
74
+ throw new GithubApiError(404, 'no-repository')
75
+ }
76
+ return data
77
+ }
78
+ const current = await checkAccess()
79
+ const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
80
+ return {
81
+ recheckAccess: () => checkAccess(true),
82
+ async refs() {
83
+ // Suggestions are bounded; the input also accepts any branch or tag name.
84
+ const results = await Promise.all(['branches', 'tags'].map(async kind => {
85
+ const data = await read(`/${kind}?per_page=100`)
86
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
87
+ return data.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
88
+ }))
89
+ return { defaultBranch, branches: results[0], tags: results[1] }
90
+ },
91
+ async commit(ref: string) {
92
+ if (/^[a-f\d]{40}$/iu.test(ref)) return ref
93
+ const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
94
+ if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
95
+ return data.sha
96
+ },
97
+ async directory(path: string, commit: string) {
98
+ const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
99
+ if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
100
+ const entries: RepositoryEntry[] = []
101
+ for (const item of data) {
102
+ if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
103
+ const expectedPath = path ? `${path}/${item.name}` : item.name
104
+ if (item.path !== expectedPath) continue
105
+ const type = item.submodule_git_url ? 'submodule' : item.type
106
+ if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
107
+ }
108
+ return { entries, limited: data.length >= 1000 }
109
+ },
110
+ }
111
+ }
@@ -2,6 +2,8 @@ import type { StoredUser } from './db.ts'
2
2
 
3
3
  const WHITEHAT = [
4
4
  291301, // ChALkeR
5
+ 259122746, // exo-nikita
6
+ 247161625, // ex0sec
5
7
  ]
6
8
  const whitehatIds = new Set(WHITEHAT)
7
9