@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.
- package/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/managed/report-filter.ts +2 -1
- package/common/managed/routes.js +3 -3
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +8 -0
- package/common/workspace-app.js +14 -1
- package/out/chunk-2ABLKCOY.js +46 -0
- package/out/chunk-5LSXOLUS.js +1 -0
- package/out/chunk-5RCM2J63.js +1 -0
- package/out/chunk-7B6HB4D7.js +1 -0
- package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
- package/out/chunk-CH4UN6WK.js +225 -0
- package/out/chunk-EDKLBQQN.js +17 -0
- package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
- package/out/chunk-FK3DZEKL.js +1 -0
- package/out/chunk-G6AJWENO.js +1 -0
- package/out/chunk-GZWM7UQM.js +2 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-LPCTJYNA.js +7 -0
- package/out/{chunk-DGVQPIYB.js → chunk-O4RJY5TP.js} +1 -1
- package/out/chunk-OD4LPN3H.js +1 -0
- package/out/{chunk-D7AF2JXL.js → chunk-QWDWTBM7.js} +1 -1
- package/out/chunk-QYWKC557.js +1 -0
- package/out/chunk-R75XOKUP.js +1 -0
- package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
- package/out/chunk-T7OKH4VW.js +1 -0
- package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
- package/out/chunk-ZESLQCTF.js +6 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +21 -21
- package/out/client-sync.js +1 -1
- package/out/graph.js +1 -1
- package/out/index.html +14 -10
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +58 -58
- package/package.json +13 -3
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +66 -15
- package/risk/npm/dependencies.js +22 -3
- package/risk/npm/new-dependency.js +42 -12
- package/risk/npm/registry.js +122 -21
- package/server-managed/comparison.ts +41 -0
- package/server-managed/http.ts +161 -56
- package/server-managed/locked-packages.ts +29 -6
- package/server-managed/npm-kept.ts +38 -14
- package/server-managed/npm-packages.ts +27 -13
- package/server-managed/npm-risk.ts +69 -11
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/queue.ts +23 -0
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +66 -0
- package/server-managed/repository-risk.ts +265 -209
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/sql.ts +1 -1
- package/server-managed/team-app.ts +38 -14
- package/server-managed/team-reports.ts +6 -11
- package/server-managed/upstream-cache.ts +16 -0
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-Q7ZSIEHP.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-WOTMRDM3.js +0 -226
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-YP2537WX.js +0 -1
- package/out/chunk-YQRVLRDH.js +0 -1
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
|
@@ -43,7 +43,7 @@ export class NpmPackageError extends Error {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Who reads, as the session has it now: the role, and the scopes of the
|
|
46
|
-
// visible teams they are a member of.
|
|
46
|
+
// visible teams they are a member of, where those count (npmScopesCount).
|
|
47
47
|
export interface NpmReader { role: Role; scopes: ReadonlySet<string>; userId: string }
|
|
48
48
|
|
|
49
49
|
// Whether a reader may have a package read with the server's token.
|
|
@@ -53,8 +53,19 @@ export function canReadPrivateNpm(reader: NpmReader, name: string): boolean {
|
|
|
53
53
|
return scope !== null && reader.scopes.has(scope)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Whether a reader's team scopes count for any of `names`, as
|
|
57
|
+
// canReadPrivateNpm reads them: a scoped one, read by someone below manager.
|
|
58
|
+
export const npmScopesCount = (role: Role, names: readonly string[]): boolean =>
|
|
59
|
+
role !== 'admin' && role !== 'manage' && names.some(name => npmPackageScope(name) !== null)
|
|
60
|
+
|
|
56
61
|
export const npmToken = () => process.env['NPM_TOKEN'] || null
|
|
57
62
|
|
|
63
|
+
// Whether a read of `name` that npm answers none anonymously is asked again
|
|
64
|
+
// with the server's token: by a reader with private access (`privileged`),
|
|
65
|
+
// where there is a token and the package is scoped, as npm's private packages
|
|
66
|
+
// always are. Else the anonymous answer is the reader's.
|
|
67
|
+
export const npmTokenReads = (name: string, privileged: boolean): boolean => privileged && npmToken() !== null && npmPackageScope(name) !== null
|
|
68
|
+
|
|
58
69
|
function registryUrl(name: string, spec?: string): string {
|
|
59
70
|
return `${NPM_REGISTRY}/${name}${spec === undefined ? '' : `/${encodeURIComponent(spec)}`}`
|
|
60
71
|
}
|
|
@@ -138,16 +149,17 @@ async function publicWholeDocument(name: string, url: string, { limit, signal }:
|
|
|
138
149
|
return json
|
|
139
150
|
}
|
|
140
151
|
|
|
141
|
-
// Asked anonymously first
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// whole document (`whole`) anyone may read is kept
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
// Asked anonymously first, unless its caller knows npm has none for anyone
|
|
153
|
+
// (`anonymous` false): a reader without private access stops there. One
|
|
154
|
+
// with it asks again with the token where npmTokenReads says so, and the
|
|
155
|
+
// answer is private. A whole document (`whole`) anyone may read is kept
|
|
156
|
+
// (publicWholeDocument).
|
|
157
|
+
async function readDocument(name: string, url: string, privileged: boolean,
|
|
158
|
+
{ whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
|
|
159
|
+
const open = anonymous ? whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
|
|
147
160
|
if (open) return { json: open, private: false }
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const json = await registryDocument(url, { ...options, token })
|
|
161
|
+
if (!npmTokenReads(name, privileged)) return null
|
|
162
|
+
const json = await registryDocument(url, { ...options, token: npmToken() })
|
|
151
163
|
return json ? { json, private: true } : null
|
|
152
164
|
}
|
|
153
165
|
|
|
@@ -244,10 +256,12 @@ export function readNpmVersions(name: string, privileged: boolean, signal: Abort
|
|
|
244
256
|
// A package's whole document as npm has it for this reader, for risk
|
|
245
257
|
// (npm-risk.ts), within the bounds and budget every document read here is
|
|
246
258
|
// held to; null where npm has none for this reader, or the name is none npm
|
|
247
|
-
// takes.
|
|
248
|
-
|
|
259
|
+
// takes. With `anonymous` false, where npm is known to have none for anyone,
|
|
260
|
+
// it is asked only with the token (readDocument).
|
|
261
|
+
export function readNpmPackument(name: string, privileged: boolean, signal: AbortSignal, { anonymous = true } = {}): Promise<Record<string, unknown> | null> {
|
|
249
262
|
if (!isNpmPackageName(name)) return Promise.resolve(null)
|
|
250
|
-
return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
|
|
263
|
+
return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
|
|
264
|
+
{ accept: 'application/json', limit: PACKUMENT_BYTES, signal, whole: true, anonymous }))?.json ?? null)
|
|
251
265
|
}
|
|
252
266
|
|
|
253
267
|
const isPublishTime = (value: string | undefined) => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
// - `risk/npm/versions`, what a version's document says, which npm never
|
|
10
10
|
// changes once published: a public package's in the database's upstream
|
|
11
11
|
// cache table, shared by every instance; a private one's in memory.
|
|
12
|
-
// - `risk/npm/
|
|
13
|
-
//
|
|
12
|
+
// - `risk/npm/downloads`, a package's downloads a day, which risk tells as
|
|
13
|
+
// kept for a week: a public package's in the database too, so that another
|
|
14
|
+
// instance, or this one restarted, asks npm's downloads API, which answers
|
|
15
|
+
// some 40 lookups a minute, none of them again that week, each removed a
|
|
16
|
+
// day past it (npm-kept.ts pruneKept); a private one's in memory.
|
|
17
|
+
// - `risk/npm/packages`, `risk/npm/public` and `risk/npm/advisories`, kept an
|
|
18
|
+
// hour or a day by risk itself: in memory.
|
|
14
19
|
// - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
|
|
15
20
|
// read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
|
|
16
21
|
// an assessment kept needs none.
|
|
@@ -28,11 +33,12 @@ import { Buffer } from 'node:buffer'
|
|
|
28
33
|
import { createHash } from 'node:crypto'
|
|
29
34
|
import { buffer } from 'node:stream/consumers'
|
|
30
35
|
import type { CacheStore } from '@preventive/upstream/npm.js'
|
|
31
|
-
import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm } from '../risk/index.js'
|
|
36
|
+
import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
|
|
32
37
|
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
33
38
|
import { loadNpmTarball } from './npm-loads.ts'
|
|
34
|
-
import { NpmPackageError, type NpmVersionDocument, readNpmPackument, readNpmVersion } from './npm-packages.ts'
|
|
39
|
+
import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
|
|
35
40
|
import { npmPackageScope } from '../common/managed/npm-packages.js'
|
|
41
|
+
import { pruneKept } from './npm-kept.ts'
|
|
36
42
|
import type { UpstreamCacheStore } from './upstream-cache.ts'
|
|
37
43
|
|
|
38
44
|
const MEMORY_ENTRIES = 2000
|
|
@@ -63,6 +69,10 @@ function remember(key: string, value: unknown) {
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
|
|
72
|
+
// What of a public package is kept in the database, and for how long its
|
|
73
|
+
// downloads are: risk's week, and a day more.
|
|
74
|
+
const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
|
|
75
|
+
const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
|
|
66
76
|
|
|
67
77
|
export interface NpmRiskStores {
|
|
68
78
|
db: UpstreamCacheStore
|
|
@@ -70,16 +80,38 @@ export interface NpmRiskStores {
|
|
|
70
80
|
debug?: boolean | undefined
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
// A cache that reads and writes many of a type at once too, as risk's
|
|
84
|
+
// readying of downloads asks (npmReadyDownloads): one read and one write of
|
|
85
|
+
// the database each, not one a key, as a request's queries take turns on its
|
|
86
|
+
// connection (db-neon.ts).
|
|
87
|
+
export interface NpmRiskCache extends CacheStore {
|
|
88
|
+
readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
|
|
89
|
+
writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
// Risk's cache for one package: `isPublic` whether anyone can read it, so
|
|
74
93
|
// that what is kept of it can be shared.
|
|
75
|
-
export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean):
|
|
94
|
+
export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
|
|
76
95
|
const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
|
|
77
|
-
|
|
96
|
+
const cache: NpmRiskCache = {
|
|
97
|
+
async readMany(type, keys) {
|
|
98
|
+
if (!isPublic || !IN_DATABASE.has(type)) return new Map(await Promise.all(keys.map(async key => [key, await cache.read(type, key)] as const)))
|
|
99
|
+
try {
|
|
100
|
+
const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
|
|
101
|
+
return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
|
|
102
|
+
} catch (error) { warn('read', error); return new Map() }
|
|
103
|
+
},
|
|
104
|
+
async writeMany(type, entries) {
|
|
105
|
+
if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
|
|
106
|
+
const at = Date.now()
|
|
107
|
+
try { await db.setUpstreamCacheEntries(entries.map(([key, value]) => [`${type}/${key}`, JSON.stringify(value)]), at) } catch (error) { warn('write', error); return }
|
|
108
|
+
if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
|
|
109
|
+
},
|
|
78
110
|
async read(type, key) {
|
|
79
111
|
if (type === 'npm/tarballs') return null
|
|
80
112
|
try {
|
|
81
113
|
if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
|
|
82
|
-
if (isPublic && type
|
|
114
|
+
if (isPublic && IN_DATABASE.has(type)) { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
|
|
83
115
|
} catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
|
|
84
116
|
return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
|
|
85
117
|
},
|
|
@@ -87,11 +119,17 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
|
|
|
87
119
|
if (type === 'npm/tarballs') return
|
|
88
120
|
try {
|
|
89
121
|
if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
|
|
90
|
-
if (isPublic &&
|
|
122
|
+
if (isPublic && IN_DATABASE.has(type)) {
|
|
123
|
+
const at = Date.now()
|
|
124
|
+
await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), at)
|
|
125
|
+
if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
91
128
|
} catch (error) { warn('write', error); return }
|
|
92
129
|
remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
|
|
93
130
|
},
|
|
94
131
|
}
|
|
132
|
+
return cache
|
|
95
133
|
}
|
|
96
134
|
|
|
97
135
|
// A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
|
|
@@ -160,8 +198,9 @@ async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Pr
|
|
|
160
198
|
// by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
|
|
161
199
|
// where anyone can read its package, OSV's, which alone has some of those
|
|
162
200
|
// npm removed (nx's), a private package's name never sent to OSV. Its
|
|
163
|
-
// package's document read anonymously, else with npm's token where
|
|
164
|
-
//
|
|
201
|
+
// package's document read anonymously, else with npm's token alone where
|
|
202
|
+
// that reads more for this reader (`privileged`, npmTokenReads), and kept as
|
|
203
|
+
// a private package's is; no
|
|
165
204
|
// tarball read, nothing of the version being left. Spare, within
|
|
166
205
|
// MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
|
|
167
206
|
// nothing tells of it.
|
|
@@ -171,7 +210,26 @@ export function npmRiskRemoved(name: string, version: string,
|
|
|
171
210
|
const open = await readNpmPackument(name, false, signal)
|
|
172
211
|
return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
|
|
173
212
|
osv: open !== null, cache: npmRiskCache(stores, false), signal,
|
|
174
|
-
packument: (read, { authorized }) => read === name
|
|
213
|
+
packument: (read, { authorized }) => read === name
|
|
214
|
+
? open || !npmTokenReads(name, privileged && authorized) ? Promise.resolve(open) : readNpmPackument(name, true, signal, { anonymous: false })
|
|
215
|
+
: readNpmPackument(read, privileged && authorized, signal),
|
|
175
216
|
tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
|
|
176
217
|
})
|
|
177
218
|
}
|
|
219
|
+
|
|
220
|
+
// What a scan readies before assessing its packages (repository-risk.ts):
|
|
221
|
+
// their downloads, as risk asks them, kept where a public package's are
|
|
222
|
+
// (npmRiskCache), so that their assessments wait on npm's downloads API for
|
|
223
|
+
// none. At once, those kept and unscoped ones, in bulk, all public, as only a
|
|
224
|
+
// scoped package can be private; each other one on its own
|
|
225
|
+
// (npmReadyScanDownload), kept only where npm counts some, which it does of
|
|
226
|
+
// public packages alone: a private one's none, as of one it has no such
|
|
227
|
+
// package of, isn't kept where public ones are, for its assessment to ask
|
|
228
|
+
// its own where private ones are.
|
|
229
|
+
export const npmReadyScanDownloads = (names: string[], ready: (name: string) => void, stores: NpmRiskStores, signal: AbortSignal) =>
|
|
230
|
+
npmReadyDownloads(names, ready, { cache: npmRiskCache(stores, true), signal })
|
|
231
|
+
export function npmReadyScanDownload(name: string, stores: NpmRiskStores, signal: AbortSignal) {
|
|
232
|
+
const cache = npmRiskCache(stores, true)
|
|
233
|
+
const counted = (value: unknown) => !(value && typeof value === 'object' && 'none' in value)
|
|
234
|
+
return npmReadyDownload(name, { cache: { read: cache.read, write: (type, key, value) => counted(value) ? cache.write(type, key, value) : Promise.resolve() }, signal })
|
|
235
|
+
}
|
|
@@ -250,18 +250,21 @@ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) =>
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// A bundle's details, from its bytes in `store`: bundles are read one at a
|
|
254
|
+
// time, as reading one decodes all of it.
|
|
255
|
+
function detailsReader(store: BundleStore) {
|
|
256
|
+
const enqueue = serial()
|
|
257
|
+
return (record: ManagedBundle) => enqueue(async () => {
|
|
258
|
+
const details = await readBundleDetails(record, store)
|
|
259
|
+
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
260
|
+
return details
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
253
264
|
export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
|
|
254
265
|
// Copies being made; readers asking for one meanwhile share it.
|
|
255
266
|
const shared = sharing<Buffer>()
|
|
256
|
-
|
|
257
|
-
const enqueue = serial()
|
|
258
|
-
function bundleDetails(record: ManagedBundle) {
|
|
259
|
-
return enqueue(async () => {
|
|
260
|
-
const details = await readBundleDetails(record, store)
|
|
261
|
-
if (!details) throw new PrettyError(422, 'bundle-unavailable')
|
|
262
|
-
return details
|
|
263
|
-
})
|
|
264
|
-
}
|
|
267
|
+
const bundleDetails = detailsReader(store)
|
|
265
268
|
// A bundle's file pretty-printed from the text `read` reads, and kept in
|
|
266
269
|
// its cache.
|
|
267
270
|
function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
|
|
@@ -341,6 +344,8 @@ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage
|
|
|
341
344
|
made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
|
|
342
345
|
return [await sideOf(base, 'base'), await sideOf(other, 'other')]
|
|
343
346
|
},
|
|
347
|
+
// A bundle's details, read in its turn, as Compare pairs them (comparison.ts).
|
|
348
|
+
bundleDetails,
|
|
344
349
|
async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
|
|
345
350
|
const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
|
|
346
351
|
kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
|
|
@@ -17,7 +17,8 @@ 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'
|
|
19
19
|
import { typeOnlyFiles } from './type-only.ts'
|
|
20
|
-
import {
|
|
20
|
+
import { comparisonBody } from './comparison.ts'
|
|
21
|
+
import { sharedTeamApp, withWorkspaceSummary } from './team-app.ts'
|
|
21
22
|
|
|
22
23
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
23
24
|
sendJson(res, status, body, { 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
@@ -35,7 +36,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
35
36
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
36
37
|
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
37
38
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
38
|
-
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only)$/u.exec(url.pathname)
|
|
39
|
+
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories|pretty|rename-diff|type-only|comparison)$/u.exec(url.pathname)
|
|
39
40
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
40
41
|
const tokenHash = hashToken(token)
|
|
41
42
|
const snapshot = await deps.db.getWorkspaceShare(tokenHash)
|
|
@@ -46,8 +47,8 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
46
47
|
}
|
|
47
48
|
const send = async (body: unknown) => { await recheck(); json(res, 200, body) }
|
|
48
49
|
const sendTeam = async () => {
|
|
49
|
-
const [summaries,
|
|
50
|
-
await send({ user: snapshot.user, team: { ...snapshot.team,
|
|
50
|
+
const [summaries, workspace] = await Promise.all([bundleSummaries(snapshot.team.bundles, deps.bundleCache), sharedTeamApp(deps.db, deps.reportStore, snapshot)])
|
|
51
|
+
await send({ user: snapshot.user, team: { ...withWorkspaceSummary(snapshot.team, workspace),
|
|
51
52
|
bundles: snapshot.team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })),
|
|
52
53
|
} })
|
|
53
54
|
await backfillBundleSummaries(snapshot.team.bundles, deps.bundleCache)
|
|
@@ -101,7 +102,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
101
102
|
const bundle = snapshot.bundles.find(item => item.id === bundleRoute![1])
|
|
102
103
|
if (!bundle) { json(res, 404, { error: 'no-bundle' }); return }
|
|
103
104
|
if (bundleRoute![2] === 'advisories' && !snapshot.permissions.security) { json(res, 403, { error: 'security-access-required' }); return }
|
|
104
|
-
if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
|
|
105
|
+
if (bundleRoute![2] === 'rename-diff' || bundleRoute![2] === 'type-only' || bundleRoute![2] === 'comparison' || (bundleRoute![2] === 'pretty' && url.searchParams.has('other'))) {
|
|
105
106
|
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
106
107
|
await serveCompared(res, deps, bundle, snapshot.bundles.find(item => item.id === url.searchParams.get('other')), bundleRoute![2], url, recheck, stream); return
|
|
107
108
|
}
|
|
@@ -109,11 +110,12 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
// What a bundle is compared with `other`, another of the link's bundles, by:
|
|
112
|
-
// one of their JavaScript files diffed
|
|
113
|
-
// files that changed between them, or the
|
|
114
|
-
// changed in types alone, a line each.
|
|
113
|
+
// their files paired, as one comparison; one of their JavaScript files diffed
|
|
114
|
+
// with renamed names hidden; or the files that changed between them, or the
|
|
115
|
+
// pairs of those, or those that changed in types alone, a line each.
|
|
115
116
|
function serveCompared(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, part: string, url: URL,
|
|
116
117
|
recheck: () => Promise<void>, stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
118
|
+
if (part === 'comparison') return serveComparison(res, deps, bundle, other, url, stream)
|
|
117
119
|
if (part === 'rename-diff' && url.searchParams.has('path')) return serveRenameDiff(res, deps, bundle, other, url, stream)
|
|
118
120
|
return serveChanges(res, deps, bundle, other, recheck, part === 'pretty' ? compared : part === 'type-only' ? typeOnlyFiles : renamedPairs,
|
|
119
121
|
part === 'pretty' ? '' : url.searchParams.get('scope') ?? '')
|
|
@@ -139,6 +141,22 @@ async function serveRenameDiff(res: ServerResponse, deps: ManagedHttpDeps, bundl
|
|
|
139
141
|
await stream(diff, 'br')
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// A bundle's files paired with `other`'s, another of the link's bundles, as
|
|
145
|
+
// Compare pairs them (comparison.ts), within a reason `scope` where given.
|
|
146
|
+
async function serveComparison(res: ServerResponse, deps: ManagedHttpDeps, bundle: ManagedBundle, other: ManagedBundle | undefined, url: URL,
|
|
147
|
+
stream: (stored: OpenedBlob, encoding: string | null, contentType?: string) => Promise<void>) {
|
|
148
|
+
if (!other) { json(res, 404, { error: 'no-bundle' }); return }
|
|
149
|
+
const { prettyCache } = deps
|
|
150
|
+
if (!prettyCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
151
|
+
let body
|
|
152
|
+
try { body = await comparisonBody(await prettyCache.bundleDetails(bundle), await prettyCache.bundleDetails(other), url.searchParams.get('scope') ?? '') }
|
|
153
|
+
catch (err) {
|
|
154
|
+
if (err instanceof PrettyError) { json(res, err.status, { error: err.message }); return }
|
|
155
|
+
throw err
|
|
156
|
+
}
|
|
157
|
+
await stream(body, 'br')
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
// The files that changed between a bundle and `other`, another of the link's
|
|
143
161
|
// bundles, both sides' pretty-printed (pretty-print.ts compared), or the pairs
|
|
144
162
|
// of them its viewer diffs with renamed names hidden (rename-diff.ts
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Items in the order they are added, each taken once by whichever reader asks
|
|
2
|
+
// first: none once closed and all taken, or once `signal` aborts, but those
|
|
3
|
+
// added by then where `drain`, none waited for.
|
|
4
|
+
export interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
|
|
5
|
+
export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T> {
|
|
6
|
+
const items: T[] = []
|
|
7
|
+
const waiting: (() => void)[] = []
|
|
8
|
+
let closed = false
|
|
9
|
+
const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
|
|
10
|
+
signal.addEventListener('abort', wake, { once: true })
|
|
11
|
+
return {
|
|
12
|
+
add(item) { items.push(item); wake() },
|
|
13
|
+
close() { closed = true; wake() },
|
|
14
|
+
async take() {
|
|
15
|
+
for (;;) {
|
|
16
|
+
if (signal.aborted && !drain) return undefined
|
|
17
|
+
const item = items.shift()
|
|
18
|
+
if (item !== undefined || closed || signal.aborted) return item
|
|
19
|
+
await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
|
29
29
|
}
|
|
30
30
|
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
31
31
|
const key = createHash('sha256').update(JSON.stringify([
|
|
32
|
-
'finding-access-
|
|
32
|
+
'finding-access-v10', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
33
33
|
])).digest('hex')
|
|
34
34
|
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
35
35
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A package's risk line, as a repository risk scan tells it
|
|
2
|
+
// (repository-risk.ts): its version's document read and assessed, as risk
|
|
3
|
+
// tells one npm removed where npm has none; and how a failure is told.
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
+
import { NpmRiskError, SEVERITIES, type Severity } from '../risk/index.js'
|
|
6
|
+
import { GithubApiError } from './github-errors.ts'
|
|
7
|
+
import { NpmPackageError, type NpmVersionDocument } from './npm-packages.ts'
|
|
8
|
+
import type { RepositoryRiskLine } from './repository-risk.ts'
|
|
9
|
+
|
|
10
|
+
// An assessment refused as busy (`npm-busy`, by npm-risk.ts or the npm reads
|
|
11
|
+
// beneath it), or that npm asked to ask later (risk's `upstream-busy`, as its
|
|
12
|
+
// downloads API answers past a burst of requests), is asked again after a
|
|
13
|
+
// wait, up to this many times.
|
|
14
|
+
export const BUSY_RETRIES = 20
|
|
15
|
+
export const BUSY_WAIT_MS = 1000
|
|
16
|
+
|
|
17
|
+
// How long a package's risk line, or a lockfile's advisories, is told as it
|
|
18
|
+
// is: after it, a risk line is told stale and assessed again, as risk tells
|
|
19
|
+
// again what of a package changes in time, and advisories asked again, as npm
|
|
20
|
+
// publishes them (repository-risk.ts).
|
|
21
|
+
export const RISK_KEPT_MS = 60 * 60_000
|
|
22
|
+
|
|
23
|
+
// The most severe of `findings`, or null for none.
|
|
24
|
+
export function highestSeverity(findings: readonly { severity: Severity }[]): Severity | null {
|
|
25
|
+
return SEVERITIES.find(severity => findings.some(finding => finding.severity === severity)) ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Why a step failed, as a line says it: an error's code, and for a lockfile
|
|
29
|
+
// refused, what of it its reader refused.
|
|
30
|
+
export type RiskFailure = { error: string; detail?: string }
|
|
31
|
+
type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
|
|
32
|
+
|
|
33
|
+
// What an error is known by: a GitHub or npm failure's code, or `failed`;
|
|
34
|
+
// GitHub not reached with what failed (githubUnreachable), while `doing` what
|
|
35
|
+
// of a repository, and risk's with what it asked of npm and how that failed,
|
|
36
|
+
// where they say.
|
|
37
|
+
export function failureOf(error: unknown, doing?: string): RiskFailure {
|
|
38
|
+
if (error instanceof GithubApiError && error.detail) return { error: error.message, detail: doing ? `${doing}: ${error.detail}` : error.detail }
|
|
39
|
+
if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
|
|
40
|
+
if (!(error instanceof NpmRiskError)) return { error: 'failed' }
|
|
41
|
+
return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
|
|
45
|
+
|
|
46
|
+
// A package to assess: npm's of its name and version, or, with an
|
|
47
|
+
// `integrity`, a mirror's held to it.
|
|
48
|
+
export interface AssessedPackage { name: string; version: string; integrity?: string }
|
|
49
|
+
|
|
50
|
+
export type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
|
|
51
|
+
|
|
52
|
+
// What of a scan's source assessing a package asks (RepositoryRiskSource).
|
|
53
|
+
export interface AssessingSource {
|
|
54
|
+
version(name: string, version: string, signal: AbortSignal): Promise<NpmVersionDocument>
|
|
55
|
+
assess(doc: NpmVersionDocument, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
|
|
56
|
+
removed(name: string, version: string, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A package's risk line, its version's document kept and the assessment asked
|
|
60
|
+
// again after a wait while the server is busy; null once the scan stops. A
|
|
61
|
+
// version npm has no document of is told as risk tells one npm removed
|
|
62
|
+
// (removedOf), its malware keeping whether its package was taken down.
|
|
63
|
+
export async function assessPackage(source: AssessingSource, signal: AbortSignal, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
|
|
64
|
+
const at = { type: 'risk', ...pkg } as const
|
|
65
|
+
let doc: NpmVersionDocument | NpmPackageError | undefined
|
|
66
|
+
for (let attempt = 0; ; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
const read = doc ??= await documentOf(source, pkg, signal)
|
|
69
|
+
if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
|
|
70
|
+
const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
|
|
71
|
+
const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
|
|
72
|
+
return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (signal.aborted) return null
|
|
75
|
+
if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
|
|
76
|
+
try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// npm's document of a version, or its answer that it has none (a 404).
|
|
82
|
+
async function documentOf(source: AssessingSource, pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
|
|
83
|
+
try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
|
|
84
|
+
if (error instanceof NpmPackageError && error.status === 404) return error
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A version's assessment; where risk finds npm no longer has it, as a document
|
|
90
|
+
// read from where versions' are kept doesn't tell, as one npm removed.
|
|
91
|
+
async function assessedOf(source: AssessingSource, pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
|
|
92
|
+
try { return await source.assess(doc, teamScoped, signal) } catch (error) {
|
|
93
|
+
if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
|
|
94
|
+
return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A version npm has no document of, as risk tells one npm removed, as it
|
|
99
|
+
// removes every one it finds malware in (`removed`): `gone`, npm's answer of
|
|
100
|
+
// none, thrown where nothing tells of it.
|
|
101
|
+
async function removedOf(source: AssessingSource, pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
|
|
102
|
+
try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
|
|
103
|
+
throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// A lockfile's advisories, as a repository risk scan asks them
|
|
2
|
+
// (repository-risk.ts): npm's registry's of its packages of the registry or
|
|
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
|
|
6
|
+
// publishes advisories, or why they couldn't be had for AUDIT_FAILED_KEPT_MS.
|
|
7
|
+
import type { Advisory } from '@preventive/upstream/advisories.js'
|
|
8
|
+
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
9
|
+
import { npmMalwareAdvisory } from '../risk/index.js'
|
|
10
|
+
import { isNpmPackageName } from '../common/managed/npm-packages.js'
|
|
11
|
+
import type { LockedPackage } from './locked-packages.ts'
|
|
12
|
+
import type { Lru } from './lru.ts'
|
|
13
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
14
|
+
import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
|
|
15
|
+
|
|
16
|
+
export type AdvisoryRow = Advisory & { malware: boolean }
|
|
17
|
+
// A lockfile's advisories, or why they couldn't be had.
|
|
18
|
+
export type Audit = AdvisoryRow[] | RiskFailure
|
|
19
|
+
|
|
20
|
+
export interface AuditSource {
|
|
21
|
+
// npm's registry's advisories of these packages' versions, asked at once.
|
|
22
|
+
advisories(packages: { name: string; versions: string[] }[], signal: AbortSignal): Promise<Advisory[]>
|
|
23
|
+
// Where audits are kept beyond this instance, by the lockfile's blob.
|
|
24
|
+
audits?: KeptStore<Audit>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// How long a lockfile's advisories that couldn't be had are told so, rather
|
|
28
|
+
// than asked again: a scan's next parts don't wait on an audit that failed,
|
|
29
|
+
// as by timing out, again and again.
|
|
30
|
+
const AUDIT_FAILED_KEPT_MS = 10 * 60_000
|
|
31
|
+
|
|
32
|
+
// Whether an audit kept `at` is told as it is: its advisories within
|
|
33
|
+
// RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
|
|
34
|
+
const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
|
|
35
|
+
|
|
36
|
+
// The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
|
|
37
|
+
// the source's), else asked and kept; null where it locks none to ask of, or
|
|
38
|
+
// once `signal` aborts.
|
|
39
|
+
export async function auditLockfile(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal, key: string,
|
|
40
|
+
packages: readonly LockedPackage[]): Promise<Audit | null> {
|
|
41
|
+
let kept = memory.get(key)
|
|
42
|
+
if (!kept || !isFresh(kept)) {
|
|
43
|
+
const stored = await source.audits?.read(key)
|
|
44
|
+
kept = stored ? { at: stored.at, audit: stored.value } : undefined
|
|
45
|
+
}
|
|
46
|
+
if (kept && isFresh(kept)) { memory.set(key, kept); return kept.audit }
|
|
47
|
+
const asked = new Map<string, Set<string>>()
|
|
48
|
+
for (const { name, version, source: from } of packages) {
|
|
49
|
+
if ((from !== 'registry' && from !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
|
|
50
|
+
if (!asked.has(name)) asked.set(name, new Set())
|
|
51
|
+
asked.get(name)!.add(version)
|
|
52
|
+
}
|
|
53
|
+
if (asked.size === 0) return null
|
|
54
|
+
let audit: Audit
|
|
55
|
+
try {
|
|
56
|
+
audit = (await source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), signal))
|
|
57
|
+
.map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (signal.aborted) return null
|
|
60
|
+
audit = failureOf(error)
|
|
61
|
+
}
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
memory.set(key, { at: now, audit })
|
|
64
|
+
await source.audits?.write(key, audit, now)
|
|
65
|
+
return audit
|
|
66
|
+
}
|