@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.45

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 (183) hide show
  1. package/common/bundle-sources.js +20 -1
  2. package/common/default-scan-models.ts +1 -1
  3. package/common/dependency-reviews.d.ts +0 -5
  4. package/common/dependency-reviews.js +0 -17
  5. package/common/managed/agent-tasks.ts +292 -0
  6. package/common/managed/github-agents.ts +92 -0
  7. package/common/managed/github-logins.ts +49 -0
  8. package/common/managed/roles.ts +7 -0
  9. package/common/managed/routes.js +88 -7
  10. package/common/managed/team-app.d.ts +5 -2
  11. package/common/managed/team-app.js +2 -2
  12. package/common/server-info.ts +3 -0
  13. package/common/workspace-app.js +6 -3
  14. package/out/brotli-fallback.js +3 -3
  15. package/out/chunk-2XUEZHIP.js +18 -18
  16. package/out/chunk-2ZDZWLFY.js +1 -0
  17. package/out/chunk-3PS4KWD2.js +10 -0
  18. package/out/chunk-5DHL5NG3.js +2 -0
  19. package/out/chunk-6HNXNIQY.js +52 -0
  20. package/out/chunk-7C7DWL7I.js +1 -0
  21. package/out/chunk-AFLZH7WG.js +1 -0
  22. package/out/chunk-BC2UCLFJ.js +1 -0
  23. package/out/chunk-C4Z6ZBXV.js +6 -6
  24. package/out/chunk-GDPCLSOS.js +2 -0
  25. package/out/chunk-GO7FT4OD.js +1 -0
  26. package/out/chunk-HMAJDVXN.js +1 -0
  27. package/out/chunk-I2MDGSQX.js +3 -0
  28. package/out/chunk-IYPY47MC.js +1 -0
  29. package/out/chunk-IZ7POWI3.js +18 -0
  30. package/out/chunk-JCIIBLKR.js +1 -1
  31. package/out/chunk-NPW52BM2.js +17 -0
  32. package/out/chunk-NRPC74AM.js +1 -1
  33. package/out/chunk-NV7ES4EW.js +1 -0
  34. package/out/chunk-OKRLLE5I.js +17 -0
  35. package/out/chunk-OOJM4CTU.js +1 -1
  36. package/out/chunk-PNHBZGDD.js +1 -1
  37. package/out/chunk-PTGDYLNS.js +36 -0
  38. package/out/chunk-PXK7EU4V.js +1 -0
  39. package/out/chunk-Q6MVYGYV.js +3 -0
  40. package/out/chunk-QB4CBOX2.js +1 -0
  41. package/out/chunk-QRD6GIG5.js +229 -0
  42. package/out/chunk-R3NZFXVR.js +1 -0
  43. package/out/chunk-R5O6FAW4.js +1 -1
  44. package/out/chunk-SXV2T5C4.js +80 -0
  45. package/out/chunk-TUII34K2.js +1 -1
  46. package/out/chunk-VFWMQ3LG.js +2 -0
  47. package/out/chunk-VH4XMZIW.js +2 -0
  48. package/out/chunk-WFYLMF6Z.js +10 -0
  49. package/out/chunk-WI7Z5A2T.js +6 -0
  50. package/out/chunk-WQ74ZBZF.js +7 -0
  51. package/out/chunk-XVBEOGOS.js +3 -0
  52. package/out/client-managed-import.js +3 -3
  53. package/out/client-managed.js +31 -35
  54. package/out/client-sync.js +11 -11
  55. package/out/compare.js +35 -0
  56. package/out/dialogs.js +51 -0
  57. package/out/github-issue-authorized.html +1 -1
  58. package/out/github-issue-authorized.js +1 -1
  59. package/out/graph.js +17 -17
  60. package/out/index.html +30 -34
  61. package/out/prism.js +14 -14
  62. package/out/scan.js +2 -0
  63. package/out/terminal.js +294 -293
  64. package/out/view.css +1 -1
  65. package/out/view.js +16 -107
  66. package/package.json +51 -6
  67. package/risk/actions/findings.js +156 -0
  68. package/risk/actions/resolve.js +219 -0
  69. package/risk/actions/scripts/commands.js +182 -0
  70. package/risk/actions/scripts/grade.js +113 -0
  71. package/risk/actions/scripts/packages.js +123 -0
  72. package/risk/actions/scripts/shell.js +165 -0
  73. package/risk/actions/uses.js +76 -0
  74. package/risk/actions/workflow.js +110 -0
  75. package/risk/files/hosts.js +6 -6
  76. package/risk/files/readability.js +23 -17
  77. package/risk/files/runs-code.js +18 -6
  78. package/risk/files/script.js +7 -3
  79. package/risk/files/stamps.js +8 -7
  80. package/risk/index.d.ts +68 -1
  81. package/risk/index.js +6 -2
  82. package/risk/npm/assess.js +1 -1
  83. package/risk/npm/compare.js +34 -10
  84. package/risk/npm/files.js +413 -2
  85. package/risk/npm/tarball.js +56 -73
  86. package/server-managed/agent-merges.ts +198 -0
  87. package/server-managed/app-agents.ts +30 -0
  88. package/server-managed/avatar-store.ts +1 -0
  89. package/server-managed/commit-coauthors.ts +83 -0
  90. package/server-managed/commit-lockfiles.ts +62 -0
  91. package/server-managed/db-methods.ts +29 -10
  92. package/server-managed/db-neon.ts +30 -2
  93. package/server-managed/db-schema.ts +9 -2
  94. package/server-managed/db.ts +7 -0
  95. package/server-managed/dependabot-access.ts +60 -0
  96. package/server-managed/dependabot-alerts.ts +185 -0
  97. package/server-managed/dependency-review-comments.ts +97 -0
  98. package/server-managed/dependency-reviews.ts +65 -15
  99. package/server-managed/github-accounts.ts +33 -0
  100. package/server-managed/github-actions.ts +71 -0
  101. package/server-managed/github-app.ts +23 -9
  102. package/server-managed/github-errors.ts +2 -0
  103. package/server-managed/github-link-oauth.ts +69 -0
  104. package/server-managed/github-links.ts +204 -0
  105. package/server-managed/github-oauth.ts +5 -2
  106. package/server-managed/github-users.ts +30 -0
  107. package/server-managed/http.ts +1122 -52
  108. package/server-managed/index.ts +6 -2
  109. package/server-managed/locked-packages.ts +37 -16
  110. package/server-managed/lockfile-scope.ts +49 -0
  111. package/server-managed/npm-delta-worker.js +23 -0
  112. package/server-managed/npm-delta.ts +173 -0
  113. package/server-managed/npm-insights.ts +20 -11
  114. package/server-managed/npm-kept.ts +51 -4
  115. package/server-managed/npm-loads.ts +20 -13
  116. package/server-managed/npm-packages.ts +24 -64
  117. package/server-managed/pull-comment-reviews.ts +161 -0
  118. package/server-managed/pull-dependencies.ts +280 -0
  119. package/server-managed/pull-dependency-reviews.ts +318 -0
  120. package/server-managed/pull-reviews.ts +205 -0
  121. package/server-managed/release-commits.ts +84 -0
  122. package/server-managed/repository-actions.ts +209 -0
  123. package/server-managed/repository-agents.ts +220 -0
  124. package/server-managed/repository-browser.ts +54 -13
  125. package/server-managed/repository-changes.ts +284 -0
  126. package/server-managed/repository-code.ts +52 -0
  127. package/server-managed/repository-dependency-reviews.ts +135 -0
  128. package/server-managed/repository-history.ts +445 -0
  129. package/server-managed/repository-issues.ts +181 -0
  130. package/server-managed/repository-pulse.ts +432 -0
  131. package/server-managed/repository-risk-read.ts +165 -0
  132. package/server-managed/repository-risk.ts +113 -105
  133. package/server-managed/reviewed-dependencies.ts +149 -0
  134. package/server-managed/session.ts +9 -5
  135. package/server-managed/sql.ts +1 -1
  136. package/server-managed/storage-stores.ts +5 -0
  137. package/server-managed/team-app.ts +19 -3
  138. package/server-managed/workspace-shares.ts +4 -1
  139. package/server.ts +11 -0
  140. package/out/chunk-2ABLKCOY.js +0 -46
  141. package/out/chunk-2KBF5TZ7.js +0 -2
  142. package/out/chunk-35VE6CDH.js +0 -2
  143. package/out/chunk-3BZ7JERB.js +0 -1
  144. package/out/chunk-4XCDFGDP.js +0 -10
  145. package/out/chunk-5LSXOLUS.js +0 -1
  146. package/out/chunk-5RCM2J63.js +0 -1
  147. package/out/chunk-7B6HB4D7.js +0 -1
  148. package/out/chunk-AJ4GTATZ.js +0 -3
  149. package/out/chunk-CH4UN6WK.js +0 -225
  150. package/out/chunk-CMQA4DM6.js +0 -1
  151. package/out/chunk-CVVJAXVY.js +0 -2
  152. package/out/chunk-DIJJNTRH.js +0 -10
  153. package/out/chunk-EDKLBQQN.js +0 -17
  154. package/out/chunk-F47DXCEV.js +0 -33
  155. package/out/chunk-FK3DZEKL.js +0 -1
  156. package/out/chunk-G6AJWENO.js +0 -1
  157. package/out/chunk-GJ4IUCNS.js +0 -1
  158. package/out/chunk-GNXOKIGP.js +0 -80
  159. package/out/chunk-GZ4MS3TE.js +0 -13
  160. package/out/chunk-GZWM7UQM.js +0 -2
  161. package/out/chunk-IU3CCINR.js +0 -3
  162. package/out/chunk-K3X5EJBQ.js +0 -2
  163. package/out/chunk-KEX2ATEW.js +0 -2
  164. package/out/chunk-KYCNJVB7.js +0 -1
  165. package/out/chunk-LPCTJYNA.js +0 -7
  166. package/out/chunk-O4RJY5TP.js +0 -2
  167. package/out/chunk-OD4LPN3H.js +0 -1
  168. package/out/chunk-QWDWTBM7.js +0 -1
  169. package/out/chunk-QYWKC557.js +0 -1
  170. package/out/chunk-R75XOKUP.js +0 -1
  171. package/out/chunk-SWTLADW5.js +0 -1
  172. package/out/chunk-T7OKH4VW.js +0 -1
  173. package/out/chunk-TZ5KB2Y5.js +0 -10
  174. package/out/chunk-UNTRGE26.js +0 -2
  175. package/out/chunk-UTR2LVME.js +0 -2
  176. package/out/chunk-UYAXVRVL.js +0 -1
  177. package/out/chunk-V3ME6FI5.js +0 -2
  178. package/out/chunk-V5O7M2YT.js +0 -1
  179. package/out/chunk-VIHGO77Y.js +0 -1
  180. package/out/chunk-W4J3MGLB.js +0 -1
  181. package/out/chunk-WGG7KQUH.js +0 -1
  182. package/out/chunk-XYFXXB2G.js +0 -10
  183. package/out/chunk-ZESLQCTF.js +0 -6
@@ -12,58 +12,47 @@
12
12
  // host with an integrity, such as a company's mirror of npm's, as npm's
13
13
  // package of its name and version where npm's integrity is among it; one
14
14
  // from elsewhere (git, a tarball with no integrity, a file) is listed, not
15
- // assessed. Repositories are read one at a time, as GitHub's limits are the
16
- // caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
15
+ // assessed. Repositories are read a few at a time (READ_AHEAD), each told in
16
+ // turn, as GitHub's limits are the caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
17
17
  // makes spare assessments, those only devDependencies need in every lockfile
18
18
  // that locks them (LockedPackage `dev`) once all the rest are, as an install
19
19
  // of no devDependencies leaves them out. A lockfile's packages of npm's
20
20
  // registry or a mirror are asked of npm's advisories all at once, as `npm
21
21
  // audit` asks, each advisory told with whether it is of malware (risk's
22
22
  // npmMalwareAdvisory), which alone is a supply chain's risk; the rest are
23
- // vulnerabilities, told beside.
24
- import type { LockfileFormat, RepoLockfile } from '@preventive/upstream/github.js'
25
- import type { Severity } from '../risk/index.js'
23
+ // vulnerabilities, told beside. A repository's Dependabot alerts of npm's, open or dismissed,
24
+ // are told once its lockfiles' advisories are, each with the versions its
25
+ // lockfile locks that it covers, for the page to blend them into npm's; one
26
+ // whose alerts couldn't be had tells why, and the scan goes on. Its GitHub
27
+ // Actions are told once its lockfiles are, the references they make
28
+ // resolved as the scan goes on (repository-actions.ts).
29
+ import type { LockfileFormat } from '@preventive/upstream/github.js'
30
+ import { type Severity, npmMalwareAdvisory } from '../risk/index.js'
26
31
  import { isNpmPackageName, isNpmPackageSpec } from '../common/managed/npm-packages.js'
27
32
  import type { NpmVersionDocument } from './npm-packages.ts'
28
33
  import { type Lru, lru } from './lru.ts'
29
34
  import type { KeptStore } from './npm-kept.ts'
30
35
  import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
31
- import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
36
+ import type { LockedPackage } from './locked-packages.ts'
32
37
  import { type Queue, queue } from './queue.ts'
33
38
  import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
34
39
  import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
35
40
  import { type AdvisoryRow, type Audit, type AuditSource, auditLockfile } from './repository-risk-audit.ts'
41
+ import { type DependabotRead, type ListedLockfile, type LockfileRead, type OpenedRepository, type RepositoryFailure, type RepositoryRead, SCAN_READ_KEPT_MS,
42
+ readRepository } from './repository-risk-read.ts'
43
+ import { type DependabotAlert, alertVersions } from './dependabot-alerts.ts'
44
+ import type { ActionsSource } from '../risk/index.js'
45
+ import { type ActionAnswers, type ActionLine, type ActionRef, type WorkflowLine, type WorkflowsLine, type WorkflowsRead, actionsLines, answerWeight,
46
+ lockedNames, resolvingActions } from './repository-actions.ts'
36
47
 
37
48
  export { RISK_KEPT_MS, highestSeverity } from './repository-risk-assess.ts'
38
-
39
- // The formats of npm's ecosystem, as findRepoLockfiles names them, and those
40
- // of them read here.
41
- const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
42
- const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
49
+ export { type OpenedRepository, SCAN_READ_KEPT_MS } from './repository-risk-read.ts'
43
50
 
44
51
  // A repository to read: where `paths` are given, a team's directories in it,
45
52
  // only the lockfiles in them or above them, as a workspace's root lockfile
46
53
  // locks them too.
47
54
  export interface RepositoryRiskRepo { repoId: number; fullName: string; paths?: readonly string[] }
48
55
 
49
- // Whether a lockfile at `path` is of `paths` (RepositoryRiskRepo).
50
- function lockfileOf(path: string, paths: readonly string[] | undefined) {
51
- const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
52
- return !paths || paths.some(scope => dir === '' || dir === scope || dir.startsWith(`${scope}/`) || scope.startsWith(`${dir}/`))
53
- }
54
-
55
- type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
56
-
57
- // A repository opened at its default branch's head (`commit`): its lockfiles,
58
- // as findRepoLockfiles lists them, each with its blob's id, and a file's
59
- // text; `recheck` checks access to it again, throwing where it is gone.
60
- export interface OpenedRepository {
61
- commit: string
62
- lockfiles(): Promise<ListedLockfile[]>
63
- file(path: string): Promise<string>
64
- recheck(): Promise<void>
65
- }
66
-
67
56
  export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends AuditSource {
68
57
  repos(): Promise<R[]>
69
58
  open(repo: R): Promise<OpenedRepository>
@@ -94,10 +83,21 @@ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends Audi
94
83
  // Readies npm's downloads of packages before they are assessed
95
84
  // (scan-downloads.ts); without it, each assessment asks its own.
96
85
  downloads?: DownloadsSource
86
+ // GitHub as references are resolved with; none without it.
87
+ actions?: ActionsSource
88
+ // Where Actions' files read and references resolved are kept.
89
+ actionFiles?: KeptStore<WorkflowsRead>
90
+ actionLines?: KeptStore<ActionLine>
97
91
  }
98
92
 
99
93
 
100
94
 
95
+ // A Dependabot alert as a scan tells it: with the versions of its package
96
+ // the lockfile it is of locks that it covers, none where it is of none read
97
+ // (a package.json, bun's lockfile), and whether it is of malware, as an
98
+ // advisory's is told.
99
+ export type DependabotAlertRow = DependabotAlert & { versions: string[]; malware: boolean }
100
+
101
101
  // A risk line is of npm's package of its name and version, or, with an
102
102
  // `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
103
103
  // of it, out of 100 (assessNpm `score`, risk/npm/score.js).
@@ -108,6 +108,8 @@ export type RepositoryRiskLine =
108
108
  | ({ type: 'risk'; name: string; version: string; integrity?: string; stale?: { assessedAt: number } }
109
109
  & ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
110
110
  | ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
111
+ | ({ type: 'dependabot'; repo: number } & ({ alerts: DependabotAlertRow[] } | RiskFailure))
112
+ | WorkflowsLine | WorkflowLine | ActionLine
111
113
  | { type: 'done' }
112
114
  // The scan stopped for its part's time (repositoryRiskLines `partMs`), what
113
115
  // it told kept: to be asked again, from where it stopped.
@@ -115,12 +117,12 @@ export type RepositoryRiskLine =
115
117
 
116
118
  // The repositories a scan answered in parts told whole by a part's end: each
117
119
  // by its id, its name and a team's directories in it then, and its
118
- // lockfiles' keys (`${format}\0${sha}`) of those read.
120
+ // lockfiles' keys (`${format}\0${sha}`) of those read, and the references
121
+ // its Actions make, to resolve.
119
122
  export interface ScanProgress { repos: ToldRepository[] }
120
- interface ToldRepository { id: number; name: string; paths: readonly string[] | null; lockfiles: string[] }
123
+ interface ToldRepository { id: number; name: string; paths: readonly string[] | null; lockfiles: string[]; actions?: ActionRef[] }
121
124
 
122
125
  type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
123
- type LockfileRead = { packages: LockedPackage[] } | RiskFailure
124
126
 
125
127
  // What scans keep between them, in this instance's memory: a repository's
126
128
  // lockfiles as listed at a commit, and a lockfile's packages by its blob,
@@ -135,6 +137,9 @@ export interface RiskCaches {
135
137
  lockfiles: Lru<LockfileRead>
136
138
  risks: Lru<{ at: number; line: RiskLine }>
137
139
  advisories: Lru<{ at: number; audit: Audit }>
140
+ actionFiles: Lru<WorkflowsRead>
141
+ actions: Lru<{ at: number; line: ActionLine }>
142
+ actionAnswers: ActionAnswers
138
143
  }
139
144
  export const riskCaches = (): RiskCaches => ({
140
145
  listings: lru(1000),
@@ -142,10 +147,16 @@ export const riskCaches = (): RiskCaches => ({
142
147
  lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
143
148
  risks: lru(50_000),
144
149
  advisories: lru(50_000, kept => Array.isArray(kept.audit) ? kept.audit.length + 1 : 1),
150
+ // By their text, a kilobyte at a time.
151
+ actionFiles: lru(64 * 1024, read => 'files' in read ? read.files.reduce((sum, file) => sum + ('text' in file ? Math.ceil(file.text.length / 1024) : 1), 1) : 1),
152
+ actions: lru(10_000),
153
+ // What GitHub answered of public repositories' actions, by answerWeight.
154
+ actionAnswers: lru(200_000, answerWeight),
145
155
  })
146
- // How long the source's `listings` and `lockfiles` are read for: what
147
- // GitHub has at a commit or a blob doesn't change, how it is read here may.
148
- export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
156
+ // How many repositories are read at once (repository-risk-read.ts), ahead of
157
+ // the one told: each asks GitHub one thing after another (opening it, and
158
+ // its head) even where all else is kept.
159
+ const READ_AHEAD = 6
149
160
  // How long a part's progress, written as it ends (`progress`), is waited on.
150
161
  const PROGRESS_WRITE_MS = 5000
151
162
  // How many kept risk lines are read of the source's `risks` at once.
@@ -168,6 +179,7 @@ interface Scan {
168
179
  stale: AssessedPackage[]
169
180
  // The repositories told whole, this part or one before (ScanProgress).
170
181
  told: Map<number, ToldRepository>
182
+ actions: ReturnType<typeof resolvingActions> | null
171
183
  }
172
184
 
173
185
  // A package as a scan meets it: by its name, version and a mirror's integrity.
@@ -255,36 +267,6 @@ async function assessing(scan: Scan, feed: (assess: (packages: AssessedPackage[]
255
267
  await Promise.all(assessors)
256
268
  }
257
269
 
258
- type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
259
-
260
- // What is kept by `key`: in this instance's memory, else in `store` within
261
- // SCAN_READ_KEPT_MS, else `read` and kept in both.
262
- async function keptRead<T>(memory: Lru<T>, store: KeptStore<T> | undefined, key: string, read: () => Promise<T>) {
263
- const known = memory.get(key)
264
- if (known !== undefined) return known
265
- const kept = await store?.read(key)
266
- if (kept && Date.now() - kept.at < SCAN_READ_KEPT_MS) { memory.set(key, kept.value); return kept.value }
267
- const value = await read()
268
- memory.set(key, value)
269
- await store?.write(key, value, Date.now())
270
- return value
271
- }
272
-
273
- // A lockfile's line, its packages or why they couldn't be had: as kept by its
274
- // blob, else read from GitHub and kept.
275
- async function readLockfile({ source, caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
276
- const at = { type: 'lockfile', repo, path, format } as const
277
- if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
278
- try {
279
- return { ...at, ...await keptRead(caches.lockfiles, source.lockfiles, `${format}\0${sha}`, async (): Promise<LockfileRead> => {
280
- const text = await opened.file(path)
281
- try { return { packages: lockedPackages(format, text) } } catch (error) {
282
- return { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
283
- }
284
- }) }
285
- } catch (error) { return { ...at, ...failureOf(error) } }
286
- }
287
-
288
270
  // A lockfile's packages of npm's registry or a mirror not yet seen this scan,
289
271
  // to look up as kept (lookUp), and assess where none is; and one seen as only
290
272
  // devDependencies need it, and put off, that this lockfile needs otherwise,
@@ -303,25 +285,8 @@ function assessLater(scan: Scan, packages: LockedPackage[]) {
303
285
  if (fresh.length > 0) scan.lookups.add(fresh)
304
286
  }
305
287
 
306
- // A repository opened and its lockfiles of npm's ecosystem listed, as kept
307
- // for its commit or else from GitHub, those of its `paths` alone; null, with
308
- // its line telling why, where either failed, or where the scan stopped.
309
- async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
310
- let doing = 'opening it'
311
- try {
312
- const opened = await scan.source.open(repo)
313
- if (scan.signal.aborted) return null
314
- doing = 'listing its lockfiles'
315
- const listed = await keptRead(scan.caches.listings, scan.source.listings, `${repo.repoId}@${opened.commit}`, () => opened.lockfiles())
316
- const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
317
- return { opened, lockfiles }
318
- } catch (error) {
319
- if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, doing) })
320
- return null
321
- }
322
- }
323
-
324
288
  type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
289
+ type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
325
290
 
326
291
  // A lockfile's advisories line (repository-risk-audit.ts auditLockfile);
327
292
  // null where it locks none, or once the scan stops.
@@ -331,38 +296,67 @@ async function readAdvisories(scan: Scan, { repo, path, format, packages }: Lock
331
296
  return audit === null ? null : Array.isArray(audit) ? { ...at, advisories: audit } : { ...at, ...audit }
332
297
  }
333
298
 
334
- // A repository's line, its lockfiles or why they couldn't be listed, and
335
- // then its lockfiles', and their advisories: told once they are all read
336
- // and access to the repository is checked again, as GitHub may have
337
- // withdrawn it meanwhile. A test's fixture or example its reader refuses
338
- // (isTestFixture) isn't told at all, as a test may keep one so on purpose.
339
- async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
340
- const listed = await listLockfiles(scan, repo)
341
- if (!listed) return
342
- const lockfiles: { sha: string; line: LockfileLine }[] = []
343
- for (const lockfile of listed.lockfiles) {
344
- if (scan.signal.aborted) return
345
- const line = await readLockfile(scan, repo.repoId, listed.opened, lockfile)
346
- if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
347
- }
348
- try { await listed.opened.recheck() } catch (error) {
299
+ type DependabotLine = Extract<RepositoryRiskLine, { type: 'dependabot' }>
300
+
301
+ // A repository's Dependabot alerts' line, each alert with the versions the
302
+ // lockfile it is of locks that it covers (`lockfiles`, a repository read's),
303
+ // or why they couldn't be had.
304
+ function dependabotLine(repo: number, read: DependabotRead, lockfiles: RepositoryRead['lockfiles']): DependabotLine {
305
+ if ('error' in read) return { type: 'dependabot', repo, ...read }
306
+ const locked = new Map(lockfiles.flatMap(({ line }) => 'packages' in line ? [[line.path, line.packages] as const] : []))
307
+ return { type: 'dependabot', repo, alerts: read.alerts.map(alert => ({ ...alert, versions: alertVersions(alert, locked.get(alert.manifest)),
308
+ malware: npmMalwareAdvisory({ id: alert.ghsa, aliases: alert.aliases, cwe: alert.cwe, title: alert.title }) })) }
309
+ }
310
+
311
+ // A repository's alerts checked again just before they are told, as access to
312
+ // the repository is (DependabotRead `recheck`): why not, where the user's
313
+ // access to them is withdrawn since they were read.
314
+ async function rechecked(read: DependabotRead): Promise<DependabotRead> {
315
+ if (!('alerts' in read) || !read.recheck) return read
316
+ try { await read.recheck() } catch (error) { return failureOf(error, 'checking access to its Dependabot alerts again') }
317
+ return read
318
+ }
319
+
320
+ // A repository read (repository-risk-read.ts readRepository) told: its
321
+ // line, or why it couldn't be read, then its lockfiles', and their
322
+ // advisories, its packages assessed meanwhile, then its Dependabot alerts.
323
+ // Access to it is checked again
324
+ // just before, however long ago it was read, as GitHub may have withdrawn it.
325
+ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: RepositoryRead | RepositoryFailure | null) {
326
+ if (!read || scan.signal.aborted) return
327
+ if ('type' in read) { scan.lines.add(read); return }
328
+ try { await read.recheck() } catch (error) {
349
329
  if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
350
330
  return
351
331
  }
352
332
  if (scan.signal.aborted) return
353
- scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: lockfiles.map(({ line: { path, format } }) => ({ path, format })) })
333
+ const { commit, lockfiles } = read
334
+ scan.lines.add({ type: 'repo', id: repo.repoId, commit, lockfiles: lockfiles.map(({ line: { path, format } }) => ({ path, format })) })
354
335
  for (const { line } of lockfiles) {
355
336
  scan.lines.add(line)
356
337
  if ('packages' in line) assessLater(scan, line.packages)
357
338
  }
339
+ const refs = read.actions ? tellActions(scan, repo, read.actions, lockfiles) : []
358
340
  for (const { sha, line } of lockfiles) {
359
341
  if (scan.signal.aborted) return
360
342
  const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
361
343
  if (advisories) scan.lines.add(advisories)
362
344
  }
363
345
  if (scan.signal.aborted) return
346
+ const dependabot = read.dependabot && await rechecked(read.dependabot)
347
+ if (scan.signal.aborted) return
348
+ if (dependabot) scan.lines.add(dependabotLine(repo.repoId, dependabot, lockfiles))
364
349
  scan.told.set(repo.repoId, { id: repo.repoId, name: repo.fullName, paths: repo.paths ?? null,
365
- lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []) })
350
+ lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []), ...refs.length > 0 ? { actions: refs } : {} })
351
+ }
352
+
353
+ // A repository's Actions told, and the references they make to resolve.
354
+ function tellActions(scan: Scan, repo: RepositoryRiskRepo, read: WorkflowsRead, lockfiles: RepositoryRead['lockfiles']) {
355
+ const locked = lockfiles.flatMap(({ line }) => 'packages' in line ? [lockedNames(line.path, line.packages)] : [])
356
+ const { lines, refs } = actionsLines(repo.repoId, repo.fullName.split('/')[0]!, read, locked)
357
+ for (const line of lines) scan.lines.add(line)
358
+ scan.actions?.add(refs)
359
+ return refs
366
360
  }
367
361
 
368
362
  // Whether an earlier part told `repo` whole, by its name and a team's
@@ -379,11 +373,12 @@ async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
379
373
  reads.push(read.packages)
380
374
  }
381
375
  for (const packages of reads) assessLater(scan, packages)
376
+ scan.actions?.add(told.actions ?? [])
382
377
  return true
383
378
  }
384
379
 
385
- // Each repository, a repository at a time, but those an earlier part told
386
- // whole (ScanProgress).
380
+ // Each repository, READ_AHEAD read at a time, each told in turn, but those an
381
+ // earlier part told whole (ScanProgress), read of none.
387
382
  async function readRepositories(scan: Scan) {
388
383
  const repos = await scan.source.repos()
389
384
  scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
@@ -393,9 +388,19 @@ async function readRepositories(scan: Scan) {
393
388
  const kept = await scan.source.progress?.read()
394
389
  const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
395
390
  for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
396
- for (const repo of repos) {
391
+ const reads: Promise<RepositoryRead | RepositoryFailure | null>[] = []
392
+ const read = (at: number) => {
393
+ if (reads[at]) return reads[at]
394
+ const repo = repos[at]!
395
+ const started = reads[at] = toldBefore(scan, repo).then(told => told ? null : readRepository(scan, repo))
396
+ // One read ahead and no longer waited on, as the scan stopped, fails alone.
397
+ started.catch(() => {})
398
+ return started
399
+ }
400
+ for (const [at, repo] of repos.entries()) {
397
401
  if (scan.signal.aborted) return
398
- if (!await toldBefore(scan, repo)) await readRepository(scan, repo)
402
+ for (let ahead = at; ahead < Math.min(at + READ_AHEAD, repos.length); ahead++) void read(ahead)
403
+ await tellRepository(scan, repo, await read(at))
399
404
  }
400
405
  }
401
406
 
@@ -431,7 +436,9 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
431
436
  // none waited for: the work under way, such as a request to GitHub, which
432
437
  // isn't given up on at once, outlasting it.
433
438
  const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
434
- const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map() }
439
+ const actions = source.actions ? resolvingActions({ source: source.actions, signal: running, tell: line => { lines.add(line) }, memory: caches.actions,
440
+ answers: caches.actionAnswers, store: source.actionLines }) : null
441
+ const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions }
435
442
  const failed: unknown[] = []
436
443
  void (async () => {
437
444
  try {
@@ -446,6 +453,7 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
446
453
  for (const left of leftOver(scan)) {
447
454
  if (!running.aborted && left.length > 0) await assessing(scan, assess => { assess(left); return Promise.resolve() })
448
455
  }
456
+ await actions?.close()
449
457
  if (!running.aborted) scan.lines.add({ type: 'done' })
450
458
  } catch (error) {
451
459
  failed.push(error)
@@ -0,0 +1,149 @@
1
+ // The reviewed dependencies a host keeps beyond the app, as it registers
2
+ // them before the server is assembled (server.ts registerReviewedDependencies):
3
+ // an async method telling whether a dependency, as a lockfile locks it, is
4
+ // reviewed there (`lookup`), and, optionally, one reading the reviews of
5
+ // dependencies an open pull request's comments make (`parseComments`,
6
+ // pull-comment-reviews.ts), and one formatting one's own dependency reviews
7
+ // as a comment, for a copy button to give (`formatDependencyReviews`,
8
+ // dependency-review-comments.ts). Registered, they replace the commit a repository
9
+ // is reviewed since (pull-dependency-reviews.ts): a version passes by the
10
+ // reviews its teams publish, and those its pull request's comments make, as
11
+ // many as the repository requires, or by `lookup`'s saying so, and is
12
+ // otherwise unreviewed. A patched version (pnpm's patchedDependencies)
13
+ // passes by `lookup`, or reviews its comments make of its patch, alone, the
14
+ // app reviewing no patch yet. Those comments' reviews count for the pull
15
+ // request alone, as it is open: the host's to keep, as it is merged, among
16
+ // those `lookup` tells of.
17
+ //
18
+ // Each version is asked of `lookup` once a request, as many at once as
19
+ // ASK_AT_ONCE, its answer kept in this instance KEPT_MS; one it fails to
20
+ // answer is unreviewed, and counted so (`unasked`).
21
+ import { type Lru, lru } from './lru.ts'
22
+
23
+ export type ReviewedLockfile = 'npm' | 'pnpm' | 'yarn1'
24
+ // A dependency as a lockfile locks it: npm's always with its integrity (one
25
+ // with none is never reviewed, nor asked of), its shasum where the lockfile
26
+ // has one (Yarn 1's, after a tarball's `#`), and the hash of its patch where
27
+ // it is patched (pnpm's).
28
+ export interface ReviewedDependency {
29
+ ecosystem: 'npm'
30
+ lockfile: ReviewedLockfile
31
+ name: string
32
+ version: string
33
+ integrity?: string
34
+ shasum?: string
35
+ patchHash?: string
36
+ }
37
+ export type ReviewVerdict = 'accept' | 'accept-with-nits' | 'request-changes' | 'reject'
38
+ // A comment of an open pull request's, as `parseComments` is given it: one
39
+ // on the pull request (`comment`), a review's own (`review`, whatever its
40
+ // state, a comment's alone too), or one on a line of its diff
41
+ // (`review-comment`); by its author's GitHub login; when it was written, and
42
+ // last edited (`editedAt`) where it was.
43
+ export interface ReviewComment {
44
+ kind: 'comment' | 'review' | 'review-comment'
45
+ id: string
46
+ author: string | null
47
+ body: string
48
+ state?: string
49
+ createdAt: string
50
+ editedAt?: string
51
+ url: string
52
+ }
53
+ // What `parseComments` reads: the pull request, its comments, and the
54
+ // dependencies: every one the lockfiles its review holds to add that can be
55
+ // reviewed and `lookup` doesn't pass, and the others still wanting reviews.
56
+ export interface ParseCommentsInput { repository: string; number: number; comments: ReviewComment[]; dependencies: ReviewedDependency[] }
57
+ // A review its comments make, by `user` (a GitHub login, one of their
58
+ // authors'), of each of `dependencies`, with its verdict, as of `time` (when
59
+ // it was made, an ISO date or milliseconds; that user's latest comment's
60
+ // where none is told).
61
+ export interface CommentReview {
62
+ user: string
63
+ time?: string | number
64
+ dependencies: { ecosystem?: 'npm'; name: string; version: string; integrity: string; patchHash?: string; status: ReviewVerdict }[]
65
+ }
66
+ // A version a dependency review is of, or compared it with: its package's
67
+ // name, its version and integrity, and its tarball's shasum and its patch's
68
+ // hash where they are known (a lockfile's, pnpm's patched dependencies').
69
+ export interface ReviewedVersion { name: string; version: string; integrity: string; shasum?: string; patchHash?: string }
70
+ // One of one's own published dependency reviews, as `formatDependencyReviews`
71
+ // is given it: the version it is of (`new`) and the one it compared it with
72
+ // (`old`, none where it read its package whole); its verdict (`status`);
73
+ // whether it read every file (`coverage`, `full`, else `partial`); its final
74
+ // comment; each file's verdict and comment (`files`, of those with either);
75
+ // by whom (`reviewer`, their GitHub login), and when it was published.
76
+ export interface DependencyReviewEntry {
77
+ ecosystem: 'npm'
78
+ old: ReviewedVersion | null
79
+ new: ReviewedVersion
80
+ status: ReviewVerdict
81
+ coverage: 'full' | 'partial'
82
+ comment: string
83
+ files: { path: string; status: ReviewVerdict | null; comment: string }[]
84
+ reviewer: string
85
+ publishedAt: number
86
+ }
87
+ // What `formatDependencyReviews` formats: one's own reviews, never another's,
88
+ // and the pull request whose versions they are of, where its copy asks.
89
+ export interface FormatDependencyReviewsInput { reviews: DependencyReviewEntry[]; pullRequest?: { repository: string; number: number } }
90
+ export interface ReviewedDependencies {
91
+ lookup(dependency: ReviewedDependency): Promise<boolean>
92
+ parseComments?(input: ParseCommentsInput): Promise<CommentReview[]>
93
+ // The comment to copy, none where it is empty, null or undefined.
94
+ formatDependencyReviews?(input: FormatDependencyReviewsInput): Promise<string | null | undefined>
95
+ }
96
+
97
+ const ASK_AT_ONCE = 16
98
+ const KEPT_MS = 60_000
99
+
100
+ let registered: ReviewedDependencies | null = null
101
+ let assembled = false
102
+ // Each lookup's answers, apart.
103
+ const answers = new WeakMap<ReviewedDependencies['lookup'], Lru<{ at: number; reviewed: boolean }>>()
104
+
105
+ // Registers the host's reviewed dependencies, once, before the server is
106
+ // assembled (init()): a server already assembled keeps what it was given.
107
+ export function registerReviewedDependencies(reviewed: ReviewedDependencies): void {
108
+ const optional = (method: unknown) => method === undefined || typeof method === 'function'
109
+ if (typeof reviewed?.lookup !== 'function' || !optional(reviewed.parseComments) || !optional(reviewed.formatDependencyReviews)) {
110
+ throw new TypeError('registerReviewedDependencies: expected { lookup, parseComments?, formatDependencyReviews? }, async methods')
111
+ }
112
+ if (assembled) throw new Error('registerReviewedDependencies: call it before init(), as the server takes them as it is assembled')
113
+ if (registered) throw new Error('registerReviewedDependencies: reviewed dependencies are registered already')
114
+ registered = { lookup: reviewed.lookup.bind(reviewed), ...reviewed.parseComments ? { parseComments: reviewed.parseComments.bind(reviewed) } : {},
115
+ ...reviewed.formatDependencyReviews ? { formatDependencyReviews: reviewed.formatDependencyReviews.bind(reviewed) } : {} }
116
+ }
117
+
118
+ // The reviewed dependencies registered, as a server takes them once it is
119
+ // assembled; none registers after.
120
+ export function assembledReviewedDependencies(): ReviewedDependencies | null {
121
+ assembled = true
122
+ return registered
123
+ }
124
+
125
+ // Which of `dependencies`, by their keys, `lookup` passes, and how many it
126
+ // failed to answer.
127
+ export async function askReviewed(lookup: ReviewedDependencies['lookup'], dependencies: Map<string, ReviewedDependency>): Promise<{ passed: Set<string>; unasked: number }> {
128
+ const passed = new Set<string>()
129
+ let unasked = 0
130
+ const pending = [...dependencies]
131
+ const kept = answers.get(lookup) ?? answers.set(lookup, lru(100_000)).get(lookup)!
132
+ await Promise.all(Array.from({ length: Math.min(ASK_AT_ONCE, pending.length) }, async () => {
133
+ for (let next = pending.pop(); next; next = pending.pop()) {
134
+ const [key, dependency] = next
135
+ const known = kept.get(key)
136
+ let answer = known && Date.now() - known.at < KEPT_MS ? known.reviewed : null
137
+ if (answer === null) {
138
+ try { answer = await lookup({ ...dependency }) === true } catch (error) {
139
+ unasked++
140
+ console.warn('reviewed dependencies: failed to answer', `${dependency.name}@${dependency.version}`, error instanceof Error ? error.message : error)
141
+ continue
142
+ }
143
+ kept.set(key, { at: Date.now(), reviewed: answer })
144
+ }
145
+ if (answer) passed.add(key)
146
+ }
147
+ }))
148
+ return { passed, unasked }
149
+ }
@@ -13,13 +13,15 @@ import { hashToken, randomToken } from './crypto.ts'
13
13
  // `cookieName` when cookies are Secure.
14
14
  export const STATE_COOKIE = 'dvstate'
15
15
 
16
- // Base names for GitHub issue authorization state (github-issue-oauth.ts) and
17
- // an administrator's read-only view as another user (view-as.ts).
16
+ // Base names for GitHub issue authorization state (github-issue-oauth.ts),
17
+ // linking another GitHub account (github-link-oauth.ts) and an
18
+ // administrator's read-only view as another user (view-as.ts).
18
19
  export const ISSUE_STATE_COOKIE = 'dvissuestate'
20
+ export const LINK_STATE_COOKIE = 'dvlinkstate'
19
21
  export const VIEW_COOKIE = 'dvview'
20
22
  // The session cookie cannot share a name with these, or one would overwrite
21
23
  // or be read as the other.
22
- export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, VIEW_COOKIE]
24
+ export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, LINK_STATE_COOKIE, VIEW_COOKIE]
23
25
 
24
26
  // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
25
27
  // Secure + Path=/ + no Domain, which `buildCookie` already emits).
@@ -55,13 +57,15 @@ export function parseCookies(header: string | undefined): Map<string, string> {
55
57
 
56
58
  // Mint a session for a freshly-authenticated user. Upserts the identity, then
57
59
  // stores the session keyed by the cookie token's hash. Returns the cookie to
58
- // set and the CSRF token (also persisted on the row).
60
+ // set and the CSRF token (also persisted on the row), or null for an account
61
+ // a user linked to theirs, which can neither sign in nor register.
59
62
  export async function createSession(
60
63
  config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
61
- ): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
64
+ ): Promise<{ setCookie: string; csrfToken: string; userId: string } | null> {
62
65
  const token = randomToken()
63
66
  const csrfToken = randomToken()
64
67
  const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
68
+ if (userId == null) return null
65
69
  await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
66
70
  const setCookie = buildCookie(config.sessionCookieName, token, {
67
71
  maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
@@ -23,7 +23,7 @@ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'g
23
23
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
24
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
25
  'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
26
- 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
26
+ 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
27
27
  // Session presence and upload keys change only their own rows.
28
28
  const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
29
29
 
@@ -61,6 +61,11 @@ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
61
61
  if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
62
62
  return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
63
63
  },
64
+ async delete(value) {
65
+ const path = `avatars/${id(value)}`
66
+ await objects.delete(path)
67
+ if (sidecar) await objects.delete(`${path}.type`)
68
+ },
64
69
  }
65
70
  }
66
71
 
@@ -8,6 +8,13 @@
8
8
  // The same read names the reports a restricted reader would find nothing in:
9
9
  // under -deps or -security the catalog leaves those out of the team's list.
10
10
  // Readers with both permissions keep every report, empty or not.
11
+ //
12
+ // A team that is not App also says how many of its reports open in App mode
13
+ // on their own, and its findings for the badge it collapses to. None is as
14
+ // uniform as an App team, and collapses like one. A team mixing App and non-App
15
+ // reports stays expanded, and so does one whose reports each open as App but
16
+ // whose combined workspace does not (contradicting verdicts, say): it has App
17
+ // findings, and only expanded does it show that they fail to combine.
11
18
  import { reportEntries } from '@preventive/report'
12
19
  import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
13
20
  import type { BlobStore } from './blob-store.ts'
@@ -48,10 +55,14 @@ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): Te
48
55
  return summary
49
56
  }
50
57
 
58
+ function isLinks(data: unknown): boolean {
59
+ const record = data as { source?: unknown; links?: unknown } | null
60
+ return record?.source === 'links' && Array.isArray(record.links)
61
+ }
62
+
51
63
  // A links document counts by its links, every other report by its findings.
52
64
  function hasContent(data: unknown): boolean {
53
- const record = data as { source?: unknown; links?: unknown } | null
54
- if (record?.source === 'links' && Array.isArray(record.links)) return record.links.length > 0
65
+ if (isLinks(data)) return (data as { links: unknown[] }).links.length > 0
55
66
  return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
56
67
  }
57
68
 
@@ -79,7 +90,12 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
79
90
  }
80
91
  const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
81
92
  const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
82
- return remember(db, key, { app: managedTeamAppMetadata(Object.assign(body.reports, { links: body.links ?? [] })), empty })
93
+ const links = body.links ?? []
94
+ const app = managedTeamAppMetadata(Object.assign(body.reports, { links }), { countFindings: true })
95
+ if (app.appMode) return remember(db, key, { app, empty })
96
+ const appReports = body.reports.filter(report => !isLinks(report.data)
97
+ && managedTeamAppMetadata(Object.assign([report], { links })).appMode).length
98
+ return remember(db, key, { app: { ...app, appReports }, empty })
83
99
  })().catch((error: unknown) => {
84
100
  if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
85
101
  if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
@@ -145,15 +145,18 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
145
145
  const links = await linkRevision(db)
146
146
  const bundleRows = (await q.bundles.all(team.id) as (Omit<ManagedBundle, 'buildConditions'> & { buildConditions: string | null; repoFullName: string })[])
147
147
  .map(row => ({ ...row, buildConditions: row.buildConditions == null ? null : JSON.parse(row.buildConditions) as BundleBuildConditions }))
148
+ const repositories = await q.repositories.all(team.id) as WorkspaceShareSnapshot['repositories']
148
149
  return {
149
150
  user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
150
151
  teamId: team.id,
151
152
  linkRevision: links,
152
153
  permissions,
153
- repositories: await q.repositories.all(team.id) as WorkspaceShareSnapshot['repositories'],
154
+ repositories,
154
155
  reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
155
156
  repo: { github: row.github, directory: row.directory }, permissions })),
156
157
  team: { ...team, permissions, cacheKey: links,
158
+ repos: repositories.map(repo => ({ repoId: repo.repoId, fullName: repo.github, path: repo.path || null }))
159
+ .toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
157
160
  reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, visible: true, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
158
161
  cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions, links]) })),
159
162
  bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,