@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,3 @@
1
+ import type { BundleDetails } from './bundle-metadata.js'
2
+ export function bundleSourcesAsMap(details: BundleDetails): Map<string, string>
3
+ export function bundlePackageVersions(details: BundleDetails, paths?: Iterable<string> | null): Map<string, Set<string>>
@@ -0,0 +1,284 @@
1
+ // Shared helper: given a parsed bundle `details`, return a
2
+ // Map<file, content-string> of its source files. Stasis bundles
3
+ // expose sources via `@exodus/stasis-core`'s `Bundle.sources` getter
4
+ // (flat Map<projectRelPath, content> from `.modules`); sourcemaps
5
+ // split them across parallel `sources` / `sourcesContent` arrays on
6
+ // the raw .map JSON. Sourcemap sources whose `sourcesContent[i]` was
7
+ // omitted are skipped.
8
+ //
9
+ // What a stasis bundle records is not all source: `Bundle.formats`
10
+ // marks each entry, and three of those formats are files only in the
11
+ // sense that they occupy a path. `Bundle.sources` hands back the raw
12
+ // stored content for every one of them, so the format is what has to
13
+ // be asked — the content's JS type cannot tell them apart:
14
+ //
15
+ // directory a readdir capture, content `["a.js","b.js"]`
16
+ // resource:base64 an image or font, content its base64 text
17
+ // resource raw bytes, the one case not a string already
18
+ //
19
+ // A directory capture is the one that does real damage, because it
20
+ // claims a path a directory also occupies: hand it to the terminal
21
+ // and `ls` lists the name twice and stops listing what is under it.
22
+ // The base64 resources are quieter but equally wrong — a PNG read as
23
+ // a wall of text, counted toward the language shares that
24
+ // render-bundle.js says resources are kept out of.
25
+ //
26
+ // Own module so `render-bundle.js` (Code tab, finding tree, graph
27
+ // data) and `terminal-attach.js` (in-shell FS) read one definition —
28
+ // a new bundle kind or field handled here is visible to both.
29
+
30
+ import { Bundle } from '@exodus/stasis-core/bundle'
31
+ import { utf8ByteLength } from './utf8.js'
32
+
33
+ const sourcesCache = new WeakMap()
34
+ const filesCache = new WeakMap()
35
+ const sizesCache = new WeakMap()
36
+ const sourceSizesCache = new WeakMap()
37
+ const kindsCache = new WeakMap()
38
+ const unsizedCache = new WeakMap()
39
+
40
+ export function bundleSourcesAsMap(details) {
41
+ if (details?.metadataOnly) return new Map()
42
+ const key = details?.bundle ?? details?.json
43
+ if (key && sourcesCache.has(key)) return sourcesCache.get(key)
44
+ const result = new Map()
45
+ if (!details) return result
46
+ if (details.kind === 'stasis') {
47
+ if (!details.bundle) return result
48
+ const formats = details.bundle.formats
49
+ for (const [file, content] of details.bundle.sources) {
50
+ if (typeof content !== 'string') continue
51
+ // `isResourceFormat` is the package's own list, so a format it
52
+ // adds later is excluded here without this needing to know. A v0
53
+ // bundle records no formats at all: `get` returns undefined,
54
+ // which is not a resource format, and every entry is kept as
55
+ // before.
56
+ if (Bundle.isResourceFormat(formats?.get(file))) continue
57
+ result.set(file, content)
58
+ }
59
+ } else if (details.kind === 'sourcemap') {
60
+ if (!details.json) return result
61
+ const srcs = details.json.sources ?? []
62
+ const contents = details.json.sourcesContent ?? []
63
+ for (let i = 0; i < srcs.length; i++) {
64
+ if (typeof contents[i] === 'string') result.set(srcs[i], contents[i])
65
+ }
66
+ }
67
+ if (key) sourcesCache.set(key, result)
68
+ return result
69
+ }
70
+
71
+ // Every file the bundle carries, for a reader that wants the filesystem
72
+ // rather than the source: what `bundleSourcesAsMap` returns, plus the
73
+ // resources it leaves out, each in the form that is true to it.
74
+ //
75
+ // The two resource formats are not two encodings of the same thing.
76
+ // Stasis picks between them by whether the bytes are valid UTF-8:
77
+ // `resource` is that text, stored verbatim, so it stays a string and a
78
+ // reader sees the SVG or the licence file it is. `resource:base64` is
79
+ // the other case — bytes no string spells — and it is handed over
80
+ // still spelt that way, as `{ format: 'base64', data }`. The terminal
81
+ // takes that form since 2.0 and decodes it the first time the file is
82
+ // read, so a bundle carrying a hundred images costs a hundred strings
83
+ // rather than a hundred decoded buffers, and pays for the one that is
84
+ // opened. Either way it knows what it is holding: `ls -l` and `wc -c`
85
+ // report the real byte count rather than the length of the base64, and
86
+ // `cat` declines instead of printing mojibake.
87
+ //
88
+ // Decoding there also puts the diagnostic where the file is named: a
89
+ // spelling that does not decode is reported by the command that read
90
+ // it, under the detail `base64 source`. Decoding here could only drop
91
+ // the file and say nothing.
92
+ //
93
+ // Directory captures stay out. They are the one entry that is not a
94
+ // file in any encoding, and mounting one puts a second `lib` beside
95
+ // the directory it names.
96
+ export function bundleFilesAsMap(details) {
97
+ const key = details?.bundle ?? details?.json
98
+ if (key && filesCache.has(key)) return filesCache.get(key)
99
+ const files = new Map(bundleSourcesAsMap(details))
100
+ if (details?.kind === 'stasis' && details.bundle) {
101
+ const formats = details.bundle.formats
102
+ for (const [file, content] of details.bundle.sources) {
103
+ if (typeof content !== 'string') continue
104
+ const format = formats?.get(file)
105
+ if (format === 'resource') files.set(file, content)
106
+ else if (format === 'resource:base64') files.set(file, { format: 'base64', data: content })
107
+ }
108
+ }
109
+ if (key) filesCache.set(key, files)
110
+ return files
111
+ }
112
+
113
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
114
+
115
+ // The length of the bytes a base64 spelling decodes to — three for every
116
+ // four characters, less what the padding stands for — or null when it
117
+ // decodes to none. The terminal decodes strictly (`@exodus/bytes`): RFC
118
+ // 4648's alphabet, no whitespace, padding present or left off but never
119
+ // wrong, and no stray bits in the last character. A spelling it refuses is
120
+ // a file `wc -c` cannot read, so it gets no size here rather than an
121
+ // invented one. Checked without decoding: a bundle's images are not worth
122
+ // a buffer each just to be weighed.
123
+ function base64ByteLength(text) {
124
+ const padded = text.endsWith('=')
125
+ if (padded ? text.length % 4 !== 0 || text.at(-3) === '=' : text.length % 4 === 1) return null
126
+ const data = padded ? text.slice(0, text.endsWith('==') ? -2 : -1) : text
127
+ if (/[^A-Za-z0-9+/]/u.test(data)) return null
128
+ // A trailing group of 2 or 3 characters spells 1 or 2 bytes; the bits of
129
+ // its last character past those bytes must be zero.
130
+ const tail = data.length % 4
131
+ if (tail > 1 && BASE64_ALPHABET.indexOf(data.at(-1)) & (tail === 2 ? 0x0f : 0x03)) return null
132
+ return Math.floor(data.length * 3 / 4)
133
+ }
134
+
135
+ // The byte size of one entry of `bundleFilesAsMap`, as `wc -c` reports it
136
+ // in the terminal: text weighs the UTF-8 it encodes to, and a base64
137
+ // declaration the bytes it decodes to — null when it decodes to none.
138
+ // Anything else is no file, and null too. The Compare tab sizes both
139
+ // bundles' files by this, so its totals are the Overview's.
140
+ export function bundleFileByteLength(content) {
141
+ if (typeof content === 'string') return utf8ByteLength(content)
142
+ return content?.format === 'base64' ? base64ByteLength(content.data) : null
143
+ }
144
+
145
+ // Every path the bundle records, keyed to the byte size of the file it
146
+ // holds — the size `wc -c` reports for it in the terminal, which is to
147
+ // say the size of the entry in `bundleFilesAsMap`: a source or `resource`
148
+ // weighs the UTF-8 its text encodes to, a `resource:base64` the bytes its
149
+ // base64 decodes to. Null is a path with no size to give: a directory
150
+ // capture, which is no file; a sourcemap source whose body was left out;
151
+ // or a `resource:base64` whose spelling does not decode.
152
+ //
153
+ // The Overview and the Treemap weigh a bundle by this, so what they show
154
+ // adds up to what `du` does. Neither the source-only view nor the base64
155
+ // text will do: the first drops every image and font, the second
156
+ // inflates them by a third.
157
+ //
158
+ // Metadata views need byte sizes and paths, never the bodies. The
159
+ // persistent index supplies this map directly; full parses compute it once.
160
+ export function bundleFileSizes(details) {
161
+ if (details?.fileSizes) return details.fileSizes
162
+ const key = details?.bundle ?? details?.json
163
+ if (key && sizesCache.has(key)) return sizesCache.get(key)
164
+ const sizes = new Map()
165
+ const files = bundleFilesAsMap(details)
166
+ const paths = details?.kind === 'stasis' ? details.bundle?.sources.keys() : details?.json?.sources
167
+ for (const path of paths ?? []) sizes.set(path, bundleFileByteLength(files.get(path)))
168
+ if (key) sizesCache.set(key, sizes)
169
+ return sizes
170
+ }
171
+
172
+ // The files the terminal mounts that have no size: a `resource:base64`
173
+ // whose spelling does not decode. The terminal lists such a file, and says
174
+ // why it cannot read it when asked, so the views list it too — but a null
175
+ // size alone cannot tell it from an entry that is no file at all, so it is
176
+ // named here. A metadata-only open reads the set from the index, which is
177
+ // the one thing a size and a format cannot say about a path.
178
+ export function bundleUnsizedFiles(details) {
179
+ if (details?.metadataOnly) return details.unsizedFiles ?? new Set()
180
+ const sizes = bundleFileSizes(details)
181
+ if (unsizedCache.has(sizes)) return unsizedCache.get(sizes)
182
+ const unsized = new Set()
183
+ for (const path of bundleFilesAsMap(details).keys()) if (sizes.get(path) === null) unsized.add(path)
184
+ unsizedCache.set(sizes, unsized)
185
+ return unsized
186
+ }
187
+
188
+ // What each file the terminal mounts is, for a view that lists files:
189
+ // 'source', or 'resource' for an image, font or other asset — a file, sized
190
+ // like any other, but none the source viewer can show. Its keys are
191
+ // `bundleFilesAsMap`'s, and nothing else: a directory capture, or an entry
192
+ // with no body to mount (a sourcemap source whose `sourcesContent` was left
193
+ // out, a body that is no string), is no file and is absent. It reads sizes
194
+ // and formats, not bodies, so it answers for a metadata-only open as it
195
+ // does for a parsed one: a sized path is a mounted file, and so is one
196
+ // `bundleUnsizedFiles` names.
197
+ export function bundleFileKinds(details) {
198
+ const sizes = bundleFileSizes(details)
199
+ if (kindsCache.has(sizes)) return kindsCache.get(sizes)
200
+ const unsized = bundleUnsizedFiles(details)
201
+ const formats = details?.kind === 'stasis' ? details.bundle?.formats : null
202
+ const kinds = new Map()
203
+ for (const [path, size] of sizes) {
204
+ if (size === null && !unsized.has(path)) continue
205
+ kinds.set(path, Bundle.isResourceFormat(formats?.get(path)) ? 'resource' : 'source')
206
+ }
207
+ kindsCache.set(sizes, kinds)
208
+ return kinds
209
+ }
210
+
211
+ // `bundleFileSizes` narrowed to source: a resource's size is nulled, as a
212
+ // directory capture's already is. The import graph draws this, and stays
213
+ // source-only: an image or a font imports nothing and carries no finding.
214
+ export function bundleSourceSizes(details) {
215
+ const sizes = bundleFileSizes(details)
216
+ const formats = details?.kind === 'stasis' ? details.bundle?.formats : null
217
+ if (!formats || formats.size === 0) return sizes
218
+ if (sourceSizesCache.has(sizes)) return sourceSizesCache.get(sizes)
219
+ const result = new Map()
220
+ for (const [path, size] of sizes) result.set(path, Bundle.isResourceFormat(formats.get(path)) ? null : size)
221
+ sourceSizesCache.set(sizes, result)
222
+ return result
223
+ }
224
+
225
+ // Map each stasis bundle source path to the package directory that
226
+ // owns it. A stasis `Bundle` already records authoritative package
227
+ // boundaries in `.modules` (a `Map<dir, { name, version, files }>`
228
+ // covering both `node_modules` deps and workspace packages — the
229
+ // PHP `vendor/<vendor>/<pkg>` case, monorepo workspaces, etc.); this
230
+ // mirrors the `Bundle.sources` getter's path construction (`dir/rel`,
231
+ // or just `rel` for the `.` root) so the returned keys line up exactly
232
+ // with `bundleSourcesAsMap`'s.
233
+ //
234
+ // The package views (overview, treemap, graph) otherwise classify
235
+ // paths with `bundlePkgOf`'s string heuristic, which only knows
236
+ // `node_modules/`/`dependencies/` and buckets everything else by
237
+ // top-level dir — collapsing sibling workspace packages under their
238
+ // shared parent (`vendor/aws/aws-crt-php` + `vendor/aws/aws-sdk-php`
239
+ // both fall under `vendor`). Feeding each path's dir into
240
+ // `bundlePkgOf` (`packageDir` option) keeps those packages separate.
241
+ //
242
+ // Returns null for sourcemap bundles (and anything without parsed
243
+ // modules) — they carry no package metadata, so callers fall back to
244
+ // the path heuristic alone.
245
+ export function bundlePackageDirs(details) {
246
+ if (details?.kind !== 'stasis' || !details.bundle?.modules) return null
247
+ const map = new Map()
248
+ for (const [dir, info] of details.bundle.modules) {
249
+ for (const rel of Object.keys(info.files)) {
250
+ map.set(dir === '.' ? rel : `${dir}/${rel}`, dir)
251
+ }
252
+ }
253
+ return map
254
+ }
255
+
256
+ // Map each named npm dependency a (stasis) bundle carries to the set of
257
+ // concrete versions present for it: `Map<name, Set<version>>`. Only
258
+ // `node_modules/...` modules that carry both a name and a non-empty
259
+ // version string count — stasis v1 `scope: 'full'` bundles merge
260
+ // workspace / own-source entries into the same `Bundle.modules` Map
261
+ // (often `version: '0.0.0'` or null), and those aren't upstream
262
+ // dependencies, so they're filtered out by the directory key. A package
263
+ // can map to more than one version (pnpm keeps duplicate majors side by
264
+ // side), hence the Set.
265
+ //
266
+ // Shared by the Advisories tab (a bulk registry lookup keyed on these
267
+ // pairs) and the Compare slide (the dependency version-update diff).
268
+ // Returns an empty Map for sourcemaps and v0 stasis bundles — neither
269
+ // carries per-module version metadata.
270
+ export function bundlePackageVersions(details, paths = null) {
271
+ const versions = new Map()
272
+ if (details?.kind !== 'stasis' || !details.bundle?.modules) return versions
273
+ const packageDirs = paths === null ? null : bundlePackageDirs(details)
274
+ const selectedDirs = paths === null ? null : new Set([...paths].map(path => packageDirs?.get(path)))
275
+ for (const [dir, info] of details.bundle.modules) {
276
+ if (selectedDirs && !selectedDirs.has(dir)) continue
277
+ if (!dir.includes('node_modules')) continue
278
+ if (!info?.name || typeof info.version !== 'string' || !info.version) continue
279
+ let set = versions.get(info.name)
280
+ if (!set) { set = new Set(); versions.set(info.name, set) }
281
+ set.add(info.version)
282
+ }
283
+ return versions
284
+ }
@@ -0,0 +1,41 @@
1
+ import { langForPath } from './code-language.js'
2
+
3
+ const BUNDLE_LANGUAGE_LABELS = Object.freeze({
4
+ javascript: 'JavaScript', jsx: 'JSX', typescript: 'TypeScript', tsx: 'TSX',
5
+ json: 'JSON', css: 'CSS', markup: 'HTML', yaml: 'YAML', bash: 'Shell',
6
+ markdown: 'Markdown', solidity: 'Solidity', php: 'PHP', rust: 'Rust',
7
+ ruby: 'Ruby', java: 'Java', cpp: 'C++', c: 'C', objectivec: 'Objective-C',
8
+ python: 'Python', go: 'Go', kotlin: 'Kotlin', swift: 'Swift', dart: 'Dart',
9
+ sql: 'SQL', lua: 'Lua', csharp: 'C#', scala: 'Scala', vue: 'Vue', svelte: 'Svelte',
10
+ })
11
+ const BUNDLE_EXTENSION_LANGUAGES = Object.freeze({
12
+ py: 'python', pyw: 'python', go: 'go', kt: 'kotlin', kts: 'kotlin', swift: 'swift',
13
+ dart: 'dart', sql: 'sql', lua: 'lua', cs: 'csharp', scala: 'scala', sc: 'scala',
14
+ vue: 'vue', svelte: 'svelte',
15
+ })
16
+
17
+ function bundleLanguageOf(path) {
18
+ const basename = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : ''
19
+ const dot = basename.lastIndexOf('.')
20
+ const ext = dot > 0 ? basename.slice(dot + 1).trim().toLowerCase() : ''
21
+ const lang = langForPath(path) ?? BUNDLE_EXTENSION_LANGUAGES[ext]
22
+ if (lang) return { key: lang, label: BUNDLE_LANGUAGE_LABELS[lang] ?? lang }
23
+ return ext ? { key: `extension:${ext}`, label: `.${ext}` } : { key: 'other', label: 'Other' }
24
+ }
25
+
26
+ export function bundleCodeStats(lineCounts, fileSizes) {
27
+ const languages = new Map()
28
+ let bytes = 0, lines = 0
29
+ for (const [path, count] of lineCounts) {
30
+ const language = bundleLanguageOf(path)
31
+ const stat = languages.get(language.key) ?? { ...language, files: 0, lines: 0, bytes: 0 }
32
+ stat.files++
33
+ stat.lines += count
34
+ stat.bytes += fileSizes.get(path) ?? 0
35
+ languages.set(language.key, stat)
36
+ lines += count
37
+ bytes += fileSizes.get(path) ?? 0
38
+ }
39
+ return { files: lineCounts.size, lines, bytes, languages: [...languages.values()]
40
+ .toSorted((a, b) => b.lines - a.lines || a.label.localeCompare(b.label)) }
41
+ }
@@ -0,0 +1 @@
1
+ export const BUNDLE_TABS = new Set(['overview', 'graph', 'treemap', 'compare', 'advisories', 'issues', 'code', 'search', 'terminal'])
@@ -0,0 +1,36 @@
1
+ const EXT_TO_LANG = {
2
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript',
3
+ jsx: 'jsx',
4
+ ts: 'typescript', mts: 'typescript', cts: 'typescript',
5
+ tsx: 'tsx',
6
+ json: 'json',
7
+ css: 'css',
8
+ html: 'markup', htm: 'markup', xml: 'markup', svg: 'markup',
9
+ yml: 'yaml', yaml: 'yaml',
10
+ sh: 'bash', bash: 'bash',
11
+ md: 'markdown', markdown: 'markdown',
12
+ sol: 'solidity',
13
+ php: 'php', phtml: 'php',
14
+ rs: 'rust',
15
+ rb: 'ruby',
16
+ java: 'java',
17
+ // .h is claimed by C, C++ and Objective-C alike. The C++ grammar
18
+ // extends the C one, so it is the superset that colours a header
19
+ // from any of the three; the C grammar would leave a C++ header's
20
+ // class/template/namespace keywords plain.
21
+ cpp: 'cpp', cc: 'cpp', cxx: 'cpp', 'c++': 'cpp',
22
+ hpp: 'cpp', hh: 'cpp', hxx: 'cpp', 'h++': 'cpp', h: 'cpp',
23
+ c: 'c',
24
+ m: 'objectivec', mm: 'objectivec',
25
+ }
26
+
27
+ export function langForPath(path) {
28
+ if (typeof path !== 'string') return null
29
+ // Only the filename can contribute an extension. A dotted directory such
30
+ // as `.abc/edf` is a path segment, not an extension on `edf`.
31
+ const basename = path.slice(path.lastIndexOf('/') + 1)
32
+ const dot = basename.lastIndexOf('.')
33
+ if (dot <= 0) return null
34
+ return EXT_TO_LANG[basename.slice(dot + 1).toLowerCase()] ?? null
35
+ }
36
+
@@ -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',
8
+ 'anthropic/claude-haiku-4.5',
9
+ 'openai/gpt-6-astra',
10
+ 'openai/gpt-6-astra-pro',
11
+ 'openai/gpt-6-sol',
12
+ 'openai/gpt-6-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 `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,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 '../../report/index.js'
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
+ }