@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.
- package/common/bundle-compare-diff.js +28 -9
- package/common/bundle-compare-inputs.d.ts +1 -0
- package/common/bundle-compare-inputs.js +91 -15
- package/common/bundle-comparison.js +14 -7
- package/common/default-scan-models.ts +3 -0
- package/common/dependency-reviews.d.ts +1 -1
- package/common/dependency-reviews.js +1 -1
- package/common/managed/finding-detail.d.ts +1 -1
- package/common/managed/finding-detail.js +7 -7
- package/common/managed/team-app.d.ts +7 -0
- package/common/managed/team-app.js +31 -7
- package/common/workspace-app.js +19 -7
- package/out/activity.js +4 -4
- package/out/chunk-4EMM6UCD.js +1 -0
- package/out/chunk-4ISO5F7Y.js +7 -0
- package/out/chunk-4XIH7YTX.js +1 -0
- package/out/chunk-6W5BMATD.js +1 -0
- package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
- package/out/chunk-DEVAORP2.js +1 -0
- package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
- package/out/chunk-E3LFSUDJ.js +9 -0
- package/out/chunk-FHIVVVUA.js +6 -0
- package/out/chunk-G3LY2FV4.js +1 -0
- package/out/chunk-HAZKDK4M.js +4 -0
- package/out/chunk-HOB3YPS5.js +1 -0
- package/out/chunk-I47TYPXO.js +52 -0
- package/out/chunk-J7A5OSZE.js +244 -0
- package/out/chunk-JFL7WLRV.js +1 -0
- package/out/chunk-JMG5DXII.js +2 -0
- package/out/chunk-JOXLZXMI.js +1 -0
- package/out/chunk-L4CTEMRG.js +2 -0
- package/out/chunk-M37X4LCF.js +14 -0
- package/out/chunk-M3Y6SLC4.js +1 -0
- package/out/chunk-NXATZJQ2.js +1 -0
- package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
- package/out/chunk-PAD2JHZT.js +1 -0
- package/out/chunk-QMVOEBWC.js +13 -0
- package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
- package/out/chunk-RRBIJL2B.js +1 -0
- package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
- package/out/chunk-TOXMUOIW.js +5 -0
- package/out/chunk-UBOCLLF2.js +1 -0
- package/out/chunk-UCP3JRE2.js +1 -0
- package/out/{chunk-JEFZTVNX.js → chunk-VC72ZO3P.js} +1 -1
- package/out/chunk-VRX2APNA.js +9 -0
- package/out/{chunk-NE2UGKRJ.js → chunk-VZMELPVL.js} +4 -3
- package/out/{chunk-YA4ETCFU.js → chunk-WHVXTJHK.js} +1 -1
- package/out/chunk-WMFDQS2L.js +11 -0
- package/out/chunk-WNSVBOSO.js +41 -0
- package/out/chunk-WREU7JAG.js +1 -0
- package/out/chunk-Y5ZHFNBD.js +2 -0
- package/out/chunk-YF4M5OCI.js +1 -0
- package/out/chunk-Z667U2UJ.js +1 -0
- package/out/chunk-ZQHLECTI.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +27 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +36 -51
- package/out/graph.js +17 -15
- package/out/index.html +26 -10
- package/out/prism.js +1 -1
- package/out/scan.js +2 -2
- package/out/terminal.js +232 -232
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +11 -7
- package/risk/actions/resolve.js +36 -5
- package/risk/index.d.ts +20 -2
- package/risk/npm/malware.js +6 -4
- package/risk/npm/registry.js +8 -6
- package/server-managed/bundle-advisories.ts +56 -74
- package/server-managed/bundle-build-worker.js +61 -4
- package/server-managed/bundle-build.ts +13 -4
- package/server-managed/bundle-dependency-reviews.ts +74 -0
- package/server-managed/bundle-validation.ts +32 -0
- package/server-managed/db-methods.ts +51 -16
- package/server-managed/db-neon.ts +18 -2
- package/server-managed/db.ts +11 -2
- package/server-managed/github-issue-links.ts +151 -47
- package/server-managed/github-pulls.ts +34 -15
- package/server-managed/http.ts +227 -12
- package/server-managed/index.ts +13 -1
- package/server-managed/managed-issues.ts +29 -15
- package/server-managed/npm-insights.ts +4 -3
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/package-entry-points.ts +37 -9
- package/server-managed/pretty-print.ts +7 -5
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-actions.ts +7 -4
- package/server-managed/repository-browser.ts +11 -4
- package/server-managed/repository-risk-audit.ts +4 -5
- package/server-managed/repository-risk.ts +4 -4
- package/server-managed/sql.ts +2 -1
- package/server-managed/stasis-config.ts +46 -0
- package/server-managed/team-app-summaries.ts +54 -0
- package/server-managed/team-app.ts +52 -31
- package/server-managed/team-reports.ts +27 -7
- package/server-managed/upstream-cache.ts +41 -32
- package/server-managed/workspace-shares.ts +3 -1
- package/out/chunk-2ZDZWLFY.js +0 -1
- package/out/chunk-7VAWINQF.js +0 -6
- package/out/chunk-FMXWXDR4.js +0 -36
- package/out/chunk-IZ7POWI3.js +0 -18
- package/out/chunk-LJ7XRXFU.js +0 -52
- package/out/chunk-QB4CBOX2.js +0 -1
- package/out/chunk-TQJDYHVE.js +0 -2
- package/out/chunk-TSIFIFBX.js +0 -232
- package/out/chunk-VH4XMZIW.js +0 -2
- 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.
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
283
|
-
|
|
284
|
-
&& typeof item.
|
|
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 (
|
|
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:
|
|
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 (
|
|
22
|
-
//
|
|
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,
|
|
98
|
-
// advisory's
|
|
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
|
package/server-managed/sql.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
80
|
-
if (
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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`),
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
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))]))
|