@preventive/triage 1.0.0-alpha.22 → 1.0.0-alpha.25
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/out/client-managed-import.js +41 -41
- package/out/client-managed.js +74 -59
- package/out/graph.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +170 -169
- package/package.json +5 -1
- package/report/index.js +7 -1
- package/report/src/finding-id.js +1 -1
- package/report/src/finding.js +6 -4
- package/report/src/labels.js +3 -1
- package/report/src/parse-codex.js +3 -1
- package/report/src/parse-deepsec.js +3 -1
- package/report/src/parse-deepview-md.js +2 -1
- package/report/src/parse-md.js +3 -2
- package/report/src/parse-piolium-rows.js +7 -12
- package/report/src/parse-piolium-tokens.js +12 -2
- package/report/src/parse-piolium.js +7 -8
- package/report/src/severity.js +18 -0
- package/server-common/vercel-blob.ts +7 -18
- package/server-e2e/objstore/blob-vercel.ts +4 -4
- package/server-managed/blob-vercel.ts +4 -4
- package/server-managed/db-methods.ts +30 -12
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/github-app.ts +5 -17
- package/server-managed/github-errors.ts +28 -0
- package/server-managed/http.ts +75 -83
- package/server-managed/import-triage.ts +2 -2
- package/server-managed/management.ts +86 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +1 -11
- package/server-managed/repository-browser.ts +34 -13
- package/server-managed/sql.ts +5 -0
- package/server-managed/workspace-shares.ts +3 -3
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
3
|
+
|
|
4
|
+
export const MAX_PACKAGE_BYTES = 256 * 1024
|
|
5
|
+
const MAX_ENTRY_POINTS = 100
|
|
6
|
+
|
|
7
|
+
// Declared paths, not a Node resolver: never guess defaults, expand export
|
|
8
|
+
// patterns, or execute package code. Keep every suggestion in its package.
|
|
9
|
+
export function packageEntryPoints(manifest: unknown, directory: string): string[] {
|
|
10
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []
|
|
11
|
+
const pkg = manifest as { main?: unknown; exports?: unknown; bin?: unknown }
|
|
12
|
+
const paths = new Set<string>()
|
|
13
|
+
const add = (value: unknown) => {
|
|
14
|
+
if (typeof value !== 'string' || value.startsWith('/') || value.endsWith('/') || /[*?]|^[a-z][a-z\d+.-]*:/iu.test(value)) return
|
|
15
|
+
const normalized = normalizeTeamPath(value)
|
|
16
|
+
if (!normalized.ok || !normalized.path || normalized.path === 'package.json') return
|
|
17
|
+
const path = directory ? `${directory}/${normalized.path}` : normalized.path
|
|
18
|
+
if (path.length <= 500 && paths.size < MAX_ENTRY_POINTS) paths.add(path)
|
|
19
|
+
}
|
|
20
|
+
add(pkg.main)
|
|
21
|
+
const pending = [pkg.exports]
|
|
22
|
+
for (let visited = 0; pending.length > 0 && visited < 4096; visited++) {
|
|
23
|
+
const value = pending.pop()
|
|
24
|
+
if (typeof value === 'string') { if (value.startsWith('./')) add(value); continue }
|
|
25
|
+
if (Array.isArray(value)) pending.push(...value.slice(0, 4096 - visited).toReversed())
|
|
26
|
+
else if (value && typeof value === 'object') {
|
|
27
|
+
const entries = Object.entries(value).filter(([key]) => key !== 'types' && !key.startsWith('types@'))
|
|
28
|
+
pending.push(...entries.slice(0, 4096 - visited).toReversed().map(([, target]) => target))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (typeof pkg.bin === 'string') add(pkg.bin)
|
|
32
|
+
else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) Object.values(pkg.bin).forEach(add)
|
|
33
|
+
return [...paths]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use the immutable blob from the authorized listing, never download_url or a
|
|
37
|
+
// resolved symlink target. Malformed or oversized manifests are optional.
|
|
38
|
+
export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
|
|
39
|
+
try {
|
|
40
|
+
const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
|
|
41
|
+
if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
|
|
42
|
+
|| blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
|
|
43
|
+
const bytes = Buffer.from(blob.content, 'base64')
|
|
44
|
+
if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
|
|
45
|
+
return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
|
|
46
|
+
} catch {
|
|
47
|
+
// Suggestions must not prevent browsing. The handler still rechecks
|
|
48
|
+
// GitHub and managed access after this optional upstream read.
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -8,7 +8,6 @@ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
|
8
8
|
import { sendJson } from './http-response.ts'
|
|
9
9
|
import { hashToken } from './crypto.ts'
|
|
10
10
|
import { TeamReportsError, loadTeamReports, teamReportVisibility } from './team-reports.ts'
|
|
11
|
-
import { MAX_FINDING_ID, MAX_TRIAGE_HISTORY } from '../common/managed/triage.ts'
|
|
12
11
|
import { triageWireEntry } from './triage-response.ts'
|
|
13
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
14
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
@@ -30,7 +29,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
30
29
|
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
31
30
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
32
31
|
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
|
|
33
|
-
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|
|
|
32
|
+
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
34
33
|
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
35
34
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
36
35
|
const tokenHash = hashToken(token)
|
|
@@ -85,15 +84,6 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
85
84
|
await recheck()
|
|
86
85
|
if (part === 'sources') { await sources(res, deps, snapshot, id, visible.sourcePaths, recheck, stream); return }
|
|
87
86
|
if (part === 'comments') { await send({ comments: await deps.db.listComments([...visible.ids]) }); return }
|
|
88
|
-
if (part === 'triage/history') {
|
|
89
|
-
const finding = url.searchParams.get('finding') ?? ''
|
|
90
|
-
if (!finding || finding.length > MAX_FINDING_ID) { json(res, 400, { error: 'bad-request' }); return }
|
|
91
|
-
if (!visible.ids.has(finding)) { json(res, 404, { error: 'no-finding' }); return }
|
|
92
|
-
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map(row => ({
|
|
93
|
-
seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
94
|
-
}))
|
|
95
|
-
await send({ finding, events }); return
|
|
96
|
-
}
|
|
97
87
|
const entries = Object.fromEntries((await deps.db.listTriage([...visible.ids])).map(row => [row.findingId, triageWireEntry(row)]))
|
|
98
88
|
await send({ entries }); return
|
|
99
89
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ManagedConfig } from './config.ts'
|
|
2
2
|
import type { SelectedRepo } from './db.ts'
|
|
3
3
|
import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
|
|
4
|
+
import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
|
|
4
5
|
|
|
5
6
|
export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
|
|
6
7
|
|
|
@@ -16,6 +17,10 @@ export function scopedDirectory(path: string, scopes: (string | null)[]): Reposi
|
|
|
16
17
|
export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
|
|
17
18
|
type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
|
|
18
19
|
|
|
20
|
+
function credentialFailure(err: unknown): err is GithubApiError {
|
|
21
|
+
return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
// Create once per HTTP request. Coalesce concurrent credential lookups without
|
|
20
25
|
// caching repository visibility or permissions, or sharing credentials across users.
|
|
21
26
|
export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
@@ -32,31 +37,40 @@ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryB
|
|
|
32
37
|
return identity
|
|
33
38
|
}
|
|
34
39
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
reader(repo: SelectedRepo) {
|
|
41
|
+
const installationToken = () => {
|
|
42
|
+
if (repo.installationId == null) return Promise.resolve(null)
|
|
38
43
|
const cached = tokens.get(repo.installationId)
|
|
39
|
-
token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
|
|
40
|
-
if (!(err
|
|
41
|
-
// Stale installations cannot prevent verified
|
|
44
|
+
const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
|
|
45
|
+
if (!credentialFailure(err)) throw err
|
|
46
|
+
// Stale installations cannot prevent verified public access.
|
|
42
47
|
return null
|
|
43
48
|
})
|
|
44
49
|
if (!cached) tokens.set(repo.installationId, token)
|
|
50
|
+
return token
|
|
45
51
|
}
|
|
46
|
-
return repositoryReader(repo,
|
|
52
|
+
return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
|
|
47
53
|
},
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
async function repositoryReader(repo: SelectedRepo,
|
|
57
|
+
async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
|
|
52
58
|
const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
|
|
59
|
+
// Public reads can use the signed-in user's quota without repository grants.
|
|
60
|
+
// Nonpublic reads still require the repository App and the effective-user gate.
|
|
61
|
+
let token = userToken ?? await installationToken()
|
|
53
62
|
const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
|
|
54
63
|
const metadata = async () => {
|
|
55
64
|
try { return await read('') }
|
|
56
65
|
catch (err) {
|
|
57
|
-
if (!token || !(err
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
if (!token || !credentialFailure(err)) throw err
|
|
67
|
+
const fallback = await installationToken()
|
|
68
|
+
if (fallback && fallback !== token) {
|
|
69
|
+
token = fallback
|
|
70
|
+
try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
|
|
71
|
+
}
|
|
72
|
+
// Anonymous fallback is only for unavailable credentials, never rate
|
|
73
|
+
// limits, network errors, or GitHub outages.
|
|
60
74
|
token = null
|
|
61
75
|
return read('')
|
|
62
76
|
}
|
|
@@ -70,9 +84,12 @@ async function repositoryReader(repo: SelectedRepo, token: string | null, getIde
|
|
|
70
84
|
}
|
|
71
85
|
if (data.private === false && data.visibility === 'public') return data
|
|
72
86
|
if (!token) throw new GithubApiError(404, 'no-repository')
|
|
73
|
-
|
|
87
|
+
const appToken = await installationToken()
|
|
88
|
+
if (!appToken) throw new GithubApiError(404, 'no-repository')
|
|
89
|
+
if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
|
|
74
90
|
throw new GithubApiError(404, 'no-repository')
|
|
75
91
|
}
|
|
92
|
+
token = appToken
|
|
76
93
|
return data
|
|
77
94
|
}
|
|
78
95
|
const current = await checkAccess()
|
|
@@ -105,7 +122,11 @@ async function repositoryReader(repo: SelectedRepo, token: string | null, getIde
|
|
|
105
122
|
const type = item.submodule_git_url ? 'submodule' : item.type
|
|
106
123
|
if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
|
|
107
124
|
}
|
|
108
|
-
|
|
125
|
+
const manifestPath = path ? `${path}/package.json` : 'package.json'
|
|
126
|
+
const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
|
|
127
|
+
&& typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
|
|
128
|
+
const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
|
|
129
|
+
return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}) }
|
|
109
130
|
},
|
|
110
131
|
}
|
|
111
132
|
}
|
package/server-managed/sql.ts
CHANGED
|
@@ -4,6 +4,11 @@ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
|
4
4
|
import type { ManagedDb } from './db-methods.ts'
|
|
5
5
|
|
|
6
6
|
type MaybePromise<T> = T | Promise<T>
|
|
7
|
+
// A failed COMMIT acknowledgement (or connection close after COMMIT) does not
|
|
8
|
+
// establish rollback. Callers must retain primary bytes until reconciled.
|
|
9
|
+
export class ManagedCommitError extends Error {
|
|
10
|
+
constructor(cause: unknown) { super('Managed commit outcome is uncertain', { cause }) }
|
|
11
|
+
}
|
|
7
12
|
export interface ManagedSql {
|
|
8
13
|
prepare(sql: string): {
|
|
9
14
|
get(...params: unknown[]): MaybePromise<unknown>
|
|
@@ -71,7 +71,7 @@ function shareQueries(db: ManagedSql) {
|
|
|
71
71
|
const repositories = db.prepare(`SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path
|
|
72
72
|
FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
|
|
73
73
|
WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`)
|
|
74
|
-
const reports = db.prepare(`SELECT DISTINCT r.id, r.slug, r.filename, r.byte_size AS byteSize,
|
|
74
|
+
const reports = db.prepare(`SELECT DISTINCT r.id, r.slug, r.filename, r.analyzer, r.byte_size AS byteSize,
|
|
75
75
|
r.sha256, r.repo_directory AS directory, sr.full_name AS github
|
|
76
76
|
FROM managed_team_repo tr JOIN managed_report r ON r.repo_id = tr.repo_id
|
|
77
77
|
JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
|
|
@@ -128,7 +128,7 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
|
|
|
128
128
|
if (!grant) return null
|
|
129
129
|
const { dependencies, security, ...team } = grant
|
|
130
130
|
const permissions = { dependencies: dependencies === 1, security: security === 1 }
|
|
131
|
-
const rows = await q.reports.all(team.id) as { id: string; slug: string; filename: string; byteSize: number; sha256: string; directory: string; github: string }[]
|
|
131
|
+
const rows = await q.reports.all(team.id) as { id: string; slug: string; filename: string; analyzer: string | null; byteSize: number; sha256: string; directory: string; github: string }[]
|
|
132
132
|
const bundleRows = await q.bundles.all(team.id) as (ManagedBundle & { repoFullName: string })[]
|
|
133
133
|
return {
|
|
134
134
|
user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
|
|
@@ -138,7 +138,7 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
|
|
|
138
138
|
reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
|
|
139
139
|
repo: { github: row.github, directory: row.directory }, permissions })),
|
|
140
140
|
team: { ...team,
|
|
141
|
-
reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, repoFullName: row.github, repoDirectory: row.directory,
|
|
141
|
+
reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
|
|
142
142
|
cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions]) })),
|
|
143
143
|
bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,
|
|
144
144
|
filename: row.filename, kind: row.kind, byteSize: row.byteSize, repoId: row.repoId!, repoDirectory: row.repoDirectory, repoFullName: row.repoFullName })),
|