@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.20",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -31,15 +31,20 @@
|
|
|
31
31
|
"exports": {
|
|
32
32
|
"./reap": "./api/reap.ts",
|
|
33
33
|
"./report": "./report/index.js",
|
|
34
|
-
"./server": "./server
|
|
34
|
+
"./server": "./server.ts",
|
|
35
|
+
"./server-e2e": "./server-e2e/index.ts",
|
|
35
36
|
"./strip-types-loader": "./strip-types-loader.js",
|
|
36
37
|
"./package.json": "./package.json"
|
|
37
38
|
},
|
|
38
39
|
"bin": {
|
|
39
|
-
"triage-server": "
|
|
40
|
+
"triage-server": "cli.js",
|
|
41
|
+
"triage-e2e-server": "server-e2e/cli.js",
|
|
42
|
+
"triage-managed-server": "server-managed/cli.js"
|
|
40
43
|
},
|
|
41
44
|
"files": [
|
|
42
45
|
"api/reap.ts",
|
|
46
|
+
"cli.js",
|
|
47
|
+
"server.ts",
|
|
43
48
|
"server-e2e/app.ts",
|
|
44
49
|
"server-e2e/auth.ts",
|
|
45
50
|
"server-e2e/bus-receiver.ts",
|
|
@@ -81,6 +86,56 @@
|
|
|
81
86
|
"server-e2e/validation.ts",
|
|
82
87
|
"server-e2e/ws-server.ts",
|
|
83
88
|
"server-e2e/config.example.json",
|
|
89
|
+
"server-managed/activity.ts",
|
|
90
|
+
"server-managed/avatar-store.ts",
|
|
91
|
+
"server-managed/blob-store.ts",
|
|
92
|
+
"server-managed/blob-vercel.ts",
|
|
93
|
+
"server-managed/brotli.ts",
|
|
94
|
+
"server-managed/bundle-cache.ts",
|
|
95
|
+
"server-managed/bundle-catalog.ts",
|
|
96
|
+
"server-managed/bundle-store.ts",
|
|
97
|
+
"server-managed/bundle-summary-cache.ts",
|
|
98
|
+
"server-managed/bundle.ts",
|
|
99
|
+
"server-managed/cache-storage.ts",
|
|
100
|
+
"server-managed/cli.js",
|
|
101
|
+
"server-managed/combined.ts",
|
|
102
|
+
"server-managed/comments.ts",
|
|
103
|
+
"server-managed/config.ts",
|
|
104
|
+
"server-managed/content-access.ts",
|
|
105
|
+
"server-managed/crypto.ts",
|
|
106
|
+
"server-managed/db-methods.ts",
|
|
107
|
+
"server-managed/db-neon.ts",
|
|
108
|
+
"server-managed/db-schema.ts",
|
|
109
|
+
"server-managed/db-table-names.ts",
|
|
110
|
+
"server-managed/db.ts",
|
|
111
|
+
"server-managed/github-app.ts",
|
|
112
|
+
"server-managed/github-metadata.ts",
|
|
113
|
+
"server-managed/github-oauth.ts",
|
|
114
|
+
"server-managed/github-pulls.ts",
|
|
115
|
+
"server-managed/http-response.ts",
|
|
116
|
+
"server-managed/http.ts",
|
|
117
|
+
"server-managed/import-triage.ts",
|
|
118
|
+
"server-managed/index.ts",
|
|
119
|
+
"server-managed/public-workspace.ts",
|
|
120
|
+
"server-managed/repo-path.ts",
|
|
121
|
+
"server-managed/report-migration.ts",
|
|
122
|
+
"server-managed/report-query.ts",
|
|
123
|
+
"server-managed/report-response.ts",
|
|
124
|
+
"server-managed/report-sources.ts",
|
|
125
|
+
"server-managed/repository-discovery.ts",
|
|
126
|
+
"server-managed/repository-policy.ts",
|
|
127
|
+
"server-managed/session.ts",
|
|
128
|
+
"server-managed/slugs.ts",
|
|
129
|
+
"server-managed/sql-postgres.ts",
|
|
130
|
+
"server-managed/sql.ts",
|
|
131
|
+
"server-managed/static.ts",
|
|
132
|
+
"server-managed/storage.ts",
|
|
133
|
+
"server-managed/team-catalog.ts",
|
|
134
|
+
"server-managed/team-feed.ts",
|
|
135
|
+
"server-managed/team-reports.ts",
|
|
136
|
+
"server-managed/triage-response.ts",
|
|
137
|
+
"server-managed/uploads.ts",
|
|
138
|
+
"server-managed/workspace-shares.ts",
|
|
84
139
|
"server-common/database-config.ts",
|
|
85
140
|
"server-common/initialize.ts",
|
|
86
141
|
"server-common/reap.ts",
|
|
@@ -111,11 +166,35 @@
|
|
|
111
166
|
"report/src/utf8.js",
|
|
112
167
|
"report/src/write-md-finding.js",
|
|
113
168
|
"report/src/write-md.js",
|
|
169
|
+
"common/bundle-metadata.d.ts",
|
|
170
|
+
"common/bundle-metadata.js",
|
|
171
|
+
"common/bundle-reasons.d.ts",
|
|
172
|
+
"common/bundle-reasons.js",
|
|
173
|
+
"common/bundle-sources.d.ts",
|
|
174
|
+
"common/bundle-sources.js",
|
|
175
|
+
"common/bundle-stats.js",
|
|
176
|
+
"common/bundle-tabs.js",
|
|
177
|
+
"common/code-language.js",
|
|
178
|
+
"common/default-scan-models.ts",
|
|
179
|
+
"common/finding-id.js",
|
|
180
|
+
"common/github-pr.ts",
|
|
181
|
+
"common/managed/comments.ts",
|
|
182
|
+
"common/managed/permissions.ts",
|
|
183
|
+
"common/managed/report-content.ts",
|
|
184
|
+
"common/managed/report-filter.ts",
|
|
185
|
+
"common/managed/roles.ts",
|
|
186
|
+
"common/managed/routes.d.ts",
|
|
187
|
+
"common/managed/routes.js",
|
|
188
|
+
"common/managed/scan-models.ts",
|
|
189
|
+
"common/managed/triage.ts",
|
|
114
190
|
"common/save-error-reason.ts",
|
|
115
191
|
"common/server-info.ts",
|
|
116
192
|
"common/scan-server.ts",
|
|
117
193
|
"common/utf8.d.ts",
|
|
118
194
|
"common/utf8.js",
|
|
195
|
+
"client/finding-link.js",
|
|
196
|
+
"client/linked-findings.d.ts",
|
|
197
|
+
"client/linked-findings.js",
|
|
119
198
|
"out/brotli-fallback.js",
|
|
120
199
|
"out/client-managed.js",
|
|
121
200
|
"out/client-managed-import.js",
|
package/server-e2e/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Executable entry for the `triage-server` bin.
|
|
2
|
+
// Executable entry for the `triage-e2e-server` bin.
|
|
3
3
|
//
|
|
4
4
|
// Node refuses to strip TypeScript types from files under node_modules
|
|
5
5
|
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so once this package is
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Persistent workspace activity. Uploads are recorded in the same transaction
|
|
2
|
+
// as their metadata; triage reuses its existing immutable trail, including
|
|
3
|
+
// legacy entries. Never copy annotation bodies or credentials into this feed.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
6
|
+
import type { ManagedSql } from './sql.ts'
|
|
7
|
+
|
|
8
|
+
export interface ActivityInput {
|
|
9
|
+
kind: 'access' | 'repository' | 'visibility' | 'delete'
|
|
10
|
+
actor: string
|
|
11
|
+
actorId?: string | null
|
|
12
|
+
action: string
|
|
13
|
+
repo?: string | null
|
|
14
|
+
reportId?: string | null
|
|
15
|
+
bundleId?: string | null
|
|
16
|
+
repoId?: number | null
|
|
17
|
+
repoDirectory?: string | null
|
|
18
|
+
report?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ActivityContext {
|
|
22
|
+
finding: string
|
|
23
|
+
reportId: string
|
|
24
|
+
report: string
|
|
25
|
+
repo: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ActivityEntry = {
|
|
29
|
+
id: string; kind: string; actor: string | null; action: string
|
|
30
|
+
repo: string | null; reportId: string | null; report: string | null
|
|
31
|
+
finding: string | null; at: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActivityQuery {
|
|
35
|
+
page: number
|
|
36
|
+
limit: number
|
|
37
|
+
kind: string
|
|
38
|
+
query: string
|
|
39
|
+
repo?: string
|
|
40
|
+
actor?: string
|
|
41
|
+
// null = admin; [] = no accessible findings. Context is supplied by the
|
|
42
|
+
// server, never by the client. It also replaces potentially private context
|
|
43
|
+
// from an old report carrying the same globally shared finding.
|
|
44
|
+
contexts: ActivityContext[] | null
|
|
45
|
+
userId?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ActivityFilters {
|
|
49
|
+
repos: string[]
|
|
50
|
+
users: { id: string; login: string; detail: string | null }[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ActivityStore {
|
|
54
|
+
recordActivity(entry: ActivityInput, at: number): Promise<void>
|
|
55
|
+
listActivityReports(userId: string): Promise<Omit<ActivityContext, 'finding'>[]>
|
|
56
|
+
listActivity(query: ActivityQuery): Promise<{ history: ActivityEntry[]; total: number; page: number; limit: number; filters: ActivityFilters }>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const triageFields = `'triage:' || e.seq AS id, 'triage' AS kind,
|
|
60
|
+
COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId,
|
|
61
|
+
CASE WHEN e.color IS NULL AND e.triage IS NULL AND e.comment IS NULL AND e.fix IS NULL AND e.flagged IS NULL
|
|
62
|
+
THEN 'cleared triage' ELSE 'updated triage' END AS action`
|
|
63
|
+
const triage = `SELECT ${triageFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
|
|
64
|
+
FROM managed_finding_triage_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
|
|
65
|
+
const commentFields = `'comment:' || e.seq AS id, 'triage' AS kind,
|
|
66
|
+
COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId, e.action`
|
|
67
|
+
const comments = `SELECT ${commentFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
|
|
68
|
+
FROM managed_finding_comment_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
|
|
69
|
+
const adminSource = `${triage} UNION ALL ${comments} UNION ALL
|
|
70
|
+
SELECT id, kind, actor, actor_id AS actorId, action, repo, report_id AS reportId, report, NULL AS finding, at FROM managed_activity`
|
|
71
|
+
// Keep the finite set of accessible findings outside the indexed event
|
|
72
|
+
// lookup. Scanning all events against json_each for every row is quadratic.
|
|
73
|
+
const managerSource = `SELECT ${triageFields},
|
|
74
|
+
json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
|
|
75
|
+
json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
|
|
76
|
+
FROM json_each(:contexts) c
|
|
77
|
+
CROSS JOIN managed_finding_triage_event e ON e.finding_id = json_extract(c.value, '$.finding')
|
|
78
|
+
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
79
|
+
UNION ALL SELECT ${commentFields},
|
|
80
|
+
json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
|
|
81
|
+
json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
|
|
82
|
+
FROM json_each(:contexts) c
|
|
83
|
+
CROSS JOIN managed_finding_comment_event e ON e.finding_id = json_extract(c.value, '$.finding')
|
|
84
|
+
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
85
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
|
|
86
|
+
CASE WHEN a.kind = 'repository' THEN 'changed a report repository assignment' ELSE a.action END AS action,
|
|
87
|
+
p.full_name AS repo, r.id AS reportId, r.filename AS report, NULL AS finding, a.at
|
|
88
|
+
FROM managed_activity a JOIN managed_report r ON r.id = a.report_id
|
|
89
|
+
LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id
|
|
90
|
+
WHERE EXISTS (
|
|
91
|
+
SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
92
|
+
WHERE tu.user_id = :userId AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')}
|
|
93
|
+
)
|
|
94
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
|
|
95
|
+
CASE WHEN a.kind = 'repository' THEN 'changed a bundle repository assignment' ELSE a.action END AS action,
|
|
96
|
+
p.full_name AS repo, NULL AS reportId, b.filename AS report, NULL AS finding, a.at
|
|
97
|
+
FROM managed_activity a JOIN managed_bundle b ON b.id = a.bundle_id
|
|
98
|
+
LEFT JOIN managed_selected_repo p ON p.repo_id = b.repo_id
|
|
99
|
+
WHERE a.report_id IS NULL AND EXISTS (
|
|
100
|
+
SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
101
|
+
WHERE tu.user_id = :userId AND tr.repo_id = b.repo_id AND ${withinTeamPath('b.repo_directory')}
|
|
102
|
+
)
|
|
103
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId, a.action, a.repo, a.report_id AS reportId,
|
|
104
|
+
a.report, NULL AS finding, a.at FROM managed_activity a
|
|
105
|
+
WHERE a.kind = 'delete' AND (a.report_id IS NOT NULL OR a.bundle_id IS NOT NULL)
|
|
106
|
+
AND NOT EXISTS (SELECT 1 FROM managed_report WHERE id = a.report_id)
|
|
107
|
+
AND NOT EXISTS (SELECT 1 FROM managed_bundle WHERE id = a.bundle_id)
|
|
108
|
+
AND EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
109
|
+
WHERE tu.user_id = :userId AND tr.repo_id = a.repo_id
|
|
110
|
+
AND ${withinTeamPath('a.repo_directory')})`
|
|
111
|
+
|
|
112
|
+
export const ACTIVITY_SCHEMA = `
|
|
113
|
+
CREATE TABLE IF NOT EXISTS managed_activity (
|
|
114
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, actor TEXT, action TEXT NOT NULL,
|
|
115
|
+
repo TEXT, report_id TEXT, report TEXT, at INTEGER NOT NULL,
|
|
116
|
+
bundle_id TEXT, repo_id INTEGER, repo_directory TEXT, actor_id TEXT
|
|
117
|
+
) STRICT;
|
|
118
|
+
CREATE INDEX IF NOT EXISTS managed_activity_at_idx ON managed_activity(at, id);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS finding_triage_event_at_idx ON managed_finding_triage_event(at, seq);
|
|
120
|
+
`
|
|
121
|
+
|
|
122
|
+
export function initActivityMethods(db: DatabaseSync): void {
|
|
123
|
+
db.exec(ACTIVITY_SCHEMA)
|
|
124
|
+
migrateActivityScope(db)
|
|
125
|
+
db.exec('CREATE INDEX IF NOT EXISTS managed_activity_actor_at_idx ON managed_activity(actor_id, at)')
|
|
126
|
+
// Stable upload IDs make backfill idempotent, including after restarts.
|
|
127
|
+
// Snapshots deliberately have no cascading FKs: deletion is itself activity.
|
|
128
|
+
for (const type of ['report', 'bundle']) {
|
|
129
|
+
const columns = `id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id`
|
|
130
|
+
const values = (alias: string) => `'${type}-upload:' || ${alias}.id, 'upload',
|
|
131
|
+
COALESCE(${alias}.uploaded_by_login, (SELECT login FROM managed_user WHERE id = ${alias}.uploaded_by)), 'uploaded a ${type}',
|
|
132
|
+
(SELECT full_name FROM managed_selected_repo WHERE repo_id = ${alias}.repo_id),
|
|
133
|
+
${type === 'report' ? `${alias}.id` : 'NULL'}, ${alias}.filename, ${alias}.uploaded_at,
|
|
134
|
+
${type === 'bundle' ? `${alias}.id` : 'NULL'}, ${alias}.uploaded_by`
|
|
135
|
+
db.exec(`
|
|
136
|
+
CREATE TRIGGER IF NOT EXISTS managed_${type}_activity AFTER INSERT ON managed_${type}
|
|
137
|
+
BEGIN INSERT INTO managed_activity (${columns}) SELECT ${values('NEW')}; END;
|
|
138
|
+
INSERT OR IGNORE INTO managed_activity (${columns}) SELECT ${values('source')} FROM managed_${type} source;
|
|
139
|
+
`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function activityMethods(db: ManagedSql): ActivityStore {
|
|
144
|
+
const insert = db.prepare(`INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, repo_id, repo_directory, actor_id)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
146
|
+
const admin = activityStatements(db, adminSource)
|
|
147
|
+
const manager = activityStatements(db, managerSource)
|
|
148
|
+
const reports = db.prepare(`SELECT r.id AS reportId, r.filename AS report, p.full_name AS repo
|
|
149
|
+
FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id
|
|
150
|
+
WHERE EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
151
|
+
WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')})
|
|
152
|
+
ORDER BY r.uploaded_at DESC, r.id`)
|
|
153
|
+
return {
|
|
154
|
+
async recordActivity(entry, at) {
|
|
155
|
+
await insert.run(randomUUID(), entry.kind, entry.actor, entry.action, entry.repo ?? null, entry.reportId ?? null, entry.report ?? null, at,
|
|
156
|
+
entry.bundleId ?? null, entry.repoId ?? null, entry.repoDirectory ?? null, entry.actorId ?? null)
|
|
157
|
+
},
|
|
158
|
+
async listActivityReports(userId) {
|
|
159
|
+
return (await reports.all(userId)) as Omit<ActivityContext, 'finding'>[]
|
|
160
|
+
},
|
|
161
|
+
async listActivity({ page, limit, kind, query, repo = '', actor = '', contexts, userId }) {
|
|
162
|
+
const stmts = contexts == null ? admin : manager
|
|
163
|
+
const scope = contexts == null ? {} : { contexts: JSON.stringify(contexts), userId: userId ?? '' }
|
|
164
|
+
const params = { ...scope, kind, query, repo, actor }
|
|
165
|
+
const { total } = (await stmts.count.get(params)) as { total: number }
|
|
166
|
+
const currentPage = Math.min(page, Math.max(1, Math.ceil(total / limit)))
|
|
167
|
+
const history = (await stmts.rows.all({ ...params, limit, offset: (currentPage - 1) * limit })) as ActivityEntry[]
|
|
168
|
+
const filters: ActivityFilters = {
|
|
169
|
+
repos: ((await stmts.repos.all(scope)) as { repo: string }[]).map(row => row.repo),
|
|
170
|
+
users: ((await stmts.users.all(scope)) as ActivityFilters['users']).map(row => ({ id: row.id, login: row.login, detail: row.detail })),
|
|
171
|
+
}
|
|
172
|
+
return { history, total, page: currentPage, limit, filters }
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Both rows and selector choices come from the authorized source. Keep legacy
|
|
178
|
+
// login-only actors separate: a historical login is not proof of user identity.
|
|
179
|
+
function activityStatements(db: ManagedSql, source: string) {
|
|
180
|
+
const activity = `WITH events AS (${source}), activity AS (
|
|
181
|
+
SELECT events.*, COALESCE('user:' || actorId, 'legacy:' || actor) AS actorKey FROM events
|
|
182
|
+
)`
|
|
183
|
+
const filtered = `${activity} SELECT id, kind, actor, action, repo, reportId, report, finding, at FROM activity
|
|
184
|
+
WHERE (:kind = 'all' OR kind = :kind)
|
|
185
|
+
AND (:repo = '' OR repo = :repo) AND (:actor = '' OR actorKey = :actor)
|
|
186
|
+
AND (:query = '' OR instr(lower(coalesce(actor, '') || ' ' || action || ' ' ||
|
|
187
|
+
coalesce(repo, '') || ' ' || coalesce(report, '') || ' ' || coalesce(finding, '')), lower(:query)) > 0)`
|
|
188
|
+
return {
|
|
189
|
+
count: db.prepare(`SELECT count(*) AS total FROM (${filtered}) AS filtered`),
|
|
190
|
+
rows: db.prepare(`${filtered} ORDER BY at DESC,
|
|
191
|
+
CASE WHEN kind = 'triage' THEN CAST(substr(id, instr(id, ':') + 1) AS INTEGER) ELSE 0 END DESC,
|
|
192
|
+
id DESC LIMIT :limit OFFSET :offset`),
|
|
193
|
+
repos: db.prepare(`${activity} SELECT DISTINCT repo FROM activity WHERE repo IS NOT NULL AND repo <> '' ORDER BY repo`),
|
|
194
|
+
users: db.prepare(`${activity} SELECT a.actorKey AS id, COALESCE(u.login, a.actor, a.actorId) AS login,
|
|
195
|
+
CASE WHEN a.actorId IS NULL THEN 'Legacy actor' ELSE NULL END AS detail FROM (
|
|
196
|
+
SELECT actorKey, actorId, actor, ROW_NUMBER() OVER (PARTITION BY actorKey ORDER BY at DESC, id DESC) AS position
|
|
197
|
+
FROM activity WHERE actorKey IS NOT NULL AND actorKey <> 'legacy:'
|
|
198
|
+
) a LEFT JOIN managed_user u ON u.id = a.actorId WHERE a.position = 1 ORDER BY login, a.actorKey`),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Literal path comparison preserves separators, case and SQL wildcard chars.
|
|
203
|
+
function withinTeamPath(path: string): string {
|
|
204
|
+
return `(tr.path IS NULL OR tr.path = '' OR ${path} = tr.path OR substr(${path}, 1, length(tr.path) + 1) = tr.path || '/')`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function migrateActivityScope(db: DatabaseSync): void {
|
|
208
|
+
const columns = new Set((db.prepare('PRAGMA table_info(managed_activity)').all() as { name: string }[]).map(column => column.name))
|
|
209
|
+
const missing = [['bundle_id', 'TEXT'], ['repo_id', 'INTEGER'], ['repo_directory', 'TEXT'], ['actor_id', 'TEXT']].filter(([name]) => !columns.has(name!))
|
|
210
|
+
if (missing.length === 0) return
|
|
211
|
+
db.exec('BEGIN')
|
|
212
|
+
try {
|
|
213
|
+
for (const [name, type] of missing) db.exec(`ALTER TABLE managed_activity ADD COLUMN ${name} ${type}`)
|
|
214
|
+
if (!columns.has('bundle_id') || !columns.has('actor_id')) {
|
|
215
|
+
db.exec('DROP TRIGGER IF EXISTS managed_report_activity; DROP TRIGGER IF EXISTS managed_bundle_activity;')
|
|
216
|
+
}
|
|
217
|
+
if (!columns.has('bundle_id')) db.exec("UPDATE managed_activity SET bundle_id = substr(id, 15) WHERE id LIKE 'bundle-upload:%'")
|
|
218
|
+
if (!columns.has('actor_id')) {
|
|
219
|
+
// Existing upload targets retain a reliable uploader identity. Do not
|
|
220
|
+
// guess identities from historical logins: names can change or be reused.
|
|
221
|
+
for (const type of ['report', 'bundle']) {
|
|
222
|
+
db.exec(`UPDATE managed_activity SET actor_id = (
|
|
223
|
+
SELECT uploaded_by FROM managed_${type} WHERE id = substr(managed_activity.id, 15)
|
|
224
|
+
) WHERE id LIKE '${type}-upload:%'`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Legacy deletions/assignments without a durable target/scope remain
|
|
228
|
+
// admin-only; neither filenames nor repository names authorize access.
|
|
229
|
+
db.exec('COMMIT')
|
|
230
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
231
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Avatar cache for the managed server. The user's GitHub avatar is fetched once
|
|
2
|
+
// at login (see github-oauth.ts) and served same-origin from GET
|
|
3
|
+
// /api/auth/avatar — the app's CSP is `img-src 'self'`, so the GitHub-hosted
|
|
4
|
+
// original can't be loaded by the page directly.
|
|
5
|
+
//
|
|
6
|
+
// The disk backend uses a dir beside SQLite; blob-vercel.ts implements the
|
|
7
|
+
// same AvatarStore interface for remote storage. Keyed by the user's opaque `id`.
|
|
8
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import type { Buffer } from 'node:buffer'
|
|
11
|
+
|
|
12
|
+
export interface CachedAvatar {
|
|
13
|
+
contentType: string
|
|
14
|
+
bytes: Buffer
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AvatarStore {
|
|
18
|
+
put(id: string, contentType: string, bytes: Buffer): Promise<void>
|
|
19
|
+
get(id: string): Promise<CachedAvatar | null>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The user id is a v4 uuid (crypto.randomUUID) — lowercase hex + dashes, no
|
|
23
|
+
// path separators. Validated anyway so a crafted id can't escape the store dir.
|
|
24
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
25
|
+
|
|
26
|
+
export function createDiskAvatarStore(dir: string): AvatarStore {
|
|
27
|
+
function pathFor(id: string): string {
|
|
28
|
+
if (!ID_RE.test(id)) throw new Error('invalid avatar id')
|
|
29
|
+
return join(dir, id)
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
async put(id, contentType, bytes) {
|
|
33
|
+
const p = pathFor(id)
|
|
34
|
+
await mkdir(dirname(p), { recursive: true })
|
|
35
|
+
// Bytes first, then the content-type sidecar — a crash between the two
|
|
36
|
+
// leaves a typeless blob that `get` treats as missing (re-fetched next
|
|
37
|
+
// login) rather than serving with a wrong/absent type.
|
|
38
|
+
await writeFile(p, bytes)
|
|
39
|
+
await writeFile(`${p}.type`, contentType, 'utf8')
|
|
40
|
+
},
|
|
41
|
+
async get(id) {
|
|
42
|
+
try {
|
|
43
|
+
const p = pathFor(id)
|
|
44
|
+
const [bytes, contentType] = await Promise.all([readFile(p), readFile(`${p}.type`, 'utf8')])
|
|
45
|
+
return { bytes, contentType: contentType.trim() || 'application/octet-stream' }
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Disk byte-store for the managed server's stored blobs (uploaded reports and
|
|
2
|
+
// bundles). Unlike the e2e relay (which only ever holds opaque client-encrypted
|
|
3
|
+
// blobs), a managed server is TRUSTED and stores the bytes in the clear so it
|
|
4
|
+
// can operate on them — the metadata + attribution live in the DB, the bytes
|
|
5
|
+
// live here, keyed by the blob's opaque `id`.
|
|
6
|
+
//
|
|
7
|
+
// The disk backend uses a dir beside SQLite (data/reports/<uuid> or
|
|
8
|
+
// data/bundles/<uuid>); blob-vercel.ts implements the same interface remotely. Bytes only — the
|
|
9
|
+
// content-type / filename / integrity ride the DB row, so there's no sidecar.
|
|
10
|
+
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import type { Buffer } from 'node:buffer'
|
|
13
|
+
import type { Readable } from 'node:stream'
|
|
14
|
+
|
|
15
|
+
export interface OpenedBlob {
|
|
16
|
+
// Remote streams may omit their size; callers then omit Content-Length.
|
|
17
|
+
size: number | null
|
|
18
|
+
stream: Readable
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BlobStore {
|
|
22
|
+
put(id: string, bytes: Buffer): Promise<void>
|
|
23
|
+
get(id: string): Promise<Buffer | null>
|
|
24
|
+
open(id: string): Promise<OpenedBlob | null>
|
|
25
|
+
delete(id: string): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The blob id is a v4 uuid (crypto.randomUUID) — lowercase hex + dashes, no
|
|
29
|
+
// path separators. Validated anyway so a crafted id can't escape the store dir.
|
|
30
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
31
|
+
|
|
32
|
+
export function createDiskBlobStore(dir: string, suffix = ''): BlobStore {
|
|
33
|
+
function pathFor(id: string): string {
|
|
34
|
+
if (!ID_RE.test(id)) throw new Error('invalid blob id')
|
|
35
|
+
return join(dir, id + suffix)
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
async put(id, bytes) {
|
|
39
|
+
const p = pathFor(id)
|
|
40
|
+
await mkdir(dirname(p), { recursive: true })
|
|
41
|
+
await writeFile(p, bytes)
|
|
42
|
+
},
|
|
43
|
+
async get(id) {
|
|
44
|
+
try {
|
|
45
|
+
return await readFile(pathFor(id))
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
async open(id) {
|
|
51
|
+
let file
|
|
52
|
+
try { file = await open(pathFor(id), 'r') }
|
|
53
|
+
catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err }
|
|
54
|
+
try {
|
|
55
|
+
const info = await file.stat()
|
|
56
|
+
// Remain paused until piped. HEAD can close without reading the body.
|
|
57
|
+
return { size: info.size, stream: file.createReadStream() }
|
|
58
|
+
} catch (err) { await file.close(); throw err }
|
|
59
|
+
},
|
|
60
|
+
async delete(id) {
|
|
61
|
+
// `force` so a missing file (already gone) is a no-op, not a throw — the
|
|
62
|
+
// metadata row is the source of truth for whether a blob "exists".
|
|
63
|
+
await rm(pathFor(id), { force: true })
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { Readable } from 'node:stream'
|
|
3
|
+
import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
|
|
4
|
+
import type { AvatarStore } from './avatar-store.ts'
|
|
5
|
+
import type { BlobStore } from './blob-store.ts'
|
|
6
|
+
import { type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
7
|
+
import type { BundleCacheStorage } from './bundle-cache.ts'
|
|
8
|
+
import { createBundleStore } from './bundle-store.ts'
|
|
9
|
+
|
|
10
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
11
|
+
function validate(id: string): string {
|
|
12
|
+
if (!ID.test(id)) throw new Error('Invalid managed blob id')
|
|
13
|
+
return id
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ListedBlob = Awaited<ReturnType<VercelBlobSdk['list']>>['blobs'][number]
|
|
17
|
+
|
|
18
|
+
async function deletePrefix(blobs: VercelBlobSdk, token: string, prefix: string, remove: (path: string) => Promise<void>, matches = (_blob: ListedBlob) => true): Promise<void> {
|
|
19
|
+
const cursors = new Set<string>(), paths = new Set<string>()
|
|
20
|
+
let cursor: string | undefined
|
|
21
|
+
// Finish listing before deleting so pagination cannot skip objects as the
|
|
22
|
+
// listed set shrinks. Callers supply a trailing slash to isolate a namespace.
|
|
23
|
+
do {
|
|
24
|
+
const page = await blobs.list({ token, prefix, ...(cursor ? { cursor } : {}) })
|
|
25
|
+
for (const blob of page.blobs) if (blob.pathname.startsWith(prefix) && matches(blob)) paths.add(blob.pathname)
|
|
26
|
+
if (!page.hasMore) break
|
|
27
|
+
if (!page.cursor || cursors.has(page.cursor)) throw new Error('Invalid blob pagination')
|
|
28
|
+
cursor = page.cursor
|
|
29
|
+
cursors.add(cursor)
|
|
30
|
+
} while (cursor)
|
|
31
|
+
for (const path of paths) await remove(path)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createBlobAvatarStore(avatarBlobs: BlobStore): AvatarStore {
|
|
35
|
+
return {
|
|
36
|
+
put(id, contentType, bytes) {
|
|
37
|
+
const type = contentType.split(';', 1)[0]!.trim()
|
|
38
|
+
if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
|
|
39
|
+
return avatarBlobs.put(id, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
|
|
40
|
+
},
|
|
41
|
+
async get(id) {
|
|
42
|
+
const bytes = await avatarBlobs.get(id)
|
|
43
|
+
if (!bytes) return null
|
|
44
|
+
const split = bytes.indexOf(10)
|
|
45
|
+
if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
|
|
46
|
+
return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSdk) {
|
|
52
|
+
const blobs = sdk ?? await loadVercelBlobSdk()
|
|
53
|
+
const options = { token, access: 'private' as const, useCache: false }
|
|
54
|
+
async function put(path: string, bytes: Buffer) {
|
|
55
|
+
await blobs.put(path, bytes, { token, access: 'private', addRandomSuffix: false,
|
|
56
|
+
allowOverwrite: true, contentType: 'application/octet-stream', cacheControlMaxAge: 60 })
|
|
57
|
+
}
|
|
58
|
+
async function open(path: string) {
|
|
59
|
+
try {
|
|
60
|
+
const result = await blobs.get(path, options)
|
|
61
|
+
if (!result) return null
|
|
62
|
+
if (result.statusCode !== 200 || !result.stream) throw new Error('Unexpected blob response')
|
|
63
|
+
// Private SDK GETs may report zero for a nonempty body. Stream without
|
|
64
|
+
// Content-Length in that case so HTTP clients do not truncate the body.
|
|
65
|
+
const size = result.blob.size === 0 ? null : result.blob.size
|
|
66
|
+
return { size, stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
|
|
67
|
+
} catch (err) { if (isNotFound(err)) return null; throw err }
|
|
68
|
+
}
|
|
69
|
+
async function get(path: string) {
|
|
70
|
+
const result = await open(path)
|
|
71
|
+
if (!result) return null
|
|
72
|
+
const parts: Buffer[] = []
|
|
73
|
+
for await (const chunk of result.stream) parts.push(Buffer.from(chunk))
|
|
74
|
+
return Buffer.concat(parts)
|
|
75
|
+
}
|
|
76
|
+
async function remove(path: string) {
|
|
77
|
+
try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err)) throw err }
|
|
78
|
+
}
|
|
79
|
+
function store(name: string, suffix = ''): BlobStore {
|
|
80
|
+
const path = (id: string) => `.managed/${name}/${validate(id)}${suffix}`
|
|
81
|
+
return {
|
|
82
|
+
async put(id, bytes) { await put(path(id), bytes) },
|
|
83
|
+
async get(id) { return await get(path(id)) },
|
|
84
|
+
open: id => open(path(id)),
|
|
85
|
+
async delete(id) { await remove(path(id)) },
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const avatarStore = createBlobAvatarStore(store('avatars'))
|
|
89
|
+
const cachePath = (id: string, file: string) => `.managed/cache/bundles/${validate(id)}/${file}`
|
|
90
|
+
const cacheStorage: BundleCacheStorage = {
|
|
91
|
+
async exists(id, file) {
|
|
92
|
+
try { await blobs.head(cachePath(id, file), { token }); return true }
|
|
93
|
+
catch (err) { if (isNotFound(err)) return false; throw err }
|
|
94
|
+
},
|
|
95
|
+
put: (id, file, bytes) => put(cachePath(id, file), bytes),
|
|
96
|
+
async open(id, file) {
|
|
97
|
+
const result = await open(cachePath(id, file))
|
|
98
|
+
if (!result) throw new Error('Bundle cache unavailable')
|
|
99
|
+
return result
|
|
100
|
+
},
|
|
101
|
+
delete: id => deletePrefix(blobs, token, cachePath(id, ''), remove),
|
|
102
|
+
}
|
|
103
|
+
// Staging uploads are never published. An interrupted browser leaves only
|
|
104
|
+
// parts here; the authenticated cron removes them after a full day.
|
|
105
|
+
function reapUploads(now = Date.now()) {
|
|
106
|
+
return deletePrefix(blobs, token, '.managed/uploads/', remove,
|
|
107
|
+
blob => new Date(blob.uploadedAt ?? now).getTime() < now - 86_400_000)
|
|
108
|
+
}
|
|
109
|
+
const sourcesPath = (key: string) => `.managed/cache/report-sources/${validateCacheKey(key)}`
|
|
110
|
+
const reportSourcesStorage: CacheStorage = {
|
|
111
|
+
async exists(key) {
|
|
112
|
+
try { await blobs.head(sourcesPath(key), { token }); return true }
|
|
113
|
+
catch (err) { if (isNotFound(err)) return false; throw err }
|
|
114
|
+
},
|
|
115
|
+
put: (key, bytes) => put(sourcesPath(key), bytes),
|
|
116
|
+
async open(key) {
|
|
117
|
+
const result = await open(sourcesPath(key))
|
|
118
|
+
if (!result) throw new Error('Report sources cache unavailable')
|
|
119
|
+
return result
|
|
120
|
+
},
|
|
121
|
+
delete: prefix => deletePrefix(blobs, token, `${sourcesPath(prefix)}/`, remove),
|
|
122
|
+
}
|
|
123
|
+
const bundleStore = createBundleStore(store('bundles'), store('bundles', '.map.br'))
|
|
124
|
+
return { reportStore: store('reports'), bundleStore, uploadStore: store('uploads'), avatarStore, cacheStorage, reportSourcesStorage, reapUploads }
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { brotliCompress, constants } from 'node:zlib'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { Buffer } from 'node:buffer'
|
|
4
|
+
|
|
5
|
+
const compress = promisify(brotliCompress)
|
|
6
|
+
|
|
7
|
+
// Quality 4 avoids Brotli's expensive default (11) for uploads and cold caches.
|
|
8
|
+
export function encodeBrotli(bytes: Uint8Array): Promise<Buffer> {
|
|
9
|
+
return compress(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
10
|
+
}
|