@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
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.
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -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
|
+
}
|