@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1336 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +27 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,108 @@
1
+ // Apply report permissions before serving findings, triage, or source files.
2
+ // Security uses the same complete-row classification as the UI. Dependency
3
+ // access is per finding: App, own source, and the own-source organizations stay.
4
+ import { inheritReportMeta, isAppFinding, reportRepoGithub, stampSecurityGroups } from '../../report/index.js'
5
+ import { readManagedReport } from './report-content.ts'
6
+
7
+ export interface ViewerPermissions {
8
+ dependencies: boolean
9
+ security: boolean
10
+ }
11
+
12
+ type Finding = Record<string, unknown>
13
+ function object(value: unknown): Finding | null {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Finding : null
15
+ }
16
+ function pathOf(finding: Finding): string {
17
+ return typeof finding['file'] === 'string' ? finding['file'].replaceAll('\\', '/') : ''
18
+ }
19
+ function isDependency(finding: Finding): boolean {
20
+ return /(?:^|\/)(?:node_modules|vendor|dependencies)(?:\/|$)/u.test(pathOf(finding))
21
+ }
22
+ function githubOrg(value: unknown): string | null {
23
+ return reportRepoGithub(value)?.split('/')[0]?.toLowerCase() ?? null
24
+ }
25
+ function npmScope(finding: Finding, fromPath = false): string | null {
26
+ let name = object(object(finding['package'])?.['npm'])?.['name']
27
+ if (typeof name !== 'string' && fromPath) {
28
+ const parts = pathOf(finding).split('/')
29
+ for (let i = parts.length - 3; i >= 0; i--) {
30
+ if (['node_modules', 'vendor', 'dependencies'].includes(parts[i]!)) {
31
+ name = `${parts[i + 1]}/${parts[i + 2]}`
32
+ break
33
+ }
34
+ }
35
+ }
36
+ return typeof name === 'string' ? /^(@[^/\s]+)\/[^/\s]+$/u.exec(name)?.[1] ?? null : null
37
+ }
38
+
39
+ // Parsed projections keep run inheritance and derived flags out of cached
40
+ // reports. A known positive security stamp may include linked findings outside
41
+ // this report; a negative stamp never overrides the intrinsic classifier.
42
+ export function projectFinding(value: unknown, report: Finding): Finding | null {
43
+ const original = object(value)
44
+ if (!original) return null
45
+ const finding = { ...original }
46
+ inheritReportMeta(finding, report)
47
+ finding['_source'] = finding['source'] ?? report['source'] ?? null
48
+ if (!('isApp' in finding)) finding['isApp'] = isAppFinding(finding, finding['_source'])
49
+ if (finding['isSecurity'] === true) finding['security'] = true
50
+ return finding
51
+ }
52
+
53
+ // Native JSON and supported text imports obey the same permission rules.
54
+ // A filtered text import is returned as JSON under its original filename, which
55
+ // readManagedReport already accepts. Unchanged inputs keep their original bytes.
56
+ export function filterReportContent(content: string, perms: ViewerPermissions, filename = '', repo?: unknown): string {
57
+ if (perms.dependencies && perms.security) return content
58
+ let data: unknown
59
+ try { data = JSON.parse(content) } catch {
60
+ const parsed = readManagedReport(content, filename)
61
+ if (parsed.data == null) {
62
+ if (/\.csv$/iu.test(filename)) throw new Error('Cannot filter unreadable CSV report')
63
+ return content
64
+ }
65
+ data = parsed.data
66
+ }
67
+ const filtered = filterReportData(data, perms, repo)
68
+ return filtered === data ? content : JSON.stringify(filtered)
69
+ }
70
+
71
+ // repo, when supplied, is the server's authoritative assignment (including an
72
+ // explicit unassigned value). Finding-specific repos still describe their source.
73
+ export function filterReportData(data: unknown, perms: ViewerPermissions, repo?: unknown, securityIds?: Set<string>): unknown {
74
+ if (perms.dependencies && perms.security) return data
75
+ const report = object(data)
76
+ if (!report) return data
77
+ const key = Array.isArray(report['findings']) ? 'findings' : Array.isArray(report['groups']) ? 'groups' : null
78
+ if (!key) return data
79
+ const entries = (report[key] as unknown[]).map(entry => ({
80
+ entry, members: (Array.isArray(entry) ? entry : [entry]).map(original => ({ original, finding: projectFinding(original, report) })),
81
+ }))
82
+ const groups = entries.map(({ members }) => members.flatMap(({ finding }) => finding ? [finding] : []))
83
+ if (!perms.security) stampSecurityGroups(groups)
84
+ const githubOrgs = new Set<string>(), npmScopes = new Set<string>()
85
+ const addOrgs = (finding: Finding) => {
86
+ const github = githubOrg(finding), npm = npmScope(finding)
87
+ if (github) githubOrgs.add(github)
88
+ if (npm) npmScopes.add(npm)
89
+ }
90
+ addOrgs({ ...report, repo: repo === undefined ? report['repo'] : repo })
91
+ for (const finding of groups.flat()) {
92
+ if (finding['isApp'] !== true && !isDependency(finding)) addOrgs(finding)
93
+ }
94
+ const kept: unknown[] = []
95
+ let changed = false
96
+ for (const { entry, members } of entries) {
97
+ // Classify the complete row before hiding dependency components.
98
+ if (!perms.security && members.some(({ finding }) => finding?.['isSecurity'] === true || securityIds?.has(String(finding?.['id'])))) { changed = true; continue }
99
+ const visible = members.filter(({ finding }) => !finding || perms.dependencies || finding['isApp'] === true || !isDependency(finding)
100
+ || githubOrgs.has(githubOrg(finding) ?? '') || npmScopes.has(npmScope(finding, true) ?? ''))
101
+ if (visible.length === members.length) kept.push(entry)
102
+ else {
103
+ changed = true
104
+ if (visible.length > 0) kept.push(Array.isArray(entry) ? visible.map(({ original }) => original) : visible[0]!.original)
105
+ }
106
+ }
107
+ return changed ? { ...report, [key]: kept } : data
108
+ }
@@ -0,0 +1,28 @@
1
+ // Managed-mode user roles, shared by the managed server (which stores +
2
+ // validates them) and the admin client (the role picker). A linear privilege
3
+ // ladder, highest first:
4
+ // admin — full access incl. managing other users' roles
5
+ // manage — owned/team content management
6
+ // triage — can triage findings
7
+ // view — read-only
8
+ // none — no access
9
+ // Admins manage workspace access. Managers handle owned uploads and content
10
+ // in their teams; repository changes also require repository/path access. View
11
+ // is the floor for reading content, triage for annotating accessible reports.
12
+ // None cannot read or mutate managed data, regardless of ownership or teams.
13
+ export type Role = 'admin' | 'manage' | 'triage' | 'view' | 'none'
14
+
15
+ export const ROLES: readonly Role[] = ['admin', 'manage', 'triage', 'view', 'none']
16
+
17
+ export function isRole(x: unknown): x is Role {
18
+ return typeof x === 'string' && (ROLES as readonly string[]).includes(x)
19
+ }
20
+
21
+ // True iff `role` sits at or above `min` in the privilege ladder. ROLES is
22
+ // ordered highest-first, so a lower index is more privileged; an unknown role
23
+ // (index -1) is never "at least" anything. e.g. roleAtLeast(role, 'view') is
24
+ // "has at least read access" (everything except 'none').
25
+ export function roleAtLeast(role: Role, min: Role): boolean {
26
+ const r = ROLES.indexOf(role)
27
+ return r !== -1 && r <= ROLES.indexOf(min)
28
+ }
@@ -0,0 +1,2 @@
1
+ // The static server only needs to recognise managed pages, not resolve them.
2
+ export function parseManagedRoute(url: URL): { view: string } | null
@@ -0,0 +1,121 @@
1
+ // Managed client pages only. API paths and E2E share hashes are not routes.
2
+ import { BUNDLE_TABS } from '../bundle-tabs.js'
3
+ import { MAX_FINDING_ID_LENGTH, isLinkableFindingId } from '../finding-id.js'
4
+
5
+ export const MANAGED_PAGES = Object.freeze({
6
+ manage: '/manage',
7
+ 'manage-bundles': '/manage/bundle',
8
+ 'manage-scans': '/manage/scans',
9
+ 'manage-reports': '/manage/report',
10
+ 'manage-repos': '/manage/repositories',
11
+ 'admin-users': '/manage/users',
12
+ 'manage-teams': '/manage/team',
13
+ 'manage-import': '/manage/import',
14
+ 'manage-links': '/manage/links',
15
+ 'manage-history': '/manage/history',
16
+ })
17
+
18
+ // Dot-only path components are normalised away by URL parsers, even escaped.
19
+ function findingPath(id) {
20
+ if (!isLinkableFindingId(id) || id === '.' || id === '..') return null
21
+ try { return `/finding/${encodeURIComponent(id)}` } catch { return null }
22
+ }
23
+
24
+ export function managedRoutePath(route) {
25
+ if (!route) return null
26
+ if (route.view === 'home') return '/'
27
+ if (route.view === 'bundles') {
28
+ if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
29
+ || (route.teamSlug != null && !/^[A-Za-z0-9_-]+$/u.test(route.teamSlug))) return null
30
+ const tab = route.bundleTab ?? 'overview'
31
+ if (!BUNDLE_TABS.has(tab)) return null
32
+ const parent = route.teamSlug ? `/team/${route.teamSlug}` : '/manage'
33
+ return `${parent}/bundle/${route.bundleSlug}${tab === 'overview' ? '' : `/${tab}`}`
34
+ }
35
+ if (Object.hasOwn(MANAGED_PAGES, route.view)) {
36
+ const path = MANAGED_PAGES[route.view]
37
+ if (route.view === 'manage-scans' && route.bundleId) return `${path}?bundle=${encodeURIComponent(route.bundleId)}`
38
+ return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
39
+ }
40
+ if (!['findings', 'files'].includes(route.view) || !route.teamSlug
41
+ || [route.teamSlug, route.reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
42
+ const team = `/team/${encodeURIComponent(route.teamSlug)}`
43
+ const report = route.reportSlug ? `/report/${encodeURIComponent(route.reportSlug)}` : ''
44
+ if (route.finding) {
45
+ const suffix = findingPath(route.finding.id)
46
+ return route.view === 'findings' && suffix ? `${team}${report}${suffix}` : null
47
+ }
48
+ return `${team}${report}${route.view === 'files' ? '/files' : ''}`
49
+ }
50
+
51
+ export function parseManagedRoute(url) {
52
+ const path = url.pathname.replace(/\/$/u, '') || '/'
53
+ if (path === '/' || path === '/index.html') return { view: 'home' }
54
+ const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
55
+ if (view) {
56
+ return { view,
57
+ ...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
58
+ ...(view === 'manage-scans' && url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {}),
59
+ }
60
+ }
61
+ const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?$/u.exec(path)
62
+ if (bundle) {
63
+ const bundleTab = bundle[3] ?? 'overview'
64
+ return BUNDLE_TABS.has(bundleTab) ? { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab } : null
65
+ }
66
+ const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
67
+ if (!match) return null
68
+ try {
69
+ const teamSlug = decodeURIComponent(match[1])
70
+ const reportSlug = match[2] ? decodeURIComponent(match[2]) : null
71
+ if ([teamSlug, reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
72
+ if (match[4]?.length > MAX_FINDING_ID_LENGTH * 9) return null
73
+ const id = match[4] ? decodeURIComponent(match[4]) : null
74
+ if (id != null && !findingPath(id)) return null
75
+ return { view: match[3] ? 'files' : 'findings', teamSlug, reportSlug, ...(id == null ? {} : { finding: { id } }) }
76
+ } catch { return null }
77
+ }
78
+
79
+ // URL tokens are exact server-assigned slugs. Application state and API calls
80
+ // continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
81
+ export function resolveManagedRoute(route, teams, adminBundles = []) {
82
+ if (route.view === 'bundles') {
83
+ const { teamSlug, bundleSlug, ...rest } = route
84
+ const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
85
+ if (teamSlug != null && matches.length !== 1) return null
86
+ const team = matches[0]
87
+ const candidates = teamSlug == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])
88
+ const ids = new Set(candidates.filter(bundle => bundle.slug === bundleSlug).map(bundle => bundle.id))
89
+ const bundle = (team?.bundles ?? adminBundles).find(entry => entry.slug === bundleSlug)
90
+ return bundleSlug && bundle && ids.size === 1 ? { ...rest, teamId: team?.id ?? null, bundleId: bundle.id } : null
91
+ }
92
+ if (!['findings', 'files'].includes(route.view)) return route
93
+ const { teamSlug, reportSlug, ...rest } = route
94
+ const matches = teams.filter(team => team.slug === teamSlug)
95
+ if (!teamSlug || matches.length !== 1) return null
96
+ const team = matches[0]
97
+ if (reportSlug == null) return { ...rest, teamId: team.id, reportId: null }
98
+ const reports = teams.flatMap(entry => entry.reports ?? []).filter(report => report.slug === reportSlug)
99
+ const ids = new Set(reports.map(report => report.id))
100
+ const report = (team.reports ?? []).find(entry => entry.slug === reportSlug)
101
+ return report && ids.size === 1 ? { ...rest, teamId: team.id, reportId: report.id } : null
102
+ }
103
+
104
+ export function managedRouteForIds(route, teams, adminBundles = []) {
105
+ if (route.view === 'bundles') {
106
+ const { teamId, bundleId, ...rest } = route
107
+ const team = teams.find(entry => entry.id === teamId)
108
+ if (teamId != null && !team?.slug) return null
109
+ const bundle = (team?.bundles ?? adminBundles).find(entry => entry.id === bundleId)
110
+ if (!bundle?.slug) return null
111
+ const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug }
112
+ return resolveManagedRoute(result, teams, adminBundles) ? result : null
113
+ }
114
+ if (!['findings', 'files'].includes(route.view)) return route
115
+ const { teamId, reportId, ...rest } = route
116
+ const team = teams.find(entry => entry.id === teamId)
117
+ const report = reportId == null ? null : team?.reports?.find(entry => entry.id === reportId)
118
+ if (!team?.slug || (reportId != null && !report?.slug)) return null
119
+ const result = { ...rest, teamSlug: team.slug, reportSlug: report?.slug ?? null }
120
+ return resolveManagedRoute(result, teams) ? result : null
121
+ }
@@ -0,0 +1,6 @@
1
+ import { defaultScanModels } from '../default-scan-models.ts'
2
+
3
+ // Use the same starting catalogue as the UI until managed scan discovery is wired.
4
+ const catalogue = defaultScanModels()
5
+ export const MANAGED_SCAN_MODELS = Object.freeze(catalogue.models)
6
+ export const DEFAULT_MANAGED_SCAN_MODEL = catalogue.defaultModel
@@ -0,0 +1,83 @@
1
+ // Per-finding triage annotations on a managed server — shared by the server
2
+ // (which stores one row per finding id, validates the wire shape, and decides
3
+ // per report which ids a viewer may read or write) and the managed client
4
+ // (which mirrors entries into its local triage map). Keyed by finding id
5
+ // alone, like that map: reports mostly repeat one another (a re-scan of the
6
+ // same code carries the same finding ids), and a finding's triage is shared by
7
+ // every report that carries it. A wire entry carries the four server-persisted
8
+ // fields below, or is null for a cleared entry (the server's tombstone); the
9
+ // client's `ignoredReports` deliberately does NOT ride this wire — the
10
+ // per-report ignore is a client-local concept keyed by report name.
11
+ export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted'
12
+
13
+ export const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted']
14
+
15
+ // One finding's server-side triage entry (also the write shape — writes
16
+ // replace the whole entry). Absent fields are unset; `flagged` is tri-state:
17
+ // absent = never set, `true` = flagged, `false` = an explicit un-flag
18
+ // tombstone that must round-trip (see client/state.ts TriageEntry).
19
+ export type TriageEntryPatch = {
20
+ color?: string
21
+ triage?: TriageBucket
22
+ // Legacy triage history/migration only. New comments use separate records;
23
+ // the managed triage write endpoint rejects this field.
24
+ comment?: string
25
+ fix?: string
26
+ flagged?: boolean
27
+ }
28
+
29
+ // Length caps enforced at the write endpoint so one entry can't balloon a row.
30
+ export const MAX_TRIAGE_TEXT = 10_000
31
+ export const MAX_TRIAGE_COLOR = 50
32
+
33
+ // Caps on one write request as a whole — shared with the client, which batches
34
+ // within them: entries per request, the finding-id length (real ids are 36-char
35
+ // uuids), and the JSON body (the server's small default is too small for entry
36
+ // batches, which carry free text).
37
+ export const MAX_TRIAGE_ENTRIES = 200
38
+ export const MAX_FINDING_ID = 100
39
+ export const MAX_TRIAGE_BODY_BYTES = 262_144
40
+
41
+ // The most trail events one history read returns (newest first). Retention is
42
+ // the server's: everything is kept unless its TRIAGE_HISTORY_LIMIT says
43
+ // otherwise.
44
+ export const MAX_TRIAGE_HISTORY = 200
45
+
46
+ export function isTriageBucket(x: unknown): x is TriageBucket {
47
+ return typeof x === 'string' && (TRIAGE_BUCKETS as readonly string[]).includes(x)
48
+ }
49
+
50
+ // Coerce one wire entry (a request-body value) into a clean patch. `null`
51
+ // means "clear the entry"; a malformed value (wrong type, unknown bucket,
52
+ // over-cap string, non-boolean flagged) is 'invalid' so the endpoint 400s
53
+ // rather than store garbage. Null/empty-string fields count as absent (an
54
+ // absent field is cleared anyway under whole-entry replace); unknown keys are
55
+ // ignored.
56
+ export function parseTriageEntryPatch(x: unknown): TriageEntryPatch | null | 'invalid' {
57
+ if (x === null) return null
58
+ if (typeof x !== 'object' || Array.isArray(x)) return 'invalid'
59
+ const o = x as Record<string, unknown>
60
+ const out: TriageEntryPatch = {}
61
+ const color = o['color']
62
+ if (color != null) {
63
+ if (typeof color !== 'string' || color.length > MAX_TRIAGE_COLOR) return 'invalid'
64
+ if (color !== '') out.color = color
65
+ }
66
+ const triage = o['triage']
67
+ if (triage != null) {
68
+ if (!isTriageBucket(triage)) return 'invalid'
69
+ out.triage = triage
70
+ }
71
+ for (const key of ['comment', 'fix'] as const) {
72
+ const v = o[key]
73
+ if (v == null) continue
74
+ if (typeof v !== 'string' || v.length > MAX_TRIAGE_TEXT) return 'invalid'
75
+ if (v !== '') out[key] = v
76
+ }
77
+ const flagged = o['flagged']
78
+ if (flagged != null) {
79
+ if (typeof flagged !== 'boolean') return 'invalid'
80
+ out.flagged = flagged
81
+ }
82
+ return out
83
+ }
@@ -15,8 +15,11 @@
15
15
  // `session.error` until `dismissError()`.
16
16
  // `busy` — per-socket inflight cap dropped the save
17
17
  // (transport backpressure). Recoverable; the
18
- // client clears `pending` and re-arms
19
- // `pendingSave` for the next natural trigger.
18
+ // client clears `pending`, re-arms `pendingSave`,
19
+ // and schedules a short timed retry — without the
20
+ // timer an idle client (no further edit /
21
+ // broadcast / reconnect) would strand its last
22
+ // edit unsynced indefinitely.
20
23
  // `stale-base` — concurrent commit advanced the head past the
21
24
  // client's claimed base. The server emits the
22
25
  // typed frame AFTER a `workspace-state` catch-up
@@ -25,11 +28,21 @@
25
28
  // and this frame's handler then early-returns
26
29
  // on the missing pending. So `stale-base` is
27
30
  // NOT in the recoverable set: in the happy path
28
- // the recoverable branch is unreachable; in the
29
- // pathological case where wire order flipped,
30
- // we'd rather mark the session errored (visible
31
- // via UI + recoverable via `dismissError()`)
32
- // than silently swallow a state divergence.
31
+ // the recoverable branch is unreachable. When
32
+ // `pending` DOES survive to this frame — the
33
+ // catch-up was empty (chain gone: wiped /
34
+ // migrated DB) or didn't apply (chain rebuilt
35
+ // past the client's base) — the client answers
36
+ // with ONE full-state-push reset per recovery
37
+ // cycle (re-anchor at base=null; safe because
38
+ // the server's null-safe head CAS rejects a
39
+ // base=null root against any non-empty chain;
40
+ // see `staleResetAttempted` in triage-sync.ts).
41
+ // A repeat within the cycle marks the session
42
+ // errored (visible via UI + recoverable via
43
+ // `dismissError()`) rather than looping
44
+ // full-state saves against a relay that keeps
45
+ // rejecting them.
33
46
 
34
47
  export type SaveErrorReason = 'too-large' | 'busy' | 'stale-base'
35
48
 
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_SCAN_SERVER = 'http://127.0.0.1:3123/'
2
+
3
+ // Discovery is optional: malformed scan configuration must never prevent
4
+ // opening local data or recognizing the server's sync protocol.
5
+ export function normalizeScanServer(value: unknown): string | null {
6
+ if (typeof value !== 'string' || !value.trim()) return null
7
+ try {
8
+ const url = new URL(value)
9
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null
10
+ url.pathname = url.pathname.replace(/\/+$/u, '') + '/'
11
+ return url.href
12
+ } catch { return null }
13
+ }
@@ -0,0 +1,33 @@
1
+ // The sync-protocol mode a server advertises — shared by the server (which
2
+ // emits it) and the client (which parses + caches it) so a change to the
3
+ // shape surfaces as a compile error on BOTH sides, the same single-source
4
+ // discipline as ./save-error-reason.ts.
5
+ //
6
+ // Delivered to the client as the first `server-info` frame on a sync
7
+ // connection (the WS plane and the SSE+POST fallback share one send path).
8
+ // A client uses it to detect whether a deployment speaks the end-to-end
9
+ // (`e2e`, zero-knowledge) protocol, the trusted `managed` protocol, or both.
10
+ // Combined discovery modes put the default protocol first.
11
+
12
+ export type ServerMode = 'e2e' | 'managed'
13
+
14
+ // Managed-mode entry points the server advertises; null for an e2e server.
15
+ export interface ManagedServerInfo {
16
+ loginPath: string
17
+ cookieName: string
18
+ uploadChunkBytes?: number
19
+ allowShare?: boolean
20
+ }
21
+
22
+ // The `server-info` frame payload (the `type` discriminant is added at the
23
+ // send site). `managed` is null on e2e-only deployments.
24
+ export interface ServerInfo {
25
+ mode: ServerMode | 'managed+e2e' | 'e2e+managed'
26
+ managed: ManagedServerInfo | null
27
+ deepviewScanServer?: string
28
+ }
29
+
30
+ // The mode-probe route. A client GETs this to learn a server's protocol up
31
+ // front — a managed server has no WS plane to carry the connect frame — and
32
+ // the response body is the same `ServerInfo` shape emitted as that frame.
33
+ export const CONFIG_PATH = '/api/config'
package/common/utf8.d.ts CHANGED
@@ -11,3 +11,6 @@
11
11
  // narrower declaration is safe.
12
12
  export function encodeUtf8(str: string): Uint8Array<ArrayBuffer>
13
13
  export function decodeUtf8(bytes: Uint8Array | ArrayBuffer | ArrayBufferView): string
14
+ // Bytes `str` takes in UTF-8, measured without encoding when no character
15
+ // is past \xFF. A lone surrogate counts as the U+FFFD it encodes to.
16
+ export function utf8ByteLength(str: string): number
package/common/utf8.js CHANGED
@@ -43,6 +43,51 @@ export function encodeUtf8(str) {
43
43
  return encoder.encode(str)
44
44
  }
45
45
 
46
+ // ASCII characters in a row after which the count hands back to the
47
+ // native scan. A jump costs a call and a step does not, so it pays only
48
+ // across a real gap: at 1, alternating text (`aéaé…`) takes a jump per
49
+ // character, and past 2, text with an accent every few words steps
50
+ // through ASCII the scan crosses faster. Measured on 50 MB of each, 2 was
51
+ // never more than 1.5x the best of the thresholds tried, 1 to 32.
52
+ const ASCII_RUN = 2
53
+
54
+ // The number of bytes `str` takes in UTF-8, without encoding it where
55
+ // that can be helped. A string with no character past \xFF is Latin-1:
56
+ // ASCII takes a byte and \x80-\xFF two, so its size is its length plus
57
+ // its high characters. An engine stores such a string a byte per
58
+ // character, where no character past \xFF can be, so the first test is
59
+ // answered without reading it. Only a string with a wider character is
60
+ // encoded to be measured.
61
+ //
62
+ // The high characters are counted without collecting them: a match array
63
+ // takes an entry apiece, which for Latin-1-heavy text outweighs the
64
+ // encoding this avoids. A native scan jumps to the next one — for ASCII
65
+ // it finds none — and a loop counts on from there while they keep coming,
66
+ // handing back to the scan after a run of ASCII. Sparse text is read
67
+ // natively, dense text by the loop, and neither allocates.
68
+ //
69
+ // This sizes text for display, so unlike `encodeUtf8` it does not refuse
70
+ // a lone surrogate: it counts the U+FFFD that TextEncoder writes for one.
71
+ export function utf8ByteLength(str) {
72
+ if (typeof str !== 'string') {
73
+ throw new TypeError(`utf8ByteLength expects a string, got ${typeof str}`)
74
+ }
75
+ // Code units, not code points: a `u` regexp reads a two-byte string by
76
+ // code point, several times slower, and a surrogate is past \xFF either way.
77
+ // eslint-disable-next-line require-unicode-regexp
78
+ if (/[\u0100-\uFFFF]/.test(str)) return encoder.encode(str).byteLength
79
+ const high = /[\u0080-\u00FF]/gu
80
+ let bytes = str.length
81
+ while (high.test(str)) {
82
+ let i = high.lastIndex - 1
83
+ for (let ascii = 0; i < str.length && ascii < ASCII_RUN; i++) {
84
+ if (str.codePointAt(i) > 0x7F) { bytes++; ascii = 0 } else ascii++
85
+ }
86
+ high.lastIndex = i
87
+ }
88
+ return bytes
89
+ }
90
+
46
91
  export function decodeUtf8(bytes) {
47
92
  // Reject non-BufferSource so a missed destructure / optional field /
48
93
  // misnamed property surfaces here instead of silently defaulting to