@preventive/triage 1.0.0-alpha.19 → 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/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/out/index.html +4 -0
- package/out/view.css +1 -1
- package/out/view.js +82 -82
- package/package.json +82 -3
- package/server-e2e/cli.js +1 -1
- 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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Apply report permissions before serving findings, triage, or source files.
|
|
2
|
+
// Security uses the same complete-row classification as the UI. Dependency
|
|
3
|
+
// access is per finding: App, own source, and the own-source organizations stay.
|
|
4
|
+
import { inheritReportMeta, isAppFinding, reportRepoGithub, stampSecurityGroups } from '../../report/index.js'
|
|
5
|
+
import { readManagedReport } from './report-content.ts'
|
|
6
|
+
|
|
7
|
+
export interface ViewerPermissions {
|
|
8
|
+
dependencies: boolean
|
|
9
|
+
security: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Finding = Record<string, unknown>
|
|
13
|
+
function object(value: unknown): Finding | null {
|
|
14
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Finding : null
|
|
15
|
+
}
|
|
16
|
+
function pathOf(finding: Finding): string {
|
|
17
|
+
return typeof finding['file'] === 'string' ? finding['file'].replaceAll('\\', '/') : ''
|
|
18
|
+
}
|
|
19
|
+
function isDependency(finding: Finding): boolean {
|
|
20
|
+
return /(?:^|\/)(?:node_modules|vendor|dependencies)(?:\/|$)/u.test(pathOf(finding))
|
|
21
|
+
}
|
|
22
|
+
function githubOrg(value: unknown): string | null {
|
|
23
|
+
return reportRepoGithub(value)?.split('/')[0]?.toLowerCase() ?? null
|
|
24
|
+
}
|
|
25
|
+
function npmScope(finding: Finding, fromPath = false): string | null {
|
|
26
|
+
let name = object(object(finding['package'])?.['npm'])?.['name']
|
|
27
|
+
if (typeof name !== 'string' && fromPath) {
|
|
28
|
+
const parts = pathOf(finding).split('/')
|
|
29
|
+
for (let i = parts.length - 3; i >= 0; i--) {
|
|
30
|
+
if (['node_modules', 'vendor', 'dependencies'].includes(parts[i]!)) {
|
|
31
|
+
name = `${parts[i + 1]}/${parts[i + 2]}`
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return typeof name === 'string' ? /^(@[^/\s]+)\/[^/\s]+$/u.exec(name)?.[1] ?? null : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parsed projections keep run inheritance and derived flags out of cached
|
|
40
|
+
// reports. A known positive security stamp may include linked findings outside
|
|
41
|
+
// this report; a negative stamp never overrides the intrinsic classifier.
|
|
42
|
+
export function projectFinding(value: unknown, report: Finding): Finding | null {
|
|
43
|
+
const original = object(value)
|
|
44
|
+
if (!original) return null
|
|
45
|
+
const finding = { ...original }
|
|
46
|
+
inheritReportMeta(finding, report)
|
|
47
|
+
finding['_source'] = finding['source'] ?? report['source'] ?? null
|
|
48
|
+
if (!('isApp' in finding)) finding['isApp'] = isAppFinding(finding, finding['_source'])
|
|
49
|
+
if (finding['isSecurity'] === true) finding['security'] = true
|
|
50
|
+
return finding
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Native JSON and supported text imports obey the same permission rules.
|
|
54
|
+
// A filtered text import is returned as JSON under its original filename, which
|
|
55
|
+
// readManagedReport already accepts. Unchanged inputs keep their original bytes.
|
|
56
|
+
export function filterReportContent(content: string, perms: ViewerPermissions, filename = '', repo?: unknown): string {
|
|
57
|
+
if (perms.dependencies && perms.security) return content
|
|
58
|
+
let data: unknown
|
|
59
|
+
try { data = JSON.parse(content) } catch {
|
|
60
|
+
const parsed = readManagedReport(content, filename)
|
|
61
|
+
if (parsed.data == null) {
|
|
62
|
+
if (/\.csv$/iu.test(filename)) throw new Error('Cannot filter unreadable CSV report')
|
|
63
|
+
return content
|
|
64
|
+
}
|
|
65
|
+
data = parsed.data
|
|
66
|
+
}
|
|
67
|
+
const filtered = filterReportData(data, perms, repo)
|
|
68
|
+
return filtered === data ? content : JSON.stringify(filtered)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// repo, when supplied, is the server's authoritative assignment (including an
|
|
72
|
+
// explicit unassigned value). Finding-specific repos still describe their source.
|
|
73
|
+
export function filterReportData(data: unknown, perms: ViewerPermissions, repo?: unknown, securityIds?: Set<string>): unknown {
|
|
74
|
+
if (perms.dependencies && perms.security) return data
|
|
75
|
+
const report = object(data)
|
|
76
|
+
if (!report) return data
|
|
77
|
+
const key = Array.isArray(report['findings']) ? 'findings' : Array.isArray(report['groups']) ? 'groups' : null
|
|
78
|
+
if (!key) return data
|
|
79
|
+
const entries = (report[key] as unknown[]).map(entry => ({
|
|
80
|
+
entry, members: (Array.isArray(entry) ? entry : [entry]).map(original => ({ original, finding: projectFinding(original, report) })),
|
|
81
|
+
}))
|
|
82
|
+
const groups = entries.map(({ members }) => members.flatMap(({ finding }) => finding ? [finding] : []))
|
|
83
|
+
if (!perms.security) stampSecurityGroups(groups)
|
|
84
|
+
const githubOrgs = new Set<string>(), npmScopes = new Set<string>()
|
|
85
|
+
const addOrgs = (finding: Finding) => {
|
|
86
|
+
const github = githubOrg(finding), npm = npmScope(finding)
|
|
87
|
+
if (github) githubOrgs.add(github)
|
|
88
|
+
if (npm) npmScopes.add(npm)
|
|
89
|
+
}
|
|
90
|
+
addOrgs({ ...report, repo: repo === undefined ? report['repo'] : repo })
|
|
91
|
+
for (const finding of groups.flat()) {
|
|
92
|
+
if (finding['isApp'] !== true && !isDependency(finding)) addOrgs(finding)
|
|
93
|
+
}
|
|
94
|
+
const kept: unknown[] = []
|
|
95
|
+
let changed = false
|
|
96
|
+
for (const { entry, members } of entries) {
|
|
97
|
+
// Classify the complete row before hiding dependency components.
|
|
98
|
+
if (!perms.security && members.some(({ finding }) => finding?.['isSecurity'] === true || securityIds?.has(String(finding?.['id'])))) { changed = true; continue }
|
|
99
|
+
const visible = members.filter(({ finding }) => !finding || perms.dependencies || finding['isApp'] === true || !isDependency(finding)
|
|
100
|
+
|| githubOrgs.has(githubOrg(finding) ?? '') || npmScopes.has(npmScope(finding, true) ?? ''))
|
|
101
|
+
if (visible.length === members.length) kept.push(entry)
|
|
102
|
+
else {
|
|
103
|
+
changed = true
|
|
104
|
+
if (visible.length > 0) kept.push(Array.isArray(entry) ? visible.map(({ original }) => original) : visible[0]!.original)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return changed ? { ...report, [key]: kept } : data
|
|
108
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Managed-mode user roles, shared by the managed server (which stores +
|
|
2
|
+
// validates them) and the admin client (the role picker). A linear privilege
|
|
3
|
+
// ladder, highest first:
|
|
4
|
+
// admin — full access incl. managing other users' roles
|
|
5
|
+
// manage — owned/team content management
|
|
6
|
+
// triage — can triage findings
|
|
7
|
+
// view — read-only
|
|
8
|
+
// none — no access
|
|
9
|
+
// Admins manage workspace access. Managers handle owned uploads and content
|
|
10
|
+
// in their teams; repository changes also require repository/path access. View
|
|
11
|
+
// is the floor for reading content, triage for annotating accessible reports.
|
|
12
|
+
// None cannot read or mutate managed data, regardless of ownership or teams.
|
|
13
|
+
export type Role = 'admin' | 'manage' | 'triage' | 'view' | 'none'
|
|
14
|
+
|
|
15
|
+
export const ROLES: readonly Role[] = ['admin', 'manage', 'triage', 'view', 'none']
|
|
16
|
+
|
|
17
|
+
export function isRole(x: unknown): x is Role {
|
|
18
|
+
return typeof x === 'string' && (ROLES as readonly string[]).includes(x)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// True iff `role` sits at or above `min` in the privilege ladder. ROLES is
|
|
22
|
+
// ordered highest-first, so a lower index is more privileged; an unknown role
|
|
23
|
+
// (index -1) is never "at least" anything. e.g. roleAtLeast(role, 'view') is
|
|
24
|
+
// "has at least read access" (everything except 'none').
|
|
25
|
+
export function roleAtLeast(role: Role, min: Role): boolean {
|
|
26
|
+
const r = ROLES.indexOf(role)
|
|
27
|
+
return r !== -1 && r <= ROLES.indexOf(min)
|
|
28
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Managed client pages only. API paths and E2E share hashes are not routes.
|
|
2
|
+
import { BUNDLE_TABS } from '../bundle-tabs.js'
|
|
3
|
+
import { MAX_FINDING_ID_LENGTH, isLinkableFindingId } from '../finding-id.js'
|
|
4
|
+
|
|
5
|
+
export const MANAGED_PAGES = Object.freeze({
|
|
6
|
+
manage: '/manage',
|
|
7
|
+
'manage-bundles': '/manage/bundle',
|
|
8
|
+
'manage-scans': '/manage/scans',
|
|
9
|
+
'manage-reports': '/manage/report',
|
|
10
|
+
'manage-repos': '/manage/repositories',
|
|
11
|
+
'admin-users': '/manage/users',
|
|
12
|
+
'manage-teams': '/manage/team',
|
|
13
|
+
'manage-import': '/manage/import',
|
|
14
|
+
'manage-links': '/manage/links',
|
|
15
|
+
'manage-history': '/manage/history',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Dot-only path components are normalised away by URL parsers, even escaped.
|
|
19
|
+
function findingPath(id) {
|
|
20
|
+
if (!isLinkableFindingId(id) || id === '.' || id === '..') return null
|
|
21
|
+
try { return `/finding/${encodeURIComponent(id)}` } catch { return null }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function managedRoutePath(route) {
|
|
25
|
+
if (!route) return null
|
|
26
|
+
if (route.view === 'home') return '/'
|
|
27
|
+
if (route.view === 'bundles') {
|
|
28
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
|
|
29
|
+
|| (route.teamSlug != null && !/^[A-Za-z0-9_-]+$/u.test(route.teamSlug))) return null
|
|
30
|
+
const tab = route.bundleTab ?? 'overview'
|
|
31
|
+
if (!BUNDLE_TABS.has(tab)) return null
|
|
32
|
+
const parent = route.teamSlug ? `/team/${route.teamSlug}` : '/manage'
|
|
33
|
+
return `${parent}/bundle/${route.bundleSlug}${tab === 'overview' ? '' : `/${tab}`}`
|
|
34
|
+
}
|
|
35
|
+
if (Object.hasOwn(MANAGED_PAGES, route.view)) {
|
|
36
|
+
const path = MANAGED_PAGES[route.view]
|
|
37
|
+
if (route.view === 'manage-scans' && route.bundleId) return `${path}?bundle=${encodeURIComponent(route.bundleId)}`
|
|
38
|
+
return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
|
|
39
|
+
}
|
|
40
|
+
if (!['findings', 'files'].includes(route.view) || !route.teamSlug
|
|
41
|
+
|| [route.teamSlug, route.reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
|
|
42
|
+
const team = `/team/${encodeURIComponent(route.teamSlug)}`
|
|
43
|
+
const report = route.reportSlug ? `/report/${encodeURIComponent(route.reportSlug)}` : ''
|
|
44
|
+
if (route.finding) {
|
|
45
|
+
const suffix = findingPath(route.finding.id)
|
|
46
|
+
return route.view === 'findings' && suffix ? `${team}${report}${suffix}` : null
|
|
47
|
+
}
|
|
48
|
+
return `${team}${report}${route.view === 'files' ? '/files' : ''}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseManagedRoute(url) {
|
|
52
|
+
const path = url.pathname.replace(/\/$/u, '') || '/'
|
|
53
|
+
if (path === '/' || path === '/index.html') return { view: 'home' }
|
|
54
|
+
const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
|
|
55
|
+
if (view) {
|
|
56
|
+
return { view,
|
|
57
|
+
...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
|
|
58
|
+
...(view === 'manage-scans' && url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {}),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?$/u.exec(path)
|
|
62
|
+
if (bundle) {
|
|
63
|
+
const bundleTab = bundle[3] ?? 'overview'
|
|
64
|
+
return BUNDLE_TABS.has(bundleTab) ? { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab } : null
|
|
65
|
+
}
|
|
66
|
+
const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
|
|
67
|
+
if (!match) return null
|
|
68
|
+
try {
|
|
69
|
+
const teamSlug = decodeURIComponent(match[1])
|
|
70
|
+
const reportSlug = match[2] ? decodeURIComponent(match[2]) : null
|
|
71
|
+
if ([teamSlug, reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
|
|
72
|
+
if (match[4]?.length > MAX_FINDING_ID_LENGTH * 9) return null
|
|
73
|
+
const id = match[4] ? decodeURIComponent(match[4]) : null
|
|
74
|
+
if (id != null && !findingPath(id)) return null
|
|
75
|
+
return { view: match[3] ? 'files' : 'findings', teamSlug, reportSlug, ...(id == null ? {} : { finding: { id } }) }
|
|
76
|
+
} catch { return null }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// URL tokens are exact server-assigned slugs. Application state and API calls
|
|
80
|
+
// continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
|
|
81
|
+
export function resolveManagedRoute(route, teams, adminBundles = []) {
|
|
82
|
+
if (route.view === 'bundles') {
|
|
83
|
+
const { teamSlug, bundleSlug, ...rest } = route
|
|
84
|
+
const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
|
|
85
|
+
if (teamSlug != null && matches.length !== 1) return null
|
|
86
|
+
const team = matches[0]
|
|
87
|
+
const candidates = teamSlug == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])
|
|
88
|
+
const ids = new Set(candidates.filter(bundle => bundle.slug === bundleSlug).map(bundle => bundle.id))
|
|
89
|
+
const bundle = (team?.bundles ?? adminBundles).find(entry => entry.slug === bundleSlug)
|
|
90
|
+
return bundleSlug && bundle && ids.size === 1 ? { ...rest, teamId: team?.id ?? null, bundleId: bundle.id } : null
|
|
91
|
+
}
|
|
92
|
+
if (!['findings', 'files'].includes(route.view)) return route
|
|
93
|
+
const { teamSlug, reportSlug, ...rest } = route
|
|
94
|
+
const matches = teams.filter(team => team.slug === teamSlug)
|
|
95
|
+
if (!teamSlug || matches.length !== 1) return null
|
|
96
|
+
const team = matches[0]
|
|
97
|
+
if (reportSlug == null) return { ...rest, teamId: team.id, reportId: null }
|
|
98
|
+
const reports = teams.flatMap(entry => entry.reports ?? []).filter(report => report.slug === reportSlug)
|
|
99
|
+
const ids = new Set(reports.map(report => report.id))
|
|
100
|
+
const report = (team.reports ?? []).find(entry => entry.slug === reportSlug)
|
|
101
|
+
return report && ids.size === 1 ? { ...rest, teamId: team.id, reportId: report.id } : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function managedRouteForIds(route, teams, adminBundles = []) {
|
|
105
|
+
if (route.view === 'bundles') {
|
|
106
|
+
const { teamId, bundleId, ...rest } = route
|
|
107
|
+
const team = teams.find(entry => entry.id === teamId)
|
|
108
|
+
if (teamId != null && !team?.slug) return null
|
|
109
|
+
const bundle = (team?.bundles ?? adminBundles).find(entry => entry.id === bundleId)
|
|
110
|
+
if (!bundle?.slug) return null
|
|
111
|
+
const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug }
|
|
112
|
+
return resolveManagedRoute(result, teams, adminBundles) ? result : null
|
|
113
|
+
}
|
|
114
|
+
if (!['findings', 'files'].includes(route.view)) return route
|
|
115
|
+
const { teamId, reportId, ...rest } = route
|
|
116
|
+
const team = teams.find(entry => entry.id === teamId)
|
|
117
|
+
const report = reportId == null ? null : team?.reports?.find(entry => entry.id === reportId)
|
|
118
|
+
if (!team?.slug || (reportId != null && !report?.slug)) return null
|
|
119
|
+
const result = { ...rest, teamSlug: team.slug, reportSlug: report?.slug ?? null }
|
|
120
|
+
return resolveManagedRoute(result, teams) ? result : null
|
|
121
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { defaultScanModels } from '../default-scan-models.ts'
|
|
2
|
+
|
|
3
|
+
// Use the same starting catalogue as the UI until managed scan discovery is wired.
|
|
4
|
+
const catalogue = defaultScanModels()
|
|
5
|
+
export const MANAGED_SCAN_MODELS = Object.freeze(catalogue.models)
|
|
6
|
+
export const DEFAULT_MANAGED_SCAN_MODEL = catalogue.defaultModel
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Per-finding triage annotations on a managed server — shared by the server
|
|
2
|
+
// (which stores one row per finding id, validates the wire shape, and decides
|
|
3
|
+
// per report which ids a viewer may read or write) and the managed client
|
|
4
|
+
// (which mirrors entries into its local triage map). Keyed by finding id
|
|
5
|
+
// alone, like that map: reports mostly repeat one another (a re-scan of the
|
|
6
|
+
// same code carries the same finding ids), and a finding's triage is shared by
|
|
7
|
+
// every report that carries it. A wire entry carries the four server-persisted
|
|
8
|
+
// fields below, or is null for a cleared entry (the server's tombstone); the
|
|
9
|
+
// client's `ignoredReports` deliberately does NOT ride this wire — the
|
|
10
|
+
// per-report ignore is a client-local concept keyed by report name.
|
|
11
|
+
export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted'
|
|
12
|
+
|
|
13
|
+
export const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted']
|
|
14
|
+
|
|
15
|
+
// One finding's server-side triage entry (also the write shape — writes
|
|
16
|
+
// replace the whole entry). Absent fields are unset; `flagged` is tri-state:
|
|
17
|
+
// absent = never set, `true` = flagged, `false` = an explicit un-flag
|
|
18
|
+
// tombstone that must round-trip (see client/state.ts TriageEntry).
|
|
19
|
+
export type TriageEntryPatch = {
|
|
20
|
+
color?: string
|
|
21
|
+
triage?: TriageBucket
|
|
22
|
+
// Legacy triage history/migration only. New comments use separate records;
|
|
23
|
+
// the managed triage write endpoint rejects this field.
|
|
24
|
+
comment?: string
|
|
25
|
+
fix?: string
|
|
26
|
+
flagged?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Length caps enforced at the write endpoint so one entry can't balloon a row.
|
|
30
|
+
export const MAX_TRIAGE_TEXT = 10_000
|
|
31
|
+
export const MAX_TRIAGE_COLOR = 50
|
|
32
|
+
|
|
33
|
+
// Caps on one write request as a whole — shared with the client, which batches
|
|
34
|
+
// within them: entries per request, the finding-id length (real ids are 36-char
|
|
35
|
+
// uuids), and the JSON body (the server's small default is too small for entry
|
|
36
|
+
// batches, which carry free text).
|
|
37
|
+
export const MAX_TRIAGE_ENTRIES = 200
|
|
38
|
+
export const MAX_FINDING_ID = 100
|
|
39
|
+
export const MAX_TRIAGE_BODY_BYTES = 262_144
|
|
40
|
+
|
|
41
|
+
// The most trail events one history read returns (newest first). Retention is
|
|
42
|
+
// the server's: everything is kept unless its TRIAGE_HISTORY_LIMIT says
|
|
43
|
+
// otherwise.
|
|
44
|
+
export const MAX_TRIAGE_HISTORY = 200
|
|
45
|
+
|
|
46
|
+
export function isTriageBucket(x: unknown): x is TriageBucket {
|
|
47
|
+
return typeof x === 'string' && (TRIAGE_BUCKETS as readonly string[]).includes(x)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Coerce one wire entry (a request-body value) into a clean patch. `null`
|
|
51
|
+
// means "clear the entry"; a malformed value (wrong type, unknown bucket,
|
|
52
|
+
// over-cap string, non-boolean flagged) is 'invalid' so the endpoint 400s
|
|
53
|
+
// rather than store garbage. Null/empty-string fields count as absent (an
|
|
54
|
+
// absent field is cleared anyway under whole-entry replace); unknown keys are
|
|
55
|
+
// ignored.
|
|
56
|
+
export function parseTriageEntryPatch(x: unknown): TriageEntryPatch | null | 'invalid' {
|
|
57
|
+
if (x === null) return null
|
|
58
|
+
if (typeof x !== 'object' || Array.isArray(x)) return 'invalid'
|
|
59
|
+
const o = x as Record<string, unknown>
|
|
60
|
+
const out: TriageEntryPatch = {}
|
|
61
|
+
const color = o['color']
|
|
62
|
+
if (color != null) {
|
|
63
|
+
if (typeof color !== 'string' || color.length > MAX_TRIAGE_COLOR) return 'invalid'
|
|
64
|
+
if (color !== '') out.color = color
|
|
65
|
+
}
|
|
66
|
+
const triage = o['triage']
|
|
67
|
+
if (triage != null) {
|
|
68
|
+
if (!isTriageBucket(triage)) return 'invalid'
|
|
69
|
+
out.triage = triage
|
|
70
|
+
}
|
|
71
|
+
for (const key of ['comment', 'fix'] as const) {
|
|
72
|
+
const v = o[key]
|
|
73
|
+
if (v == null) continue
|
|
74
|
+
if (typeof v !== 'string' || v.length > MAX_TRIAGE_TEXT) return 'invalid'
|
|
75
|
+
if (v !== '') out[key] = v
|
|
76
|
+
}
|
|
77
|
+
const flagged = o['flagged']
|
|
78
|
+
if (flagged != null) {
|
|
79
|
+
if (typeof flagged !== 'boolean') return 'invalid'
|
|
80
|
+
out.flagged = flagged
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
package/out/index.html
CHANGED
|
@@ -110,6 +110,10 @@
|
|
|
110
110
|
<span class="drop-local-copy">Review locally in your browser. Sync across devices when you need it.</span>
|
|
111
111
|
</p>
|
|
112
112
|
</div>
|
|
113
|
+
<a class="landing-github-link" href="https://github.com/PreventiveMeasures/triage" target="_blank" rel="noopener noreferrer">
|
|
114
|
+
<span class="drop-supported-icon" data-icon="github" aria-hidden="true"></span>
|
|
115
|
+
<span>GitHub</span>
|
|
116
|
+
</a>
|
|
113
117
|
</div>
|
|
114
118
|
<div id="report"></div>
|
|
115
119
|
</main>
|