@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,46 @@
|
|
|
1
|
+
// Compose the existing HTTP apps on e2e's listener, including its WS/SSE
|
|
2
|
+
// transports and static UI. Managed handles its API, discovery and page HTML;
|
|
3
|
+
// assets and other requests fall through to the unchanged e2e HTTP handler.
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import type { Server } from 'node:http'
|
|
6
|
+
import { withReap } from '../server-common/reap.ts'
|
|
7
|
+
import { loadConfig } from '../server-e2e/config.ts'
|
|
8
|
+
import { createE2eApp } from '../server-e2e/app.ts'
|
|
9
|
+
import { loadManagedConfig } from './config.ts'
|
|
10
|
+
import { LOGIN_PATH } from './github-oauth.ts'
|
|
11
|
+
import { createManagedApp, logManagedStartup } from './index.ts'
|
|
12
|
+
|
|
13
|
+
export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server> {
|
|
14
|
+
const config = loadManagedConfig({ combined: true })
|
|
15
|
+
const e2eConfig = loadConfig()
|
|
16
|
+
if (!e2eConfig.neonUrl && !config.neonUrl && config.dbPath !== ':memory:' && resolve(config.dbPath) === resolve(e2eConfig.dbPath)) {
|
|
17
|
+
throw new Error('MANAGED_DB_PATH must differ from e2e DB_PATH in combined mode.')
|
|
18
|
+
}
|
|
19
|
+
const e2e = await createE2eApp(e2eConfig)
|
|
20
|
+
// Replace the assembled server's single
|
|
21
|
+
// request listener; adding a second would write two responses per request.
|
|
22
|
+
const handlers = e2e.httpServer.listeners('request')
|
|
23
|
+
const next = handlers[0]
|
|
24
|
+
if (handlers.length !== 1 || !next) throw new Error('Expected one e2e HTTP request handler')
|
|
25
|
+
const managed = await createManagedApp(config, {
|
|
26
|
+
next: e2e.handleRequest,
|
|
27
|
+
isShuttingDown: e2e.isShuttingDown,
|
|
28
|
+
serverInfo: {
|
|
29
|
+
mode, managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName },
|
|
30
|
+
...(e2eConfig.deepviewScanServer ? { deepviewScanServer: e2eConfig.deepviewScanServer } : {}),
|
|
31
|
+
},
|
|
32
|
+
}).catch(async err => {
|
|
33
|
+
await e2e.httpServer[Symbol.asyncDispose]()
|
|
34
|
+
throw err
|
|
35
|
+
})
|
|
36
|
+
e2e.onShutdown(managed.close)
|
|
37
|
+
e2e.httpServer.removeListener('request', next)
|
|
38
|
+
e2e.httpServer.on('request', withReap(managed.handleRequest,
|
|
39
|
+
{ e2e: e2e.reap, managed: managed.reap }, { isShuttingDown: e2e.isShuttingDown }))
|
|
40
|
+
e2e.httpServer.once('listening', () => {
|
|
41
|
+
const address = e2e.httpServer.address()
|
|
42
|
+
const port = typeof address === 'object' && address ? address.port : config.port
|
|
43
|
+
logManagedStartup(config, port, mode)
|
|
44
|
+
})
|
|
45
|
+
return e2e.httpServer
|
|
46
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
3
|
+
import { type ManagedComment, canDeleteComment } from '../common/managed/comments.ts'
|
|
4
|
+
import type { ManagedSql } from './sql.ts'
|
|
5
|
+
|
|
6
|
+
export interface CommentInput {
|
|
7
|
+
findingId: string
|
|
8
|
+
body: string
|
|
9
|
+
// Runtime imports may omit authorship, including future admin UI imports.
|
|
10
|
+
// Ordinary comment posts always supply the authenticated user as author.
|
|
11
|
+
authorId: string | null
|
|
12
|
+
authorLogin: string | null
|
|
13
|
+
// The user performing an import need not be the author of its comments.
|
|
14
|
+
// Omitted for ordinary posts, where the author is also the acting user.
|
|
15
|
+
actor?: { id: string; login: string }
|
|
16
|
+
// Omitted dates default to the action time; null preserves unknown dates.
|
|
17
|
+
createdAt?: number | null
|
|
18
|
+
updatedAt?: number | null
|
|
19
|
+
reportId?: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CommentStore {
|
|
23
|
+
listComments(findingIds: readonly string[]): Promise<ManagedComment[]>
|
|
24
|
+
listCommentedFindingIds(findingIds: readonly string[]): Promise<string[]>
|
|
25
|
+
getComment(id: string): Promise<ManagedComment | null>
|
|
26
|
+
createComment(input: CommentInput, now: number): Promise<ManagedComment>
|
|
27
|
+
editComment(id: string, authorId: string, authorLogin: string, body: string, version: number, reportId: string, now: number): Promise<ManagedComment | 'conflict' | 'forbidden' | null>
|
|
28
|
+
deleteComment(id: string, actorId: string, actorLogin: string, version: number, reportId: string, now: number): Promise<'deleted' | 'conflict' | 'forbidden' | null>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COMMENT_COLUMNS = `id TEXT PRIMARY KEY, finding_id TEXT NOT NULL, body TEXT NOT NULL,
|
|
32
|
+
author_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
33
|
+
author_login TEXT, created_at INTEGER, updated_at INTEGER,
|
|
34
|
+
version INTEGER NOT NULL DEFAULT 1`
|
|
35
|
+
|
|
36
|
+
export const COMMENT_SCHEMA = `
|
|
37
|
+
CREATE TABLE IF NOT EXISTS managed_finding_comment (${COMMENT_COLUMNS}) STRICT;
|
|
38
|
+
CREATE INDEX IF NOT EXISTS finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS managed_finding_comment_event (
|
|
40
|
+
seq INTEGER PRIMARY KEY, comment_id TEXT NOT NULL, finding_id TEXT NOT NULL,
|
|
41
|
+
actor_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
42
|
+
actor_login TEXT, action TEXT NOT NULL, at INTEGER NOT NULL,
|
|
43
|
+
report_id TEXT, report TEXT, repo TEXT
|
|
44
|
+
) STRICT;
|
|
45
|
+
CREATE INDEX IF NOT EXISTS finding_comment_event_finding_idx ON managed_finding_comment_event(finding_id, seq);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS finding_comment_event_actor_at_idx ON managed_finding_comment_event(actor_id, at);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS finding_comment_event_at_idx ON managed_finding_comment_event(at, seq);
|
|
48
|
+
`
|
|
49
|
+
|
|
50
|
+
export function initCommentMethods(db: DatabaseSync): void {
|
|
51
|
+
db.exec(COMMENT_SCHEMA)
|
|
52
|
+
migrateCommentDates(db)
|
|
53
|
+
// updated_by belongs to the whole triage row, not necessarily its comment.
|
|
54
|
+
// Preserve legacy text without guessing an author. Clearing the old column
|
|
55
|
+
// in the same transaction makes the migration safe to restart. Use a fresh
|
|
56
|
+
// ID so a later legacy-server write cannot collide with an earlier import.
|
|
57
|
+
db.exec('BEGIN')
|
|
58
|
+
try {
|
|
59
|
+
db.exec(`INSERT INTO managed_finding_comment (id, finding_id, body, created_at, updated_at)
|
|
60
|
+
SELECT 'legacy:' || lower(hex(randomblob(16))), finding_id, comment, updated_at, updated_at
|
|
61
|
+
FROM managed_finding_triage WHERE comment IS NOT NULL AND comment != '';
|
|
62
|
+
UPDATE managed_finding_triage SET comment = NULL WHERE comment IS NOT NULL;`)
|
|
63
|
+
db.exec('COMMIT')
|
|
64
|
+
} catch (err) {
|
|
65
|
+
db.exec('ROLLBACK')
|
|
66
|
+
throw err
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function commentMethods(db: ManagedSql): CommentStore {
|
|
71
|
+
const fields = `c.id, c.finding_id AS findingId, c.body, c.author_id AS authorId,
|
|
72
|
+
COALESCE(u.login, c.author_login) AS authorLogin,
|
|
73
|
+
c.created_at AS createdAt, c.updated_at AS updatedAt, c.version`
|
|
74
|
+
const select = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
|
|
75
|
+
LEFT JOIN managed_user u ON u.id = c.author_id WHERE c.id = ?`)
|
|
76
|
+
const list = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
|
|
77
|
+
LEFT JOIN managed_user u ON u.id = c.author_id
|
|
78
|
+
WHERE c.finding_id IN (SELECT value FROM json_each(?)) ORDER BY c.created_at NULLS FIRST, c.id`)
|
|
79
|
+
const insert = db.prepare(`INSERT INTO managed_finding_comment
|
|
80
|
+
(id, finding_id, body, author_id, author_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
81
|
+
const update = db.prepare(`UPDATE managed_finding_comment SET body = ?, updated_at = ?, version = version + 1
|
|
82
|
+
WHERE id = ? AND author_id = ? AND version = ?`)
|
|
83
|
+
const remove = db.prepare('DELETE FROM managed_finding_comment WHERE id = ? AND (author_id = ? OR (author_id IS NULL AND ? IS NULL)) AND version = ?')
|
|
84
|
+
const userRole = db.prepare('SELECT role FROM managed_user WHERE id = ?')
|
|
85
|
+
// Deleted comments still have audit events which explicit annotation purges
|
|
86
|
+
// must find, including when no current triage/comment record remains.
|
|
87
|
+
const annotated = db.prepare(`SELECT finding_id AS id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
|
|
88
|
+
UNION SELECT finding_id AS id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`)
|
|
89
|
+
const event = db.prepare(`INSERT INTO managed_finding_comment_event
|
|
90
|
+
(comment_id, finding_id, actor_id, actor_login, action, at, report_id, report, repo)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT filename FROM managed_report WHERE id = ?),
|
|
92
|
+
(SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`)
|
|
93
|
+
const read = async (id: string) => (await select.get(id)) as ManagedComment | undefined
|
|
94
|
+
return {
|
|
95
|
+
async listComments(ids) { return ids.length > 0 ? (await list.all(JSON.stringify(ids))) as unknown as ManagedComment[] : [] },
|
|
96
|
+
async listCommentedFindingIds(ids) {
|
|
97
|
+
const json = JSON.stringify(ids)
|
|
98
|
+
return ((await annotated.all(json, json)) as { id: string }[]).map(row => row.id)
|
|
99
|
+
},
|
|
100
|
+
async getComment(id) { return (await read(id)) ?? null },
|
|
101
|
+
async createComment(input, now) {
|
|
102
|
+
const id = randomUUID()
|
|
103
|
+
const createdAt = input.createdAt === undefined ? now : input.createdAt
|
|
104
|
+
const updatedAt = input.updatedAt === undefined ? createdAt : input.updatedAt
|
|
105
|
+
await insert.run(id, input.findingId, input.body, input.authorId, input.authorLogin, createdAt, updatedAt)
|
|
106
|
+
await event.run(id, input.findingId, input.actor?.id ?? input.authorId, input.actor?.login ?? input.authorLogin, 'added a comment', now,
|
|
107
|
+
input.reportId ?? null, input.reportId ?? null, input.reportId ?? null)
|
|
108
|
+
return (await read(id))!
|
|
109
|
+
},
|
|
110
|
+
async editComment(id, authorId, authorLogin, body, version, reportId, now) {
|
|
111
|
+
// Identity/version checks and the event share the operation transaction.
|
|
112
|
+
const current = await read(id)
|
|
113
|
+
if (!current || current.authorId !== authorId || current.version !== version || current.body === body) {
|
|
114
|
+
if (!current) return null
|
|
115
|
+
if (current.authorId !== authorId) return 'forbidden'
|
|
116
|
+
if (current.version !== version) return 'conflict'
|
|
117
|
+
return current
|
|
118
|
+
}
|
|
119
|
+
await update.run(body, now, id, authorId, version)
|
|
120
|
+
await event.run(id, current.findingId, authorId, authorLogin, 'edited a comment', now, reportId, reportId, reportId)
|
|
121
|
+
return (await read(id))!
|
|
122
|
+
},
|
|
123
|
+
async deleteComment(id, actorId, actorLogin, version, reportId, now) {
|
|
124
|
+
const current = await read(id)
|
|
125
|
+
const role = (await userRole.get(actorId) as { role: string } | undefined)?.role ?? 'none'
|
|
126
|
+
const allowed = current != null && canDeleteComment(current, { id: actorId, role })
|
|
127
|
+
if (!current || !allowed || current.version !== version) {
|
|
128
|
+
if (!current) return null
|
|
129
|
+
return allowed ? 'conflict' : 'forbidden'
|
|
130
|
+
}
|
|
131
|
+
await remove.run(id, current.authorId, current.authorId, version)
|
|
132
|
+
await event.run(id, current.findingId, actorId, actorLogin, 'deleted a comment', now, reportId, reportId, reportId)
|
|
133
|
+
return 'deleted'
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function migrateCommentDates(db: DatabaseSync): void {
|
|
139
|
+
const columns = db.prepare('PRAGMA table_info(managed_finding_comment)').all() as { name: string; notnull: number }[]
|
|
140
|
+
if (!columns.some(column => ['created_at', 'updated_at'].includes(column.name) && column.notnull)) return
|
|
141
|
+
db.exec('BEGIN')
|
|
142
|
+
try {
|
|
143
|
+
db.exec(`CREATE TABLE managed_finding_comment_nullable_dates (${COMMENT_COLUMNS}) STRICT;
|
|
144
|
+
INSERT INTO managed_finding_comment_nullable_dates (id, finding_id, body, author_id, author_login, created_at, updated_at, version)
|
|
145
|
+
SELECT id, finding_id, body, author_id, author_login, created_at, updated_at, version FROM managed_finding_comment;
|
|
146
|
+
DROP TABLE managed_finding_comment;
|
|
147
|
+
ALTER TABLE managed_finding_comment_nullable_dates RENAME TO managed_finding_comment;
|
|
148
|
+
CREATE INDEX finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);`)
|
|
149
|
+
db.exec('COMMIT')
|
|
150
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
151
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Managed-server boot config. GitHub user authorization establishes identity;
|
|
2
|
+
// optional App installation credentials enable repository access. A single
|
|
3
|
+
// GitHub App can provide both flows, with repository grants at installation.
|
|
4
|
+
import { env } from 'node:process'
|
|
5
|
+
import { databaseUrls } from '../server-common/database-config.ts'
|
|
6
|
+
import { MAX_UPLOAD_BYTES } from './uploads.ts'
|
|
7
|
+
import { parseStorageKey } from '../server-common/storage-crypto.ts'
|
|
8
|
+
|
|
9
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
|
|
10
|
+
|
|
11
|
+
export interface ManagedConfig {
|
|
12
|
+
port: number
|
|
13
|
+
host: string
|
|
14
|
+
dbPath: string
|
|
15
|
+
neonUrl?: string | null
|
|
16
|
+
blobToken?: string | null
|
|
17
|
+
storageEncryptionKey?: string | null
|
|
18
|
+
storageEncryptionMigrate?: boolean
|
|
19
|
+
storageEncryptionMigrateMaxMs?: number
|
|
20
|
+
vercelPreview?: boolean
|
|
21
|
+
serverless?: boolean
|
|
22
|
+
debug: boolean
|
|
23
|
+
allowShare: boolean
|
|
24
|
+
trustProxyEnv: string | undefined
|
|
25
|
+
// GitHub App user-authorization (identity) credentials — the App's client id
|
|
26
|
+
// + secret, used by the login flow.
|
|
27
|
+
githubClientId: string
|
|
28
|
+
githubClientSecret: string
|
|
29
|
+
// Preapproved GitHub identity promoted on login when it is the only user
|
|
30
|
+
// and its role is No access, including a login after registration.
|
|
31
|
+
initialAdminGithubId: number | null
|
|
32
|
+
// Absolute callback registered with GitHub, e.g.
|
|
33
|
+
// 'https://triage.example.com/api/oauth/github/callback'.
|
|
34
|
+
oauthCallbackUrl: string
|
|
35
|
+
// Whether cookies carry `Secure` (callback is https). Also gates the
|
|
36
|
+
// `__Host-` prefix, so loopback http dev can run with a plain cookie name.
|
|
37
|
+
cookieSecure: boolean
|
|
38
|
+
sessionCookieName: string
|
|
39
|
+
sessionTtlMs: number
|
|
40
|
+
// Optional installation credentials for the same GitHub App as login.
|
|
41
|
+
// Repository permissions are approved when connecting repositories.
|
|
42
|
+
githubAppId: string | null
|
|
43
|
+
githubAppPrivateKey: string | null
|
|
44
|
+
githubAppSlug: string | null
|
|
45
|
+
githubNewIssueLabels?: string
|
|
46
|
+
// Max accepted size (bytes) for an uploaded report on the "Manage reports"
|
|
47
|
+
// page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
|
|
48
|
+
maxReportBytes: number
|
|
49
|
+
// Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
|
|
50
|
+
// archive). Bundles run larger than reports, so a higher cap (default 200 MiB).
|
|
51
|
+
maxBundleBytes: number
|
|
52
|
+
// How many triage-trail events to keep per finding (managed_finding_triage_event):
|
|
53
|
+
// 0, the default, keeps everything — the trail is the record. An operator
|
|
54
|
+
// who would rather bound the store sets a positive count; older events of a
|
|
55
|
+
// finding are then trimmed as new ones land.
|
|
56
|
+
triageHistoryLimit: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fail(msg: string): never {
|
|
60
|
+
throw new Error(msg)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requireStr(name: string): string {
|
|
64
|
+
const v = env[name]
|
|
65
|
+
if (v == null || v === '') fail(`Missing required env ${name} (managed mode).`)
|
|
66
|
+
return v
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function intEnv(name: string, def: number, min: number, max: number): number {
|
|
70
|
+
const raw = env[name]
|
|
71
|
+
const n = raw == null ? def : Number(raw)
|
|
72
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) {
|
|
73
|
+
fail(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].`)
|
|
74
|
+
}
|
|
75
|
+
return n
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function optionalGithubId(name: string): number | null {
|
|
79
|
+
const raw = env[name]
|
|
80
|
+
if (raw == null || raw === '') return null
|
|
81
|
+
const id = Number(raw)
|
|
82
|
+
if (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(id)) {
|
|
83
|
+
fail(`Invalid ${name}: must be a single positive numeric GitHub user ID.`)
|
|
84
|
+
}
|
|
85
|
+
return id
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function urlOrFail(name: string, raw: string): URL {
|
|
89
|
+
try { return new URL(raw) } catch { fail(`${name} is not a valid URL: ${raw}`) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
|
|
93
|
+
const serverless = env['VERCEL'] === '1'
|
|
94
|
+
const neonUrl = databaseUrls({ combined }).managed
|
|
95
|
+
const blobToken = env['BLOB_READ_WRITE_TOKEN'] || null
|
|
96
|
+
const storageEncryptionKey = env['MANAGED_STORAGE_ENCRYPTION_KEY'] || null
|
|
97
|
+
parseStorageKey(storageEncryptionKey)
|
|
98
|
+
const storageEncryptionMigrate = env['MANAGED_STORAGE_ENCRYPTION_MIGRATE'] === '1'
|
|
99
|
+
if (storageEncryptionMigrate && !storageEncryptionKey) fail('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY.')
|
|
100
|
+
if (serverless && !neonUrl) fail('Vercel managed mode requires DATABASE_URL or MANAGED_DATABASE_URL.')
|
|
101
|
+
if (neonUrl && !blobToken) fail('Managed Neon mode requires BLOB_READ_WRITE_TOKEN.')
|
|
102
|
+
const host = env['HOST'] ?? '127.0.0.1'
|
|
103
|
+
const oauthCallbackUrl = requireStr('OAUTH_CALLBACK_URL')
|
|
104
|
+
const callback = urlOrFail('OAUTH_CALLBACK_URL', oauthCallbackUrl)
|
|
105
|
+
const cookieSecure = callback.protocol === 'https:'
|
|
106
|
+
// A `__Host-` cookie mandates `Secure`, so any non-loopback bind must be
|
|
107
|
+
// HTTPS — fail fast (mirrors server-e2e's boot check). Loopback dev over
|
|
108
|
+
// http is allowed with a non-prefixed cookie name.
|
|
109
|
+
if ((serverless || !LOOPBACK_HOSTS.has(host)) && !cookieSecure) {
|
|
110
|
+
fail(`Vercel or non-loopback HOST=${host} requires an https OAUTH_CALLBACK_URL — the session cookie needs Secure.`)
|
|
111
|
+
}
|
|
112
|
+
const sessionCookieName = env['SESSION_COOKIE_NAME'] ?? '__Host-dvsid'
|
|
113
|
+
if (sessionCookieName.startsWith('__Host-') && !cookieSecure) {
|
|
114
|
+
fail(`SESSION_COOKIE_NAME=${sessionCookieName} uses the __Host- prefix but the callback is not https. Use a non-prefixed name for loopback http dev.`)
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
neonUrl, blobToken, serverless, storageEncryptionKey, storageEncryptionMigrate,
|
|
118
|
+
storageEncryptionMigrateMaxMs: intEnv('MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS', 150_000, 1, serverless ? 240_000 : 3_600_000),
|
|
119
|
+
vercelPreview: env['VERCEL_ENV'] === 'preview',
|
|
120
|
+
port: intEnv('PORT', 8765, 0, 65535),
|
|
121
|
+
host,
|
|
122
|
+
// DB_PATH belongs to e2e in a combined process. Keep the two stores apart.
|
|
123
|
+
dbPath: env['MANAGED_DB_PATH'] ?? (combined ? undefined : env['DB_PATH']) ?? 'server-managed/data/managed.db',
|
|
124
|
+
debug: env['DEBUG'] === '1' || env['DEBUG'] === 'true',
|
|
125
|
+
allowShare: env['DEEPVIEW_ALLOW_SHARE'] === '1',
|
|
126
|
+
trustProxyEnv: env['TRUST_PROXY'] ?? (serverless ? '1' : undefined),
|
|
127
|
+
githubClientId: requireStr('GITHUB_CLIENT_ID'),
|
|
128
|
+
githubClientSecret: requireStr('GITHUB_CLIENT_SECRET'),
|
|
129
|
+
initialAdminGithubId: optionalGithubId('MANAGED_INITIAL_ADMIN_GITHUB_ID'),
|
|
130
|
+
oauthCallbackUrl,
|
|
131
|
+
cookieSecure,
|
|
132
|
+
sessionCookieName,
|
|
133
|
+
sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
|
|
134
|
+
githubAppId: env['GITHUB_APP_ID'] ?? null,
|
|
135
|
+
githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
|
|
136
|
+
githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
|
|
137
|
+
githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
|
|
138
|
+
maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
|
|
139
|
+
maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, MAX_UPLOAD_BYTES),
|
|
140
|
+
triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// PEM private keys are awkward in env vars; accept a literal multi-line value or
|
|
145
|
+
// one with escaped newlines (`\n`). Empty/absent → null (private repos off).
|
|
146
|
+
function normalizePem(raw: string | undefined): string | null {
|
|
147
|
+
if (raw == null || raw === '') return null
|
|
148
|
+
return raw.includes('\\n') ? raw.replaceAll('\\n', '\n') : raw
|
|
149
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ManagedDb, StoredUser } from './db.ts'
|
|
2
|
+
|
|
3
|
+
export type RepoScope = { repoId: number; path: string | null }
|
|
4
|
+
type Content = { repoId: number | null; repoDirectory?: string }
|
|
5
|
+
|
|
6
|
+
// Read grants for each request: neither the manager role nor a cached page
|
|
7
|
+
// confers repository access. Bundles have repository scope; reports also have
|
|
8
|
+
// a directory scope. Unassigned content is only available to administrators.
|
|
9
|
+
export async function contentAccess(db: Pick<ManagedDb, 'listRepoScopesForUser'>, user: StoredUser) {
|
|
10
|
+
const scopes = user.role === 'admin' ? null : await db.listRepoScopesForUser(user.id)
|
|
11
|
+
const bundle = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId)
|
|
12
|
+
const report = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId
|
|
13
|
+
&& (!scope.path || item.repoDirectory === scope.path || item.repoDirectory?.startsWith(scope.path + '/')))
|
|
14
|
+
return { scopes, bundle, report }
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Token helpers for the managed auth server. The raw session token lives ONLY
|
|
2
|
+
// in the cookie; the DB row id is its SHA-256 (see db.ts / session.ts), so a
|
|
3
|
+
// DB read can't reconstruct a live cookie. No HMAC pepper is needed — the
|
|
4
|
+
// tokens are 256-bit random, not guessable or reversible from their hash.
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
// 256-bit URL-safe random token: the session cookie value and the OAuth CSRF
|
|
8
|
+
// `state` nonce.
|
|
9
|
+
export function randomToken(): string {
|
|
10
|
+
return randomBytes(32).toString('base64url')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// At-rest id for a session cookie token (stored as the managed_session PK).
|
|
14
|
+
export function hashToken(token: string): string {
|
|
15
|
+
return createHash('sha256').update(token, 'utf8').digest('base64url')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Constant-time equality for CSRF / OAuth-state checks. A length mismatch
|
|
19
|
+
// short-circuits (the length of these fixed-width tokens isn't a secret).
|
|
20
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
21
|
+
const ab = Buffer.from(a, 'utf8')
|
|
22
|
+
const bb = Buffer.from(b, 'utf8')
|
|
23
|
+
if (ab.length !== bb.length) return false
|
|
24
|
+
return timingSafeEqual(ab, bb)
|
|
25
|
+
}
|