@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.
Files changed (81) hide show
  1. package/cli.js +6 -0
  2. package/client/finding-link.js +305 -0
  3. package/client/linked-findings.d.ts +1 -0
  4. package/client/linked-findings.js +111 -0
  5. package/common/bundle-metadata.d.ts +10 -0
  6. package/common/bundle-metadata.js +177 -0
  7. package/common/bundle-reasons.d.ts +2 -0
  8. package/common/bundle-reasons.js +21 -0
  9. package/common/bundle-sources.d.ts +3 -0
  10. package/common/bundle-sources.js +284 -0
  11. package/common/bundle-stats.js +41 -0
  12. package/common/bundle-tabs.js +1 -0
  13. package/common/code-language.js +36 -0
  14. package/common/default-scan-models.ts +30 -0
  15. package/common/finding-id.js +47 -0
  16. package/common/github-pr.ts +56 -0
  17. package/common/managed/comments.ts +34 -0
  18. package/common/managed/permissions.ts +35 -0
  19. package/common/managed/report-content.ts +42 -0
  20. package/common/managed/report-filter.ts +108 -0
  21. package/common/managed/roles.ts +28 -0
  22. package/common/managed/routes.d.ts +2 -0
  23. package/common/managed/routes.js +121 -0
  24. package/common/managed/scan-models.ts +6 -0
  25. package/common/managed/triage.ts +83 -0
  26. package/out/index.html +4 -0
  27. package/out/view.css +1 -1
  28. package/out/view.js +82 -82
  29. package/package.json +82 -3
  30. package/server-e2e/cli.js +1 -1
  31. package/server-managed/activity.ts +231 -0
  32. package/server-managed/avatar-store.ts +51 -0
  33. package/server-managed/blob-store.ts +66 -0
  34. package/server-managed/blob-vercel.ts +125 -0
  35. package/server-managed/brotli.ts +10 -0
  36. package/server-managed/bundle-cache.ts +185 -0
  37. package/server-managed/bundle-catalog.ts +29 -0
  38. package/server-managed/bundle-store.ts +28 -0
  39. package/server-managed/bundle-summary-cache.ts +97 -0
  40. package/server-managed/bundle.ts +39 -0
  41. package/server-managed/cache-storage.ts +40 -0
  42. package/server-managed/cli.js +13 -0
  43. package/server-managed/combined.ts +46 -0
  44. package/server-managed/comments.ts +151 -0
  45. package/server-managed/config.ts +144 -0
  46. package/server-managed/content-access.ts +15 -0
  47. package/server-managed/crypto.ts +25 -0
  48. package/server-managed/db-methods.ts +1330 -0
  49. package/server-managed/db-neon.ts +171 -0
  50. package/server-managed/db-schema.ts +203 -0
  51. package/server-managed/db-table-names.ts +22 -0
  52. package/server-managed/db.ts +109 -0
  53. package/server-managed/github-app.ts +332 -0
  54. package/server-managed/github-metadata.ts +65 -0
  55. package/server-managed/github-oauth.ts +215 -0
  56. package/server-managed/github-pulls.ts +115 -0
  57. package/server-managed/http-response.ts +18 -0
  58. package/server-managed/http.ts +2043 -0
  59. package/server-managed/import-triage.ts +48 -0
  60. package/server-managed/index.ts +135 -0
  61. package/server-managed/public-workspace.ts +150 -0
  62. package/server-managed/repo-path.ts +21 -0
  63. package/server-managed/report-migration.ts +35 -0
  64. package/server-managed/report-query.ts +4 -0
  65. package/server-managed/report-response.ts +16 -0
  66. package/server-managed/report-sources.ts +154 -0
  67. package/server-managed/repository-discovery.ts +82 -0
  68. package/server-managed/repository-policy.ts +25 -0
  69. package/server-managed/session.ts +78 -0
  70. package/server-managed/slugs.ts +38 -0
  71. package/server-managed/sql-postgres.ts +30 -0
  72. package/server-managed/sql.ts +61 -0
  73. package/server-managed/static.ts +28 -0
  74. package/server-managed/storage.ts +34 -0
  75. package/server-managed/team-catalog.ts +7 -0
  76. package/server-managed/team-feed.ts +128 -0
  77. package/server-managed/team-reports.ts +156 -0
  78. package/server-managed/triage-response.ts +16 -0
  79. package/server-managed/uploads.ts +47 -0
  80. package/server-managed/workspace-shares.ts +150 -0
  81. package/server.ts +50 -0
@@ -0,0 +1,144 @@
1
+ // Managed-server boot config, parsed once at startup and
2
+ // failing fast on a missing / invalid required value — the same discipline as
3
+ // server-e2e/config.ts. Auth (GitHub identity + sessions) is required. An
4
+ // optional, SEPARATE GitHub App (id + private key + slug) enables PRIVATE repo
5
+ // listing via installation tokens — kept distinct from the login App so its
6
+ // Contents permission never lands on the login consent. The sync protocol is
7
+ // not parsed here yet.
8
+ import { env } from 'node:process'
9
+ import { databaseUrls } from '../server-common/database-config.ts'
10
+
11
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
12
+
13
+ export interface ManagedConfig {
14
+ port: number
15
+ host: string
16
+ dbPath: string
17
+ neonUrl?: string | null
18
+ blobToken?: string | null
19
+ serverless?: boolean
20
+ debug: boolean
21
+ allowShare: boolean
22
+ trustProxyEnv: string | undefined
23
+ // GitHub App user-authorization (identity) credentials — the App's client id
24
+ // + secret, used by the login flow.
25
+ githubClientId: string
26
+ githubClientSecret: string
27
+ // Preapproved GitHub identity for the first registration in an empty DB.
28
+ // This never changes an existing user's role on later logins.
29
+ initialAdminGithubId: number | null
30
+ // Absolute callback registered with GitHub, e.g.
31
+ // 'https://triage.example.com/api/oauth/github/callback'.
32
+ oauthCallbackUrl: string
33
+ // Whether cookies carry `Secure` (callback is https). Also gates the
34
+ // `__Host-` prefix, so loopback http dev can run with a plain cookie name.
35
+ cookieSecure: boolean
36
+ sessionCookieName: string
37
+ sessionTtlMs: number
38
+ // Optional SEPARATE GitHub App for PRIVATE repository access — distinct from
39
+ // the login App above. It carries the Contents: Read permission (so its
40
+ // consent never touches login) and is read via installation tokens: the id +
41
+ // PEM private key mint them; the slug builds the "Connect a repository"
42
+ // install URL. id/key absent → private repos are off (public-only listing);
43
+ // slug absent → no install link.
44
+ githubAppId: string | null
45
+ githubAppPrivateKey: string | null
46
+ githubAppSlug: string | null
47
+ // Max accepted size (bytes) for an uploaded report on the "Manage reports"
48
+ // page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
49
+ maxReportBytes: number
50
+ // Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
51
+ // archive). Bundles run larger than reports, so a higher cap (default 100 MiB,
52
+ // matching the e2e objstore per-upload cap).
53
+ maxBundleBytes: number
54
+ // How many triage-trail events to keep per finding (managed_finding_triage_event):
55
+ // 0, the default, keeps everything — the trail is the record. An operator
56
+ // who would rather bound the store sets a positive count; older events of a
57
+ // finding are then trimmed as new ones land.
58
+ triageHistoryLimit: number
59
+ }
60
+
61
+ function fail(msg: string): never {
62
+ throw new Error(msg)
63
+ }
64
+
65
+ function requireStr(name: string): string {
66
+ const v = env[name]
67
+ if (v == null || v === '') fail(`Missing required env ${name} (managed mode).`)
68
+ return v
69
+ }
70
+
71
+ function intEnv(name: string, def: number, min: number, max: number): number {
72
+ const raw = env[name]
73
+ const n = raw == null ? def : Number(raw)
74
+ if (!Number.isSafeInteger(n) || n < min || n > max) {
75
+ fail(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].`)
76
+ }
77
+ return n
78
+ }
79
+
80
+ function optionalGithubId(name: string): number | null {
81
+ const raw = env[name]
82
+ if (raw == null || raw === '') return null
83
+ const id = Number(raw)
84
+ if (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(id)) {
85
+ fail(`Invalid ${name}: must be a single positive numeric GitHub user ID.`)
86
+ }
87
+ return id
88
+ }
89
+
90
+ function urlOrFail(name: string, raw: string): URL {
91
+ try { return new URL(raw) } catch { fail(`${name} is not a valid URL: ${raw}`) }
92
+ }
93
+
94
+ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
95
+ const serverless = env['VERCEL'] === '1'
96
+ const neonUrl = databaseUrls({ combined }).managed
97
+ const blobToken = env['BLOB_READ_WRITE_TOKEN'] || null
98
+ if (serverless && !neonUrl) fail('Vercel managed mode requires DATABASE_URL or MANAGED_DATABASE_URL.')
99
+ if (neonUrl && !blobToken) fail('Managed Neon mode requires BLOB_READ_WRITE_TOKEN.')
100
+ const host = env['HOST'] ?? '127.0.0.1'
101
+ const oauthCallbackUrl = requireStr('OAUTH_CALLBACK_URL')
102
+ const callback = urlOrFail('OAUTH_CALLBACK_URL', oauthCallbackUrl)
103
+ const cookieSecure = callback.protocol === 'https:'
104
+ // A `__Host-` cookie mandates `Secure`, so any non-loopback bind must be
105
+ // HTTPS — fail fast (mirrors server-e2e's boot check). Loopback dev over
106
+ // http is allowed with a non-prefixed cookie name.
107
+ if ((serverless || !LOOPBACK_HOSTS.has(host)) && !cookieSecure) {
108
+ fail(`Vercel or non-loopback HOST=${host} requires an https OAUTH_CALLBACK_URL — the session cookie needs Secure.`)
109
+ }
110
+ const sessionCookieName = env['SESSION_COOKIE_NAME'] ?? '__Host-dvsid'
111
+ if (sessionCookieName.startsWith('__Host-') && !cookieSecure) {
112
+ fail(`SESSION_COOKIE_NAME=${sessionCookieName} uses the __Host- prefix but the callback is not https. Use a non-prefixed name for loopback http dev.`)
113
+ }
114
+ return {
115
+ neonUrl, blobToken, serverless,
116
+ port: intEnv('PORT', 8765, 0, 65535),
117
+ host,
118
+ // DB_PATH belongs to e2e in a combined process. Keep the two stores apart.
119
+ dbPath: env['MANAGED_DB_PATH'] ?? (combined ? undefined : env['DB_PATH']) ?? 'server-managed/data/managed.db',
120
+ debug: env['DEBUG'] === '1' || env['DEBUG'] === 'true',
121
+ allowShare: env['DEEPVIEW_ALLOW_SHARE'] === '1',
122
+ trustProxyEnv: env['TRUST_PROXY'] ?? (serverless ? '1' : undefined),
123
+ githubClientId: requireStr('GITHUB_CLIENT_ID'),
124
+ githubClientSecret: requireStr('GITHUB_CLIENT_SECRET'),
125
+ initialAdminGithubId: optionalGithubId('MANAGED_INITIAL_ADMIN_GITHUB_ID'),
126
+ oauthCallbackUrl,
127
+ cookieSecure,
128
+ sessionCookieName,
129
+ sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
130
+ githubAppId: env['GITHUB_APP_ID'] ?? null,
131
+ githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
132
+ githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
133
+ maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
134
+ maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 104_857_600, 1, 1_073_741_824),
135
+ triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
136
+ }
137
+ }
138
+
139
+ // PEM private keys are awkward in env vars; accept a literal multi-line value or
140
+ // one with escaped newlines (`\n`). Empty/absent → null (private repos off).
141
+ function normalizePem(raw: string | undefined): string | null {
142
+ if (raw == null || raw === '') return null
143
+ return raw.includes('\\n') ? raw.replaceAll('\\n', '\n') : raw
144
+ }
@@ -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: ManagedDb, 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
+ }