@preventive/triage 1.0.0-alpha.36 → 1.0.0-alpha.38

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 (177) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-build-worker.js +1 -1
  130. package/server-managed/bundle-build.ts +6 -2
  131. package/server-managed/bundle-cache.ts +37 -4
  132. package/server-managed/bundle-commits.ts +140 -0
  133. package/server-managed/bundle-summary-cache.ts +24 -10
  134. package/server-managed/config.ts +4 -0
  135. package/server-managed/db-methods.ts +98 -11
  136. package/server-managed/db-neon.ts +39 -4
  137. package/server-managed/db-schema.ts +14 -2
  138. package/server-managed/db-table-names.ts +1 -1
  139. package/server-managed/db.ts +7 -3
  140. package/server-managed/dependency-reviews.ts +248 -0
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +943 -52
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +413 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
@@ -19,11 +19,7 @@ export const VISIBILITY_PERMISSION_LABELS: Record<VisibilityPermission, string>
19
19
  export type TeamUserPermissions = Record<VisibilityPermission, boolean>
20
20
 
21
21
  // All-off — the default for a fresh membership.
22
- export const DEFAULT_TEAM_USER_PERMISSIONS: TeamUserPermissions = { dependencies: false, security: false }
23
-
24
- export function isVisibilityPermission(x: unknown): x is VisibilityPermission {
25
- return typeof x === 'string' && (VISIBILITY_PERMISSIONS as readonly string[]).includes(x)
26
- }
22
+ const DEFAULT_TEAM_USER_PERMISSIONS: TeamUserPermissions = { dependencies: false, security: false }
27
23
 
28
24
  // Coerce arbitrary input (a request body) into a full permission set: a key is
29
25
  // granted only when explicitly `true`; anything else (absent, non-bool) is off.
@@ -1,6 +1,17 @@
1
1
  // Managed client pages only. API paths and E2E share hashes are not routes.
2
2
  import { BUNDLE_TABS } from '../bundle-tabs.js'
3
3
  import { MAX_FINDING_ID_LENGTH, isLinkableFindingId } from '../finding-id.js'
4
+ import { isNpmPackageName, isNpmPackageSpec } from './npm-packages.js'
5
+
6
+ // How Compare reviews the changes, past its Overview: `code`, beside the
7
+ // tree of changed files. A link names the mode after the bundle or version
8
+ // compared with; an older one's `diff`, a mode since folded into Code,
9
+ // opens Code.
10
+ const COMPARE_MODES = new Set(['code', 'diff'])
11
+ export const compareModeOf = mode => COMPARE_MODES.has(mode) ? 'code' : 'overview'
12
+ // A route's `compareMode` field, where `mode` is one; none for the Overview.
13
+ export const compareModeField = mode => COMPARE_MODES.has(mode) ? { compareMode: 'code' } : {}
14
+ const compareModeSuffix = mode => COMPARE_MODES.has(mode) ? '/code' : ''
4
15
 
5
16
  export const MANAGED_PAGES = Object.freeze({
6
17
  manage: '/manage',
@@ -8,6 +19,7 @@ export const MANAGED_PAGES = Object.freeze({
8
19
  'manage-scans': '/manage/scans',
9
20
  'manage-reports': '/manage/report',
10
21
  'manage-repos': '/manage/repositories',
22
+ 'manage-repo-risk': '/manage/repositories/risk',
11
23
  'admin-users': '/manage/users',
12
24
  'manage-teams': '/manage/team',
13
25
  'manage-import': '/manage/import',
@@ -25,23 +37,37 @@ function findingPath(id) {
25
37
  export function managedRoutePath(route) {
26
38
  if (!route) return null
27
39
  if (route.view === 'home') return '/'
40
+ if (route.view === 'reviews') return '/reviews'
41
+ if (route.view === 'npm') return npmPackagePath(route)
28
42
  if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
29
43
  return /^[A-Za-z0-9_-]+$/u.test(route.teamSlug ?? '') ? `/team/${route.teamSlug}/${route.view.slice(10)}` : null
30
44
  }
31
45
  if (route.view === 'bundles') {
32
46
  if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
33
- || (route.teamSlug != null && !/^[A-Za-z0-9_-]+$/u.test(route.teamSlug))) return null
47
+ || [route.teamSlug, route.compareSlug].some(slug => slug != null && !/^[A-Za-z0-9_-]+$/u.test(slug))) return null
34
48
  const tab = route.bundleTab ?? 'overview'
35
49
  if (!BUNDLE_TABS.has(tab)) return null
36
50
  const parent = route.teamSlug ? `/team/${route.teamSlug}` : '/manage'
37
- return `${parent}/bundle/${route.bundleSlug}${tab === 'overview' ? '' : `/${tab}`}`
51
+ // Code names its open file by number: 1-based in the bundle's sorted
52
+ // sources, which its content hash fixes. Never by path from the bundle.
53
+ // Its marked lines follow in the fragment, as #L42 or #L42-L69.
54
+ const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
55
+ const lines = file && isLineNumber(route.line)
56
+ ? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
57
+ // Compare names the bundle compared with by its slug, then its mode
58
+ // (COMPARE_MODES) past the Overview.
59
+ const compare = tab === 'compare' && route.compareSlug != null
60
+ ? `/${route.compareSlug}${compareModeSuffix(route.compareMode)}` : ''
61
+ return `${parent}/bundle/${route.bundleSlug}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
38
62
  }
39
63
  if (Object.hasOwn(MANAGED_PAGES, route.view)) {
40
64
  const path = MANAGED_PAGES[route.view]
41
65
  if (route.view === 'manage-deduplication' && route.linkId) return `${path}?report=${encodeURIComponent(route.linkId)}`
42
66
  if (route.view === 'manage-bundles' && Number.isSafeInteger(route.createRepoId) && route.createRepoId > 0) return `${path}?createRepo=${route.createRepoId}`
43
67
  if (route.view === 'manage-scans' && route.scanMode === 'link') return `${path}?mode=link`
44
- if (route.view === 'manage-scans' && route.bundleId) return `${path}?bundle=${encodeURIComponent(route.bundleId)}`
68
+ if (route.view === 'manage-scans' && route.bundleId) {
69
+ return `${path}?bundle=${encodeURIComponent(route.bundleId)}${route.scanMode === 'dependencies' ? '&mode=dependencies' : ''}`
70
+ }
45
71
  return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
46
72
  }
47
73
  if (!['findings', 'files'].includes(route.view) || !route.teamSlug
@@ -55,9 +81,69 @@ export function managedRoutePath(route) {
55
81
  return `${team}${report}${route.view === 'files' ? '/files' : ''}`
56
82
  }
57
83
 
84
+ // The npm viewer: its lookup page, or a package version's Overview, Code,
85
+ // Compare or Treemap tab, `/npm/<name>[@<version or dist-tag>][/<tab>…]`. Code names
86
+ // its file by number and its lines in the fragment, as a bundle's Code
87
+ // does: `/code/<file>#L4`. Compare names the version compared with, then
88
+ // its mode past the Overview: `/compare/<version>/code` or `/diff`.
89
+ export const NPM_TABS = new Set(['overview', 'code', 'compare', 'review', 'treemap'])
90
+ function npmPackagePath(route) {
91
+ if (route.packageName == null) return '/npm'
92
+ const tab = route.bundleTab ?? 'overview'
93
+ if (!isNpmPackageName(route.packageName) || (route.packageSpec != null && !isNpmPackageSpec(route.packageSpec)) || !NPM_TABS.has(tab)
94
+ || (route.compareSpec != null && !isNpmPackageSpec(route.compareSpec))) return null
95
+ if (tab === 'review') {
96
+ if (route.reviewPackage != null && !isNpmPackageName(route.reviewPackage) || route.reviewSpec != null && !isNpmPackageSpec(route.reviewSpec)) return null
97
+ const base = route.reviewPackage == null ? '' : `/${route.reviewPackage}${route.reviewSpec == null ? '' : `@${route.reviewSpec}`}`
98
+ return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}/review${base}`
99
+ }
100
+ const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
101
+ const lines = file && isLineNumber(route.line)
102
+ ? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
103
+ const compare = tab === 'compare' && route.compareSpec != null ? `/${route.compareSpec}${compareModeSuffix(route.compareMode)}` : ''
104
+ return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
105
+ }
106
+
107
+ function parseNpmRoute(path, hash) {
108
+ const review = /^\/npm\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?\/review(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?)?$/u.exec(path)
109
+ if (review) {
110
+ if (!isNpmPackageName(review[1]) || review[2] != null && !isNpmPackageSpec(review[2])
111
+ || review[3] != null && !isNpmPackageName(review[3]) || review[4] != null && !isNpmPackageSpec(review[4])) return null
112
+ // The full dependency form is canonical; a bare version is a shortcut.
113
+ const versionOnly = review[3] && /^\d+\.\d+\.\d+(?:[-+].*)?$/u.test(review[3]) && review[4] == null
114
+ return { view: 'npm', packageName: review[1], packageSpec: review[2] ?? null, bundleTab: 'review',
115
+ ...(review[3] ? { reviewPackage: versionOnly ? review[1] : review[3], reviewSpec: versionOnly ? review[3] : review[4] ?? null } : {}) }
116
+ }
117
+ const match = /^\/npm(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(code|compare|treemap)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u.exec(path)
118
+ if (!match) return undefined
119
+ if (match[1] == null) return { view: 'npm' }
120
+ if (!isNpmPackageName(match[1]) || (match[2] != null && !isNpmPackageSpec(match[2]))) return null
121
+ const route = { view: 'npm', packageName: match[1], packageSpec: match[2] ?? null, bundleTab: match[3] ?? 'overview' }
122
+ if (match[4] == null) return route
123
+ if (route.bundleTab === 'compare') {
124
+ return isNpmPackageSpec(match[4]) && (match[5] == null || COMPARE_MODES.has(match[5])) ? { ...route, compareSpec: match[4], ...compareModeField(match[5]) } : null
125
+ }
126
+ if (route.bundleTab !== 'code') return null
127
+ const file = Number(match[4])
128
+ return !match[5] && /^[1-9]\d*$/u.test(match[4]) && Number.isSafeInteger(file) ? { ...route, file, ...codeLines(hash) } : null
129
+ }
130
+
131
+ function isLineNumber(value) {
132
+ return Number.isSafeInteger(value) && value > 0
133
+ }
134
+
135
+ // `#L42` or `#L42-L69`, in either order; anything else marks no lines.
136
+ function codeLines(hash) {
137
+ const match = /^#L([1-9]\d*)(?:-L([1-9]\d*))?$/u.exec(hash)
138
+ const [first, last] = match ? [Number(match[1]), Number(match[2] ?? match[1])] : []
139
+ if (!isLineNumber(first) || !isLineNumber(last)) return {}
140
+ return first === last ? { line: first } : { line: Math.min(first, last), endLine: Math.max(first, last) }
141
+ }
142
+
58
143
  export function parseManagedRoute(url) {
59
144
  const path = url.pathname.replace(/\/$/u, '') || '/'
60
145
  if (path === '/' || path === '/index.html') return { view: 'home' }
146
+ if (path === '/reviews') return { view: 'reviews' }
61
147
  const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
62
148
  if (view) {
63
149
  const createRepoId = Number(url.searchParams.get('createRepo'))
@@ -66,14 +152,23 @@ export function parseManagedRoute(url) {
66
152
  ...(view === 'manage-bundles' && Number.isSafeInteger(createRepoId) && createRepoId > 0 ? { createRepoId } : {}),
67
153
  ...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
68
154
  ...(view === 'manage-scans' ? url.searchParams.get('mode') === 'link' ? { scanMode: 'link' }
69
- : url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {} : {}),
155
+ : url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle'),
156
+ ...(url.searchParams.get('mode') === 'dependencies' ? { scanMode: 'dependencies' } : {}) } : {} : {}),
70
157
  }
71
158
  }
72
- const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?$/u.exec(path)
159
+ const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+)(?:\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?)?)?$/u.exec(path)
73
160
  if (bundle) {
74
161
  const bundleTab = bundle[3] ?? 'overview'
75
- return BUNDLE_TABS.has(bundleTab) ? { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab } : null
162
+ if (!BUNDLE_TABS.has(bundleTab)) return null
163
+ const route = { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab }
164
+ if (bundle[4] == null) return route
165
+ if (bundleTab === 'compare') return bundle[5] == null || COMPARE_MODES.has(bundle[5]) ? { ...route, compareSlug: bundle[4], ...compareModeField(bundle[5]) } : null
166
+ const file = Number(bundle[4])
167
+ if (bundleTab !== 'code' || bundle[5] || !/^[1-9]\d*$/u.test(bundle[4]) || !Number.isSafeInteger(file)) return null
168
+ return { ...route, file, ...codeLines(url.hash) }
76
169
  }
170
+ const npm = parseNpmRoute(path, url.hash)
171
+ if (npm !== undefined) return npm
77
172
  const contentList = /^\/team\/([A-Za-z0-9_-]+)\/(reports|bundles)$/u.exec(path)
78
173
  if (contentList) return { view: `workspace-${contentList[2]}`, teamSlug: contentList[1] }
79
174
  const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
@@ -97,14 +192,20 @@ export function resolveManagedRoute(route, teams, adminBundles = []) {
97
192
  return matches.length === 1 ? { view: route.view, teamId: matches[0].id } : null
98
193
  }
99
194
  if (route.view === 'bundles') {
100
- const { teamSlug, bundleSlug, ...rest } = route
195
+ const { teamSlug, bundleSlug, compareSlug, compareMode, ...rest } = route
101
196
  const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
102
197
  if (teamSlug != null && matches.length !== 1) return null
103
198
  const team = matches[0]
104
199
  const candidates = teamSlug == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])
105
- const ids = new Set(candidates.filter(bundle => bundle.slug === bundleSlug).map(bundle => bundle.id))
200
+ const idsOf = slug => new Set(candidates.filter(bundle => bundle.slug === slug).map(bundle => bundle.id))
201
+ const ids = idsOf(bundleSlug)
106
202
  const bundle = (team?.bundles ?? adminBundles).find(entry => entry.slug === bundleSlug)
107
- return bundleSlug && bundle && ids.size === 1 ? { ...rest, teamId: team?.id ?? null, bundleId: bundle.id } : null
203
+ if (!bundleSlug || !bundle || ids.size !== 1) return null
204
+ // The bundle compared with, by the same rule; one no longer listed drops
205
+ // out, leaving Compare to pick again.
206
+ const compared = compareSlug == null ? null : idsOf(compareSlug)
207
+ const compare = compared?.size === 1 ? { compareId: [...compared][0], ...compareModeField(compareMode) } : {}
208
+ return { ...rest, teamId: team?.id ?? null, bundleId: bundle.id, ...compare }
108
209
  }
109
210
  if (!['findings', 'files'].includes(route.view)) return route
110
211
  const { teamSlug, reportSlug, ...rest } = route
@@ -125,13 +226,23 @@ export function managedRouteForIds(route, teams, adminBundles = []) {
125
226
  return team?.slug && resolveManagedRoute(result, teams) ? result : null
126
227
  }
127
228
  if (route.view === 'bundles') {
128
- const { teamId, bundleId, ...rest } = route
229
+ const { teamId, bundleId, compareId, compareMode, ...rest } = route
129
230
  const team = teams.find(entry => entry.id === teamId)
130
231
  if (teamId != null && !team?.slug) return null
131
232
  const bundle = (team?.bundles ?? adminBundles).find(entry => entry.id === bundleId)
132
233
  if (!bundle?.slug) return null
133
- const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug }
134
- return resolveManagedRoute(result, teams, adminBundles) ? result : null
234
+ const compared = compareId == null ? null
235
+ : (teamId == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])).find(entry => entry.id === compareId)
236
+ const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug,
237
+ ...(compared?.slug ? { compareSlug: compared.slug, ...compareModeField(compareMode) } : {}) }
238
+ const resolved = resolveManagedRoute(result, teams, adminBundles)
239
+ if (!resolved) return null
240
+ // A compared bundle whose slug doesn't name it alone leaves the route.
241
+ if (result.compareSlug != null && resolved.compareId !== compareId) {
242
+ const { compareSlug: _slug, compareMode: _mode, ...bare } = result
243
+ return bare
244
+ }
245
+ return result
135
246
  }
136
247
  if (!['findings', 'files'].includes(route.view)) return route
137
248
  const { teamId, reportId, ...rest } = route
@@ -0,0 +1,2 @@
1
+ export type TeamAppMetadata = { appMode: true; appFindings: number } | { appMode: false }
2
+ export function managedTeamAppMetadata(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): TeamAppMetadata
@@ -0,0 +1,31 @@
1
+ import { inheritReportMeta, isAppFinding, reportEntries } from '@preventive/report'
2
+ import { collectDuplicates } from '../../client/linked-findings.js'
3
+ import { hasRevalidateStamp } from '../finding-filters.js'
4
+ import { workspaceAppMetadata } from '../workspace-app.js'
5
+
6
+ // The same classification as local workspaces, using only the complete,
7
+ // server-filtered published workspace: the reports of one team response and
8
+ // its `links`. The managed server classifies each team before sending its
9
+ // catalog, so the sidebar opens a team collapsed to its App findings at once.
10
+ // The server backfills finding IDs before building these envelopes.
11
+ export function managedTeamAppMetadata(workspace) {
12
+ const duplicates = new Map(), reports = []
13
+ collectDuplicates(workspace.links ?? [], duplicates)
14
+ for (const { filename, data } of workspace) {
15
+ if (data.source === 'links' && Array.isArray(data.links)) {
16
+ collectDuplicates(data.links, duplicates)
17
+ continue
18
+ }
19
+ const entries = (reportEntries(data) ?? []).map(entry => Array.isArray(entry) ? entry : [entry])
20
+ const judged = new Set(entries.flat().filter(hasRevalidateStamp).map(f => f.source ?? data.source ?? null))
21
+ const groups = entries.map(group => group.map(f => {
22
+ const finding = { ...f, _source: f.source ?? data.source ?? null }
23
+ inheritReportMeta(finding, data)
24
+ if (!('isApp' in finding)) finding.isApp = isAppFinding(finding, finding._source)
25
+ finding._sourcePass = judged.has(finding._source)
26
+ return finding
27
+ }))
28
+ reports.push({ fileName: filename, groups })
29
+ }
30
+ return workspaceAppMetadata(reports, id => [...duplicates.get(id) ?? []])
31
+ }
@@ -10,7 +10,7 @@
10
10
  // per-report ignore is a client-local concept keyed by report name.
11
11
  export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted' | 'ignored'
12
12
 
13
- export const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted', 'ignored']
13
+ const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted', 'ignored']
14
14
 
15
15
  // One finding's server-side triage entry (also the write shape — writes
16
16
  // replace the whole entry). Absent fields are unset; `flagged` is tri-state:
@@ -0,0 +1,126 @@
1
+ // Managed uploads cross TLS-terminating proxies (Cloudflare, corporate
2
+ // gateways) that would otherwise read report and bundle bytes in plaintext.
3
+ // The server sends each session an X25519 public key; the browser seals every
4
+ // upload to it with a fresh ephemeral key (X25519 → HKDF-SHA-256 →
5
+ // AES-256-GCM), so the content key itself never crosses the proxy. Text is
6
+ // gzipped before sealing; binary content is sealed as is.
7
+ //
8
+ // Sealed body: version(1) | flags(1) | ephemeral public key(32), then the
9
+ // payload in 1 MiB segments, each sealed under nonce = big-endian segment
10
+ // index with the last byte marking the final segment. The header is the HKDF
11
+ // salt, so altering it changes the key. Segments bound browser memory for
12
+ // large bundles, and their nonces reject reordered, truncated or extended
13
+ // bodies. Every upload derives its own key, so counter nonces never repeat.
14
+
15
+ import { gunzipBytes } from '../gzip.js'
16
+ import { encodeUtf8 } from '../utf8.js'
17
+
18
+ // Set (to the format version) on a request whose body is sealed.
19
+ export const UPLOAD_SEAL_HEADER = 'x-upload-encryption'
20
+ const VERSION = 1
21
+ const FLAG_GZIP = 1
22
+ const CURVE = { name: 'X25519' }
23
+ const HEADER_BYTES = 2 + 32
24
+ const TAG_BYTES = 16
25
+ const SEGMENT_BYTES = 1024 * 1024
26
+ const SNIFF_BYTES = 64 * 1024
27
+ const KEY_INFO = 'deepview-managed-upload.v1.content-key'
28
+
29
+ // The largest sealed body for `maxBytes` of content. Deflate can grow
30
+ // incompressible text slightly (its worst case is about 0.03%).
31
+ export function maxSealedBytes(maxBytes: number): number {
32
+ const payload = maxBytes + Math.ceil(maxBytes / 1024) + 64
33
+ return HEADER_BYTES + payload + TAG_BYTES * (Math.floor(payload / SEGMENT_BYTES) + 1)
34
+ }
35
+
36
+ function nonce(index: number, final: boolean): Uint8Array<ArrayBuffer> {
37
+ const iv = new Uint8Array(12)
38
+ new DataView(iv.buffer).setUint32(7, index)
39
+ iv[11] = final ? 1 : 0
40
+ return iv
41
+ }
42
+
43
+ async function contentKey(secret: ArrayBuffer, header: Uint8Array<ArrayBuffer>, usage: KeyUsage): Promise<CryptoKey> {
44
+ const ikm = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey'])
45
+ return await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: header, info: encodeUtf8(KEY_INFO) },
46
+ ikm, { name: 'AES-GCM', length: 256 }, false, [usage])
47
+ }
48
+
49
+ // Archives and other binary content would only grow under gzip. A NUL byte or
50
+ // invalid UTF-8 in the first 64 KiB marks content as binary.
51
+ async function isText(blob: Blob): Promise<boolean> {
52
+ const head = new Uint8Array(await blob.slice(0, SNIFF_BYTES).arrayBuffer())
53
+ if (head.includes(0)) return false
54
+ try { new TextDecoder('utf-8', { fatal: true }).decode(head, { stream: true }); return true }
55
+ catch { return false }
56
+ }
57
+
58
+ // Seal `blob` to the server's raw public key. Reads the input as a stream;
59
+ // sealed segments are kept as Blobs, which browsers may page to disk. An abort
60
+ // stops reading at the next chunk.
61
+ export async function sealUpload(blob: Blob, publicKey: Uint8Array<ArrayBuffer>, signal?: AbortSignal): Promise<Blob> {
62
+ const gzip = await isText(blob)
63
+ const [server, ephemeral] = await Promise.all([
64
+ crypto.subtle.importKey('raw', publicKey, CURVE, false, []),
65
+ crypto.subtle.generateKey(CURVE, false, ['deriveBits']) as Promise<CryptoKeyPair>,
66
+ ])
67
+ const header = new Uint8Array(HEADER_BYTES)
68
+ header[0] = VERSION
69
+ header[1] = gzip ? FLAG_GZIP : 0
70
+ header.set(new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey)), 2)
71
+ const key = await contentKey(await crypto.subtle.deriveBits({ name: 'X25519', public: server }, ephemeral.privateKey, 256), header, 'encrypt')
72
+ const parts = [new Blob([header])], segment = new Uint8Array(SEGMENT_BYTES)
73
+ let filled = 0, index = 0
74
+ const seal = async (final: boolean) => {
75
+ parts.push(new Blob([await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce(index++, final) }, key, segment.subarray(0, filled))]))
76
+ filled = 0
77
+ }
78
+ const stream: ReadableStream<Uint8Array> = gzip ? blob.stream().pipeThrough(new CompressionStream('gzip')) : blob.stream()
79
+ const reader = stream.getReader()
80
+ try {
81
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
82
+ signal?.throwIfAborted()
83
+ for (let offset = 0; offset < read.value.length;) {
84
+ // A full segment is final only if the input ends with it.
85
+ if (filled === SEGMENT_BYTES) await seal(false)
86
+ const length = Math.min(SEGMENT_BYTES - filled, read.value.length - offset)
87
+ segment.set(read.value.subarray(offset, offset + length), filled)
88
+ filled += length
89
+ offset += length
90
+ }
91
+ }
92
+ } catch (err) {
93
+ await reader.cancel(err).catch(() => {})
94
+ throw err
95
+ }
96
+ await seal(true)
97
+ return new Blob(parts)
98
+ }
99
+
100
+ // Open a sealed body with the session's private JWK. Throws 'too-large' when
101
+ // the content exceeds `maxBytes`, else 'bad-upload' for any invalid body.
102
+ export async function openUpload(sealed: Uint8Array<ArrayBuffer>, privateKey: JsonWebKey, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
103
+ // Every segment but the last is full, so the layout follows from the length.
104
+ const rest = sealed.length - HEADER_BYTES
105
+ const count = Math.ceil(rest / (SEGMENT_BYTES + TAG_BYTES))
106
+ const last = rest - (count - 1) * (SEGMENT_BYTES + TAG_BYTES)
107
+ if (rest < TAG_BYTES || last < TAG_BYTES || sealed[0] !== VERSION || (sealed[1]! & ~FLAG_GZIP) !== 0) throw new Error('bad-upload')
108
+ const gzip = sealed[1] === FLAG_GZIP, plain = new Uint8Array(rest - count * TAG_BYTES)
109
+ if (!gzip && plain.length > maxBytes) throw new Error('too-large')
110
+ try {
111
+ const header = sealed.slice(0, HEADER_BYTES)
112
+ const [key, peer] = await Promise.all([
113
+ crypto.subtle.importKey('jwk', privateKey, CURVE, false, ['deriveBits']),
114
+ crypto.subtle.importKey('raw', header.slice(2), CURVE, false, []),
115
+ ])
116
+ const content = await contentKey(await crypto.subtle.deriveBits({ name: 'X25519', public: peer }, key, 256), header, 'decrypt')
117
+ for (let index = 0; index < count; index++) {
118
+ const start = HEADER_BYTES + index * (SEGMENT_BYTES + TAG_BYTES)
119
+ const segment = sealed.subarray(start, Math.min(start + SEGMENT_BYTES + TAG_BYTES, sealed.length))
120
+ plain.set(new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce(index, index === count - 1) }, content, segment)), index * SEGMENT_BYTES)
121
+ }
122
+ } catch (err) { throw new Error('bad-upload', { cause: err }) }
123
+ if (!gzip) return plain
124
+ try { return await gunzipBytes(plain, { maxBytes }) }
125
+ catch (err) { throw new Error(err instanceof RangeError ? 'too-large' : 'bad-upload', { cause: err }) }
126
+ }
@@ -0,0 +1,5 @@
1
+ // Type declarations for `common/pretty-print.js`, for `.ts` callers.
2
+
3
+ export const MAX_PRETTY_BYTES: number
4
+ export function prettyExtension(path: string): string | null
5
+ export function needsPrettyPrint(path: string, text: string, read?: () => { category: string }): boolean
@@ -0,0 +1,24 @@
1
+ // Minified files pretty-printed by the server (server-managed/pretty-print.ts)
2
+ // for the viewer (ui/view/pretty-source.js).
3
+ import { npmFileReadability } from '../risk/files/readability.js'
4
+ import { utf8ByteLength } from './utf8.js'
5
+
6
+ // The extensions formatted, each parsed as the language it names.
7
+ const PRETTY_EXTENSIONS = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'css', 'json'])
8
+
9
+ // The largest file formatted, in UTF-8 bytes: formatting holds a syntax tree
10
+ // a hundred times the file's size.
11
+ export const MAX_PRETTY_BYTES = 4 * 1024 * 1024
12
+
13
+ // The extension a path is formatted as, or null for one that isn't.
14
+ export function prettyExtension(path) {
15
+ const extension = /\.([a-z]+)$/iu.exec(path)?.[1]?.toLowerCase()
16
+ return PRETTY_EXTENSIONS.has(extension) ? extension : null
17
+ }
18
+
19
+ // Whether a file's text is pretty-printed: in a language formatted, no larger
20
+ // than formatted, and minified, as risk reads it, `read` reading that where
21
+ // the reader keeps it.
22
+ export function needsPrettyPrint(path, text, read = () => npmFileReadability(path, text)) {
23
+ return prettyExtension(path) !== null && utf8ByteLength(text) <= MAX_PRETTY_BYTES && read().category === 'minified'
24
+ }
@@ -0,0 +1,48 @@
1
+ import { REVALIDATE_KINDS, revalidateKindOf } from '@preventive/report'
2
+
3
+ // Keep only the pass's fields, before gap-filling changes the survivor.
4
+ // In particular, a verdict borrowed from report B must never be attributed
5
+ // to an unstamped copy from report A when report C later contradicts it.
6
+ function fieldsOf(finding) {
7
+ return Object.fromEntries(Object.entries(finding).filter(([key, value]) =>
8
+ key.startsWith('revalidate') && value != null))
9
+ }
10
+
11
+ export function recordRevalidationCopies(survivor, duplicate, reportName) {
12
+ const fields = fieldsOf(duplicate)
13
+ if (!survivor._revalidationCopies) {
14
+ const own = fieldsOf(survivor)
15
+ if (Object.keys(own).length === 0 && Object.keys(fields).length === 0) return []
16
+ survivor._revalidationCopies = Object.keys(own).length > 0
17
+ ? [{ reportName: survivor._reportName ?? '', fields: own }]
18
+ : []
19
+ }
20
+ if (Object.keys(fields).length > 0) survivor._revalidationCopies.push({ reportName, fields })
21
+ return survivor._revalidationCopies
22
+ }
23
+
24
+ // Same comparisons as mergeDuplicateFields: missing values are gaps, stamps
25
+ // are normalized, and prose ignores surrounding whitespace. Group agreeing
26
+ // reports together, retaining every distinct answer rather than just a pair.
27
+ export function revalidationDifferences(copies) {
28
+ const byField = new Map()
29
+ const knownKinds = new Set(REVALIDATE_KINDS)
30
+ for (const { reportName, fields } of copies) {
31
+ for (const [key, value] of Object.entries(fields)) {
32
+ if (value == null) continue
33
+ const normalized = key === 'revalidate'
34
+ ? revalidateKindOf(fields) || String(value).trim().toLowerCase()
35
+ : String(value).trim()
36
+ if (key === 'revalidate' && !normalized) continue
37
+ if (key === 'revalidate' && !knownKinds.has(normalized)) continue
38
+ if (!byField.has(key)) byField.set(key, new Map())
39
+ const variants = byField.get(key)
40
+ if (!variants.has(normalized)) variants.set(normalized, new Set())
41
+ variants.get(normalized).add(reportName)
42
+ }
43
+ }
44
+ return [...byField].filter(([, variants]) => variants.size > 1).map(([field, variants]) => ({
45
+ field,
46
+ variants: [...variants].map(([value, reports]) => ({ value, reports: [...reports] })),
47
+ }))
48
+ }
@@ -0,0 +1,56 @@
1
+ // Recover source-level row boundaries before hiding the App layer. App
2
+ // deduplication alone does not make the underlying code findings equivalent.
3
+ // Only split when every source ID is accounted for and every referenced input
4
+ // exists in this row. Missing input lists are empty, not missing evidence for
5
+ // an otherwise complete partition. Original findings and rows stay untouched.
6
+ export function splitRevalidationInputs(group, appFindings = null) {
7
+ const app = appFindings ?? group.filter((f) => f.isApp)
8
+ const source = group.filter((f) => !f.isApp)
9
+ if (app.length === 0 || source.length === 0) return [group]
10
+ // A surviving App finding means this is not a pure non-App projection;
11
+ // never move it into a source-only partition.
12
+ if (appFindings && group.some((f) => f.isApp)) return [group]
13
+ if (source.some((f) => typeof f.id !== 'string' || !f.id)) return [group]
14
+
15
+ const sourceIds = new Set(source.map((f) => f.id))
16
+ const inputsByApp = new Map()
17
+ const covered = new Set()
18
+ for (const f of app) {
19
+ const inputs = f.revalidateInputs === undefined ? [] : f.revalidateInputs
20
+ if (!Array.isArray(inputs)) return [group]
21
+ for (const id of inputs) {
22
+ if (!sourceIds.has(id)) return [group]
23
+ covered.add(id)
24
+ }
25
+ inputsByApp.set(f, inputs)
26
+ }
27
+ if (covered.size !== sourceIds.size) return [group]
28
+
29
+ const parent = new Map([...sourceIds].map((id) => [id, id]))
30
+ const find = (id) => {
31
+ let root = id
32
+ while (parent.get(root) !== root) root = parent.get(root)
33
+ while (parent.get(id) !== root) {
34
+ const next = parent.get(id)
35
+ parent.set(id, root)
36
+ id = next
37
+ }
38
+ return root
39
+ }
40
+ for (const inputs of inputsByApp.values()) {
41
+ if (inputs.length === 0) continue
42
+ const root = find(inputs[0])
43
+ for (const id of inputs) parent.set(find(id), root)
44
+ }
45
+
46
+ const components = new Map()
47
+ for (const f of group) {
48
+ const inputs = inputsByApp.get(f)
49
+ // Keep an App finding with its inputs until the caller projects the lens.
50
+ // App findings without inputs stand alone; hiding them then drops the row.
51
+ const key = inputs ? inputs.length > 0 ? find(inputs[0]) : f : find(f.id)
52
+ if (!components.has(key)) components.set(key, [])
53
+ components.get(key).push(f)
54
+ }
55
+ return components.size === 1 ? [group] : [...components.values()]
56
+ }
@@ -0,0 +1,23 @@
1
+ import { revalidateKindOf } from '@preventive/report'
2
+ import { canLockConfirmed } from './finding-filters.js'
3
+ import { mergeReportGroups } from './workspace-groups.js'
4
+ import { mergeLinkedWorkspaceGroups } from './linked-workspace-groups.js'
5
+
6
+ function appTabs(group) {
7
+ if (group.linkedTabs) return group.linkedTabs
8
+ const app = group.filter((f) => revalidateKindOf(f) === 'revalidation')
9
+ return app.length > 0 ? app : group
10
+ }
11
+
12
+ // Evaluate the entire workspace, independent of triage, filters, or the lens
13
+ // left active by another report. Use the same merge and link rules as the view.
14
+ export function workspaceAppMetadata(reports, duplicatesOf = () => []) {
15
+ const { groups, conflicts } = mergeReportGroups(reports, { hideRuledOut: true })
16
+ if (conflicts.size > 0) return { appMode: false }
17
+ const linked = mergeLinkedWorkspaceGroups(groups, duplicatesOf, appTabs)
18
+ if (!canLockConfirmed(linked, { tabs: appTabs, kindOf: revalidateKindOf, severityMode: 'corrected' })) return { appMode: false }
19
+ return {
20
+ appMode: true,
21
+ appFindings: linked.filter((group) => appTabs(group).some((f) => f.isApp)).length,
22
+ }
23
+ }