@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.44

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 (75) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/managed/report-filter.ts +2 -1
  6. package/common/managed/routes.js +3 -3
  7. package/common/security-links.d.ts +5 -0
  8. package/common/security-links.js +33 -0
  9. package/common/server-info.ts +8 -0
  10. package/common/workspace-app.js +14 -1
  11. package/out/chunk-2ABLKCOY.js +46 -0
  12. package/out/chunk-5LSXOLUS.js +1 -0
  13. package/out/chunk-5RCM2J63.js +1 -0
  14. package/out/chunk-7B6HB4D7.js +1 -0
  15. package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
  16. package/out/chunk-CH4UN6WK.js +225 -0
  17. package/out/chunk-EDKLBQQN.js +17 -0
  18. package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
  19. package/out/chunk-FK3DZEKL.js +1 -0
  20. package/out/chunk-G6AJWENO.js +1 -0
  21. package/out/chunk-GZWM7UQM.js +2 -0
  22. package/out/chunk-JCIIBLKR.js +1 -0
  23. package/out/chunk-LPCTJYNA.js +7 -0
  24. package/out/{chunk-DGVQPIYB.js → chunk-O4RJY5TP.js} +1 -1
  25. package/out/chunk-OD4LPN3H.js +1 -0
  26. package/out/{chunk-D7AF2JXL.js → chunk-QWDWTBM7.js} +1 -1
  27. package/out/chunk-QYWKC557.js +1 -0
  28. package/out/chunk-R75XOKUP.js +1 -0
  29. package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
  30. package/out/chunk-T7OKH4VW.js +1 -0
  31. package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
  32. package/out/chunk-ZESLQCTF.js +6 -0
  33. package/out/client-managed-import.js +1 -1
  34. package/out/client-managed.js +21 -21
  35. package/out/client-sync.js +1 -1
  36. package/out/graph.js +1 -1
  37. package/out/index.html +14 -10
  38. package/out/terminal.js +1 -1
  39. package/out/view.css +1 -1
  40. package/out/view.js +58 -58
  41. package/package.json +13 -3
  42. package/risk/index.d.ts +26 -0
  43. package/risk/index.js +1 -1
  44. package/risk/npm/assess.js +66 -15
  45. package/risk/npm/dependencies.js +22 -3
  46. package/risk/npm/new-dependency.js +42 -12
  47. package/risk/npm/registry.js +122 -21
  48. package/server-managed/comparison.ts +41 -0
  49. package/server-managed/http.ts +161 -56
  50. package/server-managed/locked-packages.ts +29 -6
  51. package/server-managed/npm-kept.ts +38 -14
  52. package/server-managed/npm-packages.ts +27 -13
  53. package/server-managed/npm-risk.ts +69 -11
  54. package/server-managed/pretty-print.ts +14 -9
  55. package/server-managed/public-workspace.ts +26 -8
  56. package/server-managed/queue.ts +23 -0
  57. package/server-managed/report-sources.ts +1 -1
  58. package/server-managed/repository-risk-assess.ts +105 -0
  59. package/server-managed/repository-risk-audit.ts +66 -0
  60. package/server-managed/repository-risk.ts +265 -209
  61. package/server-managed/scan-downloads.ts +87 -0
  62. package/server-managed/sql.ts +1 -1
  63. package/server-managed/team-app.ts +38 -14
  64. package/server-managed/team-reports.ts +6 -11
  65. package/server-managed/upstream-cache.ts +16 -0
  66. package/out/chunk-CLPKNNC7.js +0 -51
  67. package/out/chunk-Q7ZSIEHP.js +0 -1
  68. package/out/chunk-RFZQQXSG.js +0 -17
  69. package/out/chunk-VQJCXGIY.js +0 -7
  70. package/out/chunk-WOTMRDM3.js +0 -226
  71. package/out/chunk-WZDTZE64.js +0 -1
  72. package/out/chunk-YP2537WX.js +0 -1
  73. package/out/chunk-YQRVLRDH.js +0 -1
  74. package/out/chunk-YVZH5SHP.js +0 -1
  75. package/out/chunk-ZBBOBQ43.js +0 -1
@@ -0,0 +1,87 @@
1
+ // A scan's packages' downloads, as risk asks them, readied before the
2
+ // packages are assessed (repository-risk.ts), so that no assessment waits on
3
+ // npm's downloads API, which answers some 40 lookups a minute: a package is
4
+ // given to the assessors once its downloads are kept (npm-risk.ts
5
+ // npmReadyScanDownloads), those that can be readied at once (kept already,
6
+ // and unscoped ones, asked in bulk) first, each lockfile's as it is read, and
7
+ // the rest (scoped ones, which npm answers only one by one) one at a time,
8
+ // at the pace npm allows, while the assessors go on with the others.
9
+ import { setTimeout as delay } from 'node:timers/promises'
10
+ import { type Queue, queue } from './queue.ts'
11
+
12
+ export interface DownloadsSource {
13
+ // Readies those of `names` that can be at once, `ready` told each.
14
+ atOnce(names: string[], ready: (name: string) => void, signal: AbortSignal): Promise<void>
15
+ // Readies one on its own.
16
+ one(name: string, signal: AbortSignal): Promise<void>
17
+ }
18
+
19
+ // How npm asking to be asked later is told (`busy`), and waited on: asked
20
+ // again after a wait, up to `retries` times running.
21
+ export interface DownloadsPacing { busy(error: unknown): boolean; retries: number; waitMs: number }
22
+
23
+ export interface ReadyingDownloads<T> {
24
+ // Packages to give once their downloads are ready.
25
+ add(items: T[]): void
26
+ // Once all added are given, or the scan stops.
27
+ close(): Promise<void>
28
+ }
29
+
30
+ // Readies the downloads of the packages added, each given (`give`) once its
31
+ // name's are, or once readying them fails otherwise than npm asking to wait,
32
+ // or npm keeps asking past `retries`: its assessment then asks its own.
33
+ export function readyingDownloads<T extends { name: string }>(source: DownloadsSource, signal: AbortSignal, give: (item: T) => void,
34
+ { busy, retries, waitMs }: DownloadsPacing): ReadyingDownloads<T> {
35
+ const waiting = new Map<string, T[]>()
36
+ const atOnce: Queue<string[]> = queue(signal), oneByOne: Queue<string> = queue(signal)
37
+ const release = (name: string) => { for (const item of waiting.get(name) ?? []) give(item); waiting.delete(name) }
38
+ const wait = async (attempt: number) => { try { await delay(waitMs * Math.min(attempt + 1, 5), undefined, { signal }); return true } catch { return false } }
39
+
40
+ // A lockfile's names, those that can be readied at once, again after a wait
41
+ // where npm asks: false once the scan stops.
42
+ const readyNow = async (names: string[]) => {
43
+ for (let attempt = 0; attempt <= retries; attempt++) {
44
+ const left = names.filter(name => waiting.has(name))
45
+ try { await source.atOnce(left, release, signal); return true } catch (error) {
46
+ if (signal.aborted) return false
47
+ if (!busy(error)) { for (const name of left) release(name); return true }
48
+ if (!await wait(attempt)) return false
49
+ }
50
+ }
51
+ return true
52
+ }
53
+ // Each lockfile's, the rest left to oneByOne.
54
+ const readyAtOnce = async () => {
55
+ for (let names = await atOnce.take(); names !== undefined; names = await atOnce.take()) {
56
+ if (!await readyNow(names)) return
57
+ for (const name of names) if (waiting.has(name)) oneByOne.add(name)
58
+ }
59
+ }
60
+ const readyOneByOne = async () => {
61
+ for (let name = await oneByOne.take(); name !== undefined; name = await oneByOne.take()) {
62
+ for (let attempt = 0; attempt <= retries; attempt++) {
63
+ try { await source.one(name, signal); break } catch (error) {
64
+ if (signal.aborted) return
65
+ if (!busy(error) || !await wait(attempt)) break
66
+ }
67
+ }
68
+ release(name)
69
+ }
70
+ }
71
+ const lanes = [readyAtOnce().finally(() => { oneByOne.close() }), readyOneByOne()]
72
+ return {
73
+ add(items) {
74
+ const names: string[] = []
75
+ for (const item of items) {
76
+ const kept = waiting.get(item.name)
77
+ if (kept) kept.push(item)
78
+ else { waiting.set(item.name, [item]); names.push(item.name) }
79
+ }
80
+ if (names.length > 0) atOnce.add(names)
81
+ },
82
+ async close() {
83
+ atOnce.close()
84
+ await Promise.all(lanes)
85
+ },
86
+ }
87
+ }
@@ -22,7 +22,7 @@ export interface ManagedSqlDriver extends ManagedSql {
22
22
  const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
23
23
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
24
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
- 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'ensureSessionUploadKey',
25
+ 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
26
26
  'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
27
27
  // Session presence and upload keys change only their own rows.
28
28
  const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
@@ -4,6 +4,11 @@
4
4
  // loads. Results are keyed by the catalog's own content keys: an unchanged
5
5
  // catalog reuses them, and every change that can move a result also changes
6
6
  // the catalog revision that clients refresh on.
7
+ //
8
+ // The same read names the reports a restricted reader would find nothing in:
9
+ // under -deps or -security the catalog leaves those out of the team's list.
10
+ // Readers with both permissions keep every report, empty or not.
11
+ import { reportEntries } from '@preventive/report'
7
12
  import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
8
13
  import type { BlobStore } from './blob-store.ts'
9
14
  import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
@@ -11,12 +16,14 @@ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
11
16
  import { TeamReportsError, loadTeamReportsResponse } from './team-reports.ts'
12
17
 
13
18
  export type { TeamAppMetadata }
19
+ export interface TeamWorkspaceSummary { app: TeamAppMetadata; empty: readonly string[] }
20
+ const NO_APP: TeamWorkspaceSummary = { app: { appMode: false }, empty: [] }
14
21
 
15
22
  const CACHE_MAX_ENTRIES = 4096
16
23
  // Each workspace build already reads four reports at a time.
17
24
  const CLASSIFY_CONCURRENCY = 2
18
- const caches = new WeakMap<ManagedDb, Map<string, TeamAppMetadata>>()
19
- const pendingJobs = new WeakMap<ManagedDb, Map<string, Promise<TeamAppMetadata | null>>>()
25
+ const caches = new WeakMap<ManagedDb, Map<string, TeamWorkspaceSummary>>()
26
+ const pendingJobs = new WeakMap<ManagedDb, Map<string, Promise<TeamWorkspaceSummary | null>>>()
20
27
 
21
28
  // Published reports with their content keys, and the team's own key (access,
22
29
  // scopes and links). Equal keys describe the same filtered workspace, whoever
@@ -29,28 +36,36 @@ function published(team: UserTeam) {
29
36
  return team.reports.filter(report => report.visible !== false)
30
37
  }
31
38
 
32
- function remember(db: ManagedDb, key: string, app: TeamAppMetadata): TeamAppMetadata {
39
+ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): TeamWorkspaceSummary {
33
40
  let cache = caches.get(db)
34
41
  if (!cache) { cache = new Map(); caches.set(db, cache) }
35
42
  cache.delete(key)
36
- cache.set(key, app)
43
+ cache.set(key, summary)
37
44
  for (const oldest of cache.keys()) {
38
45
  if (cache.size <= CACHE_MAX_ENTRIES) break
39
46
  cache.delete(oldest)
40
47
  }
41
- return app
48
+ return summary
49
+ }
50
+
51
+ // A links document counts by its links, every other report by its findings.
52
+ function hasContent(data: unknown): boolean {
53
+ const record = data as { source?: unknown; links?: unknown } | null
54
+ if (record?.source === 'links' && Array.isArray(record.links)) return record.links.length > 0
55
+ return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
42
56
  }
43
57
 
44
58
  // Null when the team cannot be classified right now: its catalog went stale,
45
59
  // the access snapshot is gone, or storage failed. The sidebar then keeps the
46
- // team expanded, and a later catalog asks again. Oversized and unreadable
47
- // workspaces never have an App view, so they are remembered as such.
60
+ // team expanded and lists every report, and a later catalog asks again.
61
+ // Oversized and unreadable workspaces never have an App view, so they are
62
+ // remembered as such, hiding nothing.
48
63
  async function classify(db: ManagedDb, store: BlobStore, key: string, team: UserTeam,
49
- snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamAppMetadata | null> {
64
+ snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamWorkspaceSummary | null> {
50
65
  const cached = caches.get(db)?.get(key)
51
66
  if (cached) return remember(db, key, cached)
52
67
  const ids = new Set(published(team).map(report => report.id))
53
- if (ids.size === 0) return remember(db, key, { appMode: false })
68
+ if (ids.size === 0) return remember(db, key, NO_APP)
54
69
  let jobs = pendingJobs.get(db)
55
70
  if (!jobs) { jobs = new Map(); pendingJobs.set(db, jobs) }
56
71
  const existing = jobs.get(key)
@@ -60,11 +75,13 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
60
75
  // A catalog read before a concurrent publication names another report set.
61
76
  if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
62
77
  const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
63
- reports: { filename: string; data: unknown }[]; links?: string[][]
78
+ reports: { id: string; filename: string; data: unknown }[]; links?: string[][]
64
79
  }
65
- return remember(db, key, managedTeamAppMetadata(Object.assign(body.reports, { links: body.links ?? [] })))
80
+ const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
81
+ const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
82
+ return remember(db, key, { app: managedTeamAppMetadata(Object.assign(body.reports, { links: body.links ?? [] })), empty })
66
83
  })().catch((error: unknown) => {
67
- if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, { appMode: false })
84
+ if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
68
85
  if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
69
86
  return null
70
87
  })
@@ -72,10 +89,17 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
72
89
  try { return await job } finally { jobs.delete(key) }
73
90
  }
74
91
 
92
+ // The catalog's view of a classified team: its App classification, and its
93
+ // reports without the ones its restricted reader would find empty.
94
+ export function withWorkspaceSummary<T extends { reports: readonly { id: string }[] }>(team: T, summary: TeamWorkspaceSummary | null | undefined) {
95
+ const empty = new Set(summary?.empty)
96
+ return { ...team, app: summary?.app ?? null, reports: team.reports.filter(report => !empty.has(report.id)) }
97
+ }
98
+
75
99
  // Classify a user's catalog, keyed by teamAppKey. `known` carries results from
76
100
  // an earlier read of the same request, so only changed teams are classified.
77
101
  export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId: string, teams: readonly UserTeam[],
78
- known: ReadonlyMap<string, TeamAppMetadata | null> = new Map()): Promise<Map<string, TeamAppMetadata | null>> {
102
+ known: ReadonlyMap<string, TeamWorkspaceSummary | null> = new Map()): Promise<Map<string, TeamWorkspaceSummary | null>> {
79
103
  const states = new Map(known)
80
104
  const missing = [...new Map(teams.map(team => [teamAppKey(team), team])).entries()].filter(([key]) => !states.has(key))
81
105
  let next = 0
@@ -89,6 +113,6 @@ export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId:
89
113
  }
90
114
 
91
115
  // A public link's workspace is its own access snapshot.
92
- export function sharedTeamApp(db: ManagedDb, store: BlobStore, snapshot: WorkspaceShareSnapshot): Promise<TeamAppMetadata | null> {
116
+ export function sharedTeamApp(db: ManagedDb, store: BlobStore, snapshot: WorkspaceShareSnapshot): Promise<TeamWorkspaceSummary | null> {
93
117
  return classify(db, store, JSON.stringify(['share', teamAppKey(snapshot.team)]), snapshot.team, () => Promise.resolve(snapshot))
94
118
  }
@@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer'
5
5
  import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
6
6
  import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
7
7
  import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
8
+ import { revalidationLinks, securityLinks } from '../common/security-links.js'
8
9
  import type { BlobStore } from './blob-store.ts'
9
10
  import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
10
11
  import { triageWireEntry } from './triage-response.ts'
@@ -104,18 +105,12 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
104
105
  }))
105
106
  for (const result of results) if (result.status === 'rejected') throw result.reason
106
107
  const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
107
- const edges = new Map<string, Set<string>>()
108
- const connect = (a: string, b: string) => {
109
- if (!edges.has(a)) edges.set(a, new Set())
110
- edges.get(a)!.add(b)
111
- }
112
- // A star is enough for transitive security propagation, without a quadratic
113
- // expansion for large links. Unknown IDs can connect known findings.
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]!) }
116
- }
117
108
  const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
118
- stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
109
+ // Unknown IDs can connect known findings. App findings link to the inputs
110
+ // they revalidated in other reports, as the App view counts them.
111
+ stampSecurityGroups(groups, { linkedIds: securityLinks([
112
+ ...globalLinks, ...reports.flatMap(report => linksOf(report.data) ?? []), ...revalidationLinks(groups.flat()),
113
+ ]).linkedIds })
119
114
  const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
120
115
  const allIds = new Set<string>(), visible: Visibility = new Map()
121
116
  for (let i = 0; i < reports.length; i++) {
@@ -27,6 +27,9 @@ export interface UpstreamCacheStore {
27
27
  // Key → value for those of `keys` that are kept.
28
28
  getUpstreamCacheEntries(keys: string[]): Promise<Map<string, string>>
29
29
  setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
30
+ // Each of `entries` (key, value), as setUpstreamCacheEntry keeps one, in one
31
+ // statement: the last of a key given twice.
32
+ setUpstreamCacheEntries(entries: [key: string, value: string][], cachedAt: number): Promise<void>
30
33
  // Removes those under `prefix` (no LIKE wildcard in it) kept before `before`;
31
34
  // how many.
32
35
  deleteUpstreamCacheEntries(prefix: string, before: number): Promise<number>
@@ -40,6 +43,8 @@ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
40
43
  ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
41
44
  WHERE managed_upstream_cache.cached_at <= excluded.cached_at`)
42
45
  const remove = db.prepare('DELETE FROM managed_upstream_cache WHERE cache_key LIKE ? AND cached_at < ?')
46
+ // setUpstreamCacheEntries's, by how many it keeps at once.
47
+ const setMany = new Map<number, ReturnType<ManagedSql['prepare']>>()
43
48
  return {
44
49
  async getUpstreamCacheEntry(key) {
45
50
  const row = await get.get(key) as { value: string } | undefined
@@ -53,6 +58,17 @@ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
53
58
  async setUpstreamCacheEntry(key, value, cachedAt) {
54
59
  await set.run(key, value, cachedAt)
55
60
  },
61
+ async setUpstreamCacheEntries(entries, cachedAt) {
62
+ const rows = [...new Map(entries)]
63
+ if (rows.length === 0) return
64
+ let many = setMany.get(rows.length)
65
+ if (!many) {
66
+ setMany.set(rows.length, many = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES ${rows.map(() => '(?, ?, ?)').join(', ')}
67
+ ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
68
+ WHERE managed_upstream_cache.cached_at <= excluded.cached_at`))
69
+ }
70
+ await many.run(...rows.flatMap(([key, value]) => [key, value, cachedAt]))
71
+ },
56
72
  async deleteUpstreamCacheEntries(prefix, before) {
57
73
  return Number((await remove.run(`${prefix}%`, before)).changes)
58
74
  },