@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.
- package/LICENSE-APACHE +202 -0
- 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-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +182 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -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 +51 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -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 +124 -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 +35 -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 +167 -0
- package/out/client-sync.js +16 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +259 -63
- package/package.json +205 -57
- 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-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +102 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +40 -9
- 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 +2 -2
- package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
- package/{server → server-e2e}/objstore/blob.ts +9 -2
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- 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 +58 -33
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +1 -1
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +54 -0
- package/server-managed/bundle-build.ts +168 -0
- package/server-managed/bundle-cache.ts +166 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +116 -0
- package/server-managed/bundle.ts +104 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +149 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1517 -0
- package/server-managed/db-neon.ts +277 -0
- package/server-managed/db-schema.ts +206 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +120 -0
- package/server-managed/github-app.ts +385 -0
- package/server-managed/github-errors.ts +49 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +98 -0
- package/server-managed/github-oauth.ts +199 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2350 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +175 -0
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +86 -0
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +60 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +146 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +166 -0
- package/server-managed/repository-browser.ts +139 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +72 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +223 -0
- package/server-managed/storage-payload.ts +72 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +58 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-reports.ts +219 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +65 -0
- package/server-managed/workspace-shares.ts +162 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /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,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
|