@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.30

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/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +81 -0
  41. package/out/client-managed.js +167 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +102 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +60 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,51 @@
1
+ const EXT_TO_LANG = {
2
+ __proto__: null,
3
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript',
4
+ jsx: 'jsx',
5
+ ts: 'typescript', mts: 'typescript', cts: 'typescript',
6
+ tsx: 'tsx',
7
+ json: 'json',
8
+ css: 'css',
9
+ html: 'markup', htm: 'markup', xml: 'markup', svg: 'markup',
10
+ yml: 'yaml', yaml: 'yaml',
11
+ sh: 'bash', bash: 'bash',
12
+ md: 'markdown', markdown: 'markdown',
13
+ sol: 'solidity',
14
+ php: 'php', phtml: 'php',
15
+ rs: 'rust',
16
+ rb: 'ruby',
17
+ py: 'python', pyw: 'python', pyi: 'python',
18
+ java: 'java',
19
+ // .h is claimed by C, C++ and Objective-C alike. The C++ grammar
20
+ // extends the C one, so it is the superset that colours a header
21
+ // from any of the three; the C grammar would leave a C++ header's
22
+ // class/template/namespace keywords plain.
23
+ cpp: 'cpp', cc: 'cpp', cxx: 'cpp', 'c++': 'cpp',
24
+ hpp: 'cpp', hh: 'cpp', hxx: 'cpp', 'h++': 'cpp', h: 'cpp',
25
+ c: 'c',
26
+ m: 'objectivec', mm: 'objectivec',
27
+ }
28
+
29
+ // Stasis records loader formats as well as language names. Resolve those to
30
+ // the grammars we ship before consulting the filename.
31
+ const FORMAT_TO_LANG = {
32
+ __proto__: null,
33
+ module: 'javascript', commonjs: 'javascript',
34
+ 'module-typescript': 'typescript', 'commonjs-typescript': 'typescript',
35
+ json: 'json', solidity: 'solidity', php: 'php', shell: 'bash', rust: 'rust',
36
+ java: 'java', objc: 'objectivec', objcpp: 'objectivec',
37
+ c: 'c', 'c-header': 'c', cpp: 'cpp', 'cpp-header': 'cpp',
38
+ ruby: 'ruby', podspec: 'ruby', podfile: 'ruby', fastlane: 'ruby',
39
+ 'podfile-lock': 'yaml', xml: 'markup',
40
+ }
41
+
42
+ export function langForPath(path, format) {
43
+ if (typeof format === 'string' && FORMAT_TO_LANG[format]) return FORMAT_TO_LANG[format]
44
+ if (typeof path !== 'string') return null
45
+ // Only the filename can contribute an extension. A dotted directory such
46
+ // as `.abc/edf` is a path segment, not an extension on `edf`.
47
+ const basename = path.slice(path.lastIndexOf('/') + 1)
48
+ const dot = basename.lastIndexOf('.')
49
+ if (dot <= 0) return null
50
+ return EXT_TO_LANG[basename.slice(dot + 1).toLowerCase()] ?? null
51
+ }
@@ -0,0 +1,30 @@
1
+ // Shared starting catalogue for the UI and managed server until scan-service
2
+ // discovery supplies supported models and effort limits. Use undated aliases.
3
+ export function defaultScanModels(provider: string | null = null) {
4
+ const ids = [
5
+ 'anthropic/claude-opus-5.5',
6
+ 'anthropic/claude-fable-5.1',
7
+ 'anthropic/claude-sonnet-5.5',
8
+ 'anthropic/claude-haiku-4.5',
9
+ 'openai/gpt-6-astra',
10
+ 'openai/gpt-6-astra-pro',
11
+ 'openai/gpt-6.1-sol',
12
+ 'openai/gpt-6.1-sol-pro',
13
+ 'openai/gpt-6-luna',
14
+ 'openai/gpt-6-luna-pro',
15
+ 'moonshotai/kimi-k3',
16
+ 'google/gemini-3.8-flash',
17
+ 'google/gemma-4-31b-it',
18
+ 'x-ai/grok-4.7',
19
+ 'z-ai/glm-5.3',
20
+ 'qwen/qwen3.8-max',
21
+ 'deepseek/deepseek-v4.1-flash',
22
+ 'deepseek/deepseek-v4-pro',
23
+ ]
24
+ // OpenRouter routes all listed providers. Direct-provider credentials only
25
+ // expose that provider's models; no selection leaves the catalogue intact.
26
+ const prefix = provider === 'moonshot' ? 'moonshotai' : provider
27
+ const models = ids.filter(id => !['anthropic', 'openai', 'moonshot'].includes(provider ?? '') || id.startsWith(`${prefix}/`))
28
+ .map(id => ({ id, efforts: ['low', 'medium', 'high', 'xhigh', 'max'] }))
29
+ return { models, defaultModel: models[0]!.id }
30
+ }
@@ -0,0 +1,47 @@
1
+ // Session-local finding ids (see `client/triage.js`'s SESSION_ID_RE:
2
+ // purely numeric `_id` fallbacks, handed out by an in-memory counter
3
+ // for findings whose id couldn't be derived) are re-assigned on every
4
+ // load, so a link built on one would point at an arbitrary other
5
+ // finding after a reload — including in the SENDER's own tab. Those are
6
+ // refused up front rather than silently mis-resolving. Everything else
7
+ // the app treats as persistent — the analyzer's uuids, the deterministic
8
+ // uuids `@preventive/report/src/finding-id.js` derives, and the codex importer's
9
+ // finding-URL ids — is linkable, which is why this is a
10
+ // "not session-local" test rather than a uuid-shape test.
11
+ const SESSION_ID_RE = /^\d+$/u
12
+
13
+ // Control characters can't appear in a uuid, a URL, or an OPFS filename
14
+ // the app will hand out, so their presence means a mangled / hand-crafted
15
+ // fragment. Rejecting them keeps a stray `\n` out of an `alert()` and out
16
+ // of the `[data-gid]` selector the reveal path builds. A scan rather than
17
+ // a character-class regex: matching control characters in a literal is a
18
+ // lint error (`no-control-regex`), and spelling the range out in code is
19
+ // clearer than the escaped equivalent anyway.
20
+ function hasControlChar(value) {
21
+ for (let i = 0; i < value.length; i++) {
22
+ const code = value.codePointAt(i)
23
+ if (code < 0x20 || code === 0x7f) return true
24
+ }
25
+ return false
26
+ }
27
+
28
+ // Per-component cap. The longest legitimate value is a codex finding-URL
29
+ // id; report names and workspace ids no longer travel in the fragment at
30
+ // all. The cap exists so a hostile fragment can't push a megabyte of
31
+ // text through `decodeURIComponent` + the "couldn't find it" alert.
32
+ export const MAX_FINDING_ID_LENGTH = 512
33
+
34
+ function isUsablePart(value) {
35
+ return typeof value === 'string'
36
+ && value.length > 0
37
+ && value.length <= MAX_FINDING_ID_LENGTH
38
+ && !hasControlChar(value)
39
+ }
40
+
41
+ // Whether a finding id survives a reload, i.e. whether a link built on
42
+ // it means anything tomorrow. Callers use this to decide whether to
43
+ // OFFER a link at all (the per-finding Link button hides itself for a
44
+ // session-local id) rather than handing out one that silently rots.
45
+ export function isLinkableFindingId(id) {
46
+ return isUsablePart(id) && !SESSION_ID_RE.test(id)
47
+ }
@@ -0,0 +1 @@
1
+ export function newIssueLabels(isSecurity?: boolean, configured?: string): string[]
@@ -0,0 +1,11 @@
1
+ // Shared by GitHub's prefilled form and managed API issue creation.
2
+ export function newIssueLabels(isSecurity = false, configured = '') {
3
+ const seen = new Set()
4
+ return ['deepview', ...(isSecurity ? ['security'] : []), ...configured.split(',')]
5
+ .map(label => label.trim()).filter(label => {
6
+ const key = label.toLowerCase()
7
+ if (!label || seen.has(key)) return false
8
+ seen.add(key)
9
+ return true
10
+ })
11
+ }
@@ -0,0 +1,56 @@
1
+ export const MAX_PULL_REQUEST_URL = 2048
2
+
3
+ export interface PullRequestRef { repo: string; number: number }
4
+ export type GithubFixRef = PullRequestRef & { kind: 'pull' | 'issue' }
5
+ export type GithubFixStatus = 'open' | 'draft' | 'closed' | 'merged'
6
+ export type GithubIssueClosedReason = 'completed' | 'not_planned' | 'duplicate' | 'unknown'
7
+ export type GithubFixResult = { url: string } & (
8
+ { title: string; description: string | null; status: GithubFixStatus; stateReason: GithubIssueClosedReason | null } | { error: 'unavailable' }
9
+ )
10
+
11
+ // Unknown closure reasons must never imply that an issue was completed.
12
+ export function githubIssueClosedReason(value: unknown): GithubIssueClosedReason {
13
+ return value === 'completed' || value === 'not_planned' || value === 'duplicate' ? value : 'unknown'
14
+ }
15
+
16
+ export function isGithubRepoName(value: string): boolean {
17
+ const [owner, repo, extra] = value.split('/')
18
+ return extra === undefined && owner !== undefined && repo !== undefined
19
+ && owner.length <= 39 && /^[a-zA-Z\d](?:-?[a-zA-Z\d])*$/u.test(owner)
20
+ && repo.length <= 100 && repo !== '.' && repo !== '..' && /^[a-zA-Z\d._-]+$/u.test(repo)
21
+ }
22
+
23
+ // Only identify the repository and PR number. This never supplies an API URL:
24
+ // the server must match a team repository and use its stored full name.
25
+ function parseGithubNumberedUrl(value: unknown, path: RegExp): PullRequestRef | null {
26
+ if (typeof value !== 'string' || value.length > MAX_PULL_REQUEST_URL) return null
27
+ let url: URL
28
+ try { url = new URL(value) } catch { return null }
29
+ if (url.href !== value || url.origin !== 'https://github.com' || url.username || url.password) return null
30
+ const match = path.exec(url.pathname)
31
+ if (!match) return null
32
+ const repo = `${match[1]}/${match[2]}`
33
+ const number = Number(match[3])
34
+ if (!isGithubRepoName(repo) || !Number.isSafeInteger(number) || number <= 0) return null
35
+ return { repo, number }
36
+ }
37
+
38
+ export function parseGithubPrUrl(value: unknown): PullRequestRef | null {
39
+ return parseGithubNumberedUrl(value, /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/(?:files|commits|checks))?\/?$/u)
40
+ }
41
+
42
+ export function parseGithubIssueUrl(value: unknown): PullRequestRef | null {
43
+ return parseGithubNumberedUrl(value, /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/u)
44
+ }
45
+
46
+ export function parseGithubFixUrl(value: unknown): GithubFixRef | null {
47
+ const pr = parseGithubPrUrl(value)
48
+ if (pr) return { ...pr, kind: 'pull' }
49
+ const issue = parseGithubIssueUrl(value)
50
+ return issue ? { ...issue, kind: 'issue' } : null
51
+ }
52
+
53
+ // Browser-only deduplication key; never used to construct a server API call.
54
+ export function githubFixKey(ref: GithubFixRef): string {
55
+ return `${ref.repo.toLowerCase()}#${ref.kind}:${ref.number}`
56
+ }
@@ -0,0 +1,34 @@
1
+ // Managed comments are independent records, shared by reports containing the
2
+ // same finding. Imported and legacy comments may lack attribution or dates.
3
+ export interface ManagedComment {
4
+ id: string
5
+ findingId: string
6
+ body: string
7
+ authorId: string | null
8
+ authorLogin: string | null
9
+ createdAt: number | null
10
+ updatedAt: number | null
11
+ version: number
12
+ }
13
+
14
+ export const MAX_COMMENT_TEXT = 10_000
15
+
16
+ // Callers also enforce write access to the report containing the finding.
17
+ // Admins can remove comments with no linked user, but cannot claim authorship.
18
+ export function canDeleteComment(comment: Pick<ManagedComment, 'authorId'>, actor: { id: string; role: string } | null | undefined): boolean {
19
+ return actor != null && (comment.authorId === actor.id || (comment.authorId == null && actor.role === 'admin'))
20
+ }
21
+
22
+ export function compareManagedComments(a: ManagedComment, b: ManagedComment): number {
23
+ if (a.createdAt == null && b.createdAt != null) return -1
24
+ if (a.createdAt != null && b.createdAt == null) return 1
25
+ const byDate = (a.createdAt ?? 0) - (b.createdAt ?? 0)
26
+ if (byDate !== 0) return byDate
27
+ if (a.id === b.id) return 0
28
+ return a.id < b.id ? -1 : 1
29
+ }
30
+
31
+ export function parseCommentBody(value: unknown): string | null {
32
+ if (typeof value !== 'string' || value.length > MAX_COMMENT_TEXT) return null
33
+ return value.trim() || null
34
+ }
@@ -0,0 +1,35 @@
1
+ // Per-membership visibility permissions for a team's users — shared by the
2
+ // managed server (which stores + validates them) and the admin client (the
3
+ // team-member checkboxes). Each is an independent opt-in, BOTH DEFAULT OFF:
4
+ // dependencies — scan findings in dependencies' own code; App impact stays visible
5
+ // security — security findings and published bundle advisories
6
+ // The managed server filters report responses and authorizes advisory lookups
7
+ // using these independent permissions.
8
+ export type VisibilityPermission = 'dependencies' | 'security'
9
+
10
+ export const VISIBILITY_PERMISSIONS: readonly VisibilityPermission[] = ['dependencies', 'security']
11
+
12
+ // Human labels for the admin checkboxes (keyed by permission).
13
+ export const VISIBILITY_PERMISSION_LABELS: Record<VisibilityPermission, string> = {
14
+ dependencies: 'Dependencies',
15
+ security: 'Security',
16
+ }
17
+
18
+ // A membership's resolved permission set; every key present (false = off).
19
+ export type TeamUserPermissions = Record<VisibilityPermission, boolean>
20
+
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
+ }
27
+
28
+ // Coerce arbitrary input (a request body) into a full permission set: a key is
29
+ // granted only when explicitly `true`; anything else (absent, non-bool) is off.
30
+ export function parseTeamUserPermissions(x: unknown): TeamUserPermissions {
31
+ const o = (x == null || typeof x !== 'object') ? {} : x as Record<string, unknown>
32
+ const out = { ...DEFAULT_TEAM_USER_PERMISSIONS }
33
+ for (const p of VISIBILITY_PERMISSIONS) out[p] = o[p] === true
34
+ return out
35
+ }
@@ -0,0 +1,42 @@
1
+ import { parseLinkedFindings } from '../../client/linked-findings.js'
2
+ import { detectFormat, loadFindings, parseCodexCsvToScans, readReport } from '@preventive/report'
3
+
4
+ // A managed blob retains one server identity even when a CSV contains several
5
+ // scans. Keep all their findings (and upstream ids) under that identity. CSV
6
+ // repository columns describe findings, not an embedded report-level location.
7
+ export function readManagedReport(content: string, filename: string): ReturnType<typeof readReport> {
8
+ const links = content.trimStart().startsWith('[') ? parseLinkedFindings(content) : null
9
+ if (links) return { data: { source: 'links', findings: [], links: links.groups }, format: 'links', reason: null }
10
+ const parsed = readReport(content)
11
+ // Permission-filtered responses can be JSON under the original CSV filename.
12
+ if (parsed.data != null || detectFormat(content, filename) !== 'codex') return parsed
13
+ try {
14
+ const scans = parseCodexCsvToScans(content)
15
+ return {
16
+ data: { type: 'security', source: 'codex-security', findings: scans.flatMap((scan) => scan.data.findings) },
17
+ format: 'codex', reason: null,
18
+ }
19
+ } catch (err) {
20
+ return { data: null, format: null, reason: err instanceof Error ? err.message : 'Invalid Codex CSV' }
21
+ }
22
+ }
23
+
24
+ export function loadManagedFindings(content: string, filename: string): ReturnType<typeof loadFindings> {
25
+ const parsed = readManagedReport(content, filename)
26
+ return parsed.data == null ? Promise.resolve(null) : loadFindings(JSON.stringify(parsed.data))
27
+ }
28
+
29
+ // Source access follows individual visible members, not their shared finding
30
+ // IDs: another member with the same ID can cite files the viewer cannot see.
31
+ export function managedFindingSourcePaths(findings: unknown[]): Set<string> {
32
+ const paths = new Set<string>()
33
+ for (const finding of findings) {
34
+ if (!finding || typeof finding !== 'object') continue
35
+ const f = finding as { file?: unknown; evidence?: { file?: unknown }[] }
36
+ if (typeof f.file === 'string' && f.file) paths.add(f.file)
37
+ for (const evidence of Array.isArray(f.evidence) ? f.evidence : []) {
38
+ if (typeof evidence?.file === 'string' && evidence.file) paths.add(evidence.file)
39
+ }
40
+ }
41
+ return paths
42
+ }
@@ -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 '@preventive/report'
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,124 @@
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-bundles' && Number.isSafeInteger(route.createRepoId) && route.createRepoId > 0) return `${path}?createRepo=${route.createRepoId}`
38
+ if (route.view === 'manage-scans' && route.bundleId) return `${path}?bundle=${encodeURIComponent(route.bundleId)}`
39
+ return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
40
+ }
41
+ if (!['findings', 'files'].includes(route.view) || !route.teamSlug
42
+ || [route.teamSlug, route.reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
43
+ const team = `/team/${encodeURIComponent(route.teamSlug)}`
44
+ const report = route.reportSlug ? `/report/${encodeURIComponent(route.reportSlug)}` : ''
45
+ if (route.finding) {
46
+ const suffix = findingPath(route.finding.id)
47
+ return route.view === 'findings' && suffix ? `${team}${report}${suffix}` : null
48
+ }
49
+ return `${team}${report}${route.view === 'files' ? '/files' : ''}`
50
+ }
51
+
52
+ export function parseManagedRoute(url) {
53
+ const path = url.pathname.replace(/\/$/u, '') || '/'
54
+ if (path === '/' || path === '/index.html') return { view: 'home' }
55
+ const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
56
+ if (view) {
57
+ const createRepoId = Number(url.searchParams.get('createRepo'))
58
+ return { view,
59
+ ...(view === 'manage-bundles' && Number.isSafeInteger(createRepoId) && createRepoId > 0 ? { createRepoId } : {}),
60
+ ...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
61
+ ...(view === 'manage-scans' && url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {}),
62
+ }
63
+ }
64
+ const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?$/u.exec(path)
65
+ if (bundle) {
66
+ const bundleTab = bundle[3] ?? 'overview'
67
+ return BUNDLE_TABS.has(bundleTab) ? { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab } : null
68
+ }
69
+ const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
70
+ if (!match) return null
71
+ try {
72
+ const teamSlug = decodeURIComponent(match[1])
73
+ const reportSlug = match[2] ? decodeURIComponent(match[2]) : null
74
+ if ([teamSlug, reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
75
+ if (match[4]?.length > MAX_FINDING_ID_LENGTH * 9) return null
76
+ const id = match[4] ? decodeURIComponent(match[4]) : null
77
+ if (id != null && !findingPath(id)) return null
78
+ return { view: match[3] ? 'files' : 'findings', teamSlug, reportSlug, ...(id == null ? {} : { finding: { id } }) }
79
+ } catch { return null }
80
+ }
81
+
82
+ // URL tokens are exact server-assigned slugs. Application state and API calls
83
+ // continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
84
+ export function resolveManagedRoute(route, teams, adminBundles = []) {
85
+ if (route.view === 'bundles') {
86
+ const { teamSlug, bundleSlug, ...rest } = route
87
+ const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
88
+ if (teamSlug != null && matches.length !== 1) return null
89
+ const team = matches[0]
90
+ const candidates = teamSlug == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])
91
+ const ids = new Set(candidates.filter(bundle => bundle.slug === bundleSlug).map(bundle => bundle.id))
92
+ const bundle = (team?.bundles ?? adminBundles).find(entry => entry.slug === bundleSlug)
93
+ return bundleSlug && bundle && ids.size === 1 ? { ...rest, teamId: team?.id ?? null, bundleId: bundle.id } : null
94
+ }
95
+ if (!['findings', 'files'].includes(route.view)) return route
96
+ const { teamSlug, reportSlug, ...rest } = route
97
+ const matches = teams.filter(team => team.slug === teamSlug)
98
+ if (!teamSlug || matches.length !== 1) return null
99
+ const team = matches[0]
100
+ if (reportSlug == null) return { ...rest, teamId: team.id, reportId: null }
101
+ const reports = teams.flatMap(entry => entry.reports ?? []).filter(report => report.slug === reportSlug)
102
+ const ids = new Set(reports.map(report => report.id))
103
+ const report = (team.reports ?? []).find(entry => entry.slug === reportSlug)
104
+ return report && ids.size === 1 ? { ...rest, teamId: team.id, reportId: report.id } : null
105
+ }
106
+
107
+ export function managedRouteForIds(route, teams, adminBundles = []) {
108
+ if (route.view === 'bundles') {
109
+ const { teamId, bundleId, ...rest } = route
110
+ const team = teams.find(entry => entry.id === teamId)
111
+ if (teamId != null && !team?.slug) return null
112
+ const bundle = (team?.bundles ?? adminBundles).find(entry => entry.id === bundleId)
113
+ if (!bundle?.slug) return null
114
+ const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug }
115
+ return resolveManagedRoute(result, teams, adminBundles) ? result : null
116
+ }
117
+ if (!['findings', 'files'].includes(route.view)) return route
118
+ const { teamId, reportId, ...rest } = route
119
+ const team = teams.find(entry => entry.id === teamId)
120
+ const report = reportId == null ? null : team?.reports?.find(entry => entry.id === reportId)
121
+ if (!team?.slug || (reportId != null && !report?.slug)) return null
122
+ const result = { ...rest, teamSlug: team.slug, reportSlug: report?.slug ?? null }
123
+ return resolveManagedRoute(result, teams) ? result : null
124
+ }
@@ -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