@preventive/triage 1.0.0-alpha.33 → 1.0.0-alpha.34

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 (48) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +169 -169
  16. package/out/view.css +1 -1
  17. package/out/view.js +410 -202
  18. package/package.json +22 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/bundle-advisories.ts +8 -3
  21. package/server-managed/bundle-advisory-inventory.ts +27 -25
  22. package/server-managed/bundle-build-worker.js +45 -9
  23. package/server-managed/bundle-build.ts +35 -11
  24. package/server-managed/bundle-cache.ts +1 -1
  25. package/server-managed/bundle.ts +17 -1
  26. package/server-managed/config.ts +5 -0
  27. package/server-managed/db-methods.ts +168 -49
  28. package/server-managed/db-neon.ts +59 -4
  29. package/server-managed/db-schema.ts +12 -3
  30. package/server-managed/db.ts +9 -1
  31. package/server-managed/github-issue-links.ts +127 -0
  32. package/server-managed/github-issues.ts +1 -1
  33. package/server-managed/http-response.ts +7 -1
  34. package/server-managed/http.ts +248 -80
  35. package/server-managed/index.ts +4 -0
  36. package/server-managed/link-reports.ts +158 -0
  37. package/server-managed/managed-issues.ts +31 -3
  38. package/server-managed/management.ts +17 -4
  39. package/server-managed/public-workspace.ts +5 -2
  40. package/server-managed/report-sources.ts +6 -5
  41. package/server-managed/repository-aliases.ts +91 -0
  42. package/server-managed/revisions.ts +2 -2
  43. package/server-managed/sql.ts +3 -2
  44. package/server-managed/storage.ts +17 -0
  45. package/server-managed/team-feed.ts +3 -3
  46. package/server-managed/team-reports.ts +24 -15
  47. package/server-managed/upstream-cache.ts +73 -0
  48. package/server-managed/workspace-shares.ts +16 -12
@@ -1,5 +1,6 @@
1
1
  // Team content is filtered as one workspace, with separate report envelopes.
2
2
  // Bounded snapshots retain filtered content and visibility for repeated reads.
3
+ import { mergeLinkGroups } from './link-reports.ts'
3
4
  import { Buffer } from 'node:buffer'
4
5
  import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
5
6
  import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
@@ -7,6 +8,7 @@ import { filterReportData, projectFinding } from '../common/managed/report-filte
7
8
  import type { BlobStore } from './blob-store.ts'
8
9
  import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
9
10
  import { triageWireEntry } from './triage-response.ts'
11
+ import { visibleManagedIssues } from './managed-issues.ts'
10
12
  import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
11
13
 
12
14
  type Finding = Record<string, unknown>
@@ -54,16 +56,17 @@ export class TeamReportsError extends Error {
54
56
  constructor(status: number, error: string) { super(error); this.status = status }
55
57
  }
56
58
  export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
57
- return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.reports, snapshot.repositories])
59
+ return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
58
60
  }
59
- export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string): Promise<TeamReportAccessSnapshot> {
60
- const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
61
+ export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot> {
62
+ const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, reportId)
61
63
  if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
62
64
  if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
65
+ if (reportId !== null && !snapshot.reports.some(report => report.id === reportId)) throw new TeamReportsError(404, 'no-report')
63
66
  return snapshot
64
67
  }
65
68
  export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
66
- if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
69
+ if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!, snapshot.reportId)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
67
70
  }
68
71
  function groupsOf(data: unknown): Finding[][] {
69
72
  return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
@@ -100,6 +103,7 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
100
103
  }
101
104
  }))
102
105
  for (const result of results) if (result.status === 'rejected') throw result.reason
106
+ const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
103
107
  const edges = new Map<string, Set<string>>()
104
108
  const connect = (a: string, b: string) => {
105
109
  if (!edges.has(a)) edges.set(a, new Set())
@@ -107,10 +111,8 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
107
111
  }
108
112
  // A star is enough for transitive security propagation, without a quadratic
109
113
  // expansion for large links. Unknown IDs can connect known findings.
110
- for (const report of reports) {
111
- for (const ids of linksOf(report.data) ?? []) {
112
- for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
113
- }
114
+ for (const ids of globalLinks.concat(reports.flatMap(report => linksOf(report.data) ?? []))) {
115
+ for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
114
116
  }
115
117
  const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
116
118
  stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
@@ -130,7 +132,9 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
130
132
  }
131
133
  for (const report of reports) {
132
134
  const links = linksOf(report.data)
133
- if (links) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
135
+ // Hidden previews are independently authorized; preserve a hidden links
136
+ // document even though its referenced findings are outside this response.
137
+ if (links && !snapshot.hiddenReportId) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
134
138
  }
135
139
  let outputBytes = 14
136
140
  const parts = [Buffer.from('{"reports":[')]
@@ -141,7 +145,10 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
141
145
  if (index) parts.push(Buffer.from(','))
142
146
  parts.push(bytes)
143
147
  }
144
- parts.push(Buffer.from(']}'))
148
+ const links = mergeLinkGroups(globalLinks, allIds)
149
+ const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
150
+ if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
151
+ parts.push(suffix)
145
152
  const body = Buffer.concat(parts)
146
153
  retainVisibility(db, teamSnapshotKey(snapshot), visible)
147
154
  retainReports(db, teamSnapshotKey(snapshot), body)
@@ -190,13 +197,13 @@ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snap
190
197
  return visible.get(reportId)!
191
198
  }
192
199
  export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
193
- const snapshot = await teamSnapshot(db, sessionId, teamId)
200
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
194
201
  const visible = await teamReportVisibility(db, store, snapshot, reportId)
195
202
  await recheckTeam(db, sessionId, snapshot)
196
203
  return visible.ids
197
204
  }
198
205
  export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
199
- const snapshot = await teamSnapshot(db, sessionId, teamId)
206
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
200
207
  const visible = await teamReportVisibility(db, store, snapshot, reportId)
201
208
  await recheckTeam(db, sessionId, snapshot)
202
209
  return visible.sourcePaths
@@ -211,9 +218,11 @@ export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snaps
211
218
  : new Map([[reportId, await teamReportVisibility(db, store, snapshot, reportId)]])
212
219
  const ids = new Set<string>()
213
220
  for (const report of visible.values()) for (const id of report.ids) ids.add(id)
214
- const { triage, comments } = await db.getAnnotations([...ids])
221
+ const { triage, comments, issues: savedIssues } = await db.getAnnotations([...ids])
222
+ const issues = Object.fromEntries(visibleManagedIssues(savedIssues, snapshot)
223
+ .map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl }]))
215
224
  const entries = Object.fromEntries(triage.map(row => [row.findingId, triageWireEntry(row)]))
216
- const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId)])
225
+ const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId), ...Object.keys(issues)])
217
226
  const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))
218
- return { reports, entries, comments }
227
+ return { reports, entries, comments, issues }
219
228
  }
@@ -0,0 +1,73 @@
1
+ import type { CacheStore } from '@preventive/upstream/advisories.js'
2
+ import type { ManagedSql } from './sql.ts'
3
+
4
+ // Records @preventive/upstream would otherwise keep in its disk cache, shared
5
+ // by every instance: each repository's published advisory listing, written by
6
+ // dependency audits that ask repositories, keyed `github/advisories/owner/name`.
7
+ // Upstream stamps, validates and expires each value (90 minutes for a listing);
8
+ // a refresh replaces the row, and there is one row per repository, so rows are
9
+ // not evicted. Only public repositories publish advisories: rows are shared
10
+ // across viewers.
11
+ export const UPSTREAM_CACHE_SCHEMA = `
12
+ CREATE TABLE IF NOT EXISTS managed_upstream_cache (
13
+ cache_key TEXT PRIMARY KEY,
14
+ value TEXT NOT NULL,
15
+ cached_at INTEGER NOT NULL
16
+ ) STRICT;
17
+ `
18
+
19
+ export interface UpstreamCacheStore {
20
+ getUpstreamCacheEntry(key: string): Promise<string | null>
21
+ setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
22
+ }
23
+
24
+ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
25
+ const get = db.prepare('SELECT value FROM managed_upstream_cache WHERE cache_key = ?')
26
+ // A slower concurrent audit cannot replace a newer listing with an older one.
27
+ const set = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES (?, ?, ?)
28
+ ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
29
+ WHERE managed_upstream_cache.cached_at <= excluded.cached_at`)
30
+ return {
31
+ async getUpstreamCacheEntry(key) {
32
+ const row = await get.get(key) as { value: string } | undefined
33
+ return row?.value ?? null
34
+ },
35
+ async setUpstreamCacheEntry(key, value, cachedAt) {
36
+ await set.run(key, value, cachedAt)
37
+ },
38
+ }
39
+ }
40
+
41
+ // An audit's `cache`: where the server has an upstream disk cache (off Vercel,
42
+ // set at startup), that keeps everything; else this store keeps listings.
43
+ export function auditCache(cacheDir: string | null | undefined, db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore | undefined {
44
+ return cacheDir ? undefined : upstreamCache(db, signal, debug)
45
+ }
46
+
47
+ // Only listings are kept: upstream's other records (npm version documents,
48
+ // registries' repository lookups) read as misses and are not written.
49
+ const CACHED_TYPE = 'github/advisories'
50
+
51
+ // The cache is an optimization: a database failure is a miss, never a failed
52
+ // audit. Upstream keeps listing after an abandoned audit; once `signal` aborts,
53
+ // the request's database work is over and the store is left alone.
54
+ export function upstreamCache(db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore {
55
+ return {
56
+ async read(type, key) {
57
+ if (type !== CACHED_TYPE || signal.aborted) return null
58
+ try {
59
+ const value = await db.getUpstreamCacheEntry(`${type}/${key}`)
60
+ return value === null ? null : JSON.parse(value) as unknown
61
+ } catch (error) {
62
+ if (debug) console.warn('managed: upstream cache read failed:', error)
63
+ return null
64
+ }
65
+ },
66
+ async write(type, key, value) {
67
+ if (type !== CACHED_TYPE || signal.aborted) return
68
+ try { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()) } catch (error) {
69
+ if (debug) console.warn('managed: upstream cache write failed:', error)
70
+ }
71
+ },
72
+ }
73
+ }
@@ -1,5 +1,6 @@
1
+ import { linkRevision } from './link-reports.ts'
1
2
  import type { ManagedSql } from './sql.ts'
2
- import type { ManagedBundle, TeamReportAccessSnapshot, UserTeam } from './db.ts'
3
+ import type { BundleBuildConditions, ManagedBundle, TeamReportAccessSnapshot, UserTeam } from './db.ts'
3
4
  import { type TeamUserPermissions, parseTeamUserPermissions } from '../common/managed/permissions.ts'
4
5
 
5
6
  export const WORKSPACE_SHARE_SCHEMA = `
@@ -47,7 +48,7 @@ export interface WorkspaceShareStore {
47
48
  function shareQueries(db: ManagedSql) {
48
49
  const manager = db.prepare(`SELECT 1 FROM managed_session s
49
50
  JOIN managed_user u ON u.id = s.user_id
50
- JOIN managed_team t ON t.id = ?
51
+ JOIN managed_team t ON t.id = ? AND t.hidden = 0
51
52
  WHERE s.id = ? AND s.expires_at > ? AND (u.role = 'admin' OR (u.role = 'manage'
52
53
  AND EXISTS (SELECT 1 FROM managed_team_user tu WHERE tu.user_id = u.id AND tu.team_id = t.id)))`)
53
54
  const insert = db.prepare(`INSERT INTO managed_workspace_share (token_hash, team_id, created_by, created_at, dependencies, security)
@@ -62,12 +63,12 @@ function shareQueries(db: ManagedSql) {
62
63
  WHERE s.id = ? AND s.expires_at > ? AND u.role IN ('manage', 'admin')`)
63
64
  const all = db.prepare(`SELECT s.token_hash AS id, s.created_at AS createdAt, u.login AS createdBy, s.dependencies, s.security,
64
65
  t.id AS teamId, t.slug AS teamSlug, t.name AS teamName FROM managed_workspace_share s
65
- JOIN managed_user u ON u.id = s.created_by JOIN managed_team t ON t.id = s.team_id
66
+ JOIN managed_user u ON u.id = s.created_by JOIN managed_team t ON t.id = s.team_id AND t.hidden = 0
66
67
  WHERE ? = 'admin' OR EXISTS (SELECT 1 FROM managed_team_user tu WHERE tu.user_id = ? AND tu.team_id = s.team_id)
67
68
  ORDER BY t.name, t.id, s.created_at DESC, s.token_hash`)
68
69
  const shareGrant = `FROM managed_workspace_share s
69
70
  JOIN managed_user u ON u.id = s.created_by
70
- JOIN managed_team t ON t.id = s.team_id
71
+ JOIN managed_team t ON t.id = s.team_id AND t.hidden = 0
71
72
  WHERE s.token_hash = ? AND (u.role = 'admin' OR (u.role = 'manage'
72
73
  AND EXISTS (SELECT 1 FROM managed_team_user tu WHERE tu.user_id = u.id AND tu.team_id = s.team_id)))`
73
74
  const share = db.prepare(`SELECT t.id, t.slug, t.name, s.dependencies, s.security ${shareGrant}`)
@@ -88,10 +89,10 @@ function shareQueries(db: ManagedSql) {
88
89
  // A raw bundle is visible only when its declared root is inside the team scope.
89
90
  // Root bundles remain hidden from directory-only grants.
90
91
  const bundles = db.prepare(`SELECT DISTINCT b.id, b.slug, b.integrity, b.filename, b.kind, b.byte_size AS byteSize,
91
- b.uploaded_by AS uploadedBy, b.repo_id AS repoId, b.repo_directory AS repoDirectory, b.uploaded_at AS uploadedAt, sr.full_name AS repoFullName
92
+ b.uploaded_by AS uploadedBy, b.repo_id AS repoId, b.repo_directory AS repoDirectory, b.uploaded_at AS uploadedAt, b.provenance, b.build_conditions AS buildConditions, sr.full_name AS repoFullName
92
93
  FROM managed_team_repo tr JOIN managed_bundle b ON b.repo_id = tr.repo_id
93
94
  JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
94
- WHERE tr.team_id = ? AND (tr.path = '' OR b.repo_directory = tr.path
95
+ WHERE tr.team_id = ? AND b.visible = 1 AND (tr.path = '' OR b.repo_directory = tr.path
95
96
  OR substr(b.repo_directory, 1, length(tr.path) + 1) = tr.path || '/') ORDER BY b.id`)
96
97
  return { manager, insert, remove, update, list, session, all, share, feed, repositories, reports, bundles }
97
98
  }
@@ -141,21 +142,24 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
141
142
  const { dependencies, security, ...team } = grant
142
143
  const permissions = { dependencies: dependencies === 1, security: security === 1 }
143
144
  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 }[]
144
- const bundleRows = await q.bundles.all(team.id) as (ManagedBundle & { repoFullName: string })[]
145
+ const links = await linkRevision(db)
146
+ const bundleRows = (await q.bundles.all(team.id) as (Omit<ManagedBundle, 'buildConditions'> & { buildConditions: string | null; repoFullName: string })[])
147
+ .map(row => ({ ...row, buildConditions: row.buildConditions == null ? null : JSON.parse(row.buildConditions) as BundleBuildConditions }))
145
148
  return {
146
149
  user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
147
150
  teamId: team.id,
151
+ linkRevision: links,
148
152
  permissions,
149
153
  repositories: await q.repositories.all(team.id) as WorkspaceShareSnapshot['repositories'],
150
154
  reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
151
155
  repo: { github: row.github, directory: row.directory }, permissions })),
152
- team: { ...team, permissions,
153
- reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
154
- cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions]) })),
156
+ team: { ...team, permissions, cacheKey: links,
157
+ reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, visible: true, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
158
+ cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions, links]) })),
155
159
  bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,
156
- filename: row.filename, kind: row.kind, byteSize: row.byteSize, repoId: row.repoId!, repoDirectory: row.repoDirectory, repoFullName: row.repoFullName })),
160
+ filename: row.filename, visible: true, kind: row.kind, byteSize: row.byteSize, repoId: row.repoId!, repoDirectory: row.repoDirectory, repoFullName: row.repoFullName, provenance: row.provenance })),
157
161
  },
158
- bundles: bundleRows,
162
+ bundles: bundleRows.map(row => ({ ...row, visible: true })),
159
163
  }
160
164
  },
161
165
  }