@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.44",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"server-managed/cache-storage.ts",
|
|
109
109
|
"server-managed/cli.js",
|
|
110
110
|
"server-managed/combined.ts",
|
|
111
|
+
"server-managed/comparison.ts",
|
|
111
112
|
"server-managed/comments.ts",
|
|
112
113
|
"server-managed/config.ts",
|
|
113
114
|
"server-managed/content-access.ts",
|
|
@@ -152,6 +153,7 @@
|
|
|
152
153
|
"server-managed/rename-diff.ts",
|
|
153
154
|
"server-managed/rename-diff-worker.js",
|
|
154
155
|
"server-managed/public-workspace.ts",
|
|
156
|
+
"server-managed/queue.ts",
|
|
155
157
|
"server-managed/repo-path.ts",
|
|
156
158
|
"server-managed/report-migration.ts",
|
|
157
159
|
"server-managed/report-query.ts",
|
|
@@ -164,6 +166,9 @@
|
|
|
164
166
|
"server-managed/repository-discovery.ts",
|
|
165
167
|
"server-managed/repository-policy.ts",
|
|
166
168
|
"server-managed/repository-risk.ts",
|
|
169
|
+
"server-managed/repository-risk-assess.ts",
|
|
170
|
+
"server-managed/repository-risk-audit.ts",
|
|
171
|
+
"server-managed/scan-downloads.ts",
|
|
167
172
|
"server-managed/session.ts",
|
|
168
173
|
"server-managed/slugs.ts",
|
|
169
174
|
"server-managed/solidity-entry-points.ts",
|
|
@@ -202,6 +207,9 @@
|
|
|
202
207
|
"common/bundle-commit.d.ts",
|
|
203
208
|
"common/bundle-commit.js",
|
|
204
209
|
"common/bundle-compare-diff.js",
|
|
210
|
+
"common/bundle-compare-versions.js",
|
|
211
|
+
"common/bundle-comparison.d.ts",
|
|
212
|
+
"common/bundle-comparison.js",
|
|
205
213
|
"common/bundle-compare-diff.d.ts",
|
|
206
214
|
"common/bundle-compare-inputs.d.ts",
|
|
207
215
|
"common/bundle-compare-inputs.js",
|
|
@@ -255,6 +263,8 @@
|
|
|
255
263
|
"common/revalidation-input-groups.js",
|
|
256
264
|
"common/server-info.ts",
|
|
257
265
|
"common/scan-server.ts",
|
|
266
|
+
"common/security-links.d.ts",
|
|
267
|
+
"common/security-links.js",
|
|
258
268
|
"common/utf8.d.ts",
|
|
259
269
|
"common/utf8.js",
|
|
260
270
|
"common/workspace-app.js",
|
|
@@ -346,9 +356,9 @@
|
|
|
346
356
|
"dependencies": {
|
|
347
357
|
"@exodus/stasis": "1.0.0-beta.9",
|
|
348
358
|
"@exodus/stasis-core": "1.0.0-beta.9",
|
|
349
|
-
"@preventive/compare": "^1.2.
|
|
359
|
+
"@preventive/compare": "^1.2.10",
|
|
350
360
|
"@preventive/diff": "^1.0.2",
|
|
351
|
-
"@preventive/lockfile": "1.
|
|
361
|
+
"@preventive/lockfile": "1.1.1",
|
|
352
362
|
"@preventive/publishers": "^1.1.0",
|
|
353
363
|
"@preventive/report": "1.0.0-alpha.9",
|
|
354
364
|
"@preventive/sourcemap": "1.0.0-alpha.1",
|
package/risk/index.d.ts
CHANGED
|
@@ -114,6 +114,13 @@ export interface DependencyRisk {
|
|
|
114
114
|
uncounted?: true
|
|
115
115
|
created?: string
|
|
116
116
|
gap?: number
|
|
117
|
+
// For one nothing imports, the version its spec installs (registry.js
|
|
118
|
+
// npmPublicFacts): its package's latest beside it, when it was published,
|
|
119
|
+
// whether it has install scripts, and whether that was since the version
|
|
120
|
+
// compared with (`new`).
|
|
121
|
+
installs?: { version: string; latest: string | null; published: string | null; scripts: boolean; new: boolean }
|
|
122
|
+
// Where its package was looked up and npm has none of it for anyone.
|
|
123
|
+
missing?: true
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
// A version's risk as one number (risk/npm/score.js): what its findings call
|
|
@@ -147,6 +154,25 @@ export interface NpmAssessment {
|
|
|
147
154
|
|
|
148
155
|
export function assessNpm(name: string, version: string, options?: AssessNpmOptions): Promise<NpmAssessment>
|
|
149
156
|
|
|
157
|
+
// Readies, at once, the downloads assessNpm asks of each of these packages
|
|
158
|
+
// that can be, kept in `cache` as it keeps them, so that their assessments
|
|
159
|
+
// ask npm none: those kept within a week, and unscoped ones, asked of npm 128
|
|
160
|
+
// at a time. `ready` is told each once its downloads are kept; the rest (scoped
|
|
161
|
+
// ones, which npm answers only one by one) are left for npmReadyDownload.
|
|
162
|
+
// npm asking to be asked later is thrown as an NpmRiskError
|
|
163
|
+
// (`upstream-busy`), those readied by then told.
|
|
164
|
+
// A cache that reads and writes many at once too (`readMany`, `writeMany`)
|
|
165
|
+
// is read once and written once a lookup.
|
|
166
|
+
export function npmReadyDownloads(names: string[], ready: (name: string) => void, options: { cache: CacheStore & {
|
|
167
|
+
readMany?(type: string, keys: string[]): Promise<Map<string, unknown>>
|
|
168
|
+
writeMany?(type: string, entries: [key: string, value: unknown][]): Promise<void>
|
|
169
|
+
}; signal?: AbortSignal; now?: number }): Promise<void>
|
|
170
|
+
|
|
171
|
+
// Readies the downloads assessNpm asks of a package, asked of npm on their
|
|
172
|
+
// own where they aren't kept; npm asking to be asked later thrown as an
|
|
173
|
+
// NpmRiskError (`upstream-busy`).
|
|
174
|
+
export function npmReadyDownload(name: string, options: { cache: CacheStore; signal?: AbortSignal; now?: number }): Promise<void>
|
|
175
|
+
|
|
150
176
|
// Whether an advisory is of malware rather than a vulnerability: OSV's of a
|
|
151
177
|
// malicious package (`MAL-…`), or one such a record names (its `aliases`),
|
|
152
178
|
// CWE-506, embedded malicious code, whatever its title, or a title naming
|
package/risk/index.js
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
export { assessNpm } from './npm/assess.js'
|
|
4
4
|
export { npmMalwareAdvisory } from './npm/malware.js'
|
|
5
5
|
export { DEFAULT_MINIMUM_RELEASE_AGE } from './npm/release.js'
|
|
6
|
-
export { NpmRiskError } from './npm/registry.js'
|
|
6
|
+
export { NpmRiskError, npmReadyDownload, npmReadyDownloads } from './npm/registry.js'
|
|
7
7
|
export { SEVERITIES, npmFileRisk } from './files/stamps.js'
|
package/risk/npm/assess.js
CHANGED
|
@@ -6,19 +6,20 @@ import { npmBaseFilesTell, npmCompareFindings } from './compare.js'
|
|
|
6
6
|
import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
|
|
7
7
|
import { npmHeldFindings, npmVersionFindings } from './held.js'
|
|
8
8
|
import { npmVersionFileRisk } from './files.js'
|
|
9
|
-
import { npmDependencyChanges, npmFlagDependencyChanges } from './dependencies.js'
|
|
9
|
+
import { npmDependencyChanges, npmFlagDependencyChanges, npmUnusedDependencyCounted } from './dependencies.js'
|
|
10
10
|
import { npmDownloadMonths, npmDownloadTier, npmSettledDownloads, npmTierLevel } from './downloads.js'
|
|
11
11
|
import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
|
|
12
12
|
import { npmMalwareFinding } from './malware.js'
|
|
13
13
|
import { npmKnownScope } from './manifest.js'
|
|
14
14
|
import { DEFAULT_MINIMUM_RELEASE_AGE, npmAccountStanding } from './release.js'
|
|
15
|
-
import { npmAddedDependency } from './new-dependency.js'
|
|
15
|
+
import { npmAddedDependency, npmUnusedDependencySeverity } from './new-dependency.js'
|
|
16
|
+
import { SEVERITIES } from '../files/stamps.js'
|
|
16
17
|
import { npmRiskScore } from './score.js'
|
|
17
18
|
|
|
18
19
|
// Which reading of a version an assessment kept (`risk/npm/assessments`) is:
|
|
19
20
|
// one more whenever a heuristic changes what it finds, so one kept by an
|
|
20
21
|
// older reading is read anew.
|
|
21
|
-
const RISK_VERSION =
|
|
22
|
+
const RISK_VERSION = 17
|
|
22
23
|
|
|
23
24
|
// How long an assessment kept may be as JSON, and what risk reads of either
|
|
24
25
|
// version's files: a package past it, whose files name thousands of hosts or
|
|
@@ -123,26 +124,33 @@ const MAX_TOLD_DEPENDENCIES = 100
|
|
|
123
124
|
// with what risk tells of its package
|
|
124
125
|
// (new-dependency.js npmAddedDependency): its tier, its last whole month's
|
|
125
126
|
// downloads by what its tier goes by where it has one (`month`, `{ total,
|
|
126
|
-
// from }`), `uncounted` where npm counts none of them,
|
|
127
|
+
// from }`), `uncounted` where npm counts none of them, `created` and `gap`
|
|
127
128
|
// where it is new since the version compared with (`published`, when that
|
|
128
|
-
// and the open version were)
|
|
129
|
+
// and the open version were), and for one nothing imports (flagged so, or
|
|
130
|
+
// among `unusedKeys`) that risk counts (dependencies.js
|
|
131
|
+
// npmUnusedDependencyCounted), the version its spec installs (`installs`,
|
|
132
|
+
// registry.js npmPublicFacts); `missing` where
|
|
133
|
+
// its package was looked up and npm has none of it for anyone, as once
|
|
134
|
+
// unpublished or taken down; four asked at a time, whether each is the
|
|
129
135
|
// caller's own among them, none more once one fails or MAX_TOLD_DEPENDENCIES
|
|
130
136
|
// not the caller's own were taken, however many a version adds.
|
|
131
|
-
async function addedDependencies(added, published, context, owned) {
|
|
137
|
+
async function addedDependencies(added, published, context, owned, unusedKeys = new Set()) {
|
|
132
138
|
const told = [...added]
|
|
133
139
|
const asked = told.flatMap((row, at) => row.opens ? [at] : [])
|
|
134
140
|
let next = 0, taken = 0
|
|
135
141
|
const tell = async row => {
|
|
136
142
|
const downloads = await npmDownloads(row.opens, context)
|
|
137
|
-
|
|
143
|
+
const unused = (row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))) && npmUnusedDependencyCounted(row)
|
|
144
|
+
let facts = npmAddedDependency({ downloads, published, unused }, context.now), missing = false
|
|
138
145
|
if (facts.wanted) {
|
|
139
|
-
const listed = await npmPublicFacts(row.opens, context)
|
|
140
|
-
|
|
146
|
+
const listed = await npmPublicFacts(row.opens, context, unused ? row.range : null)
|
|
147
|
+
missing = listed === null
|
|
148
|
+
facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published, unused }, context.now)
|
|
141
149
|
}
|
|
142
150
|
const settled = npmSettledDownloads(downloads, context.now)
|
|
143
151
|
const month = npmDownloadMonths(settled).at(-1)
|
|
144
152
|
return { ...row, ...facts.tier === null ? {} : { ...tierOf(facts.tier), ...settled ? month && { month: { total: month.total, from: month.from.toISOString().slice(0, 10) } } : { uncounted: true } },
|
|
145
|
-
...facts.created ? { created: facts.created, gap: facts.gap } : {} }
|
|
153
|
+
...facts.created ? { created: facts.created, gap: facts.gap } : {}, ...facts.installs ? { installs: facts.installs } : {}, ...missing ? { missing } : {} }
|
|
146
154
|
}
|
|
147
155
|
await Promise.all(Array.from({ length: 4 }, async () => {
|
|
148
156
|
while (next < asked.length && taken < MAX_TOLD_DEPENDENCIES) {
|
|
@@ -201,21 +209,64 @@ export async function assessNpm(name, version, { oldVersion = null, fromEmpty =
|
|
|
201
209
|
const own = npmVersionFindings({ deprecated: facts.deprecated[version], published, minimumReleaseAge, now, known: Boolean(manifest.knownAccounts), scope: npmKnownScope(name),
|
|
202
210
|
provenance: Boolean(manifest.provenance), downloads: counted })
|
|
203
211
|
const was = old === null ? null : facts.versions.get(old).published
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
// Those it holds that nothing imports by another spec than the version
|
|
213
|
+
// compared with's, told by their new spec as an added one nothing imports
|
|
214
|
+
// is (`respecified`), and an added one nothing imports however it was
|
|
215
|
+
// flagged, as one moved from another kind of dependency is.
|
|
216
|
+
const unusedKeys = unusedKeysOf(kept)
|
|
217
|
+
const respecified = kept.dependencies.changed.filter(row => unusedKeys.has(keyOf(row))).map(row => ({ ...row, range: row.to }))
|
|
218
|
+
const told = downloads && dependencies ? await addedDependencies([...kept.dependencies.added, ...respecified], [was, published], context, owned, unusedKeys)
|
|
219
|
+
: [...kept.dependencies.added, ...respecified]
|
|
220
|
+
const added = told.slice(0, kept.dependencies.added.length)
|
|
221
|
+
const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added, told.slice(added.length))
|
|
206
222
|
return { name, version, oldVersion: old, published, ...assessment, score: npmRiskScore(assessment.findings, { published, now, tier: counted ? npmDownloadTier(counted) : null }) }
|
|
207
223
|
}
|
|
208
224
|
|
|
225
|
+
// A dependency's key among a version's, by its kind and name.
|
|
226
|
+
const keyOf = row => `${row.kind ?? ''}\0${row.name}`
|
|
227
|
+
|
|
228
|
+
// The keys of the dependencies a version holds that nothing of it imports
|
|
229
|
+
// (`kept`, npmLastingRisk's: its `held-unused-deps`), those risk counts
|
|
230
|
+
// (dependencies.js npmUnusedDependencyCounted).
|
|
231
|
+
const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).filter(npmUnusedDependencyCounted).map(keyOf))
|
|
232
|
+
|
|
233
|
+
// The dependencies nothing of a version imports (`held-unused-deps`), each
|
|
234
|
+
// graded by what risk tells of its package fresh (`told`, as
|
|
235
|
+
// addedDependencies tells it; new-dependency.js
|
|
236
|
+
// npmUnusedDependencySeverity), with its tier, when its package was first
|
|
237
|
+
// published and the version its spec installs where told: kept from the
|
|
238
|
+
// version compared with where there was one (`compared`) and it named it as
|
|
239
|
+
// the same kind of dependency by the same spec: not one marked new, nor
|
|
240
|
+
// `added` (its kind and name new, as one moved from another kind is), nor
|
|
241
|
+
// `changed` (each said with the spec it had, `was`); one risk doesn't count
|
|
242
|
+
// (dependencies.js npmUnusedDependencyCounted) a notice, told all the same.
|
|
243
|
+
// The finding as its most severe.
|
|
244
|
+
function unusedGraded(finding, told, compared, { added, changed }) {
|
|
245
|
+
const facts = new Map(told.map(row => [keyOf(row), row])), was = new Map(changed.map(row => [keyOf(row), row.from]))
|
|
246
|
+
const anew = new Set(added.map(keyOf))
|
|
247
|
+
const dependencies = finding.dependencies.map(row => {
|
|
248
|
+
const of = facts.get(keyOf(row))
|
|
249
|
+
return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencyCounted(row)
|
|
250
|
+
? npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))) : 'notice',
|
|
251
|
+
...of?.level ? { tier: of.tier, level: of.level } : {}, ...of?.created ? { created: of.created, gap: of.gap } : {}, ...of?.installs ? { installs: of.installs } : {},
|
|
252
|
+
...of?.missing ? { missing: true } : {} }
|
|
253
|
+
})
|
|
254
|
+
return { ...finding, severity: SEVERITIES[Math.min(...dependencies.map(row => SEVERITIES.indexOf(row.severity)))], dependencies }
|
|
255
|
+
}
|
|
256
|
+
|
|
209
257
|
// An assessment of what of it holds whatever the time (`kept`,
|
|
210
258
|
// npmLastingRisk's) and what is told fresh: the open version's own findings
|
|
211
259
|
// (`own`, held.js npmVersionFindings, and its malware), its malware and
|
|
212
260
|
// deprecation first and the rest last, its tier as JSON holds it (tierOf),
|
|
213
|
-
// and its dependencies `added`, as addedDependencies tells them
|
|
261
|
+
// and its dependencies `added`, as addedDependencies tells them, those it
|
|
262
|
+
// holds by another spec that nothing imports beside them (`respecified`),
|
|
263
|
+
// for its unused dependencies' grades. The managed
|
|
214
264
|
// server's (assessNpm), and a test's of versions it holds.
|
|
215
265
|
const FIRST = new Set(['malware', 'deprecated'])
|
|
216
|
-
export function npmAssessmentOf(kept, own, added = kept.dependencies.added) {
|
|
266
|
+
export function npmAssessmentOf(kept, own, added = kept.dependencies.added, respecified = []) {
|
|
217
267
|
return {
|
|
218
|
-
findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings
|
|
268
|
+
findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings.map(finding => finding.id === 'held-unused-deps'
|
|
269
|
+
? unusedGraded(finding, [...added, ...respecified], Boolean(kept.dependencies.held), kept.dependencies) : finding),
|
|
219
270
|
...own.filter(({ id }) => !FIRST.has(id)).map(finding => finding.id === 'popularity' ? { ...finding, ...tierOf(finding.tier) } : finding)],
|
|
220
271
|
files: kept.files,
|
|
221
272
|
dependencies: { ...kept.dependencies, added },
|
package/risk/npm/dependencies.js
CHANGED
|
@@ -108,15 +108,34 @@ const typesStubOf = ({ json, npm }, name, spec) => npm?.name === typesOf(name) &
|
|
|
108
108
|
// (importsOf); never where its files aren't read. Not a peer dependency,
|
|
109
109
|
// which the package expects whoever installs it to provide, nor types from
|
|
110
110
|
// DefinitelyTyped (`@types/`), imported by another name, where no one else
|
|
111
|
-
// publishes; nor
|
|
112
|
-
//
|
|
111
|
+
// publishes; nor tslib, TypeScript's helpers, which a package compiled
|
|
112
|
+
// with them inlined names all the same, as a third of popular packages
|
|
113
|
+
// naming a dependency nothing imports do; each where npm installs that
|
|
114
|
+
// package from its registry (installsAs), not another under its name
|
|
115
|
+
// (`"tslib": "npm:other@1"`, a repository); nor the package a stub of
|
|
116
|
+
// DefinitelyTyped's types of it depends on (typesStubOf), with no code to
|
|
117
|
+
// import it.
|
|
118
|
+
const installsAs = (name, spec) => npmRegistrySpec(spec) ? npmAlias(spec)?.name ?? name : null
|
|
113
119
|
export function npmDependencyUnimported(details, { name, kind, range, to }) {
|
|
114
|
-
|
|
120
|
+
const installs = installsAs(name, to ?? range)
|
|
121
|
+
if (!details?.json || kind === 'peer' || (name.startsWith('@types/') && installs?.startsWith('@types/')) || (name === 'tslib' && installs === 'tslib')
|
|
122
|
+
|| typesStubOf(details, name, to ?? range)) return false
|
|
115
123
|
const { whole, parts, commands } = importsOf(details)
|
|
116
124
|
return !whole.has(name) && ![...name.slice(1).matchAll(/[-/]/gu)].some(({ index }) => parts.has(name.slice(0, index + 2)))
|
|
117
125
|
&& !commands.some(command => commandNames(command, name))
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
// Whether a dependency nothing of a version imports (`{ name, range }`,
|
|
129
|
+
// npmDependencyUnimported) counts toward its risk (assess.js): not lodash,
|
|
130
|
+
// which packages keep naming long after their code stopped importing it, nor
|
|
131
|
+
// Babel's own presets (`@babel/preset-*`), which a config names by a string
|
|
132
|
+
// (an ESLint config's parser options); each where npm installs that package
|
|
133
|
+
// from its registry (installsAs), not another under its name.
|
|
134
|
+
export function npmUnusedDependencyCounted({ name, range }) {
|
|
135
|
+
const installs = installsAs(name, range)
|
|
136
|
+
return !(name === 'lodash' && installs === 'lodash') && !(name.startsWith('@babel/preset-') && installs?.startsWith('@babel/preset-'))
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
// Where a source takes a dependency from, in a sentence.
|
|
121
140
|
const SOURCE_NAMES = new Map([['URL', 'a URL'], ['git', 'a git repository']])
|
|
122
141
|
|
|
@@ -10,21 +10,51 @@ import { npmDownloadTier, npmSettledDownloads, npmTierLow } from './downloads.js
|
|
|
10
10
|
// A dependency added, from its package's facts so far: `downloads`, npm's
|
|
11
11
|
// count of them (null where npm counts none, as it counts none of a package's
|
|
12
12
|
// until the day after it was first published, nor any of a private one's);
|
|
13
|
-
// `listed`, once its version list is read, `{ created, private }`,
|
|
14
|
-
// first version was published
|
|
15
|
-
//
|
|
16
|
-
// `
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// where
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
13
|
+
// `listed`, once its version list is read, `{ created, private, installs? }`,
|
|
14
|
+
// when its first version was published, whether only a token reads it, and
|
|
15
|
+
// the version of it its spec installs (registry.js npmPublicFacts); and
|
|
16
|
+
// `published`, when the version compared with and the open one were; and
|
|
17
|
+
// whether nothing of the version imports it (`unused`). Its `tier`
|
|
18
|
+
// (Infinity, unpopular, where npm counts none of a public package's; null
|
|
19
|
+
// where it can't yet be told), whether its version list is `wanted` to tell
|
|
20
|
+
// more (below the top 20,000, or none counted, or unused whatever its tier,
|
|
21
|
+
// as how new its package is grades it, npmUnusedDependencySeverity),
|
|
22
|
+
// `created`, `gap` where its package is new since the version compared
|
|
23
|
+
// with: when its first version was published, and how long before the open
|
|
24
|
+
// version that was (negative for after, as a name someone took once a
|
|
25
|
+
// release named it); and where told, the version its spec `installs`,
|
|
26
|
+
// `new` where that was published since the version compared with.
|
|
27
|
+
export function npmAddedDependency({ downloads, listed = null, published = [], unused = false }, now = Date.now()) {
|
|
23
28
|
const settled = npmSettledDownloads(downloads, now)
|
|
24
29
|
const counted = settled ? npmDownloadTier(settled) : null
|
|
25
|
-
const wanted = !settled || (counted !== null && npmTierLow(counted))
|
|
30
|
+
const wanted = !settled || unused || (counted !== null && npmTierLow(counted))
|
|
26
31
|
const tier = settled ? counted : listed && !listed.private ? Infinity : null
|
|
27
32
|
const [was, open] = published
|
|
28
33
|
const created = wanted && tier !== null && listed?.created && was && open && Date.parse(listed.created) > Date.parse(was) ? listed.created : null
|
|
29
|
-
|
|
34
|
+
const installs = listed?.installs ? { ...listed.installs, new: Boolean(was && listed.installs.published && Date.parse(listed.installs.published) > Date.parse(was)) } : null
|
|
35
|
+
return { tier, wanted, created, gap: created ? Date.parse(open) - Date.parse(created) : null, ...installs ? { installs } : {} }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What a dependency nothing of a version imports calls for (compare.js
|
|
39
|
+
// `unused-deps`), by what risk tells of its package (`told`, its row as
|
|
40
|
+
// assess.js addedDependencies tells it, or none): one kept from the version
|
|
41
|
+
// compared with (`kept`) yellow, as that version, risk's pick of the most
|
|
42
|
+
// likely free of an attack, held it; else red where its package is new
|
|
43
|
+
// since that version (`created`), as plain-crypto-js was to axios 1.14.1,
|
|
44
|
+
// or the version its spec installs (`installs`) is, has install scripts and
|
|
45
|
+
// isn't its package's latest, as an old package's old major line given a
|
|
46
|
+
// release to run them would, where a package still releasing its latest
|
|
47
|
+
// wouldn't; orange where that version has install scripts, as installing an unused
|
|
48
|
+
// dependency runs them and nothing else, or npm has none of its package
|
|
49
|
+
// for anyone (`missing`), as once unpublished or taken down, whatever
|
|
50
|
+
// downloads it still counts; yellow among npm's top 20,000, as
|
|
51
|
+
// a dependency named for another's peer or by a tool's config is (react,
|
|
52
|
+
// typescript, eslint), older and more downloaded than the package naming
|
|
53
|
+
// it; orange past them, or where its package isn't told.
|
|
54
|
+
export function npmUnusedDependencySeverity(told, kept) {
|
|
55
|
+
if (kept) return 'moderate'
|
|
56
|
+
const { scripts, new: anew, version, latest } = told?.installs ?? {}
|
|
57
|
+
if (told?.created || (scripts && anew && version !== latest)) return 'critical'
|
|
58
|
+
if (scripts || told?.missing) return 'high'
|
|
59
|
+
return told?.level && !npmTierLow(told.tier ?? Infinity) ? 'moderate' : 'high'
|
|
30
60
|
}
|
package/risk/npm/registry.js
CHANGED
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
// tarball unpacking past tarball.js's, `package-too-large`.
|
|
34
34
|
import { advisories } from '@preventive/upstream/advisories.js'
|
|
35
35
|
import { getTarball } from '@preventive/upstream/npm.js'
|
|
36
|
+
import { compareVersions, satisfies, validRange } from '@preventive/upstream/semver.js'
|
|
36
37
|
import { npmVersionManifest } from './manifest.js'
|
|
38
|
+
import { npmAlias } from './specs.js'
|
|
37
39
|
import { npmPlaceholder } from './malware.js'
|
|
38
40
|
import { npmOldVersion } from './old-version.js'
|
|
39
41
|
import { npmTarballVersion } from './tarball.js'
|
|
@@ -50,6 +52,14 @@ const ADVISORIES_MS = 60 * 60_000
|
|
|
50
52
|
// months' downloads a day.
|
|
51
53
|
const PACKUMENT_BYTES = 64 * 1024 * 1024
|
|
52
54
|
const DOWNLOADS_BYTES = 1024 * 1024
|
|
55
|
+
// How long a package's downloads are kept: a week, as the tier they put it in,
|
|
56
|
+
// by the most of its latest six weeks' (downloads.js npmDownloadTier), hardly
|
|
57
|
+
// moves in one, and npm's downloads API answers only some 40 lookups a minute.
|
|
58
|
+
const DOWNLOADS_MS = 7 * DAY_MS
|
|
59
|
+
// How many packages npm's downloads API answers in one lookup, unscoped ones
|
|
60
|
+
// alone (it refuses a scoped one in a bulk lookup), and how much of it is read.
|
|
61
|
+
const BULK_DOWNLOADS = 128
|
|
62
|
+
const BULK_DOWNLOADS_BYTES = 8 * 1024 * 1024
|
|
53
63
|
// How long npm is waited on for an answer, as the managed server waits on its
|
|
54
64
|
// upstreams (server-managed/npm-packages.ts askUpstream).
|
|
55
65
|
const REQUEST_MS = 30_000
|
|
@@ -59,6 +69,9 @@ export class NpmRiskError extends Error {
|
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
const isTime = value => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
|
|
72
|
+
// An object as JSON holds one, not an array (manifest.js plainObject).
|
|
73
|
+
const plainObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
74
|
+
|
|
62
75
|
// A scoped name in a downloads API path, its slash escaped.
|
|
63
76
|
const encoded = name => name.replace('/', '%2f')
|
|
64
77
|
const token = () => typeof process === 'undefined' ? null
|
|
@@ -245,13 +258,42 @@ export async function npmVersionFiles(name, version, { manifest, published, dist
|
|
|
245
258
|
}
|
|
246
259
|
}
|
|
247
260
|
|
|
261
|
+
// The version of a package's document npm installs for a dependency's spec
|
|
262
|
+
// (`spec`, an alias's by its own range, `npm:name@^1`, as specs.js npmAlias
|
|
263
|
+
// reads one), as npm picks one:
|
|
264
|
+
// the tag it names, else its `latest` where that satisfies it, or any range
|
|
265
|
+
// does (`*`, a prerelease latest too), and isn't deprecated, else the highest
|
|
266
|
+
// that satisfies it, one not deprecated first; null for none, or for a spec
|
|
267
|
+
// that is no range nor tag (a URL, a repository).
|
|
268
|
+
function installedVersion(doc, spec) {
|
|
269
|
+
const alias = npmAlias(spec)
|
|
270
|
+
const range = (alias ? alias.spec : String(spec)).trim()
|
|
271
|
+
const tags = plainObject(doc['dist-tags']) ? doc['dist-tags'] : {}, versions = plainObject(doc.versions) ? doc.versions : {}
|
|
272
|
+
if (Object.hasOwn(tags, range) && Object.hasOwn(versions, tags[range])) return tags[range]
|
|
273
|
+
if (validRange(range || '*') === null) return null
|
|
274
|
+
if (Object.hasOwn(versions, tags.latest) && typeof versions[tags.latest]?.deprecated !== 'string' && ((range || '*') === '*' || satisfies(tags.latest, range))) return tags.latest
|
|
275
|
+
const matching = Object.keys(versions).filter(version => satisfies(version, range || '*'))
|
|
276
|
+
const current = matching.filter(version => typeof versions[version]?.deprecated !== 'string')
|
|
277
|
+
return (current.length > 0 ? current : matching).reduce((best, version) => best === null || compareVersions(version, best) > 0 ? version : best, null)
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
// When a package anyone can read was first published, `{ created }`, or null
|
|
249
|
-
// for one npm has none of for anyone
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
281
|
+
// for one npm has none of for anyone; and for a dependency's spec (`spec`),
|
|
282
|
+
// the version of it npm installs (installedVersion), `installs`: `{ version,
|
|
283
|
+
// latest, published, scripts }`, its package's `latest` beside it, when it
|
|
284
|
+
// was published and whether it has install scripts: its manifest's, or
|
|
285
|
+
// node-gyp's for a `binding.gyp` (`gypfile`), or as the registry says
|
|
286
|
+
// (`hasInstallScript`); null for none.
|
|
287
|
+
export async function npmPublicFacts(name, context, spec = null) {
|
|
288
|
+
const facts = await kept(context, 'risk/npm/public', spec === null ? name : `${name}@${spec}`, async () => {
|
|
289
|
+
const doc = await packageDocument(name, context, false)
|
|
290
|
+
if (!doc) return { at: context.now, missing: true }
|
|
291
|
+
const version = spec === null ? null : installedVersion(doc, spec)
|
|
292
|
+
return { at: context.now, created: firstPublished(doc), ...spec === null ? {} : { installs: version === null ? null
|
|
293
|
+
: { version, latest: typeof doc['dist-tags']?.latest === 'string' ? doc['dist-tags'].latest : null, published: isTime(doc.time?.[version]) ? doc.time[version] : null,
|
|
294
|
+
scripts: Boolean(npmVersionManifest(doc.versions[version]).installScripts) || doc.versions[version]?.gypfile === true || doc.versions[version]?.hasInstallScript === true } } }
|
|
295
|
+
}, hit => context.now - hit.at < PACKAGE_MS)
|
|
296
|
+
return facts.missing ? null : { created: facts.created, ...spec === null ? {} : { installs: facts.installs } }
|
|
255
297
|
}
|
|
256
298
|
|
|
257
299
|
// The days a package's downloads are asked over at `now`: from the first of
|
|
@@ -262,21 +304,80 @@ function rangeAt(now) {
|
|
|
262
304
|
return `${day(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 2, 1))}:${day(now - DAY_MS)}`
|
|
263
305
|
}
|
|
264
306
|
|
|
265
|
-
// A package's downloads
|
|
266
|
-
//
|
|
307
|
+
// A package's downloads as kept, from npm's answer of them over a range of
|
|
308
|
+
// days `asked` then: `{ at, start, end, days }`, a count a day from
|
|
309
|
+
// `start`, or `{ at, none }` where npm counts none (null).
|
|
310
|
+
function downloadsOf(json, asked) {
|
|
311
|
+
if (json === null) return { at: asked, none: true }
|
|
312
|
+
const start = Date.parse(json.start)
|
|
313
|
+
const length = Math.round((Date.parse(json.end) - start) / DAY_MS) + 1
|
|
314
|
+
const days = Array.from({ length: Number.isSafeInteger(length) && length > 0 && length <= 400 ? length : 0 }, () => 0)
|
|
315
|
+
for (const row of json.downloads ?? []) {
|
|
316
|
+
const at = Math.round((Date.parse(row.day) - start) / DAY_MS)
|
|
317
|
+
if (at >= 0 && at < days.length && Number.isSafeInteger(row.downloads)) days[at] = row.downloads
|
|
318
|
+
}
|
|
319
|
+
return { at: asked, start: json.start, end: json.end, days }
|
|
320
|
+
}
|
|
321
|
+
// Whether downloads kept are told as they are: kept within DOWNLOADS_MS.
|
|
322
|
+
const freshDownloads = (held, now) => held != null && now - held.at < DOWNLOADS_MS
|
|
323
|
+
|
|
324
|
+
// A package's downloads a day over rangeAt's days when asked, `{ start, end,
|
|
325
|
+
// days }`, kept a week (DOWNLOADS_MS), null where npm counts none.
|
|
267
326
|
export async function npmDownloads(name, context) {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (json === null) return { none: true }
|
|
272
|
-
const start = Date.parse(json.start)
|
|
273
|
-
const length = Math.round((Date.parse(json.end) - start) / DAY_MS) + 1
|
|
274
|
-
const days = Array.from({ length: Number.isSafeInteger(length) && length > 0 && length <= 400 ? length : 0 }, () => 0)
|
|
275
|
-
for (const row of json.downloads ?? []) {
|
|
276
|
-
const at = Math.round((Date.parse(row.day) - start) / DAY_MS)
|
|
277
|
-
if (at >= 0 && at < days.length && Number.isSafeInteger(row.downloads)) days[at] = row.downloads
|
|
278
|
-
}
|
|
279
|
-
return { start: json.start, end: json.end, days }
|
|
280
|
-
})
|
|
327
|
+
const answer = await kept(context, 'risk/npm/downloads', name,
|
|
328
|
+
async () => downloadsOf(await askNpm(`${DOWNLOADS}/${rangeAt(context.now)}/${encoded(name)}`, {}, DOWNLOADS_BYTES, `${name}'s downloads`, context), context.now),
|
|
329
|
+
hit => freshDownloads(hit, context.now))
|
|
281
330
|
return answer.none ? null : answer
|
|
282
331
|
}
|
|
332
|
+
|
|
333
|
+
// Readies, at once, the downloads assessNpm asks of each of these packages
|
|
334
|
+
// (`names`) that can be, kept in `cache` as npmDownloads keeps them, so that
|
|
335
|
+
// their assessments ask npm none: those kept within a week (DOWNLOADS_MS), as
|
|
336
|
+
// they are, and unscoped ones, asked of npm BULK_DOWNLOADS at a time, as its downloads API
|
|
337
|
+
// answers them; `ready` is told each once its downloads are kept. The rest,
|
|
338
|
+
// scoped ones (it answers those only one by one) and any a bulk lookup
|
|
339
|
+
// didn't answer, are left for npmReadyDownload, one at a time. npm asking to
|
|
340
|
+
// be asked later is thrown (`upstream-busy`), as is any other failure, those
|
|
341
|
+
// readied by then told.
|
|
342
|
+
export async function npmReadyDownloads(names, ready, { cache, signal, now = Date.now() }) {
|
|
343
|
+
const context = { cache, signal, now }
|
|
344
|
+
const range = rangeAt(now)
|
|
345
|
+
const unique = [...new Set(names)]
|
|
346
|
+
const unscoped = []
|
|
347
|
+
const ask = async part => {
|
|
348
|
+
const json = await askNpm(`${DOWNLOADS}/${range}/${part.join(',')}`, {}, BULK_DOWNLOADS_BYTES, `${part.length} packages' downloads`, context)
|
|
349
|
+
const answered = part.filter(name => json && Object.hasOwn(json, name))
|
|
350
|
+
await keepMany(cache, answered.map(name => [name, downloadsOf(json[name], now)]))
|
|
351
|
+
for (const name of answered) ready(name)
|
|
352
|
+
}
|
|
353
|
+
// Those kept read BULK_DOWNLOADS at a time, the rest asked as they fill a
|
|
354
|
+
// lookup.
|
|
355
|
+
for (let at = 0; at < unique.length; at += BULK_DOWNLOADS) {
|
|
356
|
+
const part = unique.slice(at, at + BULK_DOWNLOADS)
|
|
357
|
+
const held = await keptMany(cache, part)
|
|
358
|
+
for (const name of part) {
|
|
359
|
+
if (freshDownloads(held.get(name), now)) ready(name)
|
|
360
|
+
else if (!name.startsWith('@')) unscoped.push(name)
|
|
361
|
+
}
|
|
362
|
+
while (unscoped.length >= BULK_DOWNLOADS) await ask(unscoped.splice(0, BULK_DOWNLOADS))
|
|
363
|
+
}
|
|
364
|
+
// A lookup of one is answered as that package's own, not by its name.
|
|
365
|
+
if (unscoped.length > 1) await ask(unscoped)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Downloads kept under each of `keys`, by key, and many kept at once: in one
|
|
369
|
+
// read and one write where the cache reads and writes many at once
|
|
370
|
+
// (`readMany`, `writeMany`), else a key at a time.
|
|
371
|
+
const keptMany = async (cache, keys) => cache.readMany ? await cache.readMany('risk/npm/downloads', keys)
|
|
372
|
+
: new Map(await Promise.all(keys.map(async key => [key, await cache.read('risk/npm/downloads', key)])))
|
|
373
|
+
const keepMany = async (cache, entries) => {
|
|
374
|
+
if (cache.writeMany) await cache.writeMany('risk/npm/downloads', entries)
|
|
375
|
+
else for (const [key, value] of entries) await cache.write('risk/npm/downloads', key, value)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Readies the downloads assessNpm asks of a package, asked of npm on their
|
|
379
|
+
// own where they aren't kept (npmDownloads), as npmReadyDownloads leaves a
|
|
380
|
+
// scoped one's: npm asking to be asked later thrown (`upstream-busy`).
|
|
381
|
+
export async function npmReadyDownload(name, { cache, signal, now = Date.now() }) {
|
|
382
|
+
await npmDownloads(name, { cache, signal, now })
|
|
383
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Compare's comparison of two managed bundles or two versions of an npm
|
|
2
|
+
// package (GET /api/bundles/:id/comparison?other=, GET /api/npm/comparison):
|
|
3
|
+
// their files paired, at one path or renamed, with the sizes, resolutions,
|
|
4
|
+
// dependency versions and line counts its Overview and Code read beside them,
|
|
5
|
+
// as common/bundle-comparison.js prepareComparison works them out. The viewer
|
|
6
|
+
// of a managed bundle or a package has every comparison worked out here and
|
|
7
|
+
// loads none of the pairing itself. Answered as Brotli-encoded JSON, its
|
|
8
|
+
// `files.basePaths` (a Map) as `[path, as laid out]` pairs where the two
|
|
9
|
+
// sides' roots differ and none else, as the viewer reads none then. Access is
|
|
10
|
+
// the caller's to check.
|
|
11
|
+
import { Buffer } from 'node:buffer'
|
|
12
|
+
import { Readable } from 'node:stream'
|
|
13
|
+
import { prepareComparison } from '../common/bundle-comparison.js'
|
|
14
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
15
|
+
import type { OpenedBlob } from './blob-store.ts'
|
|
16
|
+
import { encodeBrotli } from './brotli.ts'
|
|
17
|
+
import { type NpmPackageFile, npmFileRows } from './npm-packages.ts'
|
|
18
|
+
|
|
19
|
+
// A side with no files, as Review compares a package's first version with:
|
|
20
|
+
// one for each comparison, as working one out keeps its line counts on it.
|
|
21
|
+
export const emptySide = (): BundleDetails => ({ integrity: '(empty)', kind: 'sourcemap', size: 0, json: { version: 3, sources: [], sourcesContent: [] } })
|
|
22
|
+
|
|
23
|
+
// A version's files as the viewer reads them for a comparison (ui/view/npm-package.js
|
|
24
|
+
// npmPackageDetails): its texts, and those that are not text by their size and digest.
|
|
25
|
+
export function npmComparedSide(integrity: string, files: NpmPackageFile[]): BundleDetails & { npmBinaries: Map<string, { size: number; digest: string }> } {
|
|
26
|
+
const rows = npmFileRows(files)
|
|
27
|
+
return {
|
|
28
|
+
integrity, kind: 'sourcemap', size: 0,
|
|
29
|
+
json: { version: 3, sources: rows.map(([path]) => path), sourcesContent: rows.map(([, , text]) => text) },
|
|
30
|
+
npmBinaries: new Map(rows.flatMap(([path, size, text, digest]) => (text === null ? [[path, { size, digest: digest! }] as const] : []))),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The comparison of `base` with `other`, within a reason `scope` where given, as sent.
|
|
35
|
+
export async function comparisonBody(base: BundleDetails, other: BundleDetails, scope = ''): Promise<OpenedBlob> {
|
|
36
|
+
const comparison = prepareComparison(base, other, scope)
|
|
37
|
+
const { files } = comparison
|
|
38
|
+
const json = JSON.stringify({ ...comparison, files: { ...files, basePaths: files.root ? [...files.basePaths] : [] } })
|
|
39
|
+
const body = await encodeBrotli(Buffer.from(json))
|
|
40
|
+
return { size: body.byteLength, stream: Readable.from([body]) }
|
|
41
|
+
}
|