@preventive/triage 1.0.0-alpha.27 → 1.0.0-alpha.29

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.
Files changed (83) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +16 -16
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +21 -28
  19. package/server-e2e/static.ts +3 -4
  20. package/server-managed/bundle-advisories.ts +53 -0
  21. package/server-managed/bundle-advisory-inventory.ts +75 -0
  22. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  23. package/server-managed/bundle-build-diagnostics.js +23 -0
  24. package/server-managed/bundle-build-leases.ts +41 -0
  25. package/server-managed/bundle-build-worker.js +54 -0
  26. package/server-managed/bundle-build.ts +168 -0
  27. package/server-managed/bundle-cache.ts +22 -13
  28. package/server-managed/bundle-summary-cache.ts +27 -9
  29. package/server-managed/bundle.ts +66 -1
  30. package/server-managed/cache-storage.ts +4 -0
  31. package/server-managed/content-access.ts +1 -1
  32. package/server-managed/db-methods.ts +122 -20
  33. package/server-managed/db-neon.ts +121 -32
  34. package/server-managed/db-schema.ts +2 -0
  35. package/server-managed/db.ts +5 -1
  36. package/server-managed/github-app.ts +10 -15
  37. package/server-managed/github-errors.ts +21 -0
  38. package/server-managed/github-issues.ts +1 -1
  39. package/server-managed/github-metadata.ts +34 -1
  40. package/server-managed/github-pulls.ts +142 -30
  41. package/server-managed/http.ts +244 -131
  42. package/server-managed/index.ts +28 -11
  43. package/server-managed/management-catalog.ts +45 -0
  44. package/server-managed/object-storage-vercel.ts +1 -1
  45. package/server-managed/public-workspace.ts +20 -14
  46. package/server-managed/report-migration.ts +1 -1
  47. package/server-managed/report-sources.ts +14 -4
  48. package/server-managed/repository-browser.ts +3 -0
  49. package/server-managed/repository-discovery.ts +1 -1
  50. package/server-managed/revisions.ts +35 -0
  51. package/server-managed/sql-postgres.ts +1 -0
  52. package/server-managed/sql.ts +14 -3
  53. package/server-managed/storage-db.ts +12 -5
  54. package/server-managed/storage-maintenance.ts +47 -22
  55. package/server-managed/storage-payload.ts +16 -4
  56. package/server-managed/storage-stores.ts +2 -2
  57. package/server-managed/storage.ts +6 -4
  58. package/server-managed/team-feed.ts +50 -5
  59. package/server-managed/team-reports.ts +85 -22
  60. package/server-managed/workspace-shares.ts +15 -3
  61. package/report/index.js +0 -260
  62. package/report/src/finding-id.js +0 -80
  63. package/report/src/finding.js +0 -314
  64. package/report/src/labels.js +0 -35
  65. package/report/src/md-structure.js +0 -471
  66. package/report/src/md-text.js +0 -167
  67. package/report/src/meta.js +0 -76
  68. package/report/src/parse-codex.js +0 -149
  69. package/report/src/parse-deepsec.js +0 -199
  70. package/report/src/parse-deepview-fields.js +0 -375
  71. package/report/src/parse-deepview-md.js +0 -186
  72. package/report/src/parse-md-id.js +0 -137
  73. package/report/src/parse-md.js +0 -323
  74. package/report/src/parse-piolium-id.js +0 -79
  75. package/report/src/parse-piolium-rows.js +0 -126
  76. package/report/src/parse-piolium-tokens.js +0 -185
  77. package/report/src/parse-piolium.js +0 -399
  78. package/report/src/security.js +0 -63
  79. package/report/src/severity.js +0 -18
  80. package/report/src/utf8.js +0 -21
  81. package/report/src/write-md-finding.js +0 -273
  82. package/report/src/write-md.js +0 -291
  83. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -1,25 +1,115 @@
1
1
  import { type GithubFixRef, type GithubFixResult, type GithubFixStatus, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
2
2
  import type { ManagedConfig } from './config.ts'
3
3
  import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
4
- import type { GithubMetadata } from './github-metadata.ts'
4
+ import type { GithubMetadata, GithubRepositoryVisibility } from './github-metadata.ts'
5
5
  import { ensureUserAccessToken } from './github-oauth.ts'
6
6
 
7
7
  type Metadata = Pick<GithubMetadata, 'title' | 'description' | 'status' | 'stateReason'>
8
8
  const MAX_GITHUB_LOOKUPS = 200
9
9
  const GITHUB_LOOKUP_TIMEOUT_MS = 10_000
10
10
  const GITHUB_METADATA_TTL_MS = 60_000
11
+ const GITHUB_PUBLIC_VISIBILITY_TTL_MS = 60_000
12
+ const GITHUB_PRIVATE_REPOSITORIES_URL = 'https://api.github.com/user/repos?visibility=private&per_page=100&page=1'
13
+
14
+ type Repository = TeamReportAccessSnapshot['repositories'][number]
15
+ type RepositoryPayload = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; permissions?: { pull?: unknown } }
16
+ type RepositoryChecks = { remaining: number; verified: Set<string> }
17
+ type RepositoryAccessContext = {
18
+ db: ManagedDb
19
+ visibility: GithubRepositoryVisibility[]
20
+ publicRepos: Set<string>
21
+ token: string | null
22
+ signal: AbortSignal
23
+ fetchImpl: typeof fetch
24
+ checks: RepositoryChecks
25
+ includePrivateList?: boolean
26
+ }
27
+
28
+ function githubRequest(token: string | null): RequestInit {
29
+ return {
30
+ headers: {
31
+ ...(token ? { authorization: `Bearer ${token}` } : {}), accept: 'application/vnd.github+json',
32
+ 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
33
+ },
34
+ redirect: 'error',
35
+ }
36
+ }
37
+
38
+ function repositoryMatches(repo: Repository, body: RepositoryPayload | null): boolean {
39
+ return body?.id === repo.repoId && typeof body.full_name === 'string' && body.full_name.toLowerCase() === repo.github.toLowerCase()
40
+ }
41
+
42
+ async function listPrivateRepositories(token: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<(RepositoryPayload | null)[]> {
43
+ try {
44
+ const response = await fetchImpl(GITHUB_PRIVATE_REPOSITORIES_URL, githubRequest(token))
45
+ if (!response.ok) return []
46
+ const body = await response.json() as (RepositoryPayload | null)[] | null
47
+ return Array.isArray(body) && body.length <= 100 && !signal.aborted ? body : []
48
+ } catch { return [] } // A failed list is unknown access; direct checks can still verify it.
49
+ }
50
+
51
+ // Public visibility can be reused briefly across users. Private access stays
52
+ // request-local: one page of the viewer's own repo list grants matching repos,
53
+ // then direct checks cover omissions, pagination and failed list requests.
54
+ async function readableRepositories(repositories: Repository[], { db, visibility, publicRepos, token, signal, fetchImpl, checks, includePrivateList = true }: RepositoryAccessContext): Promise<Set<string>> {
55
+ const readable = new Set(publicRepos)
56
+ const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
57
+ const unverified = repositories.filter(repo => !readable.has(repo.github))
58
+ const updates = new Map<number, GithubRepositoryVisibility>()
59
+ const grant = (repo: Repository, body: RepositoryPayload, checkedAt: number) => {
60
+ const isPublic = body.private === false && body.visibility === 'public'
61
+ readable.add(repo.github)
62
+ checks.verified.add(repo.github)
63
+ if (isPublic) publicRepos.add(repo.github)
64
+ updates.set(repo.repoId, { repoId: repo.repoId, github: repo.github, public: isPublic, checkedAt })
65
+ }
66
+ if (includePrivateList && token && unverified.length > 0 && !signal.aborted) {
67
+ const checkedAt = Date.now()
68
+ for (const entry of await listPrivateRepositories(token, signal, fetchImpl)) {
69
+ const repo = typeof entry?.id === 'number' ? byId.get(entry.id) : undefined
70
+ if (repo && !readable.has(repo.github) && repositoryMatches(repo, entry) && entry?.permissions?.pull === true) grant(repo, entry, checkedAt)
71
+ }
72
+ }
73
+ const formerlyPublic = new Set(visibility.filter(entry => entry.public
74
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => entry.repoId))
75
+ const pending = unverified.filter(repo => !readable.has(repo.github) && (token || formerlyPublic.has(repo.repoId))).slice(0, checks.remaining)
76
+ checks.remaining -= pending.length
77
+ for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
78
+ await Promise.all(pending.slice(i, i + 4).map(async repo => {
79
+ if (signal.aborted) return
80
+ const checkedAt = Date.now()
81
+ try {
82
+ const response = await fetchImpl(`https://api.github.com/repos/${repo.github}`, githubRequest(token))
83
+ if (!response.ok) return
84
+ const body = await response.json() as RepositoryPayload | null
85
+ if (!signal.aborted && body && repositoryMatches(repo, body)
86
+ && (token || (body.private === false && body.visibility === 'public'))) grant(repo, body, checkedAt)
87
+ } catch { /* Unverified access must never expose cached metadata. */ }
88
+ }))
89
+ }
90
+ if (updates.size > 0) await db.setGithubRepositoryVisibility([...updates.values()])
91
+ return readable
92
+ }
93
+
94
+ async function verifyPublicRefreshes(config: ManagedConfig, userId: string, repositories: Repository[], context: RepositoryAccessContext): Promise<string | null> {
95
+ // Cached public metadata needs no credentials. New content uses a viewer
96
+ // token only after a live check, so privatization cannot publish private data
97
+ // under an old public grant. Anonymous reads remain available without a token.
98
+ const { db, publicRepos } = context
99
+ const token = context.token ?? await ensureUserAccessToken(config, db, userId, Date.now(), context.fetchImpl)
100
+ if (token) {
101
+ const currentPublic = new Set<string>()
102
+ const readable = await readableRepositories(repositories, { ...context, publicRepos: currentPublic, token, includePrivateList: false })
103
+ for (const repo of repositories) if (readable.has(repo.github) && !currentPublic.has(repo.github)) publicRepos.delete(repo.github)
104
+ }
105
+ return token
106
+ }
11
107
 
12
108
  // ref.repo is always the canonical name from managed_selected_repo, never an
13
109
  // owner/repo/path taken from a Fix URL. Redirects cannot move the user token.
14
- async function fetchMetadata(ref: GithubFixRef, token: string, fetchImpl: typeof fetch): Promise<Metadata | null> {
110
+ async function fetchMetadata(ref: GithubFixRef, token: string | null, fetchImpl: typeof fetch): Promise<Metadata | null> {
15
111
  try {
16
- const response = await fetchImpl(`https://api.github.com/repos/${ref.repo}/${ref.kind === 'pull' ? 'pulls' : 'issues'}/${ref.number}`, {
17
- headers: {
18
- authorization: `Bearer ${token}`, accept: 'application/vnd.github+json',
19
- 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
20
- },
21
- redirect: 'error',
22
- })
112
+ const response = await fetchImpl(`https://api.github.com/repos/${ref.repo}/${ref.kind === 'pull' ? 'pulls' : 'issues'}/${ref.number}`, githubRequest(token))
23
113
  if (!response.ok) return null
24
114
  const body = await response.json() as { number?: unknown; title?: unknown; body?: unknown; state?: unknown; merged?: unknown;
25
115
  draft?: unknown; base?: { repo?: { full_name?: unknown } }; repository_url?: unknown; pull_request?: unknown; state_reason?: unknown } | null
@@ -43,8 +133,8 @@ async function fetchMetadata(ref: GithubFixRef, token: string, fetchImpl: typeof
43
133
  }
44
134
 
45
135
  // The workspace handler checks user -> team membership and finding visibility.
46
- // Every shared cache read is additionally restricted to that team’s repositories,
47
- // including for admins; cached metadata is never itself an access grant.
136
+ // Shared private metadata also requires this viewer's live GitHub access,
137
+ // including for admins. Only recently verified public repos skip that check.
48
138
  export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot, urls: string[], fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubFixResult[]> {
49
139
  const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
50
140
  const parsed = urls.map(parseGithubFixUrl)
@@ -56,13 +146,34 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
56
146
  jobs.set(key, { ...ref, repo: repo.github })
57
147
  return key
58
148
  })
59
- const metadata = new Map((await db.listGithubMetadata([...jobs.keys()])).map(entry => [entry.key, entry]))
149
+ if (jobs.size === 0) return []
60
150
  const now = Date.now()
151
+ const signal = AbortSignal.timeout(GITHUB_LOOKUP_TIMEOUT_MS)
152
+ const fetchWithinDeadline: typeof fetch = (input, init) => {
153
+ signal.throwIfAborted()
154
+ return fetchImpl(input, { ...init, signal })
155
+ }
156
+ const repositories = [...new Map([...jobs.values()].map(ref => {
157
+ const repo = allowed.get(ref.repo.toLowerCase())!
158
+ return [repo.repoId, repo] as const
159
+ })).values()]
160
+ const visibility = await db.listGithubRepositoryVisibility(repositories.map(repo => repo.repoId))
161
+ const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
162
+ const publicRepos = new Set(visibility.filter(entry => entry.public && entry.checkedAt <= now
163
+ && entry.checkedAt + GITHUB_PUBLIC_VISIBILITY_TTL_MS > now
164
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
165
+ let token = repositories.some(repo => !publicRepos.has(repo.github))
166
+ ? await ensureUserAccessToken(config, db, snapshot.user.id, now, fetchWithinDeadline) : null
167
+ const checks: RepositoryChecks = { remaining: MAX_GITHUB_LOOKUPS, verified: new Set() }
168
+ const context = { db, visibility, publicRepos, token, signal, fetchImpl: fetchWithinDeadline, checks }
169
+ const readable = await readableRepositories(repositories, context)
170
+ const authorized = [...jobs.entries()].filter(([, ref]) => readable.has(ref.repo))
171
+ const metadata = new Map((await db.listGithubMetadata(authorized.map(([key]) => key))).map(entry => [entry.key, entry]))
61
172
  const lastCheckedAt = (key: string) => {
62
173
  const cached = metadata.get(key)
63
174
  return Math.max(cached?.fetchedAt ?? 0, cached?.attemptedAt ?? 0)
64
175
  }
65
- const pending = [...jobs.entries()].filter(([key, ref]) => {
176
+ const pending = authorized.filter(([key, ref]) => {
66
177
  const cached = metadata.get(key)
67
178
  // Backfill closed issues cached before closure reasons existed, within the
68
179
  // same authorized/capped queue. Even an unknown reason completes backfill.
@@ -72,25 +183,18 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
72
183
  // Only stale/missing, distinct, authorized entries consume the request budget.
73
184
  // Prioritize missing/oldest entries so a large workspace makes progress.
74
185
  if (pending.length > 0) {
75
- const signal = AbortSignal.timeout(GITHUB_LOOKUP_TIMEOUT_MS)
76
- const fetchWithinDeadline: typeof fetch = (input, init) => {
77
- signal.throwIfAborted()
78
- return fetchImpl(input, { ...init, signal })
79
- }
80
- const token = await ensureUserAccessToken(config, db, snapshot.user.id, now, fetchWithinDeadline)
186
+ const publicRefreshes = new Set(pending.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
187
+ if (publicRefreshes.size > 0) token = await verifyPublicRefreshes(config, snapshot.user.id, repositories.filter(repo => publicRefreshes.has(repo.github)), context)
81
188
  const fresh: Omit<GithubMetadata, 'attemptedAt'>[] = []
82
189
  const attempted: string[] = []
83
- if (token) {
84
- // Token refresh and all waves share one deadline, with at most four
85
- // upstream calls in flight. Failure retains even stale cached metadata.
86
- for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
87
- await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
88
- if (signal.aborted) return
89
- if (metadata.has(key)) attempted.push(key)
90
- const result = await fetchMetadata(ref, token, fetchWithinDeadline)
91
- if (result) fresh.push({ key, ...result, fetchedAt: Date.now() })
92
- }))
93
- }
190
+ // All waves and access checks share one deadline and four-call limit.
191
+ for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
192
+ await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
193
+ if (signal.aborted) return
194
+ if (metadata.has(key)) attempted.push(key)
195
+ const result = await fetchMetadata(ref, publicRepos.has(ref.repo) && !checks.verified.has(ref.repo) ? null : token, fetchWithinDeadline)
196
+ if (result) fresh.push({ key, ...result, fetchedAt: Date.now() })
197
+ }))
94
198
  }
95
199
  // Only started cached reads rotate. Missing entries retain priority, and
96
200
  // jobs skipped by the cap, deadline or absent token keep their place.
@@ -101,6 +205,14 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
101
205
  for (const entry of await db.listGithubMetadata(fresh.map(row => row.key))) metadata.set(entry.key, entry)
102
206
  }
103
207
  }
208
+ if (publicRepos.size > 0) {
209
+ // Another request may already have discovered a public -> private change
210
+ // and written new private metadata. Honor that newer visibility observation.
211
+ const current = await db.listGithubRepositoryVisibility(repositories.filter(repo => publicRepos.has(repo.github)).map(repo => repo.repoId))
212
+ const stillPublic = new Set(current.filter(entry => entry.public
213
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
214
+ for (const [key, ref] of authorized) if (publicRepos.has(ref.repo) && !stillPublic.has(ref.repo)) metadata.delete(key)
215
+ }
104
216
  // The handler rechecks workspace access and saved Fix links before returning.
105
217
  return urls.flatMap((url, index): GithubFixResult[] => {
106
218
  const key = keys[index]