@preventive/triage 1.0.0-alpha.53 → 1.0.0-alpha.54

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 (109) hide show
  1. package/common/bundle-compare-diff.js +28 -9
  2. package/common/bundle-compare-inputs.d.ts +1 -0
  3. package/common/bundle-compare-inputs.js +91 -15
  4. package/common/bundle-comparison.js +14 -7
  5. package/common/default-scan-models.ts +3 -0
  6. package/common/dependency-reviews.d.ts +1 -1
  7. package/common/dependency-reviews.js +1 -1
  8. package/common/managed/finding-detail.d.ts +1 -1
  9. package/common/managed/finding-detail.js +7 -7
  10. package/common/managed/team-app.d.ts +7 -0
  11. package/common/managed/team-app.js +31 -7
  12. package/common/workspace-app.js +19 -7
  13. package/out/activity.js +4 -4
  14. package/out/chunk-4EMM6UCD.js +1 -0
  15. package/out/chunk-4ISO5F7Y.js +7 -0
  16. package/out/chunk-4XIH7YTX.js +1 -0
  17. package/out/chunk-6W5BMATD.js +1 -0
  18. package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
  19. package/out/chunk-DEVAORP2.js +1 -0
  20. package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
  21. package/out/chunk-E3LFSUDJ.js +9 -0
  22. package/out/chunk-FHIVVVUA.js +6 -0
  23. package/out/chunk-G3LY2FV4.js +1 -0
  24. package/out/chunk-HAZKDK4M.js +4 -0
  25. package/out/chunk-HOB3YPS5.js +1 -0
  26. package/out/chunk-I47TYPXO.js +52 -0
  27. package/out/chunk-J7A5OSZE.js +244 -0
  28. package/out/chunk-JFL7WLRV.js +1 -0
  29. package/out/chunk-JMG5DXII.js +2 -0
  30. package/out/chunk-JOXLZXMI.js +1 -0
  31. package/out/chunk-L4CTEMRG.js +2 -0
  32. package/out/chunk-M37X4LCF.js +14 -0
  33. package/out/chunk-M3Y6SLC4.js +1 -0
  34. package/out/chunk-NXATZJQ2.js +1 -0
  35. package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
  36. package/out/chunk-PAD2JHZT.js +1 -0
  37. package/out/chunk-QMVOEBWC.js +13 -0
  38. package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
  39. package/out/chunk-RRBIJL2B.js +1 -0
  40. package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
  41. package/out/chunk-TOXMUOIW.js +5 -0
  42. package/out/chunk-UBOCLLF2.js +1 -0
  43. package/out/chunk-UCP3JRE2.js +1 -0
  44. package/out/{chunk-JEFZTVNX.js → chunk-VC72ZO3P.js} +1 -1
  45. package/out/chunk-VRX2APNA.js +9 -0
  46. package/out/{chunk-NE2UGKRJ.js → chunk-VZMELPVL.js} +4 -3
  47. package/out/{chunk-YA4ETCFU.js → chunk-WHVXTJHK.js} +1 -1
  48. package/out/chunk-WMFDQS2L.js +11 -0
  49. package/out/chunk-WNSVBOSO.js +41 -0
  50. package/out/chunk-WREU7JAG.js +1 -0
  51. package/out/chunk-Y5ZHFNBD.js +2 -0
  52. package/out/chunk-YF4M5OCI.js +1 -0
  53. package/out/chunk-Z667U2UJ.js +1 -0
  54. package/out/chunk-ZQHLECTI.js +1 -0
  55. package/out/client-managed-import.js +1 -1
  56. package/out/client-managed.js +27 -26
  57. package/out/compare.js +14 -14
  58. package/out/dialogs.js +36 -51
  59. package/out/graph.js +17 -15
  60. package/out/index.html +26 -10
  61. package/out/prism.js +1 -1
  62. package/out/scan.js +2 -2
  63. package/out/terminal.js +232 -232
  64. package/out/view.css +1 -1
  65. package/out/view.js +15 -15
  66. package/package.json +11 -7
  67. package/risk/actions/resolve.js +36 -5
  68. package/risk/index.d.ts +20 -2
  69. package/risk/npm/malware.js +6 -4
  70. package/risk/npm/registry.js +8 -6
  71. package/server-managed/bundle-advisories.ts +56 -74
  72. package/server-managed/bundle-build-worker.js +61 -4
  73. package/server-managed/bundle-build.ts +13 -4
  74. package/server-managed/bundle-dependency-reviews.ts +74 -0
  75. package/server-managed/bundle-validation.ts +32 -0
  76. package/server-managed/db-methods.ts +51 -16
  77. package/server-managed/db-neon.ts +18 -2
  78. package/server-managed/db.ts +11 -2
  79. package/server-managed/github-issue-links.ts +151 -47
  80. package/server-managed/github-pulls.ts +34 -15
  81. package/server-managed/http.ts +227 -12
  82. package/server-managed/index.ts +13 -1
  83. package/server-managed/managed-issues.ts +29 -15
  84. package/server-managed/npm-insights.ts +4 -3
  85. package/server-managed/npm-kept.ts +4 -0
  86. package/server-managed/package-entry-points.ts +37 -9
  87. package/server-managed/pretty-print.ts +7 -5
  88. package/server-managed/public-workspace.ts +2 -2
  89. package/server-managed/repository-actions.ts +7 -4
  90. package/server-managed/repository-browser.ts +11 -4
  91. package/server-managed/repository-risk-audit.ts +4 -5
  92. package/server-managed/repository-risk.ts +4 -4
  93. package/server-managed/sql.ts +2 -1
  94. package/server-managed/stasis-config.ts +46 -0
  95. package/server-managed/team-app-summaries.ts +54 -0
  96. package/server-managed/team-app.ts +52 -31
  97. package/server-managed/team-reports.ts +27 -7
  98. package/server-managed/upstream-cache.ts +41 -32
  99. package/server-managed/workspace-shares.ts +3 -1
  100. package/out/chunk-2ZDZWLFY.js +0 -1
  101. package/out/chunk-7VAWINQF.js +0 -6
  102. package/out/chunk-FMXWXDR4.js +0 -36
  103. package/out/chunk-IZ7POWI3.js +0 -18
  104. package/out/chunk-LJ7XRXFU.js +0 -52
  105. package/out/chunk-QB4CBOX2.js +0 -1
  106. package/out/chunk-TQJDYHVE.js +0 -2
  107. package/out/chunk-TSIFIFBX.js +0 -232
  108. package/out/chunk-VH4XMZIW.js +0 -2
  109. package/out/chunk-WYLQWYLA.js +0 -1
@@ -1,5 +1,7 @@
1
1
  import { Buffer } from 'node:buffer'
2
+ import { buildableEntry } from './bundle-build.ts'
2
3
  import { normalizeTeamPath } from './repo-path.ts'
4
+ import { MAX_STASIS_CONFIG_BYTES, parseStasisConfig } from './stasis-config.ts'
3
5
 
4
6
  export const MAX_PACKAGE_BYTES = 256 * 1024
5
7
  const MAX_ENTRY_POINTS = 100
@@ -33,19 +35,45 @@ export function packageEntryPoints(manifest: unknown, directory: string): string
33
35
  return [...paths]
34
36
  }
35
37
 
38
+ // The entries a stasis.config.json in `directory` lists, as repository paths:
39
+ // the ones a server build takes, which it builds with their options there.
40
+ export function configEntryPoints(text: string, directory: string): string[] {
41
+ const paths: string[] = []
42
+ for (const key of parseStasisConfig(text, directory) ?? []) {
43
+ const path = directory ? `${directory}/${key}` : key
44
+ if (buildableEntry(path) && paths.length < MAX_ENTRY_POINTS) paths.push(path)
45
+ }
46
+ return paths
47
+ }
48
+
36
49
  // Use the immutable blob from the authorized listing, never download_url or a
37
- // resolved symlink target. Malformed or oversized manifests are optional.
50
+ // resolved symlink target. A malformed or oversized blob is none (null).
51
+ async function readBlobText(sha: string, read: (suffix: string) => Promise<unknown>, maxBytes: number): Promise<string | null> {
52
+ const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
53
+ if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
54
+ || blob.size < 0 || blob.size > maxBytes || blob.content.length > maxBytes * 2) return null
55
+ const bytes = Buffer.from(blob.content, 'base64')
56
+ return bytes.length !== blob.size || bytes.length > maxBytes ? null : bytes.toString('utf8')
57
+ }
58
+
59
+ // Manifests are optional: suggestions must not prevent browsing. The handler
60
+ // still rechecks GitHub and managed access after these optional upstream reads.
38
61
  export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
39
62
  try {
40
- const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
41
- if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
42
- || blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
43
- const bytes = Buffer.from(blob.content, 'base64')
44
- if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
45
- return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
63
+ const text = await readBlobText(sha, read, MAX_PACKAGE_BYTES)
64
+ return text === null ? [] : packageEntryPoints(JSON.parse(text), directory)
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+
70
+ // A stasis.config.json whose entries Stasis can't tell lists none here;
71
+ // building them reports why (config-invalid), as it does options Stasis refuses.
72
+ export async function readConfigEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
73
+ try {
74
+ const text = await readBlobText(sha, read, MAX_STASIS_CONFIG_BYTES)
75
+ return text === null ? [] : configEntryPoints(text, directory)
46
76
  } catch {
47
- // Suggestions must not prevent browsing. The handler still rechecks
48
- // GitHub and managed access after this optional upstream read.
49
77
  return []
50
78
  }
51
79
  }
@@ -13,7 +13,7 @@ import { brotliDecompress } from 'node:zlib'
13
13
  import { format } from '@preventive/compare/pretty.js'
14
14
  import { computeFileHash } from '@preventive/report'
15
15
  import { FILE_HASH, computeBundleFileHashes } from '../common/bundle-metadata.js'
16
- import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
16
+ import { bundleCompareFiles, bundleCompareSideScopes } from '../common/bundle-compare-inputs.js'
17
17
  import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
18
18
  import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
19
19
  import type { OpenedBlob } from './blob-store.ts'
@@ -413,12 +413,14 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
413
413
  // before this resolves: for the files that changed between them
414
414
  // pretty-printed (compared), and their diffs with renamed names hidden
415
415
  // (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
416
- // where one is asked for, as Compare reads it then.
417
- bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
418
- const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
416
+ // where one is asked for, as Compare reads it then (bundleCompareSideScopes).
417
+ async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
418
+ const [baseDetails, otherDetails] = await Promise.all([bundleDetails(base), bundleDetails(other)])
419
+ const [baseScope, otherScope] = bundleCompareSideScopes(baseDetails, otherDetails, scope)
420
+ const sideOf = async (record: ManagedBundle, details: typeof baseDetails, side: Side, within: string): Promise<ComparedSide> => ({ side, ...await bundleSide(details, within),
419
421
  kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
420
422
  made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
421
- return Promise.all([sideOf(base, 'base'), sideOf(other, 'other')])
423
+ return Promise.all([sideOf(base, baseDetails, 'base', baseScope), sideOf(other, otherDetails, 'other', otherScope)])
422
424
  },
423
425
  // A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
424
426
  bundleDetails,
@@ -12,7 +12,7 @@ import { triageWireEntry } from './triage-response.ts'
12
12
  import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
13
13
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
14
14
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
15
- import { auditCache, auditedRepos } from './upstream-cache.ts'
15
+ import { auditCache, auditedKeys } from './upstream-cache.ts'
16
16
  import { serveTeamFeed } from './team-feed.ts'
17
17
  import { type ComparedSide, PrettyError, compared, prettyRequest, prettyText } from './pretty-print.ts'
18
18
  import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
@@ -207,7 +207,7 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
207
207
  const repoAdvisories = url.searchParams.get('repoAdvisories') === 'true'
208
208
  const result = await fetchBundleAdvisories(inventory.packages, signal, {
209
209
  debug: deps.config.debug, repoAdvisories, details: url.searchParams.get('details') === 'true',
210
- cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
210
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(inventory.packages, repoAdvisories)),
211
211
  })
212
212
  await recheck()
213
213
  json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
@@ -111,7 +111,8 @@ const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
111
111
 
112
112
  // `source`, what it answers of a public repository kept for every scan, the
113
113
  // same whoever asks: of a commit while kept (but none, as one can be pushed
114
- // later), the rest for RISK_KEPT_MS. A private one's, or one missing, never.
114
+ // later), the rest, its advisories too, for RISK_KEPT_MS. A private one's,
115
+ // or one missing, never.
115
116
  export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
116
117
  const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
117
118
  const kept = (key: string, lasting = false) => {
@@ -140,6 +141,8 @@ export function sharedSource(source: ActionsSource, answers: ActionAnswers): Act
140
141
  branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
141
142
  contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
142
143
  file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
144
+ ...source.advisories && { advisories: (name: string, versions: string[], internal: boolean) =>
145
+ shared(name, keyOf('advisories', name, ...versions), false, () => source.advisories!(name, versions, internal)) },
143
146
  }
144
147
  }
145
148
 
@@ -153,8 +156,8 @@ export interface ActionsResolving {
153
156
  }
154
157
 
155
158
  // A reference's line: kept (`stored`, as read of the store with others),
156
- // else resolved, and kept where it is a public repository's; why not, where
157
- // it couldn't be.
159
+ // else resolved, and kept where it is a public repository's and its
160
+ // advisories could be had; why not, where it couldn't be.
158
161
  async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef,
159
162
  stored = resolving.store?.read(key)): Promise<ActionLine | null> {
160
163
  const kept = resolving.memory.get(key) ?? await stored?.then(row => row && { at: row.at, line: row.value })
@@ -164,7 +167,7 @@ async function resolved(resolving: ActionsResolving, source: ActionsSource, { ke
164
167
  let found: ResolvedAction
165
168
  try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
166
169
  const line: ActionLine = { type: 'action', ...found }
167
- if (found.repository?.private === false) {
170
+ if (found.repository?.private === false && !found.findings.some(({ id }) => id === 'advisories-unavailable')) {
168
171
  const at = Date.now()
169
172
  resolving.memory.set(key, { at, line })
170
173
  await resolving.store?.write(key, line, at)
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto'
2
2
  import type { ManagedConfig } from './config.ts'
3
3
  import type { SelectedRepo } from './db.ts'
4
4
  import { GithubApiError, type GithubIdentity, READ_PERMISSIONS, githubJson, githubRepoPermission, githubUserIdentity, onTokenRejected, repoAccessToken } from './github-app.ts'
5
- import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
+ import { MAX_PACKAGE_BYTES, readConfigEntryPoints, readPackageEntryPoints } from './package-entry-points.ts'
6
+ import { MAX_STASIS_CONFIG_BYTES, STASIS_CONFIG } from './stasis-config.ts'
6
7
  import { readSolidityEntryPoints } from './solidity-entry-points.ts'
7
8
  import { parseGithubCommit } from './bundle-commits.ts'
8
9
  import { appDependabotAlerts } from './dependabot-access.ts'
@@ -279,9 +280,15 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
279
280
  const type = item.submodule_git_url ? 'submodule' : item.type
280
281
  if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
281
282
  }
282
- const manifestPath = path ? `${path}/package.json` : 'package.json'
283
- const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
284
- && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
283
+ const listed = (name: string, maxBytes: number) => data.find(item => item?.path === (path ? `${path}/${name}` : name) && item.name === name
284
+ && item.type === 'file' && !item.submodule_git_url && typeof item.size === 'number' && item.size >= 0 && item.size <= maxBytes
285
+ && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
286
+ // The entries stasis.config.json lists are suggested before any guess:
287
+ // a build takes them alone, with their options there.
288
+ const config = listed(STASIS_CONFIG, MAX_STASIS_CONFIG_BYTES)
289
+ const configEntryPoints = config ? await readConfigEntryPoints(config.sha, path, read) : []
290
+ if (configEntryPoints.length > 0) return { entries, limited: data.length >= 1000, configEntryPoints }
291
+ const manifest = listed('package.json', MAX_PACKAGE_BYTES)
285
292
  // Both optional suggestions, read together: neither fails, each
286
293
  // answering none where its reads do.
287
294
  const [packageEntryPoints, solidity] = await Promise.all([manifest ? readPackageEntryPoints(manifest.sha, path, read) : undefined,
@@ -1,19 +1,18 @@
1
1
  // A lockfile's advisories, as a repository risk scan asks them
2
2
  // (repository-risk.ts): npm's registry's of its packages of the registry or
3
3
  // a mirror, asked at once, as `npm audit` asks, each told with whether it is
4
- // of malware (risk's npmMalwareAdvisory); kept by the lockfile's blob, in the
5
- // instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
4
+ // of malware, as upstream tells (`malware`); kept by the lockfile's blob, in
5
+ // the instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
6
6
  // publishes advisories, or why they couldn't be had for AUDIT_FAILED_KEPT_MS.
7
7
  import type { Advisory } from '@preventive/upstream/advisories.js'
8
8
  import { isExactVersion } from '@preventive/upstream/semver.js'
9
- import { npmMalwareAdvisory } from '../risk/index.js'
10
9
  import { isNpmPackageName } from '../common/managed/npm-packages.js'
11
10
  import type { LockedPackage } from './locked-packages.ts'
12
11
  import type { Lru } from './lru.ts'
13
12
  import type { KeptStore } from './npm-kept.ts'
14
13
  import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
15
14
 
16
- export type AdvisoryRow = Advisory & { malware: boolean }
15
+ export type AdvisoryRow = Omit<Advisory, 'malware'> & { malware: boolean }
17
16
  // A lockfile's advisories, or why they couldn't be had.
18
17
  export type Audit = AdvisoryRow[] | RiskFailure
19
18
 
@@ -67,7 +66,7 @@ export async function auditLockfile(source: AuditSource, memory: Lru<{ at: numbe
67
66
  let audit: Audit
68
67
  try {
69
68
  audit = (await source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), signal))
70
- .map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
69
+ .map(advisory => ({ ...advisory, malware: advisory.malware === true }))
71
70
  } catch (error) {
72
71
  if (signal.aborted) return null
73
72
  audit = failureOf(error)
@@ -18,8 +18,8 @@
18
18
  // that locks them (LockedPackage `dev`) once all the rest are, as an install
19
19
  // of no devDependencies leaves them out. A lockfile's packages of npm's
20
20
  // registry or a mirror are asked of npm's advisories all at once, as `npm
21
- // audit` asks, each advisory told with whether it is of malware (risk's
22
- // npmMalwareAdvisory), which alone is a supply chain's risk; the rest are
21
+ // audit` asks, each advisory told with whether it is of malware (upstream's
22
+ // `malware`), which alone is a supply chain's risk; the rest are
23
23
  // vulnerabilities, told beside. A repository's Dependabot alerts of npm's, open or dismissed,
24
24
  // are told once its lockfiles' advisories are, each with the versions its
25
25
  // lockfile locks that it covers, for the page to blend them into npm's; one
@@ -94,8 +94,8 @@ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends Audi
94
94
 
95
95
  // A Dependabot alert as a scan tells it: with the versions of its package
96
96
  // the lockfile it is of locks that it covers, none where it is of none read
97
- // (a package.json, bun's lockfile), and whether it is of malware, as an
98
- // advisory's is told.
97
+ // (a package.json, bun's lockfile), and whether it is of malware, by the rule
98
+ // upstream tells an advisory's by (risk's npmMalwareAdvisory).
99
99
  export type DependabotAlertRow = DependabotAlert & { versions: string[]; malware: boolean }
100
100
 
101
101
  // A risk line is of npm's package of its name and version, or, with an
@@ -26,12 +26,13 @@ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'g
26
26
  'listTriageFixes', 'getRepoDependencyReviews', 'listTeamDependencyReviews', 'getNpmReviewReader',
27
27
  'listDependencyReviewTeams', 'listDependencyReviewEnabledTeams', 'listUserNpmScopes', 'listTeamNpmScopes', 'hasTeamNpmScope',
28
28
  'listGithubMetadata', 'listGithubRepositoryVisibility', 'setGithubMetadata', 'recordGithubMetadataAttempts', 'setGithubRepositoryVisibility',
29
- 'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForRepo', 'listBundlesForRepo',
29
+ 'listLinkReports', 'listLegacyLinkReports', 'listReportFilenamesWithBundleHash', 'listReportsForBundle', 'listReportsForRepo', 'listBundlesForRepo',
30
30
  'listFindingCatalogReports', 'userCanReadBundleAdvisories',
31
31
  'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getBundleAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getAnnotationRevision', 'getUserTokens',
32
32
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
33
33
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
34
34
  'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
35
+ 'getTeamAppSummaries', 'setTeamAppSummary', 'deleteTeamAppSummaries',
35
36
  'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
36
37
  // Session presence and upload keys change only their own rows.
37
38
  const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
@@ -0,0 +1,46 @@
1
+ // stasis.config.json's `entries`, as Stasis reads them (src/cmd/bundle-options.js's
2
+ // readConfigEntries, which is not a public package export): the entries it
3
+ // names, each a path relative to the file's directory. Their options are
4
+ // Stasis's: buildGitHubBundle builds the entries the stasis.config.json in its
5
+ // directory names with their options there, and refuses what it can't build.
6
+ export const STASIS_CONFIG = 'stasis.config.json'
7
+ export const MAX_STASIS_CONFIG_BYTES = 256 * 1024
8
+
9
+ const plainObject = (value: unknown): value is Record<string, unknown> =>
10
+ value !== null && typeof value === 'object' && !Array.isArray(value)
11
+
12
+ // `entry` as a path from `directory` ('.' for it), as `stasis bundle` run
13
+ // there resolves it; null where that is outside it.
14
+ export function configEntryKey(directory: string, entry: string): string | null {
15
+ if (entry.startsWith('/')) return null
16
+ const base = directory ? directory.split('/') : []
17
+ const parts = [...base]
18
+ for (const part of entry.split('/')) {
19
+ if (part === '' || part === '.') continue
20
+ if (part !== '..') parts.push(part)
21
+ else if (parts.pop() === undefined) return null
22
+ }
23
+ if (base.some((part, i) => parts[i] !== part)) return null
24
+ return parts.slice(base.length).join('/') || '.'
25
+ }
26
+
27
+ // The entries the stasis.config.json in `directory` whose text is `text`
28
+ // names, each as a path from there; null where it has no `entries`. One whose
29
+ // entries Stasis can't tell throws.
30
+ export function parseStasisConfig(text: string, directory = ''): Set<string> | null {
31
+ const file = directory ? `${directory}/${STASIS_CONFIG}` : STASIS_CONFIG
32
+ let config: unknown
33
+ try { config = JSON.parse(text) } catch (cause) { throw new Error(`${file} is not valid JSON`, { cause }) }
34
+ const entries = plainObject(config) ? config['entries'] : undefined
35
+ if (entries === undefined) return null
36
+ if (!plainObject(entries)) throw new Error(`${file}: entries must be an object of each entry's stasis bundle options`)
37
+ const named = new Map<string, string>()
38
+ for (const entry of Object.keys(entries)) {
39
+ const what = `${file}: entries ${JSON.stringify(entry)}`
40
+ const key = configEntryKey(directory, entry)
41
+ if (key === null) throw new Error(`${what}: an entry must be a path relative to the directory of ${STASIS_CONFIG}, within it`)
42
+ if (named.has(key)) throw new Error(`${what} names the same entry as ${JSON.stringify(named.get(key))}`)
43
+ named.set(key, entry)
44
+ }
45
+ return new Set(named.keys())
46
+ }
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedSql } from './sql.ts'
3
+
4
+ // Each team's catalog summary (team-app.ts), kept for every instance: working
5
+ // one out builds the team's whole workspace, which an instance would otherwise
6
+ // do for the first catalog it serves. Keyed by the catalog's own content key
7
+ // (team-app.ts teamAppKey), hashed: the published reports, their content and
8
+ // what the reader is allowed of each (its role, its dependency and security
9
+ // permissions, the team's scopes and links), so readers whose workspaces
10
+ // differ never share one. A change to any of them is a new key, as is a new
11
+ // deployment; one no longer asked for goes once it is old (index.ts reap).
12
+ export const TEAM_APP_SUMMARY_SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS managed_team_app_summary (
14
+ summary_key TEXT PRIMARY KEY,
15
+ summary TEXT NOT NULL,
16
+ stored_at INTEGER NOT NULL
17
+ ) STRICT;
18
+ `
19
+
20
+ export interface TeamAppSummaryStore {
21
+ // Hashed key (teamAppSummaryKey) → summary for those of `keys` kept.
22
+ getTeamAppSummaries(keys: string[]): Promise<Map<string, string>>
23
+ setTeamAppSummary(key: string, summary: string, storedAt: number): Promise<void>
24
+ // Removes those stored before `before`; how many.
25
+ deleteTeamAppSummaries(before: number): Promise<number>
26
+ }
27
+
28
+ // How a summary is worked out (common/workspace-app.js, team-app.js), which a
29
+ // deployment may change: one stored by another deployment, or before this
30
+ // is raised, is worked out again.
31
+ const SUMMARY_VERSION = 1
32
+ const DEPLOYMENT = process.env['VERCEL_DEPLOYMENT_ID'] ?? null
33
+
34
+ export const teamAppSummaryKey = (key: string): string => createHash('sha256').update(JSON.stringify([SUMMARY_VERSION, DEPLOYMENT, key])).digest('base64url')
35
+
36
+ export function teamAppSummaryMethods(db: ManagedSql): TeamAppSummaryStore {
37
+ const getMany = db.prepare('SELECT summary_key, summary FROM managed_team_app_summary WHERE summary_key IN (SELECT value FROM json_each(?))')
38
+ const set = db.prepare(`INSERT INTO managed_team_app_summary (summary_key, summary, stored_at) VALUES (?, ?, ?)
39
+ ON CONFLICT (summary_key) DO UPDATE SET summary = excluded.summary, stored_at = excluded.stored_at`)
40
+ const remove = db.prepare('DELETE FROM managed_team_app_summary WHERE stored_at < ?')
41
+ return {
42
+ async getTeamAppSummaries(keys) {
43
+ if (keys.length === 0) return new Map()
44
+ const rows = await getMany.all(JSON.stringify(keys)) as { summary_key: string; summary: string }[]
45
+ return new Map(rows.map(row => [row.summary_key, row.summary]))
46
+ },
47
+ async setTeamAppSummary(key, summary, storedAt) {
48
+ await set.run(key, summary, storedAt)
49
+ },
50
+ async deleteTeamAppSummaries(before) {
51
+ return Number((await remove.run(before)).changes)
52
+ },
53
+ }
54
+ }
@@ -15,13 +15,12 @@
15
15
  // reports stays expanded, and so does one whose reports each open as App but
16
16
  // whose combined workspace does not (contradicting verdicts, say): it has App
17
17
  // findings, and only expanded does it show that they fail to combine.
18
- import { reportEntries } from '@preventive/report'
19
- import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
20
- import { expandFindingValues } from '../common/managed/finding-values.js'
18
+ import type { TeamAppMetadata } from '../common/managed/team-app.js'
21
19
  import type { BlobStore } from './blob-store.ts'
22
20
  import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
23
21
  import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
24
- import { TeamReportsError, loadTeamReportsResponse } from './team-reports.ts'
22
+ import { TeamReportsError, loadTeamSummary } from './team-reports.ts'
23
+ import { teamAppSummaryKey } from './team-app-summaries.ts'
25
24
 
26
25
  export type { TeamAppMetadata }
27
26
  export interface TeamWorkspaceSummary { app: TeamAppMetadata; empty: readonly string[] }
@@ -56,50 +55,68 @@ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): Te
56
55
  return summary
57
56
  }
58
57
 
59
- function isLinks(data: unknown): boolean {
60
- const record = data as { source?: unknown; links?: unknown } | null
61
- return record?.source === 'links' && Array.isArray(record.links)
58
+ // Summaries kept in the database for every instance (team-app-summaries.ts),
59
+ // by key; a failed read is a miss, never a failed catalog, and an unreadable
60
+ // summary one to work out again.
61
+ async function storedSummaries(db: ManagedDb, keys: readonly string[]): Promise<Map<string, TeamWorkspaceSummary>> {
62
+ const hashed = new Map(keys.map(key => [teamAppSummaryKey(key), key]))
63
+ let rows: Map<string, string>
64
+ try { rows = await db.getTeamAppSummaries([...hashed.keys()]) } catch (error) {
65
+ console.warn('managed: stored team App classification read failed:', error)
66
+ return new Map()
67
+ }
68
+ const summaries = new Map<string, TeamWorkspaceSummary>()
69
+ for (const [hash, text] of rows) {
70
+ const key = hashed.get(hash), summary = readSummary(text)
71
+ if (summary && key !== undefined) summaries.set(key, summary)
72
+ }
73
+ return summaries
62
74
  }
63
75
 
64
- // A links document counts by its links, every other report by its findings.
65
- function hasContent(data: unknown): boolean {
66
- if (isLinks(data)) return (data as { links: unknown[] }).links.length > 0
67
- return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
76
+ function readSummary(text: string): TeamWorkspaceSummary | null {
77
+ try {
78
+ const summary = JSON.parse(text) as TeamWorkspaceSummary
79
+ return typeof summary?.app?.appMode === 'boolean' && Array.isArray(summary.empty) && summary.empty.every(id => typeof id === 'string') ? summary : null
80
+ } catch { return null }
81
+ }
82
+
83
+ // One worked out, remembered and stored for every instance.
84
+ async function settle(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): Promise<TeamWorkspaceSummary> {
85
+ remember(db, key, summary)
86
+ try { await db.setTeamAppSummary(teamAppSummaryKey(key), JSON.stringify(summary), Date.now()) } catch (error) {
87
+ console.warn('managed: storing team App classification failed:', error)
88
+ }
89
+ return summary
68
90
  }
69
91
 
70
92
  // Null when the team cannot be classified right now: its catalog went stale,
71
93
  // the access snapshot is gone, or storage failed. The sidebar then keeps the
72
94
  // team expanded and lists every report, and a later catalog asks again.
73
95
  // Oversized and unreadable workspaces never have an App view, so they are
74
- // remembered as such, hiding nothing.
96
+ // remembered as such, hiding nothing. One kept in memory, or stored (`read`,
97
+ // where the caller has not asked already), is not worked out again.
75
98
  async function classify(db: ManagedDb, store: BlobStore, key: string, team: UserTeam,
76
- snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamWorkspaceSummary | null> {
99
+ snapshot: () => Promise<TeamReportAccessSnapshot | null>, read = true): Promise<TeamWorkspaceSummary | null> {
77
100
  const cached = caches.get(db)?.get(key)
78
101
  if (cached) return remember(db, key, cached)
79
- const ids = new Set(published(team).map(report => report.id))
80
- if (ids.size === 0) return remember(db, key, NO_APP)
102
+ const keys = new Map(published(team).map(report => [report.id, report.cacheKey]))
103
+ if (keys.size === 0) return remember(db, key, NO_APP)
104
+ const stored = read ? (await storedSummaries(db, [key])).get(key) : undefined
105
+ if (stored) return remember(db, key, stored)
81
106
  let jobs = pendingJobs.get(db)
82
107
  if (!jobs) { jobs = new Map(); pendingJobs.set(db, jobs) }
83
108
  const existing = jobs.get(key)
84
109
  if (existing) return existing
85
110
  const job = (async () => {
86
111
  const access = await snapshot()
87
- // A catalog read before a concurrent publication names another report set.
88
- if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
89
- const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
90
- reports: { id: string; filename: string; data: unknown }[]; links?: string[][]; values?: unknown[]
91
- }
92
- for (const report of body.reports) expandFindingValues(report.data, body.values)
93
- const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
94
- const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
95
- const links = body.links ?? []
96
- const app = managedTeamAppMetadata(Object.assign(body.reports, { links }), { countFindings: true })
97
- if (app.appMode) return remember(db, key, { app, empty })
98
- const appReports = body.reports.filter(report => !isLinks(report.data)
99
- && managedTeamAppMetadata(Object.assign([report], { links })).appMode).length
100
- return remember(db, key, { app: { ...app, appReports }, empty })
112
+ // A catalog read before a concurrent change names other reports, or
113
+ // other content or access of them: what is read now is no workspace of
114
+ // its key, so nothing is kept under it.
115
+ if (!access?.teamId || access.reports.length !== keys.size || access.reports.some(report => access.catalogKeys?.[report.id] !== keys.get(report.id))) return null
116
+ // Worked out as the workspace is built (team-reports.ts).
117
+ return settle(db, key, await loadTeamSummary(db, store, access))
101
118
  })().catch((error: unknown) => {
102
- if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
119
+ if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return settle(db, key, NO_APP)
103
120
  if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
104
121
  return null
105
122
  })
@@ -120,11 +137,15 @@ export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId:
120
137
  known: ReadonlyMap<string, TeamWorkspaceSummary | null> = new Map()): Promise<Map<string, TeamWorkspaceSummary | null>> {
121
138
  const states = new Map(known)
122
139
  const missing = [...new Map(teams.map(team => [teamAppKey(team), team])).entries()].filter(([key]) => !states.has(key))
140
+ // Those kept in no memory of this instance's are read of the database at
141
+ // once, in one statement.
142
+ const unknown = missing.filter(([key, team]) => !caches.get(db)?.has(key) && published(team).length > 0).map(([key]) => key)
143
+ for (const [key, summary] of unknown.length > 0 ? await storedSummaries(db, unknown) : []) remember(db, key, summary)
123
144
  let next = 0
124
145
  await Promise.all(Array.from({ length: Math.min(CLASSIFY_CONCURRENCY, missing.length) }, async () => {
125
146
  while (next < missing.length) {
126
147
  const [key, team] = missing[next++]!
127
- states.set(key, await classify(db, store, key, team, () => db.getTeamReportAccessSnapshot(sessionId, Date.now(), team.id)))
148
+ states.set(key, await classify(db, store, key, team, () => db.getTeamReportAccessSnapshot(sessionId, Date.now(), team.id), false))
128
149
  }
129
150
  }))
130
151
  return states
@@ -9,9 +9,11 @@ import { filterReportData, projectFinding } from '../common/managed/report-filte
9
9
  import { revalidationLinks, securityLinks } from '../common/security-links.js'
10
10
  import { treeDependencyDirectory } from '../client/dependency-paths.js'
11
11
  import { liteWorkspace } from '../common/managed/finding-detail.js'
12
+ import { managedTeamView } from '../common/managed/team-app.js'
12
13
  import { expandFindingValues, findingValueIndex } from '../common/managed/finding-values.js'
13
14
  import type { BlobStore } from './blob-store.ts'
14
15
  import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
16
+ import type { TeamWorkspaceSummary } from './team-app.ts'
15
17
  import { triageWireEntry } from './triage-response.ts'
16
18
  import { visibleManagedIssues } from './managed-issues.ts'
17
19
  import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
@@ -25,9 +27,10 @@ const VISIBILITY_CACHE_MAX_SNAPSHOTS = 32
25
27
  const caches = new WeakMap<ManagedDb, Map<string, Visibility>>()
26
28
  const REPORT_CACHE_BYTES = 16 * 1024 * 1024
27
29
  // A workspace's encoded reports, the same without their findings' detail for
28
- // the board (`lite`), and its reports' file trees apart.
29
- type EncodedWorkspace = { reports: Buffer; lite: Buffer; trees: Buffer }
30
- export type TeamResponsePart = keyof EncodedWorkspace
30
+ // the board (`lite`), its reports' file trees apart, and its team's catalog
31
+ // summary (team-app.ts).
32
+ type EncodedWorkspace = { reports: Buffer; lite: Buffer; trees: Buffer; summary: TeamWorkspaceSummary }
33
+ export type TeamResponsePart = 'reports' | 'lite' | 'trees'
31
34
  // The part a team's `reports` or `trees` route asks for.
32
35
  export const teamResponsePart = (route: string, url: URL): TeamResponsePart =>
33
36
  route === 'trees' ? 'trees' : url.searchParams.get('lite') === '1' ? 'lite' : 'reports'
@@ -98,6 +101,12 @@ function groupsOf(data: unknown): Finding[][] {
98
101
  (finding): finding is Finding => finding !== null && typeof finding === 'object' && !Array.isArray(finding),
99
102
  ))
100
103
  }
104
+ // A links document counts by its links, every other report by its findings.
105
+ function hasContent(data: unknown): boolean {
106
+ const links = linksOf(data)
107
+ if (links) return links.length > 0
108
+ return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
109
+ }
101
110
  function linksOf(data: unknown): string[][] | null {
102
111
  const record = data as { source?: string; links?: string[][] }
103
112
  if (record?.source !== 'links' || !Array.isArray(record.links)) return null
@@ -188,14 +197,21 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
188
197
  }
189
198
  const trees = Buffer.from(`{"trees":{${reports.map(withoutTree).filter(tree => tree !== null).join(',')}}}`)
190
199
  const links = mergeLinkGroups(globalLinks, allIds)
191
- // The board's: its findings without their detail, and in App mode its rows
192
- // as the App view shows them (common/managed/finding-detail.js).
193
- const lite = liteWorkspace(reports, links)
200
+ // Its rows read once (common/managed/team-app.js managedTeamView) for both
201
+ // what its catalog entry tells and the board's copy: its findings without
202
+ // their detail, and in App mode its rows as the App view shows them
203
+ // (common/managed/finding-detail.js).
204
+ const view = managedTeamView(Object.assign(reports.map(({ filename, data }) => ({ filename, data })), { links }))
205
+ const lite = liteWorkspace(reports, links, view)
206
+ const restricted = snapshot.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
194
207
  const encoded = {
195
208
  // Bounded together, as when the trees were sent with their reports.
196
209
  reports: encodeReports(reports, links, trees.length),
197
210
  lite: encodeReports(lite.reports, links, 0, { folded: lite.folded, lens: lite.lens }),
198
211
  trees,
212
+ // Its App classification, and the reports a restricted reader finds
213
+ // nothing in, which its catalog leaves out.
214
+ summary: { app: view.app, empty: restricted ? reports.filter(report => !hasContent(report.data)).map(report => report.id) : [] },
199
215
  }
200
216
  retainVisibility(db, workspaceKey(snapshot), visible)
201
217
  retainReports(db, workspaceKey(snapshot), encoded)
@@ -244,6 +260,10 @@ export async function loadTeamReportsResponse(db: ManagedDb, store: BlobStore, s
244
260
  const cached = reportCaches.get(db)?.get(workspaceKey(snapshot))
245
261
  return cached?.[part] ?? (await loadTeamWorkspace(db, store, snapshot))[part]
246
262
  }
263
+ // A team's catalog summary (team-app.ts), as its workspace was built.
264
+ export async function loadTeamSummary(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamWorkspaceSummary> {
265
+ return reportCaches.get(db)?.get(workspaceKey(snapshot))?.summary ?? (await loadTeamWorkspace(db, store, snapshot)).summary
266
+ }
247
267
  export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
248
268
  // Object consumers get their own copy, including when sharing a cold load.
249
269
  const body = JSON.parse((await loadTeamReportsResponse(db, store, snapshot)).toString('utf8')) as { reports: TeamReport[]; values?: unknown[] }
@@ -319,7 +339,7 @@ export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snaps
319
339
  const revision = await teamAnnotationRevision(db, snapshot, [...all])
320
340
  const { triage, comments, issues: savedIssues } = await db.getAnnotations([...ids])
321
341
  const issues = Object.fromEntries(visibleManagedIssues(savedIssues, snapshot)
322
- .map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl }]))
342
+ .map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl, duplicateOf: issue.duplicateOfUrl }]))
323
343
  const entries = Object.fromEntries(triage.map(row => [row.findingId, triageWireEntry(row)]))
324
344
  const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId), ...Object.keys(issues)])
325
345
  const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))